Merge pull request #443 from AAEE86/rust

feat(admin): add Done-hub provider ops template
This commit is contained in:
fawney19
2026-05-13 18:42:28 +08:00
committed by GitHub
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,
Err(message) => {
return admin_provider_ops_action_error(
if architecture.architecture_id == "generic_api"
|| architecture.architecture_id == "new_api"
|| architecture.architecture_id == "anyrouter"
{
if matches!(
architecture.architecture_id,
"generic_api" | "new_api" | "anyrouter" | "done_hub"
) {
"unknown_error"
} else {
"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]
async fn gateway_handles_admin_provider_ops_architectures_locally_with_trusted_admin_principal() {
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);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("items should be array");
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_provider_ops_architectures_payload(&payload);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
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);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("items should be array");
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_provider_ops_architectures_payload(&payload);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();

View File

@@ -14,7 +14,7 @@ pub fn parse_query_balance_payload(
response_json: &Value,
) -> Result<Value, String> {
match architecture_id {
"generic_api" | "new_api" | "anyrouter" => {
"generic_api" | "new_api" | "anyrouter" | "done_hub" => {
parse_new_api_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));
}
#[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]
fn sub2api_parser_sums_balance_and_points() {
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 cubence;
mod done_hub;
mod generic_api;
mod nekocode;
mod new_api;
@@ -92,6 +93,7 @@ static PROVIDER_OPS_ARCHITECTURES: LazyLock<Vec<ProviderOpsArchitectureSpec>> =
vec![
anyrouter::spec(),
cubence::spec(),
done_hub::spec(),
generic_api::spec(),
nekocode::spec(),
new_api::spec(),
@@ -122,6 +124,7 @@ pub fn normalize_architecture_id(architecture_id: &str) -> &'static str {
"generic_api" => "generic_api",
"new_api" => "new_api",
"cubence" => "cubence",
"done_hub" => "done_hub",
"yescode" => "yescode",
"nekocode" => "nekocode",
"anyrouter" => "anyrouter",
@@ -178,6 +181,7 @@ fn default_action_config(architecture_id: &str, action_type: &str) -> Option<Map
match architecture_id {
"anyrouter" => anyrouter::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),
"nekocode" => nekocode::default_action_config(action_type),
"new_api" => new_api::default_action_config(action_type),
@@ -201,23 +205,47 @@ mod tests {
#[test]
fn list_architectures_hides_generic_api_by_default() {
let visible = list_architectures(false);
assert_eq!(visible.len(), 6);
assert_eq!(visible.len(), 7);
assert!(visible
.iter()
.all(|item| item.architecture_id != "generic_api"));
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"));
}
#[test]
fn normalize_architecture_id_falls_back_to_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("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]
fn get_architecture_returns_generic_api_for_unknown_id() {
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) {
"anyrouter" => admin_provider_ops_anyrouter_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),
"nekocode" => admin_provider_ops_nekocode_verify_payload(status, response_json),
"sub2api" => {
@@ -111,6 +112,12 @@ fn admin_provider_ops_cubence_cookie_header(cookie_input: &str) -> String {
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 {
if cookie_input.contains("yescode_auth=") {
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
.get("session_cookie")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
let session = admin_provider_ops_extract_cookie_value(session_cookie, "session");
insert_header(&mut headers, "Cookie", &format!("session={session}"))?;
insert_header(
&mut headers,
"Cookie",
&admin_provider_ops_session_cookie_header(session_cookie),
)?;
}
}
"anyrouter" => {
@@ -817,7 +827,7 @@ mod tests {
admin_provider_ops_anyrouter_parse_session_user_id,
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_verify_headers, ADMIN_PROVIDER_OPS_USER_AGENT,
admin_provider_ops_verify_headers, parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
};
use http::StatusCode;
use reqwest::header::COOKIE;
@@ -929,6 +939,50 @@ mod tests {
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]
fn anyrouter_verify_headers_include_shared_user_agent() {
let headers = admin_provider_ops_verify_headers(