feat(admin): add Done-hub provider ops template

- 新增 Done-hub Cookie 认证架构
- 使用 /api/user/profile 查询余额,按 quota / 500000 换算
- 补充余额解析、认证头和校验相关测试
This commit is contained in:
AAEE86
2026-05-13 16:10:59 +08:00
parent 3c2497f019
commit afbb656510
6 changed files with 237 additions and 29 deletions

View File

@@ -165,10 +165,10 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
Ok(data) => data, Ok(data) => data,
Err(message) => { Err(message) => {
return admin_provider_ops_action_error( return admin_provider_ops_action_error(
if architecture.architecture_id == "generic_api" if matches!(
|| architecture.architecture_id == "new_api" architecture.architecture_id,
|| architecture.architecture_id == "anyrouter" "generic_api" | "new_api" | "anyrouter" | "done_hub"
{ ) {
"unknown_error" "unknown_error"
} else { } else {
"parse_error" "parse_error"

View File

@@ -60,6 +60,39 @@ async fn redis_runtime_state_for_test(
) )
} }
fn assert_provider_ops_architectures_payload(payload: &serde_json::Value) {
let items = payload.as_array().expect("items should be array");
let architecture_ids = items
.iter()
.map(|item| {
item["architecture_id"]
.as_str()
.expect("architecture_id should be string")
})
.collect::<Vec<_>>();
let expected_ids = aether_admin::provider::ops::list_architectures(false)
.iter()
.map(|item| item.architecture_id)
.collect::<Vec<_>>();
assert_eq!(architecture_ids, expected_ids);
assert!(items
.iter()
.all(|item| item["architecture_id"] != "generic_api"));
let anyrouter = items
.iter()
.find(|item| item["architecture_id"] == "anyrouter")
.expect("anyrouter architecture should exist");
assert_eq!(anyrouter["default_connector"], "cookie");
let new_api = items
.iter()
.find(|item| item["architecture_id"] == "new_api")
.expect("new_api architecture should exist");
assert_eq!(new_api["supported_auth_types"][0]["type"], "api_key");
}
#[tokio::test] #[tokio::test]
async fn gateway_handles_admin_provider_ops_architectures_locally_with_trusted_admin_principal() { async fn gateway_handles_admin_provider_ops_architectures_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits = Arc::new(Mutex::new(0usize));
@@ -93,15 +126,7 @@ async fn gateway_handles_admin_provider_ops_architectures_locally_with_trusted_a
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse"); let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("items should be array"); assert_provider_ops_architectures_payload(&payload);
assert_eq!(items.len(), 6);
assert!(items
.iter()
.all(|item| item["architecture_id"] != "generic_api"));
assert_eq!(items[0]["architecture_id"], "anyrouter");
assert_eq!(items[0]["default_connector"], "cookie");
assert_eq!(items[3]["architecture_id"], "new_api");
assert_eq!(items[3]["supported_auth_types"][0]["type"], "api_key");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();
@@ -141,15 +166,7 @@ async fn gateway_handles_admin_provider_ops_architectures_locally_with_bearer_ad
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse"); let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("items should be array"); assert_provider_ops_architectures_payload(&payload);
assert_eq!(items.len(), 6);
assert!(items
.iter()
.all(|item| item["architecture_id"] != "generic_api"));
assert_eq!(items[0]["architecture_id"], "anyrouter");
assert_eq!(items[0]["default_connector"], "cookie");
assert_eq!(items[3]["architecture_id"], "new_api");
assert_eq!(items[3]["supported_auth_types"][0]["type"], "api_key");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();

View File

@@ -14,7 +14,7 @@ pub fn parse_query_balance_payload(
response_json: &Value, response_json: &Value,
) -> Result<Value, String> { ) -> Result<Value, String> {
match architecture_id { match architecture_id {
"generic_api" | "new_api" | "anyrouter" => { "generic_api" | "new_api" | "anyrouter" | "done_hub" => {
parse_new_api_balance_payload(action_config, response_json) parse_new_api_balance_payload(action_config, response_json)
} }
"cubence" => parse_cubence_balance_payload(action_config, response_json), "cubence" => parse_cubence_balance_payload(action_config, response_json),
@@ -490,6 +490,28 @@ mod tests {
assert_eq!(payload["total_used"], json!(1.0)); assert_eq!(payload["total_used"], json!(1.0));
} }
#[test]
fn done_hub_single_request_parser_reads_wrapped_quota() {
let payload = parse_query_balance_payload(
"done_hub",
&json!({ "quota_divisor": 500000 })
.as_object()
.cloned()
.expect("config"),
&json!({
"success": true,
"data": {
"quota": 2_276_139_911_u64,
"used_quota": 13860089
}
}),
)
.expect("payload should parse");
assert_eq!(payload["total_available"], json!(4552.279822));
assert_eq!(payload["total_used"], json!(27.720178));
}
#[test] #[test]
fn sub2api_parser_sums_balance_and_points() { fn sub2api_parser_sums_balance_and_points() {
let payload = parse_sub2api_balance_payload( let payload = parse_sub2api_balance_payload(

View File

@@ -0,0 +1,87 @@
use super::{
json_object, ProviderOpsActionSpec, ProviderOpsArchitectureSpec, ProviderOpsAuthSpec,
ProviderOpsBalanceMode, ProviderOpsCheckinMode, ProviderOpsVerifyMode,
};
use serde_json::{json, Map, Value};
pub(super) fn spec() -> ProviderOpsArchitectureSpec {
let credentials_schema = json!({
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址"
},
"session_cookie": {
"type": "string",
"title": "Session Cookie",
"description": "从浏览器复制的 session Cookie 值",
"x-sensitive": true,
"x-input-type": "password"
}
},
"required": ["session_cookie"],
"x-auth-type": "cookie",
"x-currency": "USD",
"x-field-groups": [
{ "fields": ["base_url"] },
{ "fields": ["session_cookie"] }
],
"x-quota-divisor": 500000,
"x-validation": [
{
"type": "required",
"fields": ["session_cookie"],
"message": "请填写 Session Cookie"
}
]
});
ProviderOpsArchitectureSpec {
architecture_id: "done_hub",
display_name: "Done-hub",
description: "Done-hub 中转站预设配置,使用 Cookie 认证",
hidden: false,
credentials_schema: credentials_schema.clone(),
verify_endpoint: "/api/user/profile",
verify_mode: ProviderOpsVerifyMode::DirectGet,
balance_mode: ProviderOpsBalanceMode::SingleRequest,
checkin_mode: ProviderOpsCheckinMode::None,
query_balance_cookie_auth_errors: true,
supported_auth_types: vec![ProviderOpsAuthSpec {
auth_type: "cookie",
display_name: "Done-hub Cookie",
credentials_schema,
}],
supported_actions: vec![ProviderOpsActionSpec {
action_type: "query_balance",
display_name: "查询余额",
description: "查询 Done-hub 账户余额信息",
config_schema: json!({
"type": "object",
"properties": {
"currency": {
"type": "string",
"title": "货币单位",
"default": "USD"
}
},
"required": []
}),
}],
default_connector: Some("cookie"),
}
}
pub(super) fn default_action_config(action_type: &str) -> Option<Map<String, Value>> {
match action_type {
"query_balance" => Some(json_object(json!({
"endpoint": "/api/user/profile",
"method": "GET",
"quota_divisor": 500000,
"currency": "USD"
}))),
_ => None,
}
}

View File

@@ -1,5 +1,6 @@
mod anyrouter; mod anyrouter;
mod cubence; mod cubence;
mod done_hub;
mod generic_api; mod generic_api;
mod nekocode; mod nekocode;
mod new_api; mod new_api;
@@ -92,6 +93,7 @@ static PROVIDER_OPS_ARCHITECTURES: LazyLock<Vec<ProviderOpsArchitectureSpec>> =
vec![ vec![
anyrouter::spec(), anyrouter::spec(),
cubence::spec(), cubence::spec(),
done_hub::spec(),
generic_api::spec(), generic_api::spec(),
nekocode::spec(), nekocode::spec(),
new_api::spec(), new_api::spec(),
@@ -122,6 +124,7 @@ pub fn normalize_architecture_id(architecture_id: &str) -> &'static str {
"generic_api" => "generic_api", "generic_api" => "generic_api",
"new_api" => "new_api", "new_api" => "new_api",
"cubence" => "cubence", "cubence" => "cubence",
"done_hub" => "done_hub",
"yescode" => "yescode", "yescode" => "yescode",
"nekocode" => "nekocode", "nekocode" => "nekocode",
"anyrouter" => "anyrouter", "anyrouter" => "anyrouter",
@@ -178,6 +181,7 @@ fn default_action_config(architecture_id: &str, action_type: &str) -> Option<Map
match architecture_id { match architecture_id {
"anyrouter" => anyrouter::default_action_config(action_type), "anyrouter" => anyrouter::default_action_config(action_type),
"cubence" => cubence::default_action_config(action_type), "cubence" => cubence::default_action_config(action_type),
"done_hub" => done_hub::default_action_config(action_type),
"generic_api" => generic_api::default_action_config(action_type), "generic_api" => generic_api::default_action_config(action_type),
"nekocode" => nekocode::default_action_config(action_type), "nekocode" => nekocode::default_action_config(action_type),
"new_api" => new_api::default_action_config(action_type), "new_api" => new_api::default_action_config(action_type),
@@ -201,23 +205,47 @@ mod tests {
#[test] #[test]
fn list_architectures_hides_generic_api_by_default() { fn list_architectures_hides_generic_api_by_default() {
let visible = list_architectures(false); let visible = list_architectures(false);
assert_eq!(visible.len(), 6); assert_eq!(visible.len(), 7);
assert!(visible assert!(visible
.iter() .iter()
.all(|item| item.architecture_id != "generic_api")); .all(|item| item.architecture_id != "generic_api"));
let all = list_architectures(true); let all = list_architectures(true);
assert_eq!(all.len(), 7); assert_eq!(all.len(), 8);
assert!(all.iter().any(|item| item.architecture_id == "generic_api")); assert!(all.iter().any(|item| item.architecture_id == "generic_api"));
} }
#[test] #[test]
fn normalize_architecture_id_falls_back_to_generic_api() { fn normalize_architecture_id_falls_back_to_generic_api() {
assert_eq!(normalize_architecture_id(""), "generic_api"); assert_eq!(normalize_architecture_id(""), "generic_api");
assert_eq!(normalize_architecture_id("done_hub"), "done_hub");
assert_eq!(normalize_architecture_id("new_api"), "new_api"); assert_eq!(normalize_architecture_id("new_api"), "new_api");
assert_eq!(normalize_architecture_id("unknown"), "generic_api"); assert_eq!(normalize_architecture_id("unknown"), "generic_api");
} }
#[test]
fn done_hub_uses_profile_balance_without_checkin() {
let architecture = get_architecture("done_hub").expect("architecture should exist");
assert_eq!(architecture.architecture_id, "done_hub");
assert_eq!(architecture.verify_endpoint, "/api/user/profile");
let resolved = resolve_action_config(
"done_hub",
&json!({})
.as_object()
.cloned()
.expect("config should be object"),
"query_balance",
None,
)
.expect("action config should resolve");
assert_eq!(resolved.get("endpoint"), Some(&json!("/api/user/profile")));
assert_eq!(resolved.get("quota_divisor"), Some(&json!(500000)));
assert_eq!(resolved.get("method"), Some(&json!("GET")));
assert!(resolved.get("checkin_endpoint").is_none());
}
#[test] #[test]
fn get_architecture_returns_generic_api_for_unknown_id() { fn get_architecture_returns_generic_api_for_unknown_id() {
let architecture = get_architecture("unknown").expect("architecture should exist"); let architecture = get_architecture("unknown").expect("architecture should exist");

View File

@@ -29,6 +29,7 @@ pub fn parse_verify_payload(
match normalize_architecture_id(architecture_id) { match normalize_architecture_id(architecture_id) {
"anyrouter" => admin_provider_ops_anyrouter_verify_payload(status, response_json), "anyrouter" => admin_provider_ops_anyrouter_verify_payload(status, response_json),
"cubence" => admin_provider_ops_cubence_verify_payload(status, response_json), "cubence" => admin_provider_ops_cubence_verify_payload(status, response_json),
"done_hub" => admin_provider_ops_anyrouter_verify_payload(status, response_json),
"yescode" => admin_provider_ops_yescode_verify_payload(status, response_json), "yescode" => admin_provider_ops_yescode_verify_payload(status, response_json),
"nekocode" => admin_provider_ops_nekocode_verify_payload(status, response_json), "nekocode" => admin_provider_ops_nekocode_verify_payload(status, response_json),
"sub2api" => { "sub2api" => {
@@ -111,6 +112,12 @@ fn admin_provider_ops_cubence_cookie_header(cookie_input: &str) -> String {
cookies.join("; ") cookies.join("; ")
} }
fn admin_provider_ops_session_cookie_header(cookie_input: &str) -> String {
let trimmed = admin_provider_ops_strip_cookie_header_prefix(cookie_input);
let session = admin_provider_ops_extract_cookie_value(trimmed, "session");
format!("session={session}")
}
pub fn admin_provider_ops_yescode_cookie_header(cookie_input: &str) -> String { pub fn admin_provider_ops_yescode_cookie_header(cookie_input: &str) -> String {
if cookie_input.contains("yescode_auth=") { if cookie_input.contains("yescode_auth=") {
let mut parts = Vec::new(); let mut parts = Vec::new();
@@ -433,14 +440,17 @@ pub fn admin_provider_ops_verify_headers(
)?; )?;
} }
} }
"nekocode" => { "nekocode" | "done_hub" => {
if let Some(session_cookie) = credentials if let Some(session_cookie) = credentials
.get("session_cookie") .get("session_cookie")
.and_then(Value::as_str) .and_then(Value::as_str)
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
{ {
let session = admin_provider_ops_extract_cookie_value(session_cookie, "session"); insert_header(
insert_header(&mut headers, "Cookie", &format!("session={session}"))?; &mut headers,
"Cookie",
&admin_provider_ops_session_cookie_header(session_cookie),
)?;
} }
} }
"anyrouter" => { "anyrouter" => {
@@ -817,7 +827,7 @@ mod tests {
admin_provider_ops_anyrouter_parse_session_user_id, admin_provider_ops_anyrouter_parse_session_user_id,
admin_provider_ops_anyrouter_verify_payload, admin_provider_ops_cubence_verify_payload, admin_provider_ops_anyrouter_verify_payload, admin_provider_ops_cubence_verify_payload,
admin_provider_ops_frontend_updated_credentials, admin_provider_ops_sub2api_verify_payload, admin_provider_ops_frontend_updated_credentials, admin_provider_ops_sub2api_verify_payload,
admin_provider_ops_verify_headers, ADMIN_PROVIDER_OPS_USER_AGENT, admin_provider_ops_verify_headers, parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
}; };
use http::StatusCode; use http::StatusCode;
use reqwest::header::COOKIE; use reqwest::header::COOKIE;
@@ -929,6 +939,50 @@ mod tests {
assert_eq!(auth_failed["message"], json!("Cookie 已失效,请重新配置")); assert_eq!(auth_failed["message"], json!("Cookie 已失效,请重新配置"));
} }
#[test]
fn done_hub_verify_payload_reads_wrapped_profile() {
let payload = parse_verify_payload(
"done_hub",
StatusCode::OK,
&json!({
"success": true,
"data": {
"username": "linuxdo_850",
"display_name": "AAEE86",
"quota": 2_276_139_911_u64,
"used_quota": 13860089,
"request_count": 55
}
}),
None,
);
assert_eq!(payload["success"], json!(true));
assert_eq!(payload["data"]["username"], json!("linuxdo_850"));
assert_eq!(payload["data"]["display_name"], json!("AAEE86"));
assert_eq!(payload["data"]["quota"], json!(2276139911.0));
assert_eq!(payload["data"]["used_quota"], json!(13860089.0));
assert_eq!(payload["data"]["request_count"], json!(55));
}
#[test]
fn done_hub_verify_headers_use_session_cookie_only() {
let headers = admin_provider_ops_verify_headers(
"done_hub",
&Map::new(),
&Map::from_iter([(
"session_cookie".to_string(),
json!("Cookie: session=abc; other=ignored"),
)]),
)
.expect("headers should build");
assert_eq!(
headers.get(COOKIE).and_then(|value| value.to_str().ok()),
Some("session=abc")
);
}
#[test] #[test]
fn anyrouter_verify_headers_include_shared_user_agent() { fn anyrouter_verify_headers_include_shared_user_agent() {
let headers = admin_provider_ops_verify_headers( let headers = admin_provider_ops_verify_headers(