Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("尚未编译")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
server-rs/crates/api-server/src/platform_errors.rs
Normal file
133
server-rs/crates/api-server/src/platform_errors.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user