use axum::{ Router, body::Body, extract::{DefaultBodyLimit, Extension}, http::Request, middleware, routing::{delete, get, post}, }; use tower_http::{ classify::ServerErrorsFailureClass, trace::{DefaultOnRequest, TraceLayer}, }; use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth}, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, }, assets::{ bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket, create_sts_upload_credentials, get_asset_history, get_asset_read_url, }, auth::{ attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie, require_bearer_auth, }, auth_me::auth_me, auth_public_user::{get_public_user_by_code, get_public_user_by_id}, auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_gallery_like, record_big_fish_play, remix_big_fish_gallery_work, start_big_fish_run, stream_big_fish_message, submit_big_fish_input, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, import_character_animation_video, list_character_animation_templates, publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow, save_character_workflow_cache, }, character_visual_assets::{ generate_character_visual, get_character_visual_job, publish_character_visual, }, creation_agent_document_input::parse_creation_agent_document_input, custom_world::{ create_custom_world_agent_session, delete_custom_world_agent_session, delete_custom_world_library_profile, execute_custom_world_agent_action, generate_custom_world_profile, get_custom_world_agent_card_detail, get_custom_world_agent_operation, get_custom_world_agent_result_view, get_custom_world_agent_session, get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code, get_custom_world_library, get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile, put_custom_world_library_profile, record_custom_world_gallery_like, record_custom_world_gallery_play, remix_custom_world_gallery_profile, stream_custom_world_agent_message, submit_custom_world_agent_message, unpublish_custom_world_library_profile, }, custom_world_ai::{ generate_custom_world_cover_image, generate_custom_world_entity, generate_custom_world_opening_cg, generate_custom_world_scene_image, generate_custom_world_scene_npc, upload_custom_world_cover_image, }, error_middleware::normalize_error_response, health::health_check, llm::proxy_llm_chat_completions, login_options::auth_login_options, logout::logout, logout_all::logout_all, match3d::{ click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session, delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message, }, password_entry::password_entry, password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, profile_identity::update_profile_identity, puzzle::{ advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session, delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, runtime_browse_history::{ delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, }, runtime_chat::stream_runtime_npc_chat_turn, runtime_chat_plain::{ generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary, stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue, stream_runtime_npc_recruit_dialogue, }, runtime_inventory::get_runtime_inventory_state, runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_list_profile_invite_codes, admin_list_profile_redeem_codes, admin_list_profile_task_configs, admin_upsert_profile_invite_code, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, put_runtime_snapshot, resume_profile_save_archive, }, runtime_settings::{get_runtime_settings, put_runtime_settings}, square_hole::{ compile_square_hole_agent_draft, create_square_hole_agent_session, delete_square_hole_work, drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up, get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail, get_square_hole_works, list_square_hole_gallery, publish_square_hole_work, put_square_hole_work, restart_square_hole_run, start_square_hole_run, stop_square_hole_run, stream_square_hole_agent_message, submit_square_hole_agent_message, }, state::AppState, story_battles::{ create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle, }, story_sessions::{ begin_story_runtime_session, begin_story_session, continue_story, get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action, }, wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, }; const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { let slow_request_threshold_ms = state.config.slow_request_threshold_ms; Router::new() .route("/admin/api/login", post(admin_login)) .route( "/admin/api/me", get(admin_me).route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/overview", get(admin_overview).route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/debug/http", post(admin_debug_http).route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/profile/redeem-codes", get(admin_list_profile_redeem_codes) .post(admin_upsert_profile_redeem_code) .route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/profile/redeem-codes/disable", post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/profile/invite-codes", get(admin_list_profile_invite_codes) .post(admin_upsert_profile_invite_code) .route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/profile/tasks", get(admin_list_profile_task_configs) .post(admin_upsert_profile_task_config) .route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/profile/tasks/disable", post(admin_disable_profile_task_config).route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/healthz", get(|Extension(request_context): Extension<_>| async move { health_check(Extension(request_context)).await }), ) .route( "/_internal/auth/claims", get(inspect_auth_claims).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/_internal/auth/refresh-cookie", get(inspect_refresh_session_cookie).route_layer(middleware::from_fn_with_state( state.clone(), attach_refresh_session_token, )), ) .route("/api/auth/login-options", get(auth_login_options)) .route( "/api/auth/public-users/by-code/{code}", get(get_public_user_by_code), ) .route( "/api/auth/public-users/by-id/{user_id}", get(get_public_user_by_id), ) .route( "/api/auth/me", get(auth_me).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/auth/sessions", get(auth_sessions) .route_layer(middleware::from_fn_with_state( state.clone(), attach_refresh_session_token, )) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/me", axum::routing::patch(update_profile_identity).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/auth/refresh", post(refresh_session).route_layer(middleware::from_fn_with_state( state.clone(), attach_refresh_session_token, )), ) .route("/api/auth/phone/send-code", post(send_phone_code)) .route("/api/auth/phone/login", post(phone_login)) .route("/api/auth/wechat/start", get(start_wechat_login)) .route("/api/auth/wechat/callback", get(handle_wechat_callback)) .route( "/api/auth/wechat/bind-phone", post(bind_wechat_phone).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/llm/chat/completions", post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/chat/character/suggestions", post(generate_runtime_character_chat_suggestions).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/runtime/chat/character/summary", post(generate_runtime_character_chat_summary).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/runtime/chat/character/reply/stream", post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/chat/npc/dialogue/stream", post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/chat/npc/turn/stream", post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/chat/npc/recruit/stream", post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/creation-agent/document-inputs/parse", post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/auth/logout", post(logout) .route_layer(middleware::from_fn_with_state( state.clone(), attach_refresh_session_token, )) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/auth/logout-all", post(logout_all).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks", post(create_ai_task).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/start", post(start_ai_task).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/stages/{stage_kind}/start", post(start_ai_task_stage).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/chunks", post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/stages/{stage_kind}/complete", post(complete_ai_stage).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/references", post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/complete", post(complete_ai_task).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/fail", post(fail_ai_task).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/ai/tasks/{task_id}/cancel", post(cancel_ai_task).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/assets/direct-upload-tickets", post(create_direct_upload_ticket), ) .route( "/api/assets/sts-upload-credentials", post(create_sts_upload_credentials), ) .route("/api/assets/objects/confirm", post(confirm_asset_object)) .route( "/api/assets/objects/bind", post(bind_asset_object_to_entity), ) .route( "/api/assets/character-visual/generate", post(generate_character_visual), ) .route( "/api/assets/character-visual/jobs/{task_id}", get(get_character_visual_job), ) .route( "/api/assets/character-visual/publish", post(publish_character_visual), ) .route( "/api/assets/character-animation/generate", post(generate_character_animation), ) .route( "/api/assets/character-animation/jobs/{task_id}", get(get_character_animation_job), ) .route( "/api/assets/character-animation/publish", post(publish_character_animation), ) .route( "/api/assets/character-animation/import-video", post(import_character_animation_video), ) .route( "/api/assets/character-animation/templates", get(list_character_animation_templates), ) .route( "/api/assets/character-workflow-cache", post(save_character_workflow_cache), ) .route( "/api/assets/character-workflow-cache/{character_id}", get(get_character_workflow_cache), ) .route( "/api/runtime/custom-world/asset-studio/role/{character_id}/workflow", post(resolve_role_asset_workflow).put(put_role_asset_workflow), ) .route("/api/assets/read-url", get(get_asset_read_url)) .route( "/api/assets/history", get(get_asset_history).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/settings", get(get_runtime_settings) .put(put_runtime_settings) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/save/snapshot", get(get_runtime_snapshot) .put(put_runtime_snapshot) .delete(delete_runtime_snapshot) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-library", get(get_custom_world_library).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-library/{profile_id}", get(get_custom_world_library_detail) .put(put_custom_world_library_profile) .delete(delete_custom_world_library_profile) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-library/{profile_id}/publish", post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-library/{profile_id}/unpublish", post(unpublish_custom_world_library_profile).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/runtime/custom-world-gallery", get(list_custom_world_gallery), ) .route( "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}", get(get_custom_world_gallery_detail), ) .route( "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix", post(remix_custom_world_gallery_profile).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play", post(record_custom_world_gallery_play).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like", post(record_custom_world_gallery_like).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-gallery/by-code/{code}", get(get_custom_world_gallery_detail_by_code), ) .route( "/api/runtime/custom-world/agent/sessions", post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}", get(get_custom_world_agent_session) .delete(delete_custom_world_agent_session) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/result-view", get(get_custom_world_agent_result_view).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/works", get(get_custom_world_works).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}", get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/messages", post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream", post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/actions", post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}", get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/agent/sessions", post(create_big_fish_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/agent/sessions/{session_id}", get(get_big_fish_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/agent/sessions/{session_id}/messages", post(submit_big_fish_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/agent/sessions/{session_id}/messages/stream", post(stream_big_fish_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/agent/sessions/{session_id}/actions", post(execute_big_fish_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/works", get(get_big_fish_works).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery)) .route( "/api/runtime/big-fish/gallery/{session_id}/remix", post(remix_big_fish_gallery_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/gallery/{session_id}/like", post(record_big_fish_gallery_like).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/works/{session_id}", delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/sessions/{session_id}/play", post(record_big_fish_play).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/works/{session_id}/play", post(record_big_fish_play).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/sessions/{session_id}/runs", post(start_big_fish_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/runs/{run_id}", get(get_big_fish_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/runs/{run_id}/input", post(submit_big_fish_input).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions", post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}", get(get_match3d_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/messages", post(submit_match3d_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/messages/stream", post(stream_match3d_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/actions", post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/compile", post(compile_match3d_agent_draft).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/works", get(get_match3d_works).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/works/{profile_id}", get(get_match3d_work_detail) .patch(put_match3d_work) .put(put_match3d_work) .delete(delete_match3d_work) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/works/{profile_id}/publish", post(publish_match3d_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route("/api/runtime/match3d/gallery", get(list_match3d_gallery)) .route( "/api/runtime/match3d/works/{profile_id}/runs", post(start_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}", get(get_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/click", post(click_match3d_item).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/stop", post(stop_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/restart", post(restart_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/time-up", post(finish_match3d_time_up).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/sessions", post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/sessions/{session_id}", get(get_square_hole_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/sessions/{session_id}/messages", post(submit_square_hole_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/sessions/{session_id}/messages/stream", post(stream_square_hole_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/sessions/{session_id}/actions", post(execute_square_hole_agent_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/sessions/{session_id}/compile", post(compile_square_hole_agent_draft).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/works", get(get_square_hole_works).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/works/{profile_id}", get(get_square_hole_work_detail) .patch(put_square_hole_work) .put(put_square_hole_work) .delete(delete_square_hole_work) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/square-hole/works/{profile_id}/publish", post(publish_square_hole_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/square-hole/gallery", get(list_square_hole_gallery), ) .route( "/api/runtime/square-hole/works/{profile_id}/runs", post(start_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}", get(get_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/drop", post(drop_square_hole_shape).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/stop", post(stop_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/restart", post(restart_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/time-up", post(finish_square_hole_time_up).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/agent/sessions", post(create_puzzle_agent_session) // 中文注释:拼图表单会携带单张参考图 Data URL,需只给该写入入口放宽 body 上限。 .layer(DefaultBodyLimit::max( PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, )) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}", get(get_puzzle_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}/messages", post(submit_puzzle_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}/messages/stream", post(stream_puzzle_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}/actions", post(execute_puzzle_agent_action) // 中文注释:生成草稿/重新出图会复用 referenceImageSrc,避免默认 2MB JSON limit 拦截。 .layer(DefaultBodyLimit::max( PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, )) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/works", get(get_puzzle_works).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/works/{profile_id}", get(get_puzzle_work_detail) .put(put_puzzle_work) .delete(delete_puzzle_work) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/works/{profile_id}/point-incentive/claim", post(claim_puzzle_work_point_incentive).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery)) .route( "/api/runtime/puzzle/gallery/{profile_id}", get(get_puzzle_gallery_detail), ) .route( "/api/runtime/puzzle/gallery/{profile_id}/remix", post(remix_puzzle_gallery_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/gallery/{profile_id}/like", post(record_puzzle_gallery_like).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs", post(start_puzzle_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}", get(get_puzzle_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/swap", post(swap_puzzle_pieces).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/drag", post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/next-level", post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/pause", post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/props", post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/leaderboard", post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/profile", post(generate_custom_world_profile).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/entity", post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/scene-npc", post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/scene-image", post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/cover-image", post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/cover-upload", post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/opening-cg", post(generate_custom_world_opening_cg).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/browse-history", get(get_runtime_browse_history) .post(post_runtime_browse_history) .delete(delete_runtime_browse_history) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/dashboard", get(get_profile_dashboard).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/wallet-ledger", get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/recharge-center", get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/recharge/orders", post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/referrals/invite-center", get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/referrals/redeem-code", post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/redeem-codes/redeem", post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/analytics/metric", get(get_profile_analytics_metric).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/tasks", get(get_profile_task_center).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/tasks/{task_id}/claim", post(claim_profile_task_reward).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/save-archives", get(list_profile_save_archives).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/save-archives/{world_key}", post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/sessions/{runtime_session_id}/inventory", get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/play-stats", get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/sessions", post(begin_story_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/sessions/runtime", post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/sessions/{story_session_id}/state", get(get_story_session_state).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/sessions/{story_session_id}/runtime-projection", get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/sessions/{story_session_id}/actions/resolve", post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/sessions/continue", post(continue_story).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/battles", post(create_story_battle).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/battles/{battle_state_id}", get(get_story_battle_state).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/npc/battle", post(create_story_npc_battle).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/story/battles/resolve", post(resolve_story_battle).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route("/api/auth/entry", post(password_entry)) .route( "/api/auth/password/change", post(change_password).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route("/api/auth/password/reset", post(reset_password)) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 .layer(middleware::from_fn(propagate_request_id_header)) // 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。 .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request
| { let request_id = resolve_request_id(request).unwrap_or_else(|| "unknown".to_string()); info_span!( "http.request", method = %request.method(), uri = %request.uri(), request_id = %request_id, ) }) .on_request(DefaultOnRequest::new().level(Level::INFO)) .on_response( move |response: &axum::response::Response, latency: std::time::Duration, span: &Span| { let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; let status = response.status().as_u16(); let slow_request = latency_ms >= slow_request_threshold_ms; span.record("status", status); span.record("latency_ms", latency_ms); if slow_request { warn!( parent: span, status, latency_ms, slow_request = true, "http request completed slowly" ); } else { info!( parent: span, status, latency_ms, slow_request = false, "http request completed" ); } }, ) .on_failure( |failure: ServerErrorsFailureClass, latency: std::time::Duration, span: &Span| { let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; error!( parent: span, latency_ms, failure = %failure, "http request failed" ); }, ), ) // request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。 .layer(middleware::from_fn(attach_request_context)) .with_state(state) } #[cfg(test)] mod tests { use axum::{ Router, body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use reqwest::Client; use serde_json::Value; use time::OffsetDateTime; use tokio::net::TcpListener; use tower::ServiceExt; use crate::{config::AppConfig, state::AppState}; use super::build_router; const TEST_PASSWORD: &str = "secret123"; async fn seed_phone_user_with_password( state: &AppState, phone_number: &str, password: &str, ) -> module_auth::AuthUser { state .seed_test_phone_user_with_password(phone_number, password) .await } fn sign_test_user_token( state: &AppState, user: &module_auth::AuthUser, session_id: &str, ) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: user.id.clone(), session_id: session_id.to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: user.token_version, phone_verified: false, binding_status: BindingStatus::Active, display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } async fn password_login_request( app: Router, phone_number: &str, password: &str, ) -> axum::response::Response { app.oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": phone_number, "password": password }) .to_string(), )) .expect("password login request should build"), ) .await .expect("password login request should succeed") } #[tokio::test] async fn healthz_returns_legacy_compatible_payload_and_headers() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/healthz") .header("x-request-id", "req-health-legacy") .body(Body::empty()) .expect("healthz request should build"), ) .await .expect("healthz request should succeed"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-request-id") .and_then(|value| value.to_str().ok()), Some("req-health-legacy") ); assert_eq!( response .headers() .get("x-api-version") .and_then(|value| value.to_str().ok()), Some("2026-04-08") ); assert_eq!( response .headers() .get("x-route-version") .and_then(|value| value.to_str().ok()), Some("2026-04-08") ); assert!(response.headers().contains_key("x-response-time-ms")); let body = response .into_body() .collect() .await .expect("healthz body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("healthz body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["service"], Value::String("genarrative-api-server".to_string()) ); } #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/healthz") .header("x-request-id", "req-health-envelope") .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("healthz request should build"), ) .await .expect("healthz request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("healthz body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("healthz body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["data"]["service"], Value::String("genarrative-api-server".to_string()) ); assert_eq!( payload["meta"]["requestId"], Value::String("req-health-envelope".to_string()) ); } #[tokio::test] async fn runtime_story_legacy_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); for (method, uri) in [ ("POST", "/api/runtime/story/sessions"), ("POST", "/api/runtime/story/state/resolve"), ("GET", "/api/runtime/story/state/runtime-main"), ("POST", "/api/runtime/story/actions/resolve"), ("POST", "/api/runtime/story/initial"), ("POST", "/api/runtime/story/continue"), ] { let response = app .clone() .oneshot( Request::builder() .method(method) .uri(uri) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("legacy runtime story request should build"), ) .await .expect("legacy runtime story request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); let body = response .into_body() .collect() .await .expect("legacy runtime story body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("legacy runtime story body should be json"); assert_eq!(payload["ok"], Value::Bool(false)); assert_eq!( payload["error"]["code"], Value::String("NOT_FOUND".to_string()) ); assert_eq!( payload["error"]["message"], Value::String("资源不存在".to_string()) ); } } #[tokio::test] async fn deleted_old_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); // 中文注释:旧 custom-world 非 runtime 前缀没有任何新路由可匹配, // 因此必须稳定返回 404,避免前端继续误用旧入口。 for uri in [ "/api/custom-world/entity", "/api/custom-world/scene-npc", "/api/custom-world/scene-image", "/api/custom-world/cover-image", "/api/custom-world/cover-upload", ] { let response = app .clone() .oneshot( Request::builder() .method("POST") .uri(uri) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("deleted old route request should build"), ) .await .expect("deleted old route request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/puzzle/runs/local-next-level") .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("deleted old puzzle route request should build"), ) .await .expect("deleted old puzzle route request should be handled"); // 中文注释:该路径会被现有 GET /runs/{run_id} 的动态段识别, // 但 POST 方法没有挂载,返回 405 代表旧 local-next-level handler 已移除。 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); } #[tokio::test] async fn generated_asset_read_proxy_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); // 中文注释:生成资产仍可作为 legacyPublicPath 传给 /api/assets/read-url, // 但不能再通过 /generated-* 同源路由裸读 OSS 对象。 for uri in [ "/generated-character-drafts/hero/visual/candidate.png", "/generated-characters/hero/visual/master.png", "/generated-animations/hero/idle/frame01.png", "/generated-big-fish-assets/session-1/level/image.png", "/generated-puzzle-assets/session-1/candidate/image.png", "/generated-custom-world-scenes/world-1/camp/scene.png", "/generated-custom-world-covers/world-1/cover.webp", "/generated-qwen-sprites/master/candidate-01.png", ] { let response = app .clone() .oneshot( Request::builder() .method("GET") .uri(uri) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("generated asset proxy route request should build"), ) .await .expect("generated asset proxy route request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } } #[tokio::test] async fn internal_auth_claims_rejects_missing_bearer_token() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/_internal/auth/claims") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn internal_auth_claims_returns_verified_claims() { let config = AppConfig::default(); let state = AppState::new(config.clone()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await; let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: seed_user.id.clone(), session_id: "sess_auth_debug".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: seed_user.token_version, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some(seed_user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); let app = build_router(state); let response = app .oneshot( Request::builder() .uri("/_internal/auth/claims") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id)); assert_eq!( payload["claims"]["sid"], Value::String("sess_auth_debug".to_string()) ); assert_eq!( payload["claims"]["ver"], Value::Number(serde_json::Number::from(seed_user.token_version)) ); } #[tokio::test] async fn internal_refresh_cookie_reports_missing_cookie() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/_internal/auth/refresh-cookie") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["present"], Value::Bool(false)); assert_eq!( payload["cookieName"], Value::String("genarrative_refresh_session".to_string()) ); } #[tokio::test] async fn internal_refresh_cookie_reports_present_cookie() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/_internal/auth/refresh-cookie") .header( "cookie", "theme=dark; genarrative_refresh_session=token12345", ) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["present"], Value::Bool(true)); assert_eq!( payload["tokenLength"], Value::Number(serde_json::Number::from(10)) ); } #[tokio::test] async fn puzzle_agent_actions_accept_reference_image_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138024", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_reference_body"); let app = build_router(state); let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "action": "unsupported_large_reference_test", "referenceImageSrc": reference_image_src, }) .to_string(); assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/puzzle/agent/sessions/puzzle-session-large/actions") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from(request_body)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( body_text.contains("unsupported_large_reference_test"), "handler should parse the oversized reference payload before rejecting the action: {body_text}" ); assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] async fn puzzle_agent_session_creation_accepts_reference_image_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138025", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_form_reference_body"); let app = build_router(state); let request_body = format!( "{{\"seedText\":\"大参考图拼图\",\"pictureDescription\":\"一张用于验证 body limit 的参考图。\",\"referenceImageSrc\":\"data:image/png;base64,{}\"", "A".repeat(3 * 1024 * 1024) ); assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/puzzle/agent/sessions") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from(request_body)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( body_text.contains("EOF") || body_text.contains("expected"), "handler should parse the oversized form payload before rejecting malformed JSON: {body_text}" ); assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] async fn password_entry_rejects_unknown_phone_without_registration() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = password_login_request(app, "13800138011", TEST_PASSWORD).await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() { let config = AppConfig { dev_password_entry_auto_register_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let first_response = password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await; let first_status = first_response.status(); let first_body = first_response .into_body() .collect() .await .expect("first response body should collect") .to_bytes(); let first_payload: Value = serde_json::from_slice(&first_body).expect("first response body should be valid json"); let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await; assert_eq!(first_status, StatusCode::OK); assert!(first_payload["token"].as_str().is_some()); assert_eq!( first_payload["user"]["loginMethod"], Value::String("password".to_string()) ); assert_eq!(second_response.status(), StatusCode::OK); } #[tokio::test] async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { let state = AppState::new(AppConfig::default()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138012", TEST_PASSWORD).await; let app = build_router(state); let response = password_login_request(app, "13800138012", TEST_PASSWORD).await; assert_eq!(response.status(), StatusCode::OK); assert!( response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("genarrative_refresh_session=")) ); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["user"]["id"], Value::String(seed_user.id)); assert_eq!( payload["user"]["loginMethod"], Value::String("password".to_string()) ); assert_eq!( payload["user"]["createdAt"], Value::String(seed_user.created_at) ); assert!(payload["token"].as_str().is_some()); } #[tokio::test] async fn auth_login_options_returns_enabled_methods_in_stable_order() { let config = AppConfig { sms_auth_enabled: true, wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/api/auth/login-options") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["phone", "password", "wechat"]) ); } #[tokio::test] async fn auth_login_options_keeps_password_entry_when_external_methods_disabled() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/api/auth/login-options") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("body should be valid json"); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["password"]) ); } #[tokio::test] async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["cooldownSeconds"], Value::Number(serde_json::Number::from(60)) ); assert_eq!( payload["expiresInSeconds"], Value::Number(serde_json::Number::from(300)) ); assert_eq!( payload["providerRequestId"], Value::String("mock-request-id".to_string()) ); } #[tokio::test] async fn send_phone_code_rejects_same_scene_during_cooldown() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let first_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("first request should build"), ) .await .expect("first request should succeed"); assert_eq!(first_response.status(), StatusCode::OK); let cooldown_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("cooldown request should build"), ) .await .expect("cooldown request should succeed"); assert_eq!(cooldown_response.status(), StatusCode::TOO_MANY_REQUESTS); assert!( cooldown_response .headers() .get("retry-after") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.parse::