use axum::{ Router, middleware, routing::{get, post}, }; use crate::{ admin::{ admin_confirm_editor_showcase_campaign_image_upload, admin_create_editor_showcase_campaign_image_upload_ticket, admin_dashboard, admin_debug_http, admin_get_asset_read_url, admin_get_editor_generation_pricing, admin_get_editor_showcase_campaign, admin_get_feature_gate_config, admin_list_database_table_rows, admin_list_database_tables, admin_list_editor_assets, admin_list_editor_showcase_assets, admin_list_external_api_keys, admin_list_tracking_event_keys, admin_list_tracking_events, admin_login, admin_me, admin_overview, admin_review_editor_showcase_asset, admin_update_editor_showcase_asset_display, admin_upsert_editor_generation_pricing, admin_upsert_editor_showcase_campaign, admin_upsert_feature_gate_config, require_admin_auth, }, admin_accounts::{admin_create_account, admin_list_accounts, admin_update_account}, admin_recharge::{ admin_execute_recharge_refund, admin_get_user_detail, admin_initialize_user_consumption_projections, admin_list_recharge_orders, admin_preview_recharge_refund, admin_reconcile_user_consumption, admin_register_recharge_refund, admin_resolve_recharge_refund_manual_review, admin_update_wallet_restriction, }, runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_get_profile_wallet_config, admin_list_profile_invite_codes, admin_list_profile_recharge_products, admin_list_profile_redeem_codes, admin_list_profile_task_configs, admin_upsert_profile_invite_code, admin_upsert_profile_recharge_product, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, admin_upsert_profile_wallet_config, }, state::AppState, }; pub fn router(state: AppState) -> Router { let auth = middleware::from_fn_with_state(state, require_admin_auth); let protected_routes = [ ( "/admin/api/agc-models", get(crate::agc_models::admin_get_agc_models) .put(crate::agc_models::admin_save_agc_models), ), ( "/admin/api/accounts", get(admin_list_accounts).post(admin_create_account), ), ( "/admin/api/accounts/{account_id}", axum::routing::put(admin_update_account), ), ("/admin/api/me", get(admin_me)), ("/admin/api/overview", get(admin_overview)), ("/admin/api/dashboard", get(admin_dashboard)), ( "/admin/api/debug/http", axum::routing::post(admin_debug_http), ), ( "/admin/api/tracking/events", get(admin_list_tracking_events), ), ( "/admin/api/tracking/event-keys", get(admin_list_tracking_event_keys), ), ( "/admin/api/database/tables", get(admin_list_database_tables), ), ( "/admin/api/database/tables/{table_name}/rows", get(admin_list_database_table_rows), ), ( "/admin/api/external-api-keys", get(admin_list_external_api_keys), ), ( "/admin/api/feature-gates", get(admin_get_feature_gate_config).put(admin_upsert_feature_gate_config), ), ( "/admin/api/editor-generation-pricing", get(admin_get_editor_generation_pricing).post(admin_upsert_editor_generation_pricing), ), ("/admin/api/editor-assets", get(admin_list_editor_assets)), ("/admin/api/assets/read-url", get(admin_get_asset_read_url)), ( "/admin/api/editor-showcase/assets", get(admin_list_editor_showcase_assets), ), ( "/admin/api/editor-showcase/assets/review", post(admin_review_editor_showcase_asset), ), ( "/admin/api/editor-showcase/assets/display", post(admin_update_editor_showcase_asset_display), ), ( "/admin/api/editor-showcase/campaign", get(admin_get_editor_showcase_campaign).post(admin_upsert_editor_showcase_campaign), ), ( "/admin/api/editor-showcase/campaign/image-upload-ticket", post(admin_create_editor_showcase_campaign_image_upload_ticket), ), ( "/admin/api/editor-showcase/campaign/image-upload-confirm", post(admin_confirm_editor_showcase_campaign_image_upload), ), ( "/admin/api/profile/redeem-codes", get(admin_list_profile_redeem_codes).post(admin_upsert_profile_redeem_code), ), ( "/admin/api/profile/redeem-codes/disable", axum::routing::post(admin_disable_profile_redeem_code), ), ( "/admin/api/profile/invite-codes", get(admin_list_profile_invite_codes).post(admin_upsert_profile_invite_code), ), ( "/admin/api/profile/tasks", get(admin_list_profile_task_configs).post(admin_upsert_profile_task_config), ), ( "/admin/api/profile/tasks/disable", axum::routing::post(admin_disable_profile_task_config), ), ( "/admin/api/profile/wallet-config", get(admin_get_profile_wallet_config).post(admin_upsert_profile_wallet_config), ), ( "/admin/api/profile/recharge-products", get(admin_list_profile_recharge_products).post(admin_upsert_profile_recharge_product), ), ( "/admin/api/profile/recharge-orders", get(admin_list_recharge_orders), ), ( "/admin/api/profile/recharge-refunds/preview", post(admin_preview_recharge_refund), ), ( "/admin/api/profile/recharge-refunds/execute", post(admin_execute_recharge_refund), ), ( "/admin/api/profile/recharge-refunds/register", post(admin_register_recharge_refund), ), ( "/admin/api/profile/recharge-refunds/manual-review/resolve", post(admin_resolve_recharge_refund_manual_review), ), ( "/admin/api/profile/users/detail", get(admin_get_user_detail), ), ( "/admin/api/profile/users/reconcile-consumption", post(admin_reconcile_user_consumption), ), ( "/admin/api/profile/users/initialize-consumption-projections", post(admin_initialize_user_consumption_projections), ), ( "/admin/api/profile/wallet-restriction", post(admin_update_wallet_restriction), ), ]; // 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。 let protected_router = protected_routes .into_iter() .fold(Router::new(), |router, (path, methods)| { router.route(path, methods.route_layer(auth.clone())) }); Router::new() .route("/admin/api/login", axum::routing::post(admin_login)) .merge(protected_router) } #[cfg(test)] mod route_contract_tests { use axum::{ body::Body, http::{Request, StatusCode, header}, middleware, }; use http_body_util::BodyExt; use tower::ServiceExt; use super::router; use crate::{config::AppConfig, request_context::attach_request_context, state::AppState}; const PROTECTED_ROUTES: &[(&str, &[&str])] = &[ ("/admin/api/agc-models", &["GET", "PUT"]), ("/admin/api/accounts", &["GET", "POST"]), ("/admin/api/accounts/{account_id}", &["PUT"]), ("/admin/api/me", &["GET"]), ("/admin/api/overview", &["GET"]), ("/admin/api/dashboard", &["GET"]), ("/admin/api/debug/http", &["POST"]), ("/admin/api/tracking/events", &["GET"]), ("/admin/api/tracking/event-keys", &["GET"]), ("/admin/api/database/tables", &["GET"]), ("/admin/api/database/tables/{table_name}/rows", &["GET"]), ("/admin/api/external-api-keys", &["GET"]), ("/admin/api/feature-gates", &["GET", "PUT"]), ("/admin/api/editor-generation-pricing", &["GET", "POST"]), ("/admin/api/editor-assets", &["GET"]), ("/admin/api/assets/read-url", &["GET"]), ("/admin/api/editor-showcase/assets", &["GET"]), ("/admin/api/editor-showcase/assets/review", &["POST"]), ("/admin/api/editor-showcase/assets/display", &["POST"]), ("/admin/api/editor-showcase/campaign", &["GET", "POST"]), ( "/admin/api/editor-showcase/campaign/image-upload-ticket", &["POST"], ), ( "/admin/api/editor-showcase/campaign/image-upload-confirm", &["POST"], ), ("/admin/api/profile/redeem-codes", &["GET", "POST"]), ("/admin/api/profile/redeem-codes/disable", &["POST"]), ("/admin/api/profile/invite-codes", &["GET", "POST"]), ("/admin/api/profile/tasks", &["GET", "POST"]), ("/admin/api/profile/tasks/disable", &["POST"]), ("/admin/api/profile/wallet-config", &["GET", "POST"]), ("/admin/api/profile/recharge-products", &["GET", "POST"]), ("/admin/api/profile/recharge-orders", &["GET"]), ("/admin/api/profile/recharge-refunds/preview", &["POST"]), ("/admin/api/profile/recharge-refunds/execute", &["POST"]), ("/admin/api/profile/recharge-refunds/register", &["POST"]), ( "/admin/api/profile/recharge-refunds/manual-review/resolve", &["POST"], ), ("/admin/api/profile/users/detail", &["GET"]), ("/admin/api/profile/users/reconcile-consumption", &["POST"]), ( "/admin/api/profile/users/initialize-consumption-projections", &["POST"], ), ("/admin/api/profile/wallet-restriction", &["POST"]), ]; fn app() -> axum::Router { let state = AppState::new(AppConfig { admin_username: Some("root".to_string()), admin_password: Some("secret123".to_string()), ..AppConfig::default() }) .expect("state should build"); router(state.clone()) .layer(middleware::from_fn(attach_request_context)) .with_state(state) } #[tokio::test] async fn protected_route_matrix_keeps_auth_methods_and_head_behavior() { let app = app(); for (template, supported) in PROTECTED_ROUTES { let path = template .split('/') .map(|segment| { if segment.starts_with('{') { "fixture" } else { segment } }) .collect::>() .join("/"); let mut allowed = supported.to_vec(); if allowed.contains(&"GET") { allowed.push("HEAD"); } allowed.sort_unstable(); for credential in [None, Some("Bearer invalid-token")] { for method in [ "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", ] { let mut request = Request::builder().method(method).uri(&path); if let Some(credential) = credential { request = request.header(header::AUTHORIZATION, credential); } let response = app .clone() .oneshot(request.body(Body::empty()).expect("request should build")) .await .expect("request should complete"); let expected = if allowed.contains(&method) { StatusCode::UNAUTHORIZED } else { StatusCode::METHOD_NOT_ALLOWED }; assert_eq!( response.status(), expected, "{method} {path}, credential={credential:?}" ); if expected == StatusCode::METHOD_NOT_ALLOWED { let mut actual = response.headers()[header::ALLOW] .to_str() .expect("Allow should be ASCII") .split(',') .map(str::trim) .collect::>(); actual.sort_unstable(); assert_eq!(actual, allowed, "Allow for {method} {path}"); } if method == "HEAD" { assert!( response .into_body() .collect() .await .expect("HEAD body should collect") .to_bytes() .is_empty(), "HEAD {path} must not return a body" ); } } } } } #[tokio::test] async fn unknown_paths_remain_not_found_before_authentication() { for credential in [None, Some("Bearer invalid-token")] { let mut request = Request::builder().uri("/admin/api/missing/unknown/route"); if let Some(credential) = credential { request = request.header(header::AUTHORIZATION, credential); } let response = app() .oneshot(request.body(Body::empty()).expect("request should build")) .await .expect("request should complete"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } } #[tokio::test] async fn login_remains_public_with_invalid_authorization_header() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/admin/api/login") .header(header::AUTHORIZATION, "Bearer invalid-token") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"username":"root","password":"secret123"}"#)) .expect("login request should build"), ) .await .expect("login should complete"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let payload: serde_json::Value = serde_json::from_slice(&body).expect("login should return JSON"); assert!( payload["token"] .as_str() .is_some_and(|token| !token.is_empty()) ); } }