OpenHuman referral 域名解析:基于托管后端 /referral/* 的薄 RPC 适配器
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
OpenHuman 的 referral(推荐奖励)程序并不在客户端实现任何业务逻辑,而是由一个刻意保持"无状态、无 schema、无持久化"的薄 RPC 适配器域名(src/openhuman/hosted/referral/)负责:它用服务端reqwest通道向托管后端发出已认证的 HTTP 请求,再把后端的原始data载荷原样暴露给 CLI 与 JSON-RPC 客户端。本文将基于该模块的 README 与 ops.rs、schemas.rs 源码,完整拆解它的职责边界、两个控制器(referral.get_stats/referral.claim)的调用链、会话令牌的 fail-closed 前置校验、参数清洗规则、响应包装协议,以及它背后的工程动机——规避桌面 WebViewfetch的 "Load failed"(CORS / TLS / WebKit)问题。
一、模块定位:一个刻意"无状态"的 RPC 适配器
referral 域在整个 OpenHuman 架构中的定位非常明确,README 第一句话就划定了边界:
Thin RPC adapter domain for the referral program. It doesnotown any business logic, state, or schema of its own.
也就是说,这个域名不拥有任何自己的业务逻辑、状态或 schema,它的全部工作只有三件事:
- 用已认证的
reqwest调用托管后端的/referral/*端点; - 把后端响应中的原始
data载荷透传出去; - 通过统一的控制器注册表暴露给 CLI / JSON-RPC 客户端。
从目录结构看,该目录只包含 6 个文件(README.md、mod.rs、ops.rs、ops_tests.rs、schemas.rs、schemas_tests.rs),没有types.rs、store.rs、tools.rs或bus.rs——这是判断它"纯 RPC 适配器而非有状态域"的最直接证据:它不挂 agent 工具(tools),不订阅事件总线(bus),也没有自己的存储(store)。README 的 Notes/gotchas 一节也明确列了这一条。
为什么需要这样一个"夹层"?
README 给出了核心工程动机:
the desktop WebView
fetchto the backend can fail with a generic "Load failed" (CORS / TLS / WebKit), so these ops reuse the same server-sidereqwestpath as the billing domain.
在 Tauri 桌面端,渲染进程(WebView)直接对托管后端发起fetch时,可能因为 CORS、TLS 或 WebKit 底层网络栈的差异而失败,报出笼统的 "Load failed"。因此 referral 域刻意复用与 billing(计费)域完全相同的服务端reqwest通道——所有请求都由 Rust 侧发出,绕开 WebView 网络栈,从而获得与计费功能一致的稳定性和可观测性。这个设计取舍在 ops.rs 的模块文档中也有同样的说明。
二、职责清单(Responsibilities)
按 README,该域名的职责可以归纳为五条:
| 职责 | 说明 |
|---|---|
| 拉取推荐统计 | 通过GET /referral/stats获取推荐码(code)、推荐链接(link)、累计数据(totals)以及被推荐用户的行列表(referred-user rows) |
| 认领推荐码 | 通过POST /referral/claim为当前用户认领推荐码,可附带可选的设备指纹(device fingerprint)作为滥用信号 |
| 会话前置校验 | 任何调用之前都要求解析出后端会话 token;没有存储 token 时 fail closed,并返回清晰的错误信息 |
| 参数清洗 | 转发前 trim 推荐码code;对deviceFingerprint做 trim 并丢弃纯空白值 |
| 响应包装 | 将后端响应包装为RpcOutcome<Value>,附带 grep 友好的日志行 |
其中"fail closed"是一个重要的安全语义:宁可调用失败,也不允许未认证请求穿透到后端。两个 ops 在拿不到会话 token 时都会返回固定错误:"no backend session token; run auth_store_session first"。
三、关键文件与分工
| 文件 | 角色 |
|---|---|
| mod.rs | 仅做导出(export-only):pub use ops::*,并重新导出 schema/controller 对(all_referral_controller_schemas、all_referral_registered_controllers、referral_schemas) |
| ops.rs | 业务逻辑核心:require_token(私有)、get_stats、claim_referral。基于有效后端 URL 构造BackendOAuthClient并发起认证 JSON 请求;附带针对 Axum mock 后端的行内测试(ops_tests.rs) |
| schemas.rs | 控制器 schema 定义 +handle_*函数(加载 Config 并委托给 ops);定义ReferralClaimParams(camelCase 反序列化)与辅助函数(to_json、deserialize_params、json_output) |
三层职责分离得很干净:mod.rs只管对外导出,schemas.rs只管 RPC 契约与参数解析,ops.rs只管实际的后端调用。
四、公开 API 表面(Public surface)
通过mod.rs的 re-export,该模块对外暴露以下 5 个符号:
// 拉取推荐统计,返回 CLI 兼容 JSON pub async fn get_stats(config: &Config) -> Result<RpcOutcome<Value>, String> // 认领推荐码;code 必填,device_fingerprint 可选 pub async fn claim_referral( config: &Config, code: &str, device_fingerprint: Option<&str>, ) -> Result<RpcOutcome<Value>, String> // 返回全部控制器 schema(referral_get_stats + referral_claim) pub fn all_referral_controller_schemas() -> Vec<ControllerSchema> // 返回已注册控制器(schema + handler 对) pub fn all_referral_registered_controllers() -> Vec<RegisteredController> // 按函数名查单个 schema;未知名返回 unknown 占位 pub fn referral_schemas(function: &str) -> ControllerSchema注意require_token是私有辅助函数,不对外导出(README 特别注明)。
五、RPC 控制器:referral.get_stats 与 referral.claim
两个控制器都位于referral命名空间,通过 src/core/all.rs 注册进全局控制器注册表(all_referral_registered_controllers()),从而同时暴露给 CLI 与 JSON-RPC。完整契约如下:
| Method | 输入 | 输出 | 后端调用 |
|---|---|---|---|
referral_get_stats(referral.get_stats) | 无 | stats(JSON) | GET /referral/stats |
referral_claim(referral.claim) | code(string,必填)、deviceFingerprint(string,可选) | result(JSON) | POST /referral/claim |
get_stats:零输入,纯透传
get_stats的 handler(schemas.rs)不解析任何参数,直接加载 Config 后调用 ops:
fn handle_referral_get_stats(_params: Map<String, Value>) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; to_json(crate::openhuman::hosted::referral::get_stats(&config).await?) }) }对应的 ops 实现(ops.rs):
pub async fn get_stats(config: &Config) -> Result<RpcOutcome<Value>, String> { let token = require_token(config)?; let api_url = effective_backend_api_url(&config.api_url); let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?; let data = client .authed_json(&token, Method::GET, "/referral/stats", None) .await .map_err(|e| e.to_string())?; Ok(RpcOutcome::single_log( data, "referral stats fetched from backend GET /referral/stats", )) }claim:两个输入字段的完整处理链
claim的 schema 定义了输入契约(schemas.rs):
code:TypeSchema::String,required,注释为 "Referral code to claim.";deviceFingerprint:TypeSchema::Option(String),optional,注释为 "Optional client fingerprint for abuse signals."
handler(schemas.rs)先反序列化参数,再经过与 ops 内部一致的 trim/空白过滤后委托给 ops:
fn handle_referral_claim(params: Map<String, Value>) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; let payload = deserialize_params::<ReferralClaimParams>(params)?; let fp = payload .device_fingerprint .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); to_json( crate::openhuman::hosted::referral::claim_referral(&config, payload.code.trim(), fp) .await?, ) }) }ops 侧的claim_referral(ops.rs)会构建请求体并转发:
pub async fn claim_referral( config: &Config, code: &str, device_fingerprint: Option<&str>, ) -> Result<RpcOutcome<Value>, String> { let token = require_token(config)?; let api_url = effective_backend_api_url(&config.api_url); let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?; let mut body = Map::new(); body.insert("code".to_string(), json!(code.trim())); if let Some(fp) = device_fingerprint.map(str::trim).filter(|s| !s.is_empty()) { body.insert("deviceFingerprint".to_string(), json!(fp)); } let data = client .authed_json( &token, Method::POST, "/referral/claim", Some(Value::Object(body)), ) .await .map_err(|e| e.to_string())?; Ok(RpcOutcome::single_log( data, "referral claim accepted by backend POST /referral/claim", )) }unknown 占位 schema
schema.rs的referral_schemas对任何无法识别的函数名返回一个unknown占位 schema(schemas.rs):namespace仍为referral,function为unknown,输出只有一个必填的error字段(TypeSchema::String,注释 "Lookup error details.")。这让外部调用者在拼错函数名时能拿到结构化错误而非静默失败,schemas_tests.rs 中unknown_function_returns_unknown_placeholder测试对该行为做了断言。
六、会话令牌:fail-closed 的认证前置
两个 ops 在发起任何网络请求前,都会先经过私有的require_token(ops.rs):
fn require_token(config: &Config) -> Result<String, String> { get_session_token(config)? .and_then(|v| { let t = v.trim().to_string(); if t.is_empty() { None } else { Some(t) } }) .ok_or_else(|| "no backend session token; run auth_store_session first".to_string()) }这里的语义是三层防御:
- 从凭据库读取会话 token(
get_session_token,见 session_support.rs,经 src/api/jwt.rs 转发、src/api/mod.rs 统一导出); - trim 掉首尾空白;
- 若结果为空白字符串,等价于"无 token",直接 fail closed。
require_token返回Result<String, String>:要么拿到干净可用的 token 字符串,要么返回错误"no backend session token; run auth_store_session first"——错误信息中明确提示了补救命令auth_store_session。三个单元测试分别覆盖了"无存储 token 报错"、"存储值被 trim"、"纯空白 token 被拒绝"三种情况(ops_tests.rs)。
测试中还展示了如何用AuthService向凭据库播种会话 token(ops_tests.rs),这正好印证了 README Dependencies 中的说明:测试专用依赖为crate::openhuman::security::credentials::{AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME}。
七、参数清洗与防御性冗余过滤
code与deviceFingerprint的清洗规则是一致且双重的:
code:始终执行trim()。测试claim_referral_posts_trimmed_code_and_drops_whitespace_fingerprint(ops_tests.rs)验证了" ABC-123 "会被转成"ABC-123"再发送;deviceFingerprint:先trim(),再用filter(|s| !s.is_empty())丢弃纯空白值。同样是上述测试验证:传Some(" ")时,请求体里不出现deviceFingerprint字段(assert!(out.value["echoed"].get("deviceFingerprint").is_none()));而传Some(" fp-1 ")时会被清洗为"fp-1"(见claim_referral_forwards_non_empty_device_fingerprint_trimmed,ops_tests.rs)。
README 特别强调:这套 trim/空白丢弃逻辑在ops::claim_referral和 schema 处理器handle_referral_claim两处各做了一遍(README)。这是刻意的防御性冗余过滤——无论调用方走哪一层入口,输入都能被清洗,避免出现"CLI 路径干净、JSON-RPC 路径脏"的不一致。
八、响应包装:RpcOutcome 与 CLI 兼容 JSON
所有 ops 的返回值都是RpcOutcome<Value>——该类型定义在 src/rpc/mod.rs,包含两个字段:value: T(RPC 调用返回的真实数据)和logs: Vec<String>(审计/调试用日志)。
两个 ops 都用RpcOutcome::single_log构造返回值(src/rpc/mod.rs),并附带grep 友好的固定前缀日志行:
get_stats→"referral stats fetched from backend GET /referral/stats"claim_referral→"referral claim accepted by backend POST /referral/claim"
在 schema handler 层,to_json调用outcome.into_cli_compatible_json()(src/rpc/mod.rs)将其转为 CLI 兼容的 JSON。根据 src/rpc/mod.rs 中定义的唯一规则(log envelope):
logs.is_empty() -> value (bare,直接输出值) otherwise -> { "result": value, "logs": … } (wrapped,包一层信封)由于 referral 的两个 ops 总是写入一条日志,实际响应形状会是{ "result": …, "logs": […] }。schemas_tests 中的to_json_wraps_result_and_logs(schemas_tests.rs)对该行为做了验证。
九、后端 URL 解析链:effective_backend_api_url
referral ops 使用effective_backend_api_url(&config.api_url)解析后端 API 基址(src/api/config.rs)。这是 OpenHuman 所有托管后端调用(auth、billing、team、referral、webhooks、credentials、channels、voice 等)共用的单一真相源,其解析顺序为:
- 用户显式配置
config.api_url——但只有当它不像推理端点时才会被采用:looks_like_local_ai_endpoint(Ollama/vLLM/LM Studio 等本地模型服务,端口 11434/8000/8080/1234 等或含/v1/chat/completions路径);looks_like_inference_provider_endpoint(openrouter.ai、openai.com、anthropic.com 等托管推理服务商域名,或/v1、/api/v1基路径);is_cloud_inference(内置云服务商主机名);- 以上任一命中且不是 OpenHuman 自有后端(
api.tinyhumans.ai/staging-api.tinyhumans.ai)时,跳过用户覆盖,回退到环境/默认链。
- 环境变量
BACKEND_URL、VITE_BACKEND_URL(运行时优先,其次编译期option_env!内嵌)。 - 环境感知默认值:
OPENHUMAN_APP_ENV=staging时回落到https://staging-api.tinyhumans.ai,否则使用https://api.tinyhumans.ai(src/api/config.rs)。
这套守卫的意义在于:用户把config.api_url指向本地 Ollama 或第三方推理服务时,referral、billing 等控制面调用不会误路由到推理服务商(否则会得到 400/404/500),从而保证了 referral 请求始终落在真正的托管后端上。README Dependencies 对该依赖的注释是 "resolves the effective backend API base URL fromconfig.api_url"。
十、测试策略:基于 Axum mock 后端的全链路验证
该模块的测试质量很高,ops_tests.rs采用真实 HTTP 环回验证,而非 mock 函数返回值:
spawn_mock(ops_tests.rs)在127.0.0.1:0上启动一个真实 Axum 服务并做就绪探测(指数退避,最长 2 秒),返回其临时端口 URL;- 测试配置把
config.api_url指向该 mock 基址,并用AuthService::store_provider_token播种"test-session-token"(config_with_backend,ops_tests.rs); - mock 路由直接返回 JSON:
GET /referral/stats返回{"referrals": 3, "earned_cents": 1500};POST /referral/claim回显请求体{"echoed": body},以便断言清洗后的字段。
覆盖的用例包括:无会话报错(stats/claim 各一个)、stats 载荷透传与日志存在性、claim 的 code trim 与空白 fingerprint 丢弃、非空 fingerprint 的 trim 转发。schemas_tests.rs则从 RPC 契约角度验证:schema 数量与控制器数量一致、get_stats 无输入且输出必填、claim 的 code 必填而 fingerprint 可选、camelCase 参数解析、缺失 fingerprint 容错、缺失 code 报错、类型错误返回invalid params前缀、unknown 占位 schema 等。
十一、设计约束与注意事项(gotchas)
结合 README 与源码,这个域名的工程约束可以总结为以下几点:
- 刻意零内部结构:没有
types.rs/store.rs/tools.rs/bus.rs——纯 RPC 适配器,不持有状态,不提供 agent 工具,不订阅事件总线。判断一个模块"是不是有状态域",看它是否挂载这些文件即可。 - 无持久化:完全无状态,只通过
get_session_token从凭据库读取后端会话 token,自身不落盘任何数据。 - fail-closed 语义:两个 ops 在缺少会话 token 时一律失败,错误信息固定为
"no backend session token; run auth_store_session first"。 - 资格判定在后端:
claim的准入条件("only users who have not yet subscribed"——仅限尚未订阅的用户)由托管后端强制执行,本模块只做请求转发,不参与判定(README)。 - 双重防御性清洗:code 与 fingerprint 的 trim/空白丢弃在 ops 层与 schema 层各执行一次,防止入口不一致。
- 刻意复用 billing 通道:绕开 WebView
fetch(CORS/TLS/WebKit "Load failed"),所有请求走 Rust 侧reqwest,保证与计费域同等稳定的网络行为与统一的可观测性日志。
小结
referral 域是 OpenHuman "托管后端 + 客户端薄适配器"架构的一个典型样本:它把所有业务复杂度留在服务端,客户端只做认证、清洗、转发、包装四件事,同时通过统一的控制器注册表(src/core/all.rs)向 CLI 与 JSON-RPC 提供一致的调用面。理解它的关键,不在于它写了多少逻辑,而在于它刻意不写哪些逻辑——无状态、无 schema、无工具、无持久化,配合 fail-closed 的会话校验、双层的参数清洗和 grep 友好的日志,构成一个高度可审计、可替换、易测试的薄适配层。如果你的目标是扩展 OpenHuman 的托管后端能力(如新增一个/referral/*端点),只需沿这条路径:在ops.rs增加一个认证请求函数、在schemas.rs增加 schema 与 handler、在all.rs完成注册,并在ops_tests.rs用 Axum mock 补上链路测试即可。
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考