Close DDD refactor and remove generated asset proxy

This commit is contained in:
kdletters
2026-05-02 00:27:22 +08:00
parent fd08262bf0
commit 9d9913095d
605 changed files with 11811 additions and 10106 deletions

View File

@@ -4,12 +4,17 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::RuntimeSnapshotRecord;
use serde_json::{Value, json};
use shared_contracts::story::{
BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload,
StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse,
BeginStoryRuntimeSessionRequest, BeginStorySessionRequest, ContinueStoryRequest,
ResolveStoryRuntimeActionRequest, StoryEventPayload, StoryRuntimeMutationResponse,
StoryRuntimeSnapshotPayload, StorySessionMutationResponse, StorySessionPayload,
StorySessionStateResponse,
};
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,
@@ -43,28 +48,75 @@ pub async fn begin_story_session(
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,
},
story_session: story_session_payload_from_record(result.session),
story_event: story_event_payload_from_record(result.event),
},
))
}
pub async fn begin_story_runtime_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BeginStoryRuntimeSessionRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let runtime_session_id = module_runtime_story::generate_runtime_session_id(
actor_user_id.as_str(),
payload.custom_world_profile.as_ref(),
&payload.character,
now_micros,
);
let story_session_id = module_story::generate_story_session_id(now_micros);
let built = module_runtime_story::build_runtime_story_bootstrap(
&payload,
module_runtime_story::RuntimeStoryBootstrapSeed {
runtime_session_id,
story_session_id,
actor_user_id: actor_user_id.clone(),
now_micros,
},
)
.map_err(|message| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let story_result = state
.spacetime_client()
.begin_story_session(
built.story_session_id.clone(),
built.runtime_session_id.clone(),
actor_user_id.clone(),
built.world_profile_id,
built.initial_prompt,
built.opening_summary,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
let persisted =
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, built.snapshot)
.await?;
Ok(json_success_body(
Some(&request_context),
StoryRuntimeMutationResponse {
projection: build_story_runtime_projection_from_persisted(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
persisted.version,
),
},
))
}
@@ -108,28 +160,105 @@ pub async fn continue_story(
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,
},
story_session: story_session_payload_from_record(result.session),
story_event: story_event_payload_from_record(result.event),
},
))
}
pub async fn resolve_story_runtime_action(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryRuntimeActionRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = validate_story_runtime_action_path(
&request_context,
story_session_id,
payload.story_session_id.as_str(),
)?;
let story_state = state
.spacetime_client()
.get_story_session_state(story_session_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
let snapshot_record = state
.get_runtime_snapshot_record(actor_user_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?
.ok_or_else(|| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "当前用户缺少 runtime snapshot",
})),
)
})?;
let snapshot = story_runtime_snapshot_payload_from_record(&snapshot_record);
validate_story_runtime_client_version(
&request_context,
payload.client_version,
&snapshot.game_state,
)?;
let resolved = module_runtime_story::resolve_story_runtime_action(
module_runtime_story::StoryRuntimeActionResolveInput {
story_session_id: story_state.session.story_session_id.clone(),
runtime_session_id: story_state.session.runtime_session_id.clone(),
snapshot,
request: payload,
},
)
.map_err(|message| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let story_result = state
.spacetime_client()
.continue_story(
story_state.session.story_session_id,
module_story::generate_story_event_id(now_micros),
resolved.narrative_text,
resolved.choice_function_id,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
let persisted =
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, resolved.snapshot)
.await?;
Ok(json_success_body(
Some(&request_context),
StoryRuntimeMutationResponse {
projection: build_story_runtime_projection_from_persisted(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
resolved.server_version.max(persisted.version),
),
},
))
}
@@ -157,36 +286,257 @@ pub async fn get_story_session_state(
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_session: story_session_payload_from_record(result.session),
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,
})
.map(story_event_payload_from_record)
.collect(),
},
))
}
async fn persist_story_runtime_snapshot(
state: &AppState,
request_context: &RequestContext,
user_id: String,
snapshot: StoryRuntimeSnapshotPayload,
) -> Result<RuntimeSnapshotRecord, Response> {
validate_story_runtime_snapshot_payload(&snapshot).map_err(|message| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = snapshot
.saved_at
.as_deref()
.and_then(module_runtime_story::normalize_required_string)
.map(|value| parse_rfc3339(value.as_str()))
.transpose()
.map_err(|error| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"field": "snapshot.savedAt",
"message": format!("savedAt 非法: {error}"),
})),
)
})?
.unwrap_or(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let game_state = canonicalize_story_runtime_game_state(snapshot.game_state);
state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
game_state,
snapshot.current_story,
updated_at_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(request_context, map_story_session_client_error(error))
})
}
fn canonicalize_story_runtime_game_state(mut game_state: Value) -> Value {
if let Some(root) = game_state.as_object_mut() {
// 中文注释NPC 交易 / 赠礼 view 是展示层投影,持久快照只保存可复算真相。
root.remove("runtimeNpcInteraction");
}
game_state
}
fn validate_story_runtime_snapshot_payload(
snapshot: &StoryRuntimeSnapshotPayload,
) -> Result<(), String> {
if module_runtime_story::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 story_runtime_snapshot_payload_from_record(
record: &RuntimeSnapshotRecord,
) -> StoryRuntimeSnapshotPayload {
let mut game_state = record.game_state.clone();
module_runtime_story::write_runtime_npc_interaction_view(&mut game_state);
StoryRuntimeSnapshotPayload {
saved_at: Some(record.saved_at.clone()),
bottom_tab: record.bottom_tab.clone(),
game_state,
current_story: record.current_story.clone(),
}
}
fn build_story_runtime_projection_from_persisted(
story_session: StorySessionPayload,
story_events: Vec<StoryEventPayload>,
record: &RuntimeSnapshotRecord,
server_version: u32,
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
let snapshot = story_runtime_snapshot_payload_from_record(record);
let current_story = snapshot.current_story.as_ref();
let options =
module_runtime_story::build_runtime_story_options(current_story, &snapshot.game_state);
let current_narrative_text = read_story_runtime_current_text(current_story)
.or_else(|| Some(story_session.latest_narrative_text.clone()));
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
let toast = read_story_runtime_current_field(current_story, "toast");
module_runtime_story::build_story_runtime_projection(
module_runtime_story::StoryRuntimeProjectionSource {
story_session,
story_events,
game_state: snapshot.game_state,
options,
server_version,
current_narrative_text,
action_result_text,
toast,
},
)
}
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
read_story_runtime_current_field(current_story, "text")
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
}
fn read_story_runtime_current_field(current_story: Option<&Value>, field: &str) -> Option<String> {
current_story?
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn validate_story_runtime_action_path(
request_context: &RequestContext,
path_story_session_id: String,
body_story_session_id: &str,
) -> Result<String, Response> {
let path_story_session_id =
module_runtime_story::normalize_required_string(path_story_session_id.as_str())
.ok_or_else(|| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": "storySessionId 不能为空",
})),
)
})?;
let body_story_session_id = module_runtime_story::normalize_required_string(
body_story_session_id,
)
.ok_or_else(|| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": "request.storySessionId 不能为空",
})),
)
})?;
if path_story_session_id != body_story_session_id {
return Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "path storySessionId 与 request.storySessionId 不一致",
"pathStorySessionId": path_story_session_id,
"requestStorySessionId": body_story_session_id,
})),
));
}
Ok(path_story_session_id)
}
fn validate_story_runtime_client_version(
request_context: &RequestContext,
client_version: Option<u32>,
game_state: &Value,
) -> Result<(), Response> {
let Some(client_version) = client_version else {
return Ok(());
};
let Some(server_version) =
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
else {
return Ok(());
};
if client_version == server_version {
return Ok(());
}
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "运行时版本已变化,请先同步最新快照后再提交动作",
"clientVersion": client_version,
"serverVersion": server_version,
})),
))
}
fn story_session_payload_from_record(
record: module_story::StorySessionRecord,
) -> StorySessionPayload {
StorySessionPayload {
story_session_id: record.story_session_id,
runtime_session_id: record.runtime_session_id,
actor_user_id: record.actor_user_id,
world_profile_id: record.world_profile_id,
initial_prompt: record.initial_prompt,
opening_summary: record.opening_summary,
latest_narrative_text: record.latest_narrative_text,
latest_choice_function_id: record.latest_choice_function_id,
status: record.status,
version: record.version,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn story_event_payload_from_record(record: module_story::StoryEventRecord) -> StoryEventPayload {
StoryEventPayload {
event_id: record.event_id,
story_session_id: record.story_session_id,
event_kind: record.event_kind,
narrative_text: record.narrative_text,
choice_function_id: record.choice_function_id,
created_at: record.created_at,
}
}
pub async fn get_story_runtime_projection(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
@@ -373,6 +723,156 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn begin_story_runtime_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/runtime")
.header("content-type", "application/json")
.body(Body::from(
json!({
"worldType": "CUSTOM",
"customWorldProfile": { "id": "profile_001" },
"character": { "id": "hero_001", "name": "沈砺" },
"runtimeMode": "play",
"disablePersistence": false
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn begin_story_runtime_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/runtime")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"worldType": "CUSTOM",
"customWorldProfile": { "id": "profile_001" },
"character": { "id": "hero_001", "name": "沈砺" },
"runtimeMode": "play",
"disablePersistence": false
})
.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 resolve_story_runtime_action_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/storysess_001/actions/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"clientVersion": 1,
"functionId": "idle_observe_signs",
"actionText": "观察周围迹象",
"payload": { "optionText": "观察周围迹象" }
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_story_runtime_action_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/storysess_001/actions/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"clientVersion": 1,
"functionId": "idle_observe_signs",
"actionText": "观察周围迹象",
"payload": { "optionText": "观察周围迹象" }
})
.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;