后端重写提交
This commit is contained in:
@@ -8,12 +8,23 @@ license.workspace = true
|
||||
axum = "0.8"
|
||||
dotenvy = "0.15"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
module-ai = { path = "../module-ai" }
|
||||
module-assets = { path = "../module-assets" }
|
||||
module-auth = { path = "../module-auth" }
|
||||
module-combat = { path = "../module-combat" }
|
||||
module-custom-world = { path = "../module-custom-world" }
|
||||
module-inventory = { path = "../module-inventory" }
|
||||
module-npc = { path = "../module-npc" }
|
||||
module-runtime = { path = "../module-runtime" }
|
||||
module-runtime-item = { path = "../module-runtime-item" }
|
||||
module-story = { path = "../module-story" }
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
platform-llm = { path = "../platform-llm" }
|
||||
platform-oss = { path = "../platform-oss" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared-contracts = { path = "../shared-contracts" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
shared-logging = { path = "../shared-logging" }
|
||||
spacetime-client = { path = "../spacetime-client" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
20. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路
|
||||
21. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路
|
||||
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
|
||||
23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
|
||||
24. 接入 custom world agent `session create / session snapshot` Axum facade
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -64,6 +66,8 @@
|
||||
17. [x] 接入 `/api/auth/wechat/bind-phone`
|
||||
18. [x] 接入 `/api/assets/objects/bind`
|
||||
19. [x] 接入 `/api/assets/sts-upload-credentials`
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -131,3 +135,4 @@
|
||||
11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。
|
||||
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
|
||||
674
server-rs/crates/api-server/src/ai_tasks.rs
Normal file
674
server-rs/crates/api-server/src/ai_tasks.rs
Normal file
@@ -0,0 +1,674 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use module_ai::{
|
||||
AiResultReferenceInput, AiResultReferenceKind, AiStageCompletionInput, AiTaskCancelInput,
|
||||
AiTaskCreateInput, AiTaskFailureInput, AiTaskFinishInput, AiTaskKind, AiTaskStageBlueprint,
|
||||
AiTaskStageKind, AiTaskStageStartInput, AiTaskStartInput, AiTextChunkAppendInput,
|
||||
generate_ai_task_id,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::ai::{
|
||||
AiResultReferencePayload, AiTaskAcceptedResponse, AiTaskMutationResponse, AiTaskPayload,
|
||||
AiTaskStagePayload, AiTextChunkPayload, AppendAiTextChunkRequest,
|
||||
AttachAiResultReferenceRequest, CompleteAiStageRequest, CreateAiTaskRequest, FailAiTaskRequest,
|
||||
};
|
||||
use spacetime_client::{AiTaskMutationRecord, SpacetimeClientError};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn create_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CreateAiTaskRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let task_kind = parse_ai_task_kind_strict(&payload.task_kind).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task",
|
||||
"message": "taskKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let stages = build_stage_blueprints(task_kind, payload.stage_kinds, &request_context)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.create_ai_task(AiTaskCreateInput {
|
||||
task_id: generate_ai_task_id(now_micros),
|
||||
task_kind,
|
||||
owner_user_id,
|
||||
request_label: payload.request_label,
|
||||
source_module: payload.source_module,
|
||||
source_entity_id: payload.source_entity_id,
|
||||
request_payload_json: payload.request_payload_json,
|
||||
stages,
|
||||
created_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn start_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Response, Response> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.start_ai_task(AiTaskStartInput {
|
||||
task_id: task_id.clone(),
|
||||
started_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(ai_task_accepted_response(
|
||||
&request_context,
|
||||
AiTaskAcceptedResponse {
|
||||
accepted: true,
|
||||
task_id,
|
||||
action: "start_task".to_string(),
|
||||
stage_kind: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn start_ai_task_stage(
|
||||
State(state): State<AppState>,
|
||||
Path((task_id, stage_kind_text)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Response, Response> {
|
||||
let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": "stageKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.start_ai_task_stage(AiTaskStageStartInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind,
|
||||
started_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(ai_task_accepted_response(
|
||||
&request_context,
|
||||
AiTaskAcceptedResponse {
|
||||
accepted: true,
|
||||
task_id,
|
||||
action: "start_stage".to_string(),
|
||||
stage_kind: Some(stage_kind.as_str().to_string()),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn append_ai_text_chunk(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<AppendAiTextChunkRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let stage_kind = parse_ai_task_stage_kind_strict(&payload.stage_kind).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": "stageKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.append_ai_text_chunk(AiTextChunkAppendInput {
|
||||
task_id,
|
||||
stage_kind,
|
||||
sequence: payload.sequence,
|
||||
delta_text: payload.delta_text,
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn complete_ai_stage(
|
||||
State(state): State<AppState>,
|
||||
Path((task_id, stage_kind_text)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CompleteAiStageRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": "stageKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.complete_ai_stage(AiStageCompletionInput {
|
||||
task_id,
|
||||
stage_kind,
|
||||
text_output: payload.text_output,
|
||||
structured_payload_json: payload.structured_payload_json,
|
||||
warning_messages: payload.warning_messages,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn attach_ai_result_reference(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<AttachAiResultReferenceRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let reference_kind = parse_ai_result_reference_kind_strict(&payload.reference_kind)
|
||||
.ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-reference",
|
||||
"message": "referenceKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.attach_ai_result_reference(AiResultReferenceInput {
|
||||
task_id,
|
||||
reference_kind,
|
||||
reference_id: payload.reference_id,
|
||||
label: payload.label,
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn complete_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.complete_ai_task(AiTaskFinishInput {
|
||||
task_id,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn fail_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<FailAiTaskRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.fail_ai_task(AiTaskFailureInput {
|
||||
task_id,
|
||||
failure_message: payload.failure_message,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn cancel_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.cancel_ai_task(AiTaskCancelInput {
|
||||
task_id,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_stage_blueprints(
|
||||
task_kind: AiTaskKind,
|
||||
stage_kinds: Vec<String>,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<Vec<AiTaskStageBlueprint>, Response> {
|
||||
if stage_kinds.is_empty() {
|
||||
return Ok(task_kind.default_stage_blueprints());
|
||||
}
|
||||
|
||||
stage_kinds
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, stage_kind_text)| {
|
||||
let stage_kind =
|
||||
parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": format!("stageKinds[{index}] 非法"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(AiTaskStageBlueprint {
|
||||
stage_kind,
|
||||
label: stage_kind.default_label().to_string(),
|
||||
detail: stage_kind.default_detail().to_string(),
|
||||
order: index as u32,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_ai_task_mutation_response(record: AiTaskMutationRecord) -> AiTaskMutationResponse {
|
||||
AiTaskMutationResponse {
|
||||
ai_task: build_ai_task_payload(record.task),
|
||||
ai_text_chunk: record.text_chunk.map(build_ai_text_chunk_payload),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_payload(record: spacetime_client::AiTaskRecord) -> AiTaskPayload {
|
||||
AiTaskPayload {
|
||||
task_id: record.task_id,
|
||||
task_kind: record.task_kind,
|
||||
owner_user_id: record.owner_user_id,
|
||||
request_label: record.request_label,
|
||||
source_module: record.source_module,
|
||||
source_entity_id: record.source_entity_id,
|
||||
request_payload_json: record.request_payload_json,
|
||||
status: record.status,
|
||||
failure_message: record.failure_message,
|
||||
stages: record
|
||||
.stages
|
||||
.into_iter()
|
||||
.map(build_ai_task_stage_payload)
|
||||
.collect(),
|
||||
result_references: record
|
||||
.result_references
|
||||
.into_iter()
|
||||
.map(build_ai_result_reference_payload)
|
||||
.collect(),
|
||||
latest_text_output: record.latest_text_output,
|
||||
latest_structured_payload_json: record.latest_structured_payload_json,
|
||||
version: record.version,
|
||||
created_at: record.created_at,
|
||||
started_at: record.started_at,
|
||||
completed_at: record.completed_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_stage_payload(record: spacetime_client::AiTaskStageRecord) -> AiTaskStagePayload {
|
||||
AiTaskStagePayload {
|
||||
stage_kind: record.stage_kind,
|
||||
label: record.label,
|
||||
detail: record.detail,
|
||||
order: record.order,
|
||||
status: record.status,
|
||||
text_output: record.text_output,
|
||||
structured_payload_json: record.structured_payload_json,
|
||||
warning_messages: record.warning_messages,
|
||||
started_at: record.started_at,
|
||||
completed_at: record.completed_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_result_reference_payload(
|
||||
record: spacetime_client::AiResultReferenceRecord,
|
||||
) -> AiResultReferencePayload {
|
||||
AiResultReferencePayload {
|
||||
result_ref_id: record.result_ref_id,
|
||||
task_id: record.task_id,
|
||||
reference_kind: record.reference_kind,
|
||||
reference_id: record.reference_id,
|
||||
label: record.label,
|
||||
created_at: record.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_text_chunk_payload(record: spacetime_client::AiTextChunkRecord) -> AiTextChunkPayload {
|
||||
AiTextChunkPayload {
|
||||
chunk_id: record.chunk_id,
|
||||
task_id: record.task_id,
|
||||
stage_kind: record.stage_kind,
|
||||
sequence: record.sequence,
|
||||
delta_text: record.delta_text,
|
||||
created_at: record.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ai_task_kind_strict(value: &str) -> Option<AiTaskKind> {
|
||||
match value.trim() {
|
||||
"story_generation" => Some(AiTaskKind::StoryGeneration),
|
||||
"character_chat" => Some(AiTaskKind::CharacterChat),
|
||||
"npc_chat" => Some(AiTaskKind::NpcChat),
|
||||
"custom_world_generation" => Some(AiTaskKind::CustomWorldGeneration),
|
||||
"quest_intent" => Some(AiTaskKind::QuestIntent),
|
||||
"runtime_item_intent" => Some(AiTaskKind::RuntimeItemIntent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ai_task_stage_kind_strict(value: &str) -> Option<AiTaskStageKind> {
|
||||
match value.trim() {
|
||||
"prepare_prompt" => Some(AiTaskStageKind::PreparePrompt),
|
||||
"request_model" => Some(AiTaskStageKind::RequestModel),
|
||||
"repair_response" => Some(AiTaskStageKind::RepairResponse),
|
||||
"normalize_result" => Some(AiTaskStageKind::NormalizeResult),
|
||||
"persist_result" => Some(AiTaskStageKind::PersistResult),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ai_result_reference_kind_strict(value: &str) -> Option<AiResultReferenceKind> {
|
||||
match value.trim() {
|
||||
"story_session" => Some(AiResultReferenceKind::StorySession),
|
||||
"story_event" => Some(AiResultReferenceKind::StoryEvent),
|
||||
"custom_world_profile" => Some(AiResultReferenceKind::CustomWorldProfile),
|
||||
"quest_record" => Some(AiResultReferenceKind::QuestRecord),
|
||||
"runtime_item_record" => Some(AiResultReferenceKind::RuntimeItemRecord),
|
||||
"asset_object" => Some(AiResultReferenceKind::AssetObject),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_ai_task_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn ai_tasks_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn ai_task_accepted_response(
|
||||
request_context: &RequestContext,
|
||||
payload: AiTaskAcceptedResponse,
|
||||
) -> Response {
|
||||
let mut response = json_success_body(Some(request_context), payload).into_response();
|
||||
*response.status_mut() = StatusCode::ACCEPTED;
|
||||
response
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_ai_task_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"taskKind": "story_generation",
|
||||
"requestLabel": "营地开场",
|
||||
"sourceModule": "story"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_ai_task_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"taskKind": "npc_chat",
|
||||
"requestLabel": "试探问话",
|
||||
"sourceModule": "npc"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_ai_task_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks/aitask_001/start")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_ai_task_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks/aitask_001/start")
|
||||
.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!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "ai_tasks_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_ai_tasks".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("AI 任务用户".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")
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,15 @@
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
#[cfg(test)]
|
||||
use serde_json::json;
|
||||
use shared_contracts::api::{
|
||||
API_VERSION, ApiErrorEnvelope, ApiErrorPayload, ApiResponseMeta, ApiSuccessEnvelope,
|
||||
LegacyApiErrorResponse,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{http_error::ApiErrorPayload, request_context::RequestContext};
|
||||
|
||||
pub const API_VERSION: &str = "2026-04-08";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ApiResponseMeta {
|
||||
#[serde(rename = "apiVersion")]
|
||||
api_version: &'static str,
|
||||
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
#[serde(rename = "routeVersion")]
|
||||
route_version: &'static str,
|
||||
operation: Option<String>,
|
||||
#[serde(rename = "latencyMs")]
|
||||
latency_ms: u64,
|
||||
timestamp: String,
|
||||
}
|
||||
use crate::request_context::RequestContext;
|
||||
|
||||
// 当前阶段先把成功响应 envelope helper 准备好,后续 `/healthz` 与业务 handler 会直接复用这里的输出逻辑。
|
||||
#[allow(dead_code)]
|
||||
@@ -30,12 +20,13 @@ where
|
||||
if let Some(context) = request_context
|
||||
&& context.wants_envelope()
|
||||
{
|
||||
return Json(json!({
|
||||
"ok": true,
|
||||
"data": data,
|
||||
"error": null,
|
||||
"meta": build_api_response_meta(Some(context)),
|
||||
}));
|
||||
return Json(
|
||||
serde_json::to_value(ApiSuccessEnvelope::new(
|
||||
data,
|
||||
build_api_response_meta(Some(context)),
|
||||
))
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
|
||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||
@@ -48,33 +39,30 @@ pub fn json_error_body(
|
||||
let meta = build_api_response_meta(request_context);
|
||||
|
||||
if request_context.is_some_and(RequestContext::wants_envelope) {
|
||||
return Json(json!({
|
||||
"ok": false,
|
||||
"data": null,
|
||||
"error": error,
|
||||
"meta": meta,
|
||||
}));
|
||||
return Json(
|
||||
serde_json::to_value(ApiErrorEnvelope::new(error.clone(), meta)).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
|
||||
Json(json!({
|
||||
"error": error,
|
||||
"meta": meta,
|
||||
}))
|
||||
Json(
|
||||
serde_json::to_value(LegacyApiErrorResponse::new(error.clone(), meta))
|
||||
.unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiResponseMeta {
|
||||
ApiResponseMeta {
|
||||
api_version: API_VERSION,
|
||||
request_id: request_context.map(|context| context.request_id().to_string()),
|
||||
route_version: API_VERSION,
|
||||
operation: request_context.map(|context| context.operation().to_string()),
|
||||
latency_ms: request_context
|
||||
ApiResponseMeta::new(
|
||||
API_VERSION,
|
||||
request_context.map(|context| context.request_id().to_string()),
|
||||
API_VERSION,
|
||||
request_context.map(|context| context.operation().to_string()),
|
||||
request_context
|
||||
.map(RequestContext::elapsed)
|
||||
.unwrap_or_default(),
|
||||
timestamp: OffsetDateTime::now_utc()
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -122,7 +110,7 @@ mod tests {
|
||||
fn error_body_returns_legacy_shape_without_envelope_header() {
|
||||
let request_context = build_request_context(false);
|
||||
let error = ApiErrorPayload {
|
||||
code: "NOT_FOUND",
|
||||
code: "NOT_FOUND".to_string(),
|
||||
message: "资源不存在".to_string(),
|
||||
details: None,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
|
||||
use tracing::{Level, info_span};
|
||||
|
||||
use crate::{
|
||||
ai_tasks::{
|
||||
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
|
||||
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
|
||||
},
|
||||
assets::{
|
||||
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
|
||||
create_sts_upload_credentials, get_asset_read_url,
|
||||
@@ -20,8 +24,18 @@ use crate::{
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_sessions::auth_sessions,
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, execute_custom_world_agent_action,
|
||||
get_custom_world_agent_operation, get_custom_world_agent_session,
|
||||
get_custom_world_gallery_detail, get_custom_world_library,
|
||||
get_custom_world_library_detail, list_custom_world_gallery,
|
||||
publish_custom_world_library_profile, put_custom_world_library_profile,
|
||||
stream_custom_world_agent_message, submit_custom_world_agent_message,
|
||||
unpublish_custom_world_library_profile,
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
logout_all::logout_all,
|
||||
@@ -30,7 +44,18 @@ use crate::{
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
runtime_browse_history::{
|
||||
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
|
||||
},
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
runtime_story::resolve_runtime_story_state,
|
||||
state::AppState,
|
||||
story_battles::{
|
||||
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
|
||||
},
|
||||
story_sessions::{begin_story_session, continue_story, get_story_session_state},
|
||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
||||
};
|
||||
|
||||
@@ -95,6 +120,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/llm/chat/completions",
|
||||
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout",
|
||||
post(logout)
|
||||
@@ -114,6 +146,69 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks",
|
||||
post(create_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/start",
|
||||
post(start_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
|
||||
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/chunks",
|
||||
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
|
||||
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/references",
|
||||
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/complete",
|
||||
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/fail",
|
||||
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/cancel",
|
||||
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/direct-upload-tickets",
|
||||
post(create_direct_upload_ticket),
|
||||
@@ -128,6 +223,219 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(bind_asset_object_to_entity),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
.put(put_runtime_settings)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library",
|
||||
get(get_custom_world_library).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library/{profile_id}",
|
||||
get(get_custom_world_library_detail)
|
||||
.put(put_custom_world_library_profile)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library/{profile_id}/publish",
|
||||
post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library/{profile_id}/unpublish",
|
||||
post(unpublish_custom_world_library_profile).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery",
|
||||
get(list_custom_world_gallery),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
|
||||
get(get_custom_world_gallery_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions",
|
||||
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}",
|
||||
get(get_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
|
||||
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream",
|
||||
post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/actions",
|
||||
post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}",
|
||||
get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/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)
|
||||
.post(post_runtime_browse_history)
|
||||
.delete(delete_runtime_browse_history)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
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(
|
||||
state.clone(),
|
||||
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(
|
||||
state.clone(),
|
||||
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/sessions/{runtime_session_id}/inventory",
|
||||
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/state/resolve",
|
||||
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/play-stats",
|
||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions",
|
||||
post(begin_story_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/state",
|
||||
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/continue",
|
||||
post(continue_story).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles",
|
||||
post(create_story_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles/{battle_state_id}",
|
||||
get(get_story_battle_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/npc/battle",
|
||||
post(create_story_npc_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles/resolve",
|
||||
post(resolve_story_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/entry", post(password_entry))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Query, State},
|
||||
@@ -14,8 +12,13 @@ use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPostObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
AssetBindingPayload, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest,
|
||||
BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest,
|
||||
ConfirmAssetObjectResponse, CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse,
|
||||
DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
@@ -23,84 +26,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketRequest {
|
||||
pub legacy_prefix: String,
|
||||
#[serde(default)]
|
||||
pub path_segments: Vec<String>,
|
||||
pub file_name: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<OssObjectAccess>,
|
||||
#[serde(default)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub max_size_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub expire_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub success_action_status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetReadUrlQuery {
|
||||
#[serde(default)]
|
||||
pub object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_public_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expire_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmAssetObjectRequest {
|
||||
#[serde(default)]
|
||||
pub bucket: Option<String>,
|
||||
pub object_key: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub content_hash: Option<String>,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub access_policy: Option<ConfirmAssetObjectAccessPolicy>,
|
||||
#[serde(default)]
|
||||
pub source_job_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entity_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BindAssetObjectRequest {
|
||||
pub asset_object_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfirmAssetObjectAccessPolicy {
|
||||
Private,
|
||||
PublicRead,
|
||||
}
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -141,9 +66,9 @@ pub async fn create_direct_upload_ticket(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"upload": signed,
|
||||
}),
|
||||
CreateDirectUploadTicketResponse {
|
||||
upload: DirectUploadTicketPayload::from(signed),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -180,9 +105,9 @@ pub async fn get_asset_read_url(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"read": signed,
|
||||
}),
|
||||
GetAssetReadUrlResponse {
|
||||
read: AssetReadUrlPayload::from(signed),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -223,25 +148,25 @@ pub async fn confirm_asset_object(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"assetObject": {
|
||||
"assetObjectId": result.asset_object_id,
|
||||
"bucket": result.bucket,
|
||||
"objectKey": result.object_key,
|
||||
"accessPolicy": result.access_policy.as_str(),
|
||||
"contentType": result.content_type,
|
||||
"contentLength": result.content_length,
|
||||
"contentHash": result.content_hash,
|
||||
"version": result.version,
|
||||
"sourceJobId": result.source_job_id,
|
||||
"ownerUserId": result.owner_user_id,
|
||||
"profileId": result.profile_id,
|
||||
"entityId": result.entity_id,
|
||||
"assetKind": result.asset_kind,
|
||||
"createdAt": result.created_at,
|
||||
"updatedAt": result.updated_at,
|
||||
}
|
||||
}),
|
||||
ConfirmAssetObjectResponse {
|
||||
asset_object: AssetObjectPayload {
|
||||
asset_object_id: result.asset_object_id,
|
||||
bucket: result.bucket,
|
||||
object_key: result.object_key,
|
||||
access_policy: result.access_policy.as_str().to_string(),
|
||||
content_type: result.content_type,
|
||||
content_length: result.content_length,
|
||||
content_hash: result.content_hash,
|
||||
version: result.version,
|
||||
source_job_id: result.source_job_id,
|
||||
owner_user_id: result.owner_user_id,
|
||||
profile_id: result.profile_id,
|
||||
entity_id: result.entity_id,
|
||||
asset_kind: result.asset_kind,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -272,20 +197,20 @@ pub async fn bind_asset_object_to_entity(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"assetBinding": {
|
||||
"bindingId": result.binding_id,
|
||||
"assetObjectId": result.asset_object_id,
|
||||
"entityKind": result.entity_kind,
|
||||
"entityId": result.entity_id,
|
||||
"slot": result.slot,
|
||||
"assetKind": result.asset_kind,
|
||||
"ownerUserId": result.owner_user_id,
|
||||
"profileId": result.profile_id,
|
||||
"createdAt": result.created_at,
|
||||
"updatedAt": result.updated_at,
|
||||
}
|
||||
}),
|
||||
BindAssetObjectResponse {
|
||||
asset_binding: AssetBindingPayload {
|
||||
binding_id: result.binding_id,
|
||||
asset_object_id: result.asset_object_id,
|
||||
entity_kind: result.entity_kind,
|
||||
entity_id: result.entity_id,
|
||||
slot: result.slot,
|
||||
asset_kind: result.asset_kind,
|
||||
owner_user_id: result.owner_user_id,
|
||||
profile_id: result.profile_id,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -355,7 +280,7 @@ async fn build_confirm_asset_object_upsert_input(
|
||||
head.object_key,
|
||||
payload
|
||||
.access_policy
|
||||
.map(Into::into)
|
||||
.map(map_confirm_asset_object_access_policy)
|
||||
.unwrap_or(AssetObjectAccessPolicy::Private),
|
||||
head.content_type
|
||||
.or_else(|| normalize_optional_value(payload.content_type)),
|
||||
@@ -439,12 +364,12 @@ impl std::fmt::Display for ConfirmAssetObjectPrepareError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfirmAssetObjectAccessPolicy> for AssetObjectAccessPolicy {
|
||||
fn from(value: ConfirmAssetObjectAccessPolicy) -> Self {
|
||||
match value {
|
||||
ConfirmAssetObjectAccessPolicy::Private => Self::Private,
|
||||
ConfirmAssetObjectAccessPolicy::PublicRead => Self::PublicRead,
|
||||
}
|
||||
fn map_confirm_asset_object_access_policy(
|
||||
value: ConfirmAssetObjectAccessPolicy,
|
||||
) -> AssetObjectAccessPolicy {
|
||||
match value {
|
||||
ConfirmAssetObjectAccessPolicy::Private => AssetObjectAccessPolicy::Private,
|
||||
ConfirmAssetObjectAccessPolicy::PublicRead => AssetObjectAccessPolicy::PublicRead,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +394,8 @@ mod tests {
|
||||
use reqwest::{Method, multipart};
|
||||
use serde_json::{Value, json};
|
||||
use sha1::Sha1;
|
||||
use shared_kernel::new_uuid_simple_string;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
@@ -885,7 +810,7 @@ mod tests {
|
||||
ensure_success_status(bucket_head.status().as_u16(), "bucket HEAD 应成功")?;
|
||||
|
||||
let app = build_router(AppState::new(config.clone()).expect("state should build"));
|
||||
let run_id = Uuid::new_v4().simple().to_string();
|
||||
let run_id = new_uuid_simple_string();
|
||||
let file_name = format!("oss-live-{run_id}.txt");
|
||||
let file_content = format!("Genarrative OSS Rust live test {run_id}");
|
||||
|
||||
@@ -1032,7 +957,7 @@ mod tests {
|
||||
|
||||
let test_result = async {
|
||||
let app = build_router(AppState::new(config.clone()).expect("state should build"));
|
||||
let run_id = Uuid::new_v4().simple().to_string();
|
||||
let run_id = new_uuid_simple_string();
|
||||
let file_content = format!("Genarrative confirm asset object live test {run_id}");
|
||||
|
||||
let ticket_response = app
|
||||
|
||||
@@ -3,32 +3,13 @@ use axum::{
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeResponse {
|
||||
pub user: AuthMeUserPayload,
|
||||
pub available_login_methods: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn auth_me(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -49,27 +30,19 @@ pub async fn auth_me(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthMeResponse {
|
||||
user: AuthMeUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: user.user.id,
|
||||
username: user.user.username,
|
||||
display_name: user.user.display_name,
|
||||
phone_number_masked: user.user.phone_number_masked,
|
||||
login_method: user.user.login_method.as_str(),
|
||||
binding_status: user.user.binding_status.as_str(),
|
||||
login_method: user.user.login_method.as_str().to_string(),
|
||||
binding_status: user.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: user.user.wechat_bound,
|
||||
},
|
||||
available_login_methods: build_available_login_methods(&state),
|
||||
available_login_methods: build_available_login_methods(
|
||||
state.config.sms_auth_enabled,
|
||||
state.config.wechat_auth_enabled,
|
||||
),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn build_available_login_methods(state: &AppState) -> Vec<&'static str> {
|
||||
let mut methods = Vec::new();
|
||||
if state.config.sms_auth_enabled {
|
||||
methods.push("phone");
|
||||
}
|
||||
if state.config.wechat_auth_enabled {
|
||||
methods.push("wechat");
|
||||
}
|
||||
methods
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -16,31 +16,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionsResponse {
|
||||
pub sessions: Vec<AuthSessionSummaryPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionSummaryPayload {
|
||||
pub session_id: String,
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_label: String,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_masked: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at: String,
|
||||
pub last_seen_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
pub async fn auth_sessions(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use std::{env, net::SocketAddr};
|
||||
|
||||
use platform_llm::{
|
||||
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
DEFAULT_RETRY_BACKOFF_MS, LlmProvider,
|
||||
};
|
||||
|
||||
const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715";
|
||||
|
||||
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
@@ -40,6 +47,13 @@ pub struct AppConfig {
|
||||
pub spacetime_server_url: String,
|
||||
pub spacetime_database: String,
|
||||
pub spacetime_token: Option<String>,
|
||||
pub llm_provider: LlmProvider,
|
||||
pub llm_base_url: String,
|
||||
pub llm_api_key: Option<String>,
|
||||
pub llm_model: String,
|
||||
pub llm_request_timeout_ms: u64,
|
||||
pub llm_max_retries: u32,
|
||||
pub llm_retry_backoff_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -83,6 +97,13 @@ impl Default for AppConfig {
|
||||
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
|
||||
spacetime_database: "genarrative-dev".to_string(),
|
||||
spacetime_token: None,
|
||||
llm_provider: LlmProvider::Ark,
|
||||
llm_base_url: DEFAULT_ARK_BASE_URL.to_string(),
|
||||
llm_api_key: None,
|
||||
llm_model: DEFAULT_LLM_MODEL.to_string(),
|
||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +265,46 @@ impl AppConfig {
|
||||
|
||||
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
|
||||
|
||||
if let Some(llm_provider) =
|
||||
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
|
||||
{
|
||||
config.llm_provider = llm_provider;
|
||||
}
|
||||
|
||||
if let Some(llm_base_url) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL"])
|
||||
{
|
||||
config.llm_base_url = llm_base_url;
|
||||
}
|
||||
|
||||
config.llm_api_key =
|
||||
read_first_non_empty_env(&["GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY"]);
|
||||
|
||||
if let Some(llm_model) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_LLM_MODEL", "LLM_MODEL", "VITE_LLM_MODEL"])
|
||||
{
|
||||
config.llm_model = llm_model;
|
||||
}
|
||||
|
||||
if let Some(llm_request_timeout_ms) = read_first_positive_u64_env(&[
|
||||
"GENARRATIVE_LLM_REQUEST_TIMEOUT_MS",
|
||||
"LLM_REQUEST_TIMEOUT_MS",
|
||||
]) {
|
||||
config.llm_request_timeout_ms = llm_request_timeout_ms;
|
||||
}
|
||||
|
||||
if let Some(llm_max_retries) =
|
||||
read_first_u32_env(&["GENARRATIVE_LLM_MAX_RETRIES", "LLM_MAX_RETRIES"])
|
||||
{
|
||||
config.llm_max_retries = llm_max_retries;
|
||||
}
|
||||
|
||||
if let Some(llm_retry_backoff_ms) =
|
||||
read_first_u64_env(&["GENARRATIVE_LLM_RETRY_BACKOFF_MS", "LLM_RETRY_BACKOFF_MS"])
|
||||
{
|
||||
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
@@ -281,6 +342,14 @@ fn read_first_bool_env(keys: &[&str]) -> Option<bool> {
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value)))
|
||||
}
|
||||
|
||||
fn read_first_llm_provider_env(keys: &[&str]) -> Option<LlmProvider> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_llm_provider(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -297,6 +366,16 @@ fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
keys.iter()
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_u32(&value)))
|
||||
}
|
||||
|
||||
fn read_first_u64_env(keys: &[&str]) -> Option<u64> {
|
||||
keys.iter()
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
|
||||
}
|
||||
|
||||
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -338,6 +417,15 @@ fn parse_bool(raw: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_llm_provider(raw: &str) -> Option<LlmProvider> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"ark" => Some(LlmProvider::Ark),
|
||||
"dash_scope" | "dashscope" => Some(LlmProvider::DashScope),
|
||||
"openai_compatible" | "openai-compatible" | "openai" => Some(LlmProvider::OpenAiCompatible),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
let value = raw.trim().parse::<u32>().ok()?;
|
||||
if value == 0 {
|
||||
@@ -347,6 +435,10 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_u32(raw: &str) -> Option<u32> {
|
||||
raw.trim().parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn parse_positive_u64(raw: &str) -> Option<u64> {
|
||||
let value = raw.trim().parse::<u64>().ok()?;
|
||||
if value == 0 {
|
||||
@@ -356,6 +448,10 @@ fn parse_positive_u64(raw: &str) -> Option<u64> {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_u64(raw: &str) -> Option<u64> {
|
||||
raw.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
let value = raw.trim().parse::<u16>().ok()?;
|
||||
if value == 0 {
|
||||
|
||||
1015
server-rs/crates/api-server/src/custom_world.rs
Normal file
1015
server-rs/crates/api-server/src/custom_world.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@ use axum::{
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::api::ApiErrorPayload;
|
||||
|
||||
use crate::{api_response::json_error_body, request_context::RequestContext};
|
||||
|
||||
@@ -17,14 +17,6 @@ pub struct AppError {
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ApiErrorPayload {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn from_status(status_code: StatusCode) -> Self {
|
||||
let (code, message) = resolve_http_error(status_code);
|
||||
@@ -71,11 +63,7 @@ impl AppError {
|
||||
}
|
||||
|
||||
fn to_payload(&self) -> ApiErrorPayload {
|
||||
ApiErrorPayload {
|
||||
code: self.code,
|
||||
message: self.message.clone(),
|
||||
details: self.details.clone(),
|
||||
}
|
||||
ApiErrorPayload::new(self.code, self.message.clone(), self.details.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +79,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"),
|
||||
StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"),
|
||||
StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"),
|
||||
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
|
||||
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
|
||||
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
|
||||
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
|
||||
|
||||
376
server-rs/crates/api-server/src/llm.rs
Normal file
376
server-rs/crates/api-server/src/llm.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::llm::{
|
||||
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn proxy_llm_chat_completions(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<LlmChatCompletionRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
if payload.stream {
|
||||
return Err(llm_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::NOT_IMPLEMENTED)
|
||||
.with_message("Rust `api-server` 首版暂不支持流式 LLM 代理"),
|
||||
));
|
||||
}
|
||||
|
||||
let llm_client = state.llm_client().ok_or_else(|| {
|
||||
llm_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("服务端尚未配置可用的 LLM API Key"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let request = LlmTextRequest {
|
||||
model: payload.model,
|
||||
messages: payload
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(map_chat_message)
|
||||
.collect::<Vec<_>>(),
|
||||
max_tokens: None,
|
||||
};
|
||||
|
||||
let response = llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.map_err(|error| llm_error_response(&request_context, map_llm_error(error)))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
LlmChatCompletionResponse {
|
||||
id: response.response_id,
|
||||
model: response.model,
|
||||
content: response.content,
|
||||
finish_reason: response.finish_reason,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
|
||||
let role = match message.role {
|
||||
LlmChatMessageRole::System => LlmMessageRole::System,
|
||||
LlmChatMessageRole::User => LlmMessageRole::User,
|
||||
LlmChatMessageRole::Assistant => LlmMessageRole::Assistant,
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
time::Duration as StdDuration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
struct MockResponse {
|
||||
status_line: &'static str,
|
||||
content_type: &'static str,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_chat_completions_returns_non_stream_text_payload() {
|
||||
let server_url = spawn_mock_server(vec![MockResponse {
|
||||
status_line: "200 OK",
|
||||
content_type: "application/json; charset=utf-8",
|
||||
body: r#"{"id":"resp_api_server_01","model":"ark-router-test","choices":[{"message":{"content":"代理成功"},"finish_reason":"stop"}]}"#.to_string(),
|
||||
}]);
|
||||
let state = seed_authenticated_state(AppConfig {
|
||||
llm_base_url: server_url,
|
||||
llm_api_key: Some("test-key".to_string()),
|
||||
..AppConfig::default()
|
||||
})
|
||||
.await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/llm/chat/completions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"messages": [
|
||||
{ "role": "system", "content": "系统" },
|
||||
{ "role": "user", "content": "用户" }
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
payload["data"]["id"],
|
||||
Value::String("resp_api_server_01".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["model"],
|
||||
Value::String("ark-router-test".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["content"],
|
||||
Value::String("代理成功".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["finishReason"],
|
||||
Value::String("stop".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_chat_completions_rejects_stream_mode() {
|
||||
let state = seed_authenticated_state(AppConfig::default()).await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/llm/chat/completions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{ "role": "user", "content": "用户" }
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["code"],
|
||||
Value::String("NOT_IMPLEMENTED".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state(config: AppConfig) -> AppState {
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "llm_proxy_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_llm_proxy".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("LLM 代理用户".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")
|
||||
}
|
||||
|
||||
fn spawn_mock_server(responses: Vec<MockResponse>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
let address = listener.local_addr().expect("listener should have addr");
|
||||
|
||||
thread::spawn(move || {
|
||||
for response in responses {
|
||||
let (mut stream, _) = listener.accept().expect("request should connect");
|
||||
read_request(&mut stream);
|
||||
write_response(&mut stream, response);
|
||||
}
|
||||
});
|
||||
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut std::net::TcpStream) {
|
||||
stream
|
||||
.set_read_timeout(Some(StdDuration::from_secs(1)))
|
||||
.expect("read timeout should be set");
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let mut expected_total = None;
|
||||
|
||||
loop {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(bytes_read) => {
|
||||
buffer.extend_from_slice(&chunk[..bytes_read]);
|
||||
|
||||
if expected_total.is_none()
|
||||
&& let Some(header_end) = find_header_end(&buffer)
|
||||
{
|
||||
let content_length =
|
||||
read_content_length(&buffer[..header_end]).unwrap_or(0);
|
||||
expected_total = Some(header_end + content_length);
|
||||
}
|
||||
|
||||
if let Some(total_bytes) = expected_total
|
||||
&& buffer.len() >= total_bytes
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| error.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(error) => panic!("mock server failed to read request: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {
|
||||
let body = response.body;
|
||||
let raw_response = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response.status_line,
|
||||
response.content_type,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
stream
|
||||
.write_all(raw_response.as_bytes())
|
||||
.expect("mock response should be written");
|
||||
stream.flush().expect("mock response should flush");
|
||||
}
|
||||
|
||||
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
}
|
||||
|
||||
fn read_content_length(headers: &[u8]) -> Option<usize> {
|
||||
let text = String::from_utf8_lossy(headers);
|
||||
text.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
return value.trim().parse::<usize>().ok();
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,21 @@ use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::{AuthLoginOptionsResponse, build_available_login_methods};
|
||||
|
||||
use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthLoginOptionsResponse {
|
||||
pub available_login_methods: Vec<&'static str>,
|
||||
}
|
||||
|
||||
pub async fn auth_login_options(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut methods = Vec::new();
|
||||
if state.config.sms_auth_enabled {
|
||||
methods.push("phone");
|
||||
}
|
||||
if state.config.wechat_auth_enabled {
|
||||
methods.push("wechat");
|
||||
}
|
||||
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
AuthLoginOptionsResponse {
|
||||
available_login_methods: methods,
|
||||
available_login_methods: build_available_login_methods(
|
||||
state.config.sms_auth_enabled,
|
||||
state.config.wechat_auth_enabled,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
use module_auth::LogoutCurrentSessionInput;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::LogoutResponse;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -19,11 +19,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::LogoutAllSessionsInput;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::LogoutAllResponse;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -18,11 +18,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutAllResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn logout_all(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod ai_tasks;
|
||||
mod api_response;
|
||||
mod app;
|
||||
mod assets;
|
||||
@@ -6,9 +7,11 @@ mod auth_me;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod config;
|
||||
mod custom_world;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod llm;
|
||||
mod login_options;
|
||||
mod logout;
|
||||
mod logout_all;
|
||||
@@ -17,8 +20,15 @@ mod phone_auth;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod runtime_browse_history;
|
||||
mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_settings;
|
||||
mod runtime_story;
|
||||
mod session_client;
|
||||
mod state;
|
||||
mod story_battles;
|
||||
mod story_sessions;
|
||||
mod wechat_auth;
|
||||
mod wechat_provider;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::{PasswordEntryError, PasswordEntryInput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
@@ -19,32 +19,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn password_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -74,13 +48,13 @@ pub async fn password_entry(
|
||||
Some(&request_context),
|
||||
PasswordEntryResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
login_method: result.user.login_method.as_str().to_string(),
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,8 +7,11 @@ use axum::{
|
||||
use module_auth::{
|
||||
AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use shared_contracts::auth::{
|
||||
AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
|
||||
PhoneSendCodeResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -17,42 +20,11 @@ use crate::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
},
|
||||
http_error::AppError,
|
||||
password_entry::PasswordEntryUserPayload,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeRequest {
|
||||
pub phone: String,
|
||||
pub scene: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeResponse {
|
||||
pub ok: bool,
|
||||
pub cooldown_seconds: u64,
|
||||
pub expires_in_seconds: u64,
|
||||
pub provider_request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
pub async fn send_phone_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -130,13 +102,13 @@ pub async fn phone_login(
|
||||
Some(&request_context),
|
||||
PhoneLoginResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
login_method: result.user.login_method.as_str().to_string(),
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::RefreshSessionResponse;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -20,12 +20,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshSessionResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn refresh_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -6,10 +6,10 @@ use axum::{
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
|
||||
pub const X_REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
pub use shared_contracts::api::X_REQUEST_ID_HEADER;
|
||||
|
||||
// 当前阶段先把请求级元信息统一挂到 extensions,后续响应头、envelope 与错误处理中间件继续复用。
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -4,15 +4,11 @@ use axum::{
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::API_VERSION,
|
||||
request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id},
|
||||
use shared_contracts::api::{
|
||||
API_VERSION, API_VERSION_HEADER, RESPONSE_TIME_HEADER, ROUTE_VERSION_HEADER,
|
||||
};
|
||||
|
||||
pub const API_VERSION_HEADER: &str = "x-api-version";
|
||||
pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms";
|
||||
pub const ROUTE_VERSION_HEADER: &str = "x-route-version";
|
||||
use crate::request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id};
|
||||
|
||||
pub async fn propagate_request_id_header(request: Request, next: Next) -> Response {
|
||||
let request_id = resolve_request_id(&request);
|
||||
|
||||
454
server-rs/crates/api-server/src/runtime_browse_history.rs
Normal file
454
server-rs/crates/api-server/src/runtime_browse_history.rs
Normal file
@@ -0,0 +1,454 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{MAX_BROWSE_HISTORY_BATCH_SIZE, RuntimeBrowseHistoryWriteInput};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
BROWSE_HISTORY_THEME_MODE_ARCANE, BROWSE_HISTORY_THEME_MODE_MACHINA,
|
||||
BROWSE_HISTORY_THEME_MODE_MARTIAL, BROWSE_HISTORY_THEME_MODE_MYTHIC,
|
||||
BROWSE_HISTORY_THEME_MODE_RIFT, BROWSE_HISTORY_THEME_MODE_TIDE,
|
||||
PlatformBrowseHistoryEntryResponse, PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryUpsertRequest, PlatformBrowseHistoryWriteEntryRequest,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_runtime_browse_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.list_platform_browse_history(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
map_runtime_browse_history_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PlatformBrowseHistoryResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(map_browse_history_entry_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn post_runtime_browse_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<PlatformBrowseHistoryUpsertRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "browse-history",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let now_micros = current_utc_micros();
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let request_entries = payload.into_entries();
|
||||
validate_browse_history_request_entries(&request_context, &request_entries)?;
|
||||
let entries = request_entries
|
||||
.into_iter()
|
||||
.map(|entry| RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle,
|
||||
summary_text: entry.summary_text,
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: entry.theme_mode,
|
||||
author_display_name: entry.author_display_name,
|
||||
visited_at: entry.visited_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.upsert_platform_browse_history_entries(user_id, entries, now_micros)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
map_runtime_browse_history_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PlatformBrowseHistoryResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(map_browse_history_entry_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_runtime_browse_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.clear_platform_browse_history(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
map_runtime_browse_history_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PlatformBrowseHistoryResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(map_browse_history_entry_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_browse_history_entry_response(
|
||||
entry: module_runtime::RuntimeBrowseHistoryRecord,
|
||||
) -> PlatformBrowseHistoryEntryResponse {
|
||||
PlatformBrowseHistoryEntryResponse {
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle,
|
||||
summary_text: entry.summary_text,
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: map_browse_history_theme_mode(entry.theme_mode).to_string(),
|
||||
author_display_name: entry.author_display_name,
|
||||
visited_at: entry.visited_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_browse_history_theme_mode(
|
||||
value: module_runtime::RuntimeBrowseHistoryThemeMode,
|
||||
) -> &'static str {
|
||||
match value {
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Martial => BROWSE_HISTORY_THEME_MODE_MARTIAL,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Arcane => BROWSE_HISTORY_THEME_MODE_ARCANE,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Machina => BROWSE_HISTORY_THEME_MODE_MACHINA,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Tide => BROWSE_HISTORY_THEME_MODE_TIDE,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Rift => BROWSE_HISTORY_THEME_MODE_RIFT,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Mythic => BROWSE_HISTORY_THEME_MODE_MYTHIC,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_runtime_browse_history_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
// 这类错误发生在 Rust 本地 DTO 构建阶段,语义上属于请求不合法,而不是下游不可用。
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "browse-history"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_browse_history_error_response(
|
||||
request_context: &RequestContext,
|
||||
error: AppError,
|
||||
) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn validate_browse_history_request_entries(
|
||||
request_context: &RequestContext,
|
||||
entries: &[PlatformBrowseHistoryWriteEntryRequest],
|
||||
) -> Result<(), Response> {
|
||||
if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request(format!(
|
||||
"entries 单次最多只允许 {} 条",
|
||||
MAX_BROWSE_HISTORY_BATCH_SIZE
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
if entry.owner_user_id.trim().is_empty() {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request("ownerUserId 不能为空"),
|
||||
));
|
||||
}
|
||||
if entry.profile_id.trim().is_empty() {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request("profileId 不能为空"),
|
||||
));
|
||||
}
|
||||
if entry.world_name.trim().is_empty() {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request("worldName 不能为空"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn browse_history_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "browse-history",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/browse-history")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_rejects_blank_required_fields() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/profile/browse-history")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"ownerUserId": " ",
|
||||
"profileId": "profile-1",
|
||||
"worldName": "世界A"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("browse-history".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway()
|
||||
{
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/profile/browse-history")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"entries": [{
|
||||
"ownerUserId": "owner-1",
|
||||
"profileId": "profile-1",
|
||||
"worldName": "世界A"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "browse_history_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_browse_history".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
196
server-rs/crates/api-server/src/runtime_inventory.rs
Normal file
196
server-rs/crates/api-server/src/runtime_inventory.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{RuntimeInventorySlotResponse, RuntimeInventoryStateResponse};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_runtime_inventory_state(
|
||||
State(state): State<AppState>,
|
||||
Path(runtime_session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.get_runtime_inventory_state(runtime_session_id, actor_user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_inventory_error_response(
|
||||
&request_context,
|
||||
map_runtime_inventory_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeInventoryStateResponse {
|
||||
runtime_session_id: record.runtime_session_id,
|
||||
actor_user_id: record.actor_user_id,
|
||||
backpack_items: record
|
||||
.backpack_items
|
||||
.into_iter()
|
||||
.map(map_runtime_inventory_slot_response)
|
||||
.collect(),
|
||||
equipment_items: record
|
||||
.equipment_items
|
||||
.into_iter()
|
||||
.map(map_runtime_inventory_slot_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_runtime_inventory_slot_response(
|
||||
record: module_inventory::RuntimeInventorySlotRecord,
|
||||
) -> RuntimeInventorySlotResponse {
|
||||
RuntimeInventorySlotResponse {
|
||||
slot_id: record.slot_id,
|
||||
container_kind: record.container_kind,
|
||||
slot_key: record.slot_key,
|
||||
item_id: record.item_id,
|
||||
category: record.category,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
quantity: record.quantity,
|
||||
rarity: record.rarity,
|
||||
tags: record.tags,
|
||||
stackable: record.stackable,
|
||||
stack_key: record.stack_key,
|
||||
equipment_slot_id: record.equipment_slot_id,
|
||||
source_kind: record.source_kind,
|
||||
source_reference_id: record.source_reference_id,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_runtime_inventory_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-inventory"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_inventory_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
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};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_inventory_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/sessions/runtime_001/inventory")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_inventory_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/sessions/runtime_001/inventory")
|
||||
.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!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_inventory_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_inventory".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
332
server-rs/crates/api-server/src/runtime_profile.rs
Normal file
332
server-rs/crates/api-server/src/runtime_profile.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileWalletLedgerEntryResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.get_profile_dashboard(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ProfileDashboardSummaryResponse {
|
||||
wallet_balance: record.wallet_balance,
|
||||
total_play_time_ms: record.total_play_time_ms,
|
||||
played_world_count: record.played_world_count,
|
||||
updated_at: record.updated_at,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_profile_wallet_ledger(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.list_profile_wallet_ledger(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ProfileWalletLedgerResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|entry| ProfileWalletLedgerEntryResponse {
|
||||
id: entry.wallet_ledger_id,
|
||||
amount_delta: entry.amount_delta,
|
||||
balance_after: entry.balance_after,
|
||||
source_type: match entry.source_type {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string()
|
||||
}
|
||||
},
|
||||
created_at: entry.created_at,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_profile_play_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.get_profile_play_stats(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ProfilePlayStatsResponse {
|
||||
total_play_time_ms: record.total_play_time_ms,
|
||||
played_works: record
|
||||
.played_works
|
||||
.into_iter()
|
||||
.map(|entry| ProfilePlayedWorkSummaryResponse {
|
||||
world_key: entry.world_key,
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_type: entry.world_type,
|
||||
world_title: entry.world_title,
|
||||
world_subtitle: entry.world_subtitle,
|
||||
first_played_at: entry.first_played_at,
|
||||
last_played_at: entry.last_played_at,
|
||||
last_observed_play_time_ms: entry.last_observed_play_time_ms,
|
||||
})
|
||||
.collect(),
|
||||
updated_at: record.updated_at,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
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};
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_dashboard_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/dashboard")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_wallet_ledger_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/wallet-ledger")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_play_stats_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/play-stats")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
|
||||
assert_compat_route_matches_main_route_error_shape(
|
||||
"/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/play-stats",
|
||||
"/api/profile/play-stats",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
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
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_profile_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
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: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
372
server-rs/crates/api-server/src/runtime_settings.rs
Normal file
372
server-rs/crates/api-server/src/runtime_settings.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{
|
||||
RuntimePlatformTheme, RuntimeSettingsFieldError, build_runtime_setting_upsert_input,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
PutRuntimeSettingsRequest, RUNTIME_PLATFORM_THEME_DARK, RUNTIME_PLATFORM_THEME_LIGHT,
|
||||
RuntimeSettingsResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_runtime_settings(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let settings = state
|
||||
.spacetime_client()
|
||||
.get_runtime_settings(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
map_runtime_settings_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeSettingsResponse {
|
||||
music_volume: settings.music_volume,
|
||||
platform_theme: settings.platform_theme.as_str().to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn put_runtime_settings(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<PutRuntimeSettingsRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let theme = parse_platform_theme_strict(&payload.platform_theme).ok_or_else(|| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": "platformTheme 仅支持 light 或 dark",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if !(0.0..=1.0).contains(&payload.music_volume) {
|
||||
return Err(runtime_settings_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": "musicVolume 必须在 0 到 1 之间",
|
||||
})),
|
||||
));
|
||||
}
|
||||
let now_micros = current_utc_micros();
|
||||
let prepared =
|
||||
build_runtime_setting_upsert_input(user_id, payload.music_volume, theme, now_micros)
|
||||
.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
map_runtime_settings_prepare_error(error),
|
||||
)
|
||||
})?;
|
||||
let settings = state
|
||||
.spacetime_client()
|
||||
.put_runtime_settings(
|
||||
prepared.user_id,
|
||||
prepared.music_volume,
|
||||
prepared.platform_theme,
|
||||
prepared.updated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
map_runtime_settings_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeSettingsResponse {
|
||||
music_volume: settings.music_volume,
|
||||
platform_theme: settings.platform_theme.as_str().to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_runtime_settings_prepare_error(error: RuntimeSettingsFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_runtime_settings_client_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_settings_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn parse_platform_theme_strict(raw: &str) -> Option<RuntimePlatformTheme> {
|
||||
match raw.trim() {
|
||||
RUNTIME_PLATFORM_THEME_LIGHT => Some(RuntimePlatformTheme::Light),
|
||||
RUNTIME_PLATFORM_THEME_DARK => Some(RuntimePlatformTheme::Dark),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/settings")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/settings")
|
||||
.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!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_rejects_invalid_theme_with_envelope() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/settings")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"musicVolume": 0.42,
|
||||
"platformTheme": "mythic"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("runtime-settings".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module;验证 PUT/GET settings 主链"]
|
||||
async fn runtime_settings_round_trip_against_local_spacetimedb() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let put_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/settings")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"musicVolume": 1.4,
|
||||
"platformTheme": "dark"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(put_response.status(), StatusCode::OK);
|
||||
let put_body = put_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let put_payload: Value =
|
||||
serde_json::from_slice(&put_body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
put_payload["data"]["platformTheme"],
|
||||
Value::String("dark".to_string())
|
||||
);
|
||||
assert_eq!(put_payload["data"]["musicVolume"], json!(1.0));
|
||||
|
||||
let get_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/settings")
|
||||
.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!(get_response.status(), StatusCode::OK);
|
||||
let get_body = get_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let get_payload: Value =
|
||||
serde_json::from_slice(&get_body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
get_payload["data"]["platformTheme"],
|
||||
Value::String("dark".to_string())
|
||||
);
|
||||
assert_eq!(get_payload["data"]["musicVolume"], json!(1.0));
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_settings_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_settings".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
593
server-rs/crates/api-server/src/runtime_story.rs
Normal file
593
server-rs/crates/api-server/src/runtime_story.rs
Normal file
@@ -0,0 +1,593 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel,
|
||||
RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
|
||||
RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
||||
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn resolve_runtime_story_state(
|
||||
State(_state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryStateResolveRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let snapshot = payload.snapshot.ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "snapshot",
|
||||
"message": "当前首版兼容状态桥要求随请求提交 snapshot",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(
|
||||
&session_id,
|
||||
payload.client_version,
|
||||
snapshot,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_runtime_story_state_response(
|
||||
requested_session_id: &str,
|
||||
client_version: Option<u32>,
|
||||
snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
let session_id = read_runtime_session_id(&snapshot.game_state)
|
||||
.unwrap_or_else(|| requested_session_id.to_string());
|
||||
let options = build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
|
||||
let story_text =
|
||||
read_story_text(snapshot.current_story.as_ref()).unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
|
||||
let server_version =
|
||||
read_u32_field(&snapshot.game_state, "runtimeActionVersion").or(client_version).unwrap_or(0);
|
||||
|
||||
RuntimeStoryActionResponse {
|
||||
session_id,
|
||||
server_version,
|
||||
view_model: RuntimeStoryViewModel {
|
||||
player: RuntimeStoryPlayerViewModel {
|
||||
hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0),
|
||||
max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1),
|
||||
mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0),
|
||||
max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1),
|
||||
},
|
||||
encounter: build_runtime_story_encounter(&snapshot.game_state),
|
||||
companions: build_runtime_story_companions(&snapshot.game_state),
|
||||
available_options: options.clone(),
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(
|
||||
&snapshot.game_state,
|
||||
"currentNpcBattleMode",
|
||||
),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
&snapshot.game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
},
|
||||
},
|
||||
presentation: RuntimeStoryPresentation {
|
||||
action_text: String::new(),
|
||||
result_text: String::new(),
|
||||
story_text,
|
||||
options,
|
||||
toast: None,
|
||||
battle: None,
|
||||
},
|
||||
patches: Vec::new(),
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
|
||||
read_array_field(game_state, "companions")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let npc_id = read_required_string_field(entry, "npcId")?;
|
||||
Some(RuntimeStoryCompanionViewModel {
|
||||
npc_id,
|
||||
character_id: read_optional_string_field(entry, "characterId"),
|
||||
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
let npc_name = read_required_string_field(encounter, "npcName")?;
|
||||
let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
|
||||
|
||||
Some(RuntimeStoryEncounterViewModel {
|
||||
id: encounter_id,
|
||||
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
|
||||
npc_name,
|
||||
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
|
||||
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
|
||||
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
|
||||
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
|
||||
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_current_encounter_npc_state<'a>(
|
||||
game_state: &'a Value,
|
||||
encounter_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Option<&'a Value> {
|
||||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||||
|
||||
npc_states
|
||||
.get(encounter_id)
|
||||
.or_else(|| npc_states.get(npc_name))
|
||||
}
|
||||
|
||||
fn build_runtime_story_options(
|
||||
current_story: Option<&Value>,
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if let Some(story) = current_story {
|
||||
let prefers_deferred = read_required_string_field(story, "displayMode")
|
||||
.is_some_and(|value| value == "dialogue")
|
||||
&& !read_array_field(story, "deferredOptions").is_empty();
|
||||
|
||||
let source = if prefers_deferred {
|
||||
read_array_field(story, "deferredOptions")
|
||||
} else {
|
||||
read_array_field(story, "options")
|
||||
};
|
||||
|
||||
let compiled = source
|
||||
.into_iter()
|
||||
.filter_map(build_runtime_story_option_from_story_option)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !compiled.is_empty() {
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_runtime_story_options(game_state)
|
||||
}
|
||||
|
||||
fn build_runtime_story_option_from_story_option(value: &Value) -> Option<RuntimeStoryOptionView> {
|
||||
let function_id = read_required_string_field(value, "functionId")?;
|
||||
let action_text = read_required_string_field(value, "actionText")
|
||||
.or_else(|| read_required_string_field(value, "text"))
|
||||
.unwrap_or_else(|| function_id.clone());
|
||||
|
||||
Some(RuntimeStoryOptionView {
|
||||
scope: infer_option_scope(function_id.as_str()).to_string(),
|
||||
detail_text: read_optional_string_field(value, "detailText"),
|
||||
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
|
||||
payload: read_field(value, "runtimePayload").cloned(),
|
||||
disabled: read_bool_field(value, "disabled"),
|
||||
reason: read_optional_string_field(value, "disabledReason")
|
||||
.or_else(|| read_optional_string_field(value, "reason")),
|
||||
function_id,
|
||||
action_text,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_runtime_story_option_interaction(
|
||||
value: Option<&Value>,
|
||||
) -> Option<RuntimeStoryOptionInteraction> {
|
||||
let interaction = value?;
|
||||
match read_required_string_field(interaction, "kind")?.as_str() {
|
||||
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: read_required_string_field(interaction, "npcId")?,
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
quest_id: read_optional_string_field(interaction, "questId"),
|
||||
}),
|
||||
"treasure" => Some(RuntimeStoryOptionInteraction::Treasure {
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return vec![
|
||||
build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"),
|
||||
build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"),
|
||||
build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"),
|
||||
];
|
||||
}
|
||||
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
if let Some(encounter) = encounter {
|
||||
match read_required_string_field(encounter, "kind").as_deref() {
|
||||
Some("npc") => {
|
||||
let interaction_active =
|
||||
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
|
||||
if interaction_active {
|
||||
return vec![
|
||||
build_static_runtime_story_option("npc_chat", "继续交谈", "npc"),
|
||||
build_static_runtime_story_option("npc_help", "请求援手", "npc"),
|
||||
build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"),
|
||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
||||
];
|
||||
}
|
||||
|
||||
return vec![
|
||||
build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"),
|
||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
||||
];
|
||||
}
|
||||
Some("treasure") => {
|
||||
return vec![
|
||||
build_static_runtime_story_option("treasure_secure", "直接收取", "story"),
|
||||
build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"),
|
||||
build_static_runtime_story_option("treasure_leave", "先记下位置", "story"),
|
||||
];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
|
||||
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
|
||||
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
|
||||
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
|
||||
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
|
||||
build_static_runtime_story_option("story_continue_adventure", "继续推进冒险", "story"),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_static_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
detail_text: None,
|
||||
scope: scope.to_string(),
|
||||
interaction: None,
|
||||
payload: None,
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_option_scope(function_id: &str) -> &'static str {
|
||||
if function_id.starts_with("battle_") || function_id == "inventory_use" {
|
||||
"combat"
|
||||
} else if function_id.starts_with("npc_") {
|
||||
"npc"
|
||||
} else {
|
||||
"story"
|
||||
}
|
||||
}
|
||||
|
||||
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||||
}
|
||||
|
||||
fn build_fallback_story_text(game_state: &Value) -> String {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
let encounter_name = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
|
||||
.unwrap_or_else(|| "眼前的敌人".to_string());
|
||||
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
|
||||
}
|
||||
|
||||
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
|
||||
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
|
||||
{
|
||||
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
|
||||
}
|
||||
|
||||
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
|
||||
}
|
||||
|
||||
fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "runtimeSessionId")
|
||||
}
|
||||
|
||||
fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
value.as_object()?.get(key)
|
||||
}
|
||||
|
||||
fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
let field = read_field(value, key)?;
|
||||
field.is_object().then_some(field)
|
||||
}
|
||||
|
||||
fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_required_string(read_field(value, key)?.as_str()?)
|
||||
}
|
||||
|
||||
fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||
read_field(value, key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
}
|
||||
|
||||
fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|number| u32::try_from(number).ok())
|
||||
}
|
||||
|
||||
fn normalize_required_string(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_required_string)
|
||||
}
|
||||
|
||||
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"snapshot": {
|
||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main"
|
||||
},
|
||||
"currentStory": null
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_rejects_missing_snapshot() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_returns_compiled_snapshot_response() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"clientVersion": 7,
|
||||
"snapshot": {
|
||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main",
|
||||
"runtimeActionVersion": 7,
|
||||
"playerHp": 32,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 18,
|
||||
"playerMaxMana": 20,
|
||||
"inBattle": false,
|
||||
"npcInteractionActive": true,
|
||||
"currentEncounter": {
|
||||
"id": "npc_camp_firekeeper",
|
||||
"kind": "npc",
|
||||
"npcName": "守火人",
|
||||
"hostile": false
|
||||
},
|
||||
"npcStates": {
|
||||
"npc_camp_firekeeper": {
|
||||
"affinity": 12,
|
||||
"recruited": false
|
||||
}
|
||||
},
|
||||
"companions": [{
|
||||
"npcId": "npc_companion_001",
|
||||
"characterId": "char_companion_001",
|
||||
"joinedAtAffinity": 64
|
||||
}]
|
||||
},
|
||||
"currentStory": {
|
||||
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。",
|
||||
"displayMode": "dialogue",
|
||||
"options": [{
|
||||
"functionId": "story_continue_adventure",
|
||||
"actionText": "继续冒险"
|
||||
}],
|
||||
"deferredOptions": [{
|
||||
"functionId": "npc_chat",
|
||||
"actionText": "继续交谈",
|
||||
"detailText": "围绕当前话题继续推进关系判断。",
|
||||
"interaction": {
|
||||
"kind": "npc",
|
||||
"npcId": "npc_camp_firekeeper",
|
||||
"action": "chat"
|
||||
},
|
||||
"runtimePayload": {
|
||||
"note": "server-runtime-test"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["data"]["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["data"]["serverVersion"], json!(7));
|
||||
assert_eq!(
|
||||
payload["data"]["viewModel"]["encounter"]["npcName"],
|
||||
json!("守火人")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["viewModel"]["availableOptions"][0]["functionId"],
|
||||
json!("npc_chat")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["presentation"]["options"][0]["interaction"]["npcId"],
|
||||
json!("npc_camp_firekeeper")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"],
|
||||
json!("npc_chat")
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_story_state_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_story_state".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::http::HeaderMap;
|
||||
use module_auth::RefreshSessionClientInfo;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use shared_kernel::normalize_optional_string;
|
||||
|
||||
const X_CLIENT_TYPE_HEADER: &str = "x-client-type";
|
||||
const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime";
|
||||
@@ -104,17 +105,6 @@ fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_client_type(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
@@ -7,6 +8,7 @@ use module_auth::{
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
|
||||
|
||||
@@ -29,7 +31,10 @@ pub struct AppState {
|
||||
wechat_auth_state_service: WechatAuthStateService,
|
||||
wechat_auth_service: WechatAuthService,
|
||||
wechat_provider: WechatProvider,
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
llm_client: Option<LlmClient>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -37,6 +42,7 @@ pub enum AppStateInitError {
|
||||
Jwt(JwtError),
|
||||
RefreshCookie(RefreshCookieError),
|
||||
Oss(OssError),
|
||||
Llm(LlmError),
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -68,11 +74,14 @@ impl AppState {
|
||||
let wechat_provider = build_wechat_provider(&config);
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
||||
// AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。
|
||||
let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default());
|
||||
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
|
||||
server_url: config.spacetime_server_url.clone(),
|
||||
database: config.spacetime_database.clone(),
|
||||
token: config.spacetime_token.clone(),
|
||||
});
|
||||
let llm_client = build_llm_client(&config)?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
@@ -86,7 +95,9 @@ impl AppState {
|
||||
wechat_auth_state_service,
|
||||
wechat_auth_service,
|
||||
wechat_provider,
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
llm_client,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,9 +141,18 @@ impl AppState {
|
||||
&self.wechat_provider
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn ai_task_service(&self) -> &AiTaskService {
|
||||
&self.ai_task_service
|
||||
}
|
||||
|
||||
pub fn spacetime_client(&self) -> &SpacetimeClient {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
@@ -141,6 +161,7 @@ impl fmt::Display for AppStateInitError {
|
||||
Self::Jwt(error) => write!(f, "{error}"),
|
||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||
Self::Oss(error) => write!(f, "{error}"),
|
||||
Self::Llm(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +186,12 @@ impl From<OssError> for AppStateInitError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LlmError> for AppStateInitError {
|
||||
fn from(value: LlmError) -> Self {
|
||||
Self::Llm(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
|
||||
let has_any_oss_field = config.oss_bucket.is_some()
|
||||
|| config.oss_endpoint.is_some()
|
||||
@@ -188,3 +215,65 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
|
||||
|
||||
Ok(Some(OssClient::new(oss_config)))
|
||||
}
|
||||
|
||||
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> {
|
||||
let Some(api_key) = config
|
||||
.llm_api_key
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let llm_config = LlmConfig::new(
|
||||
config.llm_provider,
|
||||
config.llm_base_url.clone(),
|
||||
api_key.to_string(),
|
||||
config.llm_model.clone(),
|
||||
config.llm_request_timeout_ms,
|
||||
config.llm_max_retries,
|
||||
config.llm_retry_backoff_ms,
|
||||
)?;
|
||||
|
||||
Ok(Some(LlmClient::new(llm_config)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_ai::{AiTaskKind, generate_ai_task_id};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_state_exposes_usable_ai_task_service() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let task_id = generate_ai_task_id(1_713_680_000_000_000);
|
||||
|
||||
let created = state
|
||||
.ai_task_service()
|
||||
.create_task(module_ai::AiTaskCreateInput {
|
||||
task_id: task_id.clone(),
|
||||
task_kind: AiTaskKind::StoryGeneration,
|
||||
owner_user_id: "user_001".to_string(),
|
||||
request_label: "营地开场".to_string(),
|
||||
source_module: "story".to_string(),
|
||||
source_entity_id: Some("storysess_001".to_string()),
|
||||
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
|
||||
stages: AiTaskKind::StoryGeneration.default_stage_blueprints(),
|
||||
created_at_micros: 1_713_680_000_000_000,
|
||||
})
|
||||
.expect("ai task should create");
|
||||
|
||||
assert_eq!(created.task_id, task_id);
|
||||
assert_eq!(created.task_kind, AiTaskKind::StoryGeneration);
|
||||
assert_eq!(created.stages.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_skips_llm_client_when_api_key_missing() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
assert!(state.llm_client().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
829
server-rs/crates/api-server/src/story_battles.rs
Normal file
829
server-rs/crates/api-server/src/story_battles.rs
Normal file
@@ -0,0 +1,829 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_combat::{
|
||||
BattleMode, BattleStateInput, ResolveCombatActionInput, generate_battle_state_id,
|
||||
};
|
||||
use module_npc::{NPC_FIGHT_FUNCTION_ID, NPC_SPAR_FUNCTION_ID, ResolveNpcInteractionInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
|
||||
use spacetime_client::{ResolveNpcBattleInteractionInput, SpacetimeClientError};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateStoryBattleRequest {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
#[serde(default)]
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: String,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
#[serde(default)]
|
||||
pub experience_reward: u32,
|
||||
#[serde(default)]
|
||||
pub reward_items: Vec<StoryBattleRewardItemRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveStoryBattleRequest {
|
||||
pub battle_state_id: String,
|
||||
pub function_id: String,
|
||||
pub action_text: String,
|
||||
pub base_damage: i32,
|
||||
pub mana_cost: i32,
|
||||
pub heal: i32,
|
||||
pub mana_restore: i32,
|
||||
pub counter_multiplier_basis_points: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateStoryNpcBattleRequest {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub interaction_function_id: String,
|
||||
#[serde(default)]
|
||||
pub release_npc_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub battle_state_id: Option<String>,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
#[serde(default)]
|
||||
pub experience_reward: u32,
|
||||
#[serde(default)]
|
||||
pub reward_items: Vec<StoryBattleRewardItemRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryBattleRewardItemRequest {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub item_name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
#[serde(default)]
|
||||
pub stack_key: String,
|
||||
#[serde(default)]
|
||||
pub equipment_slot_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_story_battle(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CreateStoryBattleRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let battle_mode = parse_battle_mode_strict(&payload.battle_mode).ok_or_else(|| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-battle",
|
||||
"message": "battleMode 仅支持 fight 或 spar",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let reward_items =
|
||||
parse_story_battle_reward_items(&payload.reward_items).map_err(|message| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-battle",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.create_battle_state(BattleStateInput {
|
||||
battle_state_id: generate_battle_state_id(now_micros),
|
||||
story_session_id: payload.story_session_id,
|
||||
runtime_session_id: payload.runtime_session_id,
|
||||
actor_user_id,
|
||||
chapter_id: payload.chapter_id,
|
||||
target_npc_id: payload.target_npc_id,
|
||||
target_name: payload.target_name,
|
||||
battle_mode,
|
||||
player_hp: payload.player_hp,
|
||||
player_max_hp: payload.player_max_hp,
|
||||
player_mana: payload.player_mana,
|
||||
player_max_mana: payload.player_max_mana,
|
||||
target_hp: payload.target_hp,
|
||||
target_max_hp: payload.target_max_hp,
|
||||
experience_reward: payload.experience_reward,
|
||||
reward_items,
|
||||
created_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"battleState": build_battle_state_payload(&result),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resolve_story_battle(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<ResolveStoryBattleRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.resolve_combat_action(ResolveCombatActionInput {
|
||||
battle_state_id: payload.battle_state_id,
|
||||
function_id: payload.function_id,
|
||||
action_text: payload.action_text,
|
||||
base_damage: payload.base_damage,
|
||||
mana_cost: payload.mana_cost,
|
||||
heal: payload.heal,
|
||||
mana_restore: payload.mana_restore,
|
||||
counter_multiplier_basis_points: payload.counter_multiplier_basis_points,
|
||||
updated_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"battleState": build_battle_state_payload(&result.battle_state),
|
||||
"combat": {
|
||||
"damageDealt": result.damage_dealt,
|
||||
"damageTaken": result.damage_taken,
|
||||
"outcome": result.outcome,
|
||||
}
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
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>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.get_battle_state(battle_state_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"battleState": build_battle_state_payload(&result),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_story_npc_battle(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CreateStoryNpcBattleRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let interaction_function_id =
|
||||
parse_npc_battle_interaction_function_id_strict(&payload.interaction_function_id)
|
||||
.ok_or_else(|| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-npc-battle",
|
||||
"message": "interactionFunctionId 仅支持 npc_fight 或 npc_spar",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let reward_items =
|
||||
parse_story_battle_reward_items(&payload.reward_items).map_err(|message| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-npc-battle",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.resolve_npc_battle_interaction(ResolveNpcBattleInteractionInput {
|
||||
npc_interaction: ResolveNpcInteractionInput {
|
||||
runtime_session_id: payload.runtime_session_id,
|
||||
npc_id: payload.npc_id,
|
||||
npc_name: payload.npc_name,
|
||||
interaction_function_id,
|
||||
release_npc_id: payload.release_npc_id,
|
||||
updated_at_micros: now_micros,
|
||||
},
|
||||
story_session_id: payload.story_session_id,
|
||||
actor_user_id,
|
||||
battle_state_id: payload.battle_state_id,
|
||||
player_hp: payload.player_hp,
|
||||
player_max_hp: payload.player_max_hp,
|
||||
player_mana: payload.player_mana,
|
||||
player_max_mana: payload.player_max_mana,
|
||||
target_hp: payload.target_hp,
|
||||
target_max_hp: payload.target_max_hp,
|
||||
experience_reward: payload.experience_reward,
|
||||
reward_items,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"npcInteraction": build_npc_interaction_payload(&result.npc_interaction),
|
||||
"battleState": build_battle_state_payload(&result.battle_state),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_battle_state_payload(record: &spacetime_client::BattleStateRecord) -> Value {
|
||||
json!({
|
||||
"battleStateId": record.battle_state_id,
|
||||
"storySessionId": record.story_session_id,
|
||||
"runtimeSessionId": record.runtime_session_id,
|
||||
"actorUserId": record.actor_user_id,
|
||||
"chapterId": record.chapter_id,
|
||||
"targetNpcId": record.target_npc_id,
|
||||
"targetName": record.target_name,
|
||||
"battleMode": record.battle_mode,
|
||||
"status": record.status,
|
||||
"playerHp": record.player_hp,
|
||||
"playerMaxHp": record.player_max_hp,
|
||||
"playerMana": record.player_mana,
|
||||
"playerMaxMana": record.player_max_mana,
|
||||
"targetHp": record.target_hp,
|
||||
"targetMaxHp": record.target_max_hp,
|
||||
"experienceReward": record.experience_reward,
|
||||
"rewardItems": record.reward_items.iter().map(|item| {
|
||||
json!({
|
||||
"itemId": item.item_id,
|
||||
"category": item.category,
|
||||
"itemName": item.item_name,
|
||||
"description": item.description,
|
||||
"quantity": item.quantity,
|
||||
"rarity": format_runtime_item_reward_item_rarity(item.rarity),
|
||||
"tags": item.tags,
|
||||
"stackable": item.stackable,
|
||||
"stackKey": item.stack_key,
|
||||
"equipmentSlotId": item
|
||||
.equipment_slot_id
|
||||
.map(format_runtime_item_equipment_slot),
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"turnIndex": record.turn_index,
|
||||
"lastActionFunctionId": record.last_action_function_id,
|
||||
"lastActionText": record.last_action_text,
|
||||
"lastResultText": record.last_result_text,
|
||||
"lastDamageDealt": record.last_damage_dealt,
|
||||
"lastDamageTaken": record.last_damage_taken,
|
||||
"lastOutcome": record.last_outcome,
|
||||
"version": record.version,
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn format_runtime_item_reward_item_rarity(
|
||||
value: module_runtime_item::RuntimeItemRewardItemRarity,
|
||||
) -> &'static str {
|
||||
match value {
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Common => "common",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => "uncommon",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Rare => "rare",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Epic => "epic",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Legendary => "legendary",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_runtime_item_equipment_slot(
|
||||
value: module_runtime_item::RuntimeItemEquipmentSlot,
|
||||
) -> &'static str {
|
||||
match value {
|
||||
module_runtime_item::RuntimeItemEquipmentSlot::Weapon => "weapon",
|
||||
module_runtime_item::RuntimeItemEquipmentSlot::Armor => "armor",
|
||||
module_runtime_item::RuntimeItemEquipmentSlot::Relic => "relic",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> Value {
|
||||
json!({
|
||||
"npcStateId": record.npc_state_id,
|
||||
"runtimeSessionId": record.runtime_session_id,
|
||||
"npcId": record.npc_id,
|
||||
"npcName": record.npc_name,
|
||||
"affinity": record.affinity,
|
||||
"relationStance": record.relation_stance,
|
||||
"helpUsed": record.help_used,
|
||||
"chattedCount": record.chatted_count,
|
||||
"giftsGiven": record.gifts_given,
|
||||
"recruited": record.recruited,
|
||||
"tradeStockSignature": record.trade_stock_signature,
|
||||
"revealedFacts": record.revealed_facts,
|
||||
"knownAttributeRumors": record.known_attribute_rumors,
|
||||
"firstMeaningfulContactResolved": record.first_meaningful_contact_resolved,
|
||||
"seenBackstoryChapterIds": record.seen_backstory_chapter_ids,
|
||||
"stanceProfile": {
|
||||
"trust": record.trust,
|
||||
"warmth": record.warmth,
|
||||
"ideologicalFit": record.ideological_fit,
|
||||
"fearOrGuard": record.fear_or_guard,
|
||||
"loyalty": record.loyalty,
|
||||
"currentConflictTag": record.current_conflict_tag,
|
||||
"recentApprovals": record.recent_approvals,
|
||||
"recentDisapprovals": record.recent_disapprovals,
|
||||
},
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_npc_interaction_payload(record: &spacetime_client::NpcInteractionRecord) -> Value {
|
||||
json!({
|
||||
"npcState": build_npc_state_payload(&record.npc_state),
|
||||
"interactionStatus": record.interaction_status,
|
||||
"actionText": record.action_text,
|
||||
"resultText": record.result_text,
|
||||
"storyText": record.story_text,
|
||||
"battleMode": record.battle_mode,
|
||||
"encounterClosed": record.encounter_closed,
|
||||
"affinityChanged": record.affinity_changed,
|
||||
"previousAffinity": record.previous_affinity,
|
||||
"nextAffinity": record.next_affinity,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_battle_mode_strict(raw: &str) -> Option<BattleMode> {
|
||||
match raw.trim() {
|
||||
"fight" => Some(BattleMode::Fight),
|
||||
"spar" => Some(BattleMode::Spar),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_npc_battle_interaction_function_id_strict(raw: &str) -> Option<String> {
|
||||
match raw.trim() {
|
||||
NPC_FIGHT_FUNCTION_ID => Some(NPC_FIGHT_FUNCTION_ID.to_string()),
|
||||
NPC_SPAR_FUNCTION_ID => Some(NPC_SPAR_FUNCTION_ID.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_story_battle_reward_items(
|
||||
values: &[StoryBattleRewardItemRequest],
|
||||
) -> Result<Vec<module_runtime_item::RuntimeItemRewardItemSnapshot>, String> {
|
||||
values.iter().map(parse_story_battle_reward_item).collect()
|
||||
}
|
||||
|
||||
fn parse_story_battle_reward_item(
|
||||
value: &StoryBattleRewardItemRequest,
|
||||
) -> Result<module_runtime_item::RuntimeItemRewardItemSnapshot, String> {
|
||||
Ok(module_runtime_item::RuntimeItemRewardItemSnapshot {
|
||||
item_id: normalize_required_string(&value.item_id)
|
||||
.ok_or_else(|| "battleState.rewardItems[].itemId 不能为空".to_string())?,
|
||||
category: normalize_required_string(&value.category)
|
||||
.ok_or_else(|| "battleState.rewardItems[].category 不能为空".to_string())?,
|
||||
item_name: normalize_required_string(&value.item_name)
|
||||
.ok_or_else(|| "battleState.rewardItems[].itemName 不能为空".to_string())?,
|
||||
description: normalize_optional_string(value.description.clone()),
|
||||
quantity: value.quantity,
|
||||
rarity: parse_runtime_item_reward_item_rarity(&value.rarity)?,
|
||||
tags: normalize_string_list(value.tags.clone()),
|
||||
stackable: value.stackable,
|
||||
stack_key: value.stack_key.trim().to_string(),
|
||||
equipment_slot_id: value
|
||||
.equipment_slot_id
|
||||
.as_deref()
|
||||
.map(parse_runtime_item_equipment_slot)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_runtime_item_reward_item_rarity(
|
||||
raw: &str,
|
||||
) -> Result<module_runtime_item::RuntimeItemRewardItemRarity, String> {
|
||||
match raw.trim() {
|
||||
"common" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Common),
|
||||
"uncommon" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Uncommon),
|
||||
"rare" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Rare),
|
||||
"epic" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Epic),
|
||||
"legendary" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Legendary),
|
||||
_ => Err(
|
||||
"battleState.rewardItems[].rarity 仅支持 common/uncommon/rare/epic/legendary"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_runtime_item_equipment_slot(
|
||||
raw: &str,
|
||||
) -> Result<module_runtime_item::RuntimeItemEquipmentSlot, String> {
|
||||
match raw.trim() {
|
||||
"weapon" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Weapon),
|
||||
"armor" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Armor),
|
||||
"relic" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Relic),
|
||||
_ => Err("battleState.rewardItems[].equipmentSlotId 仅支持 weapon/armor/relic".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_story_battle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn story_battles_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_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")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"targetNpcId": "npc_001",
|
||||
"targetName": "黑爪狼",
|
||||
"battleMode": "fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_story_npc_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/npc/battle")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"npcId": "npc_001",
|
||||
"npcName": "试剑门徒",
|
||||
"interactionFunctionId": "npc_fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_story_battle_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/battles")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"targetNpcId": "npc_001",
|
||||
"targetName": "黑爪狼",
|
||||
"battleMode": "fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_story_npc_battle_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/npc/battle")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"npcId": "npc_001",
|
||||
"npcName": "试剑门徒",
|
||||
"interactionFunctionId": "npc_fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_battle_state_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/battles/battle_001")
|
||||
.body(Body::empty())
|
||||
.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;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/battles/battle_001")
|
||||
.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!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_story_battle_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/battles/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.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::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "story_battles_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_battles".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
416
server-rs/crates/api-server/src/story_sessions.rs
Normal file
416
server-rs/crates/api-server/src/story_sessions.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::story::{
|
||||
BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload,
|
||||
StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn begin_story_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<BeginStorySessionRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.begin_story_session(
|
||||
module_story::generate_story_session_id(now_micros),
|
||||
payload.runtime_session_id,
|
||||
actor_user_id,
|
||||
payload.world_profile_id,
|
||||
payload.initial_prompt,
|
||||
payload.opening_summary,
|
||||
now_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
StorySessionMutationResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: result.session.story_session_id,
|
||||
runtime_session_id: result.session.runtime_session_id,
|
||||
actor_user_id: result.session.actor_user_id,
|
||||
world_profile_id: result.session.world_profile_id,
|
||||
initial_prompt: result.session.initial_prompt,
|
||||
opening_summary: result.session.opening_summary,
|
||||
latest_narrative_text: result.session.latest_narrative_text,
|
||||
latest_choice_function_id: result.session.latest_choice_function_id,
|
||||
status: result.session.status,
|
||||
version: result.session.version,
|
||||
created_at: result.session.created_at,
|
||||
updated_at: result.session.updated_at,
|
||||
},
|
||||
story_event: StoryEventPayload {
|
||||
event_id: result.event.event_id,
|
||||
story_session_id: result.event.story_session_id,
|
||||
event_kind: result.event.event_kind,
|
||||
narrative_text: result.event.narrative_text,
|
||||
choice_function_id: result.event.choice_function_id,
|
||||
created_at: result.event.created_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn continue_story(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<ContinueStoryRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.continue_story(
|
||||
payload.story_session_id,
|
||||
module_story::generate_story_event_id(now_micros),
|
||||
payload.narrative_text,
|
||||
payload.choice_function_id,
|
||||
now_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
StorySessionMutationResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: result.session.story_session_id,
|
||||
runtime_session_id: result.session.runtime_session_id,
|
||||
actor_user_id: result.session.actor_user_id,
|
||||
world_profile_id: result.session.world_profile_id,
|
||||
initial_prompt: result.session.initial_prompt,
|
||||
opening_summary: result.session.opening_summary,
|
||||
latest_narrative_text: result.session.latest_narrative_text,
|
||||
latest_choice_function_id: result.session.latest_choice_function_id,
|
||||
status: result.session.status,
|
||||
version: result.session.version,
|
||||
created_at: result.session.created_at,
|
||||
updated_at: result.session.updated_at,
|
||||
},
|
||||
story_event: StoryEventPayload {
|
||||
event_id: result.event.event_id,
|
||||
story_session_id: result.event.story_session_id,
|
||||
event_kind: result.event.event_kind,
|
||||
narrative_text: result.event.narrative_text,
|
||||
choice_function_id: result.event.choice_function_id,
|
||||
created_at: result.event.created_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
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>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.get_story_session_state(story_session_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
StorySessionStateResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: result.session.story_session_id,
|
||||
runtime_session_id: result.session.runtime_session_id,
|
||||
actor_user_id: result.session.actor_user_id,
|
||||
world_profile_id: result.session.world_profile_id,
|
||||
initial_prompt: result.session.initial_prompt,
|
||||
opening_summary: result.session.opening_summary,
|
||||
latest_narrative_text: result.session.latest_narrative_text,
|
||||
latest_choice_function_id: result.session.latest_choice_function_id,
|
||||
status: result.session.status,
|
||||
version: result.session.version,
|
||||
created_at: result.session.created_at,
|
||||
updated_at: result.session.updated_at,
|
||||
},
|
||||
story_events: result
|
||||
.events
|
||||
.into_iter()
|
||||
.map(|event| StoryEventPayload {
|
||||
event_id: event.event_id,
|
||||
story_session_id: event.story_session_id,
|
||||
event_kind: event.event_kind,
|
||||
narrative_text: event.narrative_text,
|
||||
choice_function_id: event.choice_function_id,
|
||||
created_at: event.created_at,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_story_session_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn story_sessions_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
// story session 路由需要保留 request_context,确保错误 envelope 与 requestId 一致。
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_story_session_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")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"worldProfileId": "profile_001",
|
||||
"initialPrompt": "进入营地",
|
||||
"openingSummary": "营地开场"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_story_session_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/sessions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"worldProfileId": "profile_001",
|
||||
"initialPrompt": "进入营地",
|
||||
"openingSummary": "营地开场"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/sessions/continue")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.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::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_session_state_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/sessions/storysess_001/state")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_session_state_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/sessions/storysess_001/state")
|
||||
.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!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "story_sessions_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_sessions".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ use module_auth::{
|
||||
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
|
||||
WechatAuthScene,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_contracts::auth::{
|
||||
AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
|
||||
WechatStartQuery, WechatStartResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use url::Url;
|
||||
|
||||
@@ -19,45 +22,11 @@ use crate::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
},
|
||||
http_error::AppError,
|
||||
password_entry::PasswordEntryUserPayload,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartQuery {
|
||||
pub redirect_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartResponse {
|
||||
pub authorization_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WechatCallbackQuery {
|
||||
pub state: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub mock_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
pub async fn start_wechat_login(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -230,13 +199,13 @@ pub async fn bind_wechat_phone(
|
||||
Some(&request_context),
|
||||
WechatBindPhoneResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
login_method: result.user.login_method.as_str().to_string(),
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user