Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -500,6 +500,7 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
@@ -639,6 +640,129 @@ mod tests {
);
}
#[tokio::test]
async fn ai_task_mutation_routes_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for route in ai_task_mutation_route_cases() {
let (status, _) = post_ai_task_route(app.clone(), route.uri, None, route.body).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{}", route.uri);
}
}
#[tokio::test]
async fn ai_task_mutation_routes_return_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
for route in ai_task_mutation_route_cases() {
let (status, payload) =
post_ai_task_route(app.clone(), route.uri, Some(&token), route.body).await;
assert_eq!(status, StatusCode::BAD_GATEWAY, "{}", route.uri);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string()),
"{}",
route.uri
);
}
}
struct AiTaskRouteCase {
uri: &'static str,
body: Option<Value>,
}
fn ai_task_mutation_route_cases() -> Vec<AiTaskRouteCase> {
vec![
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/start",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/chunks",
body: Some(json!({
"stageKind": "request_model",
"sequence": 1,
"deltaText": "你听见远处的铃声。"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/complete",
body: Some(json!({
"textOutput": "你听见远处的铃声。",
"structuredPayloadJson": "{\"scene\":\"camp\"}",
"warningMessages": []
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/references",
body: Some(json!({
"referenceKind": "story_event",
"referenceId": "storyevt_001",
"label": "营地开场"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/complete",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/fail",
body: Some(json!({
"failureMessage": "模型返回内容为空"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/cancel",
body: None,
},
]
}
async fn post_ai_task_route(
app: Router,
uri: &str,
bearer_token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut request = Request::builder()
.method("POST")
.uri(uri)
.header("x-genarrative-response-envelope", "v1");
if let Some(token) = bearer_token {
request = request.header("authorization", format!("Bearer {token}"));
}
let body = if let Some(payload) = body {
request = request.header("content-type", "application/json");
Body::from(payload.to_string())
} else {
Body::empty()
};
let response = app
.oneshot(request.body(body).expect("request should build"))
.await
.expect("request should succeed");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body).expect("response body should be valid json")
};
(status, payload)
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -33,9 +33,10 @@ use crate::{
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,
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_play,
stream_big_fish_message, submit_big_fish_message,
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,
@@ -652,6 +653,27 @@ pub fn build_router(state: AppState) -> Router {
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/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
@@ -828,16 +850,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/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/browse-history",
get(get_runtime_browse_history)
@@ -848,13 +860,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/dashboard",
get(get_profile_dashboard).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(
@@ -862,13 +867,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/wallet-ledger",
get(get_profile_wallet_ledger).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(
@@ -876,13 +874,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).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(
@@ -890,13 +881,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).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(
@@ -904,13 +888,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/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/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
@@ -918,13 +895,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/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/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
@@ -932,13 +902,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/redeem-codes/redeem",
post(redeem_profile_reward_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(
@@ -946,20 +909,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/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",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
@@ -967,13 +916,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives/{world_key}",
post(resume_profile_save_archive).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(

View File

@@ -23,8 +23,8 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
api_response::json_success_body, http_error::AppError, platform_errors::map_oss_error,
request_context::RequestContext, state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -377,17 +377,7 @@ fn map_confirm_asset_object_prepare_error(error: ConfirmAssetObjectPrepareError)
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(platform_oss::OssError::ObjectNotFound(_)) => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(_) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
ConfirmAssetObjectPrepareError::Oss(error) => map_oss_error(error, "aliyun-oss"),
}
}

View File

@@ -23,9 +23,10 @@ use shared_contracts::big_fish::{
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest,
SendBigFishMessageRequest,
BigFishRunResponse, BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse,
BigFishRuntimeSnapshotResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
RecordBigFishPlayRequest, SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
@@ -33,9 +34,10 @@ use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput,
BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
BigFishWorkSummaryRecord, SpacetimeClientError,
BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput,
BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishRuntimeEntityRecord,
BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, BigFishSessionCreateRecordInput,
BigFishSessionRecord, BigFishVector2Record, BigFishWorkSummaryRecord, SpacetimeClientError,
};
use tokio::time::sleep;
@@ -58,6 +60,7 @@ use crate::{
auth::AuthenticatedAccessToken,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
@@ -251,6 +254,102 @@ pub async fn record_big_fish_play(
))
}
pub async fn start_big_fish_run(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let run = state
.spacetime_client()
.start_big_fish_run(BigFishRunStartRecordInput {
run_id: build_prefixed_uuid_id("big-fish-run-"),
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn get_big_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn submit_big_fish_input(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SubmitBigFishInputRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
big_fish_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, &run_id, "runId")?;
if !payload.x.is_finite() || !payload.y.is_finite() {
return Err(big_fish_bad_request(&request_context, "input is invalid"));
}
let run = state
.spacetime_client()
.submit_big_fish_input(BigFishInputSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
x: payload.x,
y: payload.y,
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn submit_big_fish_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -799,6 +898,51 @@ fn map_big_fish_asset_coverage_response(
}
}
fn map_big_fish_run_response(run: BigFishRuntimeRunRecord) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
@@ -1570,19 +1714,7 @@ fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
}
fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn build_big_fish_level_part(level: Option<u32>) -> String {
@@ -1659,6 +1791,14 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("big_fish_runtime_run 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message) if message.contains("无权访问") => {
StatusCode::FORBIDDEN
}
SpacetimeClientError::Procedure(message)
if message.contains("不能为空")
|| message.contains("尚未编译")

View File

@@ -50,6 +50,7 @@ use crate::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::role_asset_studio::{
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
},
@@ -1639,7 +1640,9 @@ async fn load_workflow_cache(
expire_seconds: Some(60),
}) {
Ok(signed) => signed,
Err(platform_oss::OssError::ObjectNotFound(_)) => return Ok(None),
Err(error) if error.kind() == platform_oss::OssErrorKind::ObjectNotFound => {
return Ok(None);
}
Err(error) => return Err(map_character_animation_oss_error(error)),
};
let response = reqwest::Client::new()
@@ -3303,19 +3306,7 @@ fn map_character_animation_spacetime_error(error: SpacetimeClientError) -> AppEr
}
fn map_character_animation_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn character_animation_error_response(

View File

@@ -38,6 +38,7 @@ use crate::{
build_fallback_moderation_safe_character_visual_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
@@ -1335,19 +1336,7 @@ fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError
}
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn parse_json_payload(

View File

@@ -35,6 +35,7 @@ use crate::{
build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::scene_background::{
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
@@ -1016,19 +1017,7 @@ fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppErr
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {

View File

@@ -34,6 +34,11 @@ impl AppError {
self.code
}
#[cfg(test)]
pub fn status_code(&self) -> StatusCode {
self.status_code
}
pub fn message(&self) -> &str {
&self.message
}

View File

@@ -6,7 +6,7 @@ use axum::{
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
use serde_json::json;
use crate::{http_error::AppError, state::AppState};
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
@@ -183,19 +183,7 @@ fn is_invalid_path_segment(segment: &str) -> bool {
}
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn map_legacy_generated_upstream_status(

View File

@@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
response::Response,
};
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest};
use platform_llm::{LlmMessage, LlmMessageRole, LlmTextRequest};
use serde_json::Value;
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
@@ -12,7 +12,7 @@ use shared_contracts::llm::{
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
platform_errors::map_llm_error, request_context::RequestContext, state::AppState,
};
pub async fn proxy_llm_chat_completions(
@@ -74,39 +74,6 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
LlmMessage::new(role, message.content)
}
fn map_llm_error(error: LlmError) -> AppError {
match error {
LlmError::InvalidRequest(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
}
LlmError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
}
LlmError::Upstream {
status_code: 429,
message,
} => AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(message),
LlmError::Upstream { message, .. } => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
LlmError::Timeout { attempts } => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 请求超时,累计尝试 {attempts}")),
LlmError::Connectivity { attempts, message } => {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 连接失败,累计尝试 {attempts} 次:{message}"))
}
LlmError::StreamUnavailable => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 流式响应体不可用")
}
LlmError::EmptyResponse => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 返回内容为空")
}
LlmError::Transport(message) | LlmError::Deserialize(message) => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
}
}
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}

View File

@@ -42,6 +42,7 @@ mod logout_all;
mod password_entry;
mod password_management;
mod phone_auth;
mod platform_errors;
mod prompt;
mod puzzle;
mod puzzle_agent_turn;

View File

@@ -1,7 +1,7 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{
@@ -21,6 +21,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -237,10 +238,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
@@ -249,7 +247,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
map_phone_auth_platform_store_error(error.to_string())
}
}
}

View File

@@ -0,0 +1,133 @@
use axum::http::{HeaderValue, StatusCode};
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
use platform_llm::{LlmError, LlmErrorKind};
use platform_oss::{OssError, OssErrorKind};
use serde_json::json;
use crate::http_error::AppError;
// API 层统一消费 platform 的稳定错误分类,避免各 route 重复 match 具体 provider 分支。
pub fn map_llm_error(error: LlmError) -> AppError {
let message = llm_error_message(&error);
let status = match error.kind() {
LlmErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
LlmErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
LlmErrorKind::Upstream
if matches!(
error,
LlmError::Upstream {
status_code: 429,
..
}
) =>
{
StatusCode::TOO_MANY_REQUESTS
}
LlmErrorKind::Timeout
| LlmErrorKind::Connectivity
| LlmErrorKind::Upstream
| LlmErrorKind::StreamUnavailable
| LlmErrorKind::EmptyResponse
| LlmErrorKind::Transport
| LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_message(message)
}
pub fn map_oss_error(error: OssError, provider: &'static str) -> AppError {
let status = oss_error_status(error.kind());
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
pub fn map_phone_auth_platform_store_error(message: String) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
let status = match error.kind() {
AuthPlatformErrorKind::Disabled
| AuthPlatformErrorKind::MissingCode
| AuthPlatformErrorKind::InvalidCallback => StatusCode::BAD_REQUEST,
AuthPlatformErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
AuthPlatformErrorKind::RequestFailed
| AuthPlatformErrorKind::DeserializeFailed
| AuthPlatformErrorKind::MissingProfile
| AuthPlatformErrorKind::Upstream => StatusCode::BAD_GATEWAY,
AuthPlatformErrorKind::InvalidClaims
| AuthPlatformErrorKind::SignFailed
| AuthPlatformErrorKind::VerifyFailed
| AuthPlatformErrorKind::CookieConfig
| AuthPlatformErrorKind::HashFailed
| AuthPlatformErrorKind::InvalidVerifyCode => StatusCode::INTERNAL_SERVER_ERROR,
};
AppError::from_status(status).with_message(error.to_string())
}
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => error.with_header("retry-after", value),
Err(_) => error,
}
}
fn oss_error_status(kind: OssErrorKind) -> StatusCode {
match kind {
OssErrorKind::InvalidConfig | OssErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
OssErrorKind::ObjectNotFound => StatusCode::NOT_FOUND,
OssErrorKind::Request | OssErrorKind::SerializePolicy | OssErrorKind::Sign => {
StatusCode::BAD_GATEWAY
}
}
}
fn llm_error_message(error: &LlmError) -> String {
match error {
LlmError::InvalidConfig(message)
| LlmError::InvalidRequest(message)
| LlmError::Transport(message)
| LlmError::Deserialize(message) => message.clone(),
LlmError::Timeout { .. }
| LlmError::Connectivity { .. }
| LlmError::Upstream { .. }
| LlmError::StreamUnavailable
| LlmError::EmptyResponse => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_oss_error_uses_stable_kind_for_not_found() {
let error = map_oss_error(
OssError::ObjectNotFound("missing object".to_string()),
"oss",
);
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
}
#[test]
fn map_llm_error_preserves_upstream_rate_limit() {
let error = map_llm_error(LlmError::Upstream {
status_code: 429,
message: "too many requests".to_string(),
});
assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn map_wechat_provider_error_keeps_provider_boundary() {
let error = map_wechat_provider_error(WechatProviderError::MissingCode);
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(error.message(), "缺少微信授权 code");
}
}

View File

@@ -70,6 +70,7 @@ use crate::{
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_oss_error,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
@@ -2942,10 +2943,7 @@ fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> Str
}
fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError {

View File

@@ -258,7 +258,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.body(Body::empty())
.expect("request should build"),
)
@@ -278,7 +278,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -324,7 +324,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -361,64 +361,6 @@ mod tests {
);
}
#[tokio::test]
async fn runtime_browse_history_compat_route_matches_main_route_error_shape() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -551,12 +551,6 @@ mod tests {
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
@@ -585,7 +579,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/dashboard")
.uri("/api/profile/dashboard")
.body(Body::empty())
.expect("request should build"),
)
@@ -603,7 +597,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/wallet-ledger")
.uri("/api/profile/wallet-ledger")
.body(Body::empty())
.expect("request should build"),
)
@@ -621,7 +615,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/play-stats")
.uri("/api/profile/play-stats")
.body(Body::empty())
.expect("request should build"),
)
@@ -706,118 +700,35 @@ mod tests {
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
async fn runtime_profile_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for uri in [
"/api/runtime/profile/dashboard",
"/api/profile/dashboard",
)
.await;
}
#[tokio::test]
async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/wallet-ledger",
"/api/profile/wallet-ledger",
)
.await;
}
#[tokio::test]
async fn profile_play_stats_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/recharge-center",
"/api/runtime/profile/recharge/orders",
"/api/runtime/profile/referrals/invite-center",
"/api/runtime/profile/referrals/redeem-code",
"/api/runtime/profile/redeem-codes/redeem",
"/api/runtime/profile/play-stats",
"/api/profile/play-stats",
)
.await;
}
"/api/runtime/profile/save-archives",
"/api/runtime/profile/save-archives/world-1",
"/api/runtime/profile/browse-history",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_profile".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("资料页用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
}
}
}

View File

@@ -4,7 +4,10 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::format_utc_micros;
use module_runtime::{
RuntimeProfileFieldError, build_runtime_save_checkpoint_input,
build_runtime_save_checkpoint_update,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
@@ -52,26 +55,6 @@ pub async fn put_runtime_snapshot(
Json(payload): Json<PutRuntimeSaveCheckpointRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
})),
)
})?;
let bottom_tab = normalize_required_string(payload.bottom_tab.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = payload
.saved_at
@@ -90,6 +73,15 @@ pub async fn put_runtime_snapshot(
.unwrap_or(now);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let checkpoint_input = build_runtime_save_checkpoint_input(
payload.session_id,
payload.bottom_tab,
saved_at_micros,
updated_at_micros,
)
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let existing = state
.get_runtime_snapshot_record(user_id.clone())
@@ -107,16 +99,18 @@ pub async fn put_runtime_snapshot(
)
})?;
validate_checkpoint_snapshot(&request_context, &session_id, &existing.game_state)?;
let game_state = sync_runtime_snapshot_play_time(existing.game_state, updated_at_micros);
let update =
build_runtime_save_checkpoint_update(checkpoint_input, existing).map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let record = state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
existing.current_story,
updated_at_micros,
update.saved_at_micros,
update.bottom_tab,
update.game_state,
update.current_story,
update.updated_at_micros,
)
.await
.map_err(|error| {
@@ -223,132 +217,6 @@ fn build_saved_game_snapshot_response(
}
}
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
game_state
.get("runtimeMode")
.and_then(Value::as_str)
.map(str::trim),
Some("preview") | Some("test")
)
}
fn validate_checkpoint_snapshot(
request_context: &RequestContext,
session_id: &str,
game_state: &Value,
) -> Result<(), Response> {
if is_non_persistent_runtime_snapshot(game_state) {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "预览或测试运行态不能创建正式 checkpoint",
})),
));
}
let persisted_session_id =
read_string_field(game_state, "runtimeSessionId").ok_or_else(|| {
runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "服务端运行时快照缺少 runtimeSessionId无法创建 checkpoint",
})),
)
})?;
if persisted_session_id != session_id {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "checkpoint sessionId 与服务端运行时快照不一致",
"expectedSessionId": persisted_session_id,
"actualSessionId": session_id,
})),
));
}
Ok(())
}
fn sync_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
let Some(game_state_object) = game_state.as_object_mut() else {
return game_state;
};
let now_text = format_utc_micros(now_micros);
let Some(runtime_stats) = game_state_object
.get_mut("runtimeStats")
.and_then(Value::as_object_mut)
else {
game_state_object.insert(
"runtimeStats".to_string(),
json!({
"playTimeMs": 0,
"lastPlayTickAt": now_text,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0,
}),
);
return game_state;
};
let current_play_time = runtime_stats
.get("playTimeMs")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let elapsed_ms = runtime_stats
.get("lastPlayTickAt")
.and_then(Value::as_str)
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
.map(offset_datetime_to_unix_micros)
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
.unwrap_or(0.0);
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
// 中文注释checkpoint 只刷新服务端已有 runtimeStats 的时间水位,
// 不从浏览器接收任何任务、背包、战斗或剧情状态。
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
game_state
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_required_string(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
fn build_profile_save_archive_summary_response(
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
) -> ProfileSaveArchiveSummaryResponse {
@@ -395,6 +263,51 @@ fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError
}))
}
fn map_runtime_save_domain_error(error: RuntimeProfileFieldError) -> AppError {
let message = error.to_string();
match error {
RuntimeProfileFieldError::MissingCheckpointSessionId => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
}))
}
RuntimeProfileFieldError::MissingRuntimeSessionId => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::MissingBottomTab => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
}))
}
RuntimeProfileFieldError::NonPersistentRuntimeSnapshot => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::RuntimeSessionMismatch {
expected_session_id,
actual_session_id,
} => AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
"expectedSessionId": expected_session_id,
"actualSessionId": actual_session_id,
})),
_ => AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"message": message,
})),
}
}
fn runtime_save_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -584,7 +497,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/save-archives")
.uri("/api/profile/save-archives")
.body(Body::empty())
.expect("request should build"),
)
@@ -594,15 +507,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_save_archives_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/save-archives",
"/api/profile/save-archives",
)
.await;
}
#[tokio::test]
async fn resume_profile_save_archive_rejects_blank_world_key() {
let state = seed_authenticated_state().await;
@@ -613,7 +517,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/save-archives/%20%20")
.uri("/api/profile/save-archives/%20%20")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
@@ -625,68 +529,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_payload: Value = serde_json::from_slice(
&main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
let compat_payload: Value = serde_json::from_slice(
&compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -14,7 +14,7 @@ use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
@@ -24,7 +24,7 @@ use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
use crate::wechat_provider::build_wechat_provider;
const ADMIN_ROLE: &str = "admin";

View File

@@ -51,6 +51,23 @@ pub async fn create_story_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -89,14 +106,29 @@ pub async fn create_story_battle(
pub async fn resolve_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let battle_state_id = payload.battle_state_id;
let current_battle = state
.spacetime_client()
.get_battle_state(battle_state_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(
&request_context,
&current_battle.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.resolve_combat_action(ResolveCombatActionInput {
battle_state_id: payload.battle_state_id,
battle_state_id,
function_id: payload.function_id,
action_text: payload.action_text,
base_damage: payload.base_damage,
@@ -128,8 +160,9 @@ pub async fn get_story_battle_state(
State(state): State<AppState>,
Path(battle_state_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_battle_state(battle_state_id)
@@ -137,6 +170,7 @@ pub async fn get_story_battle_state(
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(&request_context, &result.actor_user_id, &actor_user_id)?;
Ok(json_success_body(
Some(&request_context),
@@ -175,6 +209,23 @@ pub async fn create_story_npc_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -431,6 +482,73 @@ fn story_battles_error_response(request_context: &RequestContext, error: AppErro
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner_for_battle(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-session",
"story session 不属于当前用户,不能创建战斗",
)
}
fn require_story_battle_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-battle",
"battle state 不属于当前用户",
)
}
fn require_story_session_runtime_for_battle(
request_context: &RequestContext,
session_runtime_id: &str,
requested_runtime_id: &str,
) -> Result<(), Response> {
if session_runtime_id == requested_runtime_id {
return Ok(());
}
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-session",
"message": "runtimeSessionId 与 story session 不匹配,不能创建战斗",
})),
))
}
fn require_resource_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
provider: &'static str,
message: &'static str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// API 层只做登录用户与资源属主的边界检查;战斗结算仍由 module-combat 与 SpacetimeDB 承担。
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": provider,
"message": message,
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -442,6 +560,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -454,7 +574,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::{require_story_battle_owner, require_story_session_runtime_for_battle};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn create_story_battle_requires_authentication() {
@@ -648,6 +771,37 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_story_battle_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/battles/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"battleStateId": "battle_001",
"functionId": "battle_attack_basic",
"actionText": "普通攻击",
"baseDamage": 10,
"manaCost": 0,
"heal": 0,
"manaRestore": 0,
"counterMultiplierBasisPoints": 10000
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -735,6 +889,63 @@ mod tests {
);
}
#[test]
fn story_battle_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
let response = require_story_battle_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_battle_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
require_story_battle_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
#[test]
fn story_battle_runtime_guard_rejects_mismatched_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
let response =
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_other")
.expect_err("mismatched runtime session should be bad request");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn story_battle_runtime_guard_accepts_matching_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_owner")
.expect("matching runtime session should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -72,14 +72,29 @@ pub async fn begin_story_session(
pub async fn continue_story(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ContinueStoryRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = payload.story_session_id;
let current_state = state
.spacetime_client()
.get_story_session_state(story_session_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&current_state.session.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.continue_story(
payload.story_session_id,
story_session_id,
module_story::generate_story_event_id(now_micros),
payload.narrative_text,
payload.choice_function_id,
@@ -123,8 +138,9 @@ pub async fn get_story_session_state(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_story_session_state(story_session_id)
@@ -132,6 +148,11 @@ pub async fn get_story_session_state(
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&result.session.actor_user_id,
&actor_user_id,
)?;
Ok(json_success_body(
Some(&request_context),
@@ -204,6 +225,25 @@ fn story_sessions_error_response(request_context: &RequestContext, error: AppErr
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// 这里只做 HTTP 鉴权边界判断story session 的推进规则仍由 SpacetimeDB 领域层处理。
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "story-session",
"message": "story session 不属于当前用户",
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -215,6 +255,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -227,7 +269,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::require_story_session_owner;
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn begin_story_session_requires_authentication() {
@@ -302,6 +347,32 @@ mod tests {
);
}
#[tokio::test]
async fn continue_story_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/continue")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"narrativeText": "你看见篝火边有人招手。",
"choiceFunctionId": "talk_to_npc"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -457,6 +528,34 @@ mod tests {
);
}
#[test]
fn story_session_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
let response = require_story_session_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_session_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
require_story_session_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -1,13 +1,13 @@
use axum::{
Json,
extract::{Extension, Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
WechatAuthScene,
};
use platform_auth::WechatAuthScene;
use shared_contracts::auth::{
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
@@ -23,6 +23,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_wechat_provider_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -50,17 +51,20 @@ pub async fn start_wechat_login(
query.redirect_path.as_deref(),
&state.config.wechat_redirect_path,
),
scene: scene.clone(),
scene: map_wechat_scene_to_domain(&scene),
request_user_agent: user_agent.clone(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_wechat_auth_error)?;
let authorization_url = state.wechat_provider().build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)?;
let authorization_url = state
.wechat_provider()
.build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)
.map_err(map_wechat_provider_error)?;
Ok(json_success_body(
Some(&request_context),
@@ -121,10 +125,12 @@ pub async fn handle_wechat_callback(
{
Ok(profile) => state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
.resolve_login(module_auth::ResolveWechatLoginInput {
profile: map_wechat_profile_to_domain(profile),
})
.await
.map_err(map_wechat_auth_error),
Err(error) => Err(error),
Err(error) => Err(map_wechat_provider_error(error)),
};
match result {
@@ -239,6 +245,24 @@ fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, App
Ok(WechatAuthScene::Desktop)
}
fn map_wechat_scene_to_domain(scene: &WechatAuthScene) -> module_auth::WechatAuthScene {
match scene {
WechatAuthScene::Desktop => module_auth::WechatAuthScene::Desktop,
WechatAuthScene::WechatInApp => module_auth::WechatAuthScene::WechatInApp,
}
}
fn map_wechat_profile_to_domain(
profile: platform_auth::WechatIdentityProfile,
) -> module_auth::WechatIdentityProfile {
module_auth::WechatIdentityProfile {
provider_uid: profile.provider_uid,
provider_union_id: profile.provider_union_id,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
}
}
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string();
@@ -340,10 +364,7 @@ fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
module_auth::PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())

View File

@@ -1,280 +1,40 @@
use module_auth::{WechatAuthScene, WechatIdentityProfile};
use reqwest::Client;
use serde::Deserialize;
use tracing::warn;
use url::Url;
use platform_auth::{
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig, WechatProvider,
};
use crate::{config::AppConfig, http_error::AppError};
use axum::http::StatusCode;
#[derive(Clone, Debug)]
pub enum WechatProvider {
Disabled,
Mock(MockWechatProvider),
Real(RealWechatProvider),
}
#[derive(Clone, Debug)]
pub struct MockWechatProvider {
mock_user_id: String,
mock_union_id: Option<String>,
mock_display_name: String,
mock_avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct RealWechatProvider {
client: Client,
app_id: String,
app_secret: String,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
}
#[derive(Debug, Deserialize)]
struct WechatAccessTokenResponse {
access_token: Option<String>,
openid: Option<String>,
unionid: Option<String>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatUserInfoResponse {
openid: Option<String>,
unionid: Option<String>,
nickname: Option<String>,
headimgurl: Option<String>,
errmsg: Option<String>,
}
use crate::config::AppConfig;
pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
if !config.wechat_auth_enabled {
return WechatProvider::Disabled;
}
if config
.wechat_auth_provider
.trim()
.eq_ignore_ascii_case("mock")
{
return WechatProvider::Mock(MockWechatProvider {
mock_user_id: config.wechat_mock_user_id.clone(),
mock_union_id: config.wechat_mock_union_id.clone(),
mock_display_name: config.wechat_mock_display_name.clone(),
mock_avatar_url: config.wechat_mock_avatar_url.clone(),
});
}
let Some(app_id) = config.wechat_app_id.clone() else {
return WechatProvider::Disabled;
};
let Some(app_secret) = config.wechat_app_secret.clone() else {
return WechatProvider::Disabled;
};
WechatProvider::Real(RealWechatProvider {
client: Client::new(),
app_id,
app_secret,
authorize_endpoint: config.wechat_authorize_endpoint.clone(),
access_token_endpoint: config.wechat_access_token_endpoint.clone(),
user_info_endpoint: config.wechat_user_info_endpoint.clone(),
})
WechatProvider::new(WechatAuthConfig::new(
config.wechat_auth_enabled,
config.wechat_auth_provider.clone(),
config.wechat_app_id.clone(),
config.wechat_app_secret.clone(),
normalize_wechat_endpoint(
&config.wechat_authorize_endpoint,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_access_token_endpoint,
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_user_info_endpoint,
DEFAULT_WECHAT_USER_INFO_ENDPOINT,
),
config.wechat_mock_user_id.clone(),
config.wechat_mock_union_id.clone(),
config.wechat_mock_display_name.clone(),
config.wechat_mock_avatar_url.clone(),
))
}
impl WechatProvider {
pub fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(_) => {
let mut callback = Url::parse(callback_url).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信回调地址非法:{error}"))
})?;
callback
.query_pairs_mut()
.append_pair("mock_code", "wx-mock-code")
.append_pair("state", state);
Ok(callback.to_string())
}
Self::Real(provider) => provider.build_authorization_url(callback_url, state, scene),
}
}
pub async fn resolve_callback_profile(
&self,
code: Option<&str>,
mock_code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(provider) => Ok(provider.resolve_callback_profile(mock_code)),
Self::Real(provider) => provider.resolve_callback_profile(code).await,
}
}
}
impl MockWechatProvider {
fn resolve_callback_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile {
let provider_uid = mock_code
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(self.mock_user_id.as_str())
.to_string();
WechatIdentityProfile {
provider_uid,
provider_union_id: self.mock_union_id.clone(),
display_name: Some(self.mock_display_name.clone()),
avatar_url: self.mock_avatar_url.clone(),
}
}
}
impl RealWechatProvider {
fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
let mut url = Url::parse(match scene {
WechatAuthScene::Desktop => &self.authorize_endpoint,
WechatAuthScene::WechatInApp => "https://open.weixin.qq.com/connect/oauth2/authorize",
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信授权地址非法:{error}"))
})?;
url.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("redirect_uri", callback_url)
.append_pair("response_type", "code")
.append_pair(
"scope",
match scene {
WechatAuthScene::Desktop => "snsapi_login",
WechatAuthScene::WechatInApp => "snsapi_userinfo",
},
)
.append_pair("state", state);
Ok(format!("{url}#wechat_redirect"))
}
async fn resolve_callback_profile(
&self,
code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
let code = code
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code")
})?;
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信 access_token 地址非法:{error}"))
})?;
access_token_url
.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("secret", &self.app_secret)
.append_pair("code", code)
.append_pair("grant_type", "authorization_code");
let access_token_payload = self
.client
.get(access_token_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 请求失败")
})?
.json::<WechatAccessTokenResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 响应非法")
})?;
let access_token = access_token_payload
.access_token
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
access_token_payload
.errmsg
.unwrap_or_else(|| "缺少 access_token".to_string())
))
})?;
let openid = access_token_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:缺少 openid")
})?;
let mut user_info_url = Url::parse(&self.user_info_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信用户信息地址非法:{error}"))
})?;
user_info_url
.query_pairs_mut()
.append_pair("access_token", &access_token)
.append_pair("openid", &openid)
.append_pair("lang", "zh_CN");
let user_info_payload = self
.client
.get(user_info_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息请求失败")
})?
.json::<WechatUserInfoResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息响应非法")
})?;
let provider_uid = user_info_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
user_info_payload
.errmsg
.unwrap_or_else(|| "缺少 openid".to_string())
))
})?;
Ok(WechatIdentityProfile {
provider_uid,
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
display_name: user_info_payload.nickname,
avatar_url: user_info_payload.headimgurl,
})
fn normalize_wechat_endpoint(value: &str, fallback: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}