Compare commits

...
83 Commits
Author SHA1 Message Date
ZyyandCodex 389dc63633 chore(release): v3.23.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 00:45:56 +08:00
ZyyandCodex 78b2250faa feat(settings): add configurable Codex privacy policies (#375)
Add configurable privacy rules and shared previews for Codex metadata, Desktop context, request bodies and headers. Freeze policy per request and preserve atomic failure handling across HTTP and WebSocket transports.

Include settings presets, persistence, API contracts, migration and transport regressions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-10 00:41:25 +08:00
ZyyandCodex bbb8278281 fix(openai): enforce image account eligibility (#374)
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 22:57:24 +08:00
ZyyandCodex 7a5521953f fix(openai): align Desktop client time context overrides
Apply configured request locations to Desktop developer time contexts alongside environment contexts while preserving role and content-kind boundaries.

Cover date alignment, disabled overrides, proxy precedence, and HTTP/WebSocket requests. Update the location contract and troubleshooting documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:53:59 +08:00
f78b1ca487 feat(openai): allow HTTP upstream API key accounts (#372)
Allow HTTP API key base URLs for private networks and container services while retaining OAuth startup URL restrictions. Keep URL credential, query and fragment validation, and cover import, export and address rotation.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:41:53 +08:00
ZyyandCodex 661649cc90 refactor(compat): remove expired upgrade and plugin adapters
Use the current host manifest format and require restart confirmation on
all management UI paths. Document v3.18.1 as the minimum online upgrade
source while retaining old release manifest reads for supported rollbacks.

Retire middleware v3 and upstream_adapter v1 Fast projections after their
compatibility window. Align SDK and host declarations with the current
contracts, keep risk warnings and per-instance failure isolation, and
exercise native fast_mode values through real plugin subprocesses.

Keep deployment, API and SDK documentation focused on current behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:41:53 +08:00
ZyyandCodex 705f0fad06 chore(release): v3.22.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:11:24 +08:00
ZyyandCodex f0c60f501b fix(client): enable remote compaction in Codex config
Declare the V2 remote compaction capability in generated Codex configuration and deployment examples. Document that CC Switch imports do not preserve the full capability configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:05:07 +08:00
ZyyandCodex d1febdd249 fix(openai): remove precommit byte limit
Keep large structural events buffered until semantic output, terminal completion, EOF, or the replay grace deadline. Retain byte counts for diagnostics and preserve historical byte_limit exports.

Refs #369

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:05:01 +08:00
ZyyandCodex 53d4086cc0 fix(openai): preserve opaque request fields and websocket messages
Forward numeric and extended reasoning effort values and preserve standalone endpoint business headers.

Retain business metadata and original WebSocket text through provider, middleware, and client delivery. Validate rewritten plugin output without rejecting unchanged heartbeats or opaque messages.

Add HTTP, WebSocket, and plugin process regressions for the passthrough boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 17:23:40 +08:00
ZyyandCodex ce03e8119d fix(diagnostics): retain health and xAI worker failure causes
Record health component failures through operational diagnostics with transition deduplication. Preserve quota and catalog failure context without interrupting the account batch.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 17:10:29 +08:00
ZyyandCodex 3f05b6450c fix(usage): preserve precommit facts in diagnostics export
Export allowlisted release reasons, prefetch bytes and wait times, and document diagnostics schema v4.

Refs #369

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 16:55:12 +08:00
ZyyandCodex a4b73099b5 fix(openai): pass through websocket control frames
Forward non-create text frames unchanged over the original upstream WebSocket while preserving response.create admission and serialization. Keep the control transport available after completion and relay idle upstream events without creating another inference attempt.

Cover unknown frames, late interrupts, terminal settlement, binding revocation, and connection reuse.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 16:26:31 +08:00
ZyyandCodex d875a62652 chore(release): v3.22.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 14:10:51 +08:00
ZyyandCodex 98704dfde8 fix(usage): parse response critical path timings
Read response-scoped engine and API overhead metrics while excluding logical-turn totals and incomplete observations. Validate nested response IDs and cover the observed payload over SSE and WebSocket.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:51:40 +08:00
ZyyandCodex b07bca312a fix(upgrade): handle long migrations and unify forced shutdown
Extend the startup healthcheck grace period to five minutes and report elapsed migration time every fifteen seconds without changing published migrations.

Use one shutdown path for OS signals, internal cancellation, and managed restarts. Stop listening without waiting for active sessions, preserve bounded worker cleanup, and remove the obsolete connection drain timeout.

Cover shutdown entry points and worker cleanup with real HTTP lifecycle tests and document the deployment behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:51:14 +08:00
ZyyandCodex 60966d935e chore(release): v3.22.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:03:32 +08:00
ZyyandCodex 52981f40f9 fix(providers): align Codex 0.162 protocol handling
Apply latest incremental tool declarations during Grok normalization and align nested Retry-After header parsing with official Codex semantics.

Cover developer instructions in input, Responses Lite tool declarations, partial_answer phases, and end_turn preservation.

Pin the upstream SQLx fix for cancelled BEGIN rollback and add a deterministic pooled-connection regression test.

Report safe Redis error categories and timeout status when the cooldown persistence regression fails.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 12:10:02 +08:00
ZyyandCodex a51c4cd640 refactor(plugins): simplify lifecycle and reuse unchanged instances
Share unchanged prepared instances across extension sets and close each
process when its last owner releases it. Keep inflight request plans intact.

Unify migration recovery and publication, centralize authentication binding
validation, avoid duplicate package inspection, and separate middleware plans.

Validated with 292 admin, 182 runtime, 37 plugin storage and 15 architecture
tests, rustfmt, and strict workspace Clippy.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:12:29 +08:00
ZyyandCodex bda6d973a8 docs: streamline documentation around current behavior
Remove version migration narratives, consolidate repeated explanations, and organize settings and architecture references.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 22:26:12 +08:00
ZyyandCodex 66a7e4a695 fix(client): align Codex config export with CC Switch 4.0.4
Export config.toml with provider bearer authentication and send supported connection and usage fields to CC Switch. Remove the separate auth.json output and size the configuration preview to its content.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 22:09:02 +08:00
think-backandCodex 9483f2c91b perf(groups): avoid aggregate queries when loading group selectors
Load account and API key group selectors through a lightweight admin options
endpoint while preserving pagination, filters, disabled groups and revision
metadata. Keep aggregate statistics on the group management endpoint.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 18:20:24 +08:00
ZyyandCodex faf50c28ad feat(usage): unify request observations and upstream performance
Consolidate request diagnostics into typed request_observation_json with
migration 0025, preserving historical facts and query constraints.

Use upstream response timestamps for output throughput, align TTFT event
boundaries, and surface official timing metrics in the latency popover.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 18:17:09 +08:00
ZyyandCodex cb397ae53e chore(release): v3.21.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-08 12:12:34 +08:00
ZyyandCodex 63802f9b1a fix(usage): exclude cached tokens from displayed input
Reuse the fresh input calculation across usage records, token details and key usage metrics.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 11:43:26 +08:00
ZyyandCodex de599ed768 fix(openai): apply timezone overrides without content kinds
Recognize complete Codex environment contexts when custom providers omit
content classifications, while keeping explicit classifications authoritative.

Cover HTTP and WebSocket overrides and proxy location precedence, and
document timezone and client privacy boundaries.

Refs #366

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 11:16:31 +08:00
ZyyandCodex 3315ddbff6 fix(openai): isolate guardian concurrency from ordinary requests
Give Guardian requests a separate per-account lease pool and wait queue while preserving ordinary concurrency and shared request intervals. Use the ordinary pool when the Guardian limit is zero, and align scheduling, diagnostics, settings copy, and documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 10:38:52 +08:00
ZyyandCodex 6c583e341a fix(openai): reject invalid continuation replay before account selection
Check the recorded native continuation scope before selecting an account
or committing a session binding. Reuse the same scope validation after
middleware while preserving native connection checks and migration policy.

Cover ReplayOwner and ReplayAny with persisted provider metadata or a
connection-local pin, with and without an existing session binding.
Rejected replays must not acquire leases, write bindings or aliases, send
upstream requests, or redirect subsequent child requests.

Refs #364

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 22:45:42 +08:00
ZyyandCodex fb15f3e36c chore(release): v3.21.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 20:21:38 +08:00
ZyyandCodex a940eb599f fix(openai): allow atomic first binding from any session request
Allow requests with a reliable session identity to claim a missing account
binding, including child threads and independent background tasks. Remove
the missing-binding wait queue while preserving existing migration rights
and bound-account waiting behavior.

Cover concurrent root/child claims, memory prewarm and turn reuse,
middleware identity changes, cancellation, and bound-account timeouts.
Update the documented strict-affinity contract.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 20:18:03 +08:00
ZyyandCodex 22a848b360 refactor(backend): clarify ownership and align xai contracts
Separate execution, provider ports, administration, and plugin lifecycle responsibilities.

Fix shutdown ordering, account lock reclamation, multipart cancellation, budget windows, and WebSocket close handling.

Align xAI catalog, reasoning, tools, replay, quota, and cost handling with current Grok contracts and remove legacy fallbacks.

Update API and architecture documentation with regression coverage. Validated backend suites, strict workspace Clippy, isolated PostgreSQL/Redis integration, and real Codex/Grok client flows.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 19:34:10 +08:00
ZyyandCodex 754da83b02 refactor(backend): simplify query ownership and resource handling
Move observability query policies into Admin and reuse account state reads.
Bound Redis reads to measured batches and share protocol and transport helpers.
Close backup process and staging cleanup gaps, and remove unused entry points,
redundant models, dependencies, and obsolete tests.

Validated 3,526 workspace tests, then all 352 Store tests after batch tuning,
plus strict workspace Clippy and formatting checks. Local release benchmarks
cover concurrent account reads and controlled transport delays.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 15:43:29 +08:00
ZyyandCodex f174320e2a chore(release): v3.21.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:57:15 +08:00
ZyyandCodex 9db8528758 fix(metrics): align TTFT and throughput with sub2api
Count non-preamble Responses events for first-token timing while preserving
semantic output boundaries and request-level timing origins. Calculate
output throughput over the complete request duration in usage rows and
aggregate queries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:52:59 +08:00
ZyyandCodex 119032c067 feat(settings): add preferred account affinity mode
Route relaxed requests through the configured scheduler and keep the session primary during preferred-mode spillover. Default new settings to strict while preserving saved modes.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:50:59 +08:00
ZyyandCodex 3d1dc79f5d fix(auth): refresh sessions only after authentication failures
Use read-only status checks for initial authentication and remove activity-driven renewal. Keep failed and newly queued requests waiting for the current refresh before replaying with the updated cookie.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:10:50 +08:00
ZyyandCodex 128ced4093 chore(release): v3.21.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 02:49:11 +08:00
ZyyandCodex 693025afa4 refactor(store): consolidate unreleased database migrations
Merge error details and account affinity settings into migration 0023. Preserve all migrations shipped in v3.20.0 and validate upgrades from its 0022 baseline.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 02:49:01 +08:00
ZyyandCodex 0bfeff28fc refactor(backend): centralize shared models and settings validation
Reuse domain models for observability, account statistics, execution facts,
and group validation. Compose shared settings values while preserving
HTTP and SDK contracts and explicit storage and runtime boundaries.

Verify serialization, concurrency, persistence, and snapshot behavior with
3528 backend tests, strict Clippy, formatting, and architecture checks.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 02:32:53 +08:00
ZyyandCodex cb7daffb66 feat(settings): configure account affinity and rotation limits
Add relaxed and strict affinity modes, configurable binding retention, and per-request account rotation limits. Freeze policies for each request and preserve continuation ownership and plugin selection behavior.

Expose the controls in a dedicated affinity settings card and cover scheduling, persistence, renewal, and retry boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 01:30:10 +08:00
ZyyandCodex d922c3af60 fix(diagnostics): preserve error chains and avoid logging backpressure
Preserve native failure causes through store, provider, plugin, admin, and
worker boundaries, including secondary cleanup failures. Route operational
failures through the shared diagnostics sink with protected error details.

Use bounded nonblocking file logging with batched writes and reserved
warning/error capacity. Reserve observation capacity for failures and
request lifecycle records, and rate-limit overload warnings.

Validate error propagation and blocked-output HTTP, admin, SSE, and WebSocket
behavior; keep the local migration plan out of version control.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 00:25:18 +08:00
ZyyandCodex 366bc5f1ca feat(client-profile): expose desktop OS version setting
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 18:29:12 +08:00
ZyyandCodex a5e4041295 fix(diagnostics): preserve failure causes across request tracing
Retain preparation and account-selection diagnostics, preserve plugin fault
details, and record failures before attempt startup. Keep failure
classification when trace summaries are truncated.

Carry native error sources through Core, infrastructure availability errors,
OpenAI credential selection, provider inference, and plugin upstream adapters.
Preserve original upstream codes independently of failure classification and
project bounded cause chains and upstream bodies into protected error details.

Rename persisted and admin API error detail fields, keep raw details out of
ordinary logs and diagnostic exports, and account for retained error data in
the observation queue memory budget. Add regression coverage for source
retention, safe output, and migration upgrades; remove account-dependent live
tests. Document the implemented diagnostic boundaries.

Refs #357

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 18:19:04 +08:00
ZyyandCodex b27877c99b fix(memory): release request copies and preallocate XZ buffers
Release preliminary Responses decodes before middleware execution and move
WebSocket frame ownership without cloning the request body. Reserve XZ
block buffers from validated lengths while retaining overflow detection.

Add allocation regression coverage for 24 MiB and 32 MiB XZ blocks.

Refs #338

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 15:47:25 +08:00
ZyyandCodex 7505459ece fix(accounts): constrain long plan badges to available width
Truncate overflowing plan names and show the full display name on hover while preserving the existing subscription column width.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 13:52:29 +08:00
ZyyandCodex 102d064ab0 fix(metrics): align first-token timing with request duration
Record semantic output from the first stream chunk and reuse Core timing for xAI. Add regression and opt-in live transport coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 13:52:24 +08:00
ZyyandCodex 7616fdf4ab feat(auth): add seamless admin session renewal
Renew active administrator sessions with atomic Redis expiry updates,
synchronized cookies, and idle and absolute lifetime limits. Preserve
revocation after logout and password changes.

Coordinate session recovery, bounded retries, error handling, and raw
responses through request plugins. Gate initial navigation in the router
and organize frontend API modules by their views.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 12:49:50 +08:00
ZyyandCodex 5cfe0c68cc fix(deps): update source-map-js to 1.2.2
Resolve GHSA-68fv-2mgg-jv7q in transitive frontend dependencies.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 11:22:48 +08:00
ZyyandCodex 7940735866 chore(release): v3.20.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 11:04:29 +08:00
ZyyandCodex 7741503b03 refactor(store): consolidate unreleased runtime migrations
Merge refresh margin and group Fast mode changes into migration 0022 before v3.20.0. Preserve the v3.19.0 migration history and verify upgrades from migration 0021.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 11:03:28 +08:00
ZyyandCodex 74f749f49d fix(openai): unify conversation account bindings
Share the current account across root and descendant requests, including
Search, Images turn aliases, and Live creation. Keep root recovery and
explicit plugin scheduling intact while descendant requests wait for the
current owner and follow root migrations.

Use versioned admission before sending so concurrent claims and late
responses cannot overwrite newer bindings. Cover queue lifecycle,
continuation recovery, endpoint routing, and Redis concurrency behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 07:04:41 +08:00
c4d8732a73 feat(usage): add performance column and align Fast indicators (#354)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 02:36:41 +08:00
0a47b5efc0 feat(groups): add Fast modes with plugin compatibility warnings (#343)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 01:21:25 +08:00
ZyyandCodex 35a7fa5a73 fix(openai): isolate live profiles and turn routing state
Freeze the selected client profile for Live sideband, reconnect and hangup,
while reloading current account credentials and outbound proxy settings.
Share identity-header protection, clear turn state across wire entry points,
and include Guardian and residency headers in WebSocket connection matching.
Normalize the existing Cargo.lock http reference without changing versions.

Validated 1389 tests, workspace strict Clippy, Rustfmt and architecture checks.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 00:51:22 +08:00
KumikoLoveandCodex 0ce81eea1d feat: add account scheduling controls and client profile options (#339)
Stagger credential refresh and quota observations, bound each request to three account rotations, and expose terminal and version lag configuration.

Retain main's bounded refresh 401 recovery policy. Version lag accepts integers from 1 to 10 or an empty value.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 00:09:02 +08:00
2han9wen71anandCodex 22acbd1d10 feat(openai): add Codex live voice call proxying (#308)
Add scoped call bootstrap, pinned-account sideband relay and hangup. Preserve model metadata, use standard WebSocket handshakes, and release session claims safely on cancellation.

Validated with regression tests, CI and a real OAuth/WebRTC call.

Co-authored-by: Codex <noreply@openai.com>
2026-10-05 23:48:55 +08:00
78ba7e412e fix(codex): align upstream contracts and transport behavior (#352)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-05 23:41:46 +08:00
ZyyandCodex f8439eb4b9 chore(deps): upgrade syn and prettyplease together (#349)
Co-authored-by: Codex <noreply@openai.com>
2026-10-05 13:47:14 +08:00
ZyyandCodex 55062228e4 fix(frontend): prevent proxy exit IP overflow
Fixes #351

Co-authored-by: Codex <noreply@openai.com>
2026-10-05 13:36:35 +08:00
dependabot[bot] f7585ede0c chore(deps): bump jsonschema from 0.57.0 to 0.58.4 in /backend (#348)
Bumps [jsonschema](https://github.com/Stranger6667/jsonschema) from 0.57.0 to 0.58.4.
- [Release notes](https://github.com/Stranger6667/jsonschema/releases)
- [Changelog](https://github.com/Stranger6667/jsonschema/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/jsonschema/compare/cli-v0.57.0...cli-v0.58.4)

---
updated-dependencies:
- dependency-name: jsonschema
  dependency-version: 0.58.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 13:31:18 +08:00
dependabot[bot] 94ae5f693f chore(deps): bump the minor-and-patch group across 1 directory with 5 updates (#347)
Bumps the minor-and-patch group with 5 updates in the /backend directory:

| Package | From | To |
| --- | --- | --- |
| [config](https://github.com/rust-cli/config-rs) | `0.15.26` | `0.15.27` |
| [quote](https://github.com/dtolnay/quote) | `1.0.46` | `1.0.47` |
| [http-body](https://github.com/hyperium/http-body) | `1.0.1` | `1.1.0` |
| [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) | `1.149.0` | `1.152.0` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.18.0` | `1.19.0` |



Updates `config` from 0.15.26 to 0.15.27
- [Changelog](https://github.com/rust-cli/config-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/config-rs/compare/v0.15.26...v0.15.27)

Updates `quote` from 1.0.46 to 1.0.47
- [Release notes](https://github.com/dtolnay/quote/releases)
- [Commits](https://github.com/dtolnay/quote/compare/1.0.46...1.0.47)

Updates `http-body` from 1.0.1 to 1.1.0
- [Release notes](https://github.com/hyperium/http-body/releases)
- [Commits](https://github.com/hyperium/http-body/compare/v1.0.1...v1.1.0)

Updates `aws-sdk-s3` from 1.149.0 to 1.152.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `aws-smithy-runtime-api` from 1.18.0 to 1.19.0
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-version: 1.152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: config
  dependency-version: 0.15.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: http-body
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: quote
  dependency-version: 1.0.47
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 13:31:13 +08:00
ZyyandCodex 4961788acf docs: align documentation and skills with current implementation
Co-authored-by: Codex <noreply@openai.com>
2026-10-05 13:22:07 +08:00
ZyyandCodex 24f39f7f0e docs(backend): standardize Rust file comments
Add Chinese responsibility summaries to every backend Rust source, test,
and build script. Remove sentence-ending periods from comments while
preserving meaningful dots in URLs, versions, identifiers, and examples.

Keep generated SDK documentation in sync with its contract generator,
group middleware module declarations and exports, and record the comment
conventions in the development skill.

Validated with Rustfmt, strict Clippy, backend tests, documentation tests,
and skill validation. Database-backed integration cases were skipped
because dedicated PostgreSQL and Redis test services were not configured.

Co-authored-by: Codex <noreply@openai.com>
2026-10-05 11:52:56 +08:00
ZyyandCodex 9c5fbdba70 refactor(frontend): clarify module boundaries and simplify state
Move shared components and helpers to their actual owners, standardize
component directory names, and use concise names for common utilities.
Remove redundant wrappers, copied API models, and duplicate state.

Separate API key editing from key usage, isolate settings drafts, reuse
paged queries, and tie debounced queries and requests to their scopes.

Validated with frontend lint, type checking and production build, local
import checks, and 15 temporary behavioral regression groups.

Co-authored-by: Codex <noreply@openai.com>
2026-10-05 10:34:10 +08:00
ZyyandCodex 28a57cbde5 feat(deploy): add opt-in low-memory allocator configuration
Keep the glibc tuning value in config.yaml's app-runtime bridge and let
Compose inject it before the application starts. Leave tuning disabled
by default and document the 128 KiB preset, recreation, and upgrade steps.

Refs #338, #345

Co-authored-by: Codex <noreply@openai.com>
2026-10-05 09:44:32 +08:00
ZyyandCodex 1c8a74e572 fix(core): release dropped cancellation waiters
Use event-listener registrations that unregister when cancellation futures
are dropped, including waits attached to ancestor tokens. Recheck the
cancelled flag after registration to preserve wakeups during races.

Add allocation regressions for select churn and child cancellation, plus
broadcast and concurrent cancellation coverage.

Refs #338, #345

Co-authored-by: Codex <noreply@openai.com>
2026-10-05 09:27:42 +08:00
ZyyandCodex 7c266b469b fix(core): remove the default model request timeout
Allow long model responses to finish without a fixed 600-second deadline,
while preserving explicit plugin deadlines and upstream idle timeouts.

Renew request recovery, client admission and provider scheduling leases
for the session lifetime, and release their renewal tasks with the owners.
Keep observation renewal best-effort and cancel on lost concurrency leases.

Fixes #342

Co-authored-by: Codex <noreply@openai.com>
2026-10-03 03:30:39 +08:00
WeiSiTaPanandCodex d37dc32731 feat(usage): add a yellow Fast tier badge (#341)
Co-authored-by: Codex <noreply@openai.com>
2026-10-02 23:18:11 +08:00
ZyyandCodex 9a749d1a03 fix(api): omit timezone offsets from chart labels
Format display labels without UTC offsets while retaining timestamps as chart identities.

Co-authored-by: Codex <noreply@openai.com>
2026-10-02 00:03:54 +08:00
ZyyandCodex fef52b3717 chore(release): v3.19.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-01 23:24:57 +08:00
ZyyandCodex 45e72682e6 refactor(store): persist warmup progress in runtime settings
Replace warmup slot history with a monotonic UTC cursor and consolidate unreleased scheduling migrations.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 23:21:48 +08:00
90e241f74b feat(openai): configure Guardian concurrency priority at runtime (#306)
Configure Guardian reservations through persisted runtime settings and frozen request policies. Combine concurrency controls and queues in the admin UI, with the scheduling strategy shown first.

Prioritize Guardian account waiters, recheck displaced queue heads before lease acquisition, and report request-specific available capacity. Preserve account, key and wait-budget limits.

Co-authored-by: Yongzhao Chen <yongzhao.derek@gmail.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-01 22:49:11 +08:00
ZyyandCodex a0e8f768dd fix(ci): align container timezone checks with UTC
Assert the UTC PostgreSQL timezone and runtime timezone configured by the deployment files. Keep the application business timezone independent from the infrastructure checks.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 21:40:27 +08:00
ZyyandCodex 318f8a748e fix(modal): preserve content through close transitions
Keep modal content and request state stable until the exit transition completes, and defer credential cleanup until after leave. Update the UI dependency and submodules to v0.3.2, including the plugin workbench consumer.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 21:40:22 +08:00
ZyyandCodex 0dcba14572 refactor(bootstrap): separate startup modes and plugin wiring
Co-authored-by: Codex <noreply@openai.com>
2026-10-01 16:00:07 +08:00
ZyyandCodex ab3a2e30c5 feat(time): unify deployment timezone across display and scheduling
Use one validated IANA timezone for backend display projections, calendar queries, budget windows, warmup slots, backups, and logs. Remove frontend date formatting and the independent backup timezone setting.

Preserve UTC instants and active budget windows, persist warmup deduplication, and advance backup schedules transactionally. Cover DST boundaries, timezone changes, invalid calendar anchors, and account cooldown displays.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 15:53:22 +08:00
ZyyandCodex 0490e28cfc feat(accounts): show Codex credit balances in quota reset dialog
Expose upstream credit facts through the account quota API while preserving
decimal balances and distinguishing zero, unlimited, and missing values.
Clear stale balances on passive updates without changing quota availability.

Display credits below reset cards, label OpenAI quota as Codex quota, and
standardize the account quota action icons.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 13:22:20 +08:00
ZyyandCodex 9c10676764 chore(release): v3.18.3
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 22:20:06 +08:00
ZyyandCodex caa6c8ffc8 refactor(plugin): keep external quota cycles out of host ledger (#332)
Restore the pre-window host implementation and document plugin-owned policy and presentation. Keep native reset RPC coverage and run plugin integration tests with the standard isolated test services.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 21:47:50 +08:00
9d651856be fix(outbound): avoid duplicate Host headers on HTTP/2 (#337)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 19:26:58 +08:00
ZyyandCodex d10b8890e0 fix(admin): improve quota forecast and subscription display
Move the quota forecast entry after reset credits and propagate account updates from the quota panel. Include each error account current subscription in troubleshooting and unify subscription labels.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 18:45:08 +08:00
ZyyandCodex d97c6e064b fix(keys): restart budget windows on use after reset (#335)
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 17:54:47 +08:00
ZyyandCodex f85801edb3 fix(usage): balance diagnostic column widths
Size diagnostic names by dimension and share available width across metric columns. Keep API key names compact.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 04:33:21 +08:00
1406 changed files with 73831 additions and 39402 deletions
+7 -1
View File
@@ -19,7 +19,7 @@ description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
| 页面开发 | `$frontend-design`、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
| 页面开发 | `$frontend-design`、[前端职责](../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
@@ -40,6 +40,12 @@ description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
## 后端注释
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
- 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号
- 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过
## 文档硬约束
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
+2 -2
View File
@@ -13,7 +13,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
- `contributes` 是能力,`permissions` 是访问域,`bindings` 是挂载与匹配范围;安装成功不等于功能生效,`trustedProcess` 也不是操作系统沙箱
- `contributes` 声明处理器,`bindings` 选择挂载与匹配范围;安装成功不等于功能生效。当前清单拒绝 `permissions`,安装者完整信任插件,`trustedProcess` 不提供操作系统沙箱或访问域隔离,见[完整信任](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
## 按当前步骤读取
@@ -33,7 +33,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
## 交付
说明源码与产物位置、兼容范围、能力与权限、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
说明源码与产物位置、兼容范围、能力与绑定、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
修改源码不默认授权在目标实例安装启用、调用真实上游或发布,沿用用户当前授权判断后续动作
@@ -13,7 +13,7 @@
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
先检查用户给定工程、`modules/plugins` 子模块或同级仓库是否存在,读取实际仓库约定及当前步骤所需的 README 章节、依赖与锁文件,不预读无关前端配置
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
只需中间件时不复制示例的管理 API 和页面;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力
本仓库子模块联调使用[开发文档](../../../../docs/development.md#源码联调)中的 source 模式和本地 SDK 验证入口,不改写正式依赖或锁文件
其他工程按其自身联调配置临时使用包级链接或 override,路径须真实可解析,不从业务源码跨仓 `import .../src/...`
@@ -37,7 +37,7 @@
| 包内静态资源构建 | `examples/workbench/frontend/vite.config.ts` |
若派生新插件,同时修改包名、二进制名、插件身份、扩展项 ID、注册描述和构建目标,不只改展示名称。
保留当前项目确实需要的依赖、资源和权限;不要把示例中的全请求范围直接用到共享环境。
保留当前项目确实需要的依赖、资源和能力声明;不要把示例中的全请求范围直接用到共享环境
## 构建与打包
@@ -58,7 +58,7 @@ cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --targ
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装:
```bash
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
```
直接打包的示意命令:
@@ -92,7 +92,7 @@ cpr-plugin package \
## 验证与交付
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
按[插件使用](../../../../docs/plugins.md)验证“校验安装包 → 确认来源并接受完整信任 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加权限或资源选择向导
只有获得目标环境操作授权才安装和启用;不把修改源码的授权扩展到生产实例或真实凭据操作。
需要 API 自动化时查[插件管理 API](../../../../docs/api.md#12-插件管理),不要依据记忆拼路由。
@@ -100,7 +100,8 @@ cpr-plugin package \
| 实现范围 | 应验证的行为 |
| --- | --- |
| 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
| 中间件 | 匹配/不匹配范围、单次 `next`、未改写字节保留、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
| 上游适配器 | 绑定匹配、账号认证与代理、HTTP/SSE 或 WS 事件解析、发送状态、用量和终态;支持续接时核对 Key、账号、凭据版本与代次隔离,失败不能透明重放已发送操作 |
| 模型路由/调度 | 真实入口能命中规则,未匹配请求保持原有行为;使用官方客户端正常携带的业务头和会话别名,确认投影不会拒绝合法请求,也不暴露身份字段 |
| 生命周期/用量/WS 观察 | 完成一次实际模型请求并核对对应事件;WS 观察需确认实际上游采用 WS,HTTP 请求成功不能证明 WS 观察生效 |
| 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 |
@@ -111,5 +112,5 @@ cpr-plugin package \
外部示例测试若依赖未提供的仓库或制品,应记录为未执行;当前示例可用于实际能力联调,但不能把被忽略的测试计为通过。
临时账号、Key 或配置只在明确的测试范围内创建,记录并清理自己创建的对象,不输出密钥、原始请求或插件 secret。
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
交付简述:源码与产物位置、兼容范围、所需能力与绑定、执行过的命令及结果、尚未验证的场景
只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。
@@ -6,7 +6,7 @@
## 清单与处理器
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件必须声明版本 `4` 并显式选择阶段,上游适配器必须声明版本 `2`。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
@@ -16,9 +16,10 @@
## 中间件与宿主服务
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;HTTP、WebSocket、服务和模型请求按各自的类型化视图处理
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
- 权限从目标 SDK 的[访问域](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#访问域)选择,不在技能中维护枚举副本;日志和自身状态无需授权项,安装器不配置 Key/账号白名单,资源选择属于插件业务
- 按[完整信任](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)使用宿主回调,不添加权限或访问域声明;资源选择属于插件业务,父调用、期限、取消、实例 revision 与事务校验仍须满足
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;主动调用 HTTP 使用 `dispatch_http`,类型化服务使用 `HostClient::service`,实际执行与账单事实仍由宿主持有
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
- 需要自定义上游编码、路径与响应解析时使用[受管上游适配器](../../../../backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md),它是已选号后的终端,不调用 `next` 或自行组织换号重试;普通请求和响应加工仍使用中间件
+4 -3
View File
@@ -117,8 +117,9 @@ runs:
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" up -d --no-build --wait postgres redis
# 基础设施使用 UTC,业务展示和调度时区由应用配置独立控制。
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = UTC
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
@@ -148,8 +149,8 @@ runs:
test "$status" = 204
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0000
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = UTC
"${compose[@]}" exec -T codex-proxy-rs sh -c \
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
-2
View File
@@ -177,8 +177,6 @@ jobs:
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
host = JSON.parse(File.read(host_path))
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
host = host.merge("schema_version" => 1, "permissions" => [])
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
manifest = {
"schema_version" => 1,
+2 -3
View File
@@ -21,7 +21,7 @@
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
无需部署即可体验管理端的系统概览、账号分组、代理管理与用量统计:
| 登录信息 | 默认值 |
| --- | --- |
@@ -53,8 +53,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务
完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)
完成后保存显示的访问地址和管理员密码,再[登录管理端](#登录管理端)并[添加账号与客户端密钥](#添加账号与客户端密钥)
可在执行 `bash install.sh` 时传入环境变量:
+121 -74
View File
@@ -31,6 +31,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocation-counter"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb9e990c0a33699f1984d85a6abead615ccc72dd8130bf3e15dcabe2ca149c9"
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -268,9 +274,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.149.0"
version = "1.152.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d48257fb0eb1907a02a965ff7d1b910770a6de2c59a2d03461678df989c5cdcd"
checksum = "004d5acbd6850ab58789a43822e0a2ef23927a46b012c17f8fa51253103db9f8"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -297,7 +303,7 @@ dependencies = [
"lru",
"percent-encoding",
"regex-lite",
"sha2 0.11.0",
"sha2",
"tracing",
"url",
]
@@ -319,7 +325,7 @@ dependencies = [
"hmac",
"http",
"percent-encoding",
"sha2 0.11.0",
"sha2",
"time",
"tracing",
]
@@ -352,7 +358,7 @@ dependencies = [
"md-5",
"pin-project-lite",
"sha1 0.11.0",
"sha2 0.11.0",
"sha2",
"tracing",
]
@@ -369,9 +375,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.0"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d"
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -391,9 +397,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bd25384a4e437aa8d8f339afad4b69e786b936a7cb10db668a7aaf66717b1a8"
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -435,9 +441,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "591e0024cdf2a8de8711860f7e1d5cfbca26b800ce13793a9aa2f4f5918e6d95"
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -459,9 +465,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.18.0"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb0deb40a69de59f7809251cfe1ab16de135f42c49d222d1202b4d0ce19720f"
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -567,6 +573,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
@@ -870,7 +877,7 @@ dependencies = [
"gateway-plugin-sdk",
"hex",
"serde_json",
"sha2 0.11.0",
"sha2",
"tar",
"tempfile",
"thiserror",
@@ -926,9 +933,9 @@ dependencies = [
[[package]]
name = "config"
version = "0.15.26"
version = "0.15.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790374c727a4cb20877dd2cc99ce7011cbe03b0b8df083acccd37df0d161304d"
checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
dependencies = [
"pathdiff",
"serde_core",
@@ -1681,9 +1688,9 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"subtle",
"syn 2.0.119",
"syn 3.0.6",
"tempfile",
"thiserror",
"tokio",
@@ -1712,7 +1719,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"thiserror",
"tokio",
"tokio-tungstenite 0.29.0",
@@ -1728,19 +1735,21 @@ dependencies = [
name = "gateway-core"
version = "0.1.0"
dependencies = [
"allocation-counter",
"async-trait",
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"event-listener",
"futures",
"futures-timer",
"http",
"http-body",
"http-body-util",
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"thiserror",
"tracing",
"url",
@@ -1779,7 +1788,8 @@ dependencies = [
"serde",
"serde_ignored",
"serde_json",
"sha2 0.11.0",
"serde_path_to_error",
"sha2",
"tar",
"tempfile",
"thiserror",
@@ -1820,7 +1830,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"sqlx",
"tar",
"tempfile",
@@ -1848,6 +1858,8 @@ dependencies = [
name = "gateway-protocol"
version = "0.0.0"
dependencies = [
"http",
"httpdate",
"memchr",
"serde",
"serde_json",
@@ -1875,7 +1887,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"sqlx",
"tempfile",
"thiserror",
@@ -1968,9 +1980,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.16"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -2098,9 +2110,9 @@ dependencies = [
[[package]]
name = "http-body"
version = "1.0.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
@@ -2434,9 +2446,9 @@ dependencies = [
[[package]]
name = "jsonschema"
version = "0.57.0"
version = "0.58.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa"
checksum = "5761b0ca0a31464030d7c2a98087fde3124ebb5e09b5a112cdb815f03b8ee150"
dependencies = [
"ahash",
"bytecount",
@@ -2462,18 +2474,18 @@ dependencies = [
[[package]]
name = "jsonschema-regex"
version = "0.57.0"
version = "0.58.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3"
checksum = "854e9e22c420535035e95eed240cbfb4a5f4b7da74d122d63144e0c31926dbfd"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.57.0"
version = "0.58.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44"
checksum = "4e3bfac11ac357ec620880025757d38c67da8fd5b9ef5024d19f0f360002b4ca"
dependencies = [
"ahash",
"bytecount",
@@ -2513,6 +2525,15 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
@@ -2687,6 +2708,23 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin 0.9.9",
"version_check",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -2813,6 +2851,24 @@ dependencies = [
"libc",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2901,7 +2957,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
"sha2 0.11.0",
"sha2",
]
[[package]]
@@ -3161,12 +3217,12 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
@@ -3209,6 +3265,7 @@ dependencies = [
name = "provider-openai"
version = "0.0.0"
dependencies = [
"allocation-counter",
"async-stream",
"async-trait",
"aws-lc-rs",
@@ -3224,6 +3281,7 @@ dependencies = [
"gateway-core",
"gateway-protocol",
"getrandom 0.4.3",
"h2",
"hex",
"hyper",
"hyper-rustls",
@@ -3231,6 +3289,7 @@ dependencies = [
"native-tls",
"openssl",
"openssl-sys",
"regex",
"reqwest",
"roxmltree",
"rustls",
@@ -3240,7 +3299,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"subtle",
"tar",
"tempfile",
@@ -3282,7 +3341,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"thiserror",
"tokio",
"tracing",
@@ -3378,9 +3437,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.46"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -3528,9 +3587,9 @@ dependencies = [
[[package]]
name = "referencing"
version = "0.57.0"
version = "0.58.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e"
checksum = "590efadb0a669f1712c1e3ab810a55d4c3d20d0127c455f7236dd0710aba0346"
dependencies = [
"ahash",
"fluent-uri",
@@ -3993,17 +4052,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
@@ -4124,8 +4172,7 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -4137,8 +4184,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4161,7 +4207,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2",
"smallvec",
"thiserror",
"tokio",
@@ -4175,8 +4221,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"proc-macro2",
"quote",
@@ -4188,8 +4233,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"cfg-if",
"dotenvy",
@@ -4200,7 +4244,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -4214,8 +4258,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"bitflags",
"byteorder",
@@ -4232,7 +4275,7 @@ dependencies = [
"percent-encoding",
"serde",
"sha1 0.11.0",
"sha2 0.11.0",
"sha2",
"sqlx-core",
"thiserror",
"tracing",
@@ -4242,8 +4285,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -4266,7 +4308,7 @@ dependencies = [
"rand 0.10.2",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
@@ -4279,8 +4321,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"atoi",
"chrono",
@@ -4463,7 +4504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -5152,6 +5193,12 @@ name = "whoami"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"web-sys",
]
[[package]]
name = "winapi"
+2
View File
@@ -59,3 +59,5 @@ panic = "abort"
[patch.crates-io]
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
-459
View File
@@ -1,459 +0,0 @@
//! 网关唯一组合根:加载包级配置并连接各 Bundle。
use gateway_core::engine::provider::{ProviderRegistry, RegistryError};
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
#[derive(Debug, Deserialize)]
pub struct GatewayConfig {
schema_version: u32,
host: HostConfig,
store: gateway_store::StoreConfig,
admin: gateway_admin::AdminConfig,
#[serde(default)]
client: gateway_admin::ClientConfig,
api: gateway_api::ApiConfig,
#[serde(default)]
openai: provider_openai::OpenAiConfig,
}
impl LoadableConfig for GatewayConfig {
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("client"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
Ok(())
}
}
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
pub async fn run() -> Result<(), BootstrapError> {
launch(None).await.map(|_| ())
}
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
pub struct PluginCommand {
pub instance_id: Option<String>,
pub name: Option<String>,
pub arguments: Vec<String>,
}
impl PluginCommand {
fn is_help(&self) -> bool {
self.name.is_none()
|| self
.arguments
.iter()
.any(|value| matches!(value.as_str(), "--help" | "-h"))
}
}
pub async fn plugin_command(
command: PluginCommand,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
launch(Some(command)).await
}
async fn launch(
command: Option<PluginCommand>,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
macro_rules! or_shutdown {
($runtime:expr, $result:expr) => {
match $result {
Ok(value) => value,
Err(error) => {
$runtime.shutdown().await;
return Err(error.into());
}
}
};
}
let config = gateway_host::load_config::<GatewayConfig>()?;
let GatewayConfig {
schema_version: _,
host,
store,
admin,
client,
api,
openai,
} = config;
let plugin_cache = host.runtime_data_dir().join("plugins");
let host = if command.is_some() {
gateway_host::initialize_command_line(host).await?
} else {
gateway_host::initialize(host).await?
};
host.report_startup_ready("Host");
let help = command.as_ref().is_some_and(PluginCommand::is_help);
let mut store = if help {
gateway_store::initialize_read_only(store).await?
} else if command.is_some() {
gateway_store::initialize_command_line(store).await?
} else {
gateway_store::initialize(store).await?
};
host.report_startup_ready("Store");
let version = host
.system_operations()
.version()
.await
.map_err(|_| ConfigError::InvalidField("host.version"))?
.version;
let plugin_http = std::sync::Arc::new(gateway_host::outbound::HttpClient::new()?);
if let Some(command) = command.as_ref().filter(|_| help) {
let runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
commands.shutdown().await;
runtime.shutdown().await;
return Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: stdout?,
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
});
}
let provider_ports = store.provider_ports();
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
host.report_startup_ready("OpenAI Provider");
let mut xai = provider_xai::initialize(provider_ports).await?;
host.report_startup_ready("xAI Provider");
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let admin_providers = gateway_admin::ports::provider::ProviderAdminRegistry::new([
openai.admin_provider(),
xai.admin_provider(),
])
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
if let Some(command) = command {
let instance_id = or_shutdown!(
plugin_runtime,
command
.instance_id
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
);
let name = or_shutdown!(
plugin_runtime,
command
.name
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
);
let core = gateway_core::prepare_command_plane(
store.core_ports(),
providers.clone(),
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
);
let accounts = gateway_admin::initialize_plugin_accounts(
admin_providers.clone(),
store.admin_ports().accounts(),
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_account_ports(&accounts));
let keys = gateway_admin::initialize_plugin_client_keys(
admin_providers.clone(),
store.admin_ports().client_keys(),
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
let settings = gateway_admin::initialize_settings(
store.admin_ports().settings(),
core.snapshot_control(),
admin_providers,
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
);
let mut services = gateway_admin::service::Registry::new({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
});
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let services = std::sync::Arc::new(services);
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
or_shutdown!(plugin_runtime, store.start_command_line_writes());
let result = commands
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
.await;
commands.shutdown().await;
plugin_runtime.shutdown().await;
store.shutdown_command_line_writes().await?;
return Ok(result?);
}
let core = gateway_core::prepare(
store.core_ports(),
providers,
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
Some(plugin_runtime.frontend_authentication_registry()),
);
let plugin_accounts = gateway_admin::initialize_plugin_accounts(
admin_providers.clone(),
store.admin_ports().accounts(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_account_ports(&plugin_accounts)
);
let plugin_keys = gateway_admin::initialize_plugin_client_keys(
admin_providers.clone(),
store.admin_ports().client_keys(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_client_key_ports(&plugin_keys)
);
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
host.report_startup_ready("Core");
let host_version = or_shutdown!(
plugin_runtime,
version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))
);
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
gateway_plugin_runtime::PackageLimits::default(),
host_version,
));
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
plugin_runtime,
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
));
let mut admin = or_shutdown!(
plugin_runtime,
gateway_admin::initialize_with_plugin_accounts(
admin,
client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
service_middleware: {
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
},
providers: admin_providers,
plugin_preparation: plugin_runtime.clone(),
plugin_management: plugin_runtime.clone(),
published_snapshot: core.snapshots(),
plugin_inspector,
plugin_distribution,
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
client_distribution: host.client_distribution_resolver(),
system: host.system_operations(),
client_key_verifier: core.client_key_verifier(),
},
plugin_accounts,
)
.await
);
let official_files = host.official_plugin_release_files();
let official_identity = host.official_plugin_release_identity();
let official_import = or_shutdown!(
plugin_runtime,
admin
.services()
.plugins()
.import_official_release(
official_files.as_ref(),
&official_identity,
&gateway_admin::model::MutationContext {
actor: gateway_admin::model::MutationActor::System,
request_id: "startup-official-plugin-import".to_owned(),
},
)
.await
);
if official_import.artifacts > 0 {
host.report_startup_ready("Official plugins");
}
host.report_startup_ready("Admin");
let mut probes = store.health_probes();
probes.extend(core.health_probes());
probes.push(host.logging_health_probe());
let api = or_shutdown!(
plugin_runtime,
gateway_api::initialize(
api,
core.execution_service(),
admin.services(),
probes,
host.worker_health(),
host.connection_lifecycle(),
)
)
.with_middleware({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
move |snapshot| {
if let Some(snapshot) = snapshot {
return middleware.resolve(snapshot.extensions()?);
}
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
let diagnostic = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(diagnostic.extensions()?)
}
});
let http_dispatcher = api.dispatcher();
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
host.report_startup_ready("API");
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_services(&admin.services().public_services())
);
let mut plan = store.take_worker_contributions();
plan.push(gateway_host::retention::worker(store.retention())?);
plan.extend(core.take_worker_contributions());
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease())
);
host.report_startup_ready("Workers");
let served = host.serve(api.router()).await;
plugin_runtime.shutdown().await;
served?;
Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
})
}
fn prepare_plugins(
store: &gateway_store::StoreBundle,
plugin_cache: std::path::PathBuf,
version: &str,
http: std::sync::Arc<gateway_host::outbound::HttpClient>,
) -> Result<std::sync::Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
Ok(std::sync::Arc::new(
gateway_plugin_runtime::PluginRuntime::new(
store.admin_ports().plugins(),
store.admin_ports().plugin_state(),
gateway_plugin_runtime::PluginRuntimeConfig {
cache_directory: plugin_cache,
host_version: version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))?,
package_limits: gateway_plugin_runtime::PackageLimits::default(),
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
restart_circuit: Default::default(),
},
http,
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
)
.with_oauth_pending(store.provider_ports().oauth_pending()),
))
}
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error(transparent)]
Worker(#[from] gateway_core::task::WorkerDefinitionError),
#[error(transparent)]
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
#[error(transparent)]
Config(#[from] gateway_host::ConfigError),
#[error(transparent)]
Host(#[from] gateway_host::HostError),
#[error(transparent)]
Outbound(#[from] gateway_host::outbound::HttpError),
#[error(transparent)]
Store(#[from] gateway_store::StoreError),
#[error(transparent)]
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
#[error(transparent)]
OpenAi(#[from] provider_openai::OpenAiInitializeError),
#[error(transparent)]
Xai(#[from] provider_xai::XaiInitializeError),
#[error(transparent)]
Registry(#[from] RegistryError),
#[error(transparent)]
Core(#[from] gateway_core::CoreError),
#[error(transparent)]
Admin(#[from] gateway_admin::model::AdminError),
#[error(transparent)]
Api(#[from] gateway_api::ApiError),
}
@@ -0,0 +1,134 @@
//! 插件帮助只读启动;命令仅组装命令平面和必要写泵
use super::{
BootstrapError, GatewayConfig,
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
startup::{Environment, Mode, Providers},
};
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret
pub struct PluginCommand {
pub instance_id: Option<String>,
pub name: Option<String>,
pub arguments: Vec<String>,
}
impl PluginCommand {
fn is_help(&self) -> bool {
self.name.is_none()
|| self
.arguments
.iter()
.any(|value| matches!(value.as_str(), "--help" | "-h"))
}
}
pub async fn plugin_command(
command: PluginCommand,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
let config = gateway_host::load_config::<GatewayConfig>()?;
if command.is_help() {
let environment = Environment::initialize(config.host, config.store, Mode::Help).await?;
help(command, environment).await
} else {
let environment = Environment::initialize(config.host, config.store, Mode::Command).await?;
let providers = Providers::initialize(config.openai, &environment).await?;
execute(command, environment, providers).await
}
}
async fn help(
command: PluginCommand,
environment: Environment,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
let runtime = prepare_plugins(
&environment.store,
environment.plugin_cache,
&environment.version,
environment.plugin_http,
)?;
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
commands.shutdown().await;
runtime.shutdown().await;
Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: stdout?,
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
})
}
async fn execute(
command: PluginCommand,
environment: Environment,
providers: Providers,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
let Environment {
host,
mut store,
plugin_cache,
version,
plugin_http,
..
} = environment;
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http)?;
let instance_id = or_shutdown!(
plugin_runtime,
command
.instance_id
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
);
let name = or_shutdown!(
plugin_runtime,
command
.name
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
);
let core = gateway_core::prepare_command_plane(
store.core_ports(),
providers.core,
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.execution_registry()),
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
let settings = gateway_admin::initialize_settings(
store.admin_ports().settings(),
core.snapshot_control(),
providers.admin,
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
);
let mut services = gateway_admin::public_service::Registry::new(service_middleware(
core.snapshots(),
&plugin_runtime,
));
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let services = std::sync::Arc::new(services);
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
let result = async {
store.start_command_line_writes()?;
Ok::<_, BootstrapError>(
commands
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
.await?,
)
}
.await;
commands.shutdown().await;
plugin_runtime.shutdown().await;
store.shutdown_command_line_writes().await?;
result
}
@@ -0,0 +1,50 @@
//! 网关顶层配置的加载、路径解析与跨模块参数校验
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验
#[derive(Debug, Deserialize)]
pub struct GatewayConfig {
schema_version: u32,
pub(super) host: HostConfig,
pub(super) store: gateway_store::StoreConfig,
pub(super) admin: gateway_admin::AdminConfig,
#[serde(default)]
pub(super) client: gateway_admin::ClientConfig,
pub(super) api: gateway_api::ApiConfig,
#[serde(default)]
pub(super) openai: provider_openai::OpenAiConfig,
}
impl LoadableConfig for GatewayConfig {
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("client"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
Ok(())
}
}
+42
View File
@@ -0,0 +1,42 @@
//! 网关唯一组合根:按启动模式连接各 Bundle
mod command;
mod config;
mod plugins;
mod server;
mod startup;
pub use command::{PluginCommand, plugin_command};
pub use config::GatewayConfig;
pub use server::run;
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error(transparent)]
Worker(#[from] gateway_core::task::WorkerDefinitionError),
#[error(transparent)]
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
#[error(transparent)]
Config(#[from] gateway_host::ConfigError),
#[error(transparent)]
Host(#[from] gateway_host::HostError),
#[error(transparent)]
Outbound(#[from] gateway_host::outbound::HttpError),
#[error(transparent)]
Store(#[from] gateway_store::StoreError),
#[error(transparent)]
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
#[error(transparent)]
OpenAi(#[from] provider_openai::OpenAiInitializeError),
#[error(transparent)]
Xai(#[from] provider_xai::XaiInitializeError),
#[error(transparent)]
Registry(#[from] gateway_core::engine::provider::RegistryError),
#[error(transparent)]
Core(#[from] gateway_core::CoreError),
#[error(transparent)]
Admin(#[from] gateway_admin::model::AdminError),
#[error(transparent)]
Api(#[from] gateway_api::ApiError),
}
@@ -0,0 +1,108 @@
//! 插件 Runtime 的组装与回调资源持有;服务和命令模式复用同一组管理端口
use std::sync::Arc;
use gateway_admin::{
model::AdminError,
ports::{
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
},
};
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
use gateway_host::ConfigError;
use gateway_plugin_runtime::PluginRuntime;
use super::BootstrapError;
// 必须在调用方仍持有 Core 和回调资源时异步关闭,不能先退出该作用域再清理
macro_rules! or_shutdown {
($runtime:expr, $result:expr) => {
match $result {
Ok(value) => value,
Err(error) => {
$runtime.shutdown().await;
return Err(error.into());
}
}
};
}
pub(super) use or_shutdown;
pub(super) struct ManagementPorts {
// Runtime 保存 Weak;组合根持有这些 Arc,直到插件全部关闭
pub(super) accounts: Arc<dyn PluginAccountAccess>,
keys: Arc<dyn PluginClientKeyAccess>,
resources: Arc<dyn PluginResourceAccess>,
}
impl ManagementPorts {
pub(super) fn new(
store: &gateway_store::StoreBundle,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
let ports = store.admin_ports();
Self {
accounts: gateway_admin::initialize_plugin_accounts(
providers.clone(),
ports.accounts(),
snapshot.clone(),
),
keys: gateway_admin::initialize_plugin_client_keys(
providers,
ports.client_keys(),
snapshot.clone(),
),
resources: gateway_admin::initialize_plugin_resources(
ports.plugin_resources(),
snapshot,
),
}
}
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
runtime.bind_account_ports(&self.accounts)?;
runtime.bind_client_key_ports(&self.keys)?;
runtime.bind_resource_ports(&self.resources)
}
}
pub(super) fn service_middleware(
snapshots: RuntimeSnapshotHandle,
runtime: &PluginRuntime,
) -> gateway_admin::public_service::PlanSource {
let middleware = runtime.execution_registry();
Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.middleware(snapshot.extensions()?)
})
}
pub(super) fn prepare_plugins(
store: &gateway_store::StoreBundle,
plugin_cache: std::path::PathBuf,
version: &str,
http: Arc<gateway_host::outbound::HttpClient>,
) -> Result<Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
Ok(Arc::new(
gateway_plugin_runtime::PluginRuntime::new(
store.admin_ports().plugins(),
store.admin_ports().plugin_state(),
gateway_plugin_runtime::PluginRuntimeConfig {
cache_directory: plugin_cache,
host_version: version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))?,
package_limits: gateway_plugin_runtime::PackageLimits::default(),
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
restart_circuit: Default::default(),
},
http,
Arc::new(gateway_host::process::ProcessSupervisor::default()),
)
.with_oauth_pending(store.provider_ports().oauth_pending()),
))
}
@@ -0,0 +1,165 @@
//! 服务模式完成全部组装后交给 Host 监听,并在停止监听与 Worker 收尾后关闭插件
use gateway_host::ConfigError;
use super::{
BootstrapError, GatewayConfig,
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
startup::{Environment, Mode, Providers},
};
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host
pub async fn run() -> Result<(), BootstrapError> {
let config = gateway_host::load_config::<GatewayConfig>()?;
let environment = Environment::initialize(config.host, config.store, Mode::Server).await?;
let mut providers = Providers::initialize(config.openai, &environment).await?;
let Environment {
host,
mut store,
timezone,
plugin_cache,
version,
plugin_http,
} = environment;
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let core = gateway_core::prepare(
store.core_ports(),
providers.core,
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.execution_registry()),
Some(plugin_runtime.frontend_authentication_registry()),
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
host.report_startup_ready("Core");
let host_version = or_shutdown!(
plugin_runtime,
version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))
);
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
gateway_plugin_runtime::PackageLimits::default(),
host_version,
));
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
plugin_runtime,
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
));
let mut admin = or_shutdown!(
plugin_runtime,
gateway_admin::initialize_with_plugin_accounts(
config.admin,
config.client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
timezone,
service_middleware: service_middleware(core.snapshots(), &plugin_runtime),
providers: providers.admin,
plugin_preparation: plugin_runtime.clone(),
plugin_management: plugin_runtime.clone(),
published_snapshot: core.snapshots(),
plugin_inspector,
plugin_distribution,
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
client_distribution: host.client_distribution_resolver(),
system: host.system_operations(),
client_key_verifier: core.client_key_verifier(),
},
management.accounts.clone(),
)
.await
);
let official_files = host.official_plugin_release_files();
let official_identity = host.official_plugin_release_identity();
let official_import = or_shutdown!(
plugin_runtime,
admin
.services()
.plugins()
.import_official_release(
official_files.as_ref(),
&official_identity,
&gateway_admin::model::MutationContext {
actor: gateway_admin::model::MutationActor::System,
request_id: "startup-official-plugin-import".to_owned(),
},
)
.await
);
if official_import.artifacts > 0 {
host.report_startup_ready("Official plugins");
}
host.report_startup_ready("Admin");
let mut probes = store.health_probes();
probes.extend(core.health_probes());
probes.push(host.logging_health_probe());
let api = or_shutdown!(
plugin_runtime,
gateway_api::initialize(
config.api,
core.execution_service(),
admin.services(),
probes,
host.worker_health(),
host.connection_lifecycle(),
store.diagnostics(),
)
)
.with_middleware({
let snapshots = core.snapshots();
let middleware = plugin_runtime.execution_registry();
move |snapshot| {
if let Some(snapshot) = snapshot {
return middleware.middleware(snapshot.extensions()?);
}
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件
let diagnostic = snapshots.snapshot_for_diagnostics()?;
middleware.middleware(diagnostic.extensions()?)
}
});
let http_dispatcher = api.dispatcher();
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
host.report_startup_ready("API");
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_services(&admin.services().public_services())
);
let mut plan = store.take_worker_contributions();
plan.push(or_shutdown!(
plugin_runtime,
gateway_host::retention::worker(store.retention())
));
plan.extend(core.take_worker_contributions());
plan.extend(providers.openai.take_worker_contributions());
plan.extend(providers.xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
plan.push(or_shutdown!(
plugin_runtime,
plugin_runtime.maintenance_worker(core.snapshots())
));
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease(), store.diagnostics())
);
host.report_startup_ready("Workers");
let served = host.serve(api.router()).await;
plugin_runtime.shutdown().await;
Ok(served?)
}
@@ -0,0 +1,94 @@
//! 启动模式共享的基础设施与 Provider 组装,不启动 HTTP 或 Worker
use std::{path::PathBuf, sync::Arc};
use gateway_admin::ports::provider::ProviderAdminRegistry;
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
use gateway_store::{StoreBundle, StoreConfig};
use super::BootstrapError;
pub(super) enum Mode {
Server,
Command,
Help,
}
pub(super) struct Environment {
pub(super) host: HostBundle,
pub(super) store: StoreBundle,
pub(super) timezone: DeploymentTimeZone,
pub(super) plugin_cache: PathBuf,
pub(super) version: String,
pub(super) plugin_http: Arc<HttpClient>,
}
impl Environment {
pub(super) async fn initialize(
host: HostConfig,
store: StoreConfig,
mode: Mode,
) -> Result<Self, BootstrapError> {
let timezone = host.timezone;
let store = store.with_timezone(timezone);
let plugin_cache = host.runtime_data_dir().join("plugins");
let host = match mode {
Mode::Server => gateway_host::initialize(host).await?,
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
};
host.report_startup_ready("Host");
let store = match mode {
Mode::Server => gateway_store::initialize(store).await?,
Mode::Command => gateway_store::initialize_command_line(store).await?,
Mode::Help => gateway_store::initialize_read_only(store).await?,
};
host.report_startup_ready("Store");
let version = host
.system_operations()
.version()
.await
.map_err(|_| ConfigError::InvalidField("host.version"))?
.version;
let plugin_http = Arc::new(HttpClient::new()?);
Ok(Self {
host,
store,
timezone,
plugin_cache,
version,
plugin_http,
})
}
}
pub(super) struct Providers {
pub(super) openai: provider_openai::ProviderBundle,
pub(super) xai: provider_xai::ProviderBundle,
pub(super) core: ProviderRegistry,
pub(super) admin: ProviderAdminRegistry,
}
impl Providers {
pub(super) async fn initialize(
config: provider_openai::OpenAiConfig,
environment: &Environment,
) -> Result<Self, BootstrapError> {
let ports = environment.store.provider_ports();
let openai =
provider_openai::initialize(config.with_timezone(environment.timezone), ports.clone())
.await?;
environment.host.report_startup_ready("OpenAI Provider");
let xai = provider_xai::initialize(ports).await?;
environment.host.report_startup_ready("xAI Provider");
let core = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let admin = ProviderAdminRegistry::new([openai.admin_provider(), xai.admin_provider()])
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
Ok(Self {
openai,
xai,
core,
admin,
})
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
//! 网关进程组装根。
//! 网关进程组装根
pub mod bootstrap;
+2
View File
@@ -1,3 +1,5 @@
//! 网关命令行入口,选择启动模式并创建异步运行时
use std::{
env,
error::Error,
+151 -38
View File
@@ -1,4 +1,4 @@
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验
use std::{
collections::{BTreeMap, BTreeSet},
@@ -8,7 +8,7 @@ use std::{
use syn::{Item, visit::Visit};
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
"apps/gateway",
"apps/plugin-cli",
@@ -79,18 +79,25 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
path if path.starts_with("policy/") => {
Some(&["account", "identity", "policy", "validation"])
}
"settings/values.rs" | "settings/compiled.rs" => {
Some(&["account", "concurrency", "identity", "metering", "policy"])
}
path if path.starts_with("settings/") => Some(&[
"account",
"concurrency",
"identity",
"metering",
"policy",
"settings",
]),
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
_ => None,
};
let Some(allowed) = allowed else { continue };
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
let syntax = syn::parse_file(&source).expect("parse core source");
let mut references = CrateReferences::default();
let mut references = CrateReferences::in_module(&relative);
references.visit_file(&syntax);
for dependency in references.0 {
// 相对路径中的 self 指向当前 owner,不是跨 owner 依赖
references.owners.remove(&references.module_path[0]);
for dependency in references.owners {
assert!(
allowed.contains(&dependency.as_str()),
"{} must not depend on crate::{dependency}",
@@ -100,49 +107,155 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
}
}
#[test]
fn core_owner_paths_form_a_dag() {
let root = backend_root().join("crates/gateway-core/src");
let mut graph = BTreeMap::<String, BTreeSet<String>>::new();
for relative in super::rust_files(&root) {
if relative == Path::new("lib.rs") {
continue;
}
let mut references = CrateReferences::in_module(&relative);
let owner = references.module_path[0].clone();
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
references.visit_file(&syn::parse_file(&source).expect("parse core source"));
references.owners.remove(&owner);
graph.entry(owner).or_default().extend(references.owners);
}
for owner in graph.keys() {
let mut pending = graph[owner].iter().cloned().collect::<Vec<_>>();
let mut visited = BTreeSet::new();
while let Some(dependency) = pending.pop() {
assert_ne!(
&dependency, owner,
"Core owner dependency cycle reaches {owner}"
);
if visited.insert(dependency.clone())
&& let Some(next) = graph.get(&dependency)
{
pending.extend(next.iter().cloned());
}
}
}
}
#[test]
fn core_reference_scan_resolves_groups_reexports_and_relative_paths() {
let syntax = syn::parse_file(
r#"
pub use crate::{engine::AttemptContext, settings::SettingsValues as Values};
use super::super::runtime as publication;
use self::compiled::Value;
// crate::ignored::Comment
const EXAMPLE: &str = "crate::ignored::Literal";
"#,
)
.expect("parse reference fixture");
let mut references = CrateReferences::in_module(Path::new("routing/snapshot.rs"));
references.visit_file(&syntax);
assert_eq!(
references.owners,
BTreeSet::from([
"engine".to_owned(),
"settings".to_owned(),
"runtime".to_owned(),
"routing".to_owned(),
])
);
}
#[derive(Default)]
struct CrateReferences(BTreeSet<String>);
struct CrateReferences {
owners: BTreeSet<String>,
module_path: Vec<String>,
}
impl<'ast> Visit<'ast> for CrateReferences {
fn visit_path(&mut self, path: &'ast syn::Path) {
let mut segments = path.segments.iter();
if segments
.next()
.is_some_and(|segment| segment.ident == "crate")
&& let Some(owner) = segments.next()
{
self.0.insert(owner.ident.to_string());
}
self.record(
&path
.segments
.iter()
.map(|part| part.ident.to_string())
.collect::<Vec<_>>(),
);
syn::visit::visit_path(self, path);
}
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
self.use_tree(&item.tree, false);
self.use_tree(&item.tree, &mut Vec::new());
}
}
impl CrateReferences {
fn use_tree(&mut self, tree: &syn::UseTree, crate_root: bool) {
fn in_module(relative: &Path) -> Self {
let mut module = relative
.with_extension("")
.components()
.map(|part| {
part.as_os_str()
.to_str()
.expect("UTF-8 module path")
.to_owned()
})
.collect::<Vec<_>>();
if module.last().is_some_and(|part| part == "mod") {
module.pop();
}
Self {
owners: BTreeSet::new(),
module_path: module,
}
}
fn record(&mut self, path: &[String]) {
let Some(first) = path.first() else { return };
let owner = if first == "crate" {
path.get(1).cloned()
} else if first == "self" || first == "super" {
let mut module = self.module_path.clone();
let mut rest = path;
while rest
.first()
.is_some_and(|part| part == "super" || part == "self")
{
if rest[0] == "super" {
module.pop();
}
rest = &rest[1..];
}
module.first().or_else(|| rest.first()).cloned()
} else {
None
};
if let Some(owner) = owner {
self.owners.insert(owner);
}
}
fn use_tree(&mut self, tree: &syn::UseTree, prefix: &mut Vec<String>) {
match tree {
syn::UseTree::Path(path) if crate_root => {
self.0.insert(path.ident.to_string());
syn::UseTree::Path(path) => {
prefix.push(path.ident.to_string());
self.use_tree(&path.tree, prefix);
prefix.pop();
}
syn::UseTree::Name(name) if crate_root => {
self.0.insert(name.ident.to_string());
syn::UseTree::Name(name) => {
prefix.push(name.ident.to_string());
self.record(prefix);
prefix.pop();
}
syn::UseTree::Rename(rename) if crate_root => {
self.0.insert(rename.ident.to_string());
syn::UseTree::Rename(rename) => {
prefix.push(rename.ident.to_string());
self.record(prefix);
prefix.pop();
}
syn::UseTree::Path(path) => self.use_tree(&path.tree, path.ident == "crate"),
syn::UseTree::Group(group) => {
for item in &group.items {
self.use_tree(item, crate_root);
self.use_tree(item, prefix);
}
}
syn::UseTree::Glob(_) if crate_root => {
self.0.insert("*".to_owned());
}
_ => {}
syn::UseTree::Glob(_) => self.record(prefix),
}
}
}
@@ -178,7 +291,7 @@ fn gateway_admin_stays_free_of_infrastructure_dependencies() {
);
}
/// workspace 包名到冻结成员路径的映射。
/// workspace 包名到冻结成员路径的映射
const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
("codex-proxy-rs", "apps/gateway"),
("codex-proxy-plugin-cli", "apps/plugin-cli"),
@@ -194,7 +307,7 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
("provider-xai", "crates/providers/xai"),
];
/// SDK 与 Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
/// SDK 与 Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
("crates/gateway-plugin/sdk", &["call", "client"]),
("crates/gateway-plugin/runtime", &[]),
@@ -221,16 +334,16 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"crates/providers/openai",
&["config", "credential", "transport"],
),
("crates/providers/xai", &["credential", "transport"]),
("crates/providers/xai", &["transport"]),
];
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景
const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
("apps/gateway", &["architecture"]),
("crates/gateway-api", &["architecture"]),
];
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("codex-proxy-plugin-cli", "gateway-plugin-sdk"),
("codex-proxy-rs", "gateway-admin"),
@@ -475,7 +588,7 @@ fn integration_tests_mirror_production_module_tree() {
}
}
/// Rust 子进程 fixture 是 Cargo 的独立 crate 根,不能要求它由测试模块再次声明。
/// Rust 子进程 fixture 是 Cargo 的独立 crate 根,不能要求它由测试模块再次声明
fn test_source_roots() -> BTreeMap<PathBuf, Vec<String>> {
let metadata = cargo_metadata_json();
let mut roots = BTreeMap::<PathBuf, Vec<String>>::new();
@@ -529,7 +642,7 @@ fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
let files = super::rust_files(root);
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查。
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查
let mut declarations = BTreeMap::<PathBuf, BTreeMap<String, usize>>::new();
for relative in &files {
if crate_roots
@@ -649,7 +762,7 @@ fn cargo_metadata_json() -> serde_json::Value {
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
}
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束。
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束
fn dependency_names(member: &str) -> Vec<String> {
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
.expect("read member manifest");
@@ -0,0 +1,51 @@
//! 验证网关基础命令无需加载配置,并拒绝非法或多余参数
use std::{fs, process::Command};
#[test]
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
for flag in ["--help", "-h", "help", "--version", "-V"] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(flag)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(output.status.success(), "{flag}");
assert!(!output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
for args in [
vec!["unknown"],
vec!["--help", "extra"],
vec!["serve", "extra"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.args(args)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
}
}
#[cfg(unix)]
#[test]
fn non_utf8_command_is_not_interpreted_as_serve() {
use std::os::unix::ffi::OsStringExt as _;
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(std::ffi::OsString::from_vec(vec![0xff]))
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
}
@@ -1,207 +1,16 @@
//! 验证顶层配置加载、路径解析、显式覆盖与敏感字段脱敏
use std::{fs, process::Command};
use codex_proxy_rs::bootstrap::GatewayConfig;
use gateway_host::LoadableConfig;
const CONFIG_EXAMPLE: &str = include_str!("../../../../deploy/config.example.yaml");
const CONFIG_EXAMPLE: &str = include_str!("../../../../../deploy/config.example.yaml");
const POSTGRES_PASSWORD: &str = "111111111111111111111111111111111111111111111111";
const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
const ADMIN_PASSWORD: &str = "test-admin-password";
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
#[test]
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
for flag in ["--help", "-h", "help", "--version", "-V"] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(flag)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(output.status.success(), "{flag}");
assert!(!output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
for args in [
vec!["unknown"],
vec!["--help", "extra"],
vec!["serve", "extra"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.args(args)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
}
}
#[cfg(unix)]
#[test]
fn non_utf8_command_is_not_interpreted_as_serve() {
use std::os::unix::ffi::OsStringExt as _;
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(std::ffi::OsString::from_vec(vec![0xff]))
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
}
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
},
};
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = tempfile::tempdir().unwrap();
generate_proxy_test_certificates(directory.path());
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
"--nocapture",
])
.env(CHILD_ENV, directory.path())
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE")
.env(ca_env, directory.path().join("ca.pem"));
if ca_env == "CODEX_CA_CERTIFICATE" {
child.env(
"SSL_CERT_FILE",
directory.path().join("missing-fallback.pem"),
);
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{ca_env}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
provider_openai::ensure_rustls_provider();
let directory = std::path::Path::new(&directory);
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
let acceptor = TlsAcceptor::from(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate], key)
.unwrap(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy =
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(socket).await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
assert!(request.len() < 8192);
request.push(stream.read_u8().await.unwrap());
}
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
let body = "{\"ip\":\"203.0.113.8\"}";
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy, false).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
fn generate_proxy_test_certificates(directory: &std::path::Path) {
let openssl = |args: &[&str]| {
let output = Command::new("openssl")
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
openssl(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"ca.key",
"-out",
"ca.pem",
"-days",
"1",
"-subj",
"/CN=Proxy Test CA",
]);
openssl(&[
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"server.key",
"-out",
"server.csr",
"-subj",
"/CN=localhost",
]);
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
openssl(&[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.pem",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.pem",
"-days",
"1",
"-extfile",
"extensions",
]);
}
#[test]
fn config_loader_should_load_complete_terminal_example() {
parse_config(&valid_config()).expect("terminal config example");
@@ -220,7 +29,7 @@ fn config_loader_should_resolve_paths_relative_to_config_file() {
fn config_loader_should_share_resolved_assets_with_system_update() {
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
let Ok(case) = std::env::var(CHILD_ENV) else {
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试
for (case, web_dist) in [
("binary", None),
("docker", Some("/app/web/dist")),
@@ -230,7 +39,7 @@ fn config_loader_should_share_resolved_assets_with_system_update() {
child
.args([
"--exact",
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
"bootstrap::config::config_loader_should_share_resolved_assets_with_system_update",
])
.env(CHILD_ENV, case)
.env_remove("CPR_WEB_DIST_DIR");
@@ -267,11 +76,6 @@ fn config_loader_should_reject_missing_runtime_data_dir() {
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
}
#[test]
fn config_loader_should_apply_only_explicit_topology_overrides() {
let invalid = valid_config()
@@ -292,7 +96,7 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
let status = Command::new(std::env::current_exe().expect("current test executable"))
.args([
"--exact",
"bootstrap::config_loader_should_apply_only_explicit_topology_overrides",
"bootstrap::config::config_loader_should_apply_only_explicit_topology_overrides",
])
.env(TOPOLOGY_CHILD_ENV, "1")
.env("CPR_SERVER_HOST", "127.0.0.1")
@@ -374,7 +178,15 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
return;
}
for case in ["normal", "unused", "missing"] {
for case in [
"normal",
"unused",
"missing",
"timezoneEmpty",
"timezoneInvalid",
"timezoneNull",
"timezoneType",
] {
let mut document = valid_config_document();
if case == "unused" {
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
@@ -384,6 +196,14 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
.unwrap()
.remove("port");
}
if case.starts_with("timezone") {
document["host"]["timezone"] = match case {
"timezoneEmpty" => serde_json::json!(""),
"timezoneInvalid" => serde_json::json!(UNUSED_SECRET),
"timezoneNull" => serde_json::json!(null),
_ => serde_json::json!(8),
};
}
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(
@@ -394,7 +214,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
"bootstrap::config::config_loader_should_report_startup_configuration_diagnostics",
"--nocapture",
])
.env(CHILD_ENV, "1")
@@ -404,7 +224,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.success(),
case != "missing",
case != "missing" && !case.starts_with("timezone"),
"{case}: {stderr}"
);
match case {
@@ -413,6 +233,9 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
case if case.starts_with("timezone") => {
assert!(stderr.contains("host.timezone"), "{stderr}")
}
_ => assert!(!stderr.contains("警告"), "{stderr}"),
}
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
@@ -553,7 +376,7 @@ fn valid_config() -> String {
}
fn valid_config_document() -> serde_json::Value {
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载。
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载
config::Config::builder()
.add_source(config::File::from_str(
&valid_config(),
@@ -580,3 +403,18 @@ fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), Stri
.map_err(|error| error.to_string())?;
Ok((config, directory))
}
#[test]
fn deployment_timezone_defaults_and_valid_override_are_loaded() {
let mut document = valid_config_document();
document["host"].as_object_mut().unwrap().remove("timezone");
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
let host: gateway_host::config::HostConfig =
serde_json::from_value(document["host"].clone()).unwrap();
assert_eq!(host.timezone.name(), "Asia/Shanghai");
document["host"]["timezone"] = serde_json::json!("Asia/Kathmandu");
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
let host: gateway_host::config::HostConfig =
serde_json::from_value(document["host"].clone()).unwrap();
assert_eq!(host.timezone.name(), "Asia/Kathmandu");
}
@@ -0,0 +1,5 @@
//! 网关启动配置、命令行与服务组装的测试入口
mod command;
mod config;
mod server;
@@ -0,0 +1,148 @@
//! 验证服务组装为 HTTPS 代理探测传递自定义 CA
use std::{fs, process::Command};
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
},
};
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = tempfile::tempdir().unwrap();
generate_proxy_test_certificates(directory.path());
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"bootstrap::server::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
"--nocapture",
])
.env(CHILD_ENV, directory.path())
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE")
.env(ca_env, directory.path().join("ca.pem"));
if ca_env == "CODEX_CA_CERTIFICATE" {
child.env(
"SSL_CERT_FILE",
directory.path().join("missing-fallback.pem"),
);
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{ca_env}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
provider_openai::ensure_rustls_provider();
let directory = std::path::Path::new(&directory);
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
let acceptor = TlsAcceptor::from(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate], key)
.unwrap(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy =
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(socket).await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
assert!(request.len() < 8192);
request.push(stream.read_u8().await.unwrap());
}
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
let body = "{\"ip\":\"203.0.113.8\"}";
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy, false).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
fn generate_proxy_test_certificates(directory: &std::path::Path) {
let openssl = |args: &[&str]| {
let output = Command::new("openssl")
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
openssl(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"ca.key",
"-out",
"ca.pem",
"-days",
"1",
"-subj",
"/CN=Proxy Test CA",
]);
openssl(&[
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"server.key",
"-out",
"server.csr",
"-subj",
"/CN=localhost",
]);
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
openssl(&[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.pem",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.pem",
"-days",
"1",
"-extfile",
"extensions",
]);
}
+14 -4
View File
@@ -1,8 +1,10 @@
//! 网关组合根测试入口,检查源码模块纪律与冻结的文件边界
mod architecture;
mod bootstrap;
// 组合根是否混入业务策略由代码审查判断,行数和标识符/注释关键词不能证明职责越界。
// 依赖 DAG、公开模块面和模块镜像继续由 architecture 检查,源码纪律由下方测试维护。
// 组合根是否混入业务策略由代码审查判断,行数和标识符/注释关键词不能证明职责越界
// 依赖 DAG、公开模块面和模块镜像继续由 architecture 检查,源码纪律由下方测试维护
use std::{
collections::BTreeSet,
@@ -18,7 +20,12 @@ fn app_tree_matches_frozen_terminal_manifest() {
assert_eq!(
rust_files(&root.join("src")),
BTreeSet::from([
PathBuf::from("bootstrap.rs"),
PathBuf::from("bootstrap/command.rs"),
PathBuf::from("bootstrap/config.rs"),
PathBuf::from("bootstrap/mod.rs"),
PathBuf::from("bootstrap/plugins.rs"),
PathBuf::from("bootstrap/server.rs"),
PathBuf::from("bootstrap/startup.rs"),
PathBuf::from("lib.rs"),
PathBuf::from("main.rs"),
]),
@@ -27,7 +34,10 @@ fn app_tree_matches_frozen_terminal_manifest() {
rust_files(&root.join("tests")),
BTreeSet::from([
PathBuf::from("architecture.rs"),
PathBuf::from("bootstrap.rs"),
PathBuf::from("bootstrap/command.rs"),
PathBuf::from("bootstrap/config.rs"),
PathBuf::from("bootstrap/mod.rs"),
PathBuf::from("bootstrap/server.rs"),
PathBuf::from("main.rs"),
]),
);
+5 -8
View File
@@ -8,10 +8,10 @@
从仓库根目录安装到本地工具目录:
```bash
cargo install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
cargo +1.97.0 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
```
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径;工具链与宿主 [验证命令](../../../docs/architecture.md#验证命令)一致
## 打包
@@ -28,11 +28,8 @@ cpr-plugin package \
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 必须显式声明版本 `3`,
并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)
作者清单的省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口,归档只保留补全字段后的 `plugin.json`
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
@@ -48,6 +45,6 @@ CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
```bash
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli -- package --help
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
```
+2
View File
@@ -1,3 +1,5 @@
//! 插件打包命令行,校验输入资源并生成带摘要的平台归档
use std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
+4 -2
View File
@@ -1,3 +1,5 @@
//! 插件打包命令行的参数、归档校验与输出行为测试
use std::{
collections::BTreeMap,
fs::{self, File},
@@ -128,7 +130,7 @@ fn rejects_duplicate_resource_prefixes() {
}
#[test]
fn packages_only_generated_resources_with_v3_metadata() {
fn packages_only_generated_resources_with_current_metadata() {
assert_packaged_resources("assets/icon.png", "image/png", b"png");
}
@@ -248,7 +250,7 @@ fn source_manifest() -> &'static [u8] {
"main":"bin/plugin",
"runtime":"trustedProcess",
"contributes":{
"middleware":{"version":3,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"middleware":{"version":4,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{}
},
+2 -2
View File
@@ -36,9 +36,9 @@ chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
prettyplease = "0.2"
prettyplease = "0.3"
quote = "1"
syn = { version = "2", features = ["full", "visit-mut"] }
syn = { version = "3", features = ["full", "visit-mut"] }
tempfile = "3"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
@@ -1,4 +1,4 @@
//! 备份纯策略与 Worker 任务。
//! 备份纯策略与 Worker 任务
pub mod policy;
pub mod task;
@@ -1,6 +1,6 @@
//! 备份纯策略:Cron 推进与保留候选。
//! 备份纯策略:Cron 推进与保留候选
//!
//! 本模块不持有任何端口或对象,是 `BackupService` 与 `BackupTask` 共用的纯函数集。
//! 本模块不持有任何端口或对象,是 `BackupService` 与 `BackupTask` 共用的纯函数集
use std::str::FromStr;
@@ -10,10 +10,11 @@ use cron::Schedule;
use crate::model::backup::{BackupError, BackupRecord, BackupStatus, code};
/// 向历史回溯的最大范围(分钟)。覆盖 Feb 29 + 特定星期等罕见组合的最长间隔。
/// 向历史回溯的最大范围(分钟)
/// 覆盖 Feb 29 + 特定星期等罕见组合的最长间隔
const MAX_BACKDATE_MINUTES: i64 = 40 * 366 * 24 * 60;
/// 已解析并校验的备份计划。
/// 已解析并校验的备份计划
#[derive(Debug, Clone)]
pub struct BackupSchedule {
schedule: Schedule,
@@ -21,43 +22,44 @@ pub struct BackupSchedule {
}
impl BackupSchedule {
/// 解析并校验 5 段 Cron 与 IANA 时区。
/// 按部署时区解析并校验 5 段 Cron
///
/// # Errors
///
/// Cron 非法或时区不是 IANA 名称时返回 [`BackupError`]。
pub fn parse(cron_expression: &str, schedule_timezone: &str) -> Result<Self, BackupError> {
/// Cron 非法时返回 [`BackupError`]
pub fn parse(
cron_expression: &str,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<Self, BackupError> {
let expression = format!("0 {cron_expression}");
let schedule = Schedule::from_str(&expression).map_err(|_| {
BackupError::new(code::INVALID_CRON, "Cron 表达式必须是 5 段格式".to_owned())
})?;
let timezone = schedule_timezone.parse::<Tz>().map_err(|_| {
BackupError::new(code::INVALID_TIMEZONE, "时区必须是 IANA 名称".to_owned())
})?;
Ok(Self { schedule, timezone })
Ok(Self {
schedule,
timezone: timezone.iana(),
})
}
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间。
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间
///
/// 若过去 `MAX_BACKDATE_MINUTES` 内没有任何触发(极罕见的稀疏计划或超长停机),
/// 返回 `None`,调用方不应为历史点补任务。
/// 返回 `None`,调用方不应为历史点补任务
#[must_use]
pub fn last_firing_at_or_before(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
let high_min = at.timestamp().div_euclid(60);
let low_min = high_min.saturating_sub(MAX_BACKDATE_MINUTES);
// 检查窗口内是否存在触发;不存在则直接返回 None。
// 检查窗口内是否存在触发;不存在则直接返回 None
let low_instant = instant(low_min, self.timezone)?;
if self
.schedule
.after(&low_instant)
.next()
.next_after(low_instant.to_utc())
.is_none_or(|firing| firing.timestamp() > high_min * 60 + 59)
{
return None;
}
// 二分查找最小的 u(分钟)使 after(u) > high;prev = after(u - 1 分钟)。
// 二分查找最小的 u(分钟)使 after(u) > high;prev = after(u - 1 分钟)
let mut lo = low_min;
let mut hi = high_min;
while lo < hi {
@@ -76,54 +78,56 @@ impl BackupSchedule {
.and_then(|firing| Utc.timestamp_opt(firing * 60, 0).single())
}
/// 返回第一个严格晚于 `at` 的触发时间。
/// 返回第一个严格晚于 `at` 的触发时间
#[must_use]
pub fn next_after(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
let local = at.with_timezone(&self.timezone);
self.schedule
.after(&local)
.next()
.map(|firing| firing.with_timezone(&Utc))
let local = at.with_timezone(&self.timezone).naive_local().and_utc();
self.schedule.after(&local).find_map(|firing| {
let instant = self
.timezone
.from_local_datetime(&firing.naive_utc())
.earliest()?
.to_utc();
(instant > at).then_some(instant)
})
}
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索。
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索
fn firing_after(&self, minute_index: i64) -> Option<i64> {
let instant = instant(minute_index, self.timezone)?;
self.schedule
.after(&instant)
.next()
let instant = instant(minute_index, self.timezone)?.to_utc();
self.next_after(instant)
.map(|firing| firing.timestamp().div_euclid(60))
}
}
/// 构造指定分钟索引(UTC epoch 分钟)在目标时区下的 `DateTime`。
/// 构造指定分钟索引(UTC epoch 分钟)在目标时区下的 `DateTime`
fn instant(minute_index: i64, timezone: Tz) -> Option<DateTime<Tz>> {
timezone.timestamp_opt(minute_index * 60, 0).single()
}
/// 一条记录被纳入删除流程的原因。
/// 一条记录被纳入删除流程的原因
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetentionReason {
/// 超过 `retention_days`。
/// 超过 `retention_days`
Days,
/// 超过 `retention_count`。
/// 超过 `retention_count`
Count,
}
/// 保留决策。
/// 保留决策
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetentionDecision {
pub record_id: String,
pub reason: RetentionReason,
}
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录
///
/// 自动保留规则:
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
/// - 自动计划备份满足任一启用阈值即可进入删除。
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理。
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理
/// - 自动计划备份满足任一启用阈值即可进入删除
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理
#[must_use]
pub fn decide_retention(
retention_days: u32,
@@ -136,7 +140,7 @@ pub fn decide_retention(
if record.status != BackupStatus::Completed {
continue;
}
// 最近一个成功的计划备份永远保留。
// 最近一个成功的计划备份永远保留
if index == 0 {
continue;
}
+99 -53
View File
@@ -1,7 +1,8 @@
//! Backup Worker 贡献:单个可取消 `DaemonTask` 承担调度、执行、删除收敛与保留清理。
//! Backup Worker 贡献:单个可取消 `DaemonTask` 承担调度、执行、删除收敛与保留清理
//!
//! 当前部署边界是单副本,因此不需要 Redis lease、fencing token 或 heartbeat。长时间
//! `pg_dump` 与上传由本 Daemon 自身持有;Host 只负责 panic 后重启、健康与关闭。
//! 当前部署边界是单副本,因此不需要 Redis lease、fencing token 或 heartbeat
//! 长时间
//! `pg_dump` 与上传由本 Daemon 自身持有;Host 只负责 panic 后重启、健康与关闭
use std::sync::Arc;
use std::time::Duration;
@@ -27,30 +28,31 @@ use crate::{
impl From<BackupError> for WorkerTaskError {
fn from(error: BackupError) -> Self {
WorkerTaskError::safe(error.message())
WorkerTaskError::safe(error.message()).with_source(error)
}
}
use super::policy::{BackupSchedule, decide_retention};
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待。
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待
const BACKUP_LOOP_INTERVAL: Duration = Duration::from_secs(30);
/// 每个循环最多执行的保留删除数量。
/// 每个循环最多执行的保留删除数量
const RETENTION_BATCH_SIZE: usize = 10;
/// 保留扫描一次读取的 completed 计划备份上限。
/// 保留扫描一次读取的 completed 计划备份上限
const RETENTION_SCAN_LIMIT: u32 = 1000;
/// 每个循环最多完成的待删除记录数量。
/// 每个循环最多完成的待删除记录数量
const PENDING_DELETION_BATCH: u32 = 20;
/// 备份 Daemon 任务。
/// 备份 Daemon 任务
pub struct BackupTask {
timezone: gateway_core::time::DeploymentTimeZone,
repository: Arc<dyn BackupRepository>,
dump: Arc<dyn DatabaseDumpPort>,
object_store: Arc<dyn BackupObjectStorePort>,
}
impl BackupTask {
/// 组合仓储、导出器与对象存储适配器。
/// 组合仓储、导出器与对象存储适配器
#[must_use]
pub fn new(
repository: Arc<dyn BackupRepository>,
@@ -58,11 +60,18 @@ impl BackupTask {
object_store: Arc<dyn BackupObjectStorePort>,
) -> Self {
Self {
timezone: Default::default(),
repository,
dump,
object_store,
}
}
#[must_use]
pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self {
self.timezone = timezone;
self
}
}
impl DaemonTask for BackupTask {
@@ -86,13 +95,13 @@ impl BackupTask {
}
}
/// 执行一个周期:推进计划、恢复中间态、删除收敛、领取执行一个任务、保留清理。
/// 执行一个周期:推进计划、恢复中间态、删除收敛、领取执行一个任务、保留清理
///
/// 对外暴露以便集成测试逐周期驱动;daemon 循环内部复用。
/// 对外暴露以便集成测试逐周期驱动;daemon 循环内部复用
///
/// # Errors
///
/// 仓储或基础设施不可用、任务执行失败时返回 [`WorkerTaskError`]。
/// 仓储或基础设施不可用、任务执行失败时返回 [`WorkerTaskError`]
pub async fn run_cycle(&self, cancellation: &CancellationToken) -> Result<(), WorkerTaskError> {
if cancellation.is_cancelled() {
return Ok(());
@@ -105,29 +114,52 @@ impl BackupTask {
Ok(())
}
/// 推进到期计划并幂等插入 scheduled 任务。
/// 推进到期计划并幂等插入 scheduled 任务
async fn advance_schedule(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
let settings = self.repository.load_settings().await.map_err(repo_error)?;
if !settings.schedule_enabled {
return Ok(());
}
let cron = settings.cron_expression.as_deref().unwrap_or_default();
let timezone = settings.schedule_timezone.as_deref().unwrap_or_default();
if cron.is_empty() || timezone.is_empty() {
let timezone = self.timezone.name();
if cron.is_empty() {
return Ok(());
}
let schedule = BackupSchedule::parse(cron, timezone)
.map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?;
let schedule = BackupSchedule::parse(cron, self.timezone).map_err(|source| {
WorkerTaskError::safe("backup schedule is invalid").with_source(source)
})?;
if settings.schedule_timezone.as_deref() != Some(timezone) {
// 时区切换只初始化未来游标,不把旧时区计划当作应补跑任务
if let Some(next_run_at) = schedule.next_after(now) {
self.repository
.advance_schedule_cursor(
next_run_at,
cron,
settings.schedule_timezone.as_deref(),
settings.next_run_at,
timezone,
)
.await
.map_err(repo_error)?;
}
return Ok(());
}
let due = match settings.next_run_at {
Some(next_run_at) if next_run_at <= now => true,
Some(_) => false,
None => {
// 刚启用计划但尚无游标:直接初始化。
// 刚启用计划但尚无游标:直接初始化
if let Some(next_run_at) = schedule.next_after(now) {
let _ = self
.repository
.advance_schedule_cursor(next_run_at, cron, timezone)
.advance_schedule_cursor(
next_run_at,
cron,
Some(timezone),
settings.next_run_at,
timezone,
)
.await
.map_err(repo_error)?;
}
@@ -141,11 +173,17 @@ impl BackupTask {
let scheduled_at = match schedule.last_firing_at_or_before(now) {
Some(scheduled_at) => scheduled_at,
None => {
// 无法从计划推导最近到期时间:推进游标避免反复触发。
// 无法从计划推导最近到期时间:推进游标避免反复触发
if let Some(next_run_at) = schedule.next_after(now) {
let _ = self
.repository
.advance_schedule_cursor(next_run_at, cron, timezone)
.advance_schedule_cursor(
next_run_at,
cron,
Some(timezone),
settings.next_run_at,
timezone,
)
.await
.map_err(repo_error)?;
}
@@ -168,25 +206,24 @@ impl BackupTask {
.map_err(infra_error)?;
let inserted = self
.repository
.insert_scheduled_record(seed)
.insert_scheduled_record(
seed,
schedule.next_after(now),
cron,
timezone,
settings.next_run_at,
)
.await
.map_err(repo_error)?;
if inserted {
info!(scheduled_at = %scheduled_at, "计划备份任务已创建");
} else {
warn!(scheduled_at = %scheduled_at, "计划时间点冲突,跳过并推进游标");
}
if let Some(next_run_at) = schedule.next_after(now) {
let _ = self
.repository
.advance_schedule_cursor(next_run_at, cron, timezone)
.await
.map_err(repo_error)?;
warn!(scheduled_at = %scheduled_at, "计划已变化或存在冲突,本轮未创建任务");
}
Ok(())
}
/// 恢复中间状态(dumping/uploading/deleting)。
/// 恢复中间状态(dumping/uploading/deleting)
async fn recover_intermediate(
&self,
cancellation: &CancellationToken,
@@ -206,7 +243,7 @@ impl BackupTask {
Ok(())
}
/// dumping:有完整暂存则继续上传,否则清理暂存并标记失败。
/// dumping:有完整暂存则继续上传,否则清理暂存并标记失败
async fn recover_dumping(
&self,
record: &BackupRecord,
@@ -244,7 +281,6 @@ impl BackupTask {
.await?;
}
None => {
let _ = self.dump.cleanup_staging(&record.id).await;
self.fail_task(record, code::PG_DUMP_FAILED, "导出进程中断,暂存归档不完整")
.await?;
}
@@ -252,7 +288,7 @@ impl BackupTask {
Ok(())
}
/// uploading:远端匹配则补记完成,有完整暂存则重试上传,否则失败。
/// uploading:远端匹配则补记完成,有完整暂存则重试上传,否则失败
async fn recover_uploading(
&self,
record: &BackupRecord,
@@ -270,7 +306,8 @@ impl BackupTask {
&& record.sha256.as_deref() == Some(remote.sha256.as_str())
{
let now = Utc::now();
self.repository
if self
.repository
.transition_status(
&record.id,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
@@ -278,7 +315,11 @@ impl BackupTask {
now,
)
.await
.map_err(repo_error)?;
.map_err(repo_error)?
.is_some()
{
self.cleanup_staging(&record.id).await;
}
return Ok(());
}
match self.dump.inspect_staging(&record.id).await? {
@@ -304,7 +345,7 @@ impl BackupTask {
}
}
/// 完成待删除记录(deleting → DeleteObject → 硬删除)。
/// 完成待删除记录(deleting → DeleteObject → 硬删除)
async fn finish_pending_deletions(&self) -> Result<(), WorkerTaskError> {
let settings = self.repository.load_settings().await.map_err(repo_error)?;
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
@@ -331,7 +372,7 @@ impl BackupTask {
Ok(())
}
/// 领取一个 queued 任务并执行导出、上传与远端校验。
/// 领取一个 queued 任务并执行导出、上传与远端校验
async fn process_next_task(
&self,
cancellation: &CancellationToken,
@@ -356,7 +397,7 @@ impl BackupTask {
Ok(())
}
/// 执行单个任务;内部处理全部状态迁移。
/// 执行单个任务;内部处理全部状态迁移
async fn execute_task(
&self,
record: &BackupRecord,
@@ -390,7 +431,7 @@ impl BackupTask {
}
};
// dumping → uploading,持久化 size/sha256。
// dumping → uploading,持久化 size/sha256
let update = StatusTransitionUpdate {
size_bytes: Some(artifact.size_bytes),
sha256: Some(artifact.sha256.clone()),
@@ -418,12 +459,10 @@ impl BackupTask {
now,
cancellation,
)
.await?;
let _ = self.dump.cleanup_staging(&record.id).await;
Ok(())
.await
}
/// 上传 + 远端校验,内部完成终态迁移。
/// 上传 + 远端校验,内部完成终态迁移
async fn upload_and_verify(
&self,
record: &BackupRecord,
@@ -481,6 +520,7 @@ impl BackupTask {
size_bytes,
"备份完成"
);
self.cleanup_staging(&record.id).await;
}
}
Ok(Some(remote)) => {
@@ -528,7 +568,7 @@ impl BackupTask {
Ok(())
}
/// 把活跃任务写入失败终态并清理暂存。
/// 把活跃任务写入失败终态并清理暂存
async fn fail_task(
&self,
record: &BackupRecord,
@@ -559,15 +599,21 @@ impl BackupTask {
)
.await
.map_err(repo_error)?;
let _ = self.dump.cleanup_staging(&record.id).await;
self.cleanup_staging(&record.id).await;
Ok(())
}
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份。
async fn cleanup_staging(&self, backup_id: &str) {
if let Err(error) = self.dump.cleanup_staging(backup_id).await {
warn!(backup_id, error = %error, "清理备份暂存失败");
}
}
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份
async fn run_retention_batch(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
let settings = self.repository.load_settings().await.map_err(repo_error)?;
// 1. expires_at 已到期的记录(手动或计划)无条件进入删除。
// 1. expires_at 已到期的记录(手动或计划)无条件进入删除
let expired = self
.repository
.list_expired_records(RETENTION_BATCH_SIZE as u32)
@@ -585,7 +631,7 @@ impl BackupTask {
self.finalize_deletion(&record).await?;
}
// 2. 计划备份按 retentionDays / retentionCount 清理。
// 2. 计划备份按 retentionDays / retentionCount 清理
let records = self
.repository
.list_scheduled_completed_desc(RETENTION_SCAN_LIMIT)
@@ -611,7 +657,7 @@ impl BackupTask {
Ok(())
}
/// 对已进入 deleting 的记录执行对象删除并硬删除记录行。
/// 对已进入 deleting 的记录执行对象删除并硬删除记录行
async fn finalize_deletion(&self, record: &BackupRecord) -> Result<(), WorkerTaskError> {
let settings = self.repository.load_settings().await.map_err(repo_error)?;
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
@@ -649,9 +695,9 @@ fn status_transition(
}
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
WorkerTaskError::safe(format!("backup store failed: {error}"))
WorkerTaskError::safe("backup store failed").with_source(error)
}
fn infra_error(error: BackupError) -> WorkerTaskError {
WorkerTaskError::safe(error.message())
WorkerTaskError::safe(error.message()).with_source(error)
}
@@ -1,8 +1,9 @@
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调。
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调
//!
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
//! (原子降低并发 / 按冻结代次结束探测)。旧观测不会覆盖管理员操作。
//! (原子降低并发 / 按冻结代次结束探测)
//! 旧观测不会覆盖管理员操作
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -22,21 +23,21 @@ use crate::model::{MutationActor, MutationContext};
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
use crate::use_case::accounts::AccountsService;
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%。
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结。
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
/// worker 周期;冷却观测与探测都依赖该节奏。
/// worker 周期;冷却观测与探测都依赖该节奏
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排。
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
/// 冻结恢复所需的管理端口组合。
/// 冻结恢复所需的管理端口组合
#[derive(Clone)]
pub struct FreezeRecoveryDeps {
pub accounts: Arc<dyn AccountsService>,
@@ -61,17 +62,17 @@ impl FreezeRecoveryTask {
}
}
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻。
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
settings.account_auto_freeze_enabled,
settings.account_auto_freeze_threshold,
settings.account_auto_freeze_window_seconds,
settings.account_auto_freeze_duration_seconds,
settings.account_auto_freeze_probe_enabled,
settings.account_auto_freeze_probe_model,
settings.account_auto_freeze_adaptive_concurrency,
settings.values.account_auto_freeze_enabled,
settings.values.account_auto_freeze_threshold,
settings.values.account_auto_freeze_window_seconds,
settings.values.account_auto_freeze_duration_seconds,
settings.values.account_auto_freeze_probe_enabled,
settings.values.account_auto_freeze_probe_model,
settings.values.account_auto_freeze_adaptive_concurrency,
)
.ok()
}
@@ -95,7 +96,7 @@ impl FreezeRecoveryTask {
}
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
/// 只降不升;未观测到峰值证据的账号保持现状。
/// 只降不升;未观测到峰值证据的账号保持现状
async fn adapt_concurrency_limits(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
@@ -146,7 +147,7 @@ impl FreezeRecoveryTask {
.ok()
}
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束。
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束
async fn recover_due_freezes(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
@@ -179,14 +180,14 @@ impl FreezeRecoveryTask {
return;
};
if !item.account.enabled {
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复。
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复
return;
}
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
return;
};
let Some(model) = self.resolve_probe_model(&account, policy).await else {
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮。
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮
self.postpone(&account, freeze, policy).await;
return;
};
@@ -218,7 +219,7 @@ impl FreezeRecoveryTask {
}
}
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号。
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号
async fn postpone(
&self,
account: &ProviderAccountId,
@@ -281,7 +282,7 @@ impl ScheduledTask for FreezeRecoveryTask {
}
}
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整。
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
if peak_in_flight == 0 {
return None;
@@ -290,7 +291,7 @@ fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
}
/// 消耗连接测试事件流并返回是否以 `Completed` 终止。
/// 消耗连接测试事件流并返回是否以 `Completed` 终止
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
let mut completed = false;
while let Some(event) = events.next().await {
+64 -45
View File
@@ -1,6 +1,6 @@
//! 管理控制面的语义模型、用例与外部能力端口。
//! 管理控制面的语义模型、用例与外部能力端口
//!
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现。
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现
use std::{fmt, path::Path, sync::Arc, time::Duration};
@@ -20,7 +20,7 @@ pub mod backup;
pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod service;
pub mod public_service;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
@@ -70,7 +70,7 @@ const WEAK_ADMIN_PASSWORDS: &[&str] = &[
const BACKUP_WORKER_OWNER: &str = "backup";
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
/// 只用于首次幂等创建默认管理员的启动密码。
/// 只用于首次幂等创建默认管理员的启动密码
#[derive(Clone, Deserialize)]
#[serde(transparent)]
pub struct InitialAdminPassword(SecretString);
@@ -100,15 +100,21 @@ impl fmt::Debug for InitialAdminPassword {
}
}
/// 管理控制面的启动配置。
/// 管理控制面的启动配置
#[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
#[serde(default = "default_absolute_session_ttl_minutes")]
pub session_absolute_ttl_minutes: u64,
pub default_username: String,
pub default_password: InitialAdminPassword,
}
/// Client 登录域的通用启动配置。
fn default_absolute_session_ttl_minutes() -> u64 {
30 * 24 * 60
}
/// Client 登录域的通用启动配置
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ClientConfig {
pub session_ttl_minutes: u64,
@@ -123,11 +129,11 @@ impl Default for ClientConfig {
}
impl ClientConfig {
/// 校验 Client session TTL;当前配置不含相对路径。
/// 校验 Client session TTL;当前配置不含相对路径
///
/// # Errors
///
/// 会话有效期为零或无法安全换算时返回错误。
/// 会话有效期为零或无法安全换算时返回错误
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
@@ -137,11 +143,11 @@ impl ClientConfig {
}
impl AdminConfig {
/// 校验 Admin-owned 字段;当前配置不含相对路径。
/// 校验 Admin-owned 字段;当前配置不含相对路径
///
/// # Errors
///
/// 用户名、会话有效期或初始密码不满足安全约束时返回错误。
/// 用户名、会话有效期或初始密码不满足安全约束时返回错误
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
if self.default_username.trim().is_empty()
|| self.default_username.chars().any(char::is_control)
@@ -151,6 +157,13 @@ impl AdminConfig {
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
return Err(AdminConfigError::InvalidField("admin.session_ttl_minutes"));
}
if self.session_absolute_ttl_minutes == 0
|| i64::try_from(self.session_absolute_ttl_minutes).is_err()
{
return Err(AdminConfigError::InvalidField(
"admin.session_absolute_ttl_minutes",
));
}
let password = self.default_password.expose().trim();
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|| password.contains('$')
@@ -167,13 +180,17 @@ impl fmt::Debug for AdminConfig {
formatter
.debug_struct("AdminConfig")
.field("session_ttl_minutes", &self.session_ttl_minutes)
.field(
"session_absolute_ttl_minutes",
&self.session_absolute_ttl_minutes,
)
.field("default_username", &self.default_username)
.field("default_password", &"[REDACTED]")
.finish()
}
}
/// Admin-owned 启动配置错误;不回显任何配置值。
/// Admin-owned 启动配置错误;不回显任何配置值
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AdminConfigError {
#[error("配置字段 `{0}` 不合法")]
@@ -182,12 +199,13 @@ pub enum AdminConfigError {
WeakInitialPassword,
}
/// API 持有的管理资源能力集合。
/// API 持有的管理资源能力集合
///
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块
#[derive(Clone)]
pub struct AdminServices {
public_services: Arc<service::Registry>,
timezone: gateway_core::time::DeploymentTimeZone,
public_services: Arc<public_service::Registry>,
plugins: Arc<PluginsService>,
plugin_management: Arc<PluginManagementService>,
proxies: Arc<dyn ProxiesService>,
@@ -201,13 +219,19 @@ pub struct AdminServices {
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
credentials: Arc<CredentialsService>,
plugin_accounts: Arc<dyn PluginAccountAccess>,
// Runtime 只持有 Weak,这个强引用保障账号端口与 Admin 服务同寿命
_plugin_accounts: Arc<dyn PluginAccountAccess>,
backups: Arc<dyn BackupService>,
import_tasks: Arc<dyn ImportTasksService>,
}
impl AdminServices {
pub fn public_services(&self) -> Arc<service::Registry> {
#[must_use]
pub const fn timezone(&self) -> gateway_core::time::DeploymentTimeZone {
self.timezone
}
pub fn public_services(&self) -> Arc<public_service::Registry> {
self.public_services.clone()
}
@@ -231,12 +255,6 @@ impl AdminServices {
self.key_usage.as_ref()
}
/// 取得账号服务的共享句柄;后台编排(冻结恢复 worker)需要持有 Arc。
#[must_use]
pub fn accounts_handle(&self) -> Arc<dyn AccountsService> {
Arc::clone(&self.accounts)
}
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
@@ -286,19 +304,13 @@ impl AdminServices {
self.credentials.as_ref()
}
/// Runtime 只持有该窄端口的 Weak;AdminBundle 保持实际生命周期。
#[must_use]
pub fn plugin_accounts_handle(&self) -> Arc<dyn PluginAccountAccess> {
Arc::clone(&self.plugin_accounts)
}
#[must_use]
pub fn backups(&self) -> &dyn BackupService {
self.backups.as_ref()
}
}
/// Admin 初始化完成后的封闭能力包。
/// Admin 初始化完成后的封闭能力包
pub struct AdminBundle {
services: AdminServices,
worker_contributions: Vec<WorkerContribution>,
@@ -310,15 +322,16 @@ impl AdminBundle {
self.services.clone()
}
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
std::mem::take(&mut self.worker_contributions)
}
}
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入
pub struct AdminRuntimePorts {
pub service_middleware: service::PlanSource,
pub timezone: gateway_core::time::DeploymentTimeZone,
pub service_middleware: public_service::PlanSource,
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
@@ -334,11 +347,11 @@ pub struct AdminRuntimePorts {
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化。
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化
///
/// # Errors
///
/// 配置非法或默认管理员初始化失败时返回错误。
/// 配置非法或默认管理员初始化失败时返回错误
pub async fn initialize(
config: AdminConfig,
client_config: ClientConfig,
@@ -348,7 +361,7 @@ pub async fn initialize(
initialize_inner(config, client_config, store, runtime, None).await
}
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例。
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例
pub async fn initialize_with_plugin_accounts(
config: AdminConfig,
client_config: ClientConfig,
@@ -367,6 +380,7 @@ async fn initialize_inner(
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
timezone,
service_middleware,
plugin_preparation,
plugin_management,
@@ -393,6 +407,7 @@ async fn initialize_inner(
let auth = Arc::new(DefaultAuthService::new(
config.default_username,
config.session_ttl_minutes,
config.session_absolute_ttl_minutes,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier.clone(),
@@ -413,16 +428,17 @@ async fn initialize_inner(
backup_ports.object_store(),
store.auth(),
snapshot.clone(),
timezone,
));
let backup_task = backup::task::BackupTask::new(
backup_ports.repository(),
backup_ports.dump(),
backup_ports.object_store(),
);
)
.with_timezone(timezone);
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
store.plugins(),
plugin_inspector.clone(),
snapshot.clone(),
));
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
@@ -431,6 +447,7 @@ async fn initialize_inner(
store.client_keys(),
store.observability(),
system.clone(),
timezone,
));
let credentials = Arc::new(CredentialsService::new(
registry.clone(),
@@ -449,9 +466,10 @@ async fn initialize_inner(
registry.clone(),
pricing_source,
);
let mut public_services = service::Registry::new(service_middleware);
let mut public_services = public_service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
let services = AdminServices {
timezone,
public_services: Arc::new(public_services),
plugin_management: Arc::new(PluginManagementService::new(
plugin_management,
@@ -492,11 +510,12 @@ async fn initialize_inner(
store.accounts(),
store.settings(),
registry.clone(),
timezone,
)),
settings,
system,
credentials,
plugin_accounts,
_plugin_accounts: plugin_accounts,
import_tasks,
backups,
};
@@ -528,7 +547,7 @@ async fn initialize_inner(
})
}
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计。
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计
pub fn initialize_plugin_accounts(
providers: ports::provider::ProviderAdminRegistry,
accounts: Arc<dyn ports::store::AccountStore>,
@@ -539,7 +558,7 @@ pub fn initialize_plugin_accounts(
))
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口。
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
providers: ports::provider::ProviderAdminRegistry,
@@ -551,7 +570,7 @@ pub fn initialize_plugin_client_keys(
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
}
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核。
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核
#[must_use]
pub fn initialize_plugin_resources(
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
@@ -560,7 +579,7 @@ pub fn initialize_plugin_resources(
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`
fn backup_worker_contribution(
task: backup::task::BackupTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
@@ -579,7 +598,7 @@ fn backup_worker_contribution(
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定。
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定
fn freeze_recovery_worker_contribution(
task: freeze_recovery::FreezeRecoveryTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
@@ -610,7 +629,7 @@ fn freeze_recovery_worker_contribution(
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布。
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
@@ -1,13 +1,33 @@
//! Provider-neutral account group commands and projections.
//! 跨 Provider 账号分组的管理命令与查询投影
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use gateway_core::{account::AccountStatusFacts, routing::AccountGroupId};
use gateway_core::{
account::{AccountStatusFacts, FastMode},
routing::AccountGroupId,
};
use super::{PageSize, Revision, observability::DecimalAmount};
/// Canonical `#RRGGBBAA` color persisted with an account group.
/// 名称与描述的共同约束,HTTP、用例和存储边界只映射错误
pub fn validate_group_fields(
name: &str,
description: Option<&str>,
) -> Result<(), super::AdminModelError> {
if name.trim() != name
|| name.is_empty()
|| name.chars().count() > 100
|| name.chars().any(char::is_control)
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(super::AdminModelError::InvalidAccountGroupFields);
}
Ok(())
}
/// 账号分组持久化使用的标准 `#RRGGBBAA` 颜色
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupColor(String);
@@ -26,7 +46,7 @@ impl AccountGroupColor {
}
}
/// Group member availability at the current observation time.
/// 当前观测时刻的分组成员可用性
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountGroupAccountSummary {
pub available: u64,
@@ -34,23 +54,23 @@ pub struct AccountGroupAccountSummary {
pub total: u64,
}
/// Group scheduling slots derived from available accounts and runtime leases.
/// 根据可用账号与运行时租约计算的分组调度槽位
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountGroupCapacity {
pub used_slots: Option<u64>,
/// `None` 表示可用账号中存在不限制并发的账号。
/// `None` 表示可用账号中存在不限制并发的账号
pub total_slots: Option<u64>,
}
/// Successful, downstream-committed USD request costs for group accounts.
/// 分组账号成功且已向下游交付的请求美元费用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupUsage {
pub today_usd: DecimalAmount,
/// 当前 usage retention 窗口内、按请求发生时 routing group 快照归属的累计成本。
/// 当前 usage retention 窗口内、按请求发生时 routing group 快照归属的累计成本
pub retained_total_usd: DecimalAmount,
}
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并。
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupMemberFact {
pub group_id: AccountGroupId,
@@ -59,7 +79,7 @@ pub struct AccountGroupMemberFact {
pub total_slots: Option<u64>,
}
/// Lightweight group reference embedded in account and client-key views.
/// 账号与 Client Key 视图内嵌的轻量分组引用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRef {
pub id: AccountGroupId,
@@ -68,7 +88,7 @@ pub struct AccountGroupRef {
pub enabled: bool,
}
/// Account group list query.
/// 账号分组列表查询
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupListQuery {
pub page: u32,
@@ -77,10 +97,10 @@ pub struct AccountGroupListQuery {
pub enabled: Option<bool>,
}
/// Complete account group summary.
/// 完整账号分组摘要
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRecord {
pub disable_fast: bool,
pub fast_mode: FastMode,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -96,7 +116,7 @@ pub struct AccountGroupRecord {
pub updated_at: DateTime<Utc>,
}
/// Paginated account groups.
/// 账号分组分页结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupPage {
pub config_revision: Revision,
@@ -106,49 +126,59 @@ pub struct AccountGroupPage {
pub page_size: u16,
}
/// Create an account group.
/// 分组选择器使用的轻量分页结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupOptionsPage {
pub config_revision: Revision,
pub items: Vec<AccountGroupRef>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
/// 创建账号分组
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAccountGroup {
pub disable_fast: bool,
pub fast_mode: FastMode,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
}
/// Store-ready create command with a generated stable ID.
/// 已生成稳定标识、可直接提交存储的创建命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewAccountGroup {
pub disable_fast: bool,
pub fast_mode: FastMode,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
}
/// Update an account group's descriptive fields.
/// 更新账号分组的描述字段
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccountGroup {
pub disable_fast: Option<bool>,
pub fast_mode: Option<FastMode>,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
}
/// Enable or disable an account group.
/// 启用或停用账号分组
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetAccountGroupEnabled {
pub id: AccountGroupId,
pub enabled: bool,
}
/// Delete an account group that is not referenced by a client key.
/// 删除未被 Client Key 引用的账号分组
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAccountGroup {
pub id: AccountGroupId,
}
/// Result of any account-group mutation.
/// 账号分组变更结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupMutation {
pub config_revision: Revision,
@@ -1,4 +1,4 @@
//! 多 Provider 账号目录与连接测试的公共事实。
//! 多 Provider 账号目录与连接测试的公共事实
use std::{collections::BTreeMap, pin::Pin};
@@ -18,7 +18,7 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 账号可用的管理操作。
/// 账号可用的管理操作
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ProviderAccountCapabilities {
pub quota: bool,
@@ -30,7 +30,7 @@ pub struct ProviderAccountCapabilities {
pub consume_reset_credit: bool,
}
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub notes: Option<String>,
@@ -41,7 +41,7 @@ pub struct AccountImportSettings {
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
/// 账号列表排序字段。
/// 账号列表排序字段
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountSortField {
Email,
@@ -52,21 +52,21 @@ pub enum AccountSortField {
ExpiresAt,
}
/// 账号列表排序方向。
/// 账号列表排序方向
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Desc,
}
/// 一组完整的账号排序规则。
/// 一组完整的账号排序规则
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSort {
pub field: AccountSortField,
pub direction: SortDirection,
}
/// 账号列表的存储查询条件。
/// 账号列表的存储查询条件
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountListQuery {
pub page: u32,
@@ -78,15 +78,15 @@ pub struct AccountListQuery {
pub sort: Option<AccountSort>,
}
/// Admin query service 从运行态存储取得的当前账号冷却快照。
/// Admin query service 从运行态存储取得的当前账号冷却快照
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountRuntimeSnapshot {
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零
pub in_flight: Option<BTreeMap<String, u64>>,
}
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结。
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountFreeze {
pub credential_revision: Revision,
@@ -95,14 +95,14 @@ pub struct AccountFreeze {
pub requires_probe: bool,
}
/// Optional account membership filter.
/// 可选的账号分组成员筛选条件
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountGroupFilter {
Group(gateway_core::routing::AccountGroupId),
Ungrouped,
}
/// 账号公共存储投影;Provider 专属字段不进入此结构。
/// 账号公共存储投影;Provider 专属字段不进入此结构
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub id: String,
@@ -133,42 +133,12 @@ pub struct AccountRecord {
pub updated_at: DateTime<Utc>,
}
/// 单一货币的账号成本聚合。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountCost {
pub currency: String,
pub amount: super::observability::DecimalAmount,
}
pub use super::observability::{
AccountModelUsage, AccountPoolMetrics as AccountSummary, AccountRequestBucket,
CurrencyCost as AccountCost,
};
/// 账号在一个模型上的历史用量。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountModelUsage {
pub model: String,
pub request_count: u64,
pub success_count: u64,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub image_input_tokens: Option<u64>,
pub image_output_tokens: Option<u64>,
pub image_request_count: u64,
pub image_request_failed_count: u64,
pub total_tokens: Option<u64>,
pub cost_coverage: super::observability::CostCoverage,
pub costs: Vec<AccountCost>,
pub last_used_at: DateTime<Utc>,
}
/// 账号在一个小时窗口内的请求数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
/// 账号历史用量聚合。
/// 账号历史用量聚合
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUsage {
pub account_id: String,
@@ -191,9 +161,9 @@ pub struct AccountUsage {
pub models: Vec<AccountModelUsage>,
}
/// 某个账号在调用方指定时间窗口内的本地用量查询。
/// 某个账号在调用方指定时间窗口内的本地用量查询
///
/// `key` 只用于把聚合结果关联回调用方的窗口,不承载 Provider 私有语义。
/// `key` 只用于把聚合结果关联回调用方的窗口,不承载 Provider 私有语义
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUsageWindowQuery {
pub account_id: String,
@@ -201,7 +171,7 @@ pub struct AccountUsageWindowQuery {
pub range: TimeRange,
}
/// 一个账号时间窗口用量查询的结果。
/// 一个账号时间窗口用量查询的结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUsageWindowResult {
pub account_id: String,
@@ -209,7 +179,7 @@ pub struct AccountUsageWindowResult {
pub usage: AccountUsage,
}
/// 账号列表页所需的完整存储事实。
/// 账号列表页所需的完整存储事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountPage {
pub config_revision: Revision,
@@ -218,7 +188,7 @@ pub struct AccountPage {
pub summary: AccountSummary,
}
/// 同一状态快照下的账号事实与唯一状态投影。
/// 同一状态快照下的账号事实与唯一状态投影
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountPageItem {
pub account: AccountRecord,
@@ -226,33 +196,20 @@ pub struct AccountPageItem {
pub capacity: AccountCapacity,
}
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制。
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountCapacity {
/// 实时租约读取失败时为 `None`,不能当作空闲。
/// 实时租约读取失败时为 `None`,不能当作空闲
pub used_slots: Option<u64>,
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限。
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限
pub total_slots: Option<u64>,
}
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
///
/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSummary {
pub total: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
/// 账号可编辑事实的一次性替换命令。
/// 账号可编辑事实的一次性替换命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
pub account_id: String,
/// 缺省保留备注;空字符串清空备注。
/// 缺省保留备注;空字符串清空备注
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
@@ -262,14 +219,14 @@ pub struct UpdateAccount {
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 账号更新结果。
/// 账号更新结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUpdateResult {
pub config_revision: Revision,
pub account_id: gateway_core::account::ProviderAccountId,
}
/// 仅修改显式字段的批量账号设置命令。
/// 仅修改显式字段的批量账号设置命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchUpdateAccounts {
pub account_ids: Vec<String>,
@@ -281,20 +238,20 @@ pub struct BatchUpdateAccounts {
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 批量账号更新结果。
/// 批量账号更新结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountsUpdateResult {
pub config_revision: Revision,
pub account_ids: Vec<gateway_core::account::ProviderAccountId>,
}
/// 账号批量删除命令。
/// 账号批量删除命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAccounts {
pub account_ids: Vec<String>,
}
/// 账号连接测试的语义事件。
/// 账号连接测试的语义事件
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountConnectionTestEvent {
Started {
@@ -323,6 +280,6 @@ pub enum AccountConnectionTestEvent {
},
}
/// 每次连接测试独占的有限事件流。
/// 每次连接测试独占的有限事件流
pub type AccountConnectionTestEventStream =
Pin<Box<dyn Stream<Item = AccountConnectionTestEvent> + Send + 'static>>;
@@ -1,4 +1,4 @@
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识
use super::{
MutationActor, MutationContext,
@@ -112,7 +112,7 @@ impl MutationAuditOperation {
}
}
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐
#[derive(Debug, Clone, Copy)]
pub struct MutationAuditIntent<'a> {
pub operation: MutationAuditOperation,
+14 -13
View File
@@ -1,30 +1,30 @@
//! 控制面认证、统一会话和管理员安全审计事实。
//! 控制面认证、统一会话和管理员安全审计事实
use chrono::{DateTime, Utc};
use gateway_core::{engine::execution::ClientAuthenticationError, policy::ClientApiKeyId};
use super::{MutationActor, MutationContext, Revision};
/// 会话审计使用的稳定管理员标识。
/// 会话审计使用的稳定管理员标识
#[must_use]
pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
format!("admin:{admin_user_id}")
}
/// 已认证的管理主体。
/// 已认证的管理主体
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminPrincipal {
Session {
admin_user_id: String,
},
ApiKey,
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发。
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发
Plugin {
instance_id: String,
},
}
/// 传给管理用例的安全请求上下文。
/// 传给管理用例的安全请求上下文
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminRequestContext {
pub principal: AdminPrincipal,
@@ -48,7 +48,7 @@ impl AdminRequestContext {
}
}
/// 登录方式只决定凭据验证流程,不能直接授予会话权限。
/// 登录方式只决定凭据验证流程,不能直接授予会话权限
#[derive(Clone, PartialEq, Eq)]
pub enum LoginCommand {
Admin {
@@ -76,7 +76,7 @@ impl std::fmt::Debug for LoginCommand {
}
}
/// 登录成功后返回的会话事实。
/// 登录成功后返回的会话事实
#[derive(Clone, PartialEq, Eq)]
pub struct LoginResult {
pub session_id: String,
@@ -93,7 +93,7 @@ impl std::fmt::Debug for LoginResult {
}
}
/// 登录状态机可被 API 精确映射的失败类型。
/// 登录状态机可被 API 精确映射的失败类型
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum LoginError {
#[error("invalid login credentials")]
@@ -114,7 +114,7 @@ impl From<ClientAuthenticationError> for LoginError {
}
}
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
/// 服务端已验证的身份绑定,不保存密码或原始 API Key
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSubject {
Admin {
@@ -126,7 +126,7 @@ pub enum SessionSubject {
},
}
/// 仅已登录管理员可以修改自己的密码。
/// 仅已登录管理员可以修改自己的密码
pub struct ChangePassword {
pub current_password: String,
pub new_password: String,
@@ -138,14 +138,15 @@ impl std::fmt::Debug for ChangePassword {
}
}
/// 两种登录方式共用的固定有效期会话。
/// 会话有效期与管理员续期上限;没有上限字段的旧会话保持固定有效期
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSession {
pub subject: SessionSubject,
pub expires_at: DateTime<Utc>,
pub absolute_expires_at: Option<DateTime<Utc>>,
}
/// 安全审计事件类型。
/// 安全审计事件类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditActorKind {
AdminSession,
@@ -154,7 +155,7 @@ pub enum AuditActorKind {
Anonymous,
}
/// 管理写操作留下的最小审计事实。
/// 管理写操作留下的最小审计事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminAuditEvent {
pub id: String,
@@ -1,34 +1,33 @@
//! 备份领域的稳定错误码。
//! 备份领域的稳定错误码
/// 配置或参数非法。
/// 配置或参数非法
pub const INVALID_CONFIG: &str = "backup.invalid_config";
/// Cron 表达式非法。
/// Cron 表达式非法
pub const INVALID_CRON: &str = "backup.invalid_cron";
/// IANA 时区非法。
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
/// 记录不存在。
/// IANA 时区非法
/// 记录不存在
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
/// 已存在活跃任务,拒绝创建。
/// 已存在活跃任务,拒绝创建
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
/// 记录状态与期望不符,无法迁移。
/// 记录状态与期望不符,无法迁移
pub const STATE_CONFLICT: &str = "backup.state_conflict";
/// 已有记录时禁止切换存储目标。
/// 已有记录时禁止切换存储目标
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
/// `pg_dump` 非零退出或失败。
/// `pg_dump` 非零退出或失败
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
/// 暂存磁盘空间不足。
/// 暂存磁盘空间不足
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
/// S3 认证失败。
/// S3 认证失败
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
/// S3 权限拒绝。
/// S3 权限拒绝
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
/// S3 上传失败。
/// S3 上传失败
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
/// 远端对象校验失败。
/// 远端对象校验失败
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
/// 任务被取消。
/// 任务被取消
pub const CANCELLED: &str = "backup.cancelled";
/// 仓储或对象存储暂不可用。
/// 仓储或对象存储暂不可用
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
/// 上游 S3 服务返回无效或失败响应。
/// 上游 S3 服务返回无效或失败响应
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
@@ -1,8 +1,10 @@
//! S3 备份领域模型、状态机与稳定错误。
//! S3 备份领域模型、状态机与稳定错误
//!
//! 领域类型只携带事实,不承载基础设施细节。Secret 使用 [`secrecy::SecretString`],
//! 全流程不实现 `Serialize`,避免被错误地写入 wire 或日志。管理员身份等审计事实
//! 只存在于 `admin_audit_events`,本模型不重复保存。
//! 领域类型只携带事实,不承载基础设施细节
//! Secret 使用 [`secrecy::SecretString`],
//! 全流程不实现 `Serialize`,避免被错误地写入 wire 或日志
//! 管理员身份等审计事实
//! 只存在于 `admin_audit_events`,本模型不重复保存
use std::fmt;
@@ -12,7 +14,7 @@ use uuid::Uuid;
use super::PageSize;
/// 备份触发来源。
/// 备份触发来源
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackupTriggerKind {
Manual,
@@ -20,7 +22,7 @@ pub enum BackupTriggerKind {
}
impl BackupTriggerKind {
/// 稳定持久化值。
/// 稳定持久化值
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
@@ -29,7 +31,7 @@ impl BackupTriggerKind {
}
}
/// 解析持久化值。
/// 解析持久化值
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
@@ -46,11 +48,11 @@ impl fmt::Display for BackupTriggerKind {
}
}
/// 备份任务状态机(六状态)。
/// 备份任务状态机(六状态)
///
/// 迁移 `backup_records_lifecycle_ck` 与字段空值语义共同定义合法持久化行;
/// [`Self::allows_transition_to`] 只约束领域内的显式状态迁移,两层都必须满足。
/// 删除成功后记录行被硬删除,因此不存在 `deleted` 终态。
/// [`Self::allows_transition_to`] 只约束领域内的显式状态迁移,两层都必须满足
/// 删除成功后记录行被硬删除,因此不存在 `deleted` 终态
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BackupStatus {
Queued,
@@ -62,7 +64,7 @@ pub enum BackupStatus {
}
impl BackupStatus {
/// 稳定持久化值。
/// 稳定持久化值
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
@@ -75,7 +77,7 @@ impl BackupStatus {
}
}
/// 解析持久化值。
/// 解析持久化值
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
@@ -89,19 +91,19 @@ impl BackupStatus {
}
}
/// 该状态是否消耗全局活跃名额。
/// 该状态是否消耗全局活跃名额
#[must_use]
pub const fn is_active(self) -> bool {
matches!(self, Self::Queued | Self::Dumping | Self::Uploading)
}
/// 该状态是否允许进入删除流程。
/// 该状态是否允许进入删除流程
#[must_use]
pub const fn can_be_deleted(self) -> bool {
matches!(self, Self::Completed | Self::Failed)
}
/// 领域内允许的显式迁移。
/// 领域内允许的显式迁移
#[must_use]
pub fn allows_transition_to(self, target: BackupStatus) -> bool {
matches!(
@@ -124,7 +126,7 @@ impl fmt::Display for BackupStatus {
}
}
/// 已通过领域状态机校验的显式迁移。
/// 已通过领域状态机校验的显式迁移
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackupStatusTransition {
from: BackupStatus,
@@ -132,26 +134,26 @@ pub struct BackupStatusTransition {
}
impl BackupStatusTransition {
/// 校验并构造显式迁移;非法状态边返回 `None`。
/// 校验并构造显式迁移;非法状态边返回 `None`
#[must_use]
pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option<Self> {
from.allows_transition_to(to).then_some(Self { from, to })
}
/// 迁移要求的当前状态。
/// 迁移要求的当前状态
#[must_use]
pub const fn from(self) -> BackupStatus {
self.from
}
/// 迁移完成后的目标状态。
/// 迁移完成后的目标状态
#[must_use]
pub const fn to(self) -> BackupStatus {
self.to
}
}
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值
#[derive(Clone)]
pub struct UpdateBackupStorageCommand {
pub endpoint: String,
@@ -178,19 +180,18 @@ impl fmt::Debug for UpdateBackupStorageCommand {
}
}
/// 调度配置更新命令。
/// 调度配置更新命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateBackupScheduleCommand {
pub schedule_enabled: bool,
pub cron_expression: String,
pub schedule_timezone: String,
pub retention_days: u32,
pub retention_count: u32,
}
/// 完整保存的备份设置(含 Secret)。
/// 完整保存的备份设置(含 Secret)
///
/// 只从仓储读取;不实现 `Serialize`,防止 Secret 进入 wire 或日志。
/// 只从仓储读取;不实现 `Serialize`,防止 Secret 进入 wire 或日志
#[derive(Clone)]
pub struct BackupSettings {
pub storage_revision: u64,
@@ -203,6 +204,7 @@ pub struct BackupSettings {
pub force_path_style: bool,
pub schedule_enabled: bool,
pub cron_expression: Option<String>,
/// 记录持久化游标采用的时区,由服务端维护,不是独立部署设置
pub schedule_timezone: Option<String>,
pub retention_days: u32,
pub retention_count: u32,
@@ -236,7 +238,7 @@ impl fmt::Debug for BackupSettings {
}
impl BackupSettings {
/// S3 存储配置是否完整(bucket/region/密钥/前缀齐全)。
/// S3 存储配置是否完整(bucket/region/密钥/前缀齐全)
#[must_use]
pub fn storage_configured(&self) -> bool {
self.endpoint.is_some()
@@ -247,14 +249,14 @@ impl BackupSettings {
&& self.prefix.is_some()
}
/// 探测是否已对当前 revision 成功;`last_verified_at` 非空即表示当前配置已通过探针。
/// 探测是否已对当前 revision 成功;`last_verified_at` 非空即表示当前配置已通过探针
#[must_use]
pub fn storage_verified(&self) -> bool {
self.last_verified_at.is_some()
}
}
/// 已经校验并可用于对象存储操作的存储配置快照。
/// 已经校验并可用于对象存储操作的存储配置快照
#[derive(Clone)]
pub struct BackupStorageConfig {
pub storage_revision: u64,
@@ -284,7 +286,7 @@ impl fmt::Debug for BackupStorageConfig {
}
impl BackupStorageConfig {
/// 从完整设置推导对象存储配置;设置不完整时返回 `None`。
/// 从完整设置推导对象存储配置;设置不完整时返回 `None`
#[must_use]
pub fn from_settings(settings: &BackupSettings) -> Option<Self> {
Some(Self {
@@ -300,7 +302,7 @@ impl BackupStorageConfig {
}
}
/// 一条备份记录。
/// 一条备份记录
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupRecord {
pub id: String,
@@ -315,13 +317,13 @@ pub struct BackupRecord {
pub error_message: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理。
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// 备份记录分页查询。
/// 备份记录分页查询
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupRecordListQuery {
pub page: u32,
@@ -330,7 +332,7 @@ pub struct BackupRecordListQuery {
pub trigger: Option<BackupTriggerKind>,
}
/// 备份记录分页结果。
/// 备份记录分页结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupRecordPage {
pub items: Vec<BackupRecord>,
@@ -340,7 +342,7 @@ pub struct BackupRecordPage {
}
impl BackupRecordPage {
/// 计算总页数;空集合视为一页。
/// 计算总页数;空集合视为一页
#[must_use]
pub fn total_pages(&self) -> u32 {
let size = u64::from(self.page_size.get());
@@ -352,7 +354,7 @@ impl BackupRecordPage {
}
}
/// 创建备份任务的持久化数据;id 与对象 key 由领域生成。
/// 创建备份任务的持久化数据;id 与对象 key 由领域生成
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupRecordSeed {
pub id: String,
@@ -362,7 +364,7 @@ pub struct BackupRecordSeed {
pub expires_at: Option<DateTime<Utc>>,
}
/// 连接测试结果;只携带稳定阶段与脱敏消息。
/// 连接测试结果;只携带稳定阶段与脱敏消息
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectionTestResult {
pub ok: bool,
@@ -371,7 +373,7 @@ pub struct ConnectionTestResult {
pub message: String,
}
/// 对象存储探测阶段。
/// 对象存储探测阶段
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionTestStage {
PutObject,
@@ -381,7 +383,7 @@ pub enum ConnectionTestStage {
}
impl ConnectionTestStage {
/// 稳定 wire 值。
/// 稳定 wire 值
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
@@ -393,10 +395,10 @@ impl ConnectionTestStage {
}
}
/// 上传到对象存储并期望远端校验的归档 metadata。
/// 上传到对象存储并期望远端校验的归档 metadata
///
/// `size_bytes` 在上传时不写入 metadata,但 `HeadObject` 会返回远端大小,
/// 用于与本地归档校验一致。
/// 用于与本地归档校验一致
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupObjectMetadata {
pub backup_id: String,
@@ -406,7 +408,7 @@ pub struct BackupObjectMetadata {
}
impl BackupObjectMetadata {
/// 构造上传 metadata。
/// 构造上传 metadata
#[must_use]
pub const fn new(
backup_id: String,
@@ -423,7 +425,7 @@ impl BackupObjectMetadata {
}
}
/// 短时下载地址结果。
/// 短时下载地址结果
#[derive(Clone, PartialEq, Eq)]
pub struct DownloadUrlResult {
pub url: String,
@@ -442,10 +444,11 @@ impl fmt::Debug for DownloadUrlResult {
}
}
/// 备份基础设施错误:只携带稳定错误码与脱敏消息。
/// 备份基础设施错误:只携带稳定错误码与脱敏消息
///
/// 该类型不构成第二套 kind 枚举;`gateway-admin` 边界统一映射为既有
/// `AdminErrorKind`。稳定 `error_code` 同时用于任务记录、日志与 UI。
/// `AdminErrorKind`
/// 稳定 `error_code` 同时用于任务记录、日志与 UI
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct BackupError {
@@ -454,32 +457,32 @@ pub struct BackupError {
}
impl BackupError {
/// 构造基础设施错误。
/// 构造基础设施错误
#[must_use]
pub const fn new(code: &'static str, message: String) -> Self {
Self { code, message }
}
/// 稳定错误码,例如 `backup.s3_auth_failed`。
/// 稳定错误码,例如 `backup.s3_auth_failed`
#[must_use]
pub const fn code(&self) -> &'static str {
self.code
}
/// 脱敏后的可操作消息。
/// 脱敏后的可操作消息
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
/// 构造稳定错误码常量。
/// 构造稳定错误码常量
pub mod code;
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`。
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`
///
/// 规范化规则:prefix 不得为空、不得以前导 `/` 开头、不得包含 `..`、反斜杠或控制字符;
/// 输出统一使用 `/` 分隔且不含空段。
/// 输出统一使用 `/` 分隔且不含空段
pub fn build_object_key(
prefix: &str,
backup_id: &str,
@@ -496,22 +499,22 @@ pub fn build_object_key(
))
}
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致。
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致
#[must_use]
pub fn build_download_file_name(backup_id: &str) -> String {
format!("codex-proxy-rs_{}.dump", short_backup_id(backup_id))
}
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex。
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex
#[must_use]
fn short_backup_id(backup_id: &str) -> String {
let hex = backup_id.strip_prefix("backup_").unwrap_or(backup_id);
hex.get(..8).unwrap_or(hex).to_owned()
}
/// 规范化并校验对象 prefix。
/// 规范化并校验对象 prefix
///
/// 返回去掉尾部 `/` 的规范 prefix;空串、前导 `/`、`..` 段、反斜杠和控制字符均拒绝。
/// 返回去掉尾部 `/` 的规范 prefix;空串、前导 `/`、`..` 段、反斜杠和控制字符均拒绝
pub fn normalize_prefix(prefix: &str) -> Result<String, BackupError> {
if prefix.is_empty() || prefix.starts_with('/') {
return Err(BackupError::new(
@@ -534,13 +537,13 @@ pub fn normalize_prefix(prefix: &str) -> Result<String, BackupError> {
Ok(prefix.trim_end_matches('/').to_owned())
}
/// 生成新的备份记录 id。
/// 生成新的备份记录 id
#[must_use]
pub fn new_backup_id() -> String {
format!("backup_{}", Uuid::now_v7().simple())
}
/// 从触发类型与计划时间构造可持久化 seed;id 与对象 key 一次生成保持一致。
/// 从触发类型与计划时间构造可持久化 seed;id 与对象 key 一次生成保持一致
pub fn build_backup_seed(
trigger_kind: BackupTriggerKind,
scheduled_at: Option<DateTime<Utc>>,
@@ -1,8 +1,8 @@
//! 管理设置页使用的 Codex 客户端下载信息。
//! 管理设置页使用的 Codex 客户端下载信息
use chrono::{DateTime, Utc};
/// Windows 安装包架构。
/// Windows 安装包架构
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ClientArchitecture {
X64,
@@ -19,7 +19,7 @@ impl ClientArchitecture {
}
}
/// 下载地址的可信来源。
/// 下载地址的可信来源
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientDownloadSource {
MicrosoftStore,
@@ -36,7 +36,7 @@ impl ClientDownloadSource {
}
}
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientDownloadPackage {
pub architecture: ClientArchitecture,
@@ -48,7 +48,7 @@ pub struct ClientDownloadPackage {
pub expires_at: Option<DateTime<Utc>>,
}
/// Codex Desktop Windows 下载解析结果。
/// Codex Desktop Windows 下载解析结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexDesktopWindowsDownloads {
pub resolved_at: DateTime<Utc>,
@@ -1,4 +1,4 @@
//! Client API Key 的 Command、Result 与安全秘密类型。
//! Client API Key 的 Command、Result 与安全秘密类型
use std::{collections::BTreeMap, fmt, num::NonZeroU16};
@@ -20,16 +20,30 @@ pub type ProviderRequestProfileOverrides =
pub type ProviderRequestProfileOverrideUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
/// Client Key 关联分组的数量与唯一性约束
pub fn validate_group_ids(group_ids: &[AccountGroupId]) -> Result<(), AdminModelError> {
if group_ids.len() > 1000
|| group_ids
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= group_ids.len()
{
return Err(AdminModelError::InvalidClientKeyGroups);
}
Ok(())
}
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClientKeyPageSize(NonZeroU16);
impl ClientKeyPageSize {
/// 创建 1 至 65535 的 Client Key 页大小。
/// 创建 1 至 65535 的 Client Key 页大小
///
/// # Errors
///
/// `value` 为零时返回 [`AdminModelError::InvalidClientKeyPageSize`]。
/// `value` 为零时返回 [`AdminModelError::InvalidClientKeyPageSize`]
pub fn new(value: u16) -> Result<Self, AdminModelError> {
NonZeroU16::new(value)
.map(Self)
@@ -42,7 +56,7 @@ impl ClientKeyPageSize {
}
}
/// Client Key 列表排序字段。
/// Client Key 列表排序字段
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeySortField {
Name,
@@ -51,21 +65,21 @@ pub enum ClientKeySortField {
LastUsedAt,
}
/// Client Key 列表排序方向。
/// Client Key 列表排序方向
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Desc,
}
/// Client Key 列表排序规则。
/// Client Key 列表排序规则
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientKeySort {
pub field: ClientKeySortField,
pub direction: SortDirection,
}
/// 与排序字段绑定的游标值。
/// 与排序字段绑定的游标值
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientKeyCursorValue {
Name(String),
@@ -74,7 +88,7 @@ pub enum ClientKeyCursorValue {
LastUsedAt(Option<DateTime<Utc>>),
}
/// Client Key 的稳定键集游标。
/// Client Key 的稳定键集游标
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyCursor {
pub sort: ClientKeySort,
@@ -82,7 +96,7 @@ pub struct ClientKeyCursor {
pub id: ClientApiKeyId,
}
/// Client Key 列表查询。
/// Client Key 列表查询
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyListQuery {
pub cursor: Option<ClientKeyCursor>,
@@ -91,7 +105,7 @@ pub struct ClientKeyListQuery {
pub sort: ClientKeySort,
}
/// 不含完整明文 Key 的管理投影。
/// 不含完整明文 Key 的管理投影
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub request_profile_overrides: ProviderRequestProfileOverrides,
@@ -109,7 +123,7 @@ pub struct ClientKeyRecord {
pub updated_at: DateTime<Utc>,
}
/// Client Key 列表页。
/// Client Key 列表页
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyPage {
pub config_revision: Revision,
@@ -118,7 +132,7 @@ pub struct ClientKeyPage {
pub next_cursor: Option<ClientKeyCursor>,
}
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文。
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文
#[derive(Clone, PartialEq, Eq)]
pub struct ClientKeySecret {
pub record: ClientKeyRecord,
@@ -150,7 +164,7 @@ impl fmt::Debug for ClientKeySecret {
}
}
/// API 提交的 Client Key 创建命令。
/// API 提交的 Client Key 创建命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub request_profile_overrides: ProviderRequestProfileOverrides,
@@ -162,7 +176,7 @@ pub struct CreateClientKey {
pub budget: ClientBudgetLimits,
}
/// 管理用例生成 ID 与明文后的持久化命令。
/// 管理用例生成 ID 与明文后的持久化命令
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub request_profile_overrides: ProviderRequestProfileOverrides,
@@ -187,7 +201,7 @@ impl fmt::Debug for NewClientKey {
}
}
/// 修改 Client Key 的公开策略字段。
/// 修改 Client Key 的公开策略字段
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
@@ -200,14 +214,14 @@ pub struct UpdateClientKey {
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
}
/// 修改 Client Key 启用状态。
/// 修改 Client Key 启用状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetClientKeyEnabled {
pub id: ClientApiKeyId,
pub enabled: bool,
}
/// 管理员选择的金额重置范围。
/// 管理员选择的金额重置范围
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeyBudgetPeriod {
Daily,
@@ -215,14 +229,14 @@ pub enum ClientKeyBudgetPeriod {
All,
}
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetClientKeyBudget {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetPeriod,
}
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口。
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKeyBudgetLimits {
pub id: ClientApiKeyId,
@@ -230,20 +244,20 @@ pub struct UpdateClientKeyBudgetLimits {
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
}
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化。
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化
#[derive(Debug, Clone)]
pub enum ClientKeyBudgetMutationOrigin {
Admin,
Plugin(PluginResourceOwner),
}
/// 删除 Client Key。
/// 删除 Client Key
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
pub id: ClientApiKeyId,
}
/// Client Key 创建结果;完整明文仅存在于该一次性结果中。
/// Client Key 创建结果;完整明文仅存在于该一次性结果中
pub struct CreatedClientKey {
pub config_revision: Revision,
pub secret: ClientKeySecret,
@@ -259,7 +273,7 @@ impl fmt::Debug for CreatedClientKey {
}
}
/// Client Key 普通写操作结果。
/// Client Key 普通写操作结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyMutation {
pub config_revision: Revision,
@@ -1,4 +1,4 @@
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果。
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果
use chrono::{DateTime, Utc};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
@@ -17,7 +17,7 @@ pub struct ImportTaskInput {
#[derive(Debug)]
pub struct SubmitImportTask {
pub submission_id: Uuid,
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交。
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交
pub fingerprint: [u8; 32],
pub context: MutationContext,
pub items: Vec<ImportTaskInput>,
@@ -1,10 +1,10 @@
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
//! 单页 Key 用量的查询合同;身份范围不由调用方提供
use super::{
PageSize,
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
UsageOverview, UsagePage,
},
};
@@ -25,7 +25,7 @@ pub struct KeyUsageRecordsQuery {
pub usage: KeyUsageQuery,
pub kind: KeyUsageRecordKind,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
pub struct KeyUsageOverview {
+51 -14
View File
@@ -1,4 +1,4 @@
//! 管理控制面使用的 Command、Result 与稳定值对象。
//! 管理控制面使用的 Command、Result 与稳定值对象
use std::num::{NonZeroU16, NonZeroU64};
@@ -24,7 +24,7 @@ pub mod retention;
pub mod settings;
pub mod system;
/// 管理用例对外返回的稳定错误分类。
/// 管理用例对外返回的稳定错误分类
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminErrorKind {
Invalid,
@@ -39,12 +39,31 @@ pub enum AdminErrorKind {
Internal,
}
/// 不携带基础设施细节、可安全返回给管理员的管理用例错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
impl AdminErrorKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Invalid => "invalid",
Self::Forbidden => "forbidden",
Self::Unauthorized => "unauthorized",
Self::NotFound => "not_found",
Self::Conflict => "conflict",
Self::RateLimited => "rate_limited",
Self::BadGateway => "bad_gateway",
Self::UpstreamResultUnknown => "upstream_result_unknown",
Self::Unavailable => "unavailable",
Self::Internal => "internal",
}
}
}
/// 管理用例的安全消息与内部原因分离,原始来源不进入响应
#[derive(Debug, Clone, thiserror::Error)]
#[error("{message}")]
pub struct AdminError {
kind: AdminErrorKind,
message: String,
source: Option<gateway_core::error::ErrorSource>,
}
impl AdminError {
@@ -53,15 +72,27 @@ impl AdminError {
Self {
kind,
message: message.into(),
source: None,
}
}
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(source.into());
self
}
#[must_use]
pub fn error_details(&self) -> Option<gateway_core::error::ErrorDetails> {
gateway_core::error::ErrorDetails::capture(self.source.as_ref(), None, false)
}
#[must_use]
pub const fn kind(&self) -> AdminErrorKind {
self.kind
}
/// 返回已脱敏、可公开给管理员的文案。
/// 返回已脱敏、可公开给管理员的文案
#[must_use]
pub fn message(&self) -> &str {
&self.message
@@ -108,7 +139,7 @@ impl AdminError {
}
}
/// PostgreSQL 中所有正整数 revision 的管理层表示。
/// PostgreSQL 中所有正整数 revision 的管理层表示
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
@@ -116,11 +147,11 @@ impl AdminError {
pub struct Revision(NonZeroU64);
impl Revision {
/// 创建正整数 revision。
/// 创建正整数 revision
///
/// # Errors
///
/// `value` 为零时返回 [`AdminModelError::ZeroRevision`]。
/// `value` 为零时返回 [`AdminModelError::ZeroRevision`]
pub fn new(value: u64) -> Result<Self, AdminModelError> {
NonZeroU64::new(value)
.map(Self)
@@ -133,18 +164,18 @@ impl Revision {
}
}
/// 管理列表统一使用的受限页大小。
/// 管理列表统一使用的受限页大小
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PageSize(NonZeroU16);
impl PageSize {
pub const MAX: u16 = 200;
/// 创建 1 至 200 的页大小。
/// 创建 1 至 200 的页大小
///
/// # Errors
///
/// `value` 不在有效范围时返回 [`AdminModelError::InvalidPageSize`]。
/// `value` 不在有效范围时返回 [`AdminModelError::InvalidPageSize`]
pub fn new(value: u16) -> Result<Self, AdminModelError> {
if value > Self::MAX {
return Err(AdminModelError::InvalidPageSize(value));
@@ -160,7 +191,7 @@ impl PageSize {
}
}
/// 可审计管理写操作的发起者。
/// 可审计管理写操作的发起者
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum MutationActor {
@@ -169,7 +200,7 @@ pub enum MutationActor {
System,
}
/// 管理写操作必须携带的审计上下文。
/// 管理写操作必须携带的审计上下文
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct MutationContext {
@@ -177,13 +208,19 @@ pub struct MutationContext {
pub request_id: String,
}
/// 领域值对象构造失败。
/// 领域值对象构造失败
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AdminModelError {
#[error("revision must be greater than zero")]
ZeroRevision,
#[error("page size {0} is outside 1..=200")]
InvalidPageSize(u16),
#[error("observability page size {0} is outside 1..=100")]
InvalidObservabilityPageSize(u16),
#[error("account group name or description is invalid")]
InvalidAccountGroupFields,
#[error("client key group IDs are duplicated or exceed 1000 entries")]
InvalidClientKeyGroups,
#[error("client key page size must be inside 1..=65535")]
InvalidClientKeyPageSize,
#[error("request outcome must be 1..=256 bytes without control characters")]
@@ -1,22 +1,53 @@
//! 用量、成本、健康与错误诊断的 UTC 语义事实。
//! 用量、成本、健康与错误诊断的 UTC 语义事实
use std::str::FromStr;
use std::{num::NonZeroU16, str::FromStr};
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use super::{AdminModelError, PageSize};
use super::AdminModelError;
/// 观测日界所用的东八区(UTC+8)固定偏移秒数。
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
/// 观测记录查询的页大小,由 HTTP、管理用例和存储端口共同使用
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObservabilityPageSize(NonZeroU16);
/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。
#[must_use]
pub fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond()))
impl ObservabilityPageSize {
pub const MAX: u16 = 100;
pub fn new(value: u16) -> Result<Self, AdminModelError> {
if value > Self::MAX {
return Err(AdminModelError::InvalidObservabilityPageSize(value));
}
NonZeroU16::new(value)
.map(Self)
.ok_or(AdminModelError::InvalidObservabilityPageSize(value))
}
#[must_use]
pub const fn get(self) -> u16 {
self.0.get()
}
}
/// 外部观测查询的 UTC 时间范围。
/// 页面筛选表达自然日范围,具体 UTC 边界由部署时区解析
#[derive(Debug, Clone, Copy)]
pub enum CalendarPeriod {
Today,
SevenDays,
ThirtyDays,
}
impl CalendarPeriod {
pub fn parse(value: &str) -> Result<Self, AdminModelError> {
match value {
"today" => Ok(Self::Today),
"7d" => Ok(Self::SevenDays),
"30d" => Ok(Self::ThirtyDays),
_ => Err(AdminModelError::InvalidTimeRange),
}
}
}
/// 外部观测查询的 UTC 时间范围
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeRange {
pub start: DateTime<Utc>,
@@ -24,11 +55,30 @@ pub struct TimeRange {
}
impl TimeRange {
/// 创建最长 366 天的正时间范围。
pub fn calendar_at(
period: CalendarPeriod,
end: DateTime<Utc>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<Self, AdminModelError> {
let days = match period {
CalendarPeriod::Today => 0,
CalendarPeriod::SevenDays => 6,
CalendarPeriod::ThirtyDays => 29,
};
let start = timezone
.days_before(end, days)
.ok_or(AdminModelError::InvalidTimeRange)?;
// 自然日刚开始时允许空快照,不借用前一天或伪造未来终点
if start == end {
return Ok(Self { start, end });
}
Self::new(start, end)
}
/// 创建最长 366 天的正时间范围
///
/// # Errors
///
/// 范围为空、反向或超过 366 天时返回错误。
/// 范围为空、反向或超过 366 天时返回错误
pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Self, AdminModelError> {
let duration = end.signed_duration_since(start);
if duration <= TimeDelta::zero() || duration > TimeDelta::days(366) {
@@ -38,7 +88,7 @@ impl TimeRange {
}
}
/// 请求结果状态。
/// 请求结果状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestOutcome {
Running,
@@ -49,19 +99,19 @@ pub enum RequestOutcome {
Other(OtherRequestOutcome),
}
/// 未知但有界的请求结果值;私有字段禁止绕过 [`RequestOutcome::new`] 构造。
/// 未知但有界的请求结果值;私有字段禁止绕过 [`RequestOutcome::new`] 构造
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OtherRequestOutcome(String);
impl RequestOutcome {
pub const MAX_BYTES: usize = 256;
/// 从持久化或 wire 字符串创建可扩展结果语义。
/// 从持久化或 wire 字符串创建可扩展结果语义
///
/// # Errors
///
/// 空值、超过 256 字节或含控制字符时返回
/// [`AdminModelError::InvalidRequestOutcome`]。
/// [`AdminModelError::InvalidRequestOutcome`]
pub fn new(value: impl Into<String>) -> Result<Self, AdminModelError> {
let value = value.into();
match value.as_str() {
@@ -100,7 +150,7 @@ impl OtherRequestOutcome {
}
}
/// 用量记录过滤条件。
/// 用量记录过滤条件
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageFilter {
pub client_api_key_ref: Option<String>,
@@ -118,16 +168,42 @@ pub struct UsageFilter {
pub search: Option<String>,
}
/// 用量记录分页查询。
/// 用量记录分页查询
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageQuery {
pub range: TimeRange,
pub filter: UsageFilter,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
/// 运维错误过滤条件。
/// Admin 确定概览的数量、趋势粒度及最近请求筛选,Store 只执行该选择
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardQuery {
pub range: TimeRange,
pub granularity: Granularity,
pub account_limit: u16,
pub recent_request_limit: u16,
pub recent_request_filter: UsageFilter,
}
impl DashboardQuery {
#[must_use]
pub fn new(range: TimeRange) -> Self {
Self {
range,
granularity: Granularity::for_range(range),
account_limit: 4,
recent_request_limit: 10,
recent_request_filter: UsageFilter {
outcome: Some(RequestOutcome::Succeeded),
..UsageFilter::default()
},
}
}
}
/// 运维错误过滤条件
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OpsErrorFilter {
pub client_api_key_ref: Option<String>,
@@ -144,16 +220,16 @@ pub struct OpsErrorFilter {
pub search: Option<String>,
}
/// 运维错误分页查询。
/// 运维错误分页查询
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorQuery {
pub range: TimeRange,
pub filter: OpsErrorFilter,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
/// 用量诊断维度。
/// 用量诊断维度
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticDimension {
Provider,
@@ -165,7 +241,7 @@ pub enum DiagnosticDimension {
Status,
}
/// `numeric(20,10)` 的非负规范金额。
/// `numeric(20,10)` 的非负规范金额
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DecimalAmount(String);
@@ -175,13 +251,13 @@ impl DecimalAmount {
&self.0
}
/// 精确相加两个 `numeric(20,10)` 金额。
/// 精确相加两个 `numeric(20,10)` 金额
#[must_use]
pub fn checked_add(&self, other: &Self) -> Option<Self> {
Self::from_decimal(self.to_decimal()?.checked_add(other.to_decimal()?)?)
}
/// 将金额按非零请求数均分,保留最多十位小数。
/// 将金额按非零请求数均分,保留最多十位小数
#[must_use]
pub fn checked_div_u64(&self, divisor: u64) -> Option<Self> {
Self::from_decimal(self.to_decimal()?.checked_div_u64(divisor)?)
@@ -192,7 +268,7 @@ impl DecimalAmount {
}
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
Some(Self(value.canonical()))
Self::from_str(&value.canonical()).ok()
}
}
@@ -234,25 +310,25 @@ impl std::fmt::Display for DecimalAmount {
}
}
/// 单一币种的成本合计。
/// 单一币种的成本合计
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurrencyCost {
pub currency: String,
pub amount: DecimalAmount,
}
/// PostgreSQL 连续百分位返回的非负有限毫秒值。
/// PostgreSQL 连续百分位返回的非负有限毫秒值
///
/// 以 IEEE-754 bits 保存,既不丢失插值小数,也可安全实现 `Eq`。
/// 以 IEEE-754 bits 保存,既不丢失插值小数,也可安全实现 `Eq`
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct PercentileMilliseconds(u64);
impl PercentileMilliseconds {
/// 校验并保存一个毫秒百分位。
/// 校验并保存一个毫秒百分位
///
/// # Errors
///
/// 非有限值或负值返回 [`AdminModelError::InvalidLatencyPercentile`]。
/// 非有限值或负值返回 [`AdminModelError::InvalidLatencyPercentile`]
pub fn new(value: f64) -> Result<Self, AdminModelError> {
if !value.is_finite() || value < 0.0 {
return Err(AdminModelError::InvalidLatencyPercentile);
@@ -272,7 +348,7 @@ impl std::fmt::Debug for PercentileMilliseconds {
}
}
/// 延迟分布的三个稳定百分位。
/// 延迟分布的三个稳定百分位
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LatencyPercentiles {
pub p50_ms: Option<PercentileMilliseconds>,
@@ -280,7 +356,7 @@ pub struct LatencyPercentiles {
pub p99_ms: Option<PercentileMilliseconds>,
}
/// Provider 价格规则计算所需的持久请求事实。
/// Provider 价格规则计算所需的持久请求事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderBillingInput {
pub upstream_model_id: String,
@@ -292,9 +368,9 @@ pub struct ProviderBillingInput {
pub total: CurrencyCost,
}
/// 已完整交付且由 Provider 计算费用的持久请求事实。
/// 已完整交付且由 Provider 计算费用的持久请求事实
///
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageCalculatedBillingFact {
pub breakdown: Option<CalculatedBillingBreakdown>,
@@ -309,7 +385,7 @@ pub struct UsageCalculatedBillingFact {
pub total: CurrencyCost,
}
/// Provider 已确认的逐项费用与单价。
/// Provider 已确认的逐项费用与单价
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedBillingBreakdown {
pub long_context_billing_applied: bool,
@@ -339,16 +415,16 @@ pub struct ImageBillingBreakdown {
pub cache_read_price_per_million: CurrencyCost,
}
/// 单次请求的费用语义。
/// 单次请求的费用语义
///
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UsageBilling {
Total { source: String, total: CurrencyCost },
Calculated(Box<CalculatedBillingBreakdown>),
}
/// 计费数据覆盖情况。
/// 计费数据覆盖情况
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CostCoverage {
pub provider_reported_count: u64,
@@ -359,7 +435,7 @@ pub struct CostCoverage {
}
impl CostCoverage {
/// Provider 直接上报或 Provider 规则完整计算的已知成本数。
/// Provider 直接上报或 Provider 规则完整计算的已知成本数
#[must_use]
pub fn known_count(&self) -> u64 {
self.provider_reported_count
@@ -367,7 +443,7 @@ impl CostCoverage {
}
}
/// 请求级聚合指标。
/// 请求级聚合指标
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestMetrics {
pub request_count: u64,
@@ -404,7 +480,7 @@ pub struct RequestMetrics {
pub cache_hit_request_count: u64,
}
/// 上游 attempt 聚合指标。
/// 上游 attempt 聚合指标
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttemptMetrics {
pub attempt_count: u64,
@@ -419,7 +495,7 @@ pub struct AttemptMetrics {
pub costs: Vec<CurrencyCost>,
}
/// 时间序列粒度。
/// 时间序列粒度
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Granularity {
FifteenMinutes,
@@ -427,7 +503,31 @@ pub enum Granularity {
Day,
}
/// 一段时间桶内的请求指标。
impl Granularity {
/// 管理趋势按范围选择粒度,费用校验与请求指标必须使用同一策略
#[must_use]
pub fn for_range(range: TimeRange) -> Self {
let seconds = range.end.signed_duration_since(range.start).num_seconds();
if seconds <= 2 * 24 * 60 * 60 {
Self::FifteenMinutes
} else if seconds <= 31 * 24 * 60 * 60 {
Self::Hour
} else {
Self::Day
}
}
#[must_use]
pub const fn seconds(self) -> i64 {
match self {
Self::FifteenMinutes => 15 * 60,
Self::Hour => 60 * 60,
Self::Day => 24 * 60 * 60,
}
}
}
/// 一段时间桶内的请求指标
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestMetricPoint {
pub bucket_start: DateTime<Utc>,
@@ -437,8 +537,8 @@ pub struct RequestMetricPoint {
pub costs: Vec<CurrencyCost>,
}
/// 账号池的统一五态统计。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// 账号池的统一五态统计
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountPoolMetrics {
pub total: u64,
pub normal: u64,
@@ -448,9 +548,9 @@ pub struct AccountPoolMetrics {
pub error: u64,
}
/// Dashboard 中一个账号的模型级用量事实。
/// 账号在查询时间范围内的模型级用量事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardAccountModelUsage {
pub struct AccountModelUsage {
pub model: String,
pub request_count: u64,
pub success_count: u64,
@@ -469,14 +569,14 @@ pub struct DashboardAccountModelUsage {
pub last_used_at: DateTime<Utc>,
}
/// Dashboard 中账号的单小时请求数。
/// 账号在单小时窗口内的请求数
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardAccountRequestBucket {
pub struct AccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
/// Dashboard 中一个账号的完整用量事实。
/// Dashboard 中一个账号的完整用量事实
#[derive(Debug, Clone, PartialEq)]
pub struct DashboardAccountUsage {
pub account_id: String,
@@ -485,7 +585,7 @@ pub struct DashboardAccountUsage {
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
/// Admin 按 Provider 补齐的套餐展示名称。
/// Admin 按 Provider 补齐的套餐展示名称
pub plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
@@ -502,29 +602,29 @@ pub struct DashboardAccountUsage {
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
pub last_used_at: Option<DateTime<Utc>>,
pub request_buckets: Vec<DashboardAccountRequestBucket>,
/// Provider 已持久化额度窗口投影出的代表性已用比例。
pub request_buckets: Vec<AccountRequestBucket>,
/// Provider 已持久化额度窗口投影出的代表性已用比例
///
/// `None` 表示上游未提供可比较的百分比,不应伪造为零。
/// `None` 表示上游未提供可比较的百分比,不应伪造为零
pub quota_used_percent: Option<f64>,
/// Provider 返回的代表窗口;缺失时保留未知语义。
/// Provider 返回的代表窗口;缺失时保留未知语义
pub quota_window: Option<super::provider_credentials::ProviderQuotaWindow>,
pub models: Vec<DashboardAccountModelUsage>,
pub models: Vec<AccountModelUsage>,
}
/// Dashboard 当前账号池的可重建运行时槽位事实。
/// Dashboard 当前账号池的可重建运行时槽位事实
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DashboardRuntimeSlots {
/// 继承运行参数默认并发上限的可调度账号数。
/// 继承运行参数默认并发上限的可调度账号数
pub inherited_accounts: u64,
/// 已配置账号级并发上限的可调度账号槽位总数。
/// 已配置账号级并发上限的可调度账号槽位总数
pub overridden_slots: u64,
/// Redis 可用时的实时 in-flight 槽位总数;不可用时为 `None`。
/// Redis 可用时的实时 in-flight 槽位总数;不可用时为 `None`
pub used_slots: Option<u64>,
}
/// 仪表盘卡片脚注展示的全历史累计。
#[derive(Debug, Clone, Default, PartialEq)]
/// 仪表盘卡片脚注展示的全历史累计
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DashboardTotals {
pub request_count: u64,
pub input_tokens: u64,
@@ -533,19 +633,20 @@ pub struct DashboardTotals {
pub billing_usd: Option<DecimalAmount>,
}
/// 仪表盘所需的当前区间事实及全历史累计。
/// 仪表盘所需的当前区间事实及全历史累计
#[derive(Debug, Clone, PartialEq)]
pub struct DashboardObservation {
pub range: TimeRange,
pub totals: DashboardTotals,
pub provider_accounts: AccountPoolMetrics,
pub runtime_slots: Option<DashboardRuntimeSlots>,
pub trend: Vec<RequestMetricPoint>,
pub account_usage: Vec<DashboardAccountUsage>,
pub recent_requests: Vec<UsageListRecord>,
}
/// 使用记录表格的窄读模型。
#[derive(Debug, Clone, PartialEq, Eq)]
/// 使用记录表格的窄读模型
#[derive(Debug, Clone, PartialEq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub id: String,
@@ -556,9 +657,9 @@ pub struct UsageListRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照
pub provider_account_notes: Option<String>,
/// 账号当前套餐,不属于请求历史快照。
/// 账号当前套餐,不属于请求历史快照
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
@@ -586,6 +687,13 @@ pub struct UsageListRecord {
pub first_text_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub upstream_response_ms: Option<u64>,
pub upstream_api_overhead_ms: Option<f64>,
pub upstream_engine_ms: Option<f64>,
pub upstream_engine_iapi_ttft_ms: Option<f64>,
pub upstream_engine_service_ttft_ms: Option<f64>,
pub upstream_engine_iapi_tbt_ms: Option<f64>,
pub upstream_engine_service_tbt_ms: Option<f64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
@@ -600,8 +708,8 @@ pub struct UsageListRecord {
pub started_at: DateTime<Utc>,
}
/// 一次完整模型请求的公共观测记录。
#[derive(Debug, Clone, PartialEq, Eq)]
/// 一次完整模型请求的公共观测记录
#[derive(Debug, Clone, PartialEq)]
pub struct UsageRecord {
pub id: String,
pub client_api_key_ref: String,
@@ -625,7 +733,7 @@ pub struct UsageRecord {
pub websocket_pool: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明
pub provider_metadata_json: Option<String>,
pub attempt_count: u32,
pub upstream_send_state: String,
@@ -660,6 +768,13 @@ pub struct UsageRecord {
pub first_text_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub upstream_response_ms: Option<u64>,
pub upstream_api_overhead_ms: Option<f64>,
pub upstream_engine_ms: Option<f64>,
pub upstream_engine_iapi_ttft_ms: Option<f64>,
pub upstream_engine_service_ttft_ms: Option<f64>,
pub upstream_engine_iapi_tbt_ms: Option<f64>,
pub upstream_engine_service_tbt_ms: Option<f64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
@@ -679,8 +794,8 @@ pub struct UsageRecord {
pub completed_at: Option<DateTime<Utc>>,
}
/// 用量分页结果。
#[derive(Debug, Clone, PartialEq, Eq)]
/// 用量分页结果
#[derive(Debug, Clone, PartialEq)]
pub struct UsagePage {
pub items: Vec<UsageListRecord>,
pub current_page: u32,
@@ -688,7 +803,7 @@ pub struct UsagePage {
pub total: u64,
}
/// 请求中的一次上游尝试或运维事件。
/// 请求中的一次上游尝试或运维事件
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageAttempt {
pub source: String,
@@ -725,8 +840,8 @@ pub struct UsageAttempt {
pub occurred_at: DateTime<Utc>,
}
/// 一条请求及其全部尝试。
#[derive(Debug, Clone, PartialEq, Eq)]
/// 一条请求及其全部尝试
#[derive(Debug, Clone, PartialEq)]
pub struct UsageDetail {
pub trace: Option<serde_json::Value>,
pub related_requests: Vec<serde_json::Value>,
@@ -734,7 +849,7 @@ pub struct UsageDetail {
pub attempts: Vec<UsageAttempt>,
}
/// 一个 Provider 的聚合用量。
/// 一个 Provider 的聚合用量
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderObservation {
pub provider_kind: String,
@@ -744,7 +859,7 @@ pub struct ProviderObservation {
pub total_tokens: u64,
}
/// 用量总览。
/// 用量总览
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageOverview {
pub range: TimeRange,
@@ -753,21 +868,21 @@ pub struct UsageOverview {
pub providers: Vec<ProviderObservation>,
}
/// 用量摘要的用例结果。
/// 用量摘要的用例结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageSummary {
pub overview: UsageOverview,
pub average_latency_ms: Option<u64>,
}
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagnosticsObservation {
pub total_request_count: u64,
pub items: Vec<DiagnosticObservation>,
}
/// 单个诊断维度值的聚合结果。
/// 单个诊断维度值的聚合结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
@@ -789,7 +904,7 @@ pub struct DiagnosticObservation {
pub costs: Vec<CurrencyCost>,
}
/// 统一运维错误记录。
/// 统一运维错误记录
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsError {
pub client_api_key_name: Option<String>,
@@ -809,6 +924,8 @@ pub struct OpsError {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -821,7 +938,7 @@ pub struct OpsError {
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub message: String,
pub raw_upstream_error: Option<String>,
pub error_details: Option<String>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -845,7 +962,7 @@ pub struct OpsError {
pub stable_sort_id: String,
}
/// 运维错误分页结果。
/// 运维错误分页结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorPage {
pub items: Vec<OpsError>,
@@ -854,7 +971,7 @@ pub struct OpsErrorPage {
pub total: u64,
}
/// 仪表盘趋势指标。
/// 仪表盘趋势指标
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrendKind {
Usage,
@@ -862,7 +979,7 @@ pub enum TrendKind {
Errors,
}
/// 一个趋势桶的全部已计算语义,API 只负责时区标签与展示格式。
/// 一个趋势桶的全部已计算语义,API 只负责时区标签与展示格式
#[derive(Debug, Clone, PartialEq)]
pub struct TrendPoint {
pub bucket_start: DateTime<Utc>,
@@ -878,7 +995,7 @@ pub struct TrendPoint {
pub costs: Vec<CurrencyCost>,
}
/// 趋势的无格式化汇总。
/// 趋势的无格式化汇总
#[derive(Debug, Clone, PartialEq)]
pub struct TrendSummary {
pub request_count: u64,
@@ -899,7 +1016,7 @@ pub struct TrendSummary {
pub cost_coverage: CostCoverage,
}
/// 趋势结果;API 再决定图表标签与数字格式。
/// 趋势结果;API 再决定图表标签与数字格式
#[derive(Debug, Clone, PartialEq)]
pub struct Trend {
pub kind: TrendKind,
@@ -907,7 +1024,7 @@ pub struct Trend {
pub summary: TrendSummary,
}
/// 健康时间桶状态。
/// 健康时间桶状态
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealthStatus {
Future,
@@ -918,7 +1035,7 @@ pub enum HealthStatus {
Stable,
}
/// 一个 15 分钟健康桶的语义结果。
/// 一个 15 分钟健康桶的语义结果
#[derive(Debug, Clone, PartialEq)]
pub struct HealthTimelinePoint {
pub bucket_start: DateTime<Utc>,
@@ -931,7 +1048,7 @@ pub struct HealthTimelinePoint {
pub caller_error_requests: u64,
}
/// 中国自然日的固定 96 槽健康时间线。
/// 中国自然日的固定 96 槽健康时间线
#[derive(Debug, Clone, PartialEq)]
pub struct HealthTimeline {
pub reliability_percent: Option<f64>,
@@ -944,7 +1061,7 @@ pub struct HealthTimeline {
pub points: Vec<HealthTimelinePoint>,
}
/// Dashboard 请求画像的目标平台。
/// Dashboard 请求画像的目标平台
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardWireTarget {
pub os_type: String,
@@ -953,7 +1070,7 @@ pub struct DashboardWireTarget {
pub terminal: String,
}
/// Desktop 发布检查状态。
/// Desktop 发布检查状态
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesktopReleaseStatus {
Unchecked,
@@ -962,7 +1079,7 @@ pub enum DesktopReleaseStatus {
Failed,
}
/// 与请求画像分离的 Desktop 发布检查事实。
/// 与请求画像分离的 Desktop 发布检查事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardDesktopRelease {
pub status: DesktopReleaseStatus,
@@ -978,9 +1095,9 @@ pub struct DashboardDesktopRelease {
pub error: Option<String>,
}
/// Provider 拥有并实际用于上游请求的身份画像快照。
/// Provider 拥有并实际用于上游请求的身份画像快照
///
/// `attributes` 承载 Provider 特有的可观测字段;公共控制面不解释其语义。
/// `attributes` 承载 Provider 特有的可观测字段;公共控制面不解释其语义
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardWireProfile {
pub provider: String,
@@ -994,24 +1111,24 @@ pub struct DashboardWireProfile {
pub release: Option<DashboardDesktopRelease>,
}
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义。
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardWireAttribute {
pub label: String,
pub value: String,
}
/// 当前账号池的并发调度容量。
/// 当前账号池的并发调度容量
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardCapacity {
pub max_concurrent_per_account: u64,
/// `None` 表示可用账号池不限制并发。
/// `None` 表示可用账号池不限制并发
pub total_slots: Option<u64>,
pub used_slots: Option<u64>,
pub available_slots: Option<u64>,
}
/// Dashboard 某个自然日区间内的卡片计数。
/// Dashboard 某个自然日区间内的卡片计数
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct DashboardPeriodMetrics {
pub request_count: u64,
@@ -1023,7 +1140,7 @@ pub struct DashboardPeriodMetrics {
pub observed_cached_token_rate: Option<f64>,
}
/// 仪表盘聚合结果。
/// 仪表盘聚合结果
#[derive(Debug, Clone, PartialEq)]
pub struct DashboardResult {
pub observation: DashboardObservation,
@@ -1039,7 +1156,7 @@ pub struct DashboardResult {
pub rotation_strategy: super::settings::RotationStrategy,
}
/// 洞察健康时间点的语义结果。
/// 洞察健康时间点的语义结果
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsHealthPoint {
pub total_requests: u64,
@@ -1052,7 +1169,7 @@ pub struct UsageInsightsHealthPoint {
pub error_rate: f64,
}
/// 洞察健康汇总;失败数已排除调用方错误。
/// 洞察健康汇总;失败数已排除调用方错误
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsHealth {
pub total_requests: u64,
@@ -1066,7 +1183,7 @@ pub struct UsageInsightsHealth {
pub points: Vec<UsageInsightsHealthPoint>,
}
/// 洞察性能时间点。
/// 洞察性能时间点
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageInsightsPerformancePoint {
pub bucket_start: DateTime<Utc>,
@@ -1081,7 +1198,7 @@ pub struct UsageInsightsPerformancePoint {
pub capacity_utilization_p95_basis_points: Option<u64>,
}
/// 洞察性能汇总与可观测覆盖率。
/// 洞察性能汇总与可观测覆盖率
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsPerformance {
pub latency_percentiles: LatencyPercentiles,
@@ -1101,7 +1218,7 @@ pub struct UsageInsightsPerformance {
pub points: Vec<UsageInsightsPerformancePoint>,
}
/// 洞察成本时间点。
/// 洞察成本时间点
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsCostPoint {
pub bucket_start: DateTime<Utc>,
@@ -1117,7 +1234,7 @@ pub struct UsageInsightsCostPoint {
pub cache_hit_request_rate: Option<f64>,
}
/// 洞察成本汇总。
/// 洞察成本汇总
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsCost {
pub estimated_cost: Option<DecimalAmount>,
@@ -1139,7 +1256,7 @@ pub struct UsageInsightsCost {
pub coverage: CostCoverage,
}
/// 用量洞察的完整用例结果;API 只负责标签、时区与字符串格式化。
/// 用量洞察的完整用例结果;API 只负责标签、时区与字符串格式化
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsights {
pub granularity: Granularity,
@@ -1150,7 +1267,7 @@ pub struct UsageInsights {
pub providers: Vec<ProviderObservation>,
}
/// 已完成分母计算的诊断项。
/// 已完成分母计算的诊断项
#[derive(Debug, Clone, PartialEq)]
pub struct DiagnosticsItem {
pub key: String,
@@ -1174,7 +1291,7 @@ pub struct DiagnosticsItem {
pub total_tokens: u64,
}
/// 诊断结果。
/// 诊断结果
#[derive(Debug, Clone, PartialEq)]
pub struct DiagnosticsResult {
pub dimension: DiagnosticDimension,
@@ -1,11 +1,11 @@
//! 插件可见的 Client Key 非秘密资源投影。
//! 插件可见的 Client Key 非秘密资源投影
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::AccountGroupId;
use super::PageSize;
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密。
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyFacts {
pub id: ClientApiKeyId,
@@ -25,7 +25,7 @@ pub struct PluginClientKeyListQuery {
pub limit: PageSize,
}
/// 插件只能发现调用所需的公开身份,不取得 Key 前缀、策略细节或明文。
/// 插件只能发现调用所需的公开身份,不取得 Key 前缀、策略细节或明文
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKey {
pub id: ClientApiKeyId,
@@ -1,4 +1,4 @@
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次。
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次
use super::Revision;
@@ -17,7 +17,7 @@ pub struct ManagedResource {
}
pub struct ResourceMutation<T> {
/// 没有事实变化时不递增配置 revision,也不触发发布。
/// 没有事实变化时不递增配置 revision,也不触发发布
pub revision: Option<Revision>,
pub value: T,
}
@@ -34,7 +34,7 @@ pub struct GroupMembersChanged {
pub removed: u64,
}
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析。
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析
pub struct ManagedKeyConfig {
pub name: String,
pub limits: gateway_core::policy::RateLimits,
@@ -1,3 +1,5 @@
//! 插件分发来源、下载凭据、发行查询与远程安装的数据合同
use std::{fmt, sync::Arc};
use chrono::{DateTime, Utc};
@@ -17,7 +19,7 @@ pub enum PluginUpdateSource {
Github { repository: String },
}
/// 检查策略只决定查询目标,任何策略都不会自动安装或切换运行实例。
/// 检查策略只决定查询目标,任何策略都不会自动安装或切换运行实例
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginUpdatePolicy {
@@ -85,7 +87,7 @@ impl fmt::Debug for PluginDistributionEgress {
#[serde(rename_all = "camelCase")]
pub struct PluginUpdateCheck {
pub binding: PluginSourceBinding,
/// Release 元数据不是已校验的插件包,不据此声明平台或 SDK 兼容。
/// Release 元数据不是已校验的插件包,不据此声明平台或 SDK 兼容
pub release: PluginRelease,
}
@@ -109,7 +111,7 @@ pub enum DownloadPurpose {
Artifact,
}
/// 授权目标以完整 origin 和路径段边界匹配;摘要、名称和列表均不包含 secret。
/// 授权目标以完整 origin 和路径段边界匹配;摘要、名称和列表均不包含 secret
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SourceCredentialInfo {
@@ -174,7 +176,7 @@ pub struct PluginRelease {
pub expires_at: DateTime<Utc>,
}
/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用。
/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum RemotePluginLocation {
@@ -200,7 +202,7 @@ pub struct RemotePluginVerify {
pub credential_ids: Vec<String>,
#[serde(default)]
pub outbound_proxy_id: Option<String>,
/// 更新已有插件时锁定身份;首次安装从包内清单读取。
/// 更新已有插件时锁定身份;首次安装从包内清单读取
pub expected_plugin_id: Option<String>,
}
@@ -222,7 +224,7 @@ pub struct DownloadedPlugin {
pub source: PluginSource,
}
/// 只读包校验结果,不代表实例配置、授权或私有状态已完成运行时准备。
/// 只读包校验结果,不代表实例配置、授权或私有状态已完成运行时准备
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifiedPluginArtifact {
@@ -1,3 +1,5 @@
//! 插件实例配置、能力绑定、运行状态与版本切换的数据合同
use std::collections::BTreeMap;
use secrecy::SecretString;
@@ -20,7 +22,7 @@ pub struct PluginCapabilityBinding {
pub stage: String,
pub order: i32,
pub failure_policy: PluginFailurePolicy,
/// 观察绑定选择事件类型;其他能力不设置事件。
/// 观察绑定选择事件类型;其他能力不设置事件
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(default)]
@@ -35,7 +37,7 @@ pub struct PluginCapabilityBinding {
pub identity_bindings: Vec<PluginFrontendIdentityBinding>,
}
/// 外部 principal 到既有 Client Key 的显式宿主映射。
/// 外部 principal 到既有 Client Key 的显式宿主映射
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginFrontendIdentityBinding {
@@ -53,7 +55,7 @@ impl std::fmt::Debug for PluginFrontendIdentityBinding {
}
}
/// 配置和 secret 分离;Debug 与 Serialize 不得无意展开实例配置。
/// 配置和 secret 分离;Debug 与 Serialize 不得无意展开实例配置
#[derive(Clone)]
pub struct PluginInstance {
pub id: String,
@@ -78,7 +80,7 @@ pub struct PluginInstanceMutation {
pub instance: PluginInstance,
}
/// 控制面展示的单实例运行状态;它描述当前进程观察到的事实,不替代持久启用配置。
/// 控制面展示的单实例运行状态;它描述当前进程观察到的事实,不替代持久启用配置
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginInstanceRuntimeStatus {
Disabled,
@@ -91,14 +93,14 @@ pub enum PluginInstanceRuntimeStatus {
Draining,
}
/// 已脱敏且可安全返回给管理员的运行失败摘要。
/// 已脱敏且可安全返回给管理员的运行失败摘要
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstanceRuntimeFailure {
pub code: String,
pub message: String,
}
/// 当前发布视图与代次索引的只读诊断。
/// 当前发布视图与代次索引的只读诊断
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstanceRuntime {
pub status: PluginInstanceRuntimeStatus,
@@ -108,10 +110,24 @@ pub struct PluginInstanceRuntime {
pub draining_revisions: Vec<u64>,
}
/// 可继续运行的旧接口提示,与无法启用的兼容性错误分开
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginApiDeprecation {
pub capability: String,
pub version: u32,
pub replacement_version: u32,
pub introduced_in: Option<String>,
pub remaining_releases: u32,
pub migration: String,
}
pub struct PluginInstanceView {
pub instance: PluginInstance,
pub configuration_required: bool,
pub compatibility_warning: Option<String>,
pub load_error: Option<String>,
pub api_deprecations: Vec<PluginApiDeprecation>,
pub running: bool,
pub published_revision: Option<u64>,
pub runtime: PluginInstanceRuntime,
@@ -120,18 +136,18 @@ pub struct PluginInstanceView {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfigurePluginInstance {
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口。
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口
pub creation_id: Option<String>,
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容。
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容
pub expected_revision: Option<u64>,
/// 管理员确认停用的同插件配置,与当前配置在同一事务中切换。
/// 管理员确认停用的同插件配置,与当前配置在同一事务中切换
#[serde(default)]
pub replace_instances: Vec<PluginInstanceReplacement>,
pub name: String,
pub artifact_sha256: String,
pub enabled: bool,
pub configuration: serde_json::Value,
/// 缺省保留旧值;显式空对象清除全部 secret。
/// 缺省保留旧值;显式空对象清除全部 secret
pub secrets: Option<BTreeMap<String, SecretString>>,
pub bindings: Vec<PluginCapabilityBinding>,
}
@@ -143,7 +159,7 @@ pub struct PluginInstanceReplacement {
pub expected_revision: u64,
}
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验。
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RollbackPluginInstance {
@@ -159,7 +175,7 @@ pub struct PluginRollbackTarget {
pub platforms: Vec<String>,
}
/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性。
/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRollbackPlan {
@@ -168,7 +184,7 @@ pub struct PluginRollbackPlan {
pub targets: Vec<PluginRollbackTarget>,
}
/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize。
/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize
#[derive(Clone)]
pub struct PluginVersionConfiguration {
pub configuration: serde_json::Value,
@@ -176,7 +192,7 @@ pub struct PluginVersionConfiguration {
pub bindings: Vec<PluginCapabilityBinding>,
}
/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置。
/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginVersionPlan {
@@ -1,8 +1,10 @@
//! 插件管理页面、静态资源、请求与授权回调的数据合同
use std::sync::Arc;
use serde::{Deserialize, Serialize};
/// 页面和业务调用绑定同一实例版本;制品摘要单独作为不可变资源身份。
/// 页面和业务调用绑定同一实例版本;制品摘要单独作为不可变资源身份
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginManagementTarget {
@@ -37,7 +39,7 @@ pub struct StartPluginManagementCallback {
pub ttl_seconds: u32,
}
/// state 是短期一次性秘密,只交给发起登录的管理员,不进入 Debug 或日志。
/// state 是短期一次性秘密,只交给发起登录的管理员,不进入 Debug 或日志
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementCallbackTicket {
@@ -72,7 +74,7 @@ pub struct PluginManagementResource {
pub public: bool,
}
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志。
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志
pub struct PluginManagementRequest {
pub method: String,
pub path: String,
@@ -1,3 +1,5 @@
//! 插件制品元数据、宿主兼容性与管理领域模型的公共入口
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
@@ -13,7 +15,7 @@ pub mod management;
pub mod official;
pub mod state;
/// 一个宿主发行版明确承诺支持的插件能力版本。
/// 一个宿主发行版明确承诺支持的插件能力版本
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginHostCapability {
@@ -21,7 +23,7 @@ pub struct PluginHostCapability {
pub versions: Vec<u32>,
}
/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明。
/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginHostCompatibility {
@@ -32,7 +34,7 @@ pub struct PluginHostCompatibility {
}
impl PluginHostCompatibility {
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配
#[must_use]
pub fn is_valid(&self) -> bool {
if self.schema_version != 2
@@ -101,7 +103,7 @@ fn valid_plugin_segment(value: &str) -> bool {
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
/// 已安装包对目标宿主的静态要求;不包含包体、配置或 secret。
/// 已安装包对目标宿主的静态要求;不包含包体、配置或 secret
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginCompatibilityRequirements {
pub host_version: String,
@@ -110,7 +112,7 @@ pub struct PluginCompatibilityRequirements {
pub capabilities: Vec<(String, u32)>,
}
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginSourceEgress {
@@ -118,7 +120,7 @@ pub struct PluginSourceEgress {
pub revision: u64,
}
/// 安装来源只保存公开定位信息,下载凭据和出站代理通过独立记录引用。
/// 安装来源只保存公开定位信息,下载凭据和出站代理通过独立记录引用
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginSource {
@@ -164,7 +166,7 @@ impl PluginSource {
}
}
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginContribution {
@@ -178,7 +180,7 @@ pub struct PluginContribution {
pub output_formats: Vec<String>,
}
/// 制品清单中的展示图标;路径只用于选择已校验的包资源。
/// 制品清单中的展示图标;路径只用于选择已校验的包资源
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PluginArtifactIcon {
@@ -198,15 +200,15 @@ pub enum PluginIconTheme {
Dark,
}
/// 从不可变制品中读取的已校验图标字节。
/// 从不可变制品中读取的已校验图标字节
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginArtifactIconResource {
pub content_type: String,
pub body: Vec<u8>,
}
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义。
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验。
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginArtifactMetadata {
@@ -235,7 +237,7 @@ fn empty_configuration_schema() -> serde_json::Value {
serde_json::json!({})
}
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据
#[derive(Clone)]
pub struct InspectedPluginArtifact {
pub metadata: PluginArtifactMetadata,
@@ -1,13 +1,15 @@
//! 官方插件发行身份与导入命令
use super::super::Revision;
/// 编译进宿主二进制的发行身份;不能由运行时配置或插件清单覆盖。
/// 编译进宿主二进制的发行身份;不能由运行时配置或插件清单覆盖
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialPluginReleaseIdentity {
pub gateway_version: String,
pub gateway_git_sha: String,
}
/// 启动时导入官方制品的结果;导入只持久化制品,不创建或启用实例。
/// 启动时导入官方制品的结果;导入只持久化制品,不创建或启用实例
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OfficialPluginImport {
pub artifacts: usize,
@@ -1,10 +1,12 @@
//! 插件私有状态的归属、代次隔离与迁移数据合同
use std::{collections::BTreeMap, fmt};
use serde::{Deserialize, Serialize};
use crate::model::Revision;
/// 已由 Runtime 校验的命名空间 schema 与配额事实。
/// 已由 Runtime 校验的命名空间 schema 与配额事实
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStateSchema {
@@ -42,7 +44,7 @@ pub struct PluginStateConfiguration {
pub namespaces: Vec<PluginStateSchema>,
}
/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据。
/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据
#[derive(Clone, Default)]
pub struct PluginStateCommit {
pub configuration: PluginStateConfiguration,
@@ -57,7 +59,8 @@ pub struct PluginStateOwnerRequest {
pub configuration: PluginStateConfiguration,
}
/// Store 签发的实例私有状态 fence。插件协议中从不传输这些字段。
/// Store 签发的实例私有状态 fence
/// 插件协议中从不传输这些字段
#[derive(Clone)]
pub struct PluginStateOwner {
instance_id: String,
@@ -136,7 +139,7 @@ impl PluginStateNamespaceOwner {
}
}
/// 私有值可能含插件自己的敏感状态,故意不实现 `Debug`。
/// 私有值可能含插件自己的敏感状态,故意不实现 `Debug`
#[derive(Clone)]
pub struct PluginStateRecord {
pub key: String,
@@ -149,7 +152,7 @@ pub struct PutPluginState {
pub namespace: String,
pub key: String,
pub value: serde_json::Value,
/// `None` 是 create-only;已有记录必须提交精确版本。
/// `None` 是 create-only;已有记录必须提交精确版本
pub expected_version: Option<u64>,
}
@@ -188,7 +191,7 @@ pub struct ApplyPluginStateMigration {
pub transition_id: String,
pub namespace: String,
pub cursor: Option<String>,
/// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对。
/// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对
pub expected_keys: Vec<String>,
pub changes: Vec<PluginStateMigrationChange>,
}
@@ -1,4 +1,4 @@
//! 全局模型定价的管理合同。
//! 全局模型定价的管理合同
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
use std::collections::{BTreeMap, BTreeSet};
@@ -1,4 +1,4 @@
//! Provider 管理能力交换的中立 Command 与 Result。
//! Provider 管理能力交换的中立 Command 与 Result
use std::{fmt, pin::Pin};
@@ -22,7 +22,7 @@ use super::{
},
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值
#[derive(Clone, PartialEq)]
pub struct ProviderDocument(OpaqueProviderData);
@@ -32,7 +32,7 @@ impl ProviderDocument {
Self(data)
}
/// 仅具体 Provider 可以解释内部字段。
/// 仅具体 Provider 可以解释内部字段
#[must_use]
pub const fn expose_to_provider(&self) -> &OpaqueProviderData {
&self.0
@@ -50,14 +50,14 @@ impl fmt::Debug for ProviderDocument {
}
}
/// Provider credential 详情。
/// Provider credential 详情
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDetails {
pub config_revision: Revision,
pub credential: AccountRecord,
}
/// Provider 正式文档批量导入命令。
/// Provider 正式文档批量导入命令
pub struct ImportCredentials {
pub outbound_proxy_id: Option<String>,
pub settings: Option<AccountImportSettings>,
@@ -75,14 +75,14 @@ impl fmt::Debug for ImportCredentials {
}
}
/// 批量导入提交结果。
/// 批量导入提交结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialImportResult {
pub config_revision: Revision,
pub credential_ids: Vec<ProviderAccountId>,
}
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文
pub struct PrepareCredentialImport {
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub document: ProviderDocument,
@@ -97,7 +97,7 @@ impl fmt::Debug for PrepareCredentialImport {
}
}
/// Provider 已验证、可由 Store 原子创建的一份 credential。
/// Provider 已验证、可由 Store 原子创建的一份 credential
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub model_access: Option<gateway_core::account::AccountModelAccess>,
@@ -119,14 +119,14 @@ pub struct PreparedCredentialCreate {
pub credential_observed_at: DateTime<Utc>,
}
/// Provider 对一份导入文档的完整验证结果。
/// Provider 对一份导入文档的完整验证结果
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialImport {
pub provider_kind: ProviderKind,
pub credentials: Vec<PreparedCredentialCreate>,
}
/// Admin 交给 Store 的导入事务命令。
/// Admin 交给 Store 的导入事务命令
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialImportCommit {
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
@@ -134,7 +134,7 @@ pub struct CredentialImportCommit {
pub prepared: PreparedCredentialImport,
}
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON。
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON
#[derive(Clone, PartialEq, Eq)]
pub enum AuthorizationOwner {
AdminSession { admin_user_id: String },
@@ -148,7 +148,7 @@ impl fmt::Debug for AuthorizationOwner {
}
}
/// Provider 写入 pending payload 与 Redis owner binding 所需的全部中立字段。
/// Provider 写入 pending payload 与 Redis owner binding 所需的全部中立字段
#[derive(Clone, PartialEq, Eq)]
pub struct AuthorizationOwnerBinding {
owner: AuthorizationOwner,
@@ -193,17 +193,17 @@ impl fmt::Debug for AuthorizationOwnerBinding {
}
}
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential。
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential
///
/// 重新授权只绑定稳定的账号身份:credential revision 由服务端在临近写入时读取,
/// 长流程期间的后台刷新不得让恢复操作失效。
/// 长流程期间的后台刷新不得让恢复操作失效
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationMutationTarget {
Create { name: String },
Reauthorize { account_id: ProviderAccountId },
}
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingAuthorizationMutation {
provider_kind: ProviderKind,
@@ -269,7 +269,7 @@ impl PendingAuthorizationMutation {
}
impl PendingAuthorizationMutation {
/// 编码 v1 中立事务字段;版本号的位置和 Provider 私有外层文档由 adapter 保持。
/// 编码 v1 中立事务字段;版本号的位置和 Provider 私有外层文档由 adapter 保持
#[must_use]
pub fn to_storage_v1(&self) -> Map<String, Value> {
let target = match &self.target {
@@ -311,11 +311,11 @@ impl PendingAuthorizationMutation {
document
}
/// 恢复 adapter 已确认版本为 v1 的中立事务字段。
/// 恢复 adapter 已确认版本为 v1 的中立事务字段
///
/// # Errors
///
/// 文档字段、Provider kind 或账号身份非法时返回错误,不携带原始 pending 内容。
/// 文档字段、Provider kind 或账号身份非法时返回错误,不携带原始 pending 内容
pub fn from_storage_v1(value: Value) -> Result<Self, AdminError> {
let invalid = || AdminError::invalid("invalid pending authorization mutation");
let document: StoredAuthorizationMutationV1 =
@@ -385,7 +385,7 @@ enum StoredAuthorizationOwnerV1 {
System,
}
/// Provider 授权请求。
/// Provider 授权请求
#[derive(Debug, Clone, PartialEq)]
pub struct StartAuthorization {
pub context: MutationContext,
@@ -394,7 +394,7 @@ pub struct StartAuthorization {
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// OAuth 流程启动结果。
/// OAuth 流程启动结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizationStarted {
pub flow_id: String,
@@ -402,35 +402,37 @@ pub struct AuthorizationStarted {
pub expires_at: DateTime<Utc>,
}
/// refresh/rotate/reauthorize 的 lease 或 completion 生命周期。
/// refresh/rotate/reauthorize 的 lease 或 completion 生命周期
///
/// 该 guard 不可 Clone。Admin 只能在 Store CAS 与审计事务成功后调用 `finish`;失败路径直接
/// drop,使 Provider 可以释放 lease 或执行补偿。
/// 该 guard 不可 Clone
/// Admin 只能在 Store CAS 与审计事务成功后调用 `finish`;失败路径直接
/// drop,使 Provider 可以释放 lease 或执行补偿
pub trait CredentialCommitGuard: Send + 'static {
fn finish(self: Box<Self>);
}
/// OAuth pending claim 在 Store 事务结果确定后执行的显式结算动作。
/// OAuth pending claim 在 Store 事务结果确定后执行的显式结算动作
///
/// OAuth provider 必须在 credential 准备阶段持有 claim:Store 提交成功后消费 flow,提交或
/// 校验失败后释放 flow。异步结算不能依赖 `Drop`,否则请求提前返回会让同一授权流无谓地
/// 进入短暂的不可重试状态。
/// 校验失败后释放 flow
/// 异步结算不能依赖 `Drop`,否则请求提前返回会让同一授权流无谓地
/// 进入短暂的不可重试状态
#[async_trait]
pub trait AuthorizationCommitGuard: Send + 'static {
/// Store 已提交 OAuth credential 后消费对应的一次性 flow。
/// Store 已提交 OAuth credential 后消费对应的一次性 flow
async fn commit(self: Box<Self>) -> Result<(), AdminError>;
/// Store 未提交 OAuth credential 时释放 claim,允许同一 flow 重试。
/// Store 未提交 OAuth credential 时释放 claim,允许同一 flow 重试
async fn abort(self: Box<Self>) -> Result<(), AdminError>;
}
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者
pub enum PreparedAuthorizationCredential {
Create(Box<PreparedCredentialCreate>),
Reauthorize(Box<PreparedCredentialRotation>),
}
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回
pub struct PreparedAuthorizationCommit {
pub pending: PendingAuthorizationMutation,
pub credential: PreparedAuthorizationCredential,
@@ -447,14 +449,14 @@ impl fmt::Debug for PreparedAuthorizationCommit {
}
}
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard
#[derive(Debug, Clone, PartialEq)]
pub enum AuthorizationCredentialCommit {
Create(Box<PreparedCredentialCreate>),
Reauthorize(Box<PreparedCredentialRotationFacts>),
}
/// Admin 交给 Store 的 OAuth 原子事务命令。
/// Admin 交给 Store 的 OAuth 原子事务命令
#[derive(Debug, Clone, PartialEq)]
pub struct AuthorizationCommit {
pub key: AuthorizationReceiptKey,
@@ -463,7 +465,7 @@ pub struct AuthorizationCommit {
pub credential: AuthorizationCredentialCommit,
}
/// 回执以 Provider、授权流和管理员身份绑定;不持久化原始 flow 或回调材料。
/// 回执以 Provider、授权流和管理员身份绑定;不持久化原始 flow 或回调材料
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthorizationReceiptKey {
provider_kind: ProviderKind,
@@ -531,10 +533,10 @@ pub struct AuthorizationCommitResult {
pub newly_committed: bool,
}
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard
///
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
/// 释放 OAuth claim,使同一授权流可以重试。
/// 释放 OAuth claim,使同一授权流可以重试
pub(crate) struct AuthorizationCommitSettlement {
pub(crate) command: AuthorizationCommit,
pub(crate) credential_guard: Option<Box<dyn CredentialCommitGuard>>,
@@ -554,7 +556,7 @@ impl PreparedAuthorizationCommit {
}
}
/// 让 OAuth claim 覆盖准备完成到 Store 事务结算之间的全部窗口。
/// 让 OAuth claim 覆盖准备完成到 Store 事务结算之间的全部窗口
#[must_use]
pub fn with_authorization_guard(mut self, guard: Box<dyn AuthorizationCommitGuard>) -> Self {
self.authorization_guard = Some(guard);
@@ -599,7 +601,7 @@ impl PreparedAuthorizationCommit {
}
}
/// 完成 Provider OAuth 流程。
/// 完成 Provider OAuth 流程
#[derive(Clone, PartialEq, Eq)]
pub struct CompleteAuthorization {
pub settings: Option<AccountImportSettings>,
@@ -619,14 +621,14 @@ impl fmt::Debug for CompleteAuthorization {
}
}
/// Credential 生命周期写操作。
/// Credential 生命周期写操作
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialMutation {
pub context: MutationContext,
pub account_id: ProviderAccountId,
}
/// Credential 写入提交结果。
/// Credential 写入提交结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialMutationResult {
pub config_revision: Revision,
@@ -634,28 +636,28 @@ pub struct CredentialMutationResult {
pub credential_revision: Option<Revision>,
}
/// 同一 Provider 管理范围内的 credential 批量删除。
/// 同一 Provider 管理范围内的 credential 批量删除
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDeletion {
pub context: MutationContext,
pub account_ids: Vec<ProviderAccountId>,
}
/// Credential 批量删除提交结果。
/// Credential 批量删除提交结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDeletionResult {
pub config_revision: Revision,
pub account_ids: Vec<ProviderAccountId>,
}
/// Provider-owned token 轮换命令。
/// Provider-owned token 轮换命令
pub struct RotateCredential {
pub mutation: CredentialMutation,
pub provider_material: ProviderDocument,
pub settings: Option<super::accounts::UpdateAccount>,
}
/// Provider 校验手工轮换材料时所需的非事务输入。
/// Provider 校验手工轮换材料时所需的非事务输入
pub struct PrepareCredentialRotation {
pub account: AccountRecord,
pub provider_material: ProviderDocument,
@@ -671,7 +673,7 @@ impl fmt::Debug for PrepareCredentialRotation {
}
}
/// Provider 已验证、可由 Store 以 credential revision CAS 原子提交的轮换 facts。
/// Provider 已验证、可由 Store 以 credential revision CAS 原子提交的轮换 facts
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialRotationFacts {
pub account_id: ProviderAccountId,
@@ -681,9 +683,9 @@ pub struct PreparedCredentialRotationFacts {
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐
pub preserve_profile: bool,
/// 连接配置变更保留当前凭据健康状态与错误事实。
/// 连接配置变更保留当前凭据健康状态与错误事实
pub preserve_credential_state: bool,
pub provider_material: ProviderDocument,
pub has_refresh_token: bool,
@@ -691,7 +693,7 @@ pub struct PreparedCredentialRotationFacts {
pub next_refresh_at: Option<DateTime<Utc>>,
}
/// Provider 返回的轮换准备结果;guard 必须覆盖后续 Store CAS 与审计事务。
/// Provider 返回的轮换准备结果;guard 必须覆盖后续 Store CAS 与审计事务
pub struct PreparedCredentialRotation {
facts: PreparedCredentialRotationFacts,
guard: Box<dyn CredentialCommitGuard>,
@@ -706,7 +708,7 @@ impl PreparedCredentialRotation {
Self { facts, guard }
}
/// 仅修改连接配置时,不把配置更新视为凭据恢复成功。
/// 仅修改连接配置时,不把配置更新视为凭据恢复成功
#[must_use]
pub fn preserving_credential_state(mut self) -> Self {
self.facts.preserve_credential_state = true;
@@ -739,7 +741,7 @@ impl fmt::Debug for PreparedCredentialRotation {
}
}
/// Admin 交给 Store 的轮换或 refresh 事务命令。
/// Admin 交给 Store 的轮换或 refresh 事务命令
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialRotationCommit {
pub prepared: PreparedCredentialRotationFacts,
@@ -757,16 +759,16 @@ impl fmt::Debug for RotateCredential {
}
}
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口。
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaLocalUsageAttribution {
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合。
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合
AccountWide,
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用。
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用
Unavailable,
}
/// Provider quota bucket 中窗口的官方位置语义。
/// Provider quota bucket 中窗口的官方位置语义
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderQuotaWindowRole {
Primary,
@@ -785,7 +787,7 @@ impl ProviderQuotaWindowRole {
}
}
/// 一个 Provider quota 窗口的公共投影。
/// 一个 Provider quota 窗口的公共投影
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
@@ -803,14 +805,14 @@ pub struct ProviderQuotaWindow {
pub provider_data: Option<ProviderDocument>,
}
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板。
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccountUsagePeriod {
Weekly,
Monthly,
}
/// Provider 解释 quota 所需的公共请求事实。
/// Provider 解释 quota 所需的公共请求事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderQuotaRequest {
pub account_id: ProviderAccountId,
@@ -818,20 +820,29 @@ pub struct ProviderQuotaRequest {
pub rolling_usage: Option<AccountUsage>,
}
/// Provider 已解析的 quota 结果及其不透明差异字段。
/// Provider 已解析的 quota 结果及其不透明差异字段
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuota {
/// 上游额度响应明确提供的套餐,可用于补全账号展示。
/// 上游额度响应明确提供的套餐,可用于补全账号展示
pub plan_type: Option<String>,
pub observed_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
pub credits: Option<ProviderQuotaCredits>,
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生
pub limit_reached: bool,
pub provider_data: Option<ProviderDocument>,
}
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
/// 上游点数余额的展示事实,保留原始十进制文本以避免精度损失
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderQuotaCredits {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
value.filter(|value| {
let value = value.trim();
@@ -839,7 +850,7 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
})
}
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
/// 按需查询的订阅周期,不参与额度或账号可用性判断
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSubscription {
pub starts_at: Option<DateTime<Utc>>,
@@ -850,14 +861,14 @@ pub struct ProviderSubscription {
pub observed_at: DateTime<Utc>,
}
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
#[derive(Debug, Clone, PartialEq)]
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果
#[derive(Debug, Clone)]
pub struct AccountPersonalInfo {
pub profile: Result<ProviderProfileStatistics, AdminError>,
pub subscription: Option<ProviderSubscription>,
}
/// Provider 官方个人资料中的累计摘要。
/// Provider 官方个人资料中的累计摘要
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
pub total_text_tokens: Option<u64>,
@@ -867,14 +878,14 @@ pub struct ProviderProfileStatisticsSummary {
pub longest_streak_days: Option<u64>,
}
/// Provider 官方个人资料中的单日 Token bucket。
/// Provider 官方个人资料中的单日 Token bucket
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileDailyUsage {
pub date: NaiveDate,
pub tokens: u64,
}
/// Provider 官方个人资料中的插件或 Skill 调用排行。
/// Provider 官方个人资料中的插件或 Skill 调用排行
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileInvocation {
pub invocation_type: String,
@@ -885,7 +896,7 @@ pub struct ProviderProfileInvocation {
pub usage_count: Option<u64>,
}
/// Provider 官方个人资料中的活动洞察。
/// Provider 官方个人资料中的活动洞察
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileActivityInsights {
pub fast_mode_percent: Option<f64>,
@@ -897,7 +908,7 @@ pub struct ProviderProfileActivityInsights {
pub invocations: Option<Vec<ProviderProfileInvocation>>,
}
/// Provider 已解释的官方个人资料统计。
/// Provider 已解释的官方个人资料统计
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileStatistics {
pub display_name: Option<String>,
@@ -909,16 +920,16 @@ pub struct ProviderProfileStatistics {
pub activity_insights: ProviderProfileActivityInsights,
}
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数。
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数
pub type ProviderProfileAvatarStream =
Pin<Box<dyn Stream<Item = Result<Bytes, ProviderProfileAvatarStreamError>> + Send + 'static>>;
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文。
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文
#[derive(Debug, thiserror::Error)]
#[error("provider profile avatar stream failed")]
pub struct ProviderProfileAvatarStreamError;
/// Provider 已校验并打开的官方头像响应。
/// Provider 已校验并打开的官方头像响应
pub struct ProviderProfileAvatar {
pub content_type: Option<String>,
pub content_length: Option<u64>,
@@ -938,7 +949,7 @@ impl fmt::Debug for ProviderProfileAvatar {
}
}
/// Provider 返回的一张安全主动额度重置卡。
/// Provider 返回的一张安全主动额度重置卡
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredit {
pub id: String,
@@ -948,14 +959,14 @@ pub struct ProviderResetCredit {
pub reset_type: Option<String>,
}
/// Provider 主动额度重置卡列表。
/// Provider 主动额度重置卡列表
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredits {
pub available_count: u64,
pub credits: Vec<ProviderResetCredit>,
}
/// 一次主动额度重置卡消费命令。
/// 一次主动额度重置卡消费命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumeProviderResetCredit {
pub account_id: ProviderAccountId,
@@ -963,7 +974,7 @@ pub struct ConsumeProviderResetCredit {
pub redeem_request_id: Uuid,
}
/// Provider 返回的主动额度重置卡消费结果。
/// Provider 返回的主动额度重置卡消费结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCreditResult {
pub code: String,
@@ -971,17 +982,18 @@ pub struct ProviderResetCreditResult {
}
impl ProviderQuota {
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全。
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全
pub(crate) fn fill_missing_plan_type(&self, account_plan_type: &mut Option<String>) {
if explicit_plan_type(account_plan_type.as_deref()).is_none() {
*account_plan_type = explicit_plan_type(self.plan_type.as_deref()).map(str::to_owned);
}
}
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实。
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实
///
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
/// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。
/// Provider 投影顺序
/// 没有符合条件的窗口时不回退到短期或历史累计
#[must_use]
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
self.usage_windows()
@@ -989,7 +1001,7 @@ impl ProviderQuota {
.min_by_key(|(_, period)| *period)
}
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量。
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量
pub(crate) fn usage_windows(
&self,
) -> impl Iterator<Item = (&ProviderQuotaWindow, AccountUsagePeriod)> {
@@ -1011,11 +1023,12 @@ impl ProviderQuota {
})
}
/// 返回 Dashboard 使用的代表性额度比例。
/// 返回 Dashboard 使用的代表性额度比例
///
/// 优先使用覆盖账号全部请求的窗口;同一归属范围内再依次使用短周期、周、月
/// 和其它窗口,同一优先级取较高的已用比例。这里只解释跨 Provider 共享的窗口
/// 语义,绝不读取 Provider 私有 JSON。
/// 和其它窗口,同一优先级取较高的已用比例
/// 这里只解释跨 Provider 共享的窗口
/// 语义,绝不读取 Provider 私有 JSON
#[must_use]
pub fn representative_used_percent(&self) -> Option<f64> {
if self.limit_reached {
@@ -1025,7 +1038,7 @@ impl ProviderQuota {
.map(|(_, used_percent)| used_percent)
}
/// 选择 Dashboard 展示的真实窗口;没有百分比时仍保留 Provider 的滚动窗口语义。
/// 选择 Dashboard 展示的真实窗口;没有百分比时仍保留 Provider 的滚动窗口语义
#[must_use]
pub fn representative_window(&self) -> Option<&ProviderQuotaWindow> {
self.representative_used_window()
@@ -1039,10 +1052,10 @@ impl ProviderQuota {
})
}
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
/// 将已确认的账号级额度耗尽事实投影到展示窗口
///
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽
pub fn apply_limit_reached_display(&mut self) {
if !self.limit_reached {
return;
@@ -1122,21 +1135,21 @@ fn is_week_window(seconds: u64) -> bool {
seconds > 0 && seconds.abs_diff(WEEK_SECONDS) <= WEEK_SECONDS / 20
}
/// Provider 实时模型目录的一项。
/// Provider 实时模型目录的一项
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModel {
pub id: UpstreamModelId,
pub name: String,
}
/// Provider 实时模型目录。
/// Provider 实时模型目录
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModels {
pub models: Vec<ProviderModel>,
pub observed_at: Option<DateTime<Utc>>,
}
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段。
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelCatalogDocument {
pub document: RawJsonPayload,
@@ -1144,7 +1157,7 @@ pub struct ProviderModelCatalogDocument {
pub observed_at: DateTime<Utc>,
}
/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页。
/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginAccountListQuery {
pub provider_kind: Option<ProviderKind>,
@@ -1158,7 +1171,7 @@ pub struct PluginAccountPage {
pub next_cursor: Option<ProviderAccountId>,
}
/// 原始凭据与当前账号 revision 的同一读取结果。
/// 原始凭据与当前账号 revision 的同一读取结果
#[derive(Clone, PartialEq)]
pub struct PluginAccountCredential {
pub account: AccountRecord,
@@ -1175,7 +1188,7 @@ impl fmt::Debug for PluginAccountCredential {
}
}
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts。
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts
#[derive(Debug, Clone, PartialEq)]
pub enum PreparedPluginAccountSave {
Create(PreparedCredentialCreate),
@@ -1192,20 +1205,20 @@ pub struct PluginAccountSaveResult {
pub credential_revision: Revision,
}
/// Provider 执行 refresh 时所需的当前公共账号事实。
/// Provider 执行 refresh 时所需的当前公共账号事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrepareCredentialRefresh {
pub account: AccountRecord,
}
/// Provider 敏感导出结果。
/// Provider 敏感导出结果
pub struct ProviderExport {
pub provider_kind: ProviderKind,
pub account_ids: Vec<ProviderAccountId>,
pub document: ProviderDocument,
}
/// Store 为 Provider 导出序列化准备的最小输入;material 对公共层保持不透明。
/// Store 为 Provider 导出序列化准备的最小输入;material 对公共层保持不透明
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderExportCredentialInput {
pub account: AccountRecord,
@@ -1223,20 +1236,20 @@ impl fmt::Debug for ProviderExport {
}
}
/// 统一账号目录的一行完整结果。
/// 统一账号目录的一行完整结果
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub capacity: super::accounts::AccountCapacity,
pub capabilities: super::accounts::ProviderAccountCapabilities,
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
/// Provider 提供的套餐展示名称;未识别到套餐时为空
pub plan_type_display: Option<String>,
pub projection: gateway_core::account::AccountStatusProjection,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
}
/// 统一账号目录页。
/// 统一账号目录页
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryPage {
pub config_revision: Revision,
@@ -1245,14 +1258,14 @@ pub struct AccountDirectoryPage {
pub summary: AccountSummary,
}
/// 凭据刷新提交后的完整账号结果。
/// 凭据刷新提交后的完整账号结果
#[derive(Debug, Clone, PartialEq)]
pub struct AccountRefreshResult {
pub config_revision: Revision,
pub account: AccountDirectoryItem,
}
/// 多 Provider 导出文档集合。
/// 多 Provider 导出文档集合
pub struct AccountExportBundle {
pub exported_at: DateTime<Utc>,
pub documents: Vec<ProviderExport>,
@@ -1,4 +1,4 @@
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果
use chrono::{DateTime, Utc};
use gateway_core::account::{OutboundProxy, RequestLocation};
@@ -39,7 +39,7 @@ pub struct ProxyAccountRef {
pub enabled: bool,
}
/// 按代理查询关联账号,分页与搜索均在存储层执行。
/// 按代理查询关联账号,分页与搜索均在存储层执行
#[derive(Debug, Clone)]
pub struct ProxyAccountListQuery {
pub proxy_id: String,
@@ -94,7 +94,7 @@ pub struct ProxyPage {
#[derive(Debug, Clone)]
pub struct NewProxy {
pub auto_location: bool,
/// 由管理用例探测,随配置原子保存,不接受 HTTP 调用方提供结果。
/// 由管理用例探测,随配置原子保存,不接受 HTTP 调用方提供结果
pub test: Option<ProxyTestResult>,
pub location: Option<gateway_core::account::RequestLocation>,
pub name: String,
@@ -105,7 +105,7 @@ pub struct NewProxy {
pub struct UpdateProxy {
pub auto_location: Option<bool>,
pub test: Option<ProxyTestResult>,
/// 外层为空保留配置,内层为空恢复全局继承。
/// 外层为空保留配置,内层为空恢复全局继承
pub location: Option<Option<gateway_core::account::RequestLocation>>,
pub id: String,
pub revision: Revision,
@@ -119,7 +119,7 @@ pub struct ProxyMutation {
pub record: ProxyRecord,
}
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置。
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ProxyLocationDetection {
@@ -155,7 +155,7 @@ impl ProxyRecord {
}
}
/// 未知出口可暂用上次成功结果,已知出口变化或时区冲突必须撤销旧覆盖。
/// 未知出口可暂用上次成功结果,已知出口变化或时区冲突必须撤销旧覆盖
#[must_use]
pub fn detected_location_after_test(
&self,
@@ -1,4 +1,4 @@
//! 账号额度的只读容量估算,不参与金额结算或账号调度。
//! 账号额度的只读容量估算,不参与金额结算或账号调度
use chrono::{DateTime, Duration, Utc};
@@ -40,9 +40,9 @@ pub struct QuotaForecastSource {
pub usd: Option<f64>,
}
/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口。
/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口
///
/// Admin 的历史查询与最终投影必须共用这里的顺序,避免查询不会进入响应的窗口。
/// Admin 的历史查询与最终投影必须共用这里的顺序,避免查询不会进入响应的窗口
pub(crate) fn quota_forecast_source_window(
quota: &ProviderQuota,
period: AccountUsagePeriod,
@@ -53,8 +53,8 @@ pub(crate) fn quota_forecast_source_window(
.or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period))
}
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度
#[must_use]
pub fn account_quota_forecasts(
quota: &ProviderQuota,
@@ -178,9 +178,9 @@ impl AccountQuotaForecast {
};
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度
let factor = self.target_seconds as f64 / seconds as f64;
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
let remaining_tokens = Some(sample.usage.tokens)
@@ -1,10 +1,10 @@
//! 预测专用的配对采样;不改变完整交付用量或费用账本的统计口径。
//! 预测专用的配对采样;不改变完整交付用量或费用账本的统计口径
use chrono::{DateTime, Utc};
use super::provider_credentials::ProviderDocument;
/// 限制带 Provider 文档的历史点数量;累计用量仍覆盖整个查询区间。
/// 限制带 Provider 文档的历史点数量;累计用量仍覆盖整个查询区间
pub const MAX_FORECAST_HISTORY_POINTS: usize = 128;
const MIN_BLOCK_PERCENT: f64 = 5.0;
const RECENT_BLOCKS: usize = 3;
@@ -24,7 +24,7 @@ pub struct QuotaForecastUsage {
}
impl QuotaForecastUsage {
/// 累计计数倒退表示不能配对,不用饱和减法掩盖数据断点。
/// 累计计数倒退表示不能配对,不用饱和减法掩盖数据断点
fn difference(&self, baseline: &Self) -> Option<Self> {
let usd = self.usd - baseline.usd;
if !usd.is_finite() || usd < -1e-9 {
@@ -68,7 +68,7 @@ pub struct QuotaForecastHistory {
pub pending_request_count: u64,
}
/// 具体 Provider 从自己的历史文档解释出的额度事实。
/// 具体 Provider 从自己的历史文档解释出的额度事实
#[derive(Debug, Clone, PartialEq)]
pub struct QuotaForecastObservation {
pub used_percent: f64,
@@ -101,13 +101,13 @@ pub struct QuotaForecastSample {
pub observation_count: usize,
pub pending_request_count: u64,
pub discontinuous: bool,
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值
pub cycle_usage: QuotaForecastUsage,
pub usage: QuotaForecastUsage,
}
/// 对同一额度段的累计点形成至少 5 个百分点的连续块,使用最近三个完整块及尾部。
/// 不平均逐请求比值;重复百分比不增加块数,5/10 个百分点仅是量化噪声门槛。
/// 对同一额度段的累计点形成至少 5 个百分点的连续块,使用最近三个完整块及尾部
/// 不平均逐请求比值;重复百分比不增加块数,5/10 个百分点仅是量化噪声门槛
#[must_use]
pub fn select_forecast_sample(
key: String,
@@ -139,7 +139,7 @@ pub fn select_forecast_sample(
usage: current.usage.clone(),
};
history.push(current);
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量
let mut cycle_baseline = interrupted.then(|| &history[0]);
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
let mut high_water: Option<&QuotaForecastPoint> = None;
@@ -149,7 +149,7 @@ pub fn select_forecast_sample(
continue;
}
if point.used_percent < previous.used_percent {
// 一个百分点内的回落可能是量化/并发乱序;更大的回落不能跨越。
// 一个百分点内的回落可能是量化/并发乱序;更大的回落不能跨越
if previous.used_percent - point.used_percent <= 1.0 {
continue;
}
@@ -198,7 +198,7 @@ pub fn select_forecast_sample(
sample.sampled_percent = progress;
sample.block_count = anchors.len() - baseline_index - 1;
sample.usage = usage;
// 断点后的新基线已有足够进度时,只消费新段;旧段从不参与本次估算。
// 断点后的新基线已有足够进度时,只消费新段;旧段从不参与本次估算
sample.discontinuous = false;
sample
}
@@ -1,4 +1,4 @@
//! 历史数据保留规则;存储实现只接受已校验的窗口。
//! 历史数据保留规则;存储实现只接受已校验的窗口
use super::AdminError;
@@ -17,8 +17,26 @@ pub struct RetentionPolicy {
}
impl RetentionPolicy {
/// 保留期管理与全量设置保存共用的字段约束
pub fn validate_values(
usage_days: u32,
ops_days: u32,
audit_days: u32,
) -> Result<(), &'static str> {
for (valid, field) in [
(usage_days >= 31, "usage_retention_days"),
(ops_days > 0, "ops_event_retention_days"),
(audit_days > 0, "audit_retention_days"),
] {
if !valid {
return Err(field);
}
}
Ok(())
}
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
if Self::validate_values(usage_days, ops_days, audit_days).is_err() {
return Err(AdminError::invalid(
"请求保留期至少 31 天,事件与审计保留期必须为正数",
));
@@ -1,4 +1,4 @@
//! Runtime settings 与明文管理员 API Key 的语义模型。
//! Runtime settings 与明文管理员 API Key 的语义模型
use std::{collections::BTreeMap, fmt};
@@ -8,96 +8,104 @@ use gateway_core::routing::{ProviderKind, PublicModelId, UpstreamModelId};
use super::Revision;
/// 客户端模型到上游模型的全局精确映射。
/// 客户端模型到上游模型的全局精确映射
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间。
/// 全局模型映射的数量与文本约束;调用方保留各自的键值表示
pub fn validate_model_mappings<'a>(
mappings: impl ExactSizeIterator<Item = (&'a str, &'a str)>,
) -> Result<(), &'static str> {
if mappings.len() > 512 {
return Err("model_mappings");
}
for (requested, upstream) in mappings {
for value in [requested, upstream] {
if value.is_empty()
|| value.len() > 256
|| value.bytes().any(|byte| byte.is_ascii_control())
{
return Err("model_mappings");
}
}
}
Ok(())
}
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间
pub type ProviderRequestProfiles =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择。
/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择
pub type ProviderRequestProfileUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
/// 运行设置的共同值;版本、Provider 画像更新和数据库秘密由外层类型拥有
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RuntimeSettingsValues {
pub codex_privacy_policy: gateway_core::settings::privacy::CodexPrivacyPolicy,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 完整运行设置事实
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
#[serde(flatten)]
pub values: RuntimeSettingsValues,
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub updated_at: DateTime<Utc>,
}
/// 原子替换运行设置的命令。
/// 原子替换运行设置的命令
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceRuntimeSettings {
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。
#[serde(flatten)]
pub values: RuntimeSettingsValues,
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
/// 只覆盖提交的 Provider;未提交项保留当前持久值
pub request_profile_updates: ProviderRequestProfileUpdates,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct AdminApiKey(String);
@@ -113,7 +121,7 @@ impl AdminApiKey {
&self.0
}
/// 仅供显式 regenerate 响应读取一次。
/// 仅供显式 regenerate 响应读取一次
#[must_use]
pub fn expose_for_response(&self) -> &str {
&self.0
@@ -126,7 +134,7 @@ impl fmt::Debug for AdminApiKey {
}
}
/// 管理员 API Key 更新结果。
/// 管理员 API Key 更新结果
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminApiKeyMutation {
@@ -134,7 +142,7 @@ pub struct AdminApiKeyMutation {
pub exists: bool,
}
/// 新管理员 API Key 的一次性返回结果。
/// 新管理员 API Key 的一次性返回结果
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegeneratedAdminApiKey {
@@ -172,35 +180,63 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
.into_iter()
.map(|(provider, value)| (provider, Some(value)))
.collect(),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
values: settings.values,
model_mappings: settings.model_mappings,
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: settings.refresh_concurrency,
max_concurrent_per_account: settings.max_concurrent_per_account,
request_interval_ms: settings.request_interval_ms,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
}
}
}
impl RuntimeSettingsValues {
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
pub fn validate(&self) -> Result<(), &'static str> {
self.request_location
.validate()
.map_err(|_| "request_location")?;
gateway_core::settings::validate_request_limits(
self.max_waiting_per_key,
self.max_waiting_per_account,
self.concurrency_wait_timeout_seconds,
self.max_account_rotations,
self.openai_session_affinity_ttl_hours,
)?;
gateway_core::settings::response_body_limit(self.responses_max_decompressed_body_bytes)?;
gateway_core::settings::client_min_versions(
self.min_codex_desktop_version.as_deref(),
self.min_codex_cli_version.as_deref(),
)?;
for (valid, field) in [
(
self.refresh_margin_seconds > 0
&& i64::try_from(self.refresh_margin_seconds).is_ok(),
"refresh_margin_seconds",
),
(self.refresh_concurrency > 0, "refresh_concurrency"),
(
i64::try_from(self.request_interval_ms).is_ok(),
"request_interval_ms",
),
] {
if !valid {
return Err(field);
}
}
super::retention::RetentionPolicy::validate_values(
self.usage_retention_days,
self.ops_event_retention_days,
self.audit_retention_days,
)?;
gateway_core::provider_ports::ProviderFreezePolicy::validate_values(
self.account_auto_freeze_threshold,
self.account_auto_freeze_window_seconds,
self.account_auto_freeze_duration_seconds,
self.account_auto_freeze_probe_model.as_deref(),
)?;
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
self.account_warmup_enabled,
&self.account_warmup_schedule_time,
self.account_warmup_model.as_deref(),
)?;
Ok(())
}
}
@@ -1,9 +1,9 @@
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
//! 版本、自更新、回滚与进程重启的 UTC 语义模型
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SystemUpdateChannel {
@@ -28,7 +28,7 @@ impl SystemUpdateChannel {
}
}
/// Host 返回的本次检查通道及当前构建允许选择的范围。
/// Host 返回的本次检查通道及当前构建允许选择的范围
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemUpdatePolicy {
@@ -36,9 +36,9 @@ pub struct SystemUpdatePolicy {
pub available_channels: Vec<SystemUpdateChannel>,
}
/// 当前运行版本及更新检查摘要。
/// 当前运行版本及更新检查摘要
///
/// `deployment_mode_label` 之类的本地化字符串由 API 根据原始枚举值生成。
/// `deployment_mode_label` 之类的本地化字符串由 API 根据原始枚举值生成
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemVersion {
pub version: String,
@@ -52,9 +52,9 @@ pub struct SystemVersion {
pub update_warning: Option<String>,
}
/// 发布源提供的完整更新详情。
/// 发布源提供的完整更新详情
///
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateDetail {
pub policy: SystemUpdatePolicy,
@@ -71,7 +71,7 @@ pub struct SystemUpdateDetail {
pub warning: Option<String>,
}
/// 系统操作类型。
/// 系统操作类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemOperationKind {
Update,
@@ -79,7 +79,7 @@ pub enum SystemOperationKind {
Restart,
}
/// 系统操作状态。
/// 系统操作状态
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemOperationStatus {
Idle,
@@ -88,7 +88,7 @@ pub enum SystemOperationStatus {
Failed,
}
/// 持久化系统操作的完整状态。
/// 持久化系统操作的完整状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemOperationState {
pub operation_id: Option<String>,
@@ -101,7 +101,7 @@ pub struct SystemOperationState {
pub finished_at: Option<DateTime<Utc>>,
}
/// 最近一次系统操作及版本交换状态。
/// 最近一次系统操作及版本交换状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateStatus {
pub previous_version: Option<String>,
@@ -110,7 +110,7 @@ pub struct SystemUpdateStatus {
pub operation: SystemOperationState,
}
/// 自更新日志级别。
/// 自更新日志级别
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemUpdateEventLevel {
Info,
@@ -119,7 +119,7 @@ pub enum SystemUpdateEventLevel {
Error,
}
/// 一条已脱敏的系统更新事件。
/// 一条已脱敏的系统更新事件
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateEvent {
pub id: String,
@@ -132,7 +132,7 @@ pub struct SystemUpdateEvent {
pub occurred_at: DateTime<Utc>,
}
/// 已受理的异步系统操作;不同 wire shape 由枚举保持为合法状态。
/// 已受理的异步系统操作;不同 wire shape 由枚举保持为合法状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemOperationAccepted {
Update {
@@ -163,7 +163,7 @@ impl SystemOperationAccepted {
}
}
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SystemRestartPlan {
@@ -1,9 +1,10 @@
//! 备份控制面所需的持久化与基础设施能力。
//! 备份控制面所需的持久化与基础设施能力
//!
//! 端口方法只接受/返回领域类型与既有错误体系:`BackupRepository` 复用
//! `AdminStoreError`;`DatabaseDumpPort` 与 `BackupObjectStorePort` 返回只携带稳定
//! 错误码的 [`BackupError`]。`gateway-store` 实现全部端口;`gateway-admin` 拥有
//! 状态机、Cron、保留策略与 BackupTask 算法。
//! 错误码的 [`BackupError`]
//! `gateway-store` 实现全部端口;`gateway-admin` 拥有
//! 状态机、Cron、保留策略与 BackupTask 算法
use std::path::PathBuf;
use std::sync::Arc;
@@ -23,7 +24,7 @@ use crate::model::{
};
use crate::ports::store::AdminStoreResult;
/// Worker 状态迁移携带的字段更新;`None` 字段不写入。
/// Worker 状态迁移携带的字段更新;`None` 字段不写入
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatusTransitionUpdate {
pub size_bytes: Option<u64>,
@@ -33,66 +34,75 @@ pub struct StatusTransitionUpdate {
pub completed_at: Option<DateTime<Utc>>,
}
/// 备份配置、任务与计划游标的 PostgreSQL 仓储端口。
/// 备份配置、任务与计划游标的 PostgreSQL 仓储端口
#[async_trait]
pub trait BackupRepository: Send + Sync {
/// 读取完整设置(含 Secret)。
/// 读取完整设置(含 Secret)
async fn load_settings(&self) -> AdminStoreResult<BackupSettings>;
/// 同事务更新 S3 存储配置、递增 revision、清空验证状态并写审计事件。
/// 同事务更新 S3 存储配置、递增 revision、清空验证状态并写审计事件
///
/// 已有记录时禁止切换 endpoint/region/bucket/path-style(存储身份锁定)。
/// 返回更新后的设置与新的全局 `config_revision`。
/// 已有记录时禁止切换 endpoint/region/bucket/path-style(存储身份锁定)
/// 返回更新后的设置与新的全局 `config_revision`
async fn update_storage_settings(
&self,
command: UpdateBackupStorageCommand,
context: &MutationContext,
) -> AdminStoreResult<(BackupSettings, Revision)>;
/// 同事务更新调度配置、推进计划游标并写审计事件。
/// 同事务更新调度配置、推进计划游标并写审计事件
async fn update_schedule_settings(
&self,
command: UpdateBackupScheduleCommand,
next_run_at: Option<DateTime<Utc>>,
context: &MutationContext,
timezone: gateway_core::time::DeploymentTimeZone,
) -> AdminStoreResult<BackupSettings>;
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)。
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)
async fn record_verification(
&self,
storage_revision: u64,
at: DateTime<Utc>,
) -> AdminStoreResult<bool>;
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`。
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
/// 幂等插入 scheduled 任务;同一时间点或活跃冲突时返回 `false`(调用方推进游标即可)。
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool>;
/// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务
/// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`
async fn insert_scheduled_record(
&self,
seed: BackupRecordSeed,
next_run_at: Option<DateTime<Utc>>,
expected_cron: &str,
expected_timezone: &str,
expected_next_run_at: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool>;
/// 分页查询记录。
/// 分页查询记录
async fn list_backup_records(
&self,
query: BackupRecordListQuery,
) -> AdminStoreResult<BackupRecordPage>;
/// 读取单条记录。
/// 读取单条记录
async fn load_backup_record(&self, id: &str) -> AdminStoreResult<Option<BackupRecord>>;
/// 读取中间状态记录(dumping/uploading),供启动恢复。
/// 读取中间状态记录(dumping/uploading),供启动恢复
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>>;
/// 读取等待完成墓碑的记录(status = deleting)。
/// 读取等待完成墓碑的记录(status = deleting)
async fn list_pending_deletions(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
/// 读取 `expires_at` 已到期且非活跃的记录(completed/failed),供保留清理。
/// 读取 `expires_at` 已到期且非活跃的记录(completed/failed),供保留清理
async fn list_expired_records(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
/// 原子领取最旧的 queued 任务并迁移到 dumping;无任务返回 `None`。
/// 原子领取最旧的 queued 任务并迁移到 dumping;无任务返回 `None`
async fn claim_next_queued(&self, now: DateTime<Utc>)
-> AdminStoreResult<Option<BackupRecord>>;
/// 条件迁移状态:要求当前状态一致;不匹配返回 `None`。
/// 条件迁移状态:要求当前状态一致;不匹配返回 `None`
async fn transition_status(
&self,
id: &str,
@@ -101,53 +111,56 @@ pub trait BackupRepository: Send + Sync {
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>>;
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用。
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用
async fn transition_to_deleting(
&self,
id: &str,
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>>;
/// 远端删除成功后硬删除记录行。
/// 远端删除成功后硬删除记录行
async fn delete_record(&self, id: &str) -> AdminStoreResult<()>;
/// 条件推进计划游标;仅当计划表达式和时区仍是 `expected_*` 时才写入,返回是否推进。
/// 条件推进计划游标;仅当启用状态、计划表达式、时区及旧游标仍匹配时才写入,返回是否推进
async fn advance_schedule_cursor(
&self,
next_run_at: DateTime<Utc>,
expected_cron: &str,
expected_timezone: &str,
expected_timezone: Option<&str>,
expected_next_run_at: Option<DateTime<Utc>>,
timezone: &str,
) -> AdminStoreResult<bool>;
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)。
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)
async fn list_scheduled_completed_desc(
&self,
limit: u32,
) -> AdminStoreResult<Vec<BackupRecord>>;
}
/// `pg_dump` 导出与本地暂存端口。
/// `pg_dump` 导出与本地暂存端口
#[async_trait]
pub trait DatabaseDumpPort: Send + Sync {
/// 创建归档;完成后原子落盘。取消时终止子进程并清理部分文件。
/// 创建归档;完成后原子落盘
/// 取消时终止子进程并清理部分文件
async fn dump(&self, request: DumpRequest) -> Result<DumpArtifact, BackupError>;
/// 检查暂存目录是否存在完整归档。
/// 检查暂存目录是否存在完整归档
async fn inspect_staging(&self, backup_id: &str)
-> Result<Option<StagedArtifact>, BackupError>;
/// 清理暂存目录中该备份的全部文件。
/// 清理暂存目录中该备份的全部文件
async fn cleanup_staging(&self, backup_id: &str) -> Result<(), BackupError>;
}
/// 一次 `pg_dump` 请求。
/// 一次 `pg_dump` 请求
#[derive(Debug, Clone)]
pub struct DumpRequest {
pub backup_id: String,
pub cancellation: gateway_core::lifecycle::CancellationToken,
}
/// 已完成并落盘的归档事实。
/// 已完成并落盘的归档事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DumpArtifact {
pub path: PathBuf,
@@ -155,7 +168,7 @@ pub struct DumpArtifact {
pub sha256: String,
}
/// 暂存目录中已存在的完整归档。
/// 暂存目录中已存在的完整归档
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StagedArtifact {
pub path: PathBuf,
@@ -163,39 +176,39 @@ pub struct StagedArtifact {
pub sha256: String,
}
/// S3 兼容对象存储适配器端口。
/// S3 兼容对象存储适配器端口
///
/// 方法接受已经校验的存储配置;SDK client 的构造与缓存属于 adapter 实现细节。
/// 方法接受已经校验的存储配置;SDK client 的构造与缓存属于 adapter 实现细节
#[async_trait]
pub trait BackupObjectStorePort: Send + Sync {
/// 对专用探针对象执行 Put/Head/Get/Delete。
/// 对专用探针对象执行 Put/Head/Get/Delete
async fn test_connection(
&self,
config: &BackupStorageConfig,
) -> Result<ConnectionTestResult, BackupError>;
/// 分片流式上传本地归档;取消时中止 multipart 并返回 `backup.cancelled`。
/// 分片流式上传本地归档;取消时中止 multipart 并返回 `backup.cancelled`
async fn upload_file(
&self,
config: &BackupStorageConfig,
request: UploadObjectRequest,
) -> Result<(), BackupError>;
/// 读取对象大小与 metadata;对象不存在返回 `Ok(None)`。
/// 读取对象大小与 metadata;对象不存在返回 `Ok(None)`
async fn head_object(
&self,
config: &BackupStorageConfig,
object_key: &str,
) -> Result<Option<BackupObjectMetadata>, BackupError>;
/// 删除对象;对象不存在视为成功。
/// 删除对象;对象不存在视为成功
async fn delete_object(
&self,
config: &BackupStorageConfig,
object_key: &str,
) -> Result<(), BackupError>;
/// 创建短时预签名下载 URL。
/// 创建短时预签名下载 URL
async fn presigned_download(
&self,
config: &BackupStorageConfig,
@@ -205,7 +218,7 @@ pub trait BackupObjectStorePort: Send + Sync {
) -> Result<String, BackupError>;
}
/// 一次对象上传请求。
/// 一次对象上传请求
#[derive(Debug, Clone)]
pub struct UploadObjectRequest {
pub object_key: String,
@@ -214,7 +227,7 @@ pub struct UploadObjectRequest {
pub cancellation: gateway_core::lifecycle::CancellationToken,
}
/// 备份控制面所需的全部 Store 能力集合。
/// 备份控制面所需的全部 Store 能力集合
#[derive(Clone)]
pub struct BackupStorePorts {
repository: Arc<dyn BackupRepository>,
@@ -223,7 +236,7 @@ pub struct BackupStorePorts {
}
impl BackupStorePorts {
/// 组合备份端口。
/// 组合备份端口
#[must_use]
pub fn new(
repository: Arc<dyn BackupRepository>,
@@ -237,25 +250,25 @@ impl BackupStorePorts {
}
}
/// 备份任务/配置仓储。
/// 备份任务/配置仓储
#[must_use]
pub fn repository(&self) -> Arc<dyn BackupRepository> {
self.repository.clone()
}
/// `pg_dump` 导出与暂存。
/// `pg_dump` 导出与暂存
#[must_use]
pub fn dump(&self) -> Arc<dyn DatabaseDumpPort> {
self.dump.clone()
}
/// S3/R2 对象存储适配器。
/// S3/R2 对象存储适配器
#[must_use]
pub fn object_store(&self) -> Arc<dyn BackupObjectStorePort> {
self.object_store.clone()
}
/// 测试与不可用路径使用的空实现;任何调用都返回不可用错误。
/// 测试与不可用路径使用的空实现;任何调用都返回不可用错误
#[doc(hidden)]
#[must_use]
pub fn disabled() -> Self {
@@ -286,6 +299,7 @@ impl BackupRepository for UnavailableBackupRepository {
_command: UpdateBackupScheduleCommand,
_next_run_at: Option<DateTime<Utc>>,
_context: &MutationContext,
_timezone: gateway_core::time::DeploymentTimeZone,
) -> AdminStoreResult<BackupSettings> {
Err(disabled_store())
}
@@ -302,7 +316,14 @@ impl BackupRepository for UnavailableBackupRepository {
) -> AdminStoreResult<BackupRecord> {
Err(disabled_store())
}
async fn insert_scheduled_record(&self, _seed: BackupRecordSeed) -> AdminStoreResult<bool> {
async fn insert_scheduled_record(
&self,
_seed: BackupRecordSeed,
_next_run_at: Option<DateTime<Utc>>,
_expected_cron: &str,
_expected_timezone: &str,
_expected_next_run_at: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Err(disabled_store())
}
async fn list_backup_records(
@@ -352,7 +373,9 @@ impl BackupRepository for UnavailableBackupRepository {
&self,
_next_run_at: DateTime<Utc>,
_expected_cron: &str,
_expected_timezone: &str,
_expected_timezone: Option<&str>,
_expected_next_run_at: Option<DateTime<Utc>>,
_timezone: &str,
) -> AdminStoreResult<bool> {
Err(disabled_store())
}
@@ -1,4 +1,4 @@
//! 客户端安装包解析的外部能力端口。
//! 客户端安装包解析的外部能力端口
use async_trait::async_trait;
@@ -1,4 +1,4 @@
//! 管理用例依赖的外部能力端口。
//! 管理用例依赖的外部能力端口
pub mod backup;
pub mod client_distribution;
@@ -1,4 +1,4 @@
//! 插件宿主访问账号权威所需的窄端口。
//! 插件宿主访问账号权威所需的窄端口
use async_trait::async_trait;
use gateway_core::account::ProviderAccountId;
@@ -11,7 +11,7 @@ use crate::model::{
},
};
/// Runtime 只取得账号读写用例,不接触 Store、SQL 或完整管理服务集合。
/// Runtime 只取得账号读写用例,不接触 Store、SQL 或完整管理服务集合
#[async_trait]
pub trait PluginAccountAccess: Send + Sync {
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError>;
@@ -26,7 +26,7 @@ pub trait PluginAccountAccess: Send + Sync {
account_id: &ProviderAccountId,
) -> Result<PluginAccountCredential, AdminError>;
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测。
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测
async fn get_quota(
&self,
_account_id: &ProviderAccountId,
@@ -34,7 +34,7 @@ pub trait PluginAccountAccess: Send + Sync {
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
}
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置。
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置
async fn refresh_quota(
&self,
_account_id: &ProviderAccountId,
@@ -1,4 +1,4 @@
//! 插件 Client Key 非秘密目录与受控预算管理端口。
//! 插件 Client Key 非秘密目录与受控预算管理端口
use async_trait::async_trait;
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
@@ -1,5 +1,7 @@
//! 插件管理页面、资源访问与授权回调的运行时端口
use async_trait::async_trait;
use gateway_core::runtime::extensions::ExtensionSetReference;
use gateway_core::routing::extensions::ExtensionSetReference;
use crate::model::{
AdminError,
@@ -9,10 +11,10 @@ use crate::model::{
},
};
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份
#[async_trait]
pub trait PluginManagement: Send + Sync {
/// 复核固定发布代次中的可用目标;调用方已校验持久实例仍为当前版本。
/// 复核固定发布代次中的可用目标;调用方已校验持久实例仍为当前版本
async fn validate_target(
&self,
published: &ExtensionSetReference,
@@ -1,3 +1,5 @@
//! 官方插件发行清单与制品文件的读取端口
use std::sync::Arc;
use async_trait::async_trait;
@@ -9,7 +11,7 @@ pub enum OfficialPluginReleaseReadErrorKind {
Unavailable,
}
/// 文件系统错误不携带部署路径,避免把宿主布局传播到管理域和日志之外。
/// 文件系统错误不携带部署路径,避免把宿主布局传播到管理域和日志之外
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("official plugin release files are unavailable")]
pub struct OfficialPluginReleaseReadError {
@@ -28,7 +30,7 @@ impl OfficialPluginReleaseReadError {
}
}
/// Host 只暴露与宿主发行物同信任边界的只读文件,不解释官方身份或插件包。
/// Host 只暴露与宿主发行物同信任边界的只读文件,不解释官方身份或插件包
#[async_trait]
pub trait OfficialPluginReleaseFiles: Send + Sync {
async fn manifest(&self) -> Result<Option<Arc<[u8]>>, OfficialPluginReleaseReadError>;
@@ -1,4 +1,4 @@
//! 插件自有资源的管理入口与原子持久化端口。
//! 插件自有资源的管理入口与原子持久化端口
use super::store::AdminStoreResult;
use crate::model::{
@@ -1,3 +1,5 @@
//! 插件准备、分发、持久化与私有状态生命周期的外部能力端口
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
@@ -24,12 +26,12 @@ use crate::model::{
PluginCompatibilityRequirements, PluginSource,
},
};
use gateway_core::runtime::extensions::ExtensionSetReference;
use gateway_core::routing::extensions::ExtensionSetReference;
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态
#[async_trait]
pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置
async fn configuration_ready(
&self,
instance: PluginInstance,
@@ -39,14 +41,14 @@ pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
&self,
instance: PluginInstance,
) -> Result<PluginStateConfiguration, AdminError>;
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离
async fn prepare(
&self,
snapshot: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError>;
}
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None。
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None
#[async_trait]
pub trait PluginRuntimeDiagnostics: Send + Sync {
async fn runtime_diagnostics(
@@ -57,7 +59,7 @@ pub trait PluginRuntimeDiagnostics: Send + Sync {
) -> Option<BTreeMap<String, PluginInstanceRuntime>>;
}
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期。
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期
#[async_trait]
pub trait PluginStateLifecycle: Send + Sync {
async fn activate_state(
@@ -103,7 +105,7 @@ impl PluginStateStoreError {
pub type PluginStateStoreResult<T> = Result<T, PluginStateStoreError>;
/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务。
/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务
#[async_trait]
pub trait PluginStateStore: Send + Sync {
async fn load_owner(
@@ -151,7 +153,7 @@ pub trait PluginStateStore: Send + Sync {
async fn abort_transition(&self, transition_id: &str) -> PluginStateStoreResult<()>;
}
/// Host 拥有联网、重定向、凭据作用域、限流与有界下载,不解释插件包。
/// Host 拥有联网、重定向、凭据作用域、限流与有界下载,不解释插件包
#[async_trait]
pub trait PluginDistribution: Send + Sync {
fn validate_source(&self, source: &PluginUpdateSource) -> Result<(), AdminError>;
@@ -170,16 +172,34 @@ pub trait PluginDistribution: Send + Sync {
) -> Result<DownloadedPlugin, AdminError>;
}
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin
#[async_trait]
pub trait PluginPackageInspector: Send + Sync {
/// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开
async fn compatibility_warning(
&self,
archive: Arc<[u8]>,
expected_sha256: String,
) -> Result<Option<String>, AdminError> {
self.inspect(archive, Some(expected_sha256)).await?;
Ok(None)
}
/// 按当前宿主的弃用计划提示旧接口,不改变安装和运行资格
fn api_deprecations(
&self,
_metadata: &crate::model::plugins::PluginArtifactMetadata,
) -> Result<Vec<crate::model::plugins::instances::PluginApiDeprecation>, AdminError> {
Ok(Vec::new())
}
async fn inspect(
&self,
archive: Arc<[u8]>,
expected_sha256: Option<String>,
) -> Result<InspectedPluginArtifact, AdminError>;
/// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例。
/// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例
async fn icon(
&self,
_archive: Arc<[u8]>,
@@ -189,7 +209,7 @@ pub trait PluginPackageInspector: Send + Sync {
Err(AdminError::unavailable("插件图标读取不可用"))
}
/// 只读取已校验包的静态宿主要求;不得准备或启动插件进程。
/// 只读取已校验包的静态宿主要求;不得准备或启动插件进程
async fn compatibility(
&self,
_archive: Arc<[u8]>,
@@ -201,13 +221,13 @@ pub trait PluginPackageInspector: Send + Sync {
#[async_trait]
pub trait PluginStore: Send + Sync {
/// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥。
/// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥
async fn management_target_is_current(
&self,
target: &crate::model::plugins::management::PluginManagementTarget,
) -> AdminStoreResult<bool>;
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot>;
/// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端。
/// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端
async fn load_version_configuration(
&self,
_id: &str,
@@ -215,11 +235,11 @@ pub trait PluginStore: Send + Sync {
) -> AdminStoreResult<Option<PluginVersionConfiguration>> {
Ok(None)
}
/// 只返回有可恢复配置的制品摘要,不读取敏感值。
/// 只返回有可恢复配置的制品摘要,不读取敏感值
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态。
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态
async fn disable_instances(
&self,
_ids: &[String],
@@ -255,7 +275,7 @@ pub trait PluginStore: Send + Sync {
self.save_instance(instance, expected_revision, context)
.await
}
/// 保存目标并停用明确确认的配置,必须共享事务和配置版本检查。
/// 保存目标并停用明确确认的配置,必须共享事务和配置版本检查
async fn save_instance_replacing(
&self,
instance: PluginInstance,
@@ -307,13 +327,13 @@ pub trait PluginStore: Send + Sync {
source: PluginSource,
context: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation>;
/// 确认信任并安装精确摘要的制品;安装事实不随实例设置变化。
/// 确认信任并安装精确摘要的制品;安装事实不随实例设置变化
async fn accept_artifact(
&self,
digest: &str,
context: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation>;
/// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交。
/// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交
async fn delete_artifact(
&self,
digest: &str,
@@ -1,4 +1,4 @@
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现。
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现
use crate::model::{AdminError, pricing::PricingSyncPreview};
use async_trait::async_trait;
@@ -1,4 +1,4 @@
//! 原生 Provider 管理能力与启动时注册表。
//! 原生 Provider 管理能力与启动时注册表
use std::{collections::BTreeMap, sync::Arc};
@@ -26,14 +26,14 @@ use crate::model::{
quota_forecast_sampling::QuotaForecastObservation,
};
/// Provider 管理失败的稳定分类。
/// Provider 管理失败的稳定分类
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAdminErrorKind {
Invalid,
Unsupported,
NotFound,
Conflict,
/// Provider 已发起不可逆操作,但无法确认最终执行结果。
/// Provider 已发起不可逆操作,但无法确认最终执行结果
Ambiguous,
Unavailable,
CredentialRefreshRequired,
@@ -41,16 +41,17 @@ pub enum ProviderAdminErrorKind {
Internal,
}
/// 不携带 OAuth 请求材料的管理错误。
/// 不携带 OAuth 请求材料的管理错误
///
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料
#[derive(Clone, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
#[source]
diagnostic: Option<Arc<ProviderAdminDiagnostic>>,
public_message: Option<&'static str>,
}
@@ -59,7 +60,7 @@ impl std::fmt::Debug for ProviderAdminError {
formatter
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.field("message", &self.message().map(|_| "<redacted>"))
.field("public_message", &self.public_message)
.finish()
}
@@ -70,18 +71,26 @@ impl ProviderAdminError {
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self {
kind,
message: None,
diagnostic: None,
public_message: None,
}
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
Arc::make_mut(self.diagnostic.get_or_insert_with(Default::default)).source =
Some(source.into());
self
}
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料。
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
Arc::make_mut(self.diagnostic.get_or_insert_with(Default::default)).message =
Some(message.into());
self
}
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料
#[must_use]
pub const fn with_public_message(mut self, message: &'static str) -> Self {
self.public_message = Some(message);
@@ -100,24 +109,61 @@ impl ProviderAdminError {
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
self.diagnostic
.as_ref()
.and_then(|diagnostic| diagnostic.message.as_deref())
}
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
// Provider 局部消息与 typed 来源共用一个不公开的上下文,避免跨用例转换后丢失
#[derive(Debug, Clone, Default, thiserror::Error)]
#[error("{}", message.as_deref().unwrap_or("provider admin dependency failed"))]
struct ProviderAdminDiagnostic {
message: Option<String>,
source: Option<gateway_core::error::ErrorSource>,
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力
///
/// 数据变更由 Provider 返回 prepared facts;config revision、审计与 PostgreSQL 事务
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
/// 全部由 [`crate::ports::store::AccountStore`] 提交
/// 运行时资源通知只在事务成功后发生
#[async_trait]
pub trait ProviderAdmin: Send + Sync {
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录。
fn compile_privacy_policy(
&self,
_policy: &gateway_core::settings::privacy::CodexPrivacyPolicy,
) -> Result<
Arc<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
gateway_core::settings::privacy::PrivacyError,
> {
Err(gateway_core::settings::privacy::PrivacyError {
rule_index: 0,
reason: "Provider 不支持隐私策略",
})
}
fn preview_privacy_policy(
&self,
_request: gateway_core::settings::privacy::PrivacyPreviewRequest,
) -> Result<
gateway_core::settings::privacy::PrivacyPreviewResult,
gateway_core::settings::privacy::PrivacyError,
> {
Err(gateway_core::settings::privacy::PrivacyError {
rule_index: 0,
reason: "Provider 不支持隐私策略",
})
}
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录
fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog {
Default::default()
}
fn provider_kind(&self) -> &ProviderKind;
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询。
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询
fn account_capabilities(
&self,
_account_id: &ProviderAccountId,
@@ -126,19 +172,19 @@ pub trait ProviderAdmin: Send + Sync {
Default::default()
}
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段
fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实。
/// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实
fn default_client_profile(&self) -> Option<gateway_core::account::OpaqueProviderData> {
None
}
/// 校验并投影客户端身份,结果不含认证或账号材料。
/// 校验并投影客户端身份,结果不含认证或账号材料
fn preview_client_profile(
&self,
_configuration: &gateway_core::account::OpaqueProviderData,
@@ -146,31 +192,33 @@ pub trait ProviderAdmin: Send + Sync {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
/// 提供最终套餐展示名称;未覆盖时采用通用的大驼峰格式
fn plan_type_display(&self, plan_type: &str) -> String {
plan_type.to_owned()
default_plan_type_display(plan_type)
}
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源
///
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态。
/// 无账号级运行时资源的 Provider 不需要执行额外操作
/// 该通知发生在 Store 事务
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态
async fn account_unavailable(&self, account_id: &ProviderAccountId);
/// 账号资格事实已经由控制面提交,失效 Provider 持有的可重建派生状态。
/// 账号资格事实已经由控制面提交,失效 Provider 持有的可重建派生状态
///
/// 该通知发生在 Store 事务成功之后、下一份 RuntimeSnapshot 编译之前;通知
/// 不参与已提交事务成败。没有账号派生状态的 Provider 可使用默认空实现。
/// 不参与已提交事务成败
/// 没有账号派生状态的 Provider 可使用默认空实现
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账
async fn connection_test_operation(
&self,
upstream_model: &UpstreamModelId,
input_text: &str,
) -> Result<Operation, ProviderAdminError>;
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
fn configured_wire_profile(
@@ -180,8 +228,8 @@ pub trait ProviderAdmin: Send + Sync {
self.dashboard_wire_profile()
}
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
/// 未保存的长上下文计费标记不得从当前价格反推。
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用
/// 未保存的长上下文计费标记不得从当前价格反推
fn calculated_billing(
&self,
input: &ProviderBillingInput,
@@ -212,7 +260,7 @@ pub trait ProviderAdmin: Send + Sync {
command: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文
async fn account_configuration(
&self,
_account_id: &ProviderAccountId,
@@ -225,7 +273,7 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算。
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算
fn quota_forecast_observation(
&self,
_document: &ProviderDocument,
@@ -234,8 +282,8 @@ pub trait ProviderAdmin: Send + Sync {
None
}
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取
async fn subscription(
&self,
_account_id: &ProviderAccountId,
@@ -250,7 +298,7 @@ pub trait ProviderAdmin: Send + Sync {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝。
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝
async fn profile_avatar(
&self,
_account_id: &ProviderAccountId,
@@ -258,7 +306,7 @@ pub trait ProviderAdmin: Send + Sync {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝
async fn reset_credits(
&self,
_account_id: &ProviderAccountId,
@@ -266,7 +314,7 @@ pub trait ProviderAdmin: Send + Sync {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝
async fn consume_reset_credit(
&self,
_command: ConsumeProviderResetCredit,
@@ -280,7 +328,7 @@ pub trait ProviderAdmin: Send + Sync {
refresh: bool,
) -> Result<ProviderModels, ProviderAdminError>;
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝。
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝
async fn model_catalog_document(
&self,
_account_id: &ProviderAccountId,
@@ -294,7 +342,7 @@ pub trait ProviderAdmin: Send + Sync {
) -> Result<ProviderExport, ProviderAdminError>;
}
/// 启动时注册原生 Provider,按 ProviderKind 查找管理能力。
/// 启动时注册原生 Provider,按 ProviderKind 查找管理能力
#[derive(Clone)]
pub struct ProviderAdminRegistry {
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
@@ -308,11 +356,11 @@ impl ProviderAdminRegistry {
.collect()
}
/// 创建无重复 ProviderKind 的注册表。
/// 创建无重复 ProviderKind 的注册表
///
/// # Errors
///
/// 重复注册同一 ProviderKind 时返回 Conflict。
/// 重复注册同一 ProviderKind 时返回 Conflict
pub fn new(
providers: impl IntoIterator<Item = Arc<dyn ProviderAdmin>>,
) -> Result<Self, ProviderAdminError> {
@@ -338,7 +386,7 @@ impl ProviderAdminRegistry {
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照
pub(crate) fn resolve_account_plan(
&self,
provider_kind: &str,
@@ -351,7 +399,7 @@ impl ProviderAdminRegistry {
self.plan_type_display(provider_kind, plan_type.as_deref())
}
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
/// 账号页和 Dashboard 共用 Provider 的最终展示名称,不改写官方拼写或原始套餐值
pub(crate) fn plan_type_display(
&self,
provider_kind: &str,
@@ -361,15 +409,13 @@ impl ProviderAdminRegistry {
let provider = ProviderKind::new(provider_kind.to_owned())
.ok()
.and_then(|kind| self.require(&kind).ok());
let display = provider.map_or_else(
|| plan_type.to_owned(),
Some(provider.map_or_else(
|| default_plan_type_display(plan_type),
|provider| provider.plan_type_display(plan_type),
);
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义。
Some(display.replace('+', " Plus ").to_upper_camel_case())
))
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像
pub fn dashboard_wire_profiles(
&self,
configurations: &std::collections::BTreeMap<
@@ -386,7 +432,7 @@ impl ProviderAdminRegistry {
.collect()
}
/// 按平台计算请求费用。
/// 按平台计算请求费用
pub fn calculated_billing(
&self,
provider_kind: &ProviderKind,
@@ -395,3 +441,7 @@ impl ProviderAdminRegistry {
self.require(provider_kind)?.calculated_billing(input)
}
}
fn default_plan_type_display(plan_type: &str) -> String {
plan_type.replace('+', " Plus ").to_upper_camel_case()
}
@@ -1,3 +1,5 @@
//! 账号出站代理的持久化、导入占用与连通性探测端口
use async_trait::async_trait;
use gateway_core::account::{OutboundProxy, ProviderAccountId};
@@ -12,7 +14,7 @@ use crate::model::{
#[async_trait]
pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
async fn list_accounts(
@@ -20,7 +22,7 @@ pub trait ProxyStore: Send + Sync {
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage>;
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址
async fn remove_account(
&self,
proxy_id: &str,
@@ -52,7 +54,7 @@ pub trait ProxyStore: Send + Sync {
) -> AdminStoreResult<ProxyMutation>;
}
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则
pub trait ProxyImportGuard: Send + Sync {}
pub struct ProxyImportReservation {
@@ -1,4 +1,4 @@
//! 有界保留期删除,不承载周期、暂停或重试策略。
//! 有界保留期删除,不承载周期、暂停或重试策略
use std::num::NonZeroU32;
@@ -12,7 +12,7 @@ use crate::model::retention::{RetentionPolicy, RetentionTarget};
pub trait RetentionStore: Send + Sync {
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy>;
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除。
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除
async fn purge_batch(
&self,
target: RetentionTarget,
+80 -50
View File
@@ -1,6 +1,6 @@
//! 管理控制面所需的持久化能力。
//! 管理控制面所需的持久化能力
//!
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client
use std::{collections::BTreeMap, net::IpAddr, sync::Arc, time::Duration};
@@ -12,8 +12,9 @@ use super::backup::BackupStorePorts;
use crate::model::{
MutationContext, Revision,
account_groups::{
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation,
AccountGroupOptionsPage, AccountGroupPage, DeleteAccountGroup, NewAccountGroup,
SetAccountGroupEnabled, UpdateAccountGroup,
},
accounts::{
AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot,
@@ -27,9 +28,9 @@ use crate::model::{
UpdateClientKey, UpdateClientKeyBudgetLimits,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
DashboardObservation, DashboardQuery, DiagnosticDimension, DiagnosticsObservation,
Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
@@ -40,7 +41,7 @@ use crate::model::{
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
};
/// 管理端可判定的持久化失败类型。
/// 管理端可判定的持久化失败类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminStoreErrorKind {
Invalid,
@@ -51,13 +52,14 @@ pub enum AdminStoreErrorKind {
Unavailable,
}
/// 隐藏数据库实现细节的持久化错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
/// 隐藏数据库实现细节的持久化错误
#[derive(Debug, Clone, thiserror::Error)]
#[error("{resource} store operation failed: {message}")]
pub struct AdminStoreError {
kind: AdminStoreErrorKind,
resource: &'static str,
message: String,
source: Option<gateway_core::error::ErrorSource>,
}
impl AdminStoreError {
@@ -71,9 +73,27 @@ impl AdminStoreError {
kind,
resource,
message: message.into(),
source: None,
}
}
/// 来源留在内部诊断链,公开消息不展开基础设施错误
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(source.into());
self
}
/// 清理失败不覆盖最初操作的分类与原因
#[must_use]
pub fn with_cleanup(mut self, cleanup: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(gateway_core::error::ErrorSource::cleanup(
self.source.take(),
cleanup,
));
self
}
#[must_use]
pub const fn kind(&self) -> AdminStoreErrorKind {
self.kind
@@ -87,10 +107,10 @@ impl AdminStoreError {
pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
/// 账号目录与公共账号写操作。
/// 账号目录与公共账号写操作
#[async_trait]
pub trait AccountStore: Send + Sync {
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页。
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页
async fn list_plugin_accounts(
&self,
query: PluginAccountListQuery,
@@ -119,7 +139,7 @@ pub trait AccountStore: Send + Sync {
windows: &[AccountUsageWindowQuery],
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
/// 从同一数据库语句取得截止快照的累计用量和有界历史观测。
/// 从同一数据库语句取得截止快照的累计用量和有界历史观测
async fn load_quota_forecast_history(
&self,
window: &AccountUsageWindowQuery,
@@ -131,7 +151,7 @@ pub trait AccountStore: Send + Sync {
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
/// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属。
/// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属
async fn credential_details_by_id(
&self,
account_id: &gateway_core::account::ProviderAccountId,
@@ -143,7 +163,7 @@ pub trait AccountStore: Send + Sync {
account_ids: &[gateway_core::account::ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
/// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider。
/// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider
async fn load_credential_for_plugin(
&self,
account_id: &gateway_core::account::ProviderAccountId,
@@ -161,7 +181,7 @@ pub trait AccountStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<crate::model::provider_credentials::AuthorizationCommitResult>;
/// 已提交的授权结果独立于 Redis 临时状态;只有原管理员身份可读。
/// 已提交的授权结果独立于 Redis 临时状态;只有原管理员身份可读
async fn authorization_receipt(
&self,
key: &crate::model::provider_credentials::AuthorizationReceiptKey,
@@ -185,7 +205,7 @@ pub trait AccountStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult>;
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限。
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限
async fn lower_concurrency_limit(
&self,
account_id: &gateway_core::account::ProviderAccountId,
@@ -218,7 +238,7 @@ pub trait AccountStore: Send + Sync {
) -> AdminStoreResult<()>;
}
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有。
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有
#[async_trait]
pub trait AccountRuntimeStore: Send + Sync {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot>;
@@ -228,18 +248,18 @@ pub trait AccountRuntimeStore: Send + Sync {
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot>;
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内。
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)。
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)
async fn capacity_peaks(
&self,
account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>>;
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结。
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结
async fn finish_freeze(
&self,
account_id: &str,
@@ -248,12 +268,12 @@ pub trait AccountRuntimeStore: Send + Sync {
) -> AdminStoreResult<bool>;
}
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
/// 控制面凭据、统一会话、登录限流与管理员安全审计
#[async_trait]
pub trait AuthStore: Send + Sync {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入
async fn change_password(
&self,
admin_user_id: &str,
@@ -274,6 +294,14 @@ pub trait AuthStore: Send + Sync {
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>;
/// 只延长仍存在且匹配的会话;并发续期返回当前值,已退出或过期时不重建
async fn renew_session(
&self,
session_id: &str,
expected: &AuthSession,
expires_at: chrono::DateTime<chrono::Utc>,
) -> AdminStoreResult<Option<AuthSession>>;
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
async fn client_key_enabled(
@@ -281,7 +309,7 @@ pub trait AuthStore: Send + Sync {
id: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<bool>;
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔。
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔
async fn consume_login_attempt(
&self,
source_ip: IpAddr,
@@ -293,10 +321,10 @@ pub trait AuthStore: Send + Sync {
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
}
/// Client API Key 资料读取与管理写入。
/// Client API Key 资料读取与管理写入
#[async_trait]
pub trait ClientKeyStore: Send + Sync {
/// 按已验证的 ID 读取资料,不读取完整明文 Key。
/// 按已验证的 ID 读取资料,不读取完整明文 Key
async fn get_client_key(
&self,
id: &gateway_core::policy::ClientApiKeyId,
@@ -333,7 +361,7 @@ pub trait ClientKeyStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<Revision>;
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计。
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计
async fn update_client_key_budget_limits(
&self,
command: UpdateClientKeyBudgetLimits,
@@ -341,7 +369,7 @@ pub trait ClientKeyStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<Option<Revision>>;
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
/// 仅修改运行时账本并原子记录审计,不推进配置版本
async fn reset_client_key_budget(
&self,
command: ResetClientKeyBudget,
@@ -350,7 +378,7 @@ pub trait ClientKeyStore: Send + Sync {
) -> AdminStoreResult<()>;
}
/// Provider-neutral account group management transactions.
/// 跨 Provider 的账号分组管理事务
#[async_trait]
pub trait AccountGroupStore: Send + Sync {
async fn list_account_groups(
@@ -358,6 +386,11 @@ pub trait AccountGroupStore: Send + Sync {
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupPage>;
async fn list_account_group_options(
&self,
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupOptionsPage>;
async fn load_account_group_members(
&self,
group_ids: &[gateway_core::routing::AccountGroupId],
@@ -388,45 +421,40 @@ pub trait AccountGroupStore: Send + Sync {
) -> AdminStoreResult<AccountGroupMutation>;
}
/// 逐条读取的已计算费用事实;消费结束或丢弃时释放查询资源。
/// 逐条读取的已计算费用事实;消费结束或丢弃时释放查询资源
pub type UsageCalculatedBillingStream<'a> =
BoxStream<'a, AdminStoreResult<UsageCalculatedBillingFact>>;
/// 用量、趋势、诊断与运维错误的只读能力。
/// 用量、趋势、诊断与运维错误的只读能力
#[async_trait]
pub trait ObservabilityStore: Send + Sync {
/// 返回历史统计区间和指定观测时刻下的实时账号状态。
/// 返回历史统计区间和指定观测时刻下的实时账号状态
async fn dashboard_summary(
&self,
range: TimeRange,
query: DashboardQuery,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation>;
/// 返回 Dashboard 可选的实时槽位事实。
///
/// 该状态来自可丢失的运行时存储;无实现或运行时存储不可用时返回 `None`,不影响
/// 持久观测数据的读取。
async fn dashboard_runtime_slots(
async fn dashboard_trend(
&self,
_observed_at: DateTime<Utc>,
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
Ok(None)
}
async fn dashboard_trend(&self, range: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>>;
range: TimeRange,
granularity: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
async fn usage_trend(
&self,
range: TimeRange,
filter: UsageFilter,
granularity: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
/// 流式返回可由 Provider 重新校验的已计算费用事实,不保证顺序。
/// 查询及解码错误由流返回;调用方应逐条聚合,避免收集整个区间。
/// 流式返回可由 Provider 重新校验的已计算费用事实,不保证顺序
/// 查询及解码错误由流返回;调用方应逐条聚合,避免收集整个区间
fn usage_calculated_billing_facts(
&self,
range: TimeRange,
filter: UsageFilter,
granularity: Granularity,
) -> UsageCalculatedBillingStream<'_>;
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
@@ -444,12 +472,13 @@ pub trait ObservabilityStore: Send + Sync {
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
limit: u16,
) -> AdminStoreResult<DiagnosticsObservation>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
}
/// Runtime settings 与管理员 API Key 写入。
/// Runtime settings 与管理员 API Key 写入
#[async_trait]
pub trait SettingsStore: Send + Sync {
async fn load_pricing(&self) -> AdminStoreResult<crate::model::pricing::StoredPricing>;
@@ -486,7 +515,7 @@ pub trait SettingsStore: Send + Sync {
) -> AdminStoreResult<AdminApiKeyMutation>;
}
/// 账号目录、运行态与分组所需的 Store 能力集合。
/// 账号目录、运行态与分组所需的 Store 能力集合
#[derive(Clone)]
pub struct AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
@@ -512,9 +541,10 @@ impl AdminAccountStorePorts {
}
}
/// 管理用例所需能力的封闭集合。
/// 管理用例所需能力的封闭集合
///
/// 字段保持私有,每个 getter 只交出一种明确能力。该类型不提供通用拆包入口。
/// 字段保持私有,每个 getter 只交出一种明确能力
/// 该类型不提供通用拆包入口
#[derive(Clone)]
pub struct AdminStorePorts {
accounts: AdminAccountStorePorts,
@@ -1,4 +1,4 @@
//! Host 为系统管理用例提供的进程与操作系统能力。
//! Host 为系统管理用例提供的进程与操作系统能力
use std::{pin::Pin, sync::Arc};
@@ -11,7 +11,7 @@ use crate::model::system::{
SystemUpdateStatus, SystemVersion,
};
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据
#[derive(Clone)]
pub struct SystemUpdateCandidate {
pub target_version: String,
@@ -28,7 +28,7 @@ impl std::fmt::Debug for SystemUpdateCandidate {
}
}
/// Host 系统操作失败类型。
/// Host 系统操作失败类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemOperationErrorKind {
Invalid,
@@ -37,7 +37,7 @@ pub enum SystemOperationErrorKind {
Internal,
}
/// 不泄漏路径、命令行或发布凭据的系统操作错误。
/// 不泄漏路径、命令行或发布凭据的系统操作错误
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("system operation failed: {message}")]
pub struct SystemOperationError {
@@ -59,17 +59,17 @@ impl SystemOperationError {
self.kind
}
/// 返回 Host 已完成脱敏的客户端安全消息。
/// 返回 Host 已完成脱敏的客户端安全消息
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
/// 每个订阅者独占的系统事件流。
/// 每个订阅者独占的系统事件流
pub type SystemUpdateEventStream = Pin<Box<dyn Stream<Item = SystemUpdateEvent> + Send + 'static>>;
/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换。
/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换
#[async_trait]
pub trait SystemUpdatePreflight: Send + Sync {
async fn validate(
@@ -77,7 +77,7 @@ pub trait SystemUpdatePreflight: Send + Sync {
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError>;
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本
async fn validate_rollback(
&self,
candidate: SystemUpdateCandidate,
@@ -85,11 +85,11 @@ pub trait SystemUpdatePreflight: Send + Sync {
self.validate(candidate).await
}
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
}
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出
#[async_trait]
pub trait SystemRestartPreflight: Send + Sync {
async fn prepare(
@@ -98,7 +98,7 @@ pub trait SystemRestartPreflight: Send + Sync {
) -> Result<(), SystemOperationError>;
}
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host
#[async_trait]
pub trait SystemOperations: Send + Sync {
async fn version(&self) -> Result<SystemVersion, SystemOperationError>;
@@ -125,7 +125,7 @@ pub trait SystemOperations: Send + Sync {
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单
async fn restart_candidate(
&self,
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
@@ -1,4 +1,4 @@
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹。
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹
mod registry;
mod settings;
@@ -8,18 +8,18 @@ use std::{future::Future, sync::Arc};
use futures::future::BoxFuture;
use gateway_core::{
engine::middleware::FrozenMiddlewarePlan,
middleware::{
compose,
engine::middleware::{
FrozenMiddlewarePlan,
service::{Context, Error, Value},
},
middleware::compose,
};
use crate::model::{AdminError, AdminErrorKind};
pub type PlanSource = Arc<dyn Fn() -> Option<FrozenMiddlewarePlan> + Send + Sync>;
/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合。
/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合
#[derive(Clone, Default)]
pub enum Plan {
#[default]
@@ -27,7 +27,7 @@ pub enum Plan {
Frozen(Option<FrozenMiddlewarePlan>),
}
/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析。
/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析
#[derive(Clone)]
pub struct Origin {
pub request_id: String,
@@ -51,7 +51,7 @@ impl From<Context> for Origin {
tokio::task_local! { static CURRENT: Origin; }
/// task local 随 future poll 进入/退出,不泄漏给并行任务。
/// task local 随 future poll 进入/退出,不泄漏给并行任务
pub async fn scope<T>(context: impl Into<Origin>, future: impl Future<Output = T>) -> T {
CURRENT.scope(context.into(), future).await
}
@@ -1,8 +1,8 @@
//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务。
//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务
use crate::model::AdminError;
use futures::future::BoxFuture;
use gateway_core::middleware::service::{Error, Value};
use gateway_core::engine::middleware::service::{Error, Value};
use serde::{Serialize, de::DeserializeOwned};
use std::{collections::BTreeMap, sync::Arc};
@@ -21,7 +21,7 @@ impl Registry {
}
}
/// 网关与命令行复用同一组设置服务;注册不会执行读取或写入。
/// 网关与命令行复用同一组设置服务;注册不会执行读取或写入
pub fn register_settings(
&mut self,
service: &Arc<dyn crate::SettingsService>,
@@ -1,4 +1,4 @@
//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成。
//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成
use crate::{
SettingsService,
@@ -14,7 +14,7 @@ pub(super) fn register(
registry: &mut super::Registry,
service: &Arc<dyn SettingsService>,
) -> Result<(), AdminError> {
// 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生。
// 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生
macro_rules! register {
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
let service = service.clone();
@@ -25,6 +25,13 @@ pub(super) fn register(
}};
}
register!(Load, load, (), ());
register!(
PreviewPrivacyPolicy,
preview_privacy_policy,
gateway_core::settings::privacy::PrivacyPreviewRequest,
request,
request
);
register!(
Replace,
replace,
@@ -1,4 +1,4 @@
//! Account group management use cases.
//! 账号分组的查询、配置与运行态容量聚合用例
use std::{
collections::{BTreeMap, BTreeSet},
@@ -19,9 +19,9 @@ use crate::{
AdminError, MutationContext,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupListQuery,
AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, AccountGroupRecord,
CreateAccountGroup, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
UpdateAccountGroup,
AccountGroupMemberFact, AccountGroupMutation, AccountGroupOptionsPage,
AccountGroupPage, AccountGroupRecord, CreateAccountGroup, DeleteAccountGroup,
NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
},
},
ports::store::{AccountGroupStore, AccountRuntimeStore},
@@ -29,10 +29,14 @@ use crate::{
use super::{map_store_error, publish_committed};
/// API-facing account group management service.
/// 面向 API 的账号分组管理服务
#[async_trait]
pub trait AccountGroupService: Send + Sync {
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError>;
async fn list_options(
&self,
query: AccountGroupListQuery,
) -> Result<AccountGroupOptionsPage, AdminError>;
async fn create(
&self,
context: &MutationContext,
@@ -130,11 +134,26 @@ impl AccountGroupService for DefaultAccountGroupService {
Ok(page)
}
async fn list_options(
&self,
query: AccountGroupListQuery,
) -> Result<AccountGroupOptionsPage, AdminError> {
self.store
.list_account_group_options(query)
.await
.map_err(|error| map_store_error(error, "account group options"))
}
async fn create(
&self,
context: &MutationContext,
command: CreateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
crate::model::account_groups::validate_group_fields(
&command.name,
command.description.as_deref(),
)
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
self.publish(
@@ -144,7 +163,7 @@ impl AccountGroupService for DefaultAccountGroupService {
id,
name: command.name,
description: command.description,
disable_fast: command.disable_fast,
fast_mode: command.fast_mode,
color: command.color,
},
context,
@@ -159,6 +178,11 @@ impl AccountGroupService for DefaultAccountGroupService {
context: &MutationContext,
command: UpdateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
crate::model::account_groups::validate_group_fields(
&command.name,
command.description.as_deref(),
)
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
self.publish(self.store.update_account_group(command, context).await)
.await
}
@@ -1,4 +1,4 @@
//! 统一账号目录与原生 Provider 分派。
//! 统一账号目录与原生 Provider 分派
use std::{
collections::{BTreeMap, BTreeSet},
@@ -49,7 +49,7 @@ use super::{
const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK.";
/// 统一账号页消费的服务。
/// 统一账号页消费的服务
#[async_trait]
pub trait AccountsService: Send + Sync {
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError>;
@@ -279,7 +279,7 @@ impl DefaultAccountsService {
accounts: &[AccountPageItem],
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
let now = Utc::now();
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录。
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录
let windows = accounts
.iter()
.filter(|item| item.account.authentication_kind == "api_key")
@@ -333,7 +333,7 @@ impl DefaultAccountsService {
.await
{
Ok(quota) => quota,
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作。
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作
Err(error)
if !refresh_quota
&& error.kind()
@@ -403,7 +403,7 @@ impl AccountsService for DefaultAccountsService {
.iter()
.map(|item| item.account.id.clone())
.collect::<Vec<_>>();
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录。
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录
let in_flight = if ids.is_empty() {
None
} else {
@@ -429,7 +429,7 @@ impl AccountsService for DefaultAccountsService {
let account_id = ProviderAccountId::new(account.id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
// 该账号降级为空额度投影,其余账号与页面状态照常返回
let provider = match providers.require(&account.provider_kind) {
Ok(provider) => provider,
Err(error) => {
@@ -536,7 +536,7 @@ impl AccountsService for DefaultAccountsService {
}) {
return Err(AdminError::invalid("账号导出列表包含重复 ID"));
}
// 混选时先确认全部 Provider 已注册,拒绝路径不读取任何一组明文凭据。
// 混选时先确认全部 Provider 已注册,拒绝路径不读取任何一组明文凭据
let export_groups = grouped
.into_iter()
.map(|(provider_kind, ids)| {
@@ -615,7 +615,7 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_store_error(error, "provider account recovery"))?
.config_revision
} else {
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实。
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实
self.accounts
.batch_update_accounts(
BatchUpdateAccounts {
@@ -774,7 +774,7 @@ impl AccountsService for DefaultAccountsService {
let Some((window, _)) = quota_forecast_source_window(&quota, period) else {
continue;
};
// 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照。
// 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照
if !selected_keys.insert(window.key.as_str()) {
continue;
}
@@ -814,8 +814,8 @@ impl AccountsService for DefaultAccountsService {
.as_deref()
.zip(fact.plan_type.as_deref())
.is_some_and(|(current, previous)| current.eq_ignore_ascii_case(previous));
// 仅容纳已观测到的秒级量化抖动,不用宽时间容差合并实际重置。
// 不匹配的段截断基线;之后的有效观测可以重新积累。
// 仅容纳已观测到的秒级量化抖动,不用宽时间容差合并实际重置
// 不匹配的段截断基线;之后的有效观测可以重新积累
if !same_plan || (fact.reset_at - reset_at).abs() > Duration::seconds(2) {
points.clear();
interrupted = true;
@@ -853,7 +853,7 @@ impl AccountsService for DefaultAccountsService {
account_id: &ProviderAccountId,
) -> Result<AccountPersonalInfo, AdminError> {
let (initial, provider) = self.provider_for_account(account_id).await?;
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新
let (profile, subscription) = futures::join!(
provider.profile_statistics(account_id),
provider.subscription(account_id),
@@ -865,7 +865,7 @@ impl AccountsService for DefaultAccountsService {
.ok()
.flatten();
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据
let current = self.load_account(account_id).await?;
if current.account.provider_kind != initial.account.provider_kind
|| current.account.credential_revision != initial.account.credential_revision
@@ -921,7 +921,7 @@ impl AccountsService for DefaultAccountsService {
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
// 之间插入另一笔不可逆消费。
// 之间插入另一笔不可逆消费
let lock = self.reset_credit_lock(&account_id).await;
let _guard = lock.lock().await;
let (_, provider) = self.provider_for_account(&account_id).await?;
@@ -1049,9 +1049,10 @@ fn map_reset_credits_error_after_refresh(
map_provider_error(error, "provider reset credits")
}
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影
fn empty_quota() -> ProviderQuota {
ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -1072,7 +1073,7 @@ fn quota_usage_window(
let seconds = i64::try_from(window.window_seconds?).ok()?;
let start = reset_at.checked_sub_signed(Duration::try_seconds(seconds)?)?;
let range = TimeRange::new(start, reset_at).ok()?;
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口。
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口
Some(AccountUsageWindowQuery {
account_id: account_id.to_owned(),
key: window.key.clone(),
@@ -1,4 +1,4 @@
//! 控制面登录、会话恢复与身份校验的唯一 owner。
//! 控制面登录、会话恢复与身份校验的唯一 owner
use std::{net::IpAddr, sync::Arc, time::Duration as StdDuration};
@@ -25,7 +25,7 @@ use crate::{
use super::map_store_error;
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定
#[async_trait]
pub trait AuthService: Send + Sync {
async fn change_password(
@@ -36,6 +36,10 @@ pub trait AuthService: Send + Sync {
) -> Result<(), AdminError>;
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
async fn renew_session(
&self,
session_id: Option<&str>,
) -> Result<Option<AuthSession>, AdminError>;
async fn resolve_admin_user_id(
&self,
session_id: Option<&str>,
@@ -58,6 +62,7 @@ const LOGIN_ATTEMPTS_GLOBAL: u32 = 200;
pub(crate) struct DefaultAuthService {
default_admin_user_id: String,
admin_session_ttl: Duration,
admin_session_absolute_ttl: Duration,
key_session_ttl: Duration,
store: Arc<dyn AuthStore>,
verifier: Arc<dyn ClientKeyVerifier>,
@@ -68,6 +73,7 @@ impl DefaultAuthService {
pub(crate) fn new(
default_admin_user_id: impl Into<String>,
admin_session_ttl_minutes: u64,
admin_session_absolute_ttl_minutes: u64,
key_session_ttl_minutes: u64,
store: Arc<dyn AuthStore>,
verifier: Arc<dyn ClientKeyVerifier>,
@@ -75,6 +81,7 @@ impl DefaultAuthService {
Self {
default_admin_user_id: default_admin_user_id.into(),
admin_session_ttl: session_ttl(admin_session_ttl_minutes),
admin_session_absolute_ttl: session_ttl(admin_session_absolute_ttl_minutes),
key_session_ttl: session_ttl(key_session_ttl_minutes),
store,
verifier,
@@ -208,7 +215,7 @@ impl AuthService for DefaultAuthService {
{
return Err(AdminError::conflict("密码已变更,请重新登录"));
}
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功。
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功
Ok(())
}
@@ -232,8 +239,12 @@ impl AuthService for DefaultAuthService {
else {
return Ok(None);
};
if session.expires_at <= Utc::now() {
let _ = self.store.delete_session(session_id).await;
if session.expires_at <= Utc::now()
|| session
.absolute_expires_at
.is_some_and(|expiry| expiry <= Utc::now())
{
// Redis 的 TTL 负责过期清理,避免旧读取删除另一个请求刚续期的会话
return Ok(None);
}
if let SessionSubject::Admin {
@@ -266,6 +277,37 @@ impl AuthService for DefaultAuthService {
Ok(Some(session))
}
async fn renew_session(
&self,
session_id: Option<&str>,
) -> Result<Option<AuthSession>, AdminError> {
let Some(session_id) = session_id else {
return Ok(None);
};
let Some(session) = self.session(Some(session_id)).await? else {
return Ok(None);
};
// Key 与升级前的会话保留原有期限,不能凭续期获得新的最长登录窗口
let Some(absolute) = session
.absolute_expires_at
.filter(|_| matches!(session.subject, SessionSubject::Admin { .. }))
else {
return Ok(Some(session));
};
let now = Utc::now();
let expires_at = (now + self.admin_session_ttl).min(absolute);
let interval = (self.admin_session_ttl / 4).min(Duration::minutes(1));
if expires_at <= session.expires_at
|| session.expires_at - now > self.admin_session_ttl - interval
{
return Ok(Some(session));
}
self.store
.renew_session(session_id, &session, expires_at)
.await
.map_err(|error| map_store_error(error, "authentication session renewal"))
}
async fn resolve_admin_user_id(
&self,
session_id: Option<&str>,
@@ -325,9 +367,13 @@ impl AuthService for DefaultAuthService {
SessionSubject::Admin { .. } => self.admin_session_ttl,
SessionSubject::Key { .. } => self.key_session_ttl,
};
let now = Utc::now();
let absolute_expires_at = matches!(subject, SessionSubject::Admin { .. })
.then_some(now + self.admin_session_absolute_ttl);
let session = AuthSession {
subject,
expires_at: Utc::now() + ttl,
expires_at: (now + ttl).min(absolute_expires_at.unwrap_or(now + ttl)),
absolute_expires_at,
};
let session_id = random_session_token();
self.store
@@ -344,7 +390,7 @@ impl AuthService for DefaultAuthService {
let _ = self.store.delete_session(&session_id).await;
return Err(LoginError::Unavailable);
}
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话。
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话
if let Some(previous) = previous_session_id.filter(|value| !value.is_empty())
&& self.logout(previous).await.is_err()
{
@@ -425,7 +471,7 @@ fn valid_admin_api_key_shape(value: &str) -> bool {
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
}
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话。
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话
fn password_fingerprint(password_hash: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes()))
}
@@ -1,4 +1,4 @@
//! 备份配置、手动创建、下载与删除用例。
//! 备份配置、手动创建、下载与删除用例
use std::sync::Arc;
use std::time::Duration;
@@ -26,10 +26,10 @@ use crate::ports::{
use super::map_store_error;
/// 默认下载地址有效期。
/// 默认下载地址有效期
const DOWNLOAD_TTL: Duration = Duration::from_secs(5 * 60);
/// API 消费的备份管理服务。
/// API 消费的备份管理服务
#[async_trait]
pub trait BackupService: Send + Sync {
async fn load_settings(&self) -> Result<BackupSettings, AdminError>;
@@ -76,6 +76,7 @@ pub trait BackupService: Send + Sync {
}
pub(crate) struct DefaultBackupService {
timezone: gateway_core::time::DeploymentTimeZone,
repository: Arc<dyn BackupRepository>,
object_store: Arc<dyn BackupObjectStorePort>,
auth: Arc<dyn AuthStore>,
@@ -89,8 +90,10 @@ impl DefaultBackupService {
object_store: Arc<dyn BackupObjectStorePort>,
auth: Arc<dyn AuthStore>,
snapshot: Arc<dyn SnapshotControl>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Self {
Self {
timezone,
repository,
object_store,
auth,
@@ -128,22 +131,20 @@ impl BackupService for DefaultBackupService {
context: &MutationContext,
command: UpdateBackupScheduleCommand,
) -> Result<BackupSettings, AdminError> {
validate_schedule(&command)?;
let schedule =
crate::backup::policy::BackupSchedule::parse(&command.cron_expression, self.timezone)
.map_err(map_backup_error)?;
let next_run_at = if command.schedule_enabled {
Some(
crate::backup::policy::BackupSchedule::parse(
&command.cron_expression,
&command.schedule_timezone,
)
.map_err(map_backup_error)?
.next_after(Utc::now())
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
schedule
.next_after(Utc::now())
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
)
} else {
None
};
self.repository
.update_schedule_settings(command, next_run_at, context)
.update_schedule_settings(command, next_run_at, context, self.timezone)
.await
.map_err(|error| map_store_error(error, "backup schedule"))
}
@@ -358,7 +359,7 @@ impl DefaultBackupService {
}
}
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值。
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值
fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminError> {
validate_endpoint(&command.endpoint)?;
validate_nonempty("region", &command.region)?;
@@ -408,23 +409,10 @@ fn validate_length(field: &str, value: &str, max: usize) -> Result<(), AdminErro
Ok(())
}
/// 校验调度配置并解析 Cron 与时区。
fn validate_schedule(command: &UpdateBackupScheduleCommand) -> Result<(), AdminError> {
if command.cron_expression.trim().is_empty() {
return Err(AdminError::bad_request("cronExpression 不能为空"));
}
crate::backup::policy::BackupSchedule::parse(
&command.cron_expression,
&command.schedule_timezone,
)
.map_err(map_backup_error)?;
Ok(())
}
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射。
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射
fn map_backup_error(error: BackupError) -> AdminError {
match error.code() {
code::INVALID_CONFIG | code::INVALID_CRON | code::INVALID_TIMEZONE => {
code::INVALID_CONFIG | code::INVALID_CRON => {
AdminError::bad_request(error.message().to_owned())
}
code::RECORD_NOT_FOUND => AdminError::not_found(error.message().to_owned()),
@@ -1,4 +1,4 @@
//! Codex 客户端下载信息用例。
//! Codex 客户端下载信息用例
use std::sync::Arc;
@@ -1,4 +1,4 @@
//! Client API Key 管理用例。
//! Client API Key 管理用例
use std::sync::Arc;
@@ -25,7 +25,7 @@ use crate::{
use super::{map_store_error, publish_committed};
/// API 消费的 Client Key 管理服务。
/// API 消费的 Client Key 管理服务
#[async_trait]
pub trait ClientKeyService: Send + Sync {
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
@@ -157,6 +157,8 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_overrides {
self.providers
.require(provider)
@@ -199,6 +201,8 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_override_updates {
if let Some(profile) = profile {
self.providers
@@ -300,7 +304,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
}
}
// 原生与插件创建共用相同的密钥生成规则。
// 原生与插件创建共用相同的密钥生成规则
pub(super) fn generate_key() -> String {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
@@ -0,0 +1,371 @@
//! 凭据变更共享的绑定校验、事务结算、失效与提交后观察
use crate::{
model::{
AdminError, MutationContext,
accounts::DeleteAccounts,
provider_credentials::{
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
ProviderQuotaRequest, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
use super::{map_store_error, publish_committed};
pub(super) async fn publish_credentials_and_observe_quota(
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
provider.account_facts_changed(account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
let provider = provider.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"initial quota observation failed"
);
}
}
});
Ok(())
}
pub(super) async fn required_credential(
accounts: &dyn AccountStore,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details(provider_kind, account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
pub(super) async fn required_plugin_credential(
accounts: &dyn AccountStore,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details_by_id(account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
pub(super) async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
let Some(id) = id else { return Ok(None) };
let reservation = proxies
.reserve_import(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(reservation))
}
pub(super) async fn pending_authorization(
accounts: &dyn AccountStore,
proxies: &dyn crate::ports::proxy::ProxyStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let proxy;
let mut proxy_id = None;
let target = match &command.reauthorization {
Some(account_id) => {
proxy = required_credential(accounts, provider_kind, account_id, resource)
.await?
.credential
.outbound_proxy;
AuthorizationMutationTarget::Reauthorize {
account_id: account_id.clone(),
}
}
None => {
use crate::model::proxies::AccountProxySelection;
proxy = match &command.outbound_proxy {
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
Some(AccountProxySelection::Saved(id)) => {
let record = proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
proxy_id = Some(record.id);
Some(record.proxy)
}
Some(AccountProxySelection::Direct) | None => None,
};
AuthorizationMutationTarget::Create {
name: command.name.clone(),
}
}
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
target,
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
&command.context,
),
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(proxy_id))
}
pub(super) fn validate_prepared_import(
provider_kind: &ProviderKind,
prepared: &PreparedCredentialImport,
_resource: &'static str,
) -> Result<(), AdminError> {
if prepared.provider_kind != *provider_kind
|| prepared
.credentials
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
}
Ok(())
}
pub(super) fn validate_prepared_rotation(
account: &crate::model::accounts::AccountRecord,
prepared: &PreparedCredentialRotation,
_resource: &'static str,
) -> Result<(), AdminError> {
validate_prepared_rotation_facts(account, prepared.facts())
}
pub(super) fn validate_prepared_rotation_facts(
account: &crate::model::accounts::AccountRecord,
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
) -> Result<(), AdminError> {
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
}
Ok(())
}
pub(super) async fn validate_authorization_commit(
provider_kind: &ProviderKind,
context: &MutationContext,
prepared: PreparedAuthorizationCommit,
resource: &'static str,
) -> Result<PreparedAuthorizationCommit, AdminError> {
if prepared.pending.provider_kind() != provider_kind
|| !prepared.pending.owner_binding().matches_context(context)
{
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
let matches_target = match (prepared.pending.target(), &prepared.credential) {
(
AuthorizationMutationTarget::Create { .. },
PreparedAuthorizationCredential::Create(credential),
) => credential.provider_kind == *provider_kind,
(
AuthorizationMutationTarget::Reauthorize { account_id },
PreparedAuthorizationCredential::Reauthorize(credential),
) => {
let facts = credential.facts();
facts.provider_kind == *provider_kind && facts.account_id == *account_id
}
_ => false,
};
if !matches_target {
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
Ok(prepared)
}
pub(super) async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
key: crate::model::provider_credentials::AuthorizationReceiptKey,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
PreparedAuthorizationCredential::Reauthorize(_)
)
{
prepared.abort().await?;
return Err(AdminError::invalid("重新授权不能修改账号设置"));
}
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit(settings, key);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard
&& result.newly_committed
{
guard.finish();
}
if let Some(guard) = authorization_guard
&& let Err(error) = guard.commit().await
{
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
}
Ok(result)
}
Err(error) => {
drop(credential_guard);
if let Some(guard) = authorization_guard
&& let Err(settlement_error) = guard.abort().await
{
tracing::warn!(
resource,
store_error = %error,
settlement_error = %settlement_error,
"OAuth authorization claim release failed after Store commit failure"
);
return Err(settlement_error);
}
Err(map_store_error(error, resource))
}
}
}
pub(super) async fn commit_credential_rotation(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
settings: Option<crate::model::accounts::UpdateAccount>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_rotation(
CredentialRotationCommit {
prepared: facts,
settings,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
pub(super) async fn commit_credential_refresh(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(
CredentialRotationCommit {
prepared: facts,
settings: None,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
pub(super) async fn delete_credentials(
accounts: &dyn AccountStore,
provider: &dyn ProviderAdmin,
command: CredentialDeletion,
resource: &'static str,
) -> Result<CredentialDeletionResult, AdminError> {
for account_id in &command.account_ids {
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
}
let account_ids = command.account_ids;
let revision = accounts
.delete_accounts(
DeleteAccounts {
account_ids: account_ids
.iter()
.map(|account_id| account_id.as_str().to_owned())
.collect(),
},
&command.context,
)
.await
.map_err(|error| map_store_error(error, resource))?;
for account_id in &account_ids {
provider.account_unavailable(account_id).await;
}
provider.account_facts_changed(&account_ids).await;
Ok(CredentialDeletionResult {
config_revision: revision,
account_ids,
})
}
@@ -1,4 +1,4 @@
//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权。
//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权
use std::sync::Arc;
@@ -1,3 +1,5 @@
//! 按 Provider 分派凭据管理服务,组合授权与导入流程
mod flow;
use std::sync::Arc;
@@ -11,7 +13,7 @@ use crate::{
pub use flow::ProviderCredentials;
/// 所有 Provider 共用凭据用例;每次操作从已发布目录冻结自己的管理实现。
/// 所有 Provider 共用凭据用例;每次操作从已发布目录冻结自己的管理实现
pub struct CredentialsService {
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
@@ -0,0 +1,67 @@
//! 管理端口错误到用例错误的统一分类与来源保留
use crate::model::{AdminError, AdminErrorKind};
use crate::ports::store::{AdminStoreError, AdminStoreErrorKind};
pub(super) fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
let kind = match error.kind() {
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
let message = match kind {
AdminErrorKind::Invalid => "请求参数不合法",
AdminErrorKind::NotFound => "请求的资源不存在",
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
AdminErrorKind::Unavailable => "依赖服务暂不可用",
_ => "服务内部错误",
};
AdminError::new(kind, message).with_source(OperationFailure {
operation: resource,
source: error.into(),
})
}
pub(super) fn map_provider_error(
error: crate::ports::provider::ProviderAdminError,
resource: &'static str,
) -> AdminError {
use crate::ports::provider::ProviderAdminErrorKind;
let kind = match error.kind() {
ProviderAdminErrorKind::Invalid => AdminErrorKind::Invalid,
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
let message = match kind {
AdminErrorKind::Invalid => "Provider 请求不合法",
AdminErrorKind::NotFound => "Provider 资源不存在",
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
AdminErrorKind::BadGateway => "上游服务请求失败",
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, error.public_message().unwrap_or(message)).with_source(OperationFailure {
operation: resource,
source: error.into(),
})
}
// 用例的操作名补充端口资源上下文,底层分类与原始原因仍由来源持有
#[derive(Debug, thiserror::Error)]
#[error("admin operation failed: {operation}")]
struct OperationFailure {
operation: &'static str,
source: gateway_core::error::ErrorSource,
}
@@ -1,4 +1,4 @@
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照。
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照
use std::{
collections::VecDeque,
@@ -100,7 +100,7 @@ impl Entry {
{
self.finished_at = Some(Utc::now());
self.finished_instant = Some(Instant::now());
// 终态仅保留结果,连已清空的输入槽位分配也一并释放。
// 终态仅保留结果,连已清空的输入槽位分配也一并释放
self.inputs = Vec::new();
}
}
@@ -163,7 +163,7 @@ impl DefaultImportTasksService {
.tasks
.iter()
.position(|entry| entry.inputs.iter().any(Option::is_some))?;
// 轮转任务,避免大批次独占后续全部执行机会。
// 轮转任务,避免大批次独占后续全部执行机会
let mut entry = registry.tasks.remove(task_index)?;
let index = entry.inputs.iter().position(Option::is_some)?;
let input = entry.inputs[index].take()?;
@@ -192,7 +192,7 @@ impl DefaultImportTasksService {
item.account_ids = result.credential_ids;
}
Ok(Err(error)) => {
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作。
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作
item.status = if matches!(
error.kind(),
AdminErrorKind::UpstreamResultUnknown
@@ -335,7 +335,7 @@ impl DaemonTask for ImportTaskWorker {
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let mut active = FuturesUnordered::new();
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter。
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter
let cancelled = cancellation.cancelled();
tokio::pin!(cancelled);
let mut cleanup = tokio::time::interval(Duration::from_secs(60));
@@ -361,7 +361,7 @@ impl DaemonTask for ImportTaskWorker {
_ = cleanup.tick() => { drop(self.0.registry()); },
}
}
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成。
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成
while active.next().await.is_some() {}
Ok(())
})
@@ -1,9 +1,8 @@
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本。
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use crate::{
AuthService, SystemService,
@@ -16,7 +15,7 @@ use crate::{
KeyUsageRecordsQuery,
},
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
},
system::SystemVersion,
},
@@ -34,10 +33,10 @@ use super::{map_store_error, observability::health_timeline_at};
#[async_trait]
pub trait KeyUsageService: Send + Sync {
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
/// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID。
/// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID
async fn budget_for_client(
&self,
id: &ClientApiKeyId,
@@ -62,6 +61,7 @@ pub trait KeyUsageService: Send + Sync {
}
pub(crate) struct DefaultKeyUsageService {
timezone: gateway_core::time::DeploymentTimeZone,
auth: Arc<dyn AuthService>,
verifier: Arc<dyn ClientKeyVerifier>,
keys: Arc<dyn ClientKeyStore>,
@@ -76,8 +76,10 @@ impl DefaultKeyUsageService {
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Self {
Self {
timezone,
auth,
verifier,
keys,
@@ -155,7 +157,7 @@ impl KeyUsageService for DefaultKeyUsageService {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
// 明文只按服务端会话绑定的 Key 读取,禁用或删除后不再提供配置。
// 明文只按服务端会话绑定的 Key 读取,禁用或删除后不再提供配置
self.keys
.reveal_client_key(&id)
.await
@@ -181,24 +183,31 @@ impl KeyUsageService for DefaultKeyUsageService {
return Ok(None);
};
let filter = usage_filter(&id, query.model);
let now = Utc::now();
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
let now = query.range.end;
// 健康条始终展示部署时区今日,不随历史范围或模型筛选改变
let today = TimeRange {
start: china_day_start(now),
start: self
.timezone
.day_start(now)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?,
end: now,
};
let (overview, trend, health_points) = futures::try_join!(
self.observations.usage_summary(query.range, filter.clone()),
self.observations.usage_trend(query.range, filter),
self.observations
.usage_trend(today, usage_filter(&id, None)),
.usage_trend(query.range, filter, Granularity::for_range(query.range)),
self.observations.usage_trend(
today,
usage_filter(&id, None),
Granularity::for_range(today)
),
)
.map_err(|error| map_store_error(error, "key usage overview"))?;
Ok(Some(KeyUsageOverview {
key,
overview,
trend,
health_timeline: health_timeline_at(&health_points, now),
health_timeline: health_timeline_at(&health_points, now, self.timezone)?,
}))
}
+13 -435
View File
@@ -1,4 +1,4 @@
//! 管理控制面的用例实现。
//! 管理控制面的用例实现
pub mod account_groups;
pub mod accounts;
@@ -19,438 +19,16 @@ pub mod proxies;
pub mod settings;
pub mod system;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext,
accounts::DeleteAccounts,
provider_credentials::{
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
ProviderQuotaRequest, StartAuthorization,
},
},
ports::{
provider::ProviderAdmin,
store::{AccountStore, AdminStoreError, AdminStoreErrorKind},
},
mod credential_mutation;
mod error;
mod publication;
use credential_mutation::{
commit_authorization, commit_credential_refresh, commit_credential_rotation,
delete_credentials, import_proxy_binding, pending_authorization,
publish_credentials_and_observe_quota, required_credential, required_plugin_credential,
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
validate_prepared_rotation_facts,
};
use gateway_core::{
account::ProviderAccountId,
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
let kind = match error.kind() {
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
let message = match kind {
AdminErrorKind::Invalid => "请求参数不合法",
AdminErrorKind::NotFound => "请求的资源不存在",
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
AdminErrorKind::Unavailable => "依赖服务暂不可用",
_ => "服务内部错误",
};
AdminError::new(kind, message)
}
fn map_provider_error(
error: crate::ports::provider::ProviderAdminError,
resource: &'static str,
) -> AdminError {
use crate::ports::provider::ProviderAdminErrorKind;
let kind = match error.kind() {
ProviderAdminErrorKind::Invalid => AdminErrorKind::Invalid,
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
tracing::warn!(resource, error_kind = ?error.kind(), "admin provider operation failed");
let message = match kind {
AdminErrorKind::Invalid => "Provider 请求不合法",
AdminErrorKind::NotFound => "Provider 资源不存在",
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
AdminErrorKind::BadGateway => "上游服务请求失败",
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, error.public_message().unwrap_or(message))
}
async fn publish_committed(
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
) -> Result<(), AdminError> {
let revision = ConfigRevision::new(revision.get())
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
snapshot.publish_committed(revision).await;
Ok(())
}
async fn publish_credentials_and_observe_quota(
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
provider.account_facts_changed(account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。
let provider = provider.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"initial quota observation failed"
);
}
}
});
Ok(())
}
async fn required_credential(
accounts: &dyn AccountStore,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details(provider_kind, account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn required_plugin_credential(
accounts: &dyn AccountStore,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details_by_id(account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
let Some(id) = id else { return Ok(None) };
let reservation = proxies
.reserve_import(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(reservation))
}
async fn pending_authorization(
accounts: &dyn AccountStore,
proxies: &dyn crate::ports::proxy::ProxyStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let proxy;
let mut proxy_id = None;
let target = match &command.reauthorization {
Some(account_id) => {
proxy = required_credential(accounts, provider_kind, account_id, resource)
.await?
.credential
.outbound_proxy;
AuthorizationMutationTarget::Reauthorize {
account_id: account_id.clone(),
}
}
None => {
use crate::model::proxies::AccountProxySelection;
proxy = match &command.outbound_proxy {
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
Some(AccountProxySelection::Saved(id)) => {
let record = proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
proxy_id = Some(record.id);
Some(record.proxy)
}
Some(AccountProxySelection::Direct) | None => None,
};
AuthorizationMutationTarget::Create {
name: command.name.clone(),
}
}
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
target,
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
&command.context,
),
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(proxy_id))
}
fn validate_prepared_import(
provider_kind: &ProviderKind,
prepared: &PreparedCredentialImport,
_resource: &'static str,
) -> Result<(), AdminError> {
if prepared.provider_kind != *provider_kind
|| prepared
.credentials
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
}
Ok(())
}
fn validate_prepared_rotation(
account: &crate::model::accounts::AccountRecord,
prepared: &PreparedCredentialRotation,
_resource: &'static str,
) -> Result<(), AdminError> {
validate_prepared_rotation_facts(account, prepared.facts())
}
fn validate_prepared_rotation_facts(
account: &crate::model::accounts::AccountRecord,
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
) -> Result<(), AdminError> {
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
}
Ok(())
}
async fn validate_authorization_commit(
provider_kind: &ProviderKind,
context: &MutationContext,
prepared: PreparedAuthorizationCommit,
resource: &'static str,
) -> Result<PreparedAuthorizationCommit, AdminError> {
if prepared.pending.provider_kind() != provider_kind
|| !prepared.pending.owner_binding().matches_context(context)
{
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
let matches_target = match (prepared.pending.target(), &prepared.credential) {
(
AuthorizationMutationTarget::Create { .. },
PreparedAuthorizationCredential::Create(credential),
) => credential.provider_kind == *provider_kind,
(
AuthorizationMutationTarget::Reauthorize { account_id },
PreparedAuthorizationCredential::Reauthorize(credential),
) => {
let facts = credential.facts();
facts.provider_kind == *provider_kind && facts.account_id == *account_id
}
_ => false,
};
if !matches_target {
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
Ok(prepared)
}
async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
key: crate::model::provider_credentials::AuthorizationReceiptKey,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
PreparedAuthorizationCredential::Reauthorize(_)
)
{
prepared.abort().await?;
return Err(AdminError::invalid("重新授权不能修改账号设置"));
}
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit(settings, key);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard
&& result.newly_committed
{
guard.finish();
}
if let Some(guard) = authorization_guard
&& let Err(error) = guard.commit().await
{
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败。
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
}
Ok(result)
}
Err(error) => {
drop(credential_guard);
if let Some(guard) = authorization_guard
&& let Err(settlement_error) = guard.abort().await
{
tracing::warn!(
resource,
store_error = %error,
settlement_error = %settlement_error,
"OAuth authorization claim release failed after Store commit failure"
);
return Err(settlement_error);
}
Err(map_store_error(error, resource))
}
}
}
async fn commit_credential_rotation(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
settings: Option<crate::model::accounts::UpdateAccount>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_rotation(
CredentialRotationCommit {
prepared: facts,
settings,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
async fn commit_credential_refresh(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(
CredentialRotationCommit {
prepared: facts,
settings: None,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
async fn delete_credentials(
accounts: &dyn AccountStore,
provider: &dyn ProviderAdmin,
command: CredentialDeletion,
resource: &'static str,
) -> Result<CredentialDeletionResult, AdminError> {
for account_id in &command.account_ids {
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
}
let account_ids = command.account_ids;
let revision = accounts
.delete_accounts(
DeleteAccounts {
account_ids: account_ids
.iter()
.map(|account_id| account_id.as_str().to_owned())
.collect(),
},
&command.context,
)
.await
.map_err(|error| map_store_error(error, resource))?;
for account_id in &account_ids {
provider.account_unavailable(account_id).await;
}
provider.account_facts_changed(&account_ids).await;
Ok(CredentialDeletionResult {
config_revision: revision,
account_ids,
})
}
use error::{map_provider_error, map_store_error};
use publication::publish_committed;
@@ -1,4 +1,4 @@
//! 观测查询、趋势、费用覆盖与健康阈值规则。
//! 观测查询、趋势、费用覆盖与健康阈值规则
use std::{
collections::{BTreeMap, HashMap, btree_map::Entry},
@@ -8,7 +8,7 @@ use std::{
};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Timelike as _, Utc};
use chrono::{DateTime, Duration, Utc};
use futures::TryStreamExt;
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
@@ -17,14 +17,14 @@ use crate::{
AdminError,
observability::{
CostCoverage, CurrencyCost, DashboardAccountUsage, DashboardCapacity,
DashboardPeriodMetrics, DashboardResult, DecimalAmount, DiagnosticDimension,
DiagnosticsItem, DiagnosticsResult, HealthStatus, HealthTimeline, HealthTimelinePoint,
OpsErrorPage, OpsErrorQuery, ProviderBillingInput, RequestMetricPoint, RequestMetrics,
TimeRange, Trend, TrendKind, TrendPoint, TrendSummary, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
UsageQuery, UsageSummary, china_day_start,
DashboardPeriodMetrics, DashboardQuery, DashboardResult, DecimalAmount,
DiagnosticDimension, DiagnosticsItem, DiagnosticsResult, Granularity, HealthStatus,
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
TrendSummary, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageInsights, UsageInsightsCost, UsageInsightsCostPoint, UsageInsightsHealth,
UsageInsightsHealthPoint, UsageInsightsPerformance, UsageInsightsPerformancePoint,
UsageOverview, UsagePage, UsageQuery, UsageSummary,
},
provider_credentials::ProviderQuotaRequest,
},
@@ -37,7 +37,6 @@ use crate::{
use super::{map_provider_error, map_store_error};
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
@@ -94,7 +93,7 @@ impl DashboardQueryCache {
}
}
/// API 消费的观测控制面服务。
/// API 消费的观测控制面服务
#[async_trait]
pub trait ObservabilityService: Send + Sync {
async fn dashboard_summary(
@@ -126,6 +125,7 @@ pub trait ObservabilityService: Send + Sync {
}
pub(crate) struct DefaultObservabilityService {
timezone: gateway_core::time::DeploymentTimeZone,
store: Arc<dyn ObservabilityStore>,
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
@@ -140,8 +140,10 @@ impl DefaultObservabilityService {
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Self {
Self {
timezone,
store,
accounts,
settings,
@@ -155,12 +157,13 @@ impl DefaultObservabilityService {
range: TimeRange,
) -> Result<DashboardResult, AdminError> {
let observed_at = Utc::now();
let (mut observation, settings, runtime_slots) = futures::try_join!(
self.store.dashboard_summary(range, observed_at),
let (mut observation, settings) = futures::try_join!(
self.store
.dashboard_summary(DashboardQuery::new(range), observed_at),
self.settings.load_runtime_settings(),
self.store.dashboard_runtime_slots(observed_at),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
let runtime_slots = observation.runtime_slots;
self.enrich_list_records(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
@@ -169,8 +172,14 @@ impl DefaultObservabilityService {
.providers
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
}
let today_start = china_day_start(observation.range.end);
let yesterday_start = today_start - Duration::days(1);
let today_start = self
.timezone
.day_start(observation.range.end)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let yesterday_start = self
.timezone
.days_before(observation.range.end, 1)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let today =
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
@@ -191,11 +200,12 @@ impl DefaultObservabilityService {
let average_first_token_latency_ms =
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let health_timeline =
health_timeline_at(&observation.trend, observation.range.end, self.timezone)?;
let wire_profiles = self
.providers
.dashboard_wire_profiles(&settings.request_profiles);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let max_concurrent_per_account = u64::from(settings.values.max_concurrent_per_account);
let (inherited_accounts, overridden_slots) = runtime_slots
.as_ref()
.map_or((observation.provider_accounts.normal, 0), |slots| {
@@ -256,7 +266,7 @@ impl ObservabilityService for DefaultObservabilityService {
) -> Result<Trend, AdminError> {
let points = self
.store
.dashboard_trend(range)
.dashboard_trend(range, Granularity::for_range(range))
.await
.map_err(|error| map_store_error(error, "dashboard trend"))?;
trend(kind, points)
@@ -310,10 +320,11 @@ impl ObservabilityService for DefaultObservabilityService {
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageInsights, AdminError> {
let granularity = Granularity::for_range(range);
let aggregates = async {
futures::try_join!(
self.store.usage_summary(range, filter.clone()),
self.store.usage_trend(range, filter.clone()),
self.store.usage_trend(range, filter.clone(), granularity),
)
.map_err(|error| map_store_error(error, "usage insights"))
};
@@ -322,7 +333,7 @@ impl ObservabilityService for DefaultObservabilityService {
recover_standard_costs(
&self.providers,
self.store
.usage_calculated_billing_facts(range, filter.clone()),
.usage_calculated_billing_facts(range, filter.clone(), granularity),
),
)?;
build_usage_insights(overview, trend, standard_costs)
@@ -336,7 +347,7 @@ impl ObservabilityService for DefaultObservabilityService {
) -> Result<DiagnosticsResult, AdminError> {
let observation = self
.store
.usage_diagnostics(range, filter, dimension)
.usage_diagnostics(range, filter, dimension, 100)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let items = observation
@@ -382,10 +393,19 @@ impl ObservabilityService for DefaultObservabilityService {
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
self.store
let mut page = self
.store
.list_ops_errors(query)
.await
.map_err(|error| map_store_error(error, "operations errors"))
.map_err(|error| map_store_error(error, "operations errors"))?;
for error in &mut page.items {
error.provider_account_plan_type_display =
error.provider_kind.as_deref().and_then(|provider| {
self.providers
.plan_type_display(provider, error.provider_account_plan_type.as_deref())
});
}
Ok(page)
}
}
@@ -781,7 +801,7 @@ impl DefaultObservabilityService {
}
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
for record in records {
record.provider_account_plan_type_display =
@@ -852,15 +872,28 @@ impl DefaultObservabilityService {
}
}
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
#[must_use]
/// 按部署时区的自然日计算 15 分钟健康桶,桶数随夏令时变化
pub(super) fn health_timeline_at(
records: &[RequestMetricPoint],
now: DateTime<Utc>,
) -> HealthTimeline {
let current_slot = quarter_hour_start(now);
let start = china_day_start(now);
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<HealthTimeline, AdminError> {
let start = timezone
.day_start(now)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let end = timezone
.days_after(now, 1)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let current_slot = start
+ Duration::minutes(
(now - start)
.num_minutes()
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
* HEALTH_TIMELINE_SLOT_MINUTES,
);
let slots = ((end - start).num_minutes() + HEALTH_TIMELINE_SLOT_MINUTES - 1)
/ HEALTH_TIMELINE_SLOT_MINUTES;
let mut buckets = (0..slots)
.map(|index| {
(
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
@@ -872,7 +905,13 @@ pub(super) fn health_timeline_at(
if record.bucket_start < start || record.bucket_start > now {
continue;
}
let record_slot = quarter_hour_start(record.bucket_start);
let record_slot = start
+ Duration::minutes(
(record.bucket_start - start)
.num_minutes()
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
* HEALTH_TIMELINE_SLOT_MINUTES,
);
if let Some((_, bucket)) = buckets
.iter_mut()
.find(|(bucket_start, _)| *bucket_start == record_slot)
@@ -888,7 +927,7 @@ pub(super) fn health_timeline_at(
totals.add_window(*bucket);
totals
});
HealthTimeline {
Ok(HealthTimeline {
reliability_percent: health_reliability(totals),
status: health_status(totals, false),
success_requests: totals.success_requests,
@@ -909,7 +948,7 @@ pub(super) fn health_timeline_at(
caller_error_requests: bucket.caller_error_requests,
})
.collect(),
}
})
}
fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, AdminError> {
@@ -1173,10 +1212,3 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 {
.failure_count
.saturating_sub(metrics.caller_error_count)
}
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
let elapsed = value
.timestamp()
.rem_euclid(HEALTH_TIMELINE_SLOT_MINUTES * 60);
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
}
@@ -1,4 +1,4 @@
//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威。
//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威
use std::sync::Arc;
@@ -1,4 +1,4 @@
//! 插件 Client Key 管理;复用管理服务,只开放非秘密目录与预算操作。
//! 插件 Client Key 管理;复用管理服务,只开放非秘密目录与预算操作
use std::sync::Arc;
@@ -1,3 +1,5 @@
//! 插件自有分组、Key 与成员关系的写入及配置发布编排
use crate::{
model::{
AdminError, MutationContext,
@@ -54,7 +56,7 @@ impl PluginResourceAccess for DefaultPluginResourceAccess {
name: command.name,
description: command.description,
color: command.color,
disable_fast: command.disable_fast,
fast_mode: command.fast_mode,
},
context,
)
@@ -1,13 +1,10 @@
//! 系统升级与回滚前的插件兼容性检查及确认版本校验
use std::{collections::BTreeMap, sync::Arc};
use super::publish_committed;
use crate::model::{
MutationContext,
system::{SystemIncompatiblePlugin, SystemRestartPlan},
};
use crate::model::system::{SystemIncompatiblePlugin, SystemRestartPlan};
use crate::ports::system::SystemRestartPreflight;
use async_trait::async_trait;
use gateway_core::runtime::SnapshotControl;
use sha2::{Digest as _, Sha256};
use crate::{
@@ -24,24 +21,18 @@ use crate::{
use super::plugins::official::{update_compatibility, validate_update_release};
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件
pub(crate) struct PluginSystemUpdatePreflight {
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
snapshots: Arc<dyn SnapshotControl>,
}
impl PluginSystemUpdatePreflight {
pub(crate) fn new(
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
snapshots: Arc<dyn SnapshotControl>,
) -> Self {
Self {
store,
inspector,
snapshots,
}
Self { store, inspector }
}
}
@@ -53,7 +44,7 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
) -> Result<Revision, SystemOperationError> {
let target_version = candidate.target_version.trim_start_matches('v');
semver::Version::parse(target_version).map_err(|_| invalid("目标网关版本不合法"))?;
// 下载阶段只校验发行身份;插件兼容性在用户点击重启时检查并确认。
// 下载阶段只校验发行身份;插件兼容性在用户点击重启时检查并确认
validate_update_release(candidate.release_manifest.as_ref(), target_version)
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
Ok(self
@@ -68,11 +59,7 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
let plan = self.plan(Some(candidate)).await?;
if !plan.incompatible_plugins.is_empty() {
return Err(conflict("启用插件与回滚目标不兼容,请先停用对应插件"));
}
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))
self.validate(candidate).await
}
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> {
@@ -148,7 +135,7 @@ impl PluginSystemUpdatePreflight {
)
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
}
// 未知目标合同不能证明兼容,列入待确认停用;不伪造目标支持范围。
// 未知目标合同不能证明兼容,列出风险供确认,不改写插件启用配置
let host = candidate.as_ref().and_then(|item| {
update_compatibility(
&item.release_manifest,
@@ -205,10 +192,20 @@ impl PluginSystemUpdatePreflight {
} else {
match self
.inspector
.inspect(artifact.archive, Some(instance.artifact_sha256.clone()))
.inspect(
artifact.archive.clone(),
Some(instance.artifact_sha256.clone()),
)
.await
{
Ok(_) => None,
Ok(_) => self
.inspector
.compatibility_warning(
artifact.archive,
instance.artifact_sha256.clone(),
)
.await
.map_err(map_inspection_error)?,
Err(error) if error.kind() == AdminErrorKind::Invalid => {
Some("插件与当前宿主不兼容".to_owned())
}
@@ -235,7 +232,6 @@ impl PluginSystemUpdatePreflight {
pub(crate) struct ConfirmedPluginRestart {
pub preflight: Arc<PluginSystemUpdatePreflight>,
pub confirmation: Option<SystemRestartPlan>,
pub context: MutationContext,
}
#[async_trait]
@@ -253,26 +249,10 @@ impl SystemRestartPreflight for ConfirmedPluginRestart {
return Err(conflict("插件配置或目标版本已变化,请重新检查并确认重启"));
}
if !plan.incompatible_plugins.is_empty() && self.confirmation.is_none() {
return Err(conflict("请先确认停用不兼容插件,再重启服务"));
return Err(conflict("请先确认插件兼容性风险,再重启服务"));
}
let mut revision =
let revision =
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))?;
if !plan.incompatible_plugins.is_empty() {
let ids = plan
.incompatible_plugins
.iter()
.map(|item| item.instance_id.clone())
.collect::<Vec<_>>();
revision = self
.preflight
.store
.disable_instances(&ids, revision, &self.context)
.await
.map_err(map_store_error)?;
publish_committed(self.preflight.snapshots.as_ref(), revision)
.await
.map_err(|_| internal("插件已停用,但运行配置尚未发布,请重试重启"))?;
}
self.preflight.confirm_revision(revision).await
}
}
@@ -1,3 +1,5 @@
//! 插件制品的验证、接受、安装与删除,以及首次安装的默认实例配置
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
@@ -30,7 +32,7 @@ use crate::{
use super::state::PluginStateService;
/// 远程插件来源访问所需的传输与受管出站代理目录。
/// 远程插件来源访问所需的传输与受管出站代理目录
pub struct PluginDistributionPorts {
pub(super) transport: Arc<dyn PluginDistribution>,
pub(super) proxies: Arc<dyn ProxyStore>,
@@ -43,7 +45,7 @@ impl PluginDistributionPorts {
}
}
/// 所有安装入口在取得完整包后进入同一校验与事务流程。
/// 所有安装入口在取得完整包后进入同一校验与事务流程
pub struct PluginsService {
pub(super) store: Arc<dyn PluginStore>,
pub(super) inspector: Arc<dyn PluginPackageInspector>,
@@ -52,7 +54,7 @@ pub struct PluginsService {
pub(super) preparation: Arc<dyn PluginPreparation>,
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
pub(super) state: PluginStateService,
pub(super) compatibility: tokio::sync::Mutex<BTreeMap<String, Option<String>>>,
pub(super) compatibility: tokio::sync::Mutex<BTreeMap<String, super::instances::PackageStatus>>,
}
impl PluginsService {
@@ -118,7 +120,7 @@ impl PluginsService {
})
}
/// 管理上传固定为自定义来源;调用方不能借通用参数声明官方身份。
/// 管理上传固定为自定义来源;调用方不能借通用参数声明官方身份
pub async fn install_upload(
&self,
archive: Arc<[u8]>,
@@ -135,7 +137,7 @@ impl PluginsService {
self.complete_install(mutation, context).await
}
/// 接受已导入但尚未安装的制品;官方发行导入也必须经过管理员的这一步。
/// 接受已导入但尚未安装的制品;官方发行导入也必须经过管理员的这一步
pub async fn accept_artifact(
&self,
digest: &str,
@@ -185,7 +187,7 @@ impl PluginsService {
.await?;
// 其它管理员写入或同插件的并发安装可能推进全局 revision;稳定 creation ID
// 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本。
// 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本
for _ in 0..3 {
let snapshot = self
.store
@@ -311,7 +313,7 @@ fn default_instance_id(plugin_id: &str) -> String {
let digest = hash.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
// RFC 9562 UUIDv8 保留给应用自定义派生;variant 固定为 RFC 4122。
// RFC 9562 UUIDv8 保留给应用自定义派生;variant 固定为 RFC 4122
bytes[6] = (bytes[6] & 0x0f) | 0x80;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
uuid::Uuid::from_bytes(bytes).to_string()
@@ -1,3 +1,5 @@
//! 插件更新来源与下载凭据管理,以及远程制品验证和安装编排
use std::collections::BTreeSet;
use super::PluginsService;
@@ -93,7 +95,7 @@ impl PluginsService {
binding.outbound_proxy_id.as_deref(),
)
.await?;
// 查询期间来源或策略被修改时返回冲突,不把旧来源的候选交给下一步安装。
// 查询期间来源或策略被修改时返回冲突,不把旧来源的候选交给下一步安装
if !self.update_sources().await?.contains(&binding) {
return Err(AdminError::conflict("插件更新来源已变更,请重新检查"));
}
@@ -259,7 +261,7 @@ impl PluginsService {
{
return Err(AdminError::invalid("插件清单身份与安装选择不符"));
}
// 首次解析前身份未知,按包内身份复核下载前的来源绑定。
// 首次解析前身份未知,按包内身份复核下载前的来源绑定
let binding = bindings
.iter()
.find(|binding| binding.plugin_id == artifact.metadata.plugin_id);
@@ -1,6 +1,11 @@
//! 插件实例的配置、停用、删除与私有状态迁移编排
use std::collections::{BTreeMap, BTreeSet};
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId};
use gateway_core::{
policy::ClientApiKeyId,
routing::{AccountGroupId, extensions::ExtensionSetReference},
};
use secrecy::ExposeSecret;
use super::PluginsService;
@@ -17,11 +22,17 @@ use crate::{
use_case::{map_store_error, publish_committed},
};
#[derive(Clone, Default)]
pub(super) struct PackageStatus {
warning: Option<String>,
load_error: Option<String>,
}
impl PluginsService {
/// 只校验固定包体,不启动插件;停用的旧版本仍需向管理端说明不能启用的原因。
async fn compatibility_warning(&self, digest: &str) -> Result<Option<String>, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包。
// 只缓存有限数量的确定结果,暂时性错误仍可重试。
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
async fn package_status(&self, digest: &str) -> Result<PackageStatus, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
// 只缓存有限数量的确定结果,暂时性错误仍可重试
let mut cache = self.compatibility.lock().await;
if let Some(warning) = cache.get(digest) {
return Ok(warning.clone());
@@ -33,13 +44,17 @@ impl PluginsService {
.map_err(|error| map_store_error(error, "plugin"))?;
let warning = match self
.inspector
.inspect(artifact.archive, Some(digest.to_owned()))
.compatibility_warning(artifact.archive, digest.to_owned())
.await
{
Ok(_) => None,
Err(error) if error.kind() == AdminErrorKind::Invalid => {
Some(error.message().to_owned())
}
Ok(warning) => PackageStatus {
warning,
load_error: None,
},
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
warning: None,
load_error: Some(error.message().to_owned()),
},
Err(error) => return Err(error),
};
if cache.len() < 128 {
@@ -82,10 +97,8 @@ impl PluginsService {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let compatibility_warning = self
.compatibility_warning(&instance.artifact_sha256)
.await?;
let configuration_required = compatibility_warning.is_none()
let package_status = self.package_status(&instance.artifact_sha256).await?;
let configuration_required = package_status.load_error.is_none()
&& !self
.preparation
.configuration_ready(instance.clone(), metadata)
@@ -109,8 +122,14 @@ impl PluginsService {
}
});
views.push(PluginInstanceView {
api_deprecations: if package_status.load_error.is_none() {
self.inspector.api_deprecations(metadata)?
} else {
Vec::new()
},
configuration_required,
compatibility_warning,
compatibility_warning: package_status.warning,
load_error: package_status.load_error,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
@@ -198,7 +217,10 @@ impl PluginsService {
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
}
if input.enabled
&& let Some(warning) = self.compatibility_warning(&input.artifact_sha256).await?
&& let Some(warning) = self
.package_status(&input.artifact_sha256)
.await?
.load_error
{
return Err(AdminError::invalid(format!(
"{warning},无法启动,请安装兼容版本"
@@ -346,25 +368,8 @@ impl PluginsService {
)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let prepared = if result.config_revision == candidate_revision
&& result.instance.revision == candidate_revision
{
prepared
} else {
snapshot.config_revision = result.config_revision;
snapshot
.instances
.retain(|item| item.id != result.instance.id);
snapshot.instances.push(result.instance.clone());
apply_replacements(&mut snapshot, &replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &result.instance)
self.publish_instance(snapshot, prepared, &result, &replacements)
.await?;
// prepared 的强引用跨过提交与唯一发布入口,防止候选在被读取之前回收。
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
drop(prepared);
Ok(result)
}
@@ -379,7 +384,7 @@ impl PluginsService {
) -> Result<PluginInstanceMutation, AdminError> {
let previous_state = self.preparation.validate(previous.clone()).await?;
let original_revision = snapshot.config_revision;
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行。
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行
let mut migration_instance = target.clone();
migration_instance.enabled = true;
snapshot.config_revision = migration_instance.revision;
@@ -441,48 +446,45 @@ impl PluginsService {
.await);
}
};
if let Err(error) = self
.preparation
.migrate_state(&migration_prepared, transition.clone())
.await
{
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
let committed = async {
self.preparation
.migrate_state(&migration_prepared, transition.clone())
.await?;
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = self.preparation.prepare(current.clone()).await?;
let result = self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
error,
)
.await);
.await
.map_err(|error| map_store_error(error, "plugin"))?;
Ok((current, prepared, result))
}
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
AdminError::conflict("迁移期间插件配置已发生变化"),
)
.await);
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = match self.preparation.prepare(current.clone()).await {
Ok(prepared) => prepared,
.await;
let (current, prepared, result) = match committed {
Ok(committed) => committed,
Err(error) => {
return Err(self
.recover_failed_state_transition(
@@ -496,53 +498,9 @@ impl PluginsService {
.await);
}
};
let result = match self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
)
.await
{
Ok(result) => result,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
map_store_error(error, "plugin"),
)
.await);
}
};
let prepared = if result.config_revision == candidate_revision
&& result.instance.revision == candidate_revision
{
prepared
} else {
current.config_revision = result.config_revision;
current
.instances
.retain(|item| item.id != result.instance.id);
current.instances.push(result.instance.clone());
apply_replacements(&mut current, replacements);
self.preparation.prepare(current).await?
};
self.preparation
.activate_state(&prepared, &result.instance)
self.publish_instance(current, prepared, &result, replacements)
.await?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
drop(migration_prepared);
drop(prepared);
Ok(result)
}
@@ -593,22 +551,8 @@ impl PluginsService {
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let prepared = if mutation.config_revision == candidate_revision
&& mutation.instance.revision == candidate_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
self.publish_instance(snapshot, prepared, &mutation, &[])
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
Ok::<(), AdminError>(())
}
.await;
@@ -624,6 +568,35 @@ impl PluginsService {
}
}
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
async fn publish_instance(
&self,
mut snapshot: PluginInstanceSnapshot,
prepared: ExtensionSetReference,
mutation: &PluginInstanceMutation,
replacements: &[PluginInstanceReplacement],
) -> Result<(), AdminError> {
let prepared = if mutation.config_revision == snapshot.config_revision
&& mutation.instance.revision == snapshot.config_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
apply_replacements(&mut snapshot, replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
drop(prepared);
Ok(())
}
pub async fn delete_instance(
&self,
id: &str,
@@ -651,7 +624,7 @@ impl PluginsService {
Ok(revision)
}
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同。
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同
pub async fn disable_instance(
&self,
id: &str,

Some files were not shown because too many files have changed in this diff Show More