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 负责真实出站。