From 8fb48dd6c33b2a0d4289ad74eb6438bdd7efc04a Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 17 Oct 2026 16:53:54 +0800 Subject: [PATCH] fix(plugins): expose reset credit automation controls Register typed account automation operations for server and CLI plugins, reusing the native account service. Preserve the initiating actor across service continuations and generate the SDK contract from host declarations. Co-authored-by: Codex --- backend/apps/gateway/src/bootstrap/command.rs | 11 + backend/crates/gateway-admin/src/lib.rs | 27 ++- .../src/model/reset_credit_automation.rs | 2 +- .../src/public_service/accounts.rs | 37 +++ .../gateway-admin/src/public_service/actor.rs | 38 +++- .../gateway-admin/src/public_service/mod.rs | 5 +- .../src/public_service/registry.rs | 7 + .../tests/use_case/accounts/mod.rs | 5 +- .../accounts/reset_credit_automation.rs | 212 ++++++++++++++++++ .../tests/use_case/service_contract.rs | 202 ++++++++++------- backend/crates/gateway-core/src/lib.rs | 8 + backend/crates/gateway-plugin/sdk/README.md | 3 +- .../gateway-plugin/sdk/docs/capabilities.md | 15 +- .../sdk/src/call/services/accounts.rs | 25 +++ .../sdk/src/call/services/mod.rs | 1 + docs/architecture.md | 1 + 16 files changed, 515 insertions(+), 84 deletions(-) create mode 100644 backend/crates/gateway-admin/src/public_service/accounts.rs create mode 100644 backend/crates/gateway-plugin/sdk/src/call/services/accounts.rs diff --git a/backend/apps/gateway/src/bootstrap/command.rs b/backend/apps/gateway/src/bootstrap/command.rs index 886ac5efb..ba1745cc8 100644 --- a/backend/apps/gateway/src/bootstrap/command.rs +++ b/backend/apps/gateway/src/bootstrap/command.rs @@ -67,6 +67,7 @@ async fn execute( let Environment { host, mut store, + timezone, plugin_cache, version, plugin_http, @@ -115,6 +116,16 @@ async fn execute( &plugin_runtime, )); or_shutdown!(plugin_runtime, services.register_settings(&settings)); + let accounts = gateway_admin::initialize_accounts( + store.admin_ports().accounts(), + store.admin_ports().account_runtime(), + store.admin_ports().settings(), + timezone, + providers.admin.clone(), + core.snapshot_control(), + core.account_probe(), + ); + or_shutdown!(plugin_runtime, services.register_accounts(&accounts)); let usage_data = gateway_admin::initialize_usage_data( store.admin_ports().usage_data(), store.admin_ports().settings(), diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index b7bca49bd..b770ed629 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -421,7 +421,7 @@ async fn initialize_inner( auth.ensure_default_admin(config.default_password.expose()) .await?; - let accounts = Arc::new(DefaultAccountsService::new( + let accounts = initialize_accounts( store.accounts(), store.account_runtime(), store.settings(), @@ -429,7 +429,7 @@ async fn initialize_inner( registry.clone(), snapshot.clone(), probe.clone(), - )); + ); let backup_ports = store.backup(); let backups = Arc::new(DefaultBackupService::new( backup_ports.repository(), @@ -480,6 +480,7 @@ async fn initialize_inner( let usage_data = initialize_usage_data(store.usage_data(), store.settings(), registry.clone()); let mut public_services = public_service::Registry::new(service_middleware); public_services.register_settings(&settings)?; + public_services.register_accounts(&accounts)?; public_services.register_usage_data(&usage_data)?; let services = AdminServices { timezone, @@ -671,6 +672,28 @@ fn freeze_recovery_worker_contribution( Ok(vec![WorkerContribution::Registration(registration)]) } +/// 服务端与 CLI 复用账号用例,不随命令启动后台调度 +#[must_use] +pub fn initialize_accounts( + accounts: Arc, + account_runtime: Arc, + settings: Arc, + timezone: gateway_core::time::DeploymentTimeZone, + providers: ProviderAdminRegistry, + snapshot: Arc, + probe: Arc, +) -> Arc { + Arc::new(DefaultAccountsService::new( + accounts, + account_runtime, + settings, + timezone, + providers, + snapshot, + probe, + )) +} + /// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布 pub fn initialize_settings( store: Arc, diff --git a/backend/crates/gateway-admin/src/model/reset_credit_automation.rs b/backend/crates/gateway-admin/src/model/reset_credit_automation.rs index e635710bf..ae67b65fa 100644 --- a/backend/crates/gateway-admin/src/model/reset_credit_automation.rs +++ b/backend/crates/gateway-admin/src/model/reset_credit_automation.rs @@ -35,7 +35,7 @@ pub struct ResetCreditAutomation { pub state: ResetCreditAutomationState, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResetCreditAutomationStatus { pub enabled: bool, pub lead_minutes: u32, diff --git a/backend/crates/gateway-admin/src/public_service/accounts.rs b/backend/crates/gateway-admin/src/public_service/accounts.rs new file mode 100644 index 000000000..bcc02e8ae --- /dev/null +++ b/backend/crates/gateway-admin/src/public_service/accounts.rs @@ -0,0 +1,37 @@ +//! 账号自动重置的公开操作声明,复用账号用例及宿主签发的审计上下文 + +use crate::{AccountsService, model::AdminError}; +use gateway_core::account::ProviderAccountId; +use std::sync::Arc; + +pub(super) fn register( + registry: &mut super::Registry, + service: &Arc, +) -> Result<(), AdminError> { + macro_rules! register { + ($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{ + let service = service.clone(); + registry.register(concat!("accounts.", stringify!($method)), move |$pattern: $input| { + let service = service.clone(); + Box::pin(async move { service.$method($($argument),*).await }) + })?; + }}; + } + register!( + GetResetCreditAutomation, + reset_credit_automation_status, + String, + account_id, + ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))? + ); + register!( + SetResetCreditAutomation, + set_reset_credit_automation, + (String, bool), + (account_id, enabled), + &super::actor::mutation_context()?, + ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))?, + enabled + ); + Ok(()) +} diff --git a/backend/crates/gateway-admin/src/public_service/actor.rs b/backend/crates/gateway-admin/src/public_service/actor.rs index 813ccc95b..38529abc5 100644 --- a/backend/crates/gateway-admin/src/public_service/actor.rs +++ b/backend/crates/gateway-admin/src/public_service/actor.rs @@ -1,21 +1,55 @@ //! 公开服务写入主体由受信宿主边界设置,插件输入不能覆盖 use crate::model::usage_data::UsageActor; +use crate::model::{AdminError, MutationActor, MutationContext}; use std::future::Future; tokio::task_local! { - static ACTOR: UsageActor; + static ACTOR: Option; } /// 与 middleware Origin 独立传递,嵌套服务不会丢失真实调用主体 pub async fn scope_actor(actor: UsageActor, future: F) -> F::Output { + scope_captured(Some(actor), future).await +} + +pub(super) async fn scope_captured(actor: Option, future: F) -> F::Output { ACTOR.scope(actor, future).await } pub(super) fn current_actor() -> Result { - ACTOR.try_with(Clone::clone).map_err(|_| { + ACTOR.try_with(Clone::clone).ok().flatten().ok_or_else(|| { crate::model::usage_data::UsageDataError::invalid( "usage mutation requires a trusted caller identity", ) }) } + +pub(super) fn mutation_context() -> Result { + let actor = ACTOR + .try_with(Clone::clone) + .ok() + .flatten() + .ok_or_else(|| AdminError::invalid("服务写入缺少可信调用主体"))?; + super::CURRENT + .try_with(|origin| { + let (actor, request_id) = match actor { + UsageActor::Plugin { instance_id, .. } => ( + MutationActor::System, + format!( + "plugin:{instance_id}:request:{}:call:{}", + origin.request_id, origin.call_id + ), + ), + UsageActor::Admin { subject } => ( + MutationActor::AdminSession { + admin_user_id: subject, + }, + origin.request_id.clone(), + ), + UsageActor::System => (MutationActor::System, origin.request_id.clone()), + }; + MutationContext { actor, request_id } + }) + .map_err(|_| AdminError::invalid("服务写入缺少调用上下文")) +} diff --git a/backend/crates/gateway-admin/src/public_service/mod.rs b/backend/crates/gateway-admin/src/public_service/mod.rs index e58879eed..dee126acb 100644 --- a/backend/crates/gateway-admin/src/public_service/mod.rs +++ b/backend/crates/gateway-admin/src/public_service/mod.rs @@ -1,5 +1,6 @@ //! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹 +mod accounts; mod actor; mod registry; mod settings; @@ -101,8 +102,10 @@ pub(crate) async fn invoke( plan: plan.clone(), }; let inner = context.clone(); + // next 可在另一回调任务中执行,终端必须保留发起主体及其缺席状态 + let actor = actor::current_actor().ok(); let next = compose(Vec::new(), move |input| { - Box::pin(scope(inner, terminal(input))) + Box::pin(actor::scope_captured(actor, scope(inner, terminal(input)))) }); tokio::select! { biased; diff --git a/backend/crates/gateway-admin/src/public_service/registry.rs b/backend/crates/gateway-admin/src/public_service/registry.rs index 9eb9089b2..d556321c1 100644 --- a/backend/crates/gateway-admin/src/public_service/registry.rs +++ b/backend/crates/gateway-admin/src/public_service/registry.rs @@ -29,6 +29,13 @@ impl Registry { super::settings::register(self, service) } + pub fn register_accounts( + &mut self, + service: &Arc, + ) -> Result<(), AdminError> { + super::accounts::register(self, service) + } + pub(crate) fn register( &mut self, name: &'static str, diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs index 02415471c..a1c0019ce 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs @@ -3742,7 +3742,7 @@ impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore async fn save_reset_credit_automation( &self, record: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation, - _: Option<&MutationContext>, + context: Option<&MutationContext>, ) -> AdminStoreResult { let mut records = self.automations.lock().unwrap(); if records.get(&record.account_id).map_or(0, |r| r.revision) != record.revision { @@ -3756,6 +3756,9 @@ impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore next.revision += 1; let revision = next.revision; records.insert(next.account_id.clone(), next); + if let Some(context) = context { + self.record_context(context); + } Ok(revision) } } diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs b/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs index e84dfb578..3eb66b7c3 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs @@ -5,6 +5,218 @@ use gateway_admin::{ model::reset_credit_automation::*, ports::reset_credit_automation::ResetCreditAutomationStore as _, }; +use gateway_core::{ + engine::middleware::{self, FrozenMiddlewarePlan, MiddlewarePlan}, + routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, +}; + +#[derive(Debug)] +struct ServiceForwarder; + +impl ExtensionSetLease for ServiceForwarder { + fn is_ready(&self) -> bool { + true + } +} + +impl MiddlewarePlan for ServiceForwarder { + fn has_service(&self) -> bool { + true + } + + fn handle_service( + &self, + _: middleware::service::Context, + input: middleware::service::Value, + next: middleware::service::Next, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + // 模拟 RPC 回调跨任务执行 next,且该任务带有另一插件的身份 + tokio::spawn(gateway_admin::public_service::scope_actor( + gateway_admin::model::usage_data::UsageActor::Plugin { + publisher: "test".into(), + name: "forwarder".into(), + instance_id: "forwarding-plugin".into(), + }, + next.run(input), + )) + .await + .unwrap() + }) + } + + fn handle( + &self, + _: middleware::MiddlewareContext, + request: middleware::MiddlewareRequest, + next: middleware::MiddlewareNext, + ) -> BoxFuture<'static, Result> + { + next.run(request) + } +} + +#[tokio::test] +async fn public_service_reads_and_updates_reset_credit_automation_with_host_identity() { + use gateway_admin::{ + model::usage_data::UsageActor, + public_service::{Origin, Plan, scope, scope_actor}, + }; + + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + enable_capability(&provider); + let store = FakeAccountStore::new("openai", events.clone()); + let services = accounts_service(provider, store.clone()).await; + let registry = services.public_services(); + let read = "accounts.reset_credit_automation_status"; + let write = "accounts.set_reset_credit_automation"; + let initial = registry.call(read, json!("acct_test")).await.unwrap(); + assert_eq!(initial["enabled"], false); + assert_eq!(initial["lead_minutes"], 10); + + let actor = UsageActor::Plugin { + publisher: "test".into(), + name: "automation".into(), + instance_id: "installed-plugin".into(), + }; + let middleware = Arc::new(ServiceForwarder); + let plan = FrozenMiddlewarePlan::new( + middleware.clone(), + ExtensionSetReference::new( + ExtensionSetId::new("service-forwarder".into()).unwrap(), + middleware, + ), + ); + let origin = Origin { + request_id: "plugin-request".into(), + call_id: "plugin-parent".into(), + cancellation: Default::default(), + extensions: Default::default(), + plan: Plan::Frozen(Some(plan)), + }; + let error = scope( + origin.clone(), + registry.call(write, json!(["acct_test", true])), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "invalid"); + let enabled = scope_actor( + actor.clone(), + scope( + origin.clone(), + registry.call(write, json!(["acct_test", true])), + ), + ) + .await + .unwrap(); + let status: ResetCreditAutomationStatus = serde_json::from_value(enabled.clone()).unwrap(); + assert!(status.enabled); + assert!(status.next_discovery_at.is_some()); + assert_eq!( + registry.call(read, json!("acct_test")).await.unwrap(), + enabled + ); + assert!(store.automations.lock().unwrap()["acct_test"].enabled); + let audit = store.audit_requests(); + assert_eq!(audit.len(), 1); + assert!(audit[0].starts_with("plugin:installed-plugin:request:plugin-request:call:")); + + let repeated = scope_actor( + actor.clone(), + scope( + origin.clone(), + registry.call(write, json!(["acct_test", true])), + ), + ) + .await + .unwrap(); + assert_eq!(repeated, enabled); + assert_eq!(store.audit_requests(), audit); + + let disabled = scope_actor( + actor, + scope(origin, registry.call(write, json!(["acct_test", false]))), + ) + .await + .unwrap(); + assert_eq!(disabled["enabled"], false); + assert!(disabled["next_discovery_at"].is_null()); + assert!(disabled["next_consume_at"].is_null()); + assert!(!store.automations.lock().unwrap()["acct_test"].enabled); + assert_eq!(store.audit_requests().len(), 2); + assert!(!recorded(&events).contains(&"provider.reset_credits")); +} + +#[tokio::test] +async fn public_service_automation_rejects_invalid_and_cancelled_writes_without_side_effects() { + use gateway_admin::{ + model::usage_data::UsageActor, + public_service::{Origin, Plan, scope, scope_actor}, + }; + + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + let store = FakeAccountStore::new("openai", events); + let services = accounts_service(provider.clone(), store.clone()).await; + let registry = services.public_services(); + let write = "accounts.set_reset_credit_automation"; + // 直接服务调用也必须有可信主体,输入不能自报管理员身份 + let error = registry + .call(write, json!(["acct_test", true])) + .await + .unwrap_err(); + assert_eq!(error.kind, "invalid"); + let error = scope_actor( + UsageActor::System, + registry.call( + write, + json!([{"actor": "admin_api_key"}, "acct_test", true]), + ), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "invalid"); + let error = scope_actor( + UsageActor::System, + registry.call(write, json!(["acct_test", true])), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "invalid"); + assert_eq!(error.message, "当前账号不支持自动重置卡"); + + enable_capability(&provider); + store.set_accounts(Vec::new()); + let error = scope_actor( + UsageActor::System, + registry.call(write, json!(["acct_test", true])), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "not_found"); + let cancellation = gateway_core::lifecycle::CancellationToken::new(); + cancellation.cancel(); + let error = scope_actor( + UsageActor::System, + scope( + Origin { + request_id: "cancelled".into(), + call_id: "parent".into(), + cancellation, + extensions: Default::default(), + plan: Plan::Frozen(None), + }, + registry.call(write, json!(["acct_test", true])), + ), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "unavailable"); + assert!(store.automations.lock().unwrap().is_empty()); + assert!(store.audit_requests().is_empty()); +} fn enable_capability(provider: &FakeProviderAdmin) { let mut capabilities = provider.capabilities.lock().unwrap(); diff --git a/backend/crates/gateway-admin/tests/use_case/service_contract.rs b/backend/crates/gateway-admin/tests/use_case/service_contract.rs index c7c0dfa6c..59ef28630 100644 --- a/backend/crates/gateway-admin/tests/use_case/service_contract.rs +++ b/backend/crates/gateway-admin/tests/use_case/service_contract.rs @@ -124,6 +124,89 @@ fn contract_type(mut item: Item, name: &str) -> Item { item } +fn service_operations( + root: &Path, + namespace: &str, + service_path: &str, + service_name: &str, +) -> (Vec, usize) { + let service = source(root, service_path) + .items + .into_iter() + .find_map(|item| match item { + Item::Trait(item) if item.ident == service_name => Some(item), + _ => None, + }) + .unwrap(); + let outputs: BTreeMap<_, _> = service + .items + .into_iter() + .filter_map(|item| match item { + syn::TraitItem::Fn(method) => { + let syn::ReturnType::Type(_, output) = method.sig.output else { + panic!("服务必须返回 Result") + }; + let Type::Path(result) = *output else { + panic!("服务必须返回 Result") + }; + let segment = result.path.segments.last().unwrap(); + assert_eq!(segment.ident, "Result"); + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + panic!("Result 缺少类型参数") + }; + let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone() + else { + panic!("Result 缺少输出类型") + }; + WireTypes.visit_type_mut(&mut output); + Some((method.sig.ident.to_string(), output)) + } + _ => None, + }) + .collect(); + let register = source(root, &format!("src/public_service/{namespace}.rs")) + .items + .into_iter() + .find_map(|item| match item { + Item::Fn(item) if item.sig.ident == "register" => Some(item), + _ => None, + }) + .unwrap(); + let operations: Vec = register + .block + .stmts + .into_iter() + .filter_map(|stmt| { + let syn::Stmt::Macro(stmt) = stmt else { + return None; + }; + if !stmt.mac.path.is_ident("register") { + return None; + } + let Operation { + name, + method, + mut input, + } = syn::parse2(stmt.mac.tokens).unwrap(); + WireTypes.visit_type_mut(&mut input); + let output = outputs.get(&method.to_string()).unwrap(); + let operation = format!("{namespace}.{method}"); + Some( + syn::parse2(quote! { + pub struct #name; + impl Operation for #name { + const NAME: &'static str = #operation; + type Input = #input; + type Output = #output; + } + }) + .unwrap(), + ) + }) + .collect(); + (operations, outputs.len()) +} + #[test] fn sdk_settings_contract_matches_host_declarations() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -240,78 +323,13 @@ fn sdk_settings_contract_matches_host_declarations() { } } - let service = source(root, "src/use_case/settings.rs") - .items - .into_iter() - .find_map(|item| match item { - Item::Trait(item) if item.ident == "SettingsService" => Some(item), - _ => None, - }) - .unwrap(); - let outputs: BTreeMap<_, _> = service - .items - .into_iter() - .filter_map(|item| match item { - syn::TraitItem::Fn(method) => { - let syn::ReturnType::Type(_, output) = method.sig.output else { - panic!("服务必须返回 Result") - }; - let Type::Path(result) = *output else { - panic!("服务必须返回 Result") - }; - let segment = result.path.segments.last().unwrap(); - assert_eq!(segment.ident, "Result"); - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - panic!("Result 缺少类型参数") - }; - let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone() - else { - panic!("Result 缺少输出类型") - }; - WireTypes.visit_type_mut(&mut output); - Some((method.sig.ident.to_string(), output)) - } - _ => None, - }) - .collect(); - let register = source(root, "src/public_service/settings.rs") - .items - .into_iter() - .find_map(|item| match item { - Item::Fn(item) if item.sig.ident == "register" => Some(item), - _ => None, - }) - .unwrap(); - let operations: Vec<_> = register - .block - .stmts - .into_iter() - .filter_map(|stmt| { - let syn::Stmt::Macro(stmt) = stmt else { - return None; - }; - if !stmt.mac.path.is_ident("register") { - return None; - } - let Operation { - name, - method, - mut input, - } = syn::parse2(stmt.mac.tokens).unwrap(); - WireTypes.visit_type_mut(&mut input); - let output = outputs.get(&method.to_string()).unwrap(); - let operation = format!("settings.{method}"); - Some(quote! { - pub struct #name; - impl Operation for #name { - const NAME: &'static str = #operation; - type Input = #input; - type Output = #output; - } - }) - }) - .collect(); - assert_eq!(operations.len(), outputs.len(), "公开服务方法必须完整声明"); + let (operations, method_count) = service_operations( + root, + "settings", + "src/use_case/settings.rs", + "SettingsService", + ); + assert_eq!(operations.len(), method_count, "公开服务方法必须完整声明"); let assignments = replacement_fields.iter().map(|field| match field.to_string().as_str() { "expected_revision" => quote!(expected_revision: settings.config_revision), "request_profile_updates" => quote!(request_profile_updates: settings.request_profiles.into_iter().map(|(provider, value)| (provider, Some(value))).collect()), @@ -340,14 +358,48 @@ fn sdk_settings_contract_matches_host_declarations() { } } }; - let output = root.join("../gateway-plugin/sdk/src/call/services/settings.rs"); - let expected = prettyplease::unparse(&syn::parse2::(generated).unwrap()); + check_contract(root, "settings", syn::parse2(generated).unwrap()); +} + +#[test] +fn sdk_accounts_contract_matches_host_declarations() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let status = source(root, "src/model/reset_credit_automation.rs") + .items + .into_iter() + .find(|item| matches!(item, Item::Struct(item) if item.ident == "ResetCreditAutomationStatus")) + .unwrap(); + let status = contract_type(status, "ResetCreditAutomationStatus"); + let (operations, _) = service_operations( + root, + "accounts", + "src/use_case/accounts/mod.rs", + "AccountsService", + ); + let generated = quote! { + //! 账号自动重置的类型化操作与状态合同 + //! + //! 从宿主状态类型与 public_service/accounts.rs 生成;更新命令见 SDK 维护说明 + + use super::Operation; + use serde::{Deserialize, Serialize}; + #status + #(#operations)* + }; + check_contract(root, "accounts", syn::parse2(generated).unwrap()); +} + +fn check_contract(root: &Path, namespace: &str, generated: syn::File) { + let output = root.join(format!( + "../gateway-plugin/sdk/src/call/services/{namespace}.rs" + )); + let expected = prettyplease::unparse(&generated); if std::env::var_os("CPR_UPDATE_SERVICE_CONTRACT").is_some() { std::fs::write(&output, &expected).unwrap(); } let actual = syn::parse_file(&std::fs::read_to_string(output).unwrap()).unwrap(); assert!( prettyplease::unparse(&actual) == expected, - "SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_settings_contract_matches_host_declarations" + "SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_{namespace}_contract_matches_host_declarations" ); } diff --git a/backend/crates/gateway-core/src/lib.rs b/backend/crates/gateway-core/src/lib.rs index f5aa8f2ad..60a27d7c0 100644 --- a/backend/crates/gateway-core/src/lib.rs +++ b/backend/crates/gateway-core/src/lib.rs @@ -397,6 +397,7 @@ pub struct CoreCommandPlaneStartup { pub struct CoreCommandPlaneBundle { control: CoreControlPlaneBundle, + account_probe: Arc, nested_model_execution: Arc, affinity_lookup: Arc, } @@ -426,10 +427,12 @@ impl CoreCommandPlaneStartup { ) .with_snapshot_refresh(Arc::clone(&control.publisher)), ); + let account_probe: Arc = service.clone(); let nested_model_execution: Arc = service.clone(); let affinity_lookup: Arc = service; Ok(CoreCommandPlaneBundle { control, + account_probe, nested_model_execution, affinity_lookup, }) @@ -437,6 +440,11 @@ impl CoreCommandPlaneStartup { } impl CoreCommandPlaneBundle { + #[must_use] + pub fn account_probe(&self) -> Arc { + Arc::clone(&self.account_probe) + } + #[must_use] pub fn snapshots(&self) -> RuntimeSnapshotHandle { self.control.snapshots() diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index e1507d234..204d1c4d4 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -105,11 +105,12 @@ sequenceDiagram ## 本地验证 -设置服务的 SDK 类型和操作由宿主领域类型及[操作声明](../../gateway-admin/src/public_service/settings.rs)生成,SDK 构建不依赖宿主源码。 +设置与账号自动重置服务的 SDK 类型和操作由宿主领域类型及[操作声明](../../gateway-admin/src/public_service)生成,SDK 构建不依赖宿主源码。 修改宿主合同后,在仓库根目录更新生成文件;Admin 合同测试会检查生成结果是否同步: ```bash CPR_UPDATE_SERVICE_CONTRACT=1 RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-admin --test main sdk_settings_contract_matches_host_declarations --locked +CPR_UPDATE_SERVICE_CONTRACT=1 RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-admin --test main sdk_accounts_contract_matches_host_declarations --locked cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml ``` diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index dd0831b73..95f0d7ca9 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -260,7 +260,7 @@ HTTP 总入口统一包裹原生路由,`dispatch_http` 复用同一路由与 返回 `ServiceResponse::from_result::(result)` 可以改写结果、短路或恢复业务错误; 不感兴趣的操作调用 `forward()`。公开值完整传递,类型校验不承担字段权限控制 -当前登记 `settings` 的 11 个操作:`Load`、`Replace`、`ApiKeyExists`、`RegenerateApiKey`、`DeleteApiKey`、 +`settings` 提供 `Load`、`PreviewPrivacyPolicy`、`Replace`、`ApiKeyExists`、`RegenerateApiKey`、`DeleteApiKey`、 `Pricing`、`PreviewPricingSync`、`Sync`、`Update`、`ClientProfileOptions`、`PreviewClientProfile`。 插件回调和 CLI 经注册表主动调用时统一进入一次 `service` 组合;网关原生设置接口由 HTTP 总入口包裹。 各入口共用同一业务实现,业务方法内部调用不重复进入 `service` 组合。 @@ -270,6 +270,19 @@ HTTP 总入口统一包裹原生路由,`dispatch_http` 复用同一路由与 `Replace` 接收 `(MutationContext, ReplaceRuntimeSettings)`,通过已有校验、事务、审计和快照发布保存设置。 版本过期返回 `ServiceError { kind: "conflict", message }`。读取结果的普通改写不会持久化,也不会回滚已经提交的事务 +`accounts::GetResetCreditAutomation` 接收账号 ID,返回 `ResetCreditAutomationStatus`:开关、全局提前分钟数、 +下次发现与使用时间、最近结果;时间为 RFC 3339 字符串,无计划时为 `None`。 +`accounts::SetResetCreditAutomation` 接收 `(account_id, enabled)`,返回保存后的同一状态;审计主体与调用标识由宿主提供。 +两者复用原生账号校验和自动重置用例,不立即查询或消费上游卡片。提前时间通过 `settings::Load` / `Replace` 的 +`reset_credit_lead_minutes` 读取和设置,范围见[主动额度重置卡](../../../../../docs/api.md#主动额度重置卡) + +```rust,ignore +use gateway_plugin_sdk::call::services::accounts::{GetResetCreditAutomation, SetResetCreditAutomation}; + +let status = host.service::(account_id.clone()).await?; +let status = host.service::((account_id, true)).await?; +``` + 服务错误保留分类与完整业务文案,子调用继承冻结发布计划及取消信号。 插件显式再次调用服务时继承发起实例链并跳过相同实例,防止无限递归;无 service 绑定时直接分派业务实现,仍保留空计划快照、父子关联和取消信号。 原生业务方法保留类型化调用,不为服务组合增加 JSON 编解码 diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/accounts.rs b/backend/crates/gateway-plugin/sdk/src/call/services/accounts.rs new file mode 100644 index 000000000..1f2b7ac37 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/services/accounts.rs @@ -0,0 +1,25 @@ +//! 账号自动重置的类型化操作与状态合同 +//! +//! 从宿主状态类型与 public_service/accounts.rs 生成;更新命令见 SDK 维护说明 +use super::Operation; +use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResetCreditAutomationStatus { + pub enabled: bool, + pub lead_minutes: u32, + pub next_discovery_at: Option, + pub next_consume_at: Option, + pub last_result: Option, +} +pub struct GetResetCreditAutomation; +impl Operation for GetResetCreditAutomation { + const NAME: &'static str = "accounts.reset_credit_automation_status"; + type Input = String; + type Output = ResetCreditAutomationStatus; +} +pub struct SetResetCreditAutomation; +impl Operation for SetResetCreditAutomation { + const NAME: &'static str = "accounts.set_reset_credit_automation"; + type Input = (String, bool); + type Output = ResetCreditAutomationStatus; +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs index 848558e2d..dc62e49e8 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs @@ -1,5 +1,6 @@ //! 公开服务的类型化操作合同;名称用于分派,不承担授权 +pub mod accounts; pub mod settings; pub mod usage; diff --git a/docs/architecture.md b/docs/architecture.md index 513ce5367..28948b6ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -178,6 +178,7 @@ flowchart LR - **主动服务调用在注册表统一组合**:Admin 按类型登记操作,Core 组合器只负责续体,Runtime 搬运跨进程调用。 插件与 CLI 主动调用公开服务时进入 `service` 中间件一次;原生 HTTP 接口由总入口包裹,业务方法及其内部调用不重复组合。 主动子调用继承冻结计划、父子关联和取消信号,无 service 绑定时也保留空计划快照;调用结束回收资源。 + 需要可信主体的写入在发起时冻结身份;跨任务执行 `next` 时仍使用发起者身份,不能继承转发者的主体。 各入口复用原用例,运行设置的整体替换仍使用读取版本做事务内比较,冲突不写入。公开范围见 [SDK 服务合同](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#公开服务) - **上游适配器是洋葱链终端**:`upstream_adapter` 只扩展内置 OpenAI / xAI,沿原 Provider 的选号、租约及 attempt 中间件进入冷流。 Provider 解释凭据、恢复账号状态和计算价格;Runtime 绑定账号版本并解析标准事件,Host 负责真实出站。