This commit is contained in:
2026-04-22 22:01:07 +08:00
parent d8716d70b0
commit b317c2a8ea
37 changed files with 1821 additions and 515 deletions

View File

@@ -9,8 +9,7 @@ use axum::{
response::Response,
};
use platform_auth::{
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token,
verify_access_token,
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
@@ -237,11 +236,11 @@ mod tests {
INTERNAL_API_SECRET_HEADER, INTERNAL_AUTH_USER_ID_HEADER, RefreshSessionToken,
extract_bearer_token, try_build_internal_forwarded_claims,
};
use crate::{config::AppConfig, state::AppState};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
response::IntoResponse,
};
use crate::{config::AppConfig, state::AppState};
#[test]
fn extract_bearer_token_accepts_standard_header() {

View File

@@ -9,8 +9,8 @@ use shared_contracts::big_fish::{
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse, BigFishRuntimeSnapshotResponse,
BigFishRunResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishRunResponse, BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse,
BigFishRuntimeSnapshotResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
@@ -58,7 +58,9 @@ pub async fn create_big_fish_session(
created_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -80,7 +82,9 @@ pub async fn get_big_fish_session(
.spacetime_client()
.get_big_fish_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -128,7 +132,9 @@ pub async fn submit_big_fish_message(
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -168,7 +174,9 @@ pub async fn stream_big_fish_message(
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let session_response = map_big_fish_session_response(session);
let reply_text = session_response
@@ -176,9 +184,24 @@ pub async fn stream_big_fish_message(
.clone()
.unwrap_or_else(|| "锚点已更新。".to_string());
let mut sse_body = String::new();
append_sse_event(&request_context, &mut sse_body, "reply_delta", &json!({ "text": reply_text }))?;
append_sse_event(&request_context, &mut sse_body, "session", &json!({ "session": session_response }))?;
append_sse_event(&request_context, &mut sse_body, "done", &json!({ "ok": true }))?;
append_sse_event(
&request_context,
&mut sse_body,
"reply_delta",
&json!({ "text": reply_text }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"session",
&json!({ "session": session_response }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"done",
&json!({ "ok": true }),
)?;
Ok(build_event_stream_response(sse_body))
}
@@ -288,7 +311,9 @@ pub async fn start_big_fish_run(
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -310,7 +335,9 @@ pub async fn get_big_fish_run(
.spacetime_client()
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -348,7 +375,9 @@ pub async fn submit_big_fish_input(
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -383,7 +412,9 @@ fn map_big_fish_session_response(session: BigFishSessionRecord) -> BigFishSessio
}
}
fn map_big_fish_anchor_pack_response(anchor_pack: BigFishAnchorPackRecord) -> BigFishAnchorPackResponse {
fn map_big_fish_anchor_pack_response(
anchor_pack: BigFishAnchorPackRecord,
) -> BigFishAnchorPackResponse {
BigFishAnchorPackResponse {
gameplay_promise: map_big_fish_anchor_item_response(anchor_pack.gameplay_promise),
ecology_visual_theme: map_big_fish_anchor_item_response(anchor_pack.ecology_visual_theme),
@@ -417,7 +448,9 @@ fn map_big_fish_draft_response(draft: BigFishGameDraftRecord) -> BigFishGameDraf
}
}
fn map_big_fish_level_response(level: BigFishLevelBlueprintRecord) -> BigFishLevelBlueprintResponse {
fn map_big_fish_level_response(
level: BigFishLevelBlueprintRecord,
) -> BigFishLevelBlueprintResponse {
BigFishLevelBlueprintResponse {
level: level.level,
name: level.name,
@@ -528,7 +561,9 @@ fn map_big_fish_runtime_response(run: BigFishRuntimeRecord) -> BigFishRuntimeSna
}
}
fn map_big_fish_entity_response(entity: BigFishRuntimeEntityRecord) -> BigFishRuntimeEntityResponse {
fn map_big_fish_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
@@ -547,7 +582,8 @@ fn map_big_fish_vector_response(vector: BigFishVector2Record) -> BigFishVector2R
fn build_big_fish_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。".to_string();
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。"
.to_string();
}
"我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string()
}

View File

@@ -1,4 +1,4 @@
use std::{env, net::SocketAddr};
use std::{env, fs, net::SocketAddr, path::PathBuf};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -7,6 +7,7 @@ use platform_llm::{
const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715";
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
@@ -123,6 +124,10 @@ 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()
{
@@ -141,8 +146,7 @@ impl AppConfig {
config.log_filter = log_filter;
}
config.internal_api_secret =
read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]);
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"])
@@ -361,6 +365,33 @@ fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
})
}
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)

View File

@@ -30,15 +30,16 @@ use shared_contracts::{
SwapPuzzlePiecesRequest,
},
puzzle_works::{
PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse,
PuzzleWorkSummaryResponse, PuzzleWorksResponse, PutPuzzleWorkRequest,
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
},
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
@@ -47,11 +48,8 @@ use spacetime_client::{
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
@@ -110,14 +108,16 @@ pub async fn get_puzzle_agent_session(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(
session_id,
authenticated.claims().user_id().to_string(),
)
.get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_error_response(
@@ -152,7 +152,12 @@ pub async fn submit_puzzle_agent_message(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let client_message_id = payload.client_message_id.trim().to_string();
let message_text = payload.text.trim().to_string();
@@ -207,7 +212,12 @@ pub async fn stream_puzzle_agent_message(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let session = state
.spacetime_client()
@@ -245,7 +255,12 @@ pub async fn stream_puzzle_agent_message(
"session",
&json!({ "session": session_response }),
)?;
append_sse_event(&request_context, &mut sse_body, "done", &json!({ "ok": true }))?;
append_sse_event(
&request_context,
&mut sse_body,
"done",
&json!({ "ok": true }),
)?;
Ok(build_event_stream_response(sse_body))
}
@@ -266,7 +281,12 @@ pub async fn execute_puzzle_agent_action(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let now = current_utc_micros();
@@ -327,11 +347,11 @@ pub async fn execute_puzzle_agent_action(
})
.collect::<Vec<_>>(),
)
.map_err(|error| {
SpacetimeClientError::Runtime(format!(
"拼图候选图序列化失败:{error}"
))
});
.map_err(|error| {
SpacetimeClientError::Runtime(format!(
"拼图候选图序列化失败:{error}"
))
});
match candidates_json {
Ok(candidates_json) => {
state
@@ -497,7 +517,12 @@ pub async fn get_puzzle_work_detail(
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
@@ -536,7 +561,12 @@ pub async fn put_puzzle_work(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
@@ -599,7 +629,12 @@ pub async fn get_puzzle_gallery_detail(
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_GALLERY_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
@@ -637,7 +672,12 @@ pub async fn start_puzzle_run(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.profile_id,
"profileId",
)?;
let run = state
.spacetime_client()
@@ -767,7 +807,12 @@ pub async fn drag_puzzle_piece_or_group(
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.piece_id, "pieceId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.piece_id,
"pieceId",
)?;
let run = state
.spacetime_client()
@@ -850,12 +895,16 @@ fn map_puzzle_agent_session_response(
.into_iter()
.map(map_puzzle_suggested_action_response)
.collect(),
result_preview: session.result_preview.map(map_puzzle_result_preview_response),
result_preview: session
.result_preview
.map(map_puzzle_result_preview_response),
updated_at: session.updated_at,
}
}
fn map_puzzle_anchor_pack_response(anchor_pack: PuzzleAnchorPackRecord) -> PuzzleAnchorPackResponse {
fn map_puzzle_anchor_pack_response(
anchor_pack: PuzzleAnchorPackRecord,
) -> PuzzleAnchorPackResponse {
PuzzleAnchorPackResponse {
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
@@ -1098,8 +1147,7 @@ fn resolve_author_display_name(
fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。"
.to_string();
return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。".to_string();
}
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string()
@@ -1124,11 +1172,7 @@ fn ensure_non_empty(
Ok(())
}
fn puzzle_bad_request(
request_context: &RequestContext,
provider: &str,
message: &str,
) -> Response {
fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response {
puzzle_error_response(
request_context,
provider,
@@ -1279,7 +1323,11 @@ fn save_placeholder_puzzle_asset(
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
Ok(GeneratedPuzzleAssetResponse {
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
image_src: format!(
"/{}/{}",
relative_dir.to_string_lossy().replace('\\', "/"),
file_name
),
asset_id,
})
}

View File

@@ -11,35 +11,27 @@ use module_npc::{
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_status_patch,
build_npc_gift_result_text,
build_runtime_story_view_model,
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_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory,
format_currency_text,
increment_runtime_stat, normalize_equipped_item,
normalize_equipment_slot_id, 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,
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,
restore_player_resource,
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
resolve_forge_craft_action,
resolve_forge_dismantle_action, resolve_forge_reforge_action,
resolve_npc_gift_affinity_gain, simple_story_resolution, trade_quantity_suffix,
resolve_current_encounter_npc_state,
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
build_static_runtime_story_option, build_story_option_from_runtime_option,
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
write_string_field, write_u32_field,
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};