init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,233 @@
use module_ai::{
AiTaskCreateInput, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageStartInput,
AiTextChunkAppendInput,
};
use serde_json::json;
use spacetime_client::{SpacetimeClient, SpacetimeClientError};
use std::sync::{Arc, Mutex};
use tracing::warn;
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftContext {
pub task_id: String,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: String,
pub template_key: String,
pub operation_id: String,
}
impl AiGenerationDraftContext {
pub fn new(
template_key: &str,
owner_user_id: &str,
session_id: &str,
operation_id: &str,
request_label: &str,
) -> Self {
let normalized_template = normalize_identifier_segment(template_key);
let normalized_session = normalize_identifier_segment(session_id);
let normalized_operation = normalize_identifier_segment(operation_id);
Self {
// 生成过程草稿使用稳定 task_id保证同一模板会话操作重试时能继续定位已有内容。
task_id: format!(
"aitask_draft_{normalized_template}_{normalized_session}_{normalized_operation}"
),
owner_user_id: owner_user_id.trim().to_string(),
request_label: request_label.trim().to_string(),
source_module: normalized_template,
source_entity_id: session_id.trim().to_string(),
template_key: template_key.trim().to_string(),
operation_id: operation_id.trim().to_string(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftSink {
context: AiGenerationDraftContext,
client: SpacetimeClient,
next_sequence: Arc<Mutex<u32>>,
persisted_text: Arc<Mutex<String>>,
}
impl AiGenerationDraftSink {
pub fn new(context: AiGenerationDraftContext, client: SpacetimeClient) -> Self {
Self {
context,
client,
next_sequence: Arc::new(Mutex::new(1)),
persisted_text: Arc::new(Mutex::new(String::new())),
}
}
pub fn persist_visible_text_async(&self, visible_text: &str) {
let (sequence, delta_text) = {
let mut persisted_text = self
.persisted_text
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let delta_text = visible_text
.strip_prefix(persisted_text.as_str())
.unwrap_or(visible_text)
.to_string();
*persisted_text = visible_text.to_string();
if delta_text.trim().is_empty() {
return;
}
let mut next_sequence = self
.next_sequence
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let sequence = *next_sequence;
*next_sequence = next_sequence.saturating_add(1);
(sequence, delta_text)
};
let context = self.context.clone();
let client = self.client.clone();
tokio::spawn(async move {
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text,
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %context.task_id,
sequence,
error = %error,
"AI 生成草稿后台增量落库失败,主生成流程继续执行"
);
}
});
}
}
#[derive(Debug)]
pub(crate) struct AiGenerationDraftWriter {
context: AiGenerationDraftContext,
next_sequence: u32,
persisted_text: String,
}
impl AiGenerationDraftWriter {
pub fn new(context: AiGenerationDraftContext) -> Self {
Self {
context,
next_sequence: 1,
persisted_text: String::new(),
}
}
pub async fn ensure_started(
&mut self,
client: &SpacetimeClient,
) -> Result<(), SpacetimeClientError> {
let now_micros = current_utc_micros();
match client
.create_ai_task(AiTaskCreateInput {
task_id: self.context.task_id.clone(),
task_kind: AiTaskKind::CustomWorldGeneration,
owner_user_id: self.context.owner_user_id.clone(),
request_label: self.context.request_label.clone(),
source_module: self.context.source_module.clone(),
source_entity_id: Some(self.context.source_entity_id.clone()),
request_payload_json: Some(
json!({
"templateKey": self.context.template_key,
"operationId": self.context.operation_id,
})
.to_string(),
),
stages: vec![AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::RequestModel,
label: "请求模型".to_string(),
detail: "模板生成过程中持续写入模型已生成文本。".to_string(),
order: 1,
}],
created_at_micros: now_micros,
})
.await
{
Ok(_) => {}
Err(error) if is_duplicate_ai_task_error(&error) => {}
Err(error) => return Err(error),
}
client
.start_ai_task_stage(AiTaskStageStartInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
started_at_micros: now_micros,
})
.await
}
pub async fn persist_visible_text(&mut self, client: &SpacetimeClient, visible_text: &str) {
let delta_text = match visible_text.strip_prefix(self.persisted_text.as_str()) {
Some(delta) => delta,
None => visible_text,
};
if delta_text.trim().is_empty() {
self.persisted_text = visible_text.to_string();
return;
}
let sequence = self.next_sequence;
self.next_sequence = self.next_sequence.saturating_add(1);
self.persisted_text = visible_text.to_string();
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text: delta_text.to_string(),
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %self.context.task_id,
sequence,
error = %error,
"AI 生成草稿增量落库失败,主生成流程继续执行"
);
}
}
}
fn normalize_identifier_segment(value: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
if normalized.is_empty() {
"unknown".to_string()
} else {
normalized
}
}
fn is_duplicate_ai_task_error(error: &SpacetimeClientError) -> bool {
error.to_string().contains("ai_task.task_id 已存在")
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}

View File

@@ -0,0 +1,670 @@
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
.seed_test_phone_user_with_password("13800138100", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_ai_tasks".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
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")
}
}

View File

@@ -0,0 +1,129 @@
use axum::Json;
use serde::Serialize;
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::request_context::RequestContext;
// 当前阶段先把成功响应 envelope helper 准备好,后续 `/healthz` 与业务 handler 会直接复用这里的输出逻辑。
#[allow(dead_code)]
pub fn json_success_body<T>(request_context: Option<&RequestContext>, data: T) -> Json<Value>
where
T: Serialize,
{
if let Some(context) = request_context
&& context.wants_envelope()
{
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))
}
pub fn json_error_body(
request_context: Option<&RequestContext>,
error: &ApiErrorPayload,
) -> Json<Value> {
let meta = build_api_response_meta(request_context);
if request_context.is_some_and(RequestContext::wants_envelope) {
return Json(
serde_json::to_value(ApiErrorEnvelope::new(error.clone(), meta)).unwrap_or(Value::Null),
);
}
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::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(),
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request_context::RequestContext;
use std::time::Duration;
fn build_request_context(wants_envelope: bool) -> RequestContext {
RequestContext::new(
"req-test".to_string(),
"GET /test".to_string(),
Duration::from_millis(12),
wants_envelope,
)
}
#[test]
fn success_body_returns_standard_envelope_when_requested() {
let request_context = build_request_context(true);
let body = json_success_body(Some(&request_context), json!({ "ok": "value" })).0;
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["data"]["ok"], Value::String("value".to_string()));
assert_eq!(
body["meta"]["requestId"],
Value::String("req-test".to_string())
);
assert_eq!(
body["meta"]["routeVersion"],
Value::String(API_VERSION.to_string())
);
}
#[test]
fn success_body_returns_raw_payload_when_envelope_not_requested() {
let request_context = build_request_context(false);
let body = json_success_body(Some(&request_context), json!({ "ok": "value" })).0;
assert_eq!(body["ok"], Value::String("value".to_string()));
assert!(body.get("meta").is_none());
}
#[test]
fn error_body_returns_legacy_shape_without_envelope_header() {
let request_context = build_request_context(false);
let error = ApiErrorPayload {
code: "NOT_FOUND".to_string(),
message: "资源不存在".to_string(),
details: None,
};
let body = json_error_body(Some(&request_context), &error).0;
assert_eq!(
body["error"]["code"],
Value::String("NOT_FOUND".to_string())
);
assert_eq!(
body["meta"]["requestId"],
Value::String("req-test".to_string())
);
assert!(body.get("ok").is_none());
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
use axum::{
Json,
extract::{Extension, Request, State},
http::{
HeaderMap, StatusCode,
header::{AUTHORIZATION, COOKIE},
},
middleware::Next,
response::Response,
};
use platform_auth::{
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tracing::warn;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
const INTERNAL_AUTH_USER_ID_HEADER: &str = "x-genarrative-authenticated-user-id";
const INTERNAL_API_SECRET_HEADER: &str = "x-genarrative-internal-api-secret";
// 统一把已校验的 claims 写入 request extensions避免后续 handler 再次重复解析 Bearer token。
#[derive(Clone, Debug)]
pub struct AuthenticatedAccessToken {
claims: AccessTokenClaims,
}
#[derive(Clone, Debug)]
pub struct RefreshSessionToken {
token: String,
}
impl AuthenticatedAccessToken {
pub fn new(claims: AccessTokenClaims) -> Self {
Self { claims }
}
pub fn claims(&self) -> &AccessTokenClaims {
&self.claims
}
}
impl RefreshSessionToken {
pub fn new(token: String) -> Self {
Self { token }
}
pub fn token(&self) -> &str {
&self.token
}
}
pub async fn require_bearer_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
if allows_internal_forwarded_auth(request.uri().path())
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
{
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
return Ok(next.run(request).await);
}
let bearer_token = extract_bearer_token(request.headers())?;
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let claims = verify_access_token(&bearer_token, state.auth_jwt_config()).map_err(|error| {
warn!(
%request_id,
error = %error,
"Bearer JWT 校验失败"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
})?;
let current_user = state
.auth_user_service()
.get_user_by_id(claims.user_id())
.map_err(|error| {
warn!(
%request_id,
error = %error,
"Bearer JWT 用户快照读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?
.ok_or_else(|| {
warn!(
%request_id,
user_id = %claims.user_id(),
"Bearer JWT 对应用户不存在"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
})?;
if current_user.token_version != claims.token_version() {
warn!(
%request_id,
user_id = %claims.user_id(),
token_version = claims.token_version(),
current_token_version = current_user.token_version,
"Bearer JWT 版本已失效"
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"));
}
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
Ok(next.run(request).await)
}
pub async fn inspect_auth_claims(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Json<Value> {
json_success_body(
Some(&request_context),
json!({
"claims": authenticated.claims(),
}),
)
}
pub async fn attach_refresh_session_token(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
if let Some(token) = request
.headers()
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|cookie_header| {
read_refresh_session_token(cookie_header, state.refresh_cookie_config())
})
{
request
.extensions_mut()
.insert(RefreshSessionToken::new(token));
}
next.run(request).await
}
pub async fn inspect_refresh_session_cookie(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
request: Request,
) -> Json<Value> {
let maybe_token = request.extensions().get::<RefreshSessionToken>();
json_success_body(
Some(&request_context),
json!({
"cookieName": state.refresh_cookie_config().cookie_name(),
"present": maybe_token.is_some(),
"tokenLength": maybe_token.map(|token| token.token().len()),
}),
)
}
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
let authorization = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
let token = authorization
.strip_prefix("Bearer ")
.or_else(|| authorization.strip_prefix("bearer "))
.map(str::trim)
.filter(|token| !token.is_empty())
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
Ok(token.to_string())
}
fn allows_internal_forwarded_auth(path: &str) -> bool {
// Node 代理已经完成平台账号 JWT 校验Rust 运行时只信任这些明确的内部转发路径。
path.starts_with("/api/runtime/big-fish/") || path.starts_with("/api/runtime/puzzle/")
}
fn try_build_internal_forwarded_claims(
state: &AppState,
headers: &HeaderMap,
) -> Option<AccessTokenClaims> {
let expected_secret = state.config.internal_api_secret.as_ref()?.trim();
if expected_secret.is_empty() {
return None;
}
let provided_secret = headers
.get(INTERNAL_API_SECRET_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())?;
if provided_secret != expected_secret {
return None;
}
let user_id = headers
.get(INTERNAL_AUTH_USER_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
// 这里的 claims 只服务于经 Node 已鉴权后的本地内部转发链路,避免在开发态复制整套账号仓储。
AccessTokenClaims::from_input(
platform_auth::AccessTokenClaimsInput {
user_id: user_id.clone(),
session_id: format!("internal-forwarded-{user_id}"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 0,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: None,
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.ok()
}
#[cfg(test)]
mod tests {
use super::{
INTERNAL_API_SECRET_HEADER, INTERNAL_AUTH_USER_ID_HEADER, RefreshSessionToken,
allows_internal_forwarded_auth, extract_bearer_token, try_build_internal_forwarded_claims,
};
use crate::{config::AppConfig, state::AppState};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
response::IntoResponse,
};
#[test]
fn extract_bearer_token_accepts_standard_header() {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer token-value"),
);
let token = extract_bearer_token(&headers).expect("bearer token should be extracted");
assert_eq!(token, "token-value");
}
#[test]
fn extract_bearer_token_rejects_missing_scheme() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic abc"));
let error = extract_bearer_token(&headers).expect_err("basic auth should be rejected");
assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn refresh_session_token_retains_original_value() {
let token = RefreshSessionToken::new("refresh-token-01".to_string());
assert_eq!(token.token(), "refresh-token-01");
}
#[test]
fn internal_forwarded_auth_allows_node_proxy_runtime_paths() {
assert!(allows_internal_forwarded_auth(
"/api/runtime/big-fish/sessions"
));
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
}
#[test]
fn internal_forwarded_claims_require_matching_secret() {
let mut config = AppConfig::default();
config.internal_api_secret = Some("bridge-secret".to_string());
let state = AppState::new(config).expect("state should build");
let mut headers = HeaderMap::new();
headers.insert(
INTERNAL_AUTH_USER_ID_HEADER,
HeaderValue::from_static("user_forwarded_01"),
);
headers.insert(
INTERNAL_API_SECRET_HEADER,
HeaderValue::from_static("bridge-secret"),
);
let claims =
try_build_internal_forwarded_claims(&state, &headers).expect("claims should resolve");
assert_eq!(claims.user_id(), "user_forwarded_01");
assert_eq!(claims.token_version(), 0);
}
}

View File

@@ -0,0 +1,42 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use shared_contracts::auth::{AuthMeResponse, build_available_login_methods};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext,
state::AppState,
};
pub async fn auth_me(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<serde_json::Value>, AppError> {
let user_id = authenticated.claims().user_id().to_string();
let user = state
.password_entry_service()
.get_user_by_id(&user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录")
})?;
Ok(json_success_body(
Some(&request_context),
AuthMeResponse {
user: map_auth_user_payload(user.user),
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
true,
state.config.wechat_auth_enabled,
),
},
))
}

View File

@@ -0,0 +1,23 @@
use module_auth::AuthUser;
use shared_contracts::auth::{AuthUserPayload, PublicUserSummaryPayload};
pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
AuthUserPayload {
id: user.id,
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
}
}
pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPayload {
PublicUserSummaryPayload {
id: user.id,
public_user_code: user.public_user_code,
display_name: user.display_name,
}
}

View File

@@ -0,0 +1,85 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use shared_contracts::auth::PublicUserSearchResponse;
use crate::{
api_response::json_success_body, auth_payload::map_public_user_summary_payload,
http_error::AppError, request_context::RequestContext, state::AppState,
};
pub async fn get_public_user_by_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(code): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let user = state
.password_entry_service()
.get_user_by_public_user_code(&code)
.map_err(map_public_user_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户")
})?;
Ok(json_success_body(
Some(&request_context),
PublicUserSearchResponse {
user: map_public_user_summary_payload(user.user),
},
))
}
pub async fn get_public_user_by_id(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(user_id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("用户 ID 不能为空"));
}
let user = state
.auth_user_service()
.get_user_by_id(user_id)
.map_err(map_public_user_id_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应用户")
})?;
Ok(json_success_body(
Some(&request_context),
PublicUserSearchResponse {
user: map_public_user_summary_payload(user),
},
))
}
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
match error {
module_auth::PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确")
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidPhoneNumber
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidCredentials
| module_auth::PasswordEntryError::UserNotFound => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}
fn map_public_user_id_search_error(error: module_auth::LogoutError) -> AppError {
match error {
module_auth::LogoutError::UserNotFound => {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应用户")
}
module_auth::LogoutError::Store(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -0,0 +1,154 @@
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
use module_auth::{
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
build_refresh_session_clear_cookie, build_refresh_session_set_cookie,
create_refresh_session_token, hash_refresh_session_token, sign_access_token,
};
use time::OffsetDateTime;
use crate::session_client::SessionClientContext;
use crate::{http_error::AppError, state::AppState};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
pub access_token: String,
pub refresh_token: String,
}
pub fn create_password_auth_session(
state: &AppState,
user: &AuthUser,
session_client: &SessionClientContext,
) -> Result<SignedAuthSession, AppError> {
create_auth_session(state, user, session_client, AuthLoginMethod::Password)
}
pub fn create_auth_session(
state: &AppState,
user: &AuthUser,
session_client: &SessionClientContext,
session_provider: AuthLoginMethod,
) -> Result<SignedAuthSession, AppError> {
let refresh_token = create_refresh_session_token();
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
let session = state
.refresh_session_service()
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash,
issued_by_provider: session_provider.clone(),
client_info: session_client.to_refresh_session_client_info(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_refresh_session_error)?;
let access_token = sign_access_token_for_user(
state,
user,
&session.session.session_id,
Some(&session_provider),
)?;
Ok(SignedAuthSession {
access_token,
refresh_token,
})
}
pub fn sign_access_token_for_user(
state: &AppState,
user: &AuthUser,
session_id: &str,
session_provider_override: Option<&AuthLoginMethod>,
) -> Result<String, AppError> {
let access_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
provider: map_auth_provider(session_provider_override.unwrap_or(&user.login_method)),
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: user.phone_number_masked.is_some(),
binding_status: map_binding_status(&user.binding_status),
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
sign_access_token(&access_claims, state.auth_jwt_config()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})
}
pub fn build_refresh_session_cookie_header(
state: &AppState,
refresh_token: &str,
) -> Result<HeaderValue, AppError> {
let refresh_cookie =
build_refresh_session_set_cookie(refresh_token, state.refresh_cookie_config());
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("refresh cookie 头构造失败:{error}"))
})
}
pub fn build_clear_refresh_session_cookie_header(
state: &AppState,
) -> Result<HeaderValue, AppError> {
let refresh_cookie = build_refresh_session_clear_cookie(state.refresh_cookie_config());
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("refresh cookie 头构造失败:{error}"))
})
}
pub fn attach_set_cookie_header(headers: &mut HeaderMap, set_cookie: HeaderValue) {
headers.insert(SET_COOKIE, set_cookie);
}
pub fn map_refresh_session_error(error: RefreshSessionError) -> AppError {
match error {
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
RefreshSessionError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}
pub fn map_logout_error(error: LogoutError) -> AppError {
match error {
LogoutError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"),
LogoutError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}
fn map_auth_provider(login_method: &AuthLoginMethod) -> AuthProvider {
match login_method {
AuthLoginMethod::Password => AuthProvider::Password,
AuthLoginMethod::Phone => AuthProvider::Phone,
AuthLoginMethod::Wechat => AuthProvider::Wechat,
}
}
fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> BindingStatus {
match binding_status {
module_auth::AuthBindingStatus::Active => BindingStatus::Active,
module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone,
}
}

View File

@@ -0,0 +1,90 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use platform_auth::hash_refresh_session_token;
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth::{AuthenticatedAccessToken, RefreshSessionToken},
http_error::AppError,
request_context::RequestContext,
session_client::mask_ip,
state::AppState,
};
pub async fn auth_sessions(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<Json<serde_json::Value>, AppError> {
// 当前设备识别仍然依赖 refresh cookie 命中的原始 token对旧前端行为保持兼容。
let user_id = authenticated.claims().user_id().to_string();
let current_refresh_token_hash = maybe_refresh_token.and_then(|token| {
let token = token.0.token().trim();
if token.is_empty() {
return None;
}
Some(hash_refresh_session_token(token))
});
let sessions = state
.refresh_session_service()
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
.map_err(map_refresh_session_list_error)?;
Ok(json_success_body(
Some(&request_context),
AuthSessionsResponse {
sessions: sessions
.sessions
.into_iter()
.map(|session| {
let is_current = current_refresh_token_hash
.as_ref()
.is_some_and(|hash| session.refresh_token_hash == *hash);
let client_label = session.client_info.device_display_name.clone();
AuthSessionSummaryPayload {
session_id: session.session_id,
client_type: session.client_info.client_type,
client_runtime: session.client_info.client_runtime,
client_platform: session.client_info.client_platform,
client_label,
device_display_name: session.client_info.device_display_name,
mini_program_app_id: session.client_info.mini_program_app_id,
mini_program_env: session.client_info.mini_program_env,
user_agent: session.client_info.user_agent,
ip_masked: mask_ip(session.client_info.ip.as_deref()),
is_current,
created_at: session.created_at,
last_seen_at: session.last_seen_at,
expires_at: session.expires_at,
}
})
.collect(),
},
))
}
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
match error {
module_auth::RefreshSessionError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录")
}
module_auth::RefreshSessionError::MissingToken
| module_auth::RefreshSessionError::SessionNotFound
| module_auth::RefreshSessionError::SessionExpired => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
module_auth::RefreshSessionError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,429 @@
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
BigFishAgentMessageRecord, BigFishMessageFinalizeRecordInput, BigFishSessionRecord,
};
use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a BigFishSessionRecord,
pub quick_fill_requested: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnError {
message: String,
}
impl BigFishAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for BigFishAgentTurnError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for BigFishAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BigFishAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_anchor_pack: BigFishAnchorPack,
}
const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"gameplayPromise": {
"key": "gameplayPromise",
"label": "玩法承诺",
"value": "",
"status": "missing"
},
"ecologyVisualTheme": {
"key": "ecologyVisualTheme",
"label": "生态视觉主题",
"value": "",
"status": "missing"
},
"growthLadder": {
"key": "growthLadder",
"label": "成长阶梯",
"value": "",
"status": "missing"
},
"riskTempo": {
"key": "riskTempo",
"label": "风险节奏",
"value": "",
"status": "missing"
}
}
}"#;
pub(crate) async fn run_big_fish_agent_turn<F>(
request: BigFishAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
where
F: FnMut(&str),
{
let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested);
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。",
parse_failed: "大鱼吃小鱼聊天结果解析失败,请稍后重试。",
},
on_reply_update,
BigFishAgentTurnError::new,
)
.await?;
let output = parse_big_fish_model_output(&turn_output.parsed)?;
Ok(BigFishAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(),
progress_percent: if request.quick_fill_requested {
100
} else {
output.progress_percent.min(100)
},
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack)
.unwrap_or_else(|_| "{}".to_string()),
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: BigFishAgentTurnResult,
updated_at_micros: i64,
) -> BigFishMessageFinalizeRecordInput {
BigFishMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
stage: result.stage,
progress_percent: result.progress_percent,
anchor_pack_json: result.anchor_pack_json,
error_message: result.error_message,
updated_at_micros,
}
}
pub(crate) fn build_failed_finalize_record_input(
session_id: String,
owner_user_id: String,
session: &BigFishSessionRecord,
error_message: String,
updated_at_micros: i64,
) -> BigFishMessageFinalizeRecordInput {
BigFishMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: None,
assistant_reply_text: None,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
anchor_pack_json: serialize_record_anchor_pack(&session.anchor_pack),
error_message: Some(error_message),
updated_at_micros,
}
}
fn build_big_fish_agent_prompt(
session: &BigFishSessionRecord,
quick_fill_requested: bool,
) -> String {
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
.map(render_anchor_question_block)
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
"不要要求用户再提供等级、鱼群、场景或节奏信息",
"输出完整 nextAnchorPack直接补齐 value 为空或 status 为 missing 的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
anchor_question_block = anchor_question_block,
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = BIG_FISH_AGENT_OUTPUT_CONTRACT,
)
}
fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_big_fish_model_output(
parsed: &JsonValue,
) -> Result<BigFishAgentModelOutput, BigFishAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(0);
let next_anchor_pack_value = parsed
.get("nextAnchorPack")
.cloned()
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少 nextAnchorPack。"))?;
let next_anchor_pack = parse_big_fish_model_anchor_pack(&next_anchor_pack_value)?;
Ok(BigFishAgentModelOutput {
reply_text,
progress_percent,
next_anchor_pack,
})
}
fn parse_big_fish_model_anchor_pack(
value: &JsonValue,
) -> Result<BigFishAnchorPack, BigFishAgentTurnError> {
Ok(BigFishAnchorPack {
// LLM 与 HTTP 契约使用 camelCaseSpacetimeDB 持久化结构保持 Rust snake_case边界处必须显式翻译。
gameplay_promise: parse_big_fish_model_anchor_item(value, "gameplayPromise")?,
ecology_visual_theme: parse_big_fish_model_anchor_item(value, "ecologyVisualTheme")?,
growth_ladder: parse_big_fish_model_anchor_item(value, "growthLadder")?,
risk_tempo: parse_big_fish_model_anchor_item(value, "riskTempo")?,
})
}
fn parse_big_fish_model_anchor_item(
pack: &JsonValue,
field_name: &str,
) -> Result<module_big_fish::BigFishAnchorItem, BigFishAgentTurnError> {
let value = pack.get(field_name).ok_or_else(|| {
BigFishAgentTurnError::new(format!("大鱼吃小鱼 anchor pack 缺少 {field_name}"))
})?;
let key = value
.get("key")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or(field_name)
.to_string();
let label = value
.get("label")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or_else(|| default_big_fish_anchor_label(field_name))
.to_string();
let item_value = value
.get("value")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string();
let status = value
.get("status")
.and_then(JsonValue::as_str)
.map(parse_big_fish_anchor_status)
.unwrap_or(BigFishAnchorStatus::Missing);
Ok(module_big_fish::BigFishAnchorItem {
key,
label,
value: item_value,
status,
})
}
fn default_big_fish_anchor_label(field_name: &str) -> &'static str {
match field_name {
"gameplayPromise" => "玩法承诺",
"ecologyVisualTheme" => "生态视觉主题",
"growthLadder" => "成长阶梯",
"riskTempo" => "风险节奏",
_ => "大鱼锚点",
}
}
fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String {
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
.unwrap_or_else(|_| "{}".to_string())
}
fn map_big_fish_record_anchor_pack(
record: &spacetime_client::BigFishAnchorPackRecord,
) -> BigFishAnchorPack {
BigFishAnchorPack {
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
}
}
fn map_big_fish_record_anchor_item(
record: &spacetime_client::BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_big_fish_anchor_status(record.status.as_str()),
}
}
fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
match value {
"confirmed" => BigFishAnchorStatus::Confirmed,
"locked" => BigFishAnchorStatus::Locked,
"inferred" => BigFishAnchorStatus::Inferred,
_ => BigFishAnchorStatus::Missing,
}
}
#[cfg(test)]
mod tests {
use super::build_big_fish_agent_prompt;
fn anchor_item(
key: &str,
label: &str,
value: &str,
status: &str,
) -> spacetime_client::BigFishAnchorItemRecord {
spacetime_client::BigFishAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: status.to_string(),
}
}
fn empty_session_record() -> spacetime_client::BigFishSessionRecord {
spacetime_client::BigFishSessionRecord {
session_id: "big-fish-session-test".to_string(),
current_turn: 2,
progress_percent: 60,
stage: "collecting_anchors".to_string(),
anchor_pack: spacetime_client::BigFishAnchorPackRecord {
gameplay_promise: anchor_item(
"gameplayPromise",
"玩法承诺",
"微光小鱼逆袭深海巨兽",
"confirmed",
),
ecology_visual_theme: anchor_item(
"ecologyVisualTheme",
"生态视觉主题",
"幽蓝珊瑚海沟",
"confirmed",
),
growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"),
risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"),
},
draft: None,
asset_slots: Vec::new(),
asset_coverage: spacetime_client::BigFishAssetCoverageRecord {
level_main_image_ready_count: 0,
level_motion_ready_count: 0,
background_ready: false,
required_level_count: 8,
publish_ready: false,
blockers: Vec::new(),
},
messages: Vec::new(),
last_assistant_reply: None,
publish_ready: false,
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
}
}
#[test]
fn quick_fill_prompt_forbids_follow_up_questions() {
let prompt = build_big_fish_agent_prompt(&empty_session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,846 @@
use std::{env, fs, net::SocketAddr, path::PathBuf};
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";
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
pub log_filter: String,
pub admin_username: Option<String>,
pub admin_password: Option<String>,
pub admin_token_ttl_seconds: u64,
pub internal_api_secret: Option<String>,
pub jwt_issuer: String,
pub jwt_secret: String,
pub jwt_access_token_ttl_seconds: u64,
pub refresh_cookie_name: String,
pub refresh_cookie_path: String,
pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
pub auth_store_path: PathBuf,
pub sms_auth_enabled: bool,
pub sms_auth_provider: String,
pub sms_endpoint: String,
pub sms_access_key_id: Option<String>,
pub sms_access_key_secret: Option<String>,
pub sms_sign_name: String,
pub sms_template_code: String,
pub sms_template_param_key: String,
pub sms_country_code: String,
pub sms_scheme_name: Option<String>,
pub sms_code_length: u8,
pub sms_code_type: u8,
pub sms_valid_time_seconds: u64,
pub sms_interval_seconds: u64,
pub sms_duplicate_policy: u8,
pub sms_case_auth_policy: u8,
pub sms_return_verify_code: bool,
pub sms_mock_verify_code: String,
pub wechat_auth_enabled: bool,
pub wechat_auth_provider: String,
pub wechat_app_id: Option<String>,
pub wechat_app_secret: Option<String>,
pub wechat_callback_path: String,
pub wechat_redirect_path: String,
pub wechat_authorize_endpoint: String,
pub wechat_access_token_endpoint: String,
pub wechat_user_info_endpoint: String,
pub wechat_state_ttl_minutes: u32,
pub wechat_mock_user_id: String,
pub wechat_mock_union_id: Option<String>,
pub wechat_mock_display_name: String,
pub wechat_mock_avatar_url: Option<String>,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
pub oss_access_key_secret: Option<String>,
pub oss_read_expire_seconds: u64,
pub oss_post_expire_seconds: u64,
pub oss_post_max_size_bytes: u64,
pub oss_success_action_status: u16,
pub spacetime_server_url: String,
pub spacetime_database: String,
pub spacetime_token: Option<String>,
pub spacetime_pool_size: u32,
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,
pub rpg_llm_web_search_enabled: bool,
pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>,
pub dashscope_scene_image_model: String,
pub dashscope_reference_image_model: String,
pub dashscope_cover_image_model: String,
pub dashscope_image_request_timeout_ms: u64,
pub draft_asset_generation_max_concurrent_requests: usize,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
pub ark_character_video_request_timeout_ms: u64,
pub ark_character_video_model: String,
pub character_animation_ffmpeg_path: String,
pub character_animation_ffprobe_path: String,
pub character_animation_frame_extract_timeout_ms: u64,
pub slow_request_threshold_ms: u64,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
log_filter: "info,tower_http=info".to_string(),
admin_username: None,
admin_password: None,
admin_token_ttl_seconds: 4 * 60 * 60,
internal_api_secret: Some(DEFAULT_INTERNAL_API_SECRET.to_string()),
jwt_issuer: "https://auth.genarrative.local".to_string(),
jwt_secret: "genarrative-dev-secret".to_string(),
jwt_access_token_ttl_seconds: 2 * 60 * 60,
refresh_cookie_name: "genarrative_refresh_session".to_string(),
refresh_cookie_path: "/api/auth".to_string(),
refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(),
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
sms_access_key_id: None,
sms_access_key_secret: None,
sms_sign_name: "速通互联验证码".to_string(),
sms_template_code: "100001".to_string(),
sms_template_param_key: "code".to_string(),
sms_country_code: "86".to_string(),
sms_scheme_name: None,
sms_code_length: 6,
sms_code_type: 1,
sms_valid_time_seconds: 300,
sms_interval_seconds: 60,
sms_duplicate_policy: 1,
sms_case_auth_policy: 1,
sms_return_verify_code: false,
sms_mock_verify_code: "123456".to_string(),
wechat_auth_enabled: false,
wechat_auth_provider: "mock".to_string(),
wechat_app_id: None,
wechat_app_secret: None,
wechat_callback_path: "/api/auth/wechat/callback".to_string(),
wechat_redirect_path: "/".to_string(),
wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(),
wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token"
.to_string(),
wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(),
wechat_state_ttl_minutes: 15,
wechat_mock_user_id: "wx-mock-user".to_string(),
wechat_mock_union_id: Some("wx-mock-union".to_string()),
wechat_mock_display_name: "微信旅人".to_string(),
wechat_mock_avatar_url: None,
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
oss_access_key_secret: None,
oss_read_expire_seconds: 10 * 60,
oss_post_expire_seconds: 10 * 60,
oss_post_max_size_bytes: 20 * 1024 * 1024,
oss_success_action_status: 200,
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
spacetime_database: "genarrative-dev".to_string(),
spacetime_token: None,
spacetime_pool_size: 4,
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,
rpg_llm_web_search_enabled: true,
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_reference_image_model: "qwen-image-2.0".to_string(),
dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_image_request_timeout_ms: 150_000,
draft_asset_generation_max_concurrent_requests: 4,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
ark_character_video_request_timeout_ms: 420_000,
ark_character_video_model: "doubao-seedance-2-0-fast-260128".to_string(),
character_animation_ffmpeg_path: "ffmpeg".to_string(),
character_animation_ffprobe_path: "ffprobe".to_string(),
character_animation_frame_extract_timeout_ms: 120_000,
slow_request_threshold_ms: 1_000,
}
}
}
impl AppConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Some(local_spacetime_database) = read_local_spacetime_database() {
config.spacetime_database = local_spacetime_database;
}
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST")
&& !bind_host.trim().is_empty()
{
config.bind_host = bind_host;
}
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT")
&& let Ok(parsed_port) = bind_port.parse::<u16>()
{
config.bind_port = parsed_port;
}
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG")
&& !log_filter.trim().is_empty()
{
config.log_filter = log_filter;
}
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
if let Some(admin_token_ttl_seconds) =
read_first_duration_seconds_env(&["GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS"])
{
config.admin_token_ttl_seconds = admin_token_ttl_seconds;
}
config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]);
if let Some(jwt_issuer) =
read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"])
{
config.jwt_issuer = jwt_issuer;
}
if let Some(jwt_secret) =
read_first_non_empty_env(&["GENARRATIVE_JWT_SECRET", "JWT_SECRET"])
{
config.jwt_secret = jwt_secret;
}
if let Some(ttl_seconds) = read_first_duration_seconds_env(&[
"GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS",
"JWT_EXPIRES_IN",
]) {
config.jwt_access_token_ttl_seconds = ttl_seconds;
}
if let Some(refresh_cookie_name) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_NAME"]) {
config.refresh_cookie_name = refresh_cookie_name;
}
if let Some(refresh_cookie_path) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_PATH"]) {
config.refresh_cookie_path = refresh_cookie_path;
}
if let Some(refresh_cookie_same_site) =
read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_SAME_SITE"])
{
config.refresh_cookie_same_site = refresh_cookie_same_site;
}
if let Some(refresh_cookie_secure) = read_first_bool_env(&["AUTH_REFRESH_COOKIE_SECURE"]) {
config.refresh_cookie_secure = refresh_cookie_secure;
}
if let Some(refresh_session_ttl_days) =
read_first_positive_u32_env(&["AUTH_REFRESH_SESSION_TTL_DAYS"])
{
config.refresh_session_ttl_days = refresh_session_ttl_days;
}
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
config.auth_store_path = PathBuf::from(auth_store_path);
}
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
config.sms_auth_enabled = sms_auth_enabled;
}
if let Some(sms_auth_provider) = read_first_non_empty_env(&["SMS_AUTH_PROVIDER"]) {
config.sms_auth_provider = sms_auth_provider;
}
if let Some(sms_endpoint) = read_first_non_empty_env(&["ALIYUN_SMS_ENDPOINT"]) {
config.sms_endpoint = sms_endpoint;
}
config.sms_access_key_id = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_ID"]);
config.sms_access_key_secret = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_SECRET"]);
if let Some(sms_sign_name) = read_first_non_empty_env(&["ALIYUN_SMS_SIGN_NAME"]) {
config.sms_sign_name = sms_sign_name;
}
if let Some(sms_template_code) = read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_CODE"]) {
config.sms_template_code = sms_template_code;
}
if let Some(sms_template_param_key) =
read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_PARAM_KEY"])
{
config.sms_template_param_key = sms_template_param_key;
}
if let Some(sms_country_code) = read_first_non_empty_env(&["ALIYUN_SMS_COUNTRY_CODE"]) {
config.sms_country_code = sms_country_code;
}
config.sms_scheme_name = read_first_non_empty_env(&["ALIYUN_SMS_SCHEME_NAME"]);
if let Some(sms_code_length) = read_first_u8_env(&["ALIYUN_SMS_CODE_LENGTH"]) {
config.sms_code_length = sms_code_length;
}
if let Some(sms_code_type) = read_first_u8_env(&["ALIYUN_SMS_CODE_TYPE"]) {
config.sms_code_type = sms_code_type;
}
if let Some(sms_valid_time_seconds) =
read_first_duration_seconds_env(&["ALIYUN_SMS_VALID_TIME_SECONDS"])
{
config.sms_valid_time_seconds = sms_valid_time_seconds;
}
if let Some(sms_interval_seconds) =
read_first_duration_seconds_env(&["ALIYUN_SMS_INTERVAL_SECONDS"])
{
config.sms_interval_seconds = sms_interval_seconds;
}
if let Some(sms_duplicate_policy) = read_first_u8_env(&["ALIYUN_SMS_DUPLICATE_POLICY"]) {
config.sms_duplicate_policy = sms_duplicate_policy;
}
if let Some(sms_case_auth_policy) = read_first_u8_env(&["ALIYUN_SMS_CASE_AUTH_POLICY"]) {
config.sms_case_auth_policy = sms_case_auth_policy;
}
if let Some(sms_return_verify_code) =
read_first_bool_env(&["ALIYUN_SMS_RETURN_VERIFY_CODE"])
{
config.sms_return_verify_code = sms_return_verify_code;
}
if let Some(sms_mock_verify_code) = read_first_non_empty_env(&["SMS_AUTH_MOCK_VERIFY_CODE"])
{
config.sms_mock_verify_code = sms_mock_verify_code;
}
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
config.wechat_auth_enabled = wechat_auth_enabled;
}
if let Some(wechat_auth_provider) = read_first_non_empty_env(&["WECHAT_AUTH_PROVIDER"]) {
config.wechat_auth_provider = wechat_auth_provider;
}
config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]);
config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]);
if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) {
config.wechat_callback_path = wechat_callback_path;
}
if let Some(wechat_redirect_path) = read_first_non_empty_env(&["WECHAT_REDIRECT_PATH"]) {
config.wechat_redirect_path = wechat_redirect_path;
}
if let Some(wechat_authorize_endpoint) =
read_first_non_empty_env(&["WECHAT_AUTHORIZE_ENDPOINT"])
{
config.wechat_authorize_endpoint = wechat_authorize_endpoint;
}
if let Some(wechat_access_token_endpoint) =
read_first_non_empty_env(&["WECHAT_ACCESS_TOKEN_ENDPOINT"])
{
config.wechat_access_token_endpoint = wechat_access_token_endpoint;
}
if let Some(wechat_user_info_endpoint) =
read_first_non_empty_env(&["WECHAT_USER_INFO_ENDPOINT"])
{
config.wechat_user_info_endpoint = wechat_user_info_endpoint;
}
if let Some(wechat_state_ttl_minutes) =
read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"])
{
config.wechat_state_ttl_minutes = wechat_state_ttl_minutes;
}
if let Some(wechat_mock_user_id) = read_first_non_empty_env(&["WECHAT_MOCK_USER_ID"]) {
config.wechat_mock_user_id = wechat_mock_user_id;
}
config.wechat_mock_union_id = read_first_non_empty_env(&["WECHAT_MOCK_UNION_ID"]);
if let Some(wechat_mock_display_name) =
read_first_non_empty_env(&["WECHAT_MOCK_DISPLAY_NAME"])
{
config.wechat_mock_display_name = wechat_mock_display_name;
}
config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]);
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]);
if let Some(oss_read_expire_seconds) =
read_first_duration_seconds_env(&["ALIYUN_OSS_READ_EXPIRE_SECONDS"])
{
config.oss_read_expire_seconds = oss_read_expire_seconds;
}
if let Some(oss_post_expire_seconds) =
read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"])
{
config.oss_post_expire_seconds = oss_post_expire_seconds;
}
if let Some(oss_post_max_size_bytes) =
read_first_positive_u64_env(&["ALIYUN_OSS_POST_MAX_SIZE_BYTES"])
{
config.oss_post_max_size_bytes = oss_post_max_size_bytes;
}
if let Some(oss_success_action_status) =
read_first_positive_u16_env(&["ALIYUN_OSS_SUCCESS_ACTION_STATUS"])
{
config.oss_success_action_status = oss_success_action_status;
}
if let Some(spacetime_server_url) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_SERVER_URL",
"GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL",
]) {
config.spacetime_server_url = spacetime_server_url;
}
if let Some(spacetime_database) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_DATABASE",
"GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE",
]) {
config.spacetime_database = spacetime_database;
}
config.spacetime_token = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_TOKEN",
"GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN",
]);
if let Some(spacetime_pool_size) =
read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"])
{
config.spacetime_pool_size = spacetime_pool_size;
}
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;
}
if let Some(rpg_llm_web_search_enabled) = read_first_bool_env(&[
"GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED",
"RPG_LLM_WEB_SEARCH_ENABLED",
]) {
config.rpg_llm_web_search_enabled = rpg_llm_web_search_enabled;
}
if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) {
config.dashscope_base_url = dashscope_base_url;
}
config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]);
if let Some(dashscope_scene_image_model) =
read_first_non_empty_env(&["DASHSCOPE_SCENE_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"])
{
config.dashscope_scene_image_model = dashscope_scene_image_model;
}
if let Some(dashscope_reference_image_model) = read_first_non_empty_env(&[
"DASHSCOPE_REFERENCE_IMAGE_MODEL",
"DASHSCOPE_IMAGE_EDIT_MODEL",
]) {
config.dashscope_reference_image_model = dashscope_reference_image_model;
}
if let Some(dashscope_cover_image_model) =
read_first_non_empty_env(&["DASHSCOPE_COVER_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"])
{
config.dashscope_cover_image_model = dashscope_cover_image_model;
}
if let Some(dashscope_image_request_timeout_ms) =
read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(max_concurrent_requests) = read_first_usize_env(&[
"GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
"DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
]) {
config.draft_asset_generation_max_concurrent_requests = max_concurrent_requests;
}
if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_BASE_URL",
"ARK_BASE_URL",
"GENARRATIVE_LLM_BASE_URL",
"LLM_BASE_URL",
]) {
config.ark_character_video_base_url = ark_character_video_base_url;
}
config.ark_character_video_api_key = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_API_KEY",
"ARK_API_KEY",
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
]);
if let Some(ark_character_video_request_timeout_ms) = read_first_positive_u64_env(&[
"ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
"DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
]) {
config.ark_character_video_request_timeout_ms = ark_character_video_request_timeout_ms;
}
if let Some(ark_character_video_model) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_MODEL",
"DASHSCOPE_CHARACTER_VIDEO_MODEL",
]) {
config.ark_character_video_model = ark_character_video_model;
}
if let Some(character_animation_ffmpeg_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFMPEG_PATH"])
{
config.character_animation_ffmpeg_path = character_animation_ffmpeg_path;
}
if let Some(character_animation_ffprobe_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFPROBE_PATH"])
{
config.character_animation_ffprobe_path = character_animation_ffprobe_path;
}
if let Some(character_animation_frame_extract_timeout_ms) =
read_first_positive_u64_env(&["CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS"])
{
config.character_animation_frame_extract_timeout_ms =
character_animation_frame_extract_timeout_ms;
}
if let Some(slow_request_threshold_ms) =
read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"])
{
config.slow_request_threshold_ms = slow_request_threshold_ms;
}
config
}
pub fn bind_socket_addr(&self) -> SocketAddr {
let address = format!("{}:{}", self.bind_host, self.bind_port);
address
.parse()
.unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000)))
}
}
fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
env::var(key).ok().and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() {
return None;
}
Some(value)
})
})
}
fn read_local_spacetime_database() -> Option<String> {
let config_path = find_upward_file(SPACETIME_LOCAL_CONFIG_FILE)?;
let raw_text = fs::read_to_string(config_path).ok()?;
let parsed = serde_json::from_str::<serde_json::Value>(&raw_text).ok()?;
parsed
.get("database")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn find_upward_file(file_name: &str) -> Option<PathBuf> {
let mut current_dir = env::current_dir().ok()?;
loop {
let candidate = current_dir.join(file_name);
if candidate.is_file() {
return Some(candidate);
}
if !current_dir.pop() {
return None;
}
}
}
fn read_first_duration_seconds_env(keys: &[&str]) -> Option<u64> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_duration_seconds(&value))
})
}
fn read_first_bool_env(keys: &[&str]) -> Option<bool> {
keys.iter()
.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)
.ok()
.and_then(|value| parse_positive_u32(&value))
})
}
fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_u64(&value))
})
}
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_usize_env(keys: &[&str]) -> Option<usize> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_usize(&value))
})
}
fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
keys.iter()
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
}
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_u16(&value))
})
}
fn parse_duration_seconds(raw: &str) -> Option<u64> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Ok(seconds) = raw.parse::<u64>() {
return Some(seconds);
}
let (number, unit) = raw.split_at(raw.len().checked_sub(1)?);
let unit = unit.to_ascii_lowercase();
let number = number.trim().parse::<u64>().ok()?;
let multiplier = match unit.as_str() {
"s" => 1,
"m" => 60,
"h" => 60 * 60,
"d" => 24 * 60 * 60,
_ => return None,
};
number.checked_mul(multiplier)
}
fn parse_bool(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
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 {
return None;
}
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 {
return None;
}
Some(value)
}
fn parse_u64(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
fn parse_positive_usize(raw: &str) -> Option<usize> {
let value = raw.trim().parse::<usize>().ok()?;
if value == 0 {
return None;
}
Some(value)
}
fn parse_u8(raw: &str) -> Option<u8> {
raw.trim().parse::<u8>().ok()
}
fn parse_positive_u16(raw: &str) -> Option<u16> {
let value = raw.trim().parse::<u16>().ok()?;
if value == 0 {
return None;
}
Some(value)
}
#[cfg(test)]
mod tests {
use super::AppConfig;
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[test]
fn from_env_reads_spacetime_pool_size() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE");
std::env::set_var("GENARRATIVE_SPACETIME_POOL_SIZE", "7");
}
let config = AppConfig::from_env();
assert_eq!(config.spacetime_pool_size, 7);
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE");
}
}
#[test]
fn from_env_ignores_zero_spacetime_pool_size() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE");
std::env::set_var("GENARRATIVE_SPACETIME_POOL_SIZE", "0");
}
let config = AppConfig::from_env();
assert_eq!(config.spacetime_pool_size, 4);
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE");
}
}
#[test]
fn from_env_reads_rpg_llm_web_search_switch() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false");
}
let config = AppConfig::from_env();
assert!(!config.rpg_llm_web_search_enabled);
unsafe {
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
}
}
}

View File

@@ -0,0 +1,127 @@
{
"templates": [
{
"templateId": "rpg_world",
"displayName": "RPG 世界共创",
"creationGoal": "收束成可直接进入 RPG 运行时的世界、角色、冲突、入口与隐藏线索。",
"anchorQuestions": [
{
"key": "worldPromise",
"label": "世界承诺",
"question": "这个世界最想让玩家体验到什么独特承诺?",
"requiredEffect": "明确世界钩子、差异化体验与玩家期待。"
},
{
"key": "playerFantasy",
"label": "玩家幻想",
"question": "玩家进入后扮演谁、追求什么、害怕失去什么?",
"requiredEffect": "明确身份、目标与核心情绪压力。"
},
{
"key": "themeBoundary",
"label": "题材边界",
"question": "题材气质、视觉方向和禁忌边界分别是什么?",
"requiredEffect": "约束语气、审美与不可越界内容。"
},
{
"key": "playerEntryPoint",
"label": "玩家入口",
"question": "玩家第一幕以什么身份遇到什么问题?",
"requiredEffect": "形成可开局的身份、问题和行动动机。"
},
{
"key": "coreConflict",
"label": "核心冲突",
"question": "表层冲突、隐藏危机和首个触发冲突是什么?",
"requiredEffect": "建立运行时持续推进的矛盾发动机。"
},
{
"key": "keyRelationships",
"label": "关键关系",
"question": "哪些人物关系最能推动选择、秘密和代价?",
"requiredEffect": "形成 NPC、势力或亲密关系的互动张力。"
},
{
"key": "hiddenLine",
"label": "隐藏线",
"question": "世界真相如何被误导、铺垫并逐步揭示?",
"requiredEffect": "为中后期探索与反转留下结构化线索。"
},
{
"key": "iconicElements",
"label": "标志元素",
"question": "哪些标志物、机构、规则或意象让世界被记住?",
"requiredEffect": "提供可复用的视觉、剧情和系统记忆点。"
}
]
},
{
"templateId": "puzzle",
"displayName": "拼图共创",
"creationGoal": "收束成可以发布为拼图关卡的视觉方案。",
"anchorQuestions": [
{
"key": "themePromise",
"label": "题材承诺",
"question": "这张拼图给玩家的题材和完成期待是什么?",
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
},
{
"key": "visualSubject",
"label": "画面主体",
"question": "画面中最需要被玩家一眼看懂的主体是什么?",
"requiredEffect": "明确主体、层级和可被切片识别的形状。"
},
{
"key": "visualMood",
"label": "视觉气质",
"question": "整体色彩、光线、情绪和美术风格是什么?",
"requiredEffect": "收束画面风格,避免结果图风格漂移。"
},
{
"key": "compositionHooks",
"label": "拼图记忆点",
"question": "哪些构图、纹理或局部细节会成为玩家拼接线索?",
"requiredEffect": "提供适合拼图玩法的边缘、块面和局部记忆点。"
},
{
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"question": "需要保留哪些关键词,又必须避开什么内容?",
"requiredEffect": "锁定生成标签和负向约束。"
}
]
},
{
"templateId": "big_fish",
"displayName": "大鱼吃小鱼共创",
"creationGoal": "收束成可直接编译为竖屏大鱼吃小鱼玩法草稿的成长、生态、节奏方案。",
"anchorQuestions": [
{
"key": "gameplayPromise",
"label": "玩法承诺",
"question": "这版大鱼吃小鱼最核心的吞噬成长爽点是什么?",
"requiredEffect": "明确玩家为什么要持续吞噬、升级和冒险。"
},
{
"key": "ecologyVisualTheme",
"label": "生态视觉主题",
"question": "鱼群、场景和敌我生态的视觉主题是什么?",
"requiredEffect": "提供后续角色图、动作图和背景图的一致视觉方向。"
},
{
"key": "growthLadder",
"label": "成长阶梯",
"question": "从弱小到终局巨兽的 6 到 12 级成长阶梯如何递进?",
"requiredEffect": "保证等级轮廓、体型、能力幻想逐级增强。"
},
{
"key": "riskTempo",
"label": "风险节奏",
"question": "玩家在每个阶段面对的威胁压力和爽快节奏如何变化?",
"requiredEffect": "确定猎物、威胁、压迫感和爽感之间的节奏比例。"
}
]
}
]
}

View File

@@ -0,0 +1,67 @@
use std::sync::OnceLock;
use serde::Deserialize;
const ANCHOR_TEMPLATE_CONFIG: &str = include_str!("creation_agent_anchor_templates.json");
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreationAgentAnchorTemplateConfig {
templates: Vec<CreationAgentAnchorTemplate>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreationAgentAnchorTemplate {
pub template_id: String,
pub display_name: String,
pub creation_goal: String,
pub anchor_questions: Vec<CreationAgentAnchorQuestion>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreationAgentAnchorQuestion {
pub key: String,
pub label: String,
pub question: String,
pub required_effect: String,
}
static ANCHOR_TEMPLATES: OnceLock<CreationAgentAnchorTemplateConfig> = OnceLock::new();
pub(crate) fn get_creation_agent_anchor_template(
template_id: &str,
) -> Option<&'static CreationAgentAnchorTemplate> {
load_creation_agent_anchor_templates()
.templates
.iter()
.find(|template| template.template_id == template_id)
}
pub(crate) fn render_anchor_question_block(template: &CreationAgentAnchorTemplate) -> String {
let mut lines = vec![
format!("模板目标:{}", template.creation_goal),
"".to_string(),
];
lines.push("本模板只通过以下锚点问题体现差异:".to_string());
for (index, question) in template.anchor_questions.iter().enumerate() {
lines.push(format!(
"{}. {}{} / {}{};达成效果:{}",
index + 1,
question.label,
question.key,
template.display_name,
question.question,
question.required_effect
));
}
lines.join("\n")
}
fn load_creation_agent_anchor_templates() -> &'static CreationAgentAnchorTemplateConfig {
ANCHOR_TEMPLATES.get_or_init(|| {
serde_json::from_str(ANCHOR_TEMPLATE_CONFIG)
.expect("creation_agent_anchor_templates.json 必须是合法配置")
})
}

View File

@@ -0,0 +1,25 @@
/// 共创聊天中“补充剩余关键字”的统一提示规则。
///
/// RPG、拼图、大鱼吃小鱼都通过聊天补充设定这里集中维护点击补全后必须进入
/// 自行补齐、不可继续追问、进度推进到可提交状态的公共约束,避免各入口各写一套。
pub(crate) fn render_quick_fill_extra_rules(
acceptance_scope: &str,
forbidden_follow_up: &str,
completion_target: &str,
submit_hint: &str,
) -> String {
format!(
r#"用户刚刚主动要求你自动补充剩余关键字。
这表示用户接受你基于当前方向自行补完仍缺失的关键设定:{acceptance_scope}
本轮要求:
1. 不要再继续提问
2. {forbidden_follow_up}
3. 必须保留已有已确认内容,并直接补齐缺失或仍为空的关键项
4. 对你自行推断补齐的项,应标记或表达为系统推断;已有明确内容继续保持确认或锁定状态
5. progressPercent 直接输出为 100
6. {completion_target}
7. replyText 只做简短完成说明,引导用户可以{submit_hint},不能出现问号"#,
)
}

View File

@@ -0,0 +1,324 @@
use axum::{Json, extract::Extension, http::StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde_json::{Value, json};
use shared_contracts::creation_agent_document_input::{
CreationAgentDocumentInputPayload, ParseCreationAgentDocumentInputRequest,
ParseCreationAgentDocumentInputResponse,
};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
};
const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 1024;
const MAX_DOCUMENT_INPUT_BASE64_CHARS: usize = 360 * 1024;
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "csv", "json"];
pub async fn parse_creation_agent_document_input(
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<ParseCreationAgentDocumentInputRequest>,
) -> Result<Json<Value>, AppError> {
let file_name = normalize_file_name(&payload.file_name)?;
ensure_supported_extension(&file_name)?;
let content_base64 = payload.content_base64.trim();
if content_base64.len() > MAX_DOCUMENT_INPUT_BASE64_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
})),
);
}
let decoded = BASE64_STANDARD.decode(content_base64).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容编码无效,请重新选择文件。",
"field": "contentBase64",
}))
})?;
if decoded.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容为空,请选择有内容的文件。",
"field": "contentBase64",
})),
);
}
if decoded.len() > MAX_DOCUMENT_INPUT_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
"actualSizeBytes": decoded.len(),
})),
);
}
let text = String::from_utf8(decoded.clone()).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})?;
let normalized_text = normalize_document_text(&text);
if normalized_text.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档解析后没有可用文本,请换一个文件。",
"field": "contentBase64",
})),
);
}
Ok(json_success_body(
Some(&request_context),
ParseCreationAgentDocumentInputResponse {
document: CreationAgentDocumentInputPayload {
file_name,
content_type: payload
.content_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
size_bytes: decoded.len(),
text: normalized_text,
},
},
))
}
fn normalize_file_name(value: &str) -> Result<String, AppError> {
let normalized = value
.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.trim()
.to_string();
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "缺少文档文件名。",
"field": "fileName",
})),
);
}
Ok(normalized)
}
fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
let extension = file_name
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))?;
if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) {
return Err(unsupported_document_error(file_name));
}
Ok(())
}
fn unsupported_document_error(file_name: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 txt、md、csv、json 文本文档。",
"field": "fileName",
"fileName": file_name,
"supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS,
}))
}
fn normalize_document_text(value: &str) -> String {
value
.trim_start_matches('\u{feff}')
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use axum::{body::Body, http::Request};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use http_body_util::BodyExt;
use module_auth::{PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use time::OffsetDateTime;
use tower::ServiceExt;
use super::MAX_DOCUMENT_INPUT_BASE64_CHARS;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn parse_document_input_returns_text_payload() {
let state = build_test_state("ok").await;
let access_token = seed_authenticated_token(&state, "13800138110").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.md",
"contentType": "text/markdown",
"contentBase64": BASE64_STANDARD.encode("第一章\r\n潮湿的港口")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::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"]["document"]["fileName"],
json!("世界设定.md")
);
assert_eq!(
payload["data"]["document"]["text"],
json!("第一章\n潮湿的港口")
);
}
#[tokio::test]
async fn parse_document_input_rejects_unsupported_extension() {
let state = build_test_state("bad-ext").await;
let access_token = seed_authenticated_token(&state, "13800138111").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.docx",
"contentBase64": BASE64_STANDARD.encode("binary")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn parse_document_input_rejects_large_base64_before_decode() {
let state = build_test_state("large-base64").await;
let access_token = seed_authenticated_token(&state, "13800138112").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.txt",
"contentBase64": "A".repeat(MAX_DOCUMENT_INPUT_BASE64_CHARS + 1)
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
async fn seed_authenticated_token(state: &AppState, phone_number: &str) -> String {
let now = OffsetDateTime::now_utc();
state
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
phone_number: phone_number.to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let user = state
.phone_auth_service()
.login(
PhoneLoginInput {
phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
)
.await
.expect("phone login should create user")
.user;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id,
session_id: "sess_creation_doc_input".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
async fn build_test_state(label: &str) -> AppState {
let mut config = AppConfig::default();
config.auth_store_path = PathBuf::from(format!(
".codex-temp/api-server-auth-store-creation-doc-{label}.json"
));
let _ = std::fs::remove_file(&config.auth_store_path);
AppState::new(config).expect("state should build")
}
}

View File

@@ -0,0 +1,170 @@
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde_json::Value as JsonValue;
#[derive(Clone, Copy, Debug)]
pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> {
pub model_unavailable: &'a str,
pub generation_failed: &'a str,
pub parse_failed: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct CreationAgentJsonTurnOutput {
pub parsed: JsonValue,
}
/**
* 创作 Agent 的通用流式 JSON turn 调用。
* 这里只处理跨玩法一致的 LLM 调用骨架prompt 内容和领域 JSON 解析仍由调用方负责。
*/
pub(crate) async fn stream_creation_agent_json_turn<F, E>(
llm_client: Option<&LlmClient>,
system_prompt: String,
user_prompt: impl Into<String>,
messages: CreationAgentLlmTurnErrorMessages<'_>,
mut on_reply_update: F,
build_error: impl Fn(String) -> E,
) -> Result<CreationAgentJsonTurnOutput, E>
where
F: FnMut(&str),
{
let llm_client =
llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?;
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt.into()),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| build_error(messages.generation_failed.to_string()))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| build_error(messages.parse_failed.to_string()))?;
let reply_text = read_reply_text(&parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
{
on_reply_update(reply_text);
}
Ok(CreationAgentJsonTurnOutput { parsed })
}
pub(crate) async fn request_creation_agent_json_turn<E>(
llm_client: &LlmClient,
system_prompt: String,
user_prompt: String,
build_error: impl Fn(String) -> E,
) -> Result<JsonValue, E> {
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| build_error(error.to_string()))?;
parse_json_response_text(response.content.as_str())
.map_err(|error| build_error(error.to_string()))
}
pub(crate) fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
pub(crate) fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
fn read_reply_text(parsed: &JsonValue) -> Option<String> {
parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::{extract_reply_text_from_partial_json, parse_json_response_text};
#[test]
fn extracts_reply_text_from_partial_json_with_chinese_text() {
let partial_json = r#"{"replyText":"你好,潮雾列岛","progressPercent":32"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("你好,潮雾列岛"));
}
#[test]
fn parses_json_inside_model_markdown_noise() {
let parsed = parse_json_response_text("```json\n{\"replyText\":\"\"}\n```")
.expect("应能截取模型返回中的 JSON 对象");
assert_eq!(parsed["replyText"].as_str(), Some(""));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,640 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT: &str =
"你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。";
const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT: &str =
"你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldGeneratedEntitiesResult {
pub payload_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CustomWorldGeneratedEntitiesPayloadError {
SerializePayload(String),
InvalidPayloadShape,
}
pub async fn generate_custom_world_agent_entities(
llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord,
payload: &ExecuteCustomWorldAgentActionRequest,
) -> Result<CustomWorldGeneratedEntitiesResult, String> {
let action = payload.action.trim();
let draft_profile = session
.draft_profile
.as_object()
.ok_or_else(|| format!("{action} requires an existing draft foundation"))?;
let count = ensure_count(payload.count);
let prompt_seed = payload
.prompt_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("没有额外要求,围绕当前底稿自然扩展。");
let anchor_summary = build_anchor_summary(
draft_profile,
payload.anchor_card_ids.as_deref().unwrap_or(&[]),
);
let creator_intent_summary = session
.anchor_pack
.get("creatorIntentSummary")
.and_then(JsonValue::as_str)
.or_else(|| {
session
.creator_intent
.get("worldHook")
.and_then(JsonValue::as_str)
})
.or_else(|| draft_profile.get("summary").and_then(JsonValue::as_str))
.unwrap_or_default();
let world_name = draft_profile
.get("name")
.and_then(JsonValue::as_str)
.unwrap_or("未命名世界");
let world_summary = draft_profile
.get("summary")
.and_then(JsonValue::as_str)
.unwrap_or_default();
let (system_prompt, user_prompt, result_key) = match action {
"generate_characters" => (
CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT,
build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams {
world_name,
world_summary,
creator_intent_summary,
anchor_summary: anchor_summary.as_str(),
existing_names: existing_character_names(draft_profile),
count,
prompt_seed,
}),
"generatedCharacters",
),
"generate_landmarks" => (
CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT,
build_custom_world_agent_landmark_expansion_prompt(ExpansionPromptParams {
world_name,
world_summary,
creator_intent_summary,
anchor_summary: anchor_summary.as_str(),
existing_names: existing_landmark_names(draft_profile),
count,
prompt_seed,
}),
"generatedLandmarks",
),
_ => return Err(format!("unsupported generated entity action: {action}")),
};
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| format!("{action} LLM 请求失败:{error}"))?;
let generated_entities = parse_json_array_response(response.content.as_str())
.map_err(|error| format!("{action} JSON 解析失败:{error}"))?;
let normalized_entities =
normalize_generated_entities(action, generated_entities, draft_profile, count);
let payload_json =
build_generated_entities_action_payload_json(payload, result_key, normalized_entities)
.map_err(|error| match error {
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(message) => message,
CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape => {
"action payload 必须是 object".to_string()
}
})?;
Ok(CustomWorldGeneratedEntitiesResult { payload_json })
}
pub fn build_generated_entities_action_payload_json(
payload: &ExecuteCustomWorldAgentActionRequest,
result_key: &str,
generated_entities: Vec<JsonValue>,
) -> Result<String, CustomWorldGeneratedEntitiesPayloadError> {
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})?;
let payload_object = payload_value
.as_object_mut()
.ok_or(CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape)?;
if payload.action.trim() == "generate_characters" {
payload_object.insert(
"roleType".to_string(),
JsonValue::String(resolve_role_type(payload.role_type.as_deref()).to_string()),
);
}
payload_object.insert(result_key.to_string(), JsonValue::Array(generated_entities));
serde_json::to_string(&payload_value).map_err(|error| {
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})
}
struct ExpansionPromptParams<'a> {
world_name: &'a str,
world_summary: &'a str,
creator_intent_summary: &'a str,
anchor_summary: &'a str,
existing_names: Vec<String>,
count: u32,
prompt_seed: &'a str,
}
fn build_custom_world_agent_character_expansion_prompt(
params: ExpansionPromptParams<'_>,
) -> String {
[
format!("当前世界:{}", params.world_name),
format!("世界摘要:{}", params.world_summary),
format!("创作意图摘要:{}", params.creator_intent_summary),
format!("参考锚点:{}", params.anchor_summary),
format!(
"已有角色:{}",
if params.existing_names.is_empty() {
"暂无".to_string()
} else {
params.existing_names.join("")
}
),
format!("数量:{}", params.count),
format!(
"补充要求:{}",
if params.prompt_seed.trim().is_empty() {
"没有额外要求,围绕当前底稿自然扩展。"
} else {
params.prompt_seed
}
),
"返回 JSON 数组。每个对象字段只允许包含name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。".to_string(),
"threadIds 必须优先引用现有线程 id。".to_string(),
]
.join("\n")
}
fn build_custom_world_agent_landmark_expansion_prompt(params: ExpansionPromptParams<'_>) -> String {
[
format!("当前世界:{}", params.world_name),
format!("世界摘要:{}", params.world_summary),
format!("创作意图摘要:{}", params.creator_intent_summary),
format!("参考锚点:{}", params.anchor_summary),
format!(
"已有地点:{}",
if params.existing_names.is_empty() {
"暂无".to_string()
} else {
params.existing_names.join("")
}
),
format!("数量:{}", params.count),
format!(
"补充要求:{}",
if params.prompt_seed.trim().is_empty() {
"没有额外要求,围绕当前底稿自然扩展。"
} else {
params.prompt_seed
}
),
"返回 JSON 数组。每个对象字段只允许包含name, purpose, mood, secret, summary, threadIds, characterIds。".to_string(),
"threadIds / characterIds 必须优先引用现有对象 id。".to_string(),
]
.join("\n")
}
fn ensure_count(count: Option<u32>) -> u32 {
count.unwrap_or(1).clamp(1, 3)
}
fn resolve_role_type(role_type: Option<&str>) -> &'static str {
match role_type.map(str::trim) {
Some("playable") => "playable",
_ => "story",
}
}
fn parse_json_array_response(text: &str) -> Result<Vec<JsonValue>, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('[')
&& let Some(end) = trimmed.rfind(']')
&& end >= start
{
return serde_json::from_str::<Vec<JsonValue>>(&trimmed[start..=end]);
}
serde_json::from_str::<Vec<JsonValue>>(trimmed)
}
fn normalize_generated_entities(
action: &str,
entities: Vec<JsonValue>,
draft_profile: &JsonMap<String, JsonValue>,
count: u32,
) -> Vec<JsonValue> {
let mut existing_names = if action == "generate_characters" {
existing_character_names(draft_profile)
} else {
existing_landmark_names(draft_profile)
};
entities
.into_iter()
.filter_map(|entry| entry.as_object().cloned())
.filter_map(|mut object| {
let name = object
.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
if existing_names.iter().any(|entry| entry == &name) {
return None;
}
existing_names.push(name.clone());
let prefix = if action == "generate_characters" {
"character"
} else {
"landmark"
};
object.entry("id".to_string()).or_insert_with(|| {
JsonValue::String(create_stable_id(
prefix,
name.as_str(),
existing_names.len(),
))
});
normalize_generated_entity_profile_fields(action, &mut object);
Some(JsonValue::Object(object))
})
.take(count as usize)
.collect()
}
fn normalize_generated_entity_profile_fields(
action: &str,
object: &mut JsonMap<String, JsonValue>,
) {
if action == "generate_characters" {
normalize_generated_character_profile_fields(object);
} else {
normalize_generated_landmark_profile_fields(object);
}
}
fn normalize_generated_character_profile_fields(object: &mut JsonMap<String, JsonValue>) {
let name = read_object_text(object, "name").unwrap_or_else(|| "新场景角色".to_string());
let role = read_object_text(object, "role").unwrap_or_else(|| "场景角色".to_string());
let summary = read_first_object_text(object, &["description", "summary", "publicMask"])
.unwrap_or_else(|| format!("{name}是围绕当前世界新补出的{role}"));
let hidden_hook = read_object_text(object, "hiddenHook").unwrap_or_else(|| summary.clone());
insert_text_if_missing(object, "title", role.as_str());
insert_text_if_missing(object, "description", summary.as_str());
insert_text_if_missing(object, "backstory", hidden_hook.as_str());
insert_text_if_missing(object, "personality", "待在后续互动中揭示");
insert_text_if_missing(object, "motivation", hidden_hook.as_str());
insert_text_if_missing(object, "combatStyle", "围绕自身身份采取行动");
object
.entry("initialAffinity".to_string())
.or_insert_with(|| JsonValue::Number(6.into()));
if !object
.get("relationshipHooks")
.is_some_and(JsonValue::is_array)
{
let mut hooks = Vec::new();
if let Some(relation) = read_object_text(object, "relationToPlayer") {
hooks.push(JsonValue::String(relation));
}
if hooks.is_empty() {
hooks.push(JsonValue::String("等待玩家接触".to_string()));
}
object.insert("relationshipHooks".to_string(), JsonValue::Array(hooks));
}
if !object.get("tags").is_some_and(JsonValue::is_array) {
object.insert(
"tags".to_string(),
JsonValue::Array(vec![JsonValue::String(role)]),
);
}
}
fn normalize_generated_landmark_profile_fields(object: &mut JsonMap<String, JsonValue>) {
let name = read_object_text(object, "name").unwrap_or_else(|| "新场景".to_string());
let description = read_first_object_text(object, &["description", "summary", "purpose"])
.unwrap_or_else(|| format!("{name}是围绕当前世界新补出的关键场景。"));
let mut description_parts = vec![description];
for key in ["mood", "secret"] {
if let Some(text) = read_object_text(object, key)
&& !description_parts.iter().any(|entry| entry == &text)
{
description_parts.push(text);
}
}
insert_text_if_missing(object, "description", description_parts.join(" ").as_str());
object
.entry("connections".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
if !object.get("sceneNpcIds").is_some_and(JsonValue::is_array) {
let npc_ids = object
.get("characterIds")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
object.insert("sceneNpcIds".to_string(), JsonValue::Array(npc_ids));
}
}
fn read_object_text(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn read_first_object_text(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| read_object_text(object, key))
}
fn insert_text_if_missing(object: &mut JsonMap<String, JsonValue>, key: &str, value: &str) {
if read_object_text(object, key).is_none() {
object.insert(key.to_string(), JsonValue::String(value.to_string()));
}
}
fn existing_character_names(draft_profile: &JsonMap<String, JsonValue>) -> Vec<String> {
["playableNpcs", "storyNpcs"]
.into_iter()
.flat_map(|key| object_array_names(draft_profile.get(key)))
.take(10)
.collect()
}
fn existing_landmark_names(draft_profile: &JsonMap<String, JsonValue>) -> Vec<String> {
object_array_names(draft_profile.get("landmarks"))
.into_iter()
.take(10)
.collect()
}
fn object_array_names(value: Option<&JsonValue>) -> Vec<String> {
value
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter_map(|entry| entry.get("name").and_then(JsonValue::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn build_anchor_summary(
draft_profile: &JsonMap<String, JsonValue>,
anchor_card_ids: &[String],
) -> String {
let selected = anchor_card_ids
.iter()
.filter_map(|card_id| find_anchor_summary(draft_profile, card_id))
.collect::<Vec<_>>();
if !selected.is_empty() {
return selected.join("");
}
draft_profile
.get("summary")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("围绕当前世界底稿自然扩展。")
.to_string()
}
fn find_anchor_summary(
draft_profile: &JsonMap<String, JsonValue>,
card_id: &str,
) -> Option<String> {
for key in ["playableNpcs", "storyNpcs", "landmarks", "threads"] {
for entry in draft_profile.get(key)?.as_array()? {
let object = entry.as_object()?;
let id = object
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or_default();
if id != card_id {
continue;
}
let name = object
.get("name")
.and_then(JsonValue::as_str)
.unwrap_or_default();
let summary = object
.get("summary")
.or_else(|| object.get("description"))
.or_else(|| object.get("publicMask"))
.and_then(JsonValue::as_str)
.unwrap_or_default();
return Some(format!("{name}{summary}"));
}
}
None
}
fn create_stable_id(prefix: &str, name: &str, index: usize) -> String {
let slug = name
.trim()
.to_lowercase()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch) {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
format!(
"{prefix}-{}-{index}",
if slug.is_empty() {
"entry"
} else {
slug.as_str()
}
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn character_expansion_prompt_keeps_node_contract_text() {
let prompt = build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams {
world_name: "雾港归航",
world_summary: "守灯人追查旧案。",
creator_intent_summary: "悬疑航海",
anchor_summary: "旧灯塔:灯火错位",
existing_names: vec!["岑灯".to_string()],
count: 2,
prompt_seed: "补一个敌对角色",
});
assert!(prompt.contains("返回 JSON 数组。每个对象字段只允许包含name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。"));
assert!(prompt.contains("threadIds 必须优先引用现有线程 id。"));
}
#[test]
fn generated_entities_payload_injects_expected_key() {
let payload = ExecuteCustomWorldAgentActionRequest {
action: "generate_landmarks".to_string(),
profile_id: None,
draft_profile: None,
legacy_result_profile: None,
setting_text: None,
card_id: None,
sections: None,
profile: None,
count: Some(1),
role_type: None,
prompt_text: Some("补地点".to_string()),
anchor_card_ids: None,
role_ids: None,
role_id: None,
portrait_path: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
scene_ids: None,
scene_id: None,
scene_kind: None,
image_src: None,
generated_scene_asset_id: None,
generated_scene_prompt: None,
generated_scene_model: None,
checkpoint_id: None,
};
let payload_json = build_generated_entities_action_payload_json(
&payload,
"generatedLandmarks",
vec![json!({ "name": "沉船湾" })],
)
.expect("payload should build");
let value = serde_json::from_str::<JsonValue>(&payload_json).expect("payload should parse");
assert_eq!(value.get("action"), Some(&json!("generate_landmarks")));
assert_eq!(
value
.get("generatedLandmarks")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(1)
);
}
#[test]
fn generated_character_payload_fills_result_profile_fields() {
let draft_profile = json!({
"playableNpcs": [],
"storyNpcs": [],
"landmarks": []
});
let entities = normalize_generated_entities(
"generate_characters",
vec![json!({
"name": "潮雾证人",
"role": "旧案目击者",
"publicMask": "总在码头边缘售卖旧航图。",
"hiddenHook": "他记得沉钟第一次响起时失踪的人。",
"relationToPlayer": "掌握玩家亲族旧案线索"
})],
draft_profile
.as_object()
.expect("draft profile should be object"),
1,
);
let character = entities[0]
.as_object()
.expect("generated character should be object");
assert_eq!(
character.get("description").and_then(JsonValue::as_str),
Some("总在码头边缘售卖旧航图。")
);
assert_eq!(
character.get("backstory").and_then(JsonValue::as_str),
Some("他记得沉钟第一次响起时失踪的人。")
);
assert_eq!(
character
.get("relationshipHooks")
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str),
Some("掌握玩家亲族旧案线索")
);
}
#[test]
fn generated_landmark_payload_fills_scene_profile_fields() {
let draft_profile = json!({
"playableNpcs": [],
"storyNpcs": [{ "id": "character-witness", "name": "潮雾证人" }],
"landmarks": []
});
let entities = normalize_generated_entities(
"generate_landmarks",
vec![json!({
"name": "沉钟码头",
"purpose": "玩家第一次追查沉钟旧案的入口。",
"mood": "潮湿、压抑、灯火忽明忽暗。",
"secret": "码头木桩下藏着改写航道的符牌。",
"characterIds": ["character-witness"]
})],
draft_profile
.as_object()
.expect("draft profile should be object"),
1,
);
let landmark = entities[0]
.as_object()
.expect("generated landmark should be object");
assert!(
landmark
.get("description")
.and_then(JsonValue::as_str)
.is_some_and(|text| text.contains("沉钟旧案") && text.contains("符牌"))
);
assert_eq!(
landmark
.get("sceneNpcIds")
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str),
Some("character-witness")
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
pub(crate) use crate::prompt::character_animation::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
};
pub(crate) use crate::prompt::character_visual::{
build_character_visual_negative_prompt, build_character_visual_prompt,
build_fallback_moderation_safe_character_visual_prompt,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
use serde_json::{Value, json};
/// 结果页新增可扮演角色 / 场景角色 / 场景的提示词脚本。
/// 这里只生成 LLM 可审计输入,不处理 fallback避免提示词规则和业务兜底混在一起。
pub(crate) fn build_result_entity_system_prompt() -> &'static str {
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。"
}
pub(crate) fn build_result_entity_user_prompt(
profile: &Value,
kind: &str,
fallback: &Value,
) -> String {
json!({
"task": "generate_custom_world_entity",
"kind": kind,
"profile": profile,
"fallback": fallback,
})
.to_string()
}
pub(crate) fn build_result_scene_npc_system_prompt() -> &'static str {
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。"
}
pub(crate) fn build_result_scene_npc_user_prompt(
profile: &Value,
landmark_id: &str,
fallback: &Value,
) -> String {
json!({
"task": "generate_custom_world_scene_npc",
"landmarkId": landmark_id,
"profile": profile,
"fallback": fallback,
})
.to_string()
}

View File

@@ -0,0 +1 @@
pub(crate) use crate::prompt::agent_chat::*;

View File

@@ -0,0 +1,50 @@
use axum::{extract::Request, http::header::CONTENT_TYPE, middleware::Next, response::Response};
use tracing::{error, warn};
use crate::{
http_error::AppError,
request_context::{RequestContext, resolve_request_id},
};
pub async fn normalize_error_response(request: Request, next: Next) -> Response {
let request_id = resolve_request_id(&request);
let request_context = request.extensions().get::<RequestContext>().cloned();
let response = next.run(request).await;
if !should_normalize_error_response(&response) {
return response;
}
let app_error = AppError::from_status(response.status());
let status = response.status();
let request_id = request_id.as_deref().unwrap_or("unknown");
if status.is_server_error() {
error!(
%request_id,
status = status.as_u16(),
error_code = app_error.code(),
"request failed"
);
} else {
warn!(
%request_id,
status = status.as_u16(),
error_code = app_error.code(),
"request failed"
);
}
app_error.into_response_with_context(request_context.as_ref())
}
fn should_normalize_error_response(response: &Response) -> bool {
let status = response.status();
if !(status.is_client_error() || status.is_server_error()) {
return false;
}
// 当前阶段只兜底框架默认的空错误响应或非 JSON 错误响应,避免覆盖后续业务 handler 主动构造的错误 body。
!response.headers().contains_key(CONTENT_TYPE)
}

View File

@@ -0,0 +1,14 @@
use axum::{Json, extract::Extension};
use serde_json::{Value, json};
use crate::{api_response::json_success_body, request_context::RequestContext};
pub async fn health_check(Extension(request_context): Extension<RequestContext>) -> Json<Value> {
json_success_body(
Some(&request_context),
json!({
"ok": true,
"service": "genarrative-api-server",
}),
)
}

View File

@@ -0,0 +1,102 @@
use axum::{
http::StatusCode,
http::{HeaderMap, HeaderValue},
response::{IntoResponse, Response},
};
use serde_json::Value;
use shared_contracts::api::ApiErrorPayload;
use crate::{api_response::json_error_body, request_context::RequestContext};
#[derive(Debug)]
pub struct AppError {
status_code: StatusCode,
code: &'static str,
message: String,
details: Option<Value>,
headers: HeaderMap,
}
impl AppError {
pub fn from_status(status_code: StatusCode) -> Self {
let (code, message) = resolve_http_error(status_code);
Self {
status_code,
code,
message: message.to_string(),
details: None,
headers: HeaderMap::new(),
}
}
pub fn code(&self) -> &'static str {
self.code
}
pub fn message(&self) -> &str {
&self.message
}
pub fn body_text(&self) -> String {
// 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。
self.details
.as_ref()
.and_then(|details| details.get("message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.unwrap_or(self.message.as_str())
.to_string()
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
}
pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response {
let status_code = self.status_code;
let payload = self.to_payload();
let mut response =
(status_code, json_error_body(request_context, &payload)).into_response();
response.headers_mut().extend(self.headers);
response
}
fn to_payload(&self) -> ApiErrorPayload {
ApiErrorPayload::new(self.code, self.message.clone(), self.details.clone())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
self.into_response_with_context(None)
}
}
fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
match status_code {
StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"),
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", "上游服务请求失败"),
StatusCode::SERVICE_UNAVAILABLE => ("SERVICE_UNAVAILABLE", "服务暂不可用"),
_ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"),
_ => ("INTERNAL_SERVER_ERROR", "服务器内部错误"),
}
}

View File

@@ -0,0 +1,251 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
use serde_json::json;
use crate::{http_error::AppError, state::AppState};
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
pub async fn proxy_generated_character_drafts(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await
}
pub async fn proxy_generated_characters(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await
}
pub async fn proxy_generated_animations(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await
}
pub async fn proxy_generated_big_fish_assets(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::BigFishAssets, path).await
}
pub async fn proxy_generated_custom_world_scenes(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await
}
pub async fn proxy_generated_custom_world_covers(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await
}
pub async fn proxy_generated_qwen_sprites(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await
}
async fn proxy_legacy_generated_asset(
state: AppState,
prefix: LegacyAssetPrefix,
path: String,
) -> Response {
match read_legacy_generated_asset(&state, prefix, path).await {
Ok(response) => response,
Err(error) => error.into_response(),
}
}
async fn read_legacy_generated_asset(
state: &AppState,
prefix: LegacyAssetPrefix,
path: String,
) -> Result<Response, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let object_key = build_generated_object_key(prefix, path.as_str())?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.clone(),
expire_seconds: Some(60),
})
.map_err(map_legacy_generated_oss_error)?;
let upstream_response = reqwest::Client::new()
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
}))
})?;
if upstream_response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
})),
);
}
let status = upstream_response.status();
let content_type = upstream_response
.headers()
.get(header::CONTENT_TYPE)
.cloned();
if !status.is_success() {
return Err(map_legacy_generated_upstream_status(status, object_key));
}
let bytes = upstream_response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
}))
})?;
// 旧 generated 路径会被 <img> / <video> 直接消费,成功分支必须返回原始二进制体。
// 这里显式组装 HeaderMap 并设置长度,避免代理层把已成功读取的 OSS 对象变成空响应。
let mut headers = HeaderMap::new();
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CACHE_CONTROL_VALUE),
);
headers.insert(
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源响应头失败:{error}"),
}))
})?,
);
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(bytes.len().to_string().as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源长度响应头失败:{error}"),
}))
})?,
);
if let Some(content_type) = content_type {
headers.insert(header::CONTENT_TYPE, content_type);
}
Ok((status, headers, bytes).into_response())
}
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
let path = path.trim().trim_matches('/');
if path.is_empty() || path.split('/').any(is_invalid_path_segment) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "legacy-generated-assets",
"message": "generated 资源路径不合法。",
})),
);
}
Ok(format!("{}/{}", prefix.as_str(), path))
}
fn is_invalid_path_segment(segment: &str) -> bool {
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
}
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
fn map_legacy_generated_upstream_status(
status: reqwest::StatusCode,
object_key: String,
) -> AppError {
let mapped_status = match status {
reqwest::StatusCode::NOT_FOUND => StatusCode::NOT_FOUND,
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(mapped_status).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
"upstreamStatus": status.as_u16(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_generated_object_key_keeps_supported_prefix() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::Animations,
"hero/animation-set-1/idle/frame01.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-animations/hero/animation-set-1/idle/frame01.png"
);
}
#[test]
fn build_generated_object_key_supports_big_fish_assets() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::BigFishAssets,
"big-fish-session-1/level-main-image/level-1/image.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-big-fish-assets/big-fish-session-1/level-main-image/level-1/image.png"
);
}
#[test]
fn build_generated_object_key_rejects_parent_segment() {
assert!(
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
);
}
}

View File

@@ -0,0 +1,373 @@
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,
enable_web_search: false,
};
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
.seed_test_phone_user_with_password("13800138101", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_llm_proxy".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
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
})
}
}

View File

@@ -0,0 +1,23 @@
use axum::{
Json,
extract::{Extension, State},
};
use shared_contracts::auth::{AuthLoginOptionsResponse, build_available_login_methods};
use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState};
pub async fn auth_login_options(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Json<serde_json::Value> {
json_success_body(
Some(&request_context),
AuthLoginOptionsResponse {
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
true,
state.config.wechat_auth_enabled,
),
},
)
}

View File

@@ -0,0 +1,65 @@
use axum::{
extract::{Extension, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::LogoutCurrentSessionInput;
use platform_auth::hash_refresh_session_token;
use shared_contracts::auth::LogoutResponse;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth::{AuthenticatedAccessToken, RefreshSessionToken},
auth_session::{
attach_set_cookie_header, build_clear_refresh_session_cookie_header, map_logout_error,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn logout(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<impl IntoResponse, AppError> {
let refresh_token_hash = maybe_refresh_token.and_then(|token| {
let token = token.0.token().trim().to_string();
if token.is_empty() {
return None;
}
Some(hash_refresh_session_token(&token))
});
state
.auth_user_service()
.logout_current_session(
LogoutCurrentSessionInput {
user_id: authenticated.claims().user_id().to_string(),
refresh_token_hash,
},
OffsetDateTime::now_utc(),
)
.map_err(map_logout_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_clear_refresh_session_cookie_header(&state)?,
);
Ok((
headers,
json_success_body(Some(&request_context), LogoutResponse { ok: true }),
))
}

View File

@@ -0,0 +1,53 @@
use axum::{
extract::{Extension, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::LogoutAllSessionsInput;
use shared_contracts::auth::LogoutAllResponse;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_session::{
attach_set_cookie_header, build_clear_refresh_session_cookie_header, map_logout_error,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn logout_all(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<impl IntoResponse, AppError> {
state
.auth_user_service()
.logout_all_sessions(
LogoutAllSessionsInput {
user_id: authenticated.claims().user_id().to_string(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_logout_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_clear_refresh_session_cookie_header(&state)?,
);
Ok((
headers,
json_success_body(Some(&request_context), LogoutAllResponse { ok: true }),
))
}

View File

@@ -0,0 +1,89 @@
mod admin;
mod ai_generation_drafts;
mod ai_tasks;
mod api_response;
mod app;
mod assets;
mod auth;
mod auth_me;
mod auth_payload;
mod auth_public_user;
mod auth_session;
mod auth_sessions;
mod big_fish;
mod big_fish_agent_turn;
mod character_animation_assets;
mod character_visual_assets;
mod config;
mod creation_agent_anchor_templates;
mod creation_agent_chat;
mod creation_agent_document_input;
mod creation_agent_llm_turn;
mod custom_world;
mod custom_world_agent_entities;
mod custom_world_agent_turn;
mod custom_world_ai;
mod custom_world_asset_prompts;
mod custom_world_foundation_draft;
mod custom_world_result_prompts;
mod custom_world_rpg_draft_prompts;
mod error_middleware;
mod health;
mod http_error;
mod legacy_generated_assets;
mod llm;
mod login_options;
mod logout;
mod logout_all;
mod password_entry;
mod password_management;
mod phone_auth;
mod prompt;
mod puzzle;
mod puzzle_agent_turn;
mod refresh_session;
mod request_context;
mod response_headers;
mod runtime_browse_history;
mod runtime_chat;
mod runtime_chat_prompt;
mod runtime_inventory;
mod runtime_profile;
mod runtime_save;
mod runtime_settings;
mod runtime_story;
mod session_client;
mod state;
mod story_battles;
mod story_sessions;
mod wechat_auth;
mod wechat_provider;
use shared_logging::init_tracing;
use tokio::net::TcpListener;
use tracing::info;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录的 .env / .env.local 加载变量,避免手工逐项导出 OSS 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();
init_tracing(&config.log_filter)?;
let bind_address = config.bind_socket_addr();
let listener = TcpListener::bind(bind_address).await?;
let state = AppState::try_restore_auth_store_from_spacetime(config)
.await
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
let router = build_router(state);
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
axum::serve(listener, router).await
}

View File

@@ -0,0 +1,92 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use serde_json::json;
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
use crate::{
api_response::json_success_body,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
},
http_error::AppError,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
pub async fn password_entry(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Json(payload): Json<PasswordEntryRequest>,
) -> Result<impl IntoResponse, AppError> {
let result = state
.password_entry_service()
.execute(PasswordEntryInput {
phone_number: payload.phone,
password: payload.password,
})
.await
.map_err(map_password_entry_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PasswordEntryResponse {
token: signed_session.access_token,
user: map_auth_user_payload(result.user),
},
),
))
}
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidPhoneNumber => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("手机号格式不正确")
.with_details(json!({
"field": "phone",
})),
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("密码长度需要在 6 到 128 位之间")
.with_details(json!({
"field": "password",
})),
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确")
.with_details(json!({
"field": "phone",
})),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
}
PasswordEntryError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
}
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -0,0 +1,117 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{ChangePasswordInput, PasswordEntryError, ResetPasswordInput};
use shared_contracts::auth::{
PasswordChangeRequest, PasswordChangeResponse, PasswordResetRequest, PasswordResetResponse,
};
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
phone_auth::map_phone_auth_error,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
pub async fn change_password(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PasswordChangeRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = state
.password_entry_service()
.change_password(ChangePasswordInput {
user_id: authenticated.claims().user_id().to_string(),
current_password: payload.current_password,
new_password: payload.new_password,
})
.await
.map_err(map_password_management_error)?;
Ok(json_success_body(
Some(&request_context),
PasswordChangeResponse {
user: map_auth_user_payload(result.user),
},
))
}
pub async fn reset_password(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Json(payload): Json<PasswordResetRequest>,
) -> Result<impl IntoResponse, AppError> {
if !state.config.sms_auth_enabled {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let result = state
.phone_auth_service()
.reset_password(
ResetPasswordInput {
phone_number: payload.phone,
verify_code: payload.code,
new_password: payload.new_password,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_phone_auth_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
module_auth::AuthLoginMethod::Password,
)?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PasswordResetResponse {
token: signed_session.access_token,
user: map_auth_user_payload(result.user),
},
),
))
}
fn map_password_management_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("密码长度需要在 6 到 128 位之间"),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("当前密码错误")
}
PasswordEntryError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"),
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -0,0 +1,255 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::IntoResponse,
};
use module_auth::{
AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput,
};
use serde_json::json;
use shared_contracts::auth::{
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse,
};
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::{
api_response::json_success_body,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
pub async fn send_phone_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<PhoneSendCodeRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
// 短信登录开关由服务端配置统一控制,避免前端误调用未开放能力。
if !state.config.sms_auth_enabled {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let scene = map_phone_auth_scene(payload.scene.as_deref())?;
let phone_input_masked = mask_phone_input(payload.phone.as_str());
info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = scene.as_str(),
provider = state.config.sms_auth_provider.as_str(),
phone_input_masked = phone_input_masked.as_str(),
"收到手机号验证码发送请求"
);
let result = match state
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
phone_number: payload.phone,
scene: scene.clone(),
},
OffsetDateTime::now_utc(),
)
.await
{
Ok(result) => {
info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = %result.scene,
phone_masked = %result.phone_number_masked,
provider = %result.provider,
provider_request_id = %result.provider_request_id.as_deref().unwrap_or("unknown"),
provider_out_id = %result.provider_out_id.as_deref().unwrap_or("unknown"),
cooldown_seconds = result.cooldown_seconds,
expires_in_seconds = result.expires_in_seconds,
"手机号验证码发送请求已提交"
);
result
}
Err(error) => {
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = scene.as_str(),
provider = state.config.sms_auth_provider.as_str(),
phone_input_masked = phone_input_masked.as_str(),
error = %error,
"手机号验证码发送失败"
);
return Err(map_phone_auth_error(error));
}
};
Ok(json_success_body(
Some(&request_context),
PhoneSendCodeResponse {
ok: true,
cooldown_seconds: result.cooldown_seconds,
expires_in_seconds: result.expires_in_seconds,
provider_request_id: result.provider_request_id,
},
))
}
pub async fn phone_login(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Json(payload): Json<PhoneLoginRequest>,
) -> Result<impl IntoResponse, AppError> {
// 手机号验证码校验通过后,沿用统一会话签发逻辑,确保 refresh cookie 与 JWT 行为一致。
if !state.config.sms_auth_enabled {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let result = match state
.phone_auth_service()
.login(
PhoneLoginInput {
phone_number: payload.phone,
verify_code: payload.code,
},
OffsetDateTime::now_utc(),
)
.await
{
Ok(result) => {
info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = "login",
phone_masked = %result.phone_number_masked,
provider = %result.provider,
provider_out_id = %result.provider_out_id.as_deref().unwrap_or("unknown"),
user_id = %result.user.id,
created = result.created,
"手机号验证码登录成功"
);
result
}
Err(error) => {
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = "login",
error = %error,
"手机号验证码登录失败"
);
return Err(map_phone_auth_error(error));
}
};
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Phone,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PhoneLoginResponse {
token: signed_session.access_token,
user: map_auth_user_payload(result.user),
},
),
))
}
fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> {
match raw_scene.unwrap_or("login").trim() {
"login" => Ok(PhoneAuthScene::Login),
"bind_phone" => Ok(PhoneAuthScene::BindPhone),
"change_phone" => Ok(PhoneAuthScene::ChangePhone),
"reset_password" => Ok(PhoneAuthScene::ResetPassword),
_ => Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("短信验证码场景不合法")
.with_details(json!({ "field": "scene" }))),
}
}
fn mask_phone_input(phone: &str) -> String {
let trimmed = phone.trim();
if trimmed.is_empty() {
return "empty".to_string();
}
let digits: String = trimmed.chars().filter(|ch| ch.is_ascii_digit()).collect();
let target = if digits.len() >= 7 {
digits
} else {
trimmed.to_string()
};
mask_phone_digits(&target)
}
fn mask_phone_digits(value: &str) -> String {
let chars: Vec<char> = value.chars().collect();
if chars.len() <= 4 {
return "*".repeat(chars.len().max(1));
}
let prefix_len = chars.len().min(3);
let suffix_len = 4.min(chars.len().saturating_sub(prefix_len));
let mask_len = chars.len().saturating_sub(prefix_len + suffix_len);
let mut masked = String::new();
masked.extend(chars.iter().take(prefix_len));
masked.push_str(&"*".repeat(mask_len.max(1)));
if suffix_len > 0 {
masked.extend(chars.iter().skip(chars.len() - suffix_len));
}
masked
}
pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
match error {
PhoneAuthError::InvalidPhoneNumber
| PhoneAuthError::InvalidVerifyCode
| PhoneAuthError::VerifyCodeNotFound
| PhoneAuthError::VerifyCodeExpired
| PhoneAuthError::UserStateMismatch => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PhoneAuthError::SendCoolingDown {
retry_after_seconds,
} => {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
}
PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
}
PhoneAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -0,0 +1,433 @@
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::custom_world_agent_turn::{
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
PromptUserInputSignal,
};
use module_custom_world::empty_agent_anchor_content_json;
use serde_json::Value as JsonValue;
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复"#;
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。"#;
pub(crate) fn quick_fill_extra_rules() -> String {
render_quick_fill_extra_rules(
"当前 RPG 世界方向里的剩余设定",
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
"直接输出一版尽量完整的设定结构",
"进入“生成游戏设定草稿”",
)
}
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中"#;
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}"#;
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": "",
"playerFantasy": "",
"themeBoundary": "",
"playerEntryPoint": "",
"coreConflict": "",
"keyRelationships": "",
"hiddenLines": "",
"iconicElements": ""
}
}
nextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null不允许输出对象或数组。
请把每个锚点写成一段凝练中文:
- worldPromise 关注世界钩子、差异点、玩家体验。
- playerFantasy 关注玩家身份、核心追求、失去风险。
- themeBoundary 关注主题气质、美术方向、禁用方向。
- playerEntryPoint 关注开局身份、开局问题、行动动机。
- coreConflict 关注表层冲突、隐藏危机、首次触发点。
- keyRelationships 关注关键人物关系、关系类型、代价或秘密。
- hiddenLines 关注隐藏真相、误导线索、揭示节奏。
- iconicElements 关注标志意象、组织/物件、硬规则。
"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
dynamic_state.user_input_signal.as_str(),
dynamic_state.drift_risk.as_str(),
dynamic_state.conversation_mode.as_str(),
dynamic_state.judgement_summary
)
}
pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
format!(
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
)
}
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
match value.and_then(JsonValue::as_str)? {
"rich" => Some(PromptUserInputSignal::Rich),
"normal" => Some(PromptUserInputSignal::Normal),
"sparse" => Some(PromptUserInputSignal::Sparse),
"correction" => Some(PromptUserInputSignal::Correction),
"delegate" => Some(PromptUserInputSignal::Delegate),
_ => None,
}
}
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
match value.and_then(JsonValue::as_str)? {
"low" => Some(PromptDriftRisk::Low),
"medium" => Some(PromptDriftRisk::Medium),
"high" => Some(PromptDriftRisk::High),
_ => None,
}
}
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
match value.and_then(JsonValue::as_str)? {
"bootstrap" => Some(PromptConversationMode::Bootstrap),
"expand" => Some(PromptConversationMode::Expand),
"compress" => Some(PromptConversationMode::Compress),
"repair_direction" => Some(PromptConversationMode::RepairDirection),
"force_complete" => Some(PromptConversationMode::ForceComplete),
"closing" => Some(PromptConversationMode::Closing),
_ => None,
}
}
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}

View File

@@ -0,0 +1,294 @@
use crate::character_animation_assets::find_motion_template;
use shared_contracts::assets::CharacterAnimationStrategy;
pub(crate) fn build_character_animation_prompt(
strategy: &CharacterAnimationStrategy,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
animation: &str,
frame_count: u32,
fps: u32,
duration_seconds: u32,
loop_: bool,
use_chroma_key: bool,
) -> String {
match strategy {
CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt(
animation,
prompt_text,
character_brief_text,
action_template_id,
loop_,
use_chroma_key,
),
CharacterAnimationStrategy::ImageSequence => {
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
}
CharacterAnimationStrategy::MotionTransfer
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
animation,
prompt_text,
character_brief_text,
action_template_id,
loop_,
use_chroma_key,
fps,
duration_seconds,
),
}
}
fn build_image_sequence_prompt(
animation: &str,
prompt_text: &str,
frame_count: u32,
use_chroma_key: bool,
) -> String {
[
format!(
"同一角色连续 {} 帧动作序列,动作主题是 {}",
frame_count, animation
),
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
if use_chroma_key {
"纯绿色背景,无地面装饰,方便后期抠像。".to_string()
} else {
"背景尽量纯净,避免复杂场景。".to_string()
},
prompt_text.trim().to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn build_npc_animation_prompt(
animation: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
loop_: bool,
use_chroma_key: bool,
fps: u32,
duration_seconds: u32,
) -> String {
let character_brief = build_compact_animation_character_brief(character_brief_text);
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
let loop_rule = if loop_ {
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
.to_string()
} else if animation == "die" {
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
.to_string()
} else {
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
};
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
return [
format!(
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
template.animation
),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
} else {
"背景简洁纯净,无复杂场景。".to_string()
},
if character_brief.is_empty() {
String::new()
} else {
format!("角色设定:{}", character_brief)
},
format!("动作补充:{}", template.prompt_suffix),
if action_detail_text.is_empty() {
String::new()
} else {
format!("动作细节:{}", action_detail_text)
},
format!("目标帧率 {} fps时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
loop_rule,
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
}
[
format!("单人 NPC 全身动作视频,动作主题是 {}", animation),
"角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
"动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
} else {
"背景简洁纯净,无复杂场景。".to_string()
},
if character_brief.is_empty() {
String::new()
} else {
format!("角色设定:{}", character_brief)
},
if action_detail_text.is_empty() {
String::new()
} else {
action_detail_text
},
format!(
"目标帧率 {} fps时长约 {} 秒。",
fps.clamp(1, 60),
duration_seconds.clamp(1, 8)
),
loop_rule,
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn build_ark_character_animation_prompt(
animation: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
loop_: bool,
use_chroma_key: bool,
) -> String {
let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_");
let normalized_animation_name = if normalized_animation_name.is_empty() {
"idle".to_string()
} else {
normalized_animation_name
};
let character_brief = build_compact_animation_character_brief(character_brief_text);
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
if let Some(template) = action_template_id.and_then(find_motion_template) {
return build_video_action_prompt(
template.id,
template.prompt_suffix,
action_detail_text.as_str(),
Some(character_brief.as_str()),
use_chroma_key,
);
}
build_video_action_prompt(
normalized_animation_name.as_str(),
if loop_ {
"循环动作必须自然闭环,不要静止开场。"
} else {
"中段完成完整动作变化,收束干净。"
},
action_detail_text.as_str(),
Some(character_brief.as_str()),
use_chroma_key,
)
}
/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。
fn build_video_action_prompt(
action_id: &str,
action_sequence: &str,
action_detail_text: &str,
character_brief_text: Option<&str>,
use_chroma_key: bool,
) -> String {
[
format!("生成有创意细节饱满的角色动作视频,动作英文名是 {}", action_id),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰禁止退化成完全 90 度纯右视图。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容。".to_string(),
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()
} else {
"背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string()
},
format!(
"动作补充细节:{}",
if action_detail_text.trim().is_empty() {
"保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。"
} else {
action_detail_text.trim()
}
),
character_brief_text
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("角色设定:{}", value))
.unwrap_or_default(),
"目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn build_fallback_moderation_safe_animation_prompt(
animation: &str,
loop_: bool,
use_chroma_key: bool,
) -> String {
[
format!("单人全身角色动作视频,动作主题是 {}", animation),
"角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(),
if loop_ {
"循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string()
} else {
"非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string()
},
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素。".to_string()
} else {
"背景简洁纯净。".to_string()
},
]
.join(" ")
}
fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String {
value
.replace(char::is_whitespace, " ")
.replace("血浆", "")
.replace("喷血", "")
.replace("鲜血", "")
.replace("断肢", "")
.replace("斩首", "")
.replace("裸体", "")
.replace("裸露", "")
.replace("色情", "")
.replace("性交", "")
.replace("死亡", "倒地结束")
.replace("死去", "倒地结束")
.replace("击杀", "倒地结束")
.replace("受击", "失衡")
.replace("受伤", "失衡")
.replace("砍杀", "挥击")
.replace("斩击", "挥击")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(max_length)
.collect::<String>()
.trim()
.to_string()
}
fn build_compact_animation_character_brief(value: Option<&str>) -> String {
let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160);
if normalized.is_empty() {
return String::new();
}
normalized
.split(['/', '|', '\n', '', ',', '。', '', ';'])
.map(str::trim)
.filter(|item| !item.is_empty())
.take(4)
.collect::<Vec<_>>()
.join("")
}

View File

@@ -0,0 +1,96 @@
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(prompt_text: &str) -> String {
build_master_prompt(prompt_text.trim())
}
/// 角色主图被供应商内容审核拦截时使用的安全兜底提示词。
///
/// 这里刻意不继续携带角色姓名、作品名和长设定文本,避免把可疑专名原样送回上游导致连续失败。
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(prompt_text: &str) -> String {
let archetype = resolve_original_role_archetype(prompt_text);
build_master_prompt(
[
format!("角色定位:{}", archetype),
"原创奇幻冒险角色,成年类人骨架,站姿稳定,表情中性,服装为无品牌旅行装、轻甲或职业装备的原创组合。".to_string(),
"不参考任何现有动漫、游戏、影视、小说角色,不使用可识别 IP 元素、商标、队徽、作品名、角色名或知名角色标志性发型服装。".to_string(),
"所有图案、配色、武器、饰品都采用原创通用设计,只保留横版像素动作角色所需的清晰轮廓和可读职业特征。".to_string(),
]
.join("\n")
.as_str(),
)
}
fn resolve_original_role_archetype(source: &str) -> &'static str {
if source.contains("法师") || source.contains("魔法") || source.contains("术士") {
return "原创法术职业冒险者";
}
if source.contains("骑士") || source.contains("守卫") || source.contains("圣骑") {
return "原创重装守护者";
}
if source.contains("") || source.contains("猎人") || source.contains("游侠") {
return "原创远程游侠";
}
if source.contains("刺客") || source.contains("盗贼") || source.contains("潜行") {
return "原创敏捷潜行者";
}
if source.contains("") || source.contains("战士") || source.contains("武士") {
return "原创近战剑士";
}
if source.contains("祭司") || source.contains("牧师") || source.contains("治疗") {
return "原创支援祭司";
}
"原创冒险者"
}
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D像素角色形象头身比必须控制在 1.5 到 2 头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素角色,细节精致,设计感足。使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(),
"如果角色形象设定没有明确要求非人身体结构,默认优先使用人类或类人动作角色骨架。\
默认将角色形象设定作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上。".to_string(),
"角色形象设定:".to_string(),
character_brief.trim().to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
/// 自定义世界角色主图负面提示词脚本。
pub(crate) fn build_character_visual_negative_prompt() -> String {
[
"正面视角",
"左朝向",
"完全 90 度纯右视图",
"镜头透视",
"半身像",
"脚被裁切",
"头顶被裁切",
"多角色",
"复杂背景",
"建筑场景",
"漂浮物",
"烟雾环境",
"武器消失",
"武器换手",
"额外手臂",
"额外腿",
"服装变化",
"脸部变化",
"模糊",
"运动模糊",
"文字",
"水印",
"UI 元素",
"厚涂插画感",
"低对比柔边",
]
.join("")
}

View File

@@ -0,0 +1,502 @@
use serde_json::Value as JsonValue;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String {
[
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。".to_string(),
"玩家设定:".to_string(),
setting_text.trim().to_string(),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"name\": \"世界名称\",".to_string(),
" \"subtitle\": \"世界副标题\",".to_string(),
" \"summary\": \"世界概述\",".to_string(),
" \"tone\": \"世界基调\",".to_string(),
" \"playerGoal\": \"玩家核心目标\",".to_string(),
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"attributeSchema\": {".to_string(),
" \"schemaName\": \"本世界六维名称\",".to_string(),
" \"slots\": [".to_string(),
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(),
" ]".to_string(),
" },".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
" }".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 这一步只输出顶层 10 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
"- 每个属性维度都要同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].join("\n")
}
pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
[
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
"请只输出修复后的 JSON 对象。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。",
"原始文本:",
response_text.trim(),
].join("\n")
}
pub(crate) fn build_custom_world_role_outline_batch_prompt(
framework: &JsonValue,
role_type: &str,
batch_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
" \"title\": \"称号\",".to_string(),
" \"role\": \"身份\",".to_string(),
" \"description\": \"极简定位描述\",".to_string(),
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
" \"initialAffinity\": 18,".to_string(),
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count}{label}"),
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
"- 只保留name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
"- relationshipHooks 最多 1 条tags 保持 1 到 2 个。".to_string(),
"- description 控制在 8 到 18 个汉字内title 和 role 也尽量短。".to_string(),
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
[
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("必须保留恰好 {expected_count} 个角色对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"如果缺少字段字符串补空字符串relationshipHooks 和 tags 补空数组initialAffinity 补默认整数。".to_string(),
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
) -> String {
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"这一步必须一次性生成场景骨架、地点默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"landmarks\": [".to_string(),
" {".to_string(),
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景极简描述\",".to_string(),
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
" \"actNPCNames\": [\"第一幕主场景角色名\", \"第二幕主场景角色名\", \"第三幕主场景角色名\"],".to_string(),
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(),
"- 可用场景角色名单非空时actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- description 控制在 12 到 24 个汉字内。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
response_text: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
[
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count} 个地点对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTexts、actEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
"不要输出 items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_batch_prompt(
framework: &JsonValue,
role_type: &str,
role_batch: &[JsonValue],
stage: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
let stage_label = if stage == "narrative" {
"叙事档案"
} else {
"养成档案"
};
let required_fields = if stage == "narrative" {
"name、backstory、personality、motivation、combatStyle"
} else {
"name、backstoryReveal、skills、initialItems"
};
let template_extra = if stage == "narrative" {
[
" \"backstory\": \"公开背景\",",
" \"personality\": \"性格关键词\",",
" \"motivation\": \"当前动机\",",
" \"combatStyle\": \"行动或战斗风格\"",
]
.join("\n")
} else {
[
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
].join("\n")
};
[
format!("请为下面这一批{label}补全{stage_label}"),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 10),
"本批角色:".to_string(),
build_role_outline_prompt_text(role_batch, framework, role_type),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
template_extra,
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。".to_string(),
format!("- 每个角色必须包含:{required_fields}"),
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")) },
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
stage: &str,
expected_names: &[String],
) -> String {
let key = role_key(role_type);
if stage == "narrative" {
return [
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstory、personality、motivation、combatStyle。".to_string(),
"如果缺少字段:字符串补空字符串。".to_string(),
"不要输出 backstoryReveal、skills、initialItems也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n");
}
[
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstoryReveal、skills、initialItems。".to_string(),
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")),
"skills 默认补成 3 个对象,每个对象包含 name、summary、styleinitialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
"不要输出 backstory、personality、motivation、combatStyle、landmarks也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
let landmark_text = array_field(framework, "landmarks")
.into_iter()
.take(max_landmarks)
.map(|landmark| {
format!(
"{}{}",
json_text(&landmark, "name").unwrap_or_default(),
json_text(&landmark, "description").unwrap_or_default()
)
})
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("");
[
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
format!(
"副标题:{}",
json_text(framework, "subtitle").unwrap_or_default()
),
format!(
"世界概述:{}",
json_text(framework, "summary").unwrap_or_default()
),
format!(
"世界基调:{}",
json_text(framework, "tone").unwrap_or_default()
),
format!(
"玩家核心目标:{}",
json_text(framework, "playerGoal").unwrap_or_default()
),
json_string_array(framework, "majorFactions")
.map(|items| format!("主要势力:{}", items.join("")))
.unwrap_or_default(),
json_string_array(framework, "coreConflicts")
.map(|items| format!("核心冲突:{}", items.join("")))
.unwrap_or_default(),
format!(
"开局归处:{}{}",
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
),
if landmark_text.is_empty() {
String::new()
} else {
format!("关键场景:{landmark_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn build_role_outline_prompt_text(
role_batch: &[JsonValue],
framework: &JsonValue,
role_type: &str,
) -> String {
role_batch
.iter()
.map(|role| {
let appearance_text = if role_type == "story" {
landmark_names_for_role(
framework,
json_text(role, "name").unwrap_or_default().as_str(),
)
.join("")
} else {
String::new()
};
[
format!(
"- {} / {}",
json_text(role, "name").unwrap_or_default(),
json_text(role, "title").unwrap_or_default()
),
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
format!(
"框架描述:{}",
json_text(role, "description").unwrap_or_default()
),
format!(
"预设好感:{}",
role.get("initialAffinity")
.and_then(JsonValue::as_i64)
.unwrap_or(0)
),
json_string_array(role, "relationshipHooks")
.map(|items| format!("关系切入口:{}", items.join("")))
.unwrap_or_default(),
json_string_array(role, "tags")
.map(|items| format!("标签:{}", items.join("")))
.unwrap_or_default(),
if appearance_text.is_empty() {
String::new()
} else {
format!("出现场景:{appearance_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
})
.collect::<Vec<_>>()
.join("\n")
}
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
array_field(framework, "landmarks")
.into_iter()
.filter_map(|landmark| {
let names = json_string_array(&landmark, "actNPCNames")
.or_else(|| json_string_array(&landmark, "sceneNpcNames"))
.unwrap_or_default();
if names.iter().any(|name| name == role_name) {
json_text(&landmark, "name")
} else {
None
}
})
.collect()
}
fn role_key(role_type: &str) -> &'static str {
if role_type == "playable" {
"playableNpcs"
} else {
"storyNpcs"
}
}
fn array_field(value: &JsonValue, key: &str) -> Vec<JsonValue> {
value
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default()
}
fn names_from_entries(entries: &[JsonValue]) -> Vec<String> {
entries
.iter()
.filter_map(|entry| json_text(entry, "name"))
.filter(|value| !value.is_empty())
.collect()
}
fn json_text(value: &JsonValue, key: &str) -> Option<String> {
json_path_text(value, &[key])
}
fn json_path_text(value: &JsonValue, path: &[&str]) -> Option<String> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
let items = value
.get(key)?
.as_array()?
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if items.is_empty() { None } else { Some(items) }
}

View File

@@ -0,0 +1,6 @@
pub(crate) mod agent_chat;
pub(crate) mod character_animation;
pub(crate) mod character_visual;
pub(crate) mod foundation_draft;
pub(crate) mod runtime_chat;
pub(crate) mod scene_background;

View File

@@ -0,0 +1,114 @@
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub(crate) struct RuntimeStoryTextPromptParams<'a> {
pub world_type: &'a str,
pub character: Value,
pub monsters: Value,
pub history: Value,
pub choice: Value,
pub context: Value,
pub available_options: Value,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub topic: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeReasonedStoryPromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub choice: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
pub(crate) fn runtime_story_director_system_prompt(initial: bool) -> &'static str {
if initial {
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
} else {
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
}
}
pub(crate) fn build_runtime_story_director_user_prompt(
params: RuntimeStoryTextPromptParams<'_>,
) -> String {
json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"choice": params.choice,
"context": params.context,
"availableOptions": params.available_options,
})
.to_string()
}
pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
}
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
npc_name: &str,
params: RuntimeNpcDialoguePromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"encounter": params.encounter,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"topic": params.topic,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{state_prompt}"
)
}
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态不要发明额外奖励。"
}
pub(crate) fn build_runtime_reasoned_story_user_prompt(
params: RuntimeReasonedStoryPromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"choice": params.choice,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
)
}

View File

@@ -0,0 +1,166 @@
#[derive(Clone, Debug, Default)]
pub(crate) struct SceneImagePromptProfile<'a> {
pub name: &'a str,
pub subtitle: &'a str,
pub tone: &'a str,
pub player_goal: &'a str,
pub summary: &'a str,
pub setting_text: &'a str,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SceneImagePromptLandmark<'a> {
pub name: &'a str,
pub description: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct SceneImagePromptParams<'a> {
pub profile: SceneImagePromptProfile<'a>,
pub landmark: SceneImagePromptLandmark<'a>,
pub user_prompt: &'a str,
pub has_reference_image: bool,
pub fallback_landmark_name: Option<&'a str>,
pub fallback_world_name: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct SceneActBackgroundPromptParams<'a> {
pub world_name: &'a str,
pub world_tone: &'a str,
pub scene_name: &'a str,
pub title: &'a str,
pub summary: &'a str,
pub act_goal: &'a str,
pub transition_hook: &'a str,
pub primary_role_name: &'a str,
pub support_role_names: Vec<String>,
pub prompt_text: &'a str,
}
pub(crate) const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头";
pub(crate) fn build_custom_world_scene_image_prompt(params: SceneImagePromptParams<'_>) -> String {
let world_name = clamp_scene_image_text(
if params.profile.name.trim().is_empty() {
params.fallback_world_name
} else {
params.profile.name
},
18,
);
let world_subtitle = clamp_scene_image_text(params.profile.subtitle, 18);
let world_tone = clamp_scene_image_text(params.profile.tone, 48);
let world_goal = clamp_scene_image_text(params.profile.player_goal, 48);
let world_summary = clamp_scene_image_text(params.profile.summary, 72);
let world_setting = clamp_scene_image_text(params.profile.setting_text, 72);
let landmark_name = clamp_scene_image_text(
if params.landmark.name.trim().is_empty() {
params.fallback_landmark_name.unwrap_or("未命名场景")
} else {
params.landmark.name
},
18,
);
let landmark_description = clamp_scene_image_text(params.landmark.description, 96);
let requested_visual = clamp_scene_image_text(params.user_prompt, 120);
vec![
"为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(),
"画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(),
"下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(),
"下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(),
"下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(),
if params.has_reference_image {
"已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string()
} else {
String::new()
},
format!(
"世界:{}{}",
if world_name.is_empty() {
"未命名世界"
} else {
world_name.as_str()
},
if world_subtitle.is_empty() {
String::new()
} else {
format!("{world_subtitle}")
}
),
conditional_prompt_line("玩家设定", world_setting.as_str()),
conditional_prompt_line("世界概述", world_summary.as_str()),
conditional_prompt_line("整体基调", world_tone.as_str()),
conditional_prompt_line("玩家目标关联", world_goal.as_str()),
format!(
"场景名称:{}",
if landmark_name.is_empty() {
"未命名场景"
} else {
landmark_name.as_str()
}
),
conditional_prompt_line("场景描述", landmark_description.as_str()),
conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()),
"不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(),
]
.into_iter()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("")
}
pub(crate) fn build_scene_act_background_image_prompt(
params: SceneActBackgroundPromptParams<'_>,
) -> String {
// 幕背景图不是普通地点图,必须把世界、幕目标、过渡钩子和角色关系一起写入图像提示词,
// 同时明确禁止角色立绘和 UI 元素进入背景资产。
[
format!("这是世界《{}》中的场景幕背景图。", params.world_name),
format!("场景:{}", params.scene_name),
format!("幕标题:{}", params.title),
format!("幕摘要:{}", params.summary),
format!("幕目标:{}", params.act_goal),
format!("过渡钩子:{}", params.transition_hook),
format!(
"主角色:{}",
if params.primary_role_name.trim().is_empty() {
"待补主角色"
} else {
params.primary_role_name.trim()
}
),
if params.support_role_names.is_empty() {
String::new()
} else {
format!("辅助角色:{}", params.support_role_names.join(""))
},
format!("世界气质:{}", params.world_tone),
format!("背景描述:{}", params.prompt_text),
"要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。".to_string(),
]
.into_iter()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn clamp_scene_image_text(value: &str, max_length: usize) -> String {
value
.trim()
.replace(char::is_whitespace, " ")
.chars()
.take(max_length)
.collect::<String>()
.trim()
.to_string()
}
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
if value.is_empty() {
String::new()
} else {
format!("{prefix}{value}")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,508 @@
use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord,
};
use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a PuzzleAgentSessionRecord,
pub quick_fill_requested: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnError {
message: String,
}
impl PuzzleAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for PuzzleAgentTurnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for PuzzleAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PuzzleAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_anchor_pack: PuzzleAnchorPack,
}
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"themePromise": {
"key": "themePromise",
"label": "题材承诺",
"value": "",
"status": "missing"
},
"visualSubject": {
"key": "visualSubject",
"label": "画面主体",
"value": "",
"status": "missing"
},
"visualMood": {
"key": "visualMood",
"label": "视觉气质",
"value": "",
"status": "missing"
},
"compositionHooks": {
"key": "compositionHooks",
"label": "拼图记忆点",
"value": "",
"status": "missing"
},
"tagsAndForbidden": {
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"value": "",
"status": "missing"
}
}
}"#;
pub(crate) async fn run_puzzle_agent_turn<F>(
request: PuzzleAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<PuzzleAgentTurnResult, PuzzleAgentTurnError>
where
F: FnMut(&str),
{
let prompt = build_puzzle_agent_prompt(request.session, request.quick_fill_requested);
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "拼图聊天生成失败,请稍后重试。",
parse_failed: "拼图聊天结果解析失败,请稍后重试。",
},
on_reply_update,
PuzzleAgentTurnError::new,
)
.await?;
let output = parse_model_output(&turn_output.parsed)?;
Ok(PuzzleAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: resolve_puzzle_agent_stage(output.progress_percent)
.as_str()
.to_string(),
progress_percent: if request.quick_fill_requested {
100
} else {
output.progress_percent
},
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack).unwrap_or_else(|_| {
serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
}),
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: PuzzleAgentTurnResult,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
stage: result.stage,
progress_percent: result.progress_percent,
anchor_pack_json: result.anchor_pack_json,
error_message: result.error_message,
updated_at_micros,
}
}
pub(crate) fn build_failed_finalize_record_input(
session_id: String,
owner_user_id: String,
session: &PuzzleAgentSessionRecord,
error_message: String,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| {
serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
});
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: None,
assistant_reply_text: None,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
anchor_pack_json,
error_message: Some(error_message),
updated_at_micros,
}
}
fn build_puzzle_agent_prompt(
session: &PuzzleAgentSessionRecord,
quick_fill_requested: bool,
) -> String {
let anchor_question_block = get_creation_agent_anchor_template("puzzle")
.map(render_anchor_question_block)
.unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string());
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前题材方向里的拼图关键词",
"不要要求用户再提供素材、风格或禁忌",
"输出完整 nextAnchorPack直接补齐 value 为空或 status 为 missing 的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
anchor_question_block = anchor_question_block,
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| "{}".to_string()),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = PUZZLE_AGENT_OUTPUT_CONTRACT,
)
}
fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_model_output(parsed: &JsonValue) -> Result<PuzzleAgentModelOutput, PuzzleAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(0);
let next_anchor_pack_value = parsed
.get("nextAnchorPack")
.cloned()
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少 nextAnchorPack。"))?;
let next_anchor_pack = parse_model_anchor_pack(&next_anchor_pack_value)?;
Ok(PuzzleAgentModelOutput {
reply_text,
progress_percent,
next_anchor_pack,
})
}
fn parse_model_anchor_pack(value: &JsonValue) -> Result<PuzzleAnchorPack, PuzzleAgentTurnError> {
Ok(PuzzleAnchorPack {
// LLM 输出契约面向前端与 prompt使用 camelCaseRust 领域模型仍保持 snake_case
// 因此这里显式做边界翻译,避免把 JSON 命名差异扩散到领域 crate。
theme_promise: parse_model_anchor_item(value, "themePromise")?,
visual_subject: parse_model_anchor_item(value, "visualSubject")?,
visual_mood: parse_model_anchor_item(value, "visualMood")?,
composition_hooks: parse_model_anchor_item(value, "compositionHooks")?,
tags_and_forbidden: parse_model_anchor_item(value, "tagsAndForbidden")?,
})
}
fn parse_model_anchor_item(
pack: &JsonValue,
field_name: &str,
) -> Result<module_puzzle::PuzzleAnchorItem, PuzzleAgentTurnError> {
let value = pack.get(field_name).ok_or_else(|| {
PuzzleAgentTurnError::new(format!("拼图 anchor pack 缺少 {field_name}"))
})?;
let key = value
.get("key")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or(field_name)
.to_string();
let label = value
.get("label")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or_else(|| default_puzzle_anchor_label(field_name))
.to_string();
let item_value = value
.get("value")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string();
let status = value
.get("status")
.and_then(JsonValue::as_str)
.map(parse_anchor_status)
.unwrap_or(PuzzleAnchorStatus::Missing);
Ok(module_puzzle::PuzzleAnchorItem {
key,
label,
value: item_value,
status,
})
}
fn default_puzzle_anchor_label(field_name: &str) -> &'static str {
match field_name {
"themePromise" => "题材承诺",
"visualSubject" => "画面主体",
"visualMood" => "视觉气质",
"compositionHooks" => "拼图记忆点",
"tagsAndForbidden" => "标签与禁忌",
_ => "拼图锚点",
}
}
fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage {
if progress_percent >= 85 {
PuzzleAgentStage::DraftReady
} else {
PuzzleAgentStage::CollectingAnchors
}
}
fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack {
PuzzleAnchorPack {
theme_promise: map_record_anchor_item(&record.theme_promise),
visual_subject: map_record_anchor_item(&record.visual_subject),
visual_mood: map_record_anchor_item(&record.visual_mood),
composition_hooks: map_record_anchor_item(&record.composition_hooks),
tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden),
}
}
fn map_record_anchor_item(
record: &spacetime_client::PuzzleAnchorItemRecord,
) -> module_puzzle::PuzzleAnchorItem {
module_puzzle::PuzzleAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_anchor_status(record.status.as_str()),
}
}
fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus {
match value {
"confirmed" => PuzzleAnchorStatus::Confirmed,
"locked" => PuzzleAnchorStatus::Locked,
"inferred" => PuzzleAnchorStatus::Inferred,
_ => PuzzleAnchorStatus::Missing,
}
}
#[cfg(test)]
mod tests {
use module_puzzle::PuzzleAnchorStatus;
use serde_json::json;
use super::{build_puzzle_agent_prompt, parse_model_output};
use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json;
fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord {
spacetime_client::PuzzleAgentSessionRecord {
session_id: "puzzle-session-test".to_string(),
current_turn: 2,
progress_percent: 60,
stage: "collecting_anchors".to_string(),
anchor_pack: spacetime_client::PuzzleAnchorPackRecord {
theme_promise: spacetime_client::PuzzleAnchorItemRecord {
key: "themePromise".to_string(),
label: "题材承诺".to_string(),
value: "雨夜猫咪遗迹".to_string(),
status: "confirmed".to_string(),
},
visual_subject: spacetime_client::PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
label: "画面主体".to_string(),
value: String::new(),
status: "missing".to_string(),
},
visual_mood: spacetime_client::PuzzleAnchorItemRecord {
key: "visualMood".to_string(),
label: "视觉气质".to_string(),
value: String::new(),
status: "missing".to_string(),
},
composition_hooks: spacetime_client::PuzzleAnchorItemRecord {
key: "compositionHooks".to_string(),
label: "拼图记忆点".to_string(),
value: String::new(),
status: "missing".to_string(),
},
tags_and_forbidden: spacetime_client::PuzzleAnchorItemRecord {
key: "tagsAndForbidden".to_string(),
label: "标签与禁忌".to_string(),
value: String::new(),
status: "missing".to_string(),
},
},
draft: None,
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
}
}
#[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
let partial_json = r#"{"replyText":"夜雨猫咪遗迹","progressPercent":42"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("夜雨猫咪遗迹"));
}
#[test]
fn parse_model_output_accepts_camel_case_anchor_pack_contract() {
let model_output = json!({
"replyText": "我先把雨夜猫咪的方向收住。",
"progressPercent": 46,
"nextAnchorPack": {
"themePromise": {
"key": "themePromise",
"label": "题材承诺",
"value": "雨夜中的奇幻探索",
"status": "confirmed"
},
"visualSubject": {
"key": "visualSubject",
"label": "画面主体",
"value": "发光猫咪站在遗迹台阶上",
"status": "confirmed"
},
"visualMood": {
"key": "visualMood",
"label": "视觉气质",
"value": "潮湿、梦幻、带轻微悬疑",
"status": "inferred"
},
"compositionHooks": {
"key": "compositionHooks",
"label": "拼图记忆点",
"value": "台阶透视、倒影、远处遗迹门洞",
"status": "inferred"
},
"tagsAndForbidden": {
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"value": "雨夜、猫咪、神庙遗迹;禁止文字水印",
"status": "inferred"
}
}
});
let parsed = parse_model_output(&model_output).expect("camelCase 契约应能解析");
assert_eq!(parsed.progress_percent, 46);
assert_eq!(
parsed.next_anchor_pack.theme_promise.value,
"雨夜中的奇幻探索"
);
assert_eq!(
parsed.next_anchor_pack.theme_promise.status,
PuzzleAnchorStatus::Confirmed
);
assert_eq!(
parsed.next_anchor_pack.tags_and_forbidden.value,
"雨夜、猫咪、神庙遗迹;禁止文字水印"
);
}
#[test]
fn quick_fill_prompt_forbids_follow_up_questions() {
let prompt = build_puzzle_agent_prompt(&empty_session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}

View File

@@ -0,0 +1,89 @@
use axum::{
extract::{Extension, State},
http::HeaderMap,
response::IntoResponse,
};
use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
use platform_auth::hash_refresh_session_token;
use shared_contracts::auth::RefreshSessionResponse;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth::RefreshSessionToken,
auth_session::{
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
build_refresh_session_cookie_header, map_refresh_session_error, sign_access_token_for_user,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn refresh_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<impl IntoResponse, AppError> {
let raw_refresh_token = maybe_refresh_token
.map(|token| token.0.token().to_string())
.unwrap_or_default();
if raw_refresh_token.trim().is_empty() {
return Err(map_refresh_error_with_clear_cookie(
&state,
RefreshSessionError::MissingToken,
));
}
let refresh_token_hash = hash_refresh_session_token(&raw_refresh_token);
let next_refresh_token = platform_auth::create_refresh_session_token();
let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token);
let rotated = state
.refresh_session_service()
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash,
next_refresh_token_hash,
},
OffsetDateTime::now_utc(),
)
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?;
let access_token = sign_access_token_for_user(
&state,
&rotated.user,
&rotated.session.session_id,
Some(&rotated.session.issued_by_provider),
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &next_refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
RefreshSessionResponse {
token: access_token,
},
),
))
}
fn map_refresh_error_with_clear_cookie(state: &AppState, error: RefreshSessionError) -> AppError {
let response_error = map_refresh_session_error(error);
if let Ok(set_cookie) = build_clear_refresh_session_cookie_header(state) {
return response_error.with_header("set-cookie", set_cookie);
}
response_error
}

View File

@@ -0,0 +1,113 @@
use std::time::{Duration, Instant};
use axum::{
extract::Request,
http::{HeaderValue, Request as HttpRequest, header::HeaderName},
middleware::Next,
response::Response,
};
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
use uuid::Uuid;
pub use shared_contracts::api::X_REQUEST_ID_HEADER;
// 当前阶段先把请求级元信息统一挂到 extensions后续响应头、envelope 与错误处理中间件继续复用。
#[derive(Clone, Debug)]
pub struct RequestContext {
request_id: String,
operation: String,
request_started_at: Instant,
wants_envelope: bool,
}
impl RequestContext {
pub fn new(
request_id: String,
operation: String,
elapsed_seed: Duration,
wants_envelope: bool,
) -> Self {
Self {
request_id,
operation,
request_started_at: Instant::now()
.checked_sub(elapsed_seed)
.unwrap_or_else(Instant::now),
wants_envelope,
}
}
pub fn request_id(&self) -> &str {
&self.request_id
}
pub fn operation(&self) -> &str {
&self.operation
}
pub fn wants_envelope(&self) -> bool {
self.wants_envelope
}
pub fn elapsed(&self) -> u64 {
self.request_started_at
.elapsed()
.as_millis()
.min(u64::MAX as u128) as u64
}
}
pub async fn attach_request_context(mut request: Request, next: Next) -> Response {
let wants_envelope = wants_api_envelope(&request);
let request_id = request
.headers()
.get(X_REQUEST_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| Uuid::new_v4().to_string());
let operation = format!("{} {}", request.method(), request.uri());
request.extensions_mut().insert(RequestContext::new(
request_id.clone(),
operation,
Duration::ZERO,
wants_envelope,
));
// 统一把 request_id 写回请求头,方便后续 tracing、响应头与 envelope 层读取同一来源。
if let Ok(header_value) = HeaderValue::from_str(&request_id) {
request
.headers_mut()
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
}
next.run(request).await
}
pub fn resolve_request_id<B>(request: &HttpRequest<B>) -> Option<String> {
request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.or_else(|| {
request
.headers()
.get(X_REQUEST_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn wants_api_envelope<B>(request: &HttpRequest<B>) -> bool {
request
.headers()
.get(API_RESPONSE_ENVELOPE_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.map(str::to_lowercase)
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "v1" | "envelope"))
}

View File

@@ -0,0 +1,45 @@
use axum::{
extract::Request,
http::{HeaderValue, header::HeaderName},
middleware::Next,
response::Response,
};
use shared_contracts::api::{
API_VERSION, API_VERSION_HEADER, RESPONSE_TIME_HEADER, ROUTE_VERSION_HEADER,
};
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);
let request_context = request.extensions().get::<RequestContext>().cloned();
let mut response = next.run(request).await;
if let Some(request_id) = request_id
&& let Ok(header_value) = HeaderValue::from_str(&request_id)
{
response
.headers_mut()
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
}
if let Ok(header_value) = HeaderValue::from_str(API_VERSION) {
response.headers_mut().insert(
HeaderName::from_static(API_VERSION_HEADER),
header_value.clone(),
);
response
.headers_mut()
.insert(HeaderName::from_static(ROUTE_VERSION_HEADER), header_value);
}
if let Some(request_context) = request_context
&& let Ok(header_value) = HeaderValue::from_str(&request_context.elapsed().to_string())
{
response
.headers_mut()
.insert(HeaderName::from_static(RESPONSE_TIME_HEADER), header_value);
}
response
}

View File

@@ -0,0 +1,450 @@
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
.seed_test_phone_user_with_password("13800138102", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_browse_history".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("浏览历史用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,683 @@
use axum::{
Json,
extract::{Extension, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
http_error::AppError,
request_context::RequestContext,
runtime_chat_prompt::{
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
NpcChatTurnPromptInput, build_npc_chat_turn_reply_prompt,
build_npc_chat_turn_suggestion_prompt,
},
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NpcChatTurnRequest {
#[serde(default)]
world_type: String,
#[serde(default)]
character: Option<Value>,
#[serde(default)]
player: Option<Value>,
encounter: Value,
#[serde(default)]
monsters: Vec<Value>,
#[serde(default)]
history: Vec<Value>,
#[serde(default)]
context: Value,
#[serde(default)]
conversation_history: Vec<Value>,
#[serde(default)]
dialogue: Vec<Value>,
#[serde(default)]
combat_context: Option<Value>,
player_message: String,
#[serde(default)]
npc_state: Value,
#[serde(default)]
npc_initiates_conversation: bool,
#[serde(default)]
chat_directive: Option<Value>,
}
pub async fn stream_runtime_npc_chat_turn(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<NpcChatTurnRequest>,
) -> Result<Response, Response> {
let npc_name = read_string_field(&payload.encounter, "npcName")
.or_else(|| read_string_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let player_message = payload.player_message.trim();
if player_message.is_empty() && !payload.npc_initiates_conversation {
return Err(runtime_chat_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-chat",
"message": "playerMessage 不能为空",
})),
));
}
let llm_result =
generate_llm_npc_chat_turn(&state, &request_context, &payload, &npc_name).await;
let (mut body, npc_reply, suggestions, function_suggestions, force_exit) = match llm_result {
Some(result) => result,
None => {
let npc_reply = build_deterministic_npc_reply(
npc_name.as_str(),
player_message,
payload.npc_initiates_conversation,
);
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|| should_hostile_chat_breakoff_deterministically(
player_message,
payload.chat_directive.as_ref(),
);
let suggestions = if force_exit {
Vec::new()
} else {
build_deterministic_chat_suggestions(npc_name.as_str(), player_message)
};
let function_suggestions = if force_exit {
Vec::new()
} else {
build_fallback_function_suggestions(payload.chat_directive.as_ref())
};
let mut body = String::new();
append_sse_event(
&request_context,
&mut body,
"reply_delta",
&json!({ "text": npc_reply }),
)?;
(
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
)
}
};
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
let affinity_delta = if payload.npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count)
};
let complete_payload = json!({
"npcReply": npc_reply,
"affinityDelta": affinity_delta,
"affinityText": describe_affinity_shift(affinity_delta),
"suggestions": suggestions,
"functionSuggestions": function_suggestions,
"pendingQuestOffer": null,
"chatDirective": build_completion_directive(payload.chat_directive.as_ref(), force_exit),
});
append_sse_event(&request_context, &mut body, "complete", &complete_payload)?;
body.push_str("data: [DONE]\n\n");
Ok(build_event_stream_response(body))
}
async fn generate_llm_npc_chat_turn(
state: &AppState,
request_context: &RequestContext,
payload: &NpcChatTurnRequest,
npc_name: &str,
) -> Option<(String, String, Vec<String>, Vec<Value>, bool)> {
let llm_client = state.llm_client()?;
let character = payload
.character
.as_ref()
.or(payload.player.as_ref())
.unwrap_or(&Value::Null);
let prompt_input = NpcChatTurnPromptInput {
world_type: payload.world_type.as_str(),
character,
encounter: &payload.encounter,
monsters: &payload.monsters,
history: &payload.history,
context: &payload.context,
conversation_history: &payload.conversation_history,
dialogue: &payload.dialogue,
combat_context: payload.combat_context.as_ref(),
player_message: payload.player_message.as_str(),
npc_state: &payload.npc_state,
npc_initiates_conversation: payload.npc_initiates_conversation,
chat_directive: payload.chat_directive.as_ref(),
};
let mut body = String::new();
let reply_prompt = build_npc_chat_turn_reply_prompt(&prompt_input);
let mut reply_request = LlmTextRequest::new(vec![
LlmMessage::system(NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT),
LlmMessage::user(reply_prompt),
]);
reply_request.max_tokens = Some(700);
reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
let reply_response = llm_client
.stream_text(reply_request, |delta| {
let _ = append_sse_event(
request_context,
&mut body,
"reply_delta",
&json!({ "text": delta.accumulated_text }),
);
})
.await
.ok()?;
let npc_reply = normalize_required_text(reply_response.content.as_str()).unwrap_or_else(|| {
build_deterministic_npc_reply(
npc_name,
payload.player_message.as_str(),
payload.npc_initiates_conversation,
)
});
if should_force_chat_exit(payload.chat_directive.as_ref()) {
return Some((body, npc_reply, Vec::new(), Vec::new(), true));
}
let suggestion_prompt =
build_npc_chat_turn_suggestion_prompt(&prompt_input, npc_reply.as_str());
let mut suggestion_request = LlmTextRequest::new(vec![
LlmMessage::system(NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT),
LlmMessage::user(suggestion_prompt),
]);
suggestion_request.max_tokens = Some(200);
suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
let suggestion_text = llm_client
.request_text(suggestion_request)
.await
.ok()
.map(|response| response.content)
.unwrap_or_default();
let (mut suggestions, mut function_suggestions, should_end_chat) =
parse_npc_chat_suggestion_resolution(
suggestion_text.as_str(),
payload.chat_directive.as_ref(),
);
let force_exit = should_end_chat
|| should_hostile_chat_breakoff_deterministically(
payload.player_message.as_str(),
payload.chat_directive.as_ref(),
);
if force_exit {
suggestions.clear();
function_suggestions.clear();
} else if suggestions.is_empty() {
suggestions = build_fallback_npc_chat_suggestions(payload.player_message.as_str());
}
Some((
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
))
}
fn build_deterministic_npc_reply(
npc_name: &str,
player_message: &str,
npc_initiates_conversation: bool,
) -> String {
// Rust API 尚未迁入旧 Node 的完整 LLM NPC 聊天编排前,先由后端提供稳定兜底,保证相遇与选项聊天链路不断。
if npc_initiates_conversation {
return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”");
}
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
}
fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec<String> {
// 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。
vec![
format!("{npc_name},我想先听你说"),
"这件事哪里不对劲".to_string(),
if player_message.contains('帮') || player_message.contains('忙') {
"先别绕,说清代价".to_string()
} else {
"你是不是还瞒着我".to_string()
},
]
}
fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec<String> {
let topic = player_message.trim().chars().take(8).collect::<String>();
let topic = if topic.is_empty() {
"刚才那句".to_string()
} else {
topic
};
vec![
"我愿意先听你说完".to_string(),
format!("这事和{topic}有关吗"),
"你别再避重就轻".to_string(),
]
}
fn build_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec<Value> {
read_function_options(chat_directive)
.into_iter()
.filter(|option| {
read_string_field(option, "functionId")
.as_deref()
.is_some_and(|function_id| function_id != "npc_chat")
})
.take(2)
.filter_map(|option| {
let function_id = read_string_field(option, "functionId")?;
let action_text = read_string_field(option, "actionText")?;
Some(json!({
"functionId": function_id,
"actionText": action_text,
}))
})
.collect()
}
fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value {
let Some(directive) = chat_directive else {
return Value::Null;
};
let closing_mode = read_string_field(directive, "closingMode")
.filter(|value| value == "foreshadow_close")
.unwrap_or_else(|| "free".to_string());
let force_exit = force_exit
|| closing_mode == "foreshadow_close"
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false);
let termination_reason = if force_exit {
read_string_field(directive, "terminationReason")
.filter(|value| value == "player_exit" || value == "hostile_breakoff")
.or_else(|| {
if is_hostile_model_chat(chat_directive) {
Some("hostile_breakoff".to_string())
} else {
None
}
})
} else {
None
};
json!({
"turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null),
"remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null),
"forceExit": force_exit,
"closingMode": if force_exit { "foreshadow_close" } else { closing_mode.as_str() },
"terminationReason": termination_reason,
})
}
fn parse_npc_chat_suggestion_resolution(
text: &str,
chat_directive: Option<&Value>,
) -> (Vec<String>, Vec<Value>, bool) {
let normalized = text.trim();
if normalized.is_empty() {
return (
Vec::new(),
build_fallback_function_suggestions(chat_directive),
false,
);
}
if let Ok(value) = serde_json::from_str::<Value>(normalized) {
let should_end_chat = value
.get("shouldEndChat")
.and_then(Value::as_bool)
.unwrap_or(false)
&& is_hostile_model_chat(chat_directive);
let suggestions = value
.get("suggestions")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.take(3)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let function_suggestions =
parse_function_suggestions(value.get("functionSuggestions"), chat_directive);
return (suggestions, function_suggestions, should_end_chat);
}
(
parse_line_list_content(normalized, 3),
build_fallback_function_suggestions(chat_directive),
false,
)
}
fn parse_function_suggestions(value: Option<&Value>, chat_directive: Option<&Value>) -> Vec<Value> {
let allowed_options = read_function_options(chat_directive);
let allowed_ids = allowed_options
.iter()
.filter_map(|item| read_string_field(item, "functionId"))
.collect::<Vec<_>>();
let mut used_ids: Vec<String> = Vec::new();
value
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| {
let function_id = read_string_field(item, "functionId")?;
if function_id == "npc_chat" {
return None;
}
if !allowed_ids.is_empty() && !allowed_ids.contains(&function_id) {
return None;
}
if used_ids.contains(&function_id) {
return None;
}
let fallback_text = allowed_options
.iter()
.find(|option| {
read_string_field(option, "functionId").as_deref() == Some(function_id.as_str())
})
.and_then(|option| read_string_field(option, "actionText"));
let action_text = read_string_field(item, "actionText")
.or(fallback_text)
.filter(|text| !text.trim().is_empty())?;
used_ids.push(function_id.clone());
Some(json!({
"functionId": function_id,
"actionText": action_text,
}))
})
.take(3)
.collect()
}
fn read_function_options(chat_directive: Option<&Value>) -> Vec<&Value> {
chat_directive
.and_then(|directive| directive.get("functionOptions"))
.and_then(Value::as_array)
.map(|items| items.iter().collect::<Vec<_>>())
.unwrap_or_default()
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_number_field(value: &Value, field: &str) -> Option<f64> {
value
.get(field)
.and_then(Value::as_f64)
.filter(|number| number.is_finite())
}
fn read_bool_field(value: &Value, field: &str) -> Option<bool> {
value.get(field).and_then(Value::as_bool)
}
fn should_force_chat_exit(chat_directive: Option<&Value>) -> bool {
let Some(directive) = chat_directive else {
return false;
};
read_string_field(directive, "closingMode").as_deref() == Some("foreshadow_close")
|| read_string_field(directive, "terminationReason").as_deref() == Some("player_exit")
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool {
let Some(directive) = chat_directive else {
return false;
};
read_string_field(directive, "terminationMode").as_deref() == Some("hostile_model")
|| read_bool_field(directive, "isHostileChat").unwrap_or(false)
}
fn should_hostile_chat_breakoff_deterministically(
player_message: &str,
chat_directive: Option<&Value>,
) -> bool {
if !is_hostile_model_chat(chat_directive) {
return false;
}
let Some(directive) = chat_directive else {
return false;
};
if read_string_field(directive, "terminationReason").as_deref() == Some("player_exit") {
return true;
}
let hostile_break_words = [
"动手",
"开战",
"拔刀",
"",
"",
"闭嘴",
"少废话",
"别挡路",
];
count_keyword_matches(player_message, &hostile_break_words) > 0
}
fn normalize_required_text(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
return None;
}
Some(normalized.to_string())
}
fn parse_line_list_content(text: &str, max_items: usize) -> Vec<String> {
text.replace('\r', "")
.lines()
.map(|line| trim_line_list_marker(line.trim()).trim().to_string())
.filter(|line| !line.is_empty())
.take(max_items)
.collect()
}
fn trim_line_list_marker(line: &str) -> &str {
line.trim_start_matches(|character: char| {
character == '-'
|| character == '*'
|| character.is_ascii_digit()
|| character == '.'
|| character == ')'
|| character.is_whitespace()
})
}
fn count_keyword_matches(text: &str, keywords: &[&str]) -> i32 {
keywords
.iter()
.filter(|keyword| text.contains(**keyword))
.count() as i32
}
fn clamp_affinity_delta(value: i32) -> i32 {
value.clamp(-3, 3)
}
fn compute_npc_chat_affinity_delta(
player_message: &str,
npc_reply: &str,
chatted_count: f64,
) -> i32 {
let positive_keywords = [
"谢谢", "辛苦", "抱歉", "理解", "相信", "放心", "一起", "帮你", "在意", "关心",
];
let negative_keywords = [
"闭嘴",
"",
"少废话",
"威胁",
"",
"不信",
"别装",
"快说",
"审问",
"怀疑",
];
let warm_reply_keywords = ["可以", "愿意", "放心", "谢谢", "明白", ""];
let cold_reply_keywords = ["没必要", "不想", "别问", "与你无关", "算了", "住口"];
let positive_score = count_keyword_matches(player_message.trim(), &positive_keywords)
+ count_keyword_matches(npc_reply.trim(), &warm_reply_keywords);
let negative_score = count_keyword_matches(player_message.trim(), &negative_keywords)
+ count_keyword_matches(npc_reply.trim(), &cold_reply_keywords);
if positive_score == 0 && negative_score == 0 {
return if chatted_count == 0.0 { 1 } else { 0 };
}
if positive_score > negative_score {
let base_delta = positive_score - negative_score + if chatted_count <= 1.0 { 1 } else { 0 };
return clamp_affinity_delta(base_delta);
}
if negative_score > positive_score {
return clamp_affinity_delta(positive_score - negative_score);
}
0
}
fn describe_affinity_shift(affinity_delta: i32) -> &'static str {
if affinity_delta >= 8 {
return "态度明显软化了下来。";
}
if affinity_delta >= 5 {
return "态度比刚才亲近了一些。";
}
if affinity_delta > 0 {
return "对话气氛稍微松动了一点。";
}
if affinity_delta < 0 {
return "这轮对话让气氛变得更紧了一些。";
}
"这轮对话暂时没有带来明显关系变化。"
}
fn append_sse_event(
request_context: &RequestContext,
body: &mut String,
event: &str,
payload: &Value,
) -> Result<(), Response> {
let payload_text = serde_json::to_string(payload).map_err(|error| {
runtime_chat_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "runtime-chat",
"message": format!("SSE payload 序列化失败:{error}"),
})),
)
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
body,
)
.into_response()
}
fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn npc_chat_affinity_delta_keeps_node_keyword_rules() {
assert_eq!(
compute_npc_chat_affinity_delta("谢谢你愿意帮忙", "放心,我明白。", 0.0),
3
);
assert_eq!(
compute_npc_chat_affinity_delta("快说,别装。", "与你无关。", 2.0),
-3
);
assert_eq!(
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 0.0),
1
);
assert_eq!(
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 2.0),
0
);
}
#[test]
fn npc_initiated_opening_keeps_neutral_affinity_delta() {
// 首遇主动开场不是玩家发言结算,不能因为空 playerMessage 或占位文本触发好感变化。
let npc_initiates_conversation = true;
let player_message = "";
let npc_reply = "你来了。先别急着走,我正有话想和你说。";
let affinity_delta = if npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message, npc_reply, 0.0)
};
assert_eq!(affinity_delta, 0);
}
#[test]
fn npc_chat_suggestion_parser_strips_list_markers() {
assert_eq!(
parse_line_list_content("1. 继续问线索\n- 表明立场\n* 拉近关系\n4. 多余", 3),
vec!["继续问线索", "表明立场", "拉近关系"]
);
}
}

View File

@@ -0,0 +1,644 @@
use serde_json::Value;
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。
只输出 JSON不要输出 Markdown 或解释。
JSON 结构:
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId不写规则说明。
- 非敌对聊天 shouldEndChat 必须为 false。
- 敌对聊天可以随时 shouldEndChat=true且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: &'a [Value],
pub history: &'a [Value],
pub context: &'a Value,
pub conversation_history: &'a [Value],
pub dialogue: &'a [Value],
pub combat_context: Option<&'a Value>,
pub player_message: &'a str,
pub npc_state: &'a Value,
pub npc_initiates_conversation: bool,
pub chat_directive: Option<&'a Value>,
}
pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
let context = as_record(payload.context);
let npc_state = as_record(payload.npc_state);
let chat_directive = payload.chat_directive.and_then(as_record);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let opening_camp_background =
context.and_then(|record| read_string(record.get("openingCampBackground")));
let opening_camp_dialogue =
context.and_then(|record| read_string(record.get("openingCampDialogue")));
let allowed_topics = context
.and_then(|record| record.get("encounterAllowedTopics"))
.map(read_string_array)
.unwrap_or_default();
let blocked_topics = context
.and_then(|record| record.get("encounterBlockedTopics"))
.map(read_string_array)
.unwrap_or_default();
let is_first_meaningful_contact = context
.and_then(|record| read_bool(record.get("isFirstMeaningfulContact")))
.unwrap_or(false);
let affinity = npc_state
.and_then(|record| read_number(record.get("affinity")))
.unwrap_or(0.0);
let chatted_count = npc_state
.and_then(|record| read_number(record.get("chattedCount")))
.unwrap_or(0.0);
let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason")));
let turn_limit = chat_directive
.and_then(|record| read_number(record.get("turnLimit")))
.unwrap_or(0.0)
.max(0.0);
let remaining_turns = chat_directive
.and_then(|record| read_number(record.get("remainingTurns")))
.unwrap_or(0.0)
.max(0.0);
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
let is_limited_negative_affinity_chat =
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|| chat_directive
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
.unwrap_or(false);
let has_npc_reply_in_history = conversation_history.iter().any(|item| {
as_record(item)
.and_then(|turn| read_string(turn.get("speaker")))
.is_some_and(|speaker| speaker == "npc")
});
let is_first_npc_spoken_turn =
is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0;
let first_contact_relation_stance = describe_first_contact_relation_stance(
context.and_then(|record| record.get("firstContactRelationStance")),
);
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
opening_camp_background.map(|text| format!("营地开场背景:{text}")),
opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")),
Some(format!("当前关系值:{}", format_prompt_number(affinity))),
Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))),
if is_first_npc_spoken_turn {
Some(format!(
"当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。",
encounter.npc_name
))
} else {
None
},
if is_first_npc_spoken_turn {
Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string())
} else {
None
},
if is_first_npc_spoken_turn {
Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some(format!(
"当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。",
encounter.npc_name
))
} else {
None
},
if allowed_topics.is_empty() {
None
} else {
Some(format!("当前更适合先谈:{}", allowed_topics.join("")))
},
if blocked_topics.is_empty() {
None
} else {
Some(format!("当前避免直接说破:{}", blocked_topics.join("")))
},
if is_limited_negative_affinity_chat {
Some(format!(
"当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。",
format_prompt_number(turn_limit)
))
} else {
None
},
if is_hostile_model_chat {
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
} else {
None
},
if is_hostile_model_chat {
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
} else {
None
},
if is_player_exit_turn {
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
} else {
None
},
if is_limited_negative_affinity_chat {
Some(format!(
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
format_prompt_number(remaining_turns)
))
} else {
None
},
if is_limited_negative_affinity_chat && !is_foreshadow_close_turn {
Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message.trim()))
},
if payload.npc_initiates_conversation {
Some(format!(
"现在请只写 {} 主动开口时会说的话。",
encounter.npc_name
))
} else {
Some(format!(
"现在请只写 {} 这一轮会回复玩家的话。",
encounter.npc_name
))
},
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
pub(crate) fn build_npc_chat_turn_suggestion_prompt(
payload: &NpcChatTurnPromptInput<'_>,
npc_reply: &str,
) -> String {
let encounter = describe_encounter(payload.encounter);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
let chat_directive = payload.chat_directive.and_then(as_record);
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let function_options_block = chat_directive
.and_then(|record| record.get("functionOptions"))
.map(describe_function_options)
.filter(|text| !text.trim().is_empty());
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
function_options_block,
if payload.npc_initiates_conversation {
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message))
},
Some(format!("NPC 刚刚回复:{npc_reply}")),
if is_hostile_model_chat {
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())
} else {
Some("这是非敌对聊天shouldEndChat 必须为 false。".to_string())
},
if is_player_exit_turn {
Some("玩家已经选择结束聊天shouldEndChat 必须为 trueterminationReason 必须为 player_exit。".to_string())
} else {
None
},
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
Some("只输出 JSON{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
fn describe_function_options(value: &Value) -> String {
let lines = value
.as_array()
.map(|items| {
items
.iter()
.take(8)
.filter_map(|item| {
let record = as_record(item)?;
let function_id = read_string(record.get("functionId"))?;
let action_text = read_string(record.get("actionText"))?;
let detail_text = read_string(record.get("detailText"));
let action = read_string(record.get("action"));
Some(format!(
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
action.unwrap_or_else(|| "unknown".to_string()),
detail_text.unwrap_or_else(|| "".to_string()),
))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if lines.is_empty() {
return String::new();
}
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions".to_string()];
result.extend(lines);
result.join("\n")
}
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
[
format!("世界:{}", describe_world(payload.world_type)),
describe_scene_context(payload.context),
describe_character("玩家 / ", payload.character),
encounter.block,
describe_monsters(payload.monsters),
describe_story_history(payload.history),
]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
struct EncounterDescription {
npc_name: String,
block: String,
}
fn describe_encounter(encounter: &Value) -> EncounterDescription {
let record = as_record(encounter);
let npc_name = record
.and_then(|item| read_string(item.get("npcName")))
.unwrap_or_else(|| "眼前角色".to_string());
let context_text = record
.and_then(|item| read_string(item.get("context")))
.or_else(|| record.and_then(|item| read_string(item.get("npcDescription"))))
.unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string());
EncounterDescription {
npc_name: npc_name.clone(),
block: format!("当前对象:{npc_name}\n对象背景:{context_text}"),
}
}
fn describe_first_contact_relation_stance(value: Option<&Value>) -> String {
match value.and_then(|item| item.as_str()).map(str::trim) {
Some("guarded") => "戒备试探".to_string(),
Some("neutral") => "正常交流但仍不熟".to_string(),
Some("cooperative") => "已有善意,先确认合作节奏".to_string(),
Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(),
_ => "第一次真正接触".to_string(),
}
}
fn describe_world(world_type: &str) -> String {
match world_type {
"WUXIA" => "边城模板".to_string(),
"XIANXIA" => "灵潮模板".to_string(),
"CUSTOM" => "自定义世界".to_string(),
value if !value.trim().is_empty() => value.to_string(),
_ => "未知世界".to_string(),
}
}
fn describe_stats(label: &str, record: Option<&serde_json::Map<String, Value>>) -> String {
let hp = record
.and_then(|item| read_number(item.get("hp")))
.unwrap_or(0.0);
let max_hp = record
.and_then(|item| read_number(item.get("maxHp")))
.unwrap_or(hp)
.max(1.0);
let mana = record
.and_then(|item| read_number(item.get("mana")))
.unwrap_or(0.0);
let max_mana = record
.and_then(|item| read_number(item.get("maxMana")))
.unwrap_or(mana)
.max(1.0);
format!(
"{label}生命 {}/{},灵力 {}/{}",
format_prompt_number(hp),
format_prompt_number(max_hp),
format_prompt_number(mana),
format_prompt_number(max_mana)
)
}
fn describe_character(label: &str, value: &Value) -> String {
let record = as_record(value);
let name = record
.and_then(|item| read_string(item.get("name")))
.unwrap_or_else(|| "未知角色".to_string());
let title = record
.and_then(|item| read_string(item.get("title")))
.unwrap_or_else(|| "未知称号".to_string());
let description = record
.and_then(|item| read_string(item.get("description")))
.unwrap_or_else(|| "暂无额外描述".to_string());
let personality = record
.and_then(|item| read_string(item.get("personality")))
.unwrap_or_else(|| "性格信息未显式提供".to_string());
[
format!("{label}姓名:{name}"),
format!("{label}称号:{title}"),
format!("{label}描述:{description}"),
format!("{label}性格:{personality}"),
]
.join("\n")
}
fn describe_story_history(history: &[Value]) -> String {
if history.is_empty() {
return "近期剧情:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text"))))
.collect::<Vec<_>>();
if lines.is_empty() {
"近期剧情:暂无。".to_string()
} else {
let mut result = vec!["近期剧情:".to_string()];
result.extend(lines.into_iter().map(|line| format!("- {line}")));
result.join("\n")
}
}
fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String {
if history.is_empty() {
return "当前聊天记录:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| {
let record = as_record(item)?;
let speaker = read_string(record.get("speaker"));
let speaker_name = read_string(record.get("speakerName"));
let text = read_string(record.get("text"))?;
match speaker.as_deref() {
Some("player") => Some(format!("- 玩家:{text}")),
Some("npc") => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| npc_name.to_string())
)),
Some("system") => Some(format!("- 系统提示:{text}")),
_ => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| "同伴".to_string())
)),
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前聊天记录:暂无。".to_string()
} else {
let mut result = vec!["当前聊天记录:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn describe_npc_combat_context(combat_context: &Value) -> Option<String> {
let record = as_record(combat_context)?;
let summary = read_string(record.get("summary"));
let battle_outcome = read_string(record.get("battleOutcome"));
let log_lines = record
.get("logLines")
.map(read_string_array)
.unwrap_or_default()
.into_iter()
.take(6)
.collect::<Vec<_>>();
if summary.is_none() && log_lines.is_empty() {
return None;
}
let outcome_text = match battle_outcome.as_deref() {
Some("spar_complete") => Some("切磋刚刚结束。".to_string()),
Some("victory") => Some("战斗刚刚分出胜负。".to_string()),
_ => None,
};
let mut lines = vec!["刚刚结束的交锋:".to_string()];
if let Some(text) = outcome_text {
lines.push(text);
}
if let Some(text) = summary {
lines.push(format!("- 结果摘要:{text}"));
}
if !log_lines.is_empty() {
lines.push("- 战斗日志:".to_string());
lines.extend(log_lines.into_iter().map(|line| format!(" - {line}")));
}
Some(lines.join("\n"))
}
fn describe_scene_context(context: &Value) -> String {
let record = as_record(context);
let scene_name = record
.and_then(|item| read_string(item.get("sceneName")))
.unwrap_or_else(|| "当前区域".to_string());
let scene_description = record
.and_then(|item| read_string(item.get("sceneDescription")))
.unwrap_or_else(|| "周围气氛仍未完全安定。".to_string());
let in_battle = if record
.and_then(|item| read_bool(item.get("inBattle")))
.unwrap_or(false)
{
"战斗中"
} else {
"非战斗"
};
let custom_world_profile = record
.and_then(|item| item.get("customWorldProfile"))
.and_then(as_record);
let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name")));
let custom_world_summary =
custom_world_profile.and_then(|item| read_string(item.get("summary")));
[
Some(format!(
"世界补充:{}",
custom_world_name.unwrap_or_else(|| "".to_string())
)),
custom_world_summary.map(|text| format!("世界摘要:{text}")),
Some(format!("场景:{scene_name}")),
Some(format!("场景描述:{scene_description}")),
Some(format!("当前状态:{in_battle}")),
Some(describe_stats("玩家", record)),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n")
}
fn describe_monsters(monsters: &[Value]) -> String {
if monsters.is_empty() {
return "当前敌对目标:无。".to_string();
}
let lines = monsters
.iter()
.take(4)
.filter_map(|item| {
let record = as_record(item)?;
let name = read_string(record.get("name"))
.or_else(|| read_string(record.get("npcName")))
.or_else(|| read_string(record.get("id")))?;
let hp = read_number(record.get("hp")).unwrap_or(0.0);
let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0);
Some(format!(
"- {name}(生命 {}/{})",
format_prompt_number(hp),
format_prompt_number(max_hp)
))
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前敌对目标:无。".to_string()
} else {
let mut result = vec!["当前敌对目标:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn read_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_number(value: Option<&Value>) -> Option<f64> {
value
.and_then(Value::as_f64)
.filter(|number| number.is_finite())
}
fn read_bool(value: Option<&Value>) -> Option<bool> {
value.and_then(Value::as_bool)
}
fn read_string_array(value: &Value) -> Vec<String> {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| read_string(Some(item)))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn as_record(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
fn format_prompt_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
value.to_string()
}
}

View File

@@ -0,0 +1,192 @@
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
.seed_test_phone_user_with_password("13800138103", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_inventory".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("背包查询用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,627 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
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: entry.source_type.as_str().to_string(),
created_at: entry.created_at,
})
.collect(),
},
))
}
pub async fn get_profile_recharge_center(
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_recharge_center(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),
build_profile_recharge_center_response(record),
))
}
pub async fn create_profile_recharge_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateProfileRechargeOrderRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let payment_channel = payload
.payment_channel
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let (center, order) = state
.spacetime_client()
.create_profile_recharge_order(
user_id,
payload.product_id,
payment_channel,
created_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
CreateProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
))
}
pub async fn get_profile_referral_invite_center(
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_referral_invite_center(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),
build_profile_referral_invite_center_response(record),
))
}
pub async fn redeem_profile_referral_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_referral_invite_code_response(record),
))
}
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))
}
fn build_profile_recharge_center_response(
record: RuntimeProfileRechargeCenterRecord,
) -> ProfileRechargeCenterResponse {
ProfileRechargeCenterResponse {
wallet_balance: record.wallet_balance,
membership: ProfileMembershipResponse {
status: record.membership.status.as_str().to_string(),
tier: record.membership.tier.as_str().to_string(),
started_at: record.membership.started_at,
expires_at: record.membership.expires_at,
updated_at: record.membership.updated_at,
},
point_products: record
.point_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
membership_products: record
.membership_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
benefits: record
.benefits
.into_iter()
.map(build_profile_membership_benefit_response)
.collect(),
latest_order: record
.latest_order
.map(build_profile_recharge_order_response),
has_points_recharged: record.has_points_recharged,
}
}
fn build_profile_recharge_product_response(
record: RuntimeProfileRechargeProductRecord,
) -> ProfileRechargeProductResponse {
ProfileRechargeProductResponse {
product_id: record.product_id,
title: record.title,
price_cents: record.price_cents,
kind: record.kind.as_str().to_string(),
points_amount: record.points_amount,
bonus_points: record.bonus_points,
duration_days: record.duration_days,
badge_label: record.badge_label,
description: record.description,
tier: record.tier.as_str().to_string(),
}
}
fn build_profile_membership_benefit_response(
record: RuntimeProfileMembershipBenefitRecord,
) -> ProfileMembershipBenefitResponse {
ProfileMembershipBenefitResponse {
benefit_name: record.benefit_name,
normal_value: record.normal_value,
month_value: record.month_value,
season_value: record.season_value,
year_value: record.year_value,
}
}
fn build_profile_recharge_order_response(
record: RuntimeProfileRechargeOrderRecord,
) -> ProfileRechargeOrderResponse {
ProfileRechargeOrderResponse {
order_id: record.order_id,
product_id: record.product_id,
product_title: record.product_title,
kind: record.kind.as_str().to_string(),
amount_cents: record.amount_cents,
status: record.status.as_str().to_string(),
payment_channel: record.payment_channel,
paid_at: record.paid_at,
created_at: record.created_at,
points_delta: record.points_delta,
membership_expires_at: record.membership_expires_at,
}
}
fn build_profile_referral_invite_center_response(
record: RuntimeReferralInviteCenterRecord,
) -> ProfileReferralInviteCenterResponse {
ProfileReferralInviteCenterResponse {
invite_code: record.invite_code,
invite_link_path: record.invite_link_path,
invited_count: record.invited_count,
rewarded_invite_count: record.rewarded_invite_count,
today_inviter_reward_count: record.today_inviter_reward_count,
today_inviter_reward_remaining: record.today_inviter_reward_remaining,
reward_points: record.reward_points,
has_redeemed_code: record.has_redeemed_code,
bound_inviter_user_id: record.bound_inviter_user_id,
bound_at: record.bound_at,
updated_at: record.updated_at,
}
}
fn build_redeem_profile_referral_invite_code_response(
record: RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
#[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_recharge_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/recharge-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_order_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_60"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_invite_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/referrals/invite-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_redeem_code_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/referrals/redeem-code")
.header("content-type", "application/json")
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
.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
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_profile".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("资料页用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,408 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummaryResponse, PutSavedGameSnapshotRequest, SavedGameSnapshotResponse,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
#[derive(Clone, Debug, Deserialize)]
pub struct WorldKeyPath {
#[serde(rename = "world_key")]
pub world_key: String,
}
pub async fn get_runtime_snapshot(
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
.get_runtime_snapshot_record(user_id)
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
record.as_ref().map(build_saved_game_snapshot_response),
))
}
pub async fn put_runtime_snapshot(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PutSavedGameSnapshotRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let now = OffsetDateTime::now_utc();
let saved_at = payload
.saved_at
.as_deref()
.map(parse_rfc3339)
.transpose()
.map_err(|error| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"message": format!("savedAt 非法: {error}"),
})),
)
})?
.unwrap_or(now);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let record = state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
payload.bottom_tab,
payload.game_state,
payload.current_story,
updated_at_micros,
)
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
build_saved_game_snapshot_response(&record),
))
}
pub async fn delete_runtime_snapshot(
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();
state
.delete_runtime_snapshot_record(user_id)
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BasicOkResponse { ok: true },
))
}
pub async fn list_profile_save_archives(
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_save_archives(user_id)
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
ProfileSaveArchiveListResponse {
entries: entries
.iter()
.map(build_profile_save_archive_summary_response)
.collect(),
},
))
}
pub async fn resume_profile_save_archive(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(path): Path<WorldKeyPath>,
) -> Result<Json<Value>, Response> {
let world_key = path.world_key.trim().to_string();
if world_key.is_empty() {
return Err(runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"message": "worldKey 不能为空",
})),
));
}
let user_id = authenticated.claims().user_id().to_string();
let (entry, snapshot) = state
.spacetime_client()
.resume_profile_save_archive(user_id, world_key)
.await
.map_err(|error| {
runtime_save_error_response(
&request_context,
map_runtime_save_resume_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileSaveArchiveResumeResponse {
entry: build_profile_save_archive_summary_response(&entry),
snapshot: build_saved_game_snapshot_response(&snapshot),
},
))
}
fn build_saved_game_snapshot_response(
record: &module_runtime::RuntimeSnapshotRecord,
) -> SavedGameSnapshotResponse {
SavedGameSnapshotResponse {
version: record.version,
saved_at: record.saved_at.clone(),
game_state: record.game_state.clone(),
bottom_tab: record.bottom_tab.clone(),
current_story: record.current_story.clone(),
}
}
fn build_profile_save_archive_summary_response(
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
) -> ProfileSaveArchiveSummaryResponse {
ProfileSaveArchiveSummaryResponse {
world_key: record.world_key.clone(),
owner_user_id: record.owner_user_id.clone(),
profile_id: record.profile_id.clone(),
world_type: record.world_type.clone(),
world_name: record.world_name.clone(),
subtitle: record.subtitle.clone(),
summary_text: record.summary_text.clone(),
cover_image_src: record.cover_image_src.clone(),
last_played_at: record.saved_at.clone(),
}
}
fn map_runtime_save_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-save"),
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match &error {
SpacetimeClientError::Procedure(message)
if message.contains("world_key 不存在")
|| message.contains("对应 world_key 不存在") =>
{
(StatusCode::NOT_FOUND, "runtime-save")
}
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-save"),
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
fn runtime_save_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_snapshot_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/save/snapshot")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_save_archives_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/save-archives")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_save_archives_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/save-archives",
"/api/profile/save-archives",
)
.await;
}
#[tokio::test]
async fn resume_profile_save_archive_rejects_blank_world_key() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/save-archives/%20%20")
.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_REQUEST);
}
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_payload: Value = serde_json::from_slice(
&main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
let compat_payload: Value = serde_json::from_slice(
&compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138105", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_save".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("存档用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,368 @@
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
.seed_test_phone_user_with_password("13800138106", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_settings".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("设置用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,6 @@
mod compat;
pub use compat::{
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
resolve_runtime_story_action, resolve_runtime_story_state,
};

View File

@@ -0,0 +1,620 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
use module_npc::{
NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile,
build_relation_state as build_module_npc_relation_state,
};
use module_runtime::RuntimeSnapshotRecord;
use module_runtime_story_compat::{
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
add_player_currency, add_player_inventory_items, append_story_history,
apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options,
build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text,
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
current_encounter_id, current_encounter_name, current_world_type,
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
find_player_inventory_entry, format_currency_text, format_now_rfc3339,
grant_player_progression_experience, has_giftable_player_inventory, increment_runtime_stat,
normalize_equipment_slot_id, normalize_equipped_item, normalize_required_string,
npc_buyback_price, npc_purchase_price, read_array_field, read_bool_field, read_field,
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text,
resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item,
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
trade_quantity_suffix, write_bool_field, write_i32_field, write_null_field,
write_player_equipment_item, write_string_field, write_u32_field,
};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map, Value, json};
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction,
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation,
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
mod ai;
mod equipment_actions;
mod game_state;
mod npc_actions;
mod presentation;
mod quest_actions;
use self::{
ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*,
};
#[cfg(test)]
mod tests;
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 = resolve_snapshot_for_request(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
payload.snapshot,
)
.await?;
validate_client_version(
&request_context,
payload.client_version,
&snapshot.game_state,
"运行时版本已变化,请先同步最新快照后再读取状态",
)?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_state_response(&session_id, payload.client_version, snapshot),
))
}
pub async fn get_runtime_story_state(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let session_id = normalize_required_string(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 = resolve_snapshot_for_request(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
None,
)
.await?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_state_response(&session_id, None, snapshot),
))
}
pub async fn resolve_runtime_story_action(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryActionRequest>,
) -> Result<Json<Value>, Response> {
let requested_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 function_id =
normalize_required_string(payload.action.function_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": "action.functionId",
"message": "functionId 不能为空",
})),
)
})?;
if payload.action.action_type.trim() != "story_choice" {
return Err(runtime_story_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"field": "action.type",
"message": "runtime story 当前只支持 story_choice 动作",
})),
));
}
let mut snapshot = resolve_snapshot_for_request(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
payload.snapshot.clone(),
)
.await?;
validate_client_version(
&request_context,
payload.client_version,
&snapshot.game_state,
"运行时版本已变化,请先同步最新快照后再提交动作",
)?;
let current_story_before = snapshot.current_story.clone();
let mut game_state = snapshot.game_state.clone();
let mut resolution = resolve_runtime_story_choice_action(
&mut game_state,
current_story_before.as_ref(),
&payload,
&function_id,
)
.map_err(|message| {
runtime_story_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"message": message,
})),
)
})?;
let server_version = read_u32_field(&game_state, "runtimeActionVersion")
.unwrap_or(0)
.saturating_add(1);
write_u32_field(&mut game_state, "runtimeActionVersion", server_version);
write_string_field(
&mut game_state,
"runtimeSessionId",
requested_session_id.as_str(),
);
let mut options = resolution
.presentation_options
.take()
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
if options.is_empty() {
options = build_fallback_runtime_story_options(&game_state);
}
let mut story_text = resolution
.story_text
.clone()
.unwrap_or_else(|| resolution.result_text.clone());
let mut history_result_text = resolution.result_text.clone();
let mut saved_current_story = resolution
.saved_current_story
.take()
.unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options));
if let Some(generated_payload) = generate_action_story_payload(
&state,
&game_state,
&payload,
&function_id,
resolution.action_text.as_str(),
resolution.result_text.as_str(),
&options,
resolution.battle.as_ref(),
)
.await
{
story_text = generated_payload.story_text;
history_result_text = generated_payload.history_result_text;
options = generated_payload.presentation_options;
saved_current_story = generated_payload.saved_current_story;
}
append_story_history(
&mut game_state,
resolution.action_text.as_str(),
history_result_text.as_str(),
);
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
action_text: resolution.action_text.clone(),
result_text: history_result_text,
}];
patches.extend(resolution.patches);
snapshot.saved_at = Some(format_now_rfc3339());
snapshot.game_state = game_state;
snapshot.current_story = Some(saved_current_story);
let persisted = persist_runtime_story_snapshot(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
snapshot,
)
.await?;
let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted);
Ok(json_success_body(
Some(&request_context),
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
requested_session_id,
server_version,
snapshot: persisted_snapshot,
action_text: resolution.action_text,
result_text: resolution.result_text,
story_text,
options,
patches,
toast: resolution.toast,
battle: resolution.battle,
}),
))
}
pub async fn generate_runtime_story_initial(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryAiRequest>,
) -> Result<Json<Value>, Response> {
Ok(json_success_body(
Some(&request_context),
build_runtime_story_ai_response(&state, payload, true).await,
))
}
pub async fn generate_runtime_story_continue(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryAiRequest>,
) -> Result<Json<Value>, Response> {
Ok(json_success_body(
Some(&request_context),
build_runtime_story_ai_response(&state, payload, false).await,
))
}
async fn resolve_snapshot_for_request(
state: &AppState,
request_context: &RequestContext,
user_id: String,
snapshot: Option<RuntimeStorySnapshotPayload>,
) -> Result<RuntimeStorySnapshotPayload, Response> {
if let Some(snapshot) = snapshot {
let record =
persist_runtime_story_snapshot(state, request_context, user_id, snapshot).await?;
return Ok(runtime_snapshot_payload_from_record(&record));
}
let record = state
.get_runtime_snapshot_record(user_id)
.await
.map_err(|error| {
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
})?
.ok_or_else(|| {
runtime_story_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-story",
"message": "运行时快照不存在,请先初始化并保存一次游戏",
})),
)
})?;
Ok(runtime_snapshot_payload_from_record(&record))
}
async fn persist_runtime_story_snapshot(
state: &AppState,
request_context: &RequestContext,
user_id: String,
snapshot: RuntimeStorySnapshotPayload,
) -> Result<RuntimeSnapshotRecord, Response> {
validate_snapshot_payload(&snapshot).map_err(|message| {
runtime_story_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"message": message,
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = snapshot
.saved_at
.as_deref()
.and_then(|value| normalize_required_string(value))
.map(|value| parse_rfc3339(value.as_str()))
.transpose()
.map_err(|error| {
runtime_story_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"field": "snapshot.savedAt",
"message": format!("savedAt 非法: {error}"),
})),
)
})?
.unwrap_or(now);
state
.put_runtime_snapshot_record(
user_id,
offset_datetime_to_unix_micros(saved_at),
snapshot.bottom_tab,
snapshot.game_state,
snapshot.current_story,
offset_datetime_to_unix_micros(now),
)
.await
.map_err(|error| {
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
})
}
fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> {
if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
return Err("snapshot.bottomTab 不能为空".to_string());
}
if !snapshot.game_state.is_object() {
return Err("snapshot.gameState 必须是 JSON object".to_string());
}
if snapshot
.current_story
.as_ref()
.is_some_and(|current_story| !current_story.is_object())
{
return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string());
}
Ok(())
}
fn runtime_snapshot_payload_from_record(
record: &RuntimeSnapshotRecord,
) -> RuntimeStorySnapshotPayload {
RuntimeStorySnapshotPayload {
saved_at: Some(record.saved_at.clone()),
bottom_tab: record.bottom_tab.clone(),
game_state: record.game_state.clone(),
current_story: record.current_story.clone(),
}
}
fn validate_client_version(
request_context: &RequestContext,
client_version: Option<u32>,
game_state: &Value,
message: &str,
) -> Result<(), Response> {
let Some(client_version) = client_version else {
return Ok(());
};
let Some(server_version) = read_u32_field(game_state, "runtimeActionVersion") else {
return Ok(());
};
if client_version == server_version {
return Ok(());
}
Err(runtime_story_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-story",
"message": message,
"clientVersion": client_version,
"serverVersion": server_version,
})),
))
}
fn resolve_runtime_story_choice_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
ensure_runtime_story_bridge_state(game_state);
match function_id {
CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story),
"story_opening_camp_dialogue" => resolve_npc_affinity_action(
game_state,
request,
"交换开场判断",
2,
"你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。",
),
"camp_travel_home_scene" => {
clear_encounter_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text("返回营地", request),
result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: None,
toast: None,
})
}
"idle_call_out" => Ok(simple_story_resolution(
game_state,
resolve_action_text("主动出声试探", request),
"你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。",
)),
"idle_explore_forward" => Ok(simple_story_resolution(
game_state,
resolve_action_text("继续向前探索", request),
"你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。",
)),
"idle_observe_signs" => Ok(simple_story_resolution(
game_state,
resolve_action_text("观察周围迹象", request),
"你先压住动作,把风向、脚印和气味这些细节重新读了一遍。",
)),
"idle_rest_focus" => {
restore_player_resource(game_state, 8, 6);
Ok(simple_story_resolution(
game_state,
resolve_action_text("原地调息", request),
"你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。",
))
}
"idle_travel_next_scene" => {
clear_encounter_state(game_state);
increment_runtime_stat(game_state, "scenesTraveled", 1);
Ok(StoryResolution {
action_text: resolve_action_text("前往相邻场景", request),
result_text: "你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: None,
toast: None,
})
}
"npc_preview_talk" => resolve_npc_preview_action(game_state, request),
"npc_chat" => resolve_npc_chat_action(game_state, request),
"npc_help" => resolve_npc_help_action(game_state, request),
"npc_chat_quest_offer_view" => {
resolve_pending_quest_offer_view_action(game_state, current_story, request)
}
"npc_chat_quest_offer_replace" => {
resolve_pending_quest_offer_replace_action(game_state, current_story, request)
}
"npc_chat_quest_offer_abandon" => {
resolve_pending_quest_offer_abandon_action(game_state, current_story, request)
}
"npc_quest_accept" => {
resolve_pending_quest_accept_action(game_state, current_story, request)
}
"npc_quest_turn_in" => resolve_pending_quest_turn_in_action(game_state, request),
"npc_leave" => {
let npc_name = current_encounter_name(game_state);
clear_encounter_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text("离开当前角色", request),
result_text: format!("你结束了与 {npc_name} 的这一轮接触,把注意力重新放回旅途。"),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: None,
toast: None,
})
}
"npc_fight" | "npc_spar" => {
resolve_npc_battle_entry_action(game_state, request, function_id)
}
"npc_trade" => resolve_npc_trade_action(game_state, request),
"npc_gift" => resolve_npc_gift_action(game_state, request),
"npc_recruit" => resolve_npc_recruit_action(game_state, request),
"equipment_equip" => resolve_equipment_equip_action(game_state, request),
"equipment_unequip" => resolve_equipment_unequip_action(game_state, request),
"forge_craft" => resolve_forge_craft_action(game_state, request),
"forge_dismantle" => resolve_forge_dismantle_action(game_state, request),
"forge_reforge" => resolve_forge_reforge_action(game_state, request),
"battle_attack_basic"
| "battle_use_skill"
| "battle_all_in_crush"
| "battle_escape_breakout"
| "battle_feint_step"
| "battle_finisher_window"
| "battle_guard_break"
| "battle_probe_pressure"
| "battle_recover_breath"
| "inventory_use" => resolve_battle_action(game_state, request, function_id),
_ => Err(format!("暂不支持的 runtime action{function_id}")),
}
}
fn resolve_continue_adventure_action(
current_story: Option<&Value>,
) -> Result<StoryResolution, String> {
let deferred_options = current_story
.map(|story| {
read_array_field(story, "deferredOptions")
.into_iter()
.filter_map(build_runtime_story_option_from_story_option)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let options = (!deferred_options.is_empty()).then_some(deferred_options);
Ok(StoryResolution {
action_text: "继续推进冒险".to_string(),
result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(),
story_text: None,
presentation_options: options,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
})
}
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}

View File

@@ -0,0 +1,369 @@
use super::*;
use crate::prompt::runtime_chat::{
RuntimeNpcDialoguePromptParams, RuntimeReasonedStoryPromptParams, RuntimeStoryTextPromptParams,
build_runtime_npc_dialogue_user_prompt, build_runtime_reasoned_story_user_prompt,
build_runtime_story_director_user_prompt, runtime_npc_dialogue_system_prompt,
runtime_reasoned_story_system_prompt, runtime_story_director_system_prompt,
};
pub(super) async fn build_runtime_story_ai_response(
state: &AppState,
payload: RuntimeStoryAiRequest,
initial: bool,
) -> RuntimeStoryAiResponse {
let options = build_ai_response_options(&payload);
let fallback = build_ai_fallback_story_text(&payload, initial);
let story_text = generate_ai_story_text(state, &payload, initial)
.await
.filter(|text| !text.trim().is_empty())
.unwrap_or(fallback);
RuntimeStoryAiResponse {
story_text,
options,
encounter: None,
}
}
pub(super) async fn generate_ai_story_text(
state: &AppState,
payload: &RuntimeStoryAiRequest,
initial: bool,
) -> Option<String> {
let llm_client = state.llm_client()?;
let system_prompt = runtime_story_director_system_prompt(initial);
let user_prompt = build_runtime_story_director_user_prompt(RuntimeStoryTextPromptParams {
world_type: payload.world_type.as_str(),
character: payload.character.clone(),
monsters: Value::Array(payload.monsters.clone()),
history: Value::Array(payload.history.clone()),
choice: Value::String(payload.choice.clone()),
context: payload.context.clone(),
available_options: Value::Array(payload.request_options.available_options.clone()),
});
let mut request = LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]);
request.max_tokens = Some(700);
apply_rpg_web_search(state, &mut request);
llm_client
.request_text(request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|text| !text.is_empty())
}
pub(super) async fn generate_action_story_payload(
state: &AppState,
game_state: &Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
action_text: &str,
result_text: &str,
options: &[RuntimeStoryOptionView],
battle: Option<&RuntimeBattlePresentation>,
) -> Option<GeneratedStoryPayload> {
let llm_client = state.llm_client()?;
// 动作结算仍由确定性规则完成LLM 只负责把已结算结果改写为可展示文本,失败时不影响主链。
if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" {
return generate_npc_dialogue_payload(
llm_client,
state.config.rpg_llm_web_search_enabled,
game_state,
request,
action_text,
result_text,
options,
)
.await;
}
if should_generate_reasoned_combat_story(battle) {
return generate_reasoned_story_payload(
llm_client,
state.config.rpg_llm_web_search_enabled,
game_state,
request,
action_text,
result_text,
options,
battle,
)
.await;
}
None
}
fn apply_rpg_web_search(state: &AppState, request: &mut LlmTextRequest) {
request.enable_web_search = state.config.rpg_llm_web_search_enabled;
}
pub(super) async fn generate_npc_dialogue_payload(
llm_client: &LlmClient,
enable_web_search: bool,
game_state: &Value,
request: &RuntimeStoryActionRequest,
action_text: &str,
result_text: &str,
deferred_options: &[RuntimeStoryOptionView],
) -> Option<GeneratedStoryPayload> {
let world_type = current_world_type(game_state)?;
let character = read_object_field(game_state, "playerCharacter")?.clone();
let encounter = read_object_field(game_state, "currentEncounter")?;
if read_required_string_field(encounter, "kind").as_deref() != Some("npc") {
return None;
}
let npc_name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let user_prompt = build_runtime_npc_dialogue_user_prompt(
npc_name.as_str(),
RuntimeNpcDialoguePromptParams {
world_type: world_type.as_str(),
character: &character,
encounter,
monsters: read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>(),
history: build_action_story_history(game_state, action_text, result_text),
context: build_action_story_prompt_context(game_state, None),
topic: action_text,
result_summary: result_text,
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(deferred_options),
},
);
let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system(runtime_npc_dialogue_system_prompt()),
LlmMessage::user(user_prompt),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;
let dialogue_text = llm_client
.request_text(llm_request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|text| !text.is_empty())?;
let presentation_options = vec![build_continue_adventure_runtime_story_option()];
let saved_current_story =
build_dialogue_current_story(npc_name.as_str(), dialogue_text.as_str(), deferred_options);
Some(GeneratedStoryPayload {
story_text: dialogue_text.clone(),
history_result_text: dialogue_text,
presentation_options,
saved_current_story,
})
}
pub(super) async fn generate_reasoned_story_payload(
llm_client: &LlmClient,
enable_web_search: bool,
game_state: &Value,
request: &RuntimeStoryActionRequest,
action_text: &str,
result_text: &str,
options: &[RuntimeStoryOptionView],
battle: Option<&RuntimeBattlePresentation>,
) -> Option<GeneratedStoryPayload> {
let world_type = current_world_type(game_state)?;
let character = read_object_field(game_state, "playerCharacter")?.clone();
let user_prompt = build_runtime_reasoned_story_user_prompt(RuntimeReasonedStoryPromptParams {
world_type: world_type.as_str(),
character: &character,
monsters: read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>(),
history: build_action_story_history(game_state, action_text, result_text),
context: build_action_story_prompt_context(game_state, battle),
choice: action_text,
result_summary: result_text,
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(options),
});
let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system(runtime_reasoned_story_system_prompt()),
LlmMessage::user(user_prompt),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;
let story_text = llm_client
.request_text(llm_request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|text| !text.is_empty())?;
Some(GeneratedStoryPayload {
story_text: story_text.clone(),
history_result_text: story_text.clone(),
presentation_options: options.to_vec(),
saved_current_story: build_legacy_current_story(story_text.as_str(), options),
})
}
pub(super) fn should_generate_reasoned_combat_story(
battle: Option<&RuntimeBattlePresentation>,
) -> bool {
battle
.and_then(|presentation| presentation.outcome.as_deref())
.is_some_and(|outcome| matches!(outcome, "victory" | "spar_complete" | "escaped"))
}
pub(super) fn build_action_story_history(
game_state: &Value,
action_text: &str,
result_text: &str,
) -> Vec<Value> {
let mut history = read_array_field(game_state, "storyHistory")
.into_iter()
.filter_map(|entry| {
let text = read_optional_string_field(entry, "text")?;
let history_role = read_optional_string_field(entry, "historyRole")
.unwrap_or_else(|| "result".to_string());
Some(json!({
"text": text,
"historyRole": history_role,
}))
})
.collect::<Vec<_>>();
history.push(json!({
"text": action_text,
"historyRole": "action",
}));
history.push(json!({
"text": result_text,
"historyRole": "result",
}));
let keep_from = history.len().saturating_sub(12);
history.into_iter().skip(keep_from).collect()
}
pub(super) fn build_action_story_prompt_context(
game_state: &Value,
battle: Option<&RuntimeBattlePresentation>,
) -> Value {
let scene_preset = read_object_field(game_state, "currentScenePreset");
let battle_value = battle
.and_then(|presentation| serde_json::to_value(presentation).ok())
.unwrap_or(Value::Null);
json!({
"sceneName": scene_preset
.and_then(|scene| read_optional_string_field(scene, "name"))
.or_else(|| read_optional_string_field(game_state, "currentScene"))
.unwrap_or_else(|| "当前区域".to_string()),
"sceneDescription": scene_preset
.and_then(|scene| read_optional_string_field(scene, "description"))
.or_else(|| read_optional_string_field(game_state, "sceneDescription"))
.unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()),
"encounterName": read_object_field(game_state, "currentEncounter")
.and_then(|encounter| {
read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
}),
"encounterId": current_encounter_id(game_state),
"playerHp": read_i32_field(game_state, "playerHp").unwrap_or(0),
"playerMaxHp": read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
"playerMana": read_i32_field(game_state, "playerMana").unwrap_or(0),
"playerMaxMana": read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
"inBattle": read_bool_field(game_state, "inBattle").unwrap_or(false),
"currentNpcBattleOutcome": read_optional_string_field(game_state, "currentNpcBattleOutcome"),
"battle": battle_value,
})
}
pub(super) fn build_action_prompt_options(options: &[RuntimeStoryOptionView]) -> Vec<Value> {
options
.iter()
.filter(|option| !option.disabled.unwrap_or(false))
.map(|option| {
json!({
"functionId": option.function_id,
"actionText": option.action_text,
"text": option.action_text,
})
})
.collect()
}
pub(super) fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec<Value> {
let source = if payload.request_options.available_options.is_empty() {
&payload.request_options.option_catalog
} else {
&payload.request_options.available_options
};
let options = source
.iter()
.filter_map(normalize_ai_story_option)
.collect::<Vec<_>>();
if !options.is_empty() {
return options;
}
vec![
build_ai_story_option_value("idle_observe_signs", "观察周围迹象"),
build_ai_story_option_value("idle_explore_forward", "继续向前探索"),
build_ai_story_option_value("idle_rest_focus", "原地调息"),
]
}
pub(super) fn normalize_ai_story_option(value: &Value) -> Option<Value> {
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());
let mut option = value.as_object()?.clone();
option.insert("functionId".to_string(), Value::String(function_id));
option.insert("actionText".to_string(), Value::String(action_text.clone()));
option
.entry("text".to_string())
.or_insert_with(|| Value::String(action_text));
Some(Value::Object(option))
}
pub(super) fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value {
json!({
"functionId": function_id,
"actionText": action_text,
"text": action_text,
"visuals": {
"playerAnimation": "idle",
"playerMoveMeters": 0,
"playerOffsetY": 0,
"playerFacing": "right",
"scrollWorld": false,
"monsterChanges": []
}
})
}
pub(super) fn build_ai_fallback_story_text(
payload: &RuntimeStoryAiRequest,
initial: bool,
) -> String {
let character_name =
read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "".to_string());
let scene_name = read_optional_string_field(&payload.context, "sceneName")
.or_else(|| read_optional_string_field(&payload.context, "scene"))
.unwrap_or_else(|| "当前区域".to_string());
if initial {
return format!(
"{character_name}{scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。"
);
}
let choice = normalize_required_string(payload.choice.as_str())
.unwrap_or_else(|| "继续推进".to_string());
format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。")
}

View File

@@ -0,0 +1,106 @@
use super::*;
/// 对齐 Node 旧 inventory compat先按装备位把物品从背包切到 playerEquipment
/// 再把基础面板属性回算到快照上。
pub(super) fn resolve_equipment_equip_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
if read_field(game_state, "playerCharacter").is_none() {
return Err("缺少玩家角色,无法调整装备。".to_string());
}
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return Err("战斗中无法调整装备。".to_string());
}
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "equipment_equip 缺少 itemId".to_string())?;
let item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有这件装备。".to_string())?;
let slot_id = resolve_equipment_slot_for_item(&item)
.ok_or_else(|| format!("{} 不是可装备物品。", read_inventory_item_name(&item)))?;
let previous_equipment = read_player_equipment_item(game_state, slot_id);
let next_equipment_item = normalize_equipped_item(&item);
remove_player_inventory_item(game_state, item_id.as_str(), 1);
if let Some(previous_equipment) = previous_equipment.as_ref() {
add_player_inventory_items(game_state, vec![previous_equipment.clone()]);
}
write_player_equipment_item(game_state, slot_id, Some(next_equipment_item));
apply_equipment_loadout_to_state(game_state);
let item_name = read_inventory_item_name(&item);
let result_text = if let Some(previous_equipment) = previous_equipment.as_ref() {
format!(
"你将{}{}位上换下,改为装备{}",
read_inventory_item_name(previous_equipment),
equipment_slot_label(slot_id),
item_name
)
} else {
format!(
"你将{}装备在{}位上。",
item_name,
equipment_slot_label(slot_id)
)
};
Ok(StoryResolution {
action_text: resolve_action_text(&format!("装备{}", item_name), request),
result_text,
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}
pub(super) fn resolve_equipment_unequip_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
ensure_inventory_action_available(
game_state,
"缺少玩家角色,无法卸下装备。",
"战斗中无法卸下装备。",
)?;
let slot_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "slotId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
let slot_id = normalize_equipment_slot_id(slot_id.as_str())
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
let equipped_item = read_player_equipment_item(game_state, slot_id)
.ok_or_else(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))?;
write_player_equipment_item(game_state, slot_id, None);
add_player_inventory_items(game_state, vec![equipped_item.clone()]);
apply_equipment_loadout_to_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("卸下{}", read_inventory_item_name(&equipped_item)),
request,
),
result_text: format!(
"你卸下了{},暂时收回背包。",
read_inventory_item_name(&equipped_item)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}

View File

@@ -0,0 +1,699 @@
use super::*;
use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item};
pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), String> {
let encounter = read_object_field(game_state, "currentEncounter")
.ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?;
let kind = read_required_string_field(encounter, "kind")
.ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?;
if kind != "npc" {
return Err("当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string());
}
let npc_name = current_encounter_name(game_state);
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| npc_name.clone());
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
{
return Err("当前 NPC 状态不存在,无法继续结算。".to_string());
}
Ok((npc_id, npc_name))
}
pub(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> {
let Some(npc_id) = current_encounter_id(game_state) else {
return Vec::new();
};
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.map(|state| read_array_field(state, "inventory"))
.unwrap_or_default()
}
/// 兼容桥沿用 Node 旧域的入口预处理:在读取选项或结算动作前,
/// 先确保当前 NPC 的持久状态最少可用,避免空快照直接打断交易/赠礼/委托主链。
pub(super) fn ensure_runtime_story_bridge_state(game_state: &mut Value) {
ensure_current_encounter_npc_state_initialized(game_state);
}
/// 这里不尝试一次性重建完整真相态,只补 compat bridge 当前确实依赖的字段,
/// 并为“纯商贩型 NPC”补一份确定性 trade stock保证旧前端菜单不因空状态掉链子。
pub(super) fn ensure_current_encounter_npc_state_initialized(game_state: &mut Value) {
let Some(encounter) = read_object_field(game_state, "currentEncounter").cloned() else {
return;
};
if read_optional_string_field(&encounter, "kind").as_deref() != Some("npc") {
return;
}
let npc_name = read_optional_string_field(&encounter, "npcName")
.or_else(|| read_optional_string_field(&encounter, "name"))
.unwrap_or_else(|| "当前遭遇".to_string());
let npc_id = read_optional_string_field(&encounter, "id").unwrap_or_else(|| npc_name.clone());
let storage_key = resolve_npc_state_storage_key(game_state, npc_id.as_str(), npc_name.as_str());
let existing_state = read_field(game_state, "npcStates")
.and_then(|states| read_field(states, storage_key.as_str()))
.cloned();
let affinity = existing_state
.as_ref()
.and_then(|state| read_i32_field(state, "affinity"))
.unwrap_or_else(|| default_current_npc_affinity(&encounter));
let recruited = existing_state
.as_ref()
.and_then(|state| read_bool_field(state, "recruited"))
.unwrap_or(false);
let chatted_count = existing_state
.as_ref()
.and_then(|state| read_i32_field(state, "chattedCount"))
.unwrap_or(0)
.max(0);
let gifts_given = existing_state
.as_ref()
.and_then(|state| read_i32_field(state, "giftsGiven"))
.unwrap_or(0)
.max(0);
let help_used = existing_state
.as_ref()
.and_then(|state| read_bool_field(state, "helpUsed"))
.unwrap_or(false);
let first_meaningful_contact_resolved = existing_state
.as_ref()
.and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved"))
.unwrap_or(false);
let revealed_facts = existing_state
.as_ref()
.map(|state| read_string_list_field(state, "revealedFacts"))
.unwrap_or_default();
let known_attribute_rumors = existing_state
.as_ref()
.map(|state| read_string_list_field(state, "knownAttributeRumors"))
.unwrap_or_default();
let seen_backstory_chapter_ids = existing_state
.as_ref()
.map(|state| read_string_list_field(state, "seenBackstoryChapterIds"))
.unwrap_or_default();
let existing_inventory = existing_state
.as_ref()
.map(|state| {
read_array_field(state, "inventory")
.into_iter()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let existing_trade_stock_signature = existing_state
.as_ref()
.and_then(|state| read_optional_string_field(state, "tradeStockSignature"));
let hostile = read_bool_field(&encounter, "hostile").unwrap_or(false)
|| read_optional_string_field(&encounter, "monsterPresetId").is_some()
|| affinity < 0;
let context_text = read_optional_string_field(&encounter, "context");
let (inventory, trade_stock_signature) = if is_trade_driven_role_npc(&encounter) {
let next_signature = build_current_npc_trade_stock_signature(game_state, npc_id.as_str());
if existing_trade_stock_signature.as_deref() == Some(next_signature.as_str()) {
(existing_inventory, Some(next_signature))
} else {
(
sync_bootstrapped_trade_inventory(
game_state,
npc_id.as_str(),
npc_name.as_str(),
existing_inventory,
next_signature.as_str(),
),
Some(next_signature),
)
}
} else {
(existing_inventory, existing_trade_stock_signature)
};
let relation_state = build_runtime_story_relation_state_value(affinity);
let stance_profile = build_runtime_story_stance_profile_value(
affinity,
recruited,
hostile,
context_text.as_deref(),
existing_state
.as_ref()
.and_then(|state| read_field(state, "stanceProfile"))
.and_then(Value::as_object),
);
let npc_state = json!({
"affinity": affinity,
"chattedCount": chatted_count,
"helpUsed": help_used,
"giftsGiven": gifts_given,
"inventory": inventory,
"recruited": recruited,
"relationState": relation_state,
"revealedFacts": revealed_facts,
"knownAttributeRumors": known_attribute_rumors,
"firstMeaningfulContactResolved": first_meaningful_contact_resolved,
"seenBackstoryChapterIds": seen_backstory_chapter_ids,
"tradeStockSignature": trade_stock_signature,
"stanceProfile": stance_profile,
});
let root = ensure_json_object(game_state);
let npc_states = root
.entry("npcStates".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !npc_states.is_object() {
*npc_states = Value::Object(Map::new());
}
npc_states
.as_object_mut()
.expect("npcStates should be object")
.insert(storage_key, npc_state);
}
pub(super) fn resolve_npc_state_storage_key(
game_state: &Value,
npc_id: &str,
npc_name: &str,
) -> String {
read_object_field(game_state, "npcStates")
.and_then(Value::as_object)
.and_then(|states| {
if states.contains_key(npc_id) {
Some(npc_id.to_string())
} else if states.contains_key(npc_name) {
Some(npc_name.to_string())
} else {
None
}
})
.unwrap_or_else(|| npc_id.to_string())
}
pub(super) fn default_current_npc_affinity(encounter: &Value) -> i32 {
read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| {
if read_optional_string_field(encounter, "monsterPresetId").is_some() {
-40
} else if read_optional_string_field(encounter, "characterId").is_some() {
18
} else {
6
}
})
}
pub(super) fn read_string_list_field(value: &Value, key: &str) -> Vec<String> {
let mut items = read_array_field(value, key)
.into_iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if items.len() > 3 {
items = items.split_off(items.len() - 3);
}
items
}
pub(super) fn build_runtime_story_relation_state_value(affinity: i32) -> Value {
let relation_state = build_module_npc_relation_state(affinity);
json!({
"affinity": relation_state.affinity,
"stance": npc_relation_stance_key(relation_state.stance),
})
}
pub(super) fn npc_relation_stance_key(value: NpcRelationStance) -> &'static str {
match value {
NpcRelationStance::Hostile => "hostile",
NpcRelationStance::Guarded => "guarded",
NpcRelationStance::Neutral => "neutral",
NpcRelationStance::Cooperative => "cooperative",
NpcRelationStance::Bonded => "bonded",
}
}
pub(super) fn build_runtime_story_stance_profile_value(
affinity: i32,
recruited: bool,
hostile: bool,
role_text: Option<&str>,
existing_profile: Option<&Map<String, Value>>,
) -> Value {
let base = build_module_npc_initial_stance_profile(affinity, recruited, hostile, role_text);
let read_metric = |key: &str, fallback: u8| -> i32 {
existing_profile
.and_then(|profile| profile.get(key))
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(i32::from(fallback))
.clamp(0, 100)
};
let recent_approvals = existing_profile
.and_then(|profile| profile.get("recentApprovals"))
.map(|value| read_string_list_field(value, ""))
.unwrap_or_else(|| base.recent_approvals.clone());
let recent_disapprovals = existing_profile
.and_then(|profile| profile.get("recentDisapprovals"))
.map(|value| read_string_list_field(value, ""))
.unwrap_or_else(|| base.recent_disapprovals.clone());
json!({
"trust": read_metric("trust", base.trust),
"warmth": read_metric("warmth", base.warmth),
"ideologicalFit": read_metric("ideologicalFit", base.ideological_fit),
"fearOrGuard": read_metric("fearOrGuard", base.fear_or_guard),
"loyalty": read_metric("loyalty", base.loyalty),
"currentConflictTag": existing_profile
.and_then(|profile| profile.get("currentConflictTag"))
.and_then(Value::as_str)
.map(str::to_string)
.or(base.current_conflict_tag),
"recentApprovals": recent_approvals,
"recentDisapprovals": recent_disapprovals,
})
}
pub(super) fn is_trade_driven_role_npc(encounter: &Value) -> bool {
read_optional_string_field(encounter, "characterId").is_none()
&& read_optional_string_field(encounter, "monsterPresetId").is_none()
}
pub(super) fn build_current_npc_trade_stock_signature(game_state: &Value, npc_id: &str) -> String {
let scene_key = read_object_field(game_state, "currentScenePreset")
.and_then(|preset| {
read_optional_string_field(preset, "id")
.or_else(|| read_optional_string_field(preset, "name"))
})
.or_else(|| read_optional_string_field(game_state, "currentScene"))
.unwrap_or_else(|| "scene".to_string());
let world_key = current_world_type(game_state).unwrap_or_else(|| "world".to_string());
format!(
"{}:{}:{}",
sanitize_trade_stock_fragment(npc_id),
sanitize_trade_stock_fragment(scene_key.as_str()),
sanitize_trade_stock_fragment(world_key.as_str())
)
}
pub(super) fn sanitize_trade_stock_fragment(value: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|ch| match ch {
':' | '/' | '\\' | ' ' => '-',
_ => ch,
})
.collect::<String>();
if normalized.is_empty() {
"unknown".to_string()
} else {
normalized
}
}
pub(super) fn sync_bootstrapped_trade_inventory(
game_state: &Value,
npc_id: &str,
npc_name: &str,
existing_inventory: Vec<Value>,
trade_stock_signature: &str,
) -> Vec<Value> {
let preserved_inventory = existing_inventory
.into_iter()
.filter(|item| {
read_field(item, "runtimeMetadata")
.and_then(|metadata| read_optional_string_field(metadata, "generationChannel"))
.as_deref()
!= Some("npc_trade")
})
.collect::<Vec<_>>();
let mut next_inventory = preserved_inventory;
next_inventory.extend(build_bootstrapped_trade_inventory(
game_state,
npc_id,
npc_name,
trade_stock_signature,
));
next_inventory
}
pub(super) fn build_bootstrapped_trade_inventory(
game_state: &Value,
npc_id: &str,
npc_name: &str,
trade_stock_signature: &str,
) -> Vec<Value> {
let world_type = current_world_type(game_state);
let consumable_name = if world_type.as_deref() == Some("XIANXIA") {
"回灵散"
} else {
"回气散"
};
let material_name = if world_type.as_deref() == Some("XIANXIA") {
"凝光纱"
} else {
"工巧残材"
};
let relic_name = if world_type.as_deref() == Some("XIANXIA") {
"行旅护符"
} else {
"结绳护符"
};
let armor_name = if world_type.as_deref() == Some("XIANXIA") {
"护行法衣"
} else {
"护行短甲"
};
let tonic_id = format!("npc-trade:{trade_stock_signature}:tonic");
let material_id = format!("npc-trade:{trade_stock_signature}:material");
let relic_id = format!("npc-trade:{trade_stock_signature}:relic");
let armor_id = format!("npc-trade:{trade_stock_signature}:armor");
vec![
build_bootstrapped_trade_consumable_item(
tonic_id.as_str(),
consumable_name,
npc_name,
world_type.as_deref(),
),
attach_generated_trade_metadata(
build_runtime_material_item(
game_state,
material_name,
2,
&["工巧", "补给"],
"uncommon",
),
material_id.as_str(),
"npc_trade",
format!("{npc_id}:material").as_str(),
format!("{npc_name}整理出来的可交易工坊材料。").as_str(),
),
attach_generated_trade_metadata(
build_runtime_equipment_item(
game_state,
relic_name,
"relic",
"rare",
"适合长途行路时稳住灵力与节奏的护符。",
"护持",
&["护持", "法力"],
&["护持", "法力"],
json!({
"maxManaBonus": 12,
"outgoingDamageBonus": 0.05
}),
),
relic_id.as_str(),
"npc_trade",
format!("{npc_id}:relic").as_str(),
format!("{npc_name}随身携带的护身小物。").as_str(),
),
attach_generated_trade_metadata(
build_runtime_equipment_item(
game_state,
armor_name,
"armor",
"rare",
"为行路与近身护体准备的轻装护具。",
"守御",
&["守御", "护体"],
&["守御", "护体"],
json!({
"maxHpBonus": 18,
"incomingDamageMultiplier": 0.93
}),
),
armor_id.as_str(),
"npc_trade",
format!("{npc_id}:armor").as_str(),
format!("{npc_name}压箱底留下的一件护身装备。").as_str(),
),
]
}
pub(super) fn build_bootstrapped_trade_consumable_item(
item_id: &str,
name: &str,
npc_name: &str,
world_type: Option<&str>,
) -> Value {
json!({
"id": item_id,
"category": "消耗品",
"name": name,
"description": format!("{npc_name}常备的一份行路补给。"),
"quantity": 2,
"rarity": "uncommon",
"tags": if world_type == Some("XIANXIA") {
vec!["mana", "support", "trade"]
} else {
vec!["mana", "support", "trade"]
},
"useProfile": {
"hpRestore": 0,
"manaRestore": 10,
"cooldownReduction": 0,
"buildBuffs": []
},
"runtimeMetadata": {
"origin": "procedural",
"generationChannel": "npc_trade",
"seedKey": format!("{item_id}:seed"),
"sourceReason": format!("{npc_name}把最常用的补给拿出来做成了交易库存。"),
"storyFingerprint": {
"relatedScarIds": [format!("scar:npc_trade:{item_id}")],
"relatedThreadIds": [],
"visibleClue": format!("{npc_name}随身药囊里最顺手的一味补给。"),
"witnessMark": "药包封口处还留着反复拆开的折痕。",
"unresolvedQuestion": "这份补给之前究竟替谁留着。"
}
}
})
}
pub(super) fn attach_generated_trade_metadata(
mut item: Value,
item_id: &str,
generation_channel: &str,
seed_key: &str,
source_reason: &str,
) -> Value {
let item_name = read_inventory_item_name(&item);
let entry = ensure_json_object(&mut item);
entry.insert("id".to_string(), Value::String(item_id.to_string()));
entry.insert(
"runtimeMetadata".to_string(),
json!({
"origin": "procedural",
"generationChannel": generation_channel,
"seedKey": seed_key,
"sourceReason": source_reason,
"storyFingerprint": {
"relatedScarIds": [format!("scar:{generation_channel}:{seed_key}")],
"relatedThreadIds": [],
"visibleClue": format!("{item_name}上保留着反复流转留下的使用痕迹。"),
"witnessMark": "表面仍残留旧主人长期携带的磨损。",
"unresolvedQuestion": format!("{item_name}最初为什么会落到这名 NPC 手里。"),
}
}),
);
item
}
pub(super) fn read_current_npc_inventory_item<'a>(
game_state: &'a Value,
item_id: &str,
) -> Option<&'a Value> {
current_npc_inventory_items(game_state)
.into_iter()
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id))
}
pub(super) fn adjust_current_npc_affinity(
game_state: &mut Value,
delta: i32,
) -> Option<(String, i32, i32)> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
let previous_affinity = state
.get("affinity")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0);
let next_affinity = (previous_affinity + delta).clamp(-100, 100);
state.insert("affinity".to_string(), json!(next_affinity));
state
.entry("recruited".to_string())
.or_insert(Value::Bool(false));
Some((npc_id, previous_affinity, next_affinity))
}
pub(super) fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option<i32> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.and_then(|state| read_i32_field(state, key))
}
pub(super) fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option<bool> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.and_then(|state| read_bool_field(state, key))
}
pub(super) fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) {
let Some(npc_id) = current_encounter_id(game_state) else {
return;
};
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
state.insert(key.to_string(), json!(value));
}
pub(super) fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) {
let Some(npc_id) = current_encounter_id(game_state) else {
return;
};
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
state.insert(key.to_string(), Value::Bool(value));
}
pub(super) fn set_current_npc_recruited(
game_state: &mut Value,
recruited: bool,
) -> Option<(i32, i32)> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
let previous_affinity = state
.get("affinity")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0);
let next_affinity = previous_affinity.max(60);
state.insert("affinity".to_string(), json!(next_affinity));
state.insert("recruited".to_string(), Value::Bool(recruited));
Some((previous_affinity, next_affinity))
}
pub(super) fn read_current_npc_affinity(game_state: &Value) -> i32 {
let Some(npc_id) = current_encounter_id(game_state) else {
return 0;
};
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.and_then(|state| read_i32_field(state, "affinity"))
.unwrap_or(0)
}
pub(super) fn ensure_npc_state_object<'a>(
game_state: &'a mut Value,
npc_id: &str,
npc_name: &str,
) -> &'a mut Map<String, Value> {
let root = ensure_json_object(game_state);
let npc_states = root
.entry("npcStates".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !npc_states.is_object() {
*npc_states = Value::Object(Map::new());
}
let states = npc_states
.as_object_mut()
.expect("npcStates should be object");
let existing_key = if states.contains_key(npc_id) {
npc_id.to_string()
} else if states.contains_key(npc_name) {
npc_name.to_string()
} else {
npc_id.to_string()
};
let state = states
.entry(existing_key)
.or_insert_with(|| Value::Object(Map::new()));
if !state.is_object() {
*state = Value::Object(Map::new());
}
state.as_object_mut().expect("npc state should be object")
}
pub(super) fn mark_current_npc_first_meaningful_contact_resolved(game_state: &mut Value) {
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
}
pub(super) fn ensure_current_npc_inventory_array<'a>(
game_state: &'a mut Value,
) -> Option<&'a mut Vec<Value>> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
let inventory = state
.entry("inventory".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !inventory.is_array() {
*inventory = Value::Array(Vec::new());
}
inventory.as_array_mut()
}
pub(super) fn add_current_npc_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
if additions.is_empty() {
return;
}
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
return;
};
for addition in additions {
let Some(add_id) = read_optional_string_field(&addition, "id") else {
continue;
};
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
if let Some(existing) = items
.iter_mut()
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()))
{
let next_quantity =
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
if let Some(existing_object) = existing.as_object_mut() {
existing_object.insert("quantity".to_string(), json!(next_quantity));
}
continue;
}
items.push(addition);
}
}
pub(super) fn remove_current_npc_inventory_item(
game_state: &mut Value,
item_id: &str,
quantity: i32,
) {
if quantity <= 0 {
return;
}
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
return;
};
let Some(index) = items
.iter()
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
else {
return;
};
let current_quantity = read_i32_field(&items[index], "quantity")
.unwrap_or(0)
.max(0);
let next_quantity = current_quantity - quantity;
if next_quantity <= 0 {
items.remove(index);
return;
}
if let Some(entry) = items[index].as_object_mut() {
entry.insert("quantity".to_string(), json!(next_quantity));
}
}

View File

@@ -0,0 +1,398 @@
use super::*;
pub(super) fn resolve_npc_preview_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc_name = current_encounter_name(game_state);
write_bool_field(game_state, "npcInteractionActive", true);
Ok(StoryResolution {
action_text: resolve_action_text("转向眼前角色", request),
result_text: format!("{npc_name} 注意到了你的靠近,正在等你先把话说出来。"),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_affinity_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
default_action_text: &str,
affinity_delta: i32,
fallback_result_text: &str,
) -> Result<StoryResolution, String> {
write_bool_field(game_state, "npcInteractionActive", true);
let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map(
|(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged {
npc_id,
previous_affinity,
next_affinity,
},
);
let mut patches = Vec::new();
if let Some(patch) = affinity_patch {
patches.push(patch);
}
patches.push(build_status_patch(game_state));
Ok(StoryResolution {
action_text: resolve_action_text(default_action_text, request),
result_text: fallback_result_text.to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_chat_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0);
let affinity_gain = (6 - chatted_count).max(2);
let result_text = format!(
"{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。",
current_encounter_name(game_state),
affinity_gain
);
let mut resolution = resolve_npc_affinity_action(
game_state,
request,
"继续交谈",
affinity_gain,
result_text.as_str(),
)?;
write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1));
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state));
Ok(resolution)
}
pub(super) fn resolve_npc_help_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
return Err("当前 NPC 的一次性援手已经用完了".to_string());
}
restore_player_resource(game_state, 10, 8);
write_current_npc_state_bool_field(game_state, "helpUsed", true);
resolve_npc_affinity_action(
game_state,
request,
&format!("{}请求援手", current_encounter_name(game_state)),
4,
&format!(
"{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。",
current_encounter_name(game_state)
),
)
}
pub(super) fn resolve_npc_battle_entry_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
let npc_name = current_encounter_name(game_state);
let battle_mode = if function_id == "npc_spar" {
"spar"
} else {
"fight"
};
write_bool_field(game_state, "inBattle", true);
write_bool_field(game_state, "npcInteractionActive", false);
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
write_null_field(game_state, "currentNpcBattleOutcome");
Ok(StoryResolution {
action_text: resolve_action_text(
if battle_mode == "spar" {
"点到为止切磋"
} else {
"与对方战斗"
},
request,
),
result_text: format!(
"{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。",
battle_mode_text(battle_mode)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: Some(RuntimeBattlePresentation {
target_id: Some(npc_id),
target_name: Some(npc_name),
damage_dealt: None,
damage_taken: None,
outcome: Some("ongoing".to_string()),
}),
toast: None,
})
}
pub(super) fn resolve_npc_recruit_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
let npc_name = current_encounter_name(game_state);
let current_affinity = read_current_npc_affinity(game_state);
if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) {
return Err("当前 NPC 已经处于已招募状态".to_string());
}
if current_affinity < 60 {
return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string());
}
let release_npc_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "releaseNpcId"));
let released_companion_name = recruit_companion_to_party(
game_state,
npc_id.as_str(),
current_affinity,
release_npc_id.as_deref(),
)?;
let affinity_patch =
set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| {
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc_id.clone(),
previous_affinity,
next_affinity,
}
});
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
write_bool_field(game_state, "npcInteractionActive", false);
clear_encounter_only(game_state);
write_null_field(game_state, "currentNpcBattleMode");
write_null_field(game_state, "currentNpcBattleOutcome");
write_bool_field(game_state, "inBattle", false);
let mut patches = Vec::new();
if let Some(patch) = affinity_patch {
patches.push(patch);
}
patches.push(build_status_patch(game_state));
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
Ok(StoryResolution {
action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request),
result_text: match released_companion_name {
Some(released_name) => format!(
"{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。"
),
None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"),
},
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: None,
toast: Some(format!("{npc_name} 已加入队伍")),
})
}
/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回,
/// 后续再由真相态 inventory / runtime-item reducer 接管。
pub(super) fn resolve_npc_trade_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let (_npc_id, npc_name) = current_npc_trade_context(game_state)?;
let payload = request.action.payload.as_ref();
let mode = payload
.and_then(|value| read_optional_string_field(value, "mode"))
.ok_or_else(|| "npc_trade 缺少合法 mode需为 buy 或 sell".to_string())?;
if mode != "buy" && mode != "sell" {
return Err("npc_trade 缺少合法 mode需为 buy 或 sell".to_string());
}
let item_id = payload
.and_then(|value| {
read_optional_string_field(value, "itemId")
.or_else(|| read_optional_string_field(value, "selectedNpcItemId"))
.or_else(|| read_optional_string_field(value, "selectedPlayerItemId"))
})
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
let quantity = payload
.and_then(|value| read_i32_field(value, "quantity"))
.unwrap_or(1)
.max(1);
if mode == "buy" {
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "目标商品不存在或库存不足。".to_string())?;
let available_quantity = read_i32_field(&npc_item, "quantity").unwrap_or(0).max(0);
if available_quantity < quantity {
return Err("目标商品不存在或库存不足。".to_string());
}
let total_price = npc_purchase_price(&npc_item, read_current_npc_affinity(game_state))
.saturating_mul(quantity);
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
if player_currency < total_price {
return Err("当前钱币不足,无法完成购买。".to_string());
}
write_i32_field(game_state, "playerCurrency", player_currency - total_price);
add_player_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&npc_item, quantity)],
);
remove_current_npc_inventory_item(game_state, item_id.as_str(), quantity);
mark_current_npc_first_meaningful_contact_resolved(game_state);
let item_name = read_inventory_item_name(&npc_item);
return Ok(StoryResolution {
action_text: resolve_action_text(
&format!(
"{}手里买下{}{}",
npc_name,
item_name,
trade_quantity_suffix(quantity)
),
request,
),
result_text: format!(
"{}收下了{},把{}{}卖给了你。",
npc_name,
format_currency_text(
total_price,
read_optional_string_field(game_state, "worldType").as_deref()
),
item_name,
trade_quantity_suffix(quantity)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
});
}
let player_item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有足够数量的目标物品。".to_string())?;
let available_quantity = read_i32_field(&player_item, "quantity").unwrap_or(0).max(0);
if available_quantity < quantity {
return Err("背包里没有足够数量的目标物品。".to_string());
}
let total_price = npc_buyback_price(&player_item, read_current_npc_affinity(game_state))
.saturating_mul(quantity);
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
write_i32_field(
game_state,
"playerCurrency",
player_currency.saturating_add(total_price),
);
remove_player_inventory_item(game_state, item_id.as_str(), quantity);
add_current_npc_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&player_item, quantity)],
);
mark_current_npc_first_meaningful_contact_resolved(game_state);
let item_name = read_inventory_item_name(&player_item);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!(
"{}{}卖给{}",
item_name,
trade_quantity_suffix(quantity),
npc_name
),
request,
),
result_text: format!(
"{}收下了{}{},付给你{}。",
npc_name,
item_name,
trade_quantity_suffix(quantity),
format_currency_text(
total_price,
read_optional_string_field(game_state, "worldType").as_deref()
)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_gift_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let (npc_id, npc_name) = current_npc_trade_context(game_state)?;
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "npc_gift 缺少 itemId".to_string())?;
let gift_item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有这件可赠送的物品。".to_string())?;
if read_i32_field(&gift_item, "quantity").unwrap_or(0) <= 0 {
return Err("背包里没有这件可赠送的物品。".to_string());
}
let previous_affinity = read_current_npc_affinity(game_state);
let affinity_gain = resolve_npc_gift_affinity_gain(&gift_item);
let next_affinity = (previous_affinity + affinity_gain).clamp(-100, 100);
remove_player_inventory_item(game_state, item_id.as_str(), 1);
add_current_npc_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&gift_item, 1)],
);
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
let next_gifts_given =
read_current_npc_state_i32_field(game_state, "giftsGiven").unwrap_or(0) + 1;
write_current_npc_state_i32_field(game_state, "giftsGiven", next_gifts_given);
mark_current_npc_first_meaningful_contact_resolved(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("{}赠给{}", read_inventory_item_name(&gift_item), npc_name),
request,
),
result_text: build_npc_gift_result_text(
npc_name.as_str(),
&gift_item,
affinity_gain,
next_affinity,
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
npc_id,
previous_affinity,
next_affinity,
}],
battle: None,
toast: None,
})
}

View File

@@ -0,0 +1,734 @@
use super::*;
pub(super) fn build_runtime_story_state_response(
requested_session_id: &str,
client_version: Option<u32>,
mut snapshot: RuntimeStorySnapshotPayload,
) -> RuntimeStoryActionResponse {
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
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);
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
requested_session_id: session_id,
server_version,
snapshot,
action_text: String::new(),
result_text: String::new(),
story_text,
options,
patches: Vec::new(),
toast: None,
battle: None,
})
}
pub(super) fn build_runtime_story_action_response(
parts: RuntimeStoryActionResponseParts,
) -> RuntimeStoryActionResponse {
let session_id = read_runtime_session_id(&parts.snapshot.game_state)
.unwrap_or_else(|| parts.requested_session_id);
RuntimeStoryActionResponse {
session_id,
server_version: parts.server_version,
view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options),
presentation: RuntimeStoryPresentation {
action_text: parts.action_text,
result_text: parts.result_text,
story_text: parts.story_text,
options: parts.options,
toast: parts.toast,
battle: parts.battle,
},
patches: parts.patches,
snapshot: parts.snapshot,
}
}
pub(super) fn build_dialogue_current_story(
npc_name: &str,
text: &str,
deferred_options: &[RuntimeStoryOptionView],
) -> Value {
let continue_option = build_continue_adventure_runtime_story_option();
// 对齐 Node 旧 currentStory先展示单轮对话只把真实下一步选项压到 deferredOptions。
json!({
"text": text,
"options": vec![build_story_option_from_runtime_option(&continue_option)],
"displayMode": "dialogue",
"dialogue": parse_dialogue_turns(text, npc_name),
"streaming": false,
"deferredOptions": deferred_options
.iter()
.map(build_story_option_from_runtime_option)
.collect::<Vec<_>>(),
})
}
pub(super) fn build_continue_adventure_runtime_story_option() -> RuntimeStoryOptionView {
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story")
}
pub(super) fn parse_dialogue_turns(text: &str, npc_name: &str) -> Vec<Value> {
let mut turns = Vec::new();
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(turn) = parse_dialogue_line(line, npc_name) {
turns.push(turn);
}
}
if turns.is_empty() && !text.trim().is_empty() {
turns.push(json!({
"speaker": "npc",
"speakerName": npc_name,
"text": text.trim(),
}));
}
turns
}
pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option<Value> {
let delimiter_index = line.find('').or_else(|| line.find(':'))?;
let speaker_name = line[..delimiter_index].trim();
let content_start = delimiter_index + line[delimiter_index..].chars().next()?.len_utf8();
let content = line[content_start..].trim();
if content.is_empty() {
return None;
}
if speaker_name == "" {
return Some(json!({
"speaker": "player",
"text": content,
}));
}
if speaker_name == npc_name {
return Some(json!({
"speaker": "npc",
"speakerName": npc_name,
"text": content,
}));
}
Some(json!({
"speaker": "companion",
"speakerName": speaker_name,
"text": content,
}))
}
pub(super) 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)
}
pub(super) fn build_fallback_runtime_story_options(
game_state: &Value,
) -> Vec<RuntimeStoryOptionView> {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return build_battle_runtime_story_options(game_state);
}
let encounter = read_object_field(game_state, "currentEncounter");
if let Some(encounter) = encounter {
if matches!(
read_required_string_field(encounter, "kind").as_deref(),
Some("npc")
) {
let interaction_active =
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
let npc_id = read_required_string_field(encounter, "id")
.unwrap_or_else(|| "npc_current".to_string());
if let Some(active_quest) = find_active_quest_for_issuer(game_state, npc_id.as_str()) {
if read_optional_string_field(active_quest, "status")
.is_some_and(|status| status == "completed")
{
return vec![
build_npc_runtime_story_option_with_quest(
"npc_quest_turn_in",
&format!("{}交付委托", current_encounter_name(game_state)),
&npc_id,
"quest_turn_in",
read_optional_string_field(active_quest, "id"),
),
build_npc_runtime_story_option(
"npc_leave",
"离开当前角色",
&npc_id,
"leave",
),
];
}
}
if interaction_active {
return build_active_npc_runtime_story_options(game_state, npc_id.as_str());
}
return vec![
build_npc_runtime_story_option("npc_preview_talk", "转向眼前角色", &npc_id, "chat"),
build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"),
build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"),
];
}
}
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(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"),
]
}
pub(super) fn build_npc_runtime_story_option(
function_id: &str,
action_text: &str,
npc_id: &str,
action: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
interaction: Some(RuntimeStoryOptionInteraction::Npc {
npc_id: npc_id.to_string(),
action: action.to_string(),
quest_id: None,
}),
..build_static_runtime_story_option(function_id, action_text, "npc")
}
}
pub(super) fn build_npc_runtime_story_option_with_payload(
function_id: &str,
action_text: &str,
npc_id: &str,
action: &str,
payload: Value,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
payload: Some(payload),
..build_npc_runtime_story_option(function_id, action_text, npc_id, action)
}
}
pub(super) fn build_npc_runtime_story_option_with_quest(
function_id: &str,
action_text: &str,
npc_id: &str,
action: &str,
quest_id: Option<String>,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
interaction: Some(RuntimeStoryOptionInteraction::Npc {
npc_id: npc_id.to_string(),
action: action.to_string(),
quest_id,
}),
..build_static_runtime_story_option(function_id, action_text, "npc")
}
}
/// 对齐 Node 旧 compat 入口顺序,在 NPC 交互态下统一补齐交易、赠礼、委托与招募入口。
pub(super) fn build_active_npc_runtime_story_options(
game_state: &Value,
npc_id: &str,
) -> Vec<RuntimeStoryOptionView> {
let mut options = vec![
build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"),
build_npc_help_runtime_story_option(game_state, npc_id),
build_npc_runtime_story_option("npc_spar", "点到为止切磋", npc_id, "spar"),
build_npc_runtime_story_option("npc_fight", "与对方战斗", npc_id, "fight"),
];
if current_npc_inventory_items(game_state)
.iter()
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
{
options.push(build_npc_runtime_story_option(
"npc_trade",
"交易",
npc_id,
"trade",
));
}
if has_giftable_player_inventory(game_state) {
options.push(build_npc_runtime_story_option(
"npc_gift",
"赠送礼物",
npc_id,
"gift",
));
}
let active_quest = find_active_quest_for_issuer(game_state, npc_id);
if let Some(active_quest) = active_quest {
let can_turn_in = read_optional_string_field(active_quest, "status")
.is_some_and(|status| status == "completed" || status == "ready_to_turn_in");
if can_turn_in {
options.push(build_npc_runtime_story_option_with_quest(
"npc_quest_turn_in",
&format!("{}交付委托", current_encounter_name(game_state)),
npc_id,
"quest_turn_in",
read_optional_string_field(active_quest, "id"),
));
}
} else {
options.push(build_npc_runtime_story_option(
"npc_quest_accept",
"接下委托",
npc_id,
"quest_accept",
));
}
if read_current_npc_affinity(game_state) >= 60
&& !read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false)
{
options.push(build_npc_runtime_story_option(
"npc_recruit",
"邀请同行",
npc_id,
"recruit",
));
}
options.push(build_npc_runtime_story_option(
"npc_leave",
"离开当前角色",
npc_id,
"leave",
));
options
}
pub(super) fn build_npc_help_runtime_story_option(
game_state: &Value,
npc_id: &str,
) -> RuntimeStoryOptionView {
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
return build_disabled_runtime_story_option(
"npc_help",
"请求援手",
"npc",
None,
"当前 NPC 的一次性援手已经用完了。",
None,
);
}
build_npc_runtime_story_option("npc_help", "请求援手", npc_id, "help")
}
pub(super) fn current_encounter_npc_quest_context(
game_state: &Value,
) -> Result<CurrentEncounterNpcQuestContext, String> {
let encounter = read_object_field(game_state, "currentEncounter")
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
let kind = read_required_string_field(encounter, "kind")
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
if kind != "npc" {
return Err("当前不在可结算的 NPC 委托态。".to_string());
}
let npc_name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "当前角色".to_string());
let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
{
return Err("当前 NPC 状态不存在,无法处理委托。".to_string());
}
Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name })
}
pub(super) fn read_pending_quest_offer_context(
current_story: Option<&Value>,
npc_key: &str,
) -> Option<PendingQuestOfferContext> {
let current_story = current_story?;
let npc_chat_state = read_object_field(current_story, "npcChatState")?;
let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?;
let quest = read_object_field(pending_offer, "quest")?.clone();
let quest_id = read_optional_string_field(&quest, "id")?;
let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId");
let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId");
if pending_npc_id
.as_deref()
.is_some_and(|value| value != npc_key)
{
return None;
}
if issuer_npc_id
.as_deref()
.is_some_and(|value| value != npc_key)
{
return None;
}
Some(PendingQuestOfferContext {
dialogue: read_array_field(current_story, "dialogue")
.into_iter()
.cloned()
.collect(),
turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0),
custom_input_placeholder: read_optional_string_field(
npc_chat_state,
"customInputPlaceholder",
)
.unwrap_or_else(|| "输入你想对 TA 说的话".to_string()),
quest,
quest_id,
intro_text: read_optional_string_field(pending_offer, "introText"),
})
}
pub(super) fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String {
let summary_text = read_optional_string_field(quest, "summary")
.or_else(|| read_optional_string_field(quest, "description"))
.unwrap_or_default();
if summary_text.is_empty() {
return format!(
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。"
);
}
format!(
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}"
)
}
pub(super) fn append_dialogue_turns(existing: &[Value], additions: Vec<Value>) -> Vec<Value> {
let mut dialogue = existing.to_vec();
dialogue.extend(additions);
dialogue
}
pub(super) fn build_pending_quest_offer_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
vec![
build_npc_runtime_story_option_with_payload(
"npc_chat_quest_offer_view",
"查看任务",
npc_id,
"quest_offer_view",
json!({
"npcChatQuestOfferAction": "view"
}),
),
build_npc_runtime_story_option_with_payload(
"npc_chat_quest_offer_replace",
"更换任务",
npc_id,
"quest_offer_replace",
json!({
"npcChatQuestOfferAction": "replace"
}),
),
build_npc_runtime_story_option_with_payload(
"npc_chat_quest_offer_abandon",
"放弃任务",
npc_id,
"quest_offer_abandon",
json!({
"npcChatQuestOfferAction": "abandon"
}),
),
]
}
pub(super) fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
vec![
build_npc_runtime_story_option(
"npc_chat",
"那先继续聊聊你刚才没说完的部分",
npc_id,
"chat",
),
build_npc_runtime_story_option(
"npc_chat",
"除了委托,你对眼前局势还有什么判断",
npc_id,
"chat",
),
build_npc_runtime_story_option(
"npc_chat",
"先把这附近真正危险的地方说清楚",
npc_id,
"chat",
),
]
}
pub(super) fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
vec![
build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"),
build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"),
build_npc_runtime_story_option(
"npc_chat",
"除了这份委托,你还想提醒我什么",
npc_id,
"chat",
),
]
}
pub(super) fn build_pending_quest_offer_story(
dialogue: Vec<Value>,
npc_id: &str,
npc_name: &str,
turn_count: i32,
custom_input_placeholder: &str,
pending_quest: Option<Value>,
options: &[RuntimeStoryOptionView],
) -> Value {
json!({
"text": dialogue
.iter()
.filter_map(|entry| read_optional_string_field(entry, "text"))
.collect::<Vec<_>>()
.join("\n"),
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
"displayMode": "dialogue",
"dialogue": dialogue,
"streaming": false,
"npcChatState": {
"npcId": npc_id,
"npcName": npc_name,
"turnCount": turn_count,
"customInputPlaceholder": custom_input_placeholder,
"pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })),
}
})
}
pub(super) fn build_next_pending_quest_offer(
game_state: &Value,
npc_id: &str,
npc_name: &str,
previous_quest_id: Option<&str>,
) -> Value {
let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") {
"quest-bridge-replaced"
} else {
"quest-generated-replaced"
};
let title = if next_id == "quest-bridge-replaced" {
"断桥夜巡"
} else {
"新的临时委托"
};
let scene_id = read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "id"));
json!({
"id": next_id,
"issuerNpcId": npc_id,
"issuerNpcName": npc_name,
"sceneId": scene_id,
"title": title,
"description": format!("{title}的详细说明。"),
"summary": format!("{title}的简要目标。"),
"objective": {
"kind": "talk_to_npc",
"requiredCount": 1
},
"progress": 0,
"status": "active",
"reward": {
"affinityBonus": 6,
"currency": 30,
"items": []
},
"rewardText": "完成后可以领取报酬。",
"steps": [{
"id": format!("{next_id}-step-1"),
"title": "查清线索",
"kind": "talk_to_npc",
"requiredCount": 1,
"progress": 0,
"revealText": "先去断桥口附近把相关线索问清楚。",
"completeText": "关键线索已经问清。"
}],
"activeStepId": format!("{next_id}-step-1")
})
}
pub(super) fn find_active_quest_for_issuer<'a>(
game_state: &'a Value,
issuer_npc_id: &str,
) -> Option<&'a Value> {
read_array_field(game_state, "quests")
.into_iter()
.find(|quest| {
read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
&& read_optional_string_field(quest, "status")
.is_some_and(|status| status != "turned_in")
})
}
pub(super) fn push_quest_record(game_state: &mut Value, quest: &Value) {
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !quests.is_array() {
*quests = Value::Array(Vec::new());
}
quests
.as_array_mut()
.expect("quests should be array")
.push(quest.clone());
}
pub(super) fn first_quest_reveal_text(quest: &Value) -> Option<String> {
read_array_field(quest, "steps")
.first()
.and_then(|step| read_optional_string_field(step, "revealText"))
}
pub(super) fn build_quest_accept_result_text(quest: &Value) -> String {
let issuer_name =
read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string());
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。")
}
pub(super) fn turn_in_quest_record(
game_state: &mut Value,
issuer_npc_id: &str,
quest_id: &str,
) -> Result<Value, String> {
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !quests.is_array() {
*quests = Value::Array(Vec::new());
}
let quests = quests.as_array_mut().expect("quests should be array");
let Some(index) = quests.iter().position(|quest| {
read_optional_string_field(quest, "id").as_deref() == Some(quest_id)
&& read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
}) else {
return Err("当前没有可交付的委托。".to_string());
};
let mut turned_in = quests[index].clone();
if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") {
return Err("这份委托还没有达到可交付状态。".to_string());
}
if let Some(object) = turned_in.as_object_mut() {
object.insert("status".to_string(), Value::String("turned_in".to_string()));
object.insert("completionNotified".to_string(), Value::Bool(true));
if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
for step in steps.iter_mut() {
let required_count = read_i32_field(step, "requiredCount").unwrap_or(0);
if let Some(step_object) = step.as_object_mut() {
step_object.insert("progress".to_string(), json!(required_count.max(0)));
}
}
}
}
quests[index] = turned_in.clone();
Ok(turned_in)
}
pub(super) fn build_quest_turn_in_result_text(quest: &Value) -> String {
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
let reward_text = read_optional_string_field(quest, "rewardText")
.unwrap_or_else(|| "报酬已经结清。".to_string());
format!("你已经完成并交付了「{title}」。{reward_text}")
}
pub(super) fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) {
let Some(reward) = read_field(quest, "reward") else {
return;
};
let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0);
if currency > 0 {
add_player_currency(game_state, currency);
}
let reward_items = read_array_field(reward, "items")
.into_iter()
.cloned()
.collect::<Vec<_>>();
if !reward_items.is_empty() {
add_player_inventory_items(game_state, reward_items);
}
let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0);
if experience > 0 {
grant_player_progression_experience(game_state, experience, "quest");
}
}
pub(super) fn build_legacy_current_story(
story_text: &str,
options: &[RuntimeStoryOptionView],
) -> Value {
json!({
"text": story_text,
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
"streaming": false
})
}
pub(super) fn read_story_text(current_story: Option<&Value>) -> Option<String> {
current_story.and_then(|story| read_optional_string_field(story, "text"))
}
pub(super) 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()
}

View File

@@ -0,0 +1,234 @@
use super::*;
pub(super) fn resolve_pending_quest_offer_view_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?;
Ok(StoryResolution {
action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request),
result_text: pending_offer.intro_text.clone().unwrap_or_else(|| {
build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest)
}),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_offer_replace_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?;
let next_quest = build_next_pending_quest_offer(
game_state,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
Some(pending_offer.quest_id.as_str()),
);
let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest);
let dialogue = append_dialogue_turns(
pending_offer.dialogue.as_slice(),
vec![
json!({
"speaker": "player",
"text": "能不能换一份更适合眼下局势的委托?"
}),
json!({
"speaker": "npc",
"speakerName": encounter.npc_name,
"text": quest_text,
}),
],
);
let options = build_pending_quest_offer_options(encounter.npc_id.as_str());
let saved_current_story = build_pending_quest_offer_story(
dialogue,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
pending_offer.turn_count,
pending_offer.custom_input_placeholder.as_str(),
Some(next_quest.clone()),
options.as_slice(),
);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("{}更换委托", encounter.npc_name), request),
result_text: quest_text.clone(),
story_text: Some(quest_text),
presentation_options: Some(options),
saved_current_story: Some(saved_current_story),
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_offer_abandon_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?;
let npc_reply = format!(
"{}点了点头,没有继续强求,只把这份委托暂时收了回去。",
encounter.npc_name
);
let dialogue = append_dialogue_turns(
pending_offer.dialogue.as_slice(),
vec![
json!({
"speaker": "player",
"text": "这件事我先不接,咱们还是先聊别的。"
}),
json!({
"speaker": "npc",
"speakerName": encounter.npc_name,
"text": npc_reply,
}),
],
);
let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str());
let saved_current_story = build_pending_quest_offer_story(
dialogue,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
pending_offer.turn_count,
pending_offer.custom_input_placeholder.as_str(),
None,
options.as_slice(),
);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request),
result_text: npc_reply.clone(),
story_text: Some(npc_reply),
presentation_options: Some(options),
saved_current_story: Some(saved_current_story),
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_accept_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?;
if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() {
return Err("当前角色已经有未结清的委托。".to_string());
}
let quest = pending_offer.quest.clone();
push_quest_record(game_state, &quest);
increment_runtime_stat(game_state, "questsAccepted", 1);
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
let reply_text = first_quest_reveal_text(&quest)
.map(|text| format!("那就拜托你了。{text}"))
.unwrap_or_else(|| {
format!(
"那就拜托你了。{}",
read_optional_string_field(&quest, "summary")
.unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string())
)
});
let dialogue = append_dialogue_turns(
pending_offer.dialogue.as_slice(),
vec![
json!({
"speaker": "player",
"text": "这件事我愿意接下,你把关键要点交给我。"
}),
json!({
"speaker": "npc",
"speakerName": encounter.npc_name,
"text": reply_text,
}),
],
);
let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str());
let saved_current_story = build_pending_quest_offer_story(
dialogue,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
pending_offer.turn_count,
pending_offer.custom_input_placeholder.as_str(),
None,
options.as_slice(),
);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request),
result_text: build_quest_accept_result_text(&quest),
story_text: Some(
saved_current_story["text"]
.as_str()
.unwrap_or_default()
.to_string(),
),
presentation_options: Some(options),
saved_current_story: Some(saved_current_story),
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_turn_in_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let quest_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "questId"))
.or_else(|| request.action.target_id.clone())
.or_else(|| {
find_active_quest_for_issuer(game_state, encounter.npc_id.as_str())
.and_then(|quest| read_optional_string_field(quest, "id"))
})
.ok_or_else(|| "当前没有可交付的委托。".to_string())?;
let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?;
let previous_affinity = read_current_npc_affinity(game_state);
let affinity_bonus = read_field(&turned_in, "reward")
.and_then(|reward| read_i32_field(reward, "affinityBonus"))
.unwrap_or(0);
let next_affinity = previous_affinity.saturating_add(affinity_bonus);
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
apply_quest_turn_in_rewards(game_state, &turned_in);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("{}交付委托", encounter.npc_name), request),
result_text: build_quest_turn_in_result_text(&turned_in),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
npc_id: encounter.npc_id,
previous_affinity,
next_affinity,
}],
battle: None,
toast: None,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,441 @@
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";
const X_CLIENT_PLATFORM_HEADER: &str = "x-client-platform";
const X_CLIENT_INSTANCE_ID_HEADER: &str = "x-client-instance-id";
const X_MINI_PROGRAM_APP_ID_HEADER: &str = "x-mini-program-app-id";
const X_MINI_PROGRAM_ENV_HEADER: &str = "x-mini-program-env";
const USER_AGENT_HEADER: &str = "user-agent";
const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for";
const X_REAL_IP_HEADER: &str = "x-real-ip";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionClientContext {
// 统一保存登录时采集到的客户端身份快照,后续直接写入 refresh_session。
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<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: Option<String>,
}
impl SessionClientContext {
pub fn to_refresh_session_client_info(&self) -> RefreshSessionClientInfo {
RefreshSessionClientInfo {
client_type: self.client_type.clone(),
client_runtime: self.client_runtime.clone(),
client_platform: self.client_platform.clone(),
client_instance_id: self.client_instance_id.clone(),
device_fingerprint: self.device_fingerprint.clone(),
device_display_name: self.device_display_name.clone(),
mini_program_app_id: self.mini_program_app_id.clone(),
mini_program_env: self.mini_program_env.clone(),
user_agent: self.user_agent.clone(),
ip: self.ip.clone(),
}
}
}
pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientContext {
// 显式头优先UA 自动识别兜底,避免前端没有完全补头时整条登录链路不可用。
let user_agent = header_value(headers, USER_AGENT_HEADER);
let ua_lower = user_agent
.as_ref()
.map(|value| value.to_ascii_lowercase())
.unwrap_or_default();
let explicit_client_type = normalize_client_type(header_value(headers, X_CLIENT_TYPE_HEADER));
let explicit_client_runtime =
normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower);
let explicit_client_platform =
normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower);
let client_instance_id =
normalize_optional_string(header_value(headers, X_CLIENT_INSTANCE_ID_HEADER));
let mini_program_app_id =
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER));
let mini_program_env =
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER));
let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower);
let inferred_runtime = infer_client_runtime(
explicit_client_runtime.as_deref(),
&inferred_client_type,
&ua_lower,
);
let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower);
let ip = resolve_ip(headers);
let device_display_name =
build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform);
let device_fingerprint = build_device_fingerprint(
&inferred_client_type,
&inferred_runtime,
&inferred_platform,
client_instance_id.as_deref(),
user_agent.as_deref(),
);
SessionClientContext {
client_type: inferred_client_type,
client_runtime: inferred_runtime,
client_platform: inferred_platform,
client_instance_id,
device_fingerprint,
device_display_name,
mini_program_app_id,
mini_program_env,
user_agent: normalize_optional_string(user_agent),
ip,
}
}
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_client_type(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"web_browser" | "wechat_h5" | "mini_program" | "native_app" | "desktop_app"
| "unknown" => Some(normalized),
_ => None,
}
})
}
fn normalize_runtime(value: Option<String>, ua_lower: &str) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"chrome"
| "edge"
| "safari"
| "firefox"
| "wechat_embedded_browser"
| "wechat_mini_program"
| "alipay_mini_program"
| "douyin_mini_program"
| "unknown" => Some(normalized),
_ => infer_runtime_from_user_agent(ua_lower),
}
})
}
fn normalize_platform(value: Option<String>, ua_lower: &str) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"windows" | "macos" | "linux" | "ios" | "android" | "unknown" => Some(normalized),
_ => infer_platform_from_user_agent(ua_lower),
}
})
}
fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String {
if let Some(client_type) = explicit_type {
return client_type.to_string();
}
if ua_lower.contains("micromessenger") {
return "wechat_h5".to_string();
}
"web_browser".to_string()
}
fn infer_client_runtime(
explicit_runtime: Option<&str>,
client_type: &str,
ua_lower: &str,
) -> String {
if client_type == "mini_program" {
if let Some(runtime) = explicit_runtime {
return runtime.to_string();
}
if ua_lower.contains("alipayclient") {
return "alipay_mini_program".to_string();
}
if ua_lower.contains("toutiaomicroapp") || ua_lower.contains("douyin") {
return "douyin_mini_program".to_string();
}
return "wechat_mini_program".to_string();
}
if client_type == "wechat_h5" {
return "wechat_embedded_browser".to_string();
}
explicit_runtime
.map(ToOwned::to_owned)
.or_else(|| infer_runtime_from_user_agent(ua_lower))
.unwrap_or_else(|| "unknown".to_string())
}
fn infer_client_platform(explicit_platform: Option<&str>, ua_lower: &str) -> String {
explicit_platform
.map(ToOwned::to_owned)
.or_else(|| infer_platform_from_user_agent(ua_lower))
.unwrap_or_else(|| "unknown".to_string())
}
fn infer_runtime_from_user_agent(ua_lower: &str) -> Option<String> {
if ua_lower.contains("edg/") {
return Some("edge".to_string());
}
if ua_lower.contains("firefox/") {
return Some("firefox".to_string());
}
if ua_lower.contains("chrome/") || ua_lower.contains("crios/") {
return Some("chrome".to_string());
}
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/")
{
return Some("safari".to_string());
}
None
}
fn infer_platform_from_user_agent(ua_lower: &str) -> Option<String> {
if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ios") {
return Some("ios".to_string());
}
if ua_lower.contains("android") {
return Some("android".to_string());
}
if ua_lower.contains("windows") {
return Some("windows".to_string());
}
if ua_lower.contains("mac os") || ua_lower.contains("macintosh") {
return Some("macos".to_string());
}
if ua_lower.contains("linux") {
return Some("linux".to_string());
}
None
}
fn build_device_display_name(
client_type: &str,
client_runtime: &str,
client_platform: &str,
) -> String {
// 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。
if client_type == "mini_program" {
return format!(
"{} / {}",
map_runtime_display(client_runtime),
map_platform_display(client_platform)
);
}
if client_type == "wechat_h5" {
return format!("微信内网页 / {}", map_platform_display(client_platform));
}
if client_type == "unknown" {
return "未知设备".to_string();
}
format!(
"{} / {}",
map_platform_display(client_platform),
map_runtime_display(client_runtime)
)
}
fn map_runtime_display(runtime: &str) -> &'static str {
match runtime {
"chrome" => "Chrome",
"edge" => "Edge",
"safari" => "Safari",
"firefox" => "Firefox",
"wechat_embedded_browser" => "微信内网页",
"wechat_mini_program" => "微信小程序",
"alipay_mini_program" => "支付宝小程序",
"douyin_mini_program" => "抖音小程序",
_ => "未知客户端",
}
}
fn map_platform_display(platform: &str) -> &'static str {
match platform {
"windows" => "Windows",
"macos" => "macOS",
"linux" => "Linux",
"ios" => "iPhone",
"android" => "Android",
_ => "未知设备",
}
}
fn build_device_fingerprint(
client_type: &str,
client_runtime: &str,
client_platform: &str,
client_instance_id: Option<&str>,
user_agent: Option<&str>,
) -> Option<String> {
// 这里的指纹只用于会话聚类与展示,不参与任何鉴权决策。
let seed = if let Some(instance_id) = client_instance_id {
format!("{client_type}|{client_runtime}|{client_platform}|{instance_id}")
} else if let Some(user_agent) = user_agent {
format!(
"{client_type}|{client_runtime}|{client_platform}|{}",
normalize_user_agent(user_agent)
)
} else {
return None;
};
Some(hash_refresh_session_token(&seed))
}
fn normalize_user_agent(user_agent: &str) -> String {
user_agent
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn resolve_ip(headers: &HeaderMap) -> Option<String> {
if let Some(forwarded) = header_value(headers, X_FORWARDED_FOR_HEADER) {
let ip = forwarded
.split(',')
.map(str::trim)
.find(|value| !value.is_empty())
.map(ToOwned::to_owned);
if ip.is_some() {
return ip;
}
}
normalize_optional_string(header_value(headers, X_REAL_IP_HEADER))
}
pub fn mask_ip(ip: Option<&str>) -> Option<String> {
// 会话列表只返回脱敏后的 IP避免把完整地址直接暴露给前端。
let ip = ip?.trim();
if ip.is_empty() {
return None;
}
if ip.contains(':') {
let parts = ip
.split(':')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.len() <= 2 {
return Some(ip.to_string());
}
return Some(format!("{}:{}::*", parts[0], parts[1]));
}
let parts = ip.split('.').collect::<Vec<_>>();
if parts.len() != 4 {
return Some(ip.to_string());
}
Some(format!("{}.{}.*.*", parts[0], parts[1]))
}
#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue};
use super::{mask_ip, resolve_session_client_context};
#[test]
fn resolve_session_client_context_detects_wechat_h5_from_user_agent() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static(
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit Safari MicroMessenger",
),
);
let context = resolve_session_client_context(&headers);
assert_eq!(context.client_type, "wechat_h5");
assert_eq!(context.client_runtime, "wechat_embedded_browser");
assert_eq!(context.client_platform, "ios");
assert_eq!(context.device_display_name, "微信内网页 / iPhone");
}
#[test]
fn resolve_session_client_context_prefers_explicit_mini_program_headers() {
let mut headers = HeaderMap::new();
headers.insert("x-client-type", HeaderValue::from_static("mini_program"));
headers.insert(
"x-client-runtime",
HeaderValue::from_static("wechat_mini_program"),
);
headers.insert("x-client-platform", HeaderValue::from_static("android"));
headers.insert(
"x-client-instance-id",
HeaderValue::from_static("mini-instance-001"),
);
headers.insert(
"x-mini-program-app-id",
HeaderValue::from_static("wx1234567890"),
);
headers.insert("x-mini-program-env", HeaderValue::from_static("release"));
headers.insert(
"user-agent",
HeaderValue::from_static("Mozilla/5.0 Chrome/123.0 MicroMessenger"),
);
let context = resolve_session_client_context(&headers);
assert_eq!(context.client_type, "mini_program");
assert_eq!(context.client_runtime, "wechat_mini_program");
assert_eq!(context.client_platform, "android");
assert_eq!(context.mini_program_app_id.as_deref(), Some("wx1234567890"));
assert_eq!(context.mini_program_env.as_deref(), Some("release"));
assert_eq!(context.device_display_name, "微信小程序 / Android");
assert!(context.device_fingerprint.is_some());
}
#[test]
fn resolve_session_client_context_distinguishes_web_browser_runtime() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
),
);
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.11"));
let context = resolve_session_client_context(&headers);
assert_eq!(context.client_type, "web_browser");
assert_eq!(context.client_runtime, "chrome");
assert_eq!(context.client_platform, "windows");
assert_eq!(context.ip.as_deref(), Some("203.0.113.11"));
assert_eq!(context.device_display_name, "Windows / Chrome");
}
#[test]
fn mask_ip_returns_masked_ipv4_and_ipv6() {
assert_eq!(mask_ip(Some("203.0.113.11")).as_deref(), Some("203.0.*.*"));
assert_eq!(
mask_ip(Some("2408:8000:abcd:1234::1")).as_deref(),
Some("2408:8000::*")
);
}
}

View File

@@ -0,0 +1,769 @@
use std::{error::Error, fmt, sync::Arc};
#[cfg(test)]
use std::{collections::HashMap, sync::Mutex};
use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
const ADMIN_ROLE: &str = "admin";
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
pub struct AppState {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
pub config: AppConfig,
auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
password_entry_service: PasswordEntryService,
refresh_session_service: RefreshSessionService,
auth_user_service: AuthUserService,
phone_auth_service: PhoneAuthService,
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>,
#[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
#[derive(Clone, Debug)]
pub struct AdminRuntime {
username: Arc<str>,
password: Arc<str>,
subject: Arc<str>,
display_name: Arc<str>,
token_ttl_seconds: u64,
jwt_config: JwtConfig,
}
#[derive(Clone, Debug)]
pub struct AdminClaims {
pub subject: String,
pub username: String,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Clone, Debug)]
pub struct AdminSession {
pub subject: String,
pub username: String,
pub display_name: String,
pub roles: Vec<String>,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Debug)]
pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
AuthStore(String),
SmsProvider(SmsProviderError),
Oss(OssError),
Llm(LlmError),
}
impl AppState {
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
#[cfg(test)]
let auth_store = InMemoryAuthStore::default();
#[cfg(not(test))]
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Self::new_with_auth_store(config, auth_store)
}
fn new_with_auth_store(
config: AppConfig,
auth_store: InMemoryAuthStore,
) -> Result<Self, AppStateInitError> {
let auth_jwt_config = JwtConfig::new(
config.jwt_issuer.clone(),
config.jwt_secret.clone(),
config.jwt_access_token_ttl_seconds,
)?;
let admin_runtime = build_admin_runtime(&config, &auth_jwt_config)?;
let refresh_cookie_same_site =
RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or(
RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"),
)?;
let refresh_cookie_config = RefreshCookieConfig::new(
config.refresh_cookie_name.clone(),
config.refresh_cookie_path.clone(),
config.refresh_cookie_secure,
refresh_cookie_same_site,
config.refresh_session_ttl_days,
)?;
let oss_client = build_oss_client(&config)?;
let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new(
SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| {
SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string())
})?,
config.sms_endpoint.clone(),
config.sms_access_key_id.clone(),
config.sms_access_key_secret.clone(),
config.sms_sign_name.clone(),
config.sms_template_code.clone(),
config.sms_template_param_key.clone(),
config.sms_country_code.clone(),
config.sms_scheme_name.clone(),
config.sms_code_length,
config.sms_code_type,
config.sms_valid_time_seconds,
config.sms_interval_seconds,
config.sms_duplicate_policy,
config.sms_case_auth_policy,
config.sms_return_verify_code,
config.sms_mock_verify_code.clone(),
)?)?;
let password_entry_service = PasswordEntryService::new(auth_store.clone());
let auth_user_service = AuthUserService::new(auth_store.clone());
let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
let wechat_auth_state_service =
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
let wechat_provider = build_wechat_provider(&config);
let refresh_session_service =
RefreshSessionService::new(auth_store.clone(), 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(),
pool_size: config.spacetime_pool_size,
});
let llm_client = build_llm_client(&config)?;
Ok(Self {
config,
auth_jwt_config,
admin_runtime,
refresh_cookie_config,
oss_client,
auth_store,
password_entry_service,
refresh_session_service,
auth_user_service,
phone_auth_service,
wechat_auth_state_service,
wechat_auth_service,
wechat_provider,
ai_task_service,
spacetime_client,
llm_client,
#[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
})
}
pub fn auth_jwt_config(&self) -> &JwtConfig {
&self.auth_jwt_config
}
pub fn admin_runtime(&self) -> Option<&AdminRuntime> {
self.admin_runtime.as_ref()
}
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
&self.refresh_cookie_config
}
pub fn oss_client(&self) -> Option<&OssClient> {
self.oss_client.as_ref()
}
pub fn password_entry_service(&self) -> &PasswordEntryService {
&self.password_entry_service
}
pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
#[cfg(test)]
return Ok(());
#[cfg(not(test))]
let snapshot_json = self
.auth_store
.export_snapshot_json()
.map_err(SpacetimeClientError::Runtime)?;
#[cfg(not(test))]
let updated_at_micros = i64::try_from(
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
#[cfg(not(test))]
self.spacetime_client
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
.await?;
// ?????????????????????????????????
#[cfg(not(test))]
self.spacetime_client.import_auth_store_snapshot().await?;
#[cfg(not(test))]
Ok(())
}
pub async fn try_restore_auth_store_from_spacetime(
config: AppConfig,
) -> Result<Self, AppStateInitError> {
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
server_url: config.spacetime_server_url.clone(),
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
pool_size: config.spacetime_pool_size,
});
match spacetime_client
.export_auth_store_snapshot_from_tables()
.await
{
Ok(snapshot) => {
if let Some(snapshot_json) = snapshot.snapshot_json {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????");
return Self::new_with_auth_store(config, auth_store);
}
}
}
Err(error) => {
warn!(error = %error, "? SpacetimeDB ????????????????");
}
}
match spacetime_client.get_auth_store_snapshot().await {
Ok(snapshot) => {
if let Some(snapshot_json) = snapshot.snapshot_json {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????");
return Self::new_with_auth_store(config, auth_store);
}
}
}
Err(error) => {
warn!(error = %error, "? SpacetimeDB ?????????????????");
}
}
Self::new(config)
}
pub fn refresh_session_service(&self) -> &RefreshSessionService {
&self.refresh_session_service
}
pub fn auth_user_service(&self) -> &AuthUserService {
&self.auth_user_service
}
pub fn phone_auth_service(&self) -> &PhoneAuthService {
&self.phone_auth_service
}
pub fn wechat_auth_state_service(&self) -> &WechatAuthStateService {
&self.wechat_auth_state_service
}
pub fn wechat_auth_service(&self) -> &WechatAuthService {
&self.wechat_auth_service
}
pub fn wechat_provider(&self) -> &WechatProvider {
&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()
}
pub async fn get_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<Option<RuntimeSnapshotRecord>, SpacetimeClientError> {
match self
.spacetime_client
.get_runtime_snapshot(user_id.clone())
.await
{
Ok(record) => {
#[cfg(test)]
if let Some(snapshot) = record.as_ref() {
self.cache_test_runtime_snapshot(snapshot.clone());
}
Ok(record)
}
#[cfg(test)]
Err(_) => Ok(self.read_test_runtime_snapshot(user_id.as_str())),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn put_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
match self
.spacetime_client
.put_runtime_snapshot(
user_id.clone(),
saved_at_micros,
bottom_tab.clone(),
game_state.clone(),
current_story.clone(),
updated_at_micros,
)
.await
{
Ok(record) => {
#[cfg(test)]
self.cache_test_runtime_snapshot(record.clone());
Ok(record)
}
#[cfg(test)]
Err(_) => {
let snapshot = self.build_test_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
current_story,
updated_at_micros,
)?;
self.cache_test_runtime_snapshot(snapshot.clone());
Ok(snapshot)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn delete_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<bool, SpacetimeClientError> {
match self
.spacetime_client
.delete_runtime_snapshot(user_id.clone())
.await
{
Ok(deleted) => {
#[cfg(test)]
if deleted {
self.remove_test_runtime_snapshot(user_id.as_str());
}
Ok(deleted)
}
#[cfg(test)]
Err(_) => Ok(self
.remove_test_runtime_snapshot(user_id.as_str())
.is_some()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
}
#[cfg(test)]
impl AppState {
pub(crate) async fn seed_test_phone_user_with_password(
&self,
phone_number: &str,
password: &str,
) -> module_auth::AuthUser {
let now = OffsetDateTime::now_utc();
self.phone_auth_service()
.send_code(
module_auth::SendPhoneCodeInput {
phone_number: phone_number.to_string(),
scene: module_auth::PhoneAuthScene::Login,
},
now,
)
.await
.expect("test phone code should send");
let user = self
.phone_auth_service()
.login(
module_auth::PhoneLoginInput {
phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
)
.await
.expect("test phone login should create user")
.user;
let changed = self
.password_entry_service()
.change_password(module_auth::ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
new_password: password.to_string(),
})
.await
.expect("test password should set");
changed.user
}
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.insert(record.user_id.clone(), record);
}
fn read_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.get(user_id)
.cloned()
}
fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.remove(user_id)
}
fn build_test_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
let previous = self.read_test_runtime_snapshot(user_id.as_str());
let game_state_json = serde_json::to_string(&game_state).map_err(|error| {
SpacetimeClientError::Runtime(format!("测试快照 game_state 序列化失败: {error}"))
})?;
let current_story_json = current_story
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
SpacetimeClientError::Runtime(format!("测试快照 current_story 序列化失败: {error}"))
})?;
Ok(RuntimeSnapshotRecord {
user_id,
version: SAVE_SNAPSHOT_VERSION,
saved_at: format_utc_micros(saved_at_micros),
saved_at_micros,
bottom_tab,
game_state,
current_story,
game_state_json,
current_story_json,
created_at_micros: previous
.as_ref()
.map(|record| record.created_at_micros)
.unwrap_or(updated_at_micros),
updated_at_micros,
})
}
}
impl fmt::Display for AppStateInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"),
Self::AuthStore(error) => write!(f, "{error}"),
Self::SmsProvider(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
Self::Llm(error) => write!(f, "{error}"),
}
}
}
impl Error for AppStateInitError {}
impl From<JwtError> for AppStateInitError {
fn from(value: JwtError) -> Self {
Self::Jwt(value)
}
}
impl From<RefreshCookieError> for AppStateInitError {
fn from(value: RefreshCookieError) -> Self {
Self::RefreshCookie(value)
}
}
impl From<SmsProviderError> for AppStateInitError {
fn from(value: SmsProviderError) -> Self {
Self::SmsProvider(value)
}
}
impl From<OssError> for AppStateInitError {
fn from(value: OssError) -> Self {
Self::Oss(value)
}
}
impl From<LlmError> for AppStateInitError {
fn from(value: LlmError) -> Self {
Self::Llm(value)
}
}
impl AdminRuntime {
pub fn is_enabled(&self) -> bool {
!self.username.trim().is_empty() && !self.password.trim().is_empty()
}
pub fn username(&self) -> &str {
&self.username
}
pub fn password(&self) -> &str {
&self.password
}
pub fn build_claims(&self, now: OffsetDateTime) -> Result<AdminClaims, String> {
let expires_at = now
.checked_add(time::Duration::seconds(
i64::try_from(self.token_ttl_seconds)
.map_err(|_| "后台 token TTL 超出 i64 上限".to_string())?,
))
.ok_or_else(|| "后台 token 过期时间计算溢出".to_string())?;
Ok(AdminClaims {
subject: self.subject.to_string(),
username: self.username.to_string(),
issued_at: now,
expires_at,
})
}
pub fn sign_token(&self, claims: &AdminClaims) -> Result<String, String> {
let jwt_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: claims.subject.clone(),
session_id: format!("admin-session-{}", claims.username),
provider: AuthProvider::Password,
roles: vec![ADMIN_ROLE.to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(self.display_name.to_string()),
},
&self.jwt_config,
claims.issued_at,
)
.map_err(|error| error.to_string())?;
sign_access_token(&jwt_claims, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn verify_token(&self, token: &str) -> Result<AccessTokenClaims, String> {
verify_access_token(token, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn validate_claims(&self, claims: &AccessTokenClaims) -> Result<AdminSession, String> {
if claims.user_id() != self.subject.as_ref() {
return Err("后台管理员主体不匹配".to_string());
}
if !claims.roles.iter().any(|role| role == ADMIN_ROLE) {
return Err("当前令牌不是管理员令牌".to_string());
}
let issued_at = OffsetDateTime::from_unix_timestamp(claims.iat as i64)
.map_err(|_| "后台令牌签发时间无效".to_string())?;
let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp as i64)
.map_err(|_| "后台令牌过期时间无效".to_string())?;
Ok(AdminSession {
subject: claims.user_id().to_string(),
username: self.username.to_string(),
display_name: self.display_name.to_string(),
roles: claims.roles.clone(),
issued_at,
expires_at,
})
}
pub fn build_session(&self, claims: &AdminClaims) -> AdminSession {
AdminSession {
subject: claims.subject.clone(),
username: claims.username.clone(),
display_name: self.display_name.to_string(),
roles: vec![ADMIN_ROLE.to_string()],
issued_at: claims.issued_at,
expires_at: claims.expires_at,
}
}
}
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()
|| config.oss_access_key_id.is_some()
|| config.oss_access_key_secret.is_some();
if !has_any_oss_field {
return Ok(None);
}
let oss_config = OssConfig::new(
config.oss_bucket.clone().unwrap_or_default(),
config.oss_endpoint.clone().unwrap_or_default(),
config.oss_access_key_id.clone().unwrap_or_default(),
config.oss_access_key_secret.clone().unwrap_or_default(),
config.oss_read_expire_seconds,
config.oss_post_expire_seconds,
config.oss_post_max_size_bytes,
config.oss_success_action_status,
)?;
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)?))
}
// 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。
fn build_admin_runtime(
config: &AppConfig,
base_jwt_config: &JwtConfig,
) -> Result<Option<AdminRuntime>, AppStateInitError> {
let Some(username) = config
.admin_username
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let Some(password) = config
.admin_password
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let jwt_config = JwtConfig::new(
base_jwt_config.issuer().to_string(),
config.jwt_secret.clone(),
config.admin_token_ttl_seconds,
)?;
Ok(Some(AdminRuntime {
username: Arc::<str>::from(username),
password: Arc::<str>::from(password),
subject: Arc::<str>::from(format!("admin:{username}")),
display_name: Arc::<str>::from(format!("管理员 {username}")),
token_ttl_seconds: config.admin_token_ttl_seconds,
jwt_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());
}
}

View File

@@ -0,0 +1,825 @@
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
.seed_test_phone_user_with_password("13800138107", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_story_battles".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("战斗接口用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,412 @@
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
.seed_test_phone_user_with_password("13800138108", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_story_sessions".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("故事会话用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}

View File

@@ -0,0 +1,358 @@
use axum::{
Json,
extract::{Extension, Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
WechatAuthScene,
};
use shared_contracts::auth::{
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
};
use time::OffsetDateTime;
use url::Url;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
pub async fn start_wechat_login(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Query(query): Query<WechatStartQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let user_agent = headers
.get("user-agent")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let scene = resolve_wechat_scene(user_agent.as_deref())?;
let state_record = state
.wechat_auth_state_service()
.create_state(
CreateWechatAuthStateInput {
redirect_path: normalize_redirect_path(
query.redirect_path.as_deref(),
&state.config.wechat_redirect_path,
),
scene: scene.clone(),
request_user_agent: user_agent.clone(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_wechat_auth_error)?;
let authorization_url = state.wechat_provider().build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)?;
Ok(json_success_body(
Some(&request_context),
WechatStartResponse { authorization_url },
))
}
pub async fn handle_wechat_callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<WechatCallbackQuery>,
) -> Result<impl IntoResponse, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let fallback_redirect = state.config.wechat_redirect_path.clone();
let state_token = query
.state
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
if state_token.is_empty() {
return Ok(Redirect::to(&build_auth_result_redirect_url(
&fallback_redirect,
&[
("auth_provider", "wechat"),
("auth_error", "微信登录状态已失效,请重新发起登录。"),
],
))
.into_response());
}
let consumed = match state
.wechat_auth_state_service()
.consume_state(&state_token, OffsetDateTime::now_utc())
{
Ok(value) => value,
Err(_) => {
return Ok(Redirect::to(&build_auth_result_redirect_url(
&fallback_redirect,
&[
("auth_provider", "wechat"),
("auth_error", "微信登录状态已失效,请重新发起登录。"),
],
))
.into_response());
}
};
let redirect_path = consumed.state.redirect_path.clone();
let session_client = resolve_session_client_context(&headers);
let result = match state
.wechat_provider()
.resolve_callback_profile(query.code.as_deref(), query.mock_code.as_deref())
.await
{
Ok(profile) => state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
.await
.map_err(map_wechat_auth_error),
Err(error) => Err(error),
};
match result {
Ok(result) => {
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut response = Redirect::to(&build_auth_result_redirect_url(
&redirect_path,
&[
("auth_provider", "wechat"),
("auth_token", signed_session.access_token.as_str()),
("auth_binding_status", result.user.binding_status.as_str()),
],
))
.into_response();
attach_set_cookie_header(
response.headers_mut(),
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok(response)
}
Err(error) => Ok(Redirect::to(&build_auth_result_redirect_url(
&redirect_path,
&[("auth_provider", "wechat"), ("auth_error", error.message())],
))
.into_response()),
}
}
pub async fn bind_wechat_phone(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<WechatBindPhoneRequest>,
) -> Result<impl IntoResponse, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let result = state
.phone_auth_service()
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: authenticated.claims().user_id().to_string(),
phone_number: payload.phone,
verify_code: payload.code,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_wechat_bind_phone_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut response_headers = HeaderMap::new();
attach_set_cookie_header(
&mut response_headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
response_headers,
json_success_body(
Some(&request_context),
WechatBindPhoneResponse {
token: signed_session.access_token,
user: map_auth_user_payload(result.user),
},
),
))
}
fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, AppError> {
let user_agent = user_agent.unwrap_or_default();
let is_wechat = user_agent.contains("MicroMessenger");
let is_mobile = user_agent.contains("Android")
|| user_agent.contains("iPhone")
|| user_agent.contains("iPad")
|| user_agent.contains("Mobile");
if is_wechat {
return Ok(WechatAuthScene::WechatInApp);
}
if is_mobile {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录"));
}
Ok(WechatAuthScene::Desktop)
}
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string();
};
if raw_value.starts_with('/') {
return raw_value.to_string();
}
Url::parse(raw_value)
.map(|url| {
format!(
"{}{}{}",
url.path(),
url.query().map(|v| format!("?{v}")).unwrap_or_default(),
url.fragment().map(|v| format!("#{v}")).unwrap_or_default()
)
})
.unwrap_or_else(|_| fallback.to_string())
}
fn resolve_wechat_callback_url(state: &AppState, headers: &HeaderMap) -> Result<String, AppError> {
let proto = headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http");
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("127.0.0.1:3000");
Ok(format!(
"{proto}://{host}{}",
state.config.wechat_callback_path
))
}
fn build_auth_result_redirect_url(redirect_path: &str, params: &[(&str, &str)]) -> String {
let hash = params
.iter()
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
let path_without_hash = redirect_path.split('#').next().unwrap_or("/");
format!(
"{}#{}",
if path_without_hash.is_empty() {
"/"
} else {
path_without_hash
},
hash
)
}
#[allow(dead_code)]
fn _assert_response_type(_: Response) {}
fn map_wechat_auth_error(error: WechatAuthError) -> AppError {
match error {
WechatAuthError::MissingProfile
| WechatAuthError::StateNotFound
| WechatAuthError::StateExpired
| WechatAuthError::StateConsumed
| WechatAuthError::MissingWechatIdentity => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
WechatAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
WechatAuthError::Store(_) | WechatAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}
fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
match error {
module_auth::PhoneAuthError::InvalidPhoneNumber
| module_auth::PhoneAuthError::InvalidVerifyCode
| module_auth::PhoneAuthError::VerifyCodeNotFound
| module_auth::PhoneAuthError::VerifyCodeExpired
| module_auth::PhoneAuthError::UserStateMismatch => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
module_auth::PhoneAuthError::SendCoolingDown {
retry_after_seconds,
} => {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
}
module_auth::PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
}
module_auth::PhoneAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
module_auth::PhoneAuthError::Store(_) | module_auth::PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

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