Close DDD refactor and remove generated asset proxy
This commit is contained in:
@@ -46,12 +46,10 @@
|
||||
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
|
||||
23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
|
||||
24. 接入 custom world agent `session create / session snapshot` Axum facade
|
||||
25. 接入旧 `runtime story` 兼容接口:
|
||||
- `POST /api/runtime/story/state/resolve`
|
||||
- `GET /api/runtime/story/state/{session_id}`
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
25. 旧 `runtime story` 兼容接口已下线并保持未挂载;运行时故事写链路改为:
|
||||
- `POST /api/story/sessions/runtime`
|
||||
- `GET /api/story/sessions/{story_session_id}/runtime-projection`
|
||||
- `POST /api/story/sessions/{story_session_id}/actions/resolve`
|
||||
26. 接入 `POST /api/assets/character-visual/generate`
|
||||
27. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
28. 接入 `POST /api/assets/character-visual/publish`
|
||||
@@ -62,7 +60,8 @@
|
||||
33. 接入 `POST /api/assets/character-animation/generate`
|
||||
34. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
35. 接入 `POST /api/assets/character-animation/publish`
|
||||
36. 接入旧 `/generated-character-drafts/*`、`/generated-characters/*`、`/generated-animations/*`、`/generated-custom-world-scenes/*`、`/generated-custom-world-covers/*`、`/generated-qwen-sprites/*` 到 OSS 私有读代理
|
||||
36. 下线旧 `/generated-*` 资产直读代理;生成资产读取统一走 `/api/assets/read-url` 或 asset object projection
|
||||
37. 下线旧 `/api/custom-world/*` 非 runtime 前缀和 `/api/runtime/puzzle/runs/local-next-level`,并用路由回归测试确认这些旧入口保持 `404`
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -87,12 +86,13 @@
|
||||
19. [x] 接入 `/api/assets/sts-upload-credentials`
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
22. [x] 接入旧 `runtime story` compat facade
|
||||
22. [x] 下线旧 `runtime story` compat facade,并接入 story session scoped runtime story 写读入口
|
||||
23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade
|
||||
25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade
|
||||
26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
27. [x] 接入旧 `/generated-*` 路径到 OSS 私有读同源代理
|
||||
27. [x] 下线旧 `/generated-*` 路径 OSS 私有读同源代理
|
||||
28. [x] 下线旧 `/api/custom-world/*` 非 runtime 前缀和 Puzzle `local-next-level` 兼容入口
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -161,10 +161,12 @@
|
||||
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler,但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。
|
||||
15. 当前 `/api/runtime/story/*` 不再挂载;runtime story 开局、读取和动作结算通过 `/api/story/sessions/runtime`、`/api/story/sessions/{story_session_id}/runtime-projection` 与 `/api/story/sessions/{story_session_id}/actions/resolve` 进入 BFF,并由 `module-runtime-story`、story session 和 runtime snapshot 投影共同闭合。
|
||||
16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
|
||||
17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。
|
||||
18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。
|
||||
19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
|
||||
20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。
|
||||
21. 当前旧 `/generated-*` 读取兼容层只代理受支持 generated 前缀到 OSS 私有读签名,不回退仓库 `public/`,Stage 1 不支持视频 Range 分片。
|
||||
21. 当前旧 `/generated-*` 直读兼容层已下线;`/generated-*` 只作为 `legacyPublicPath` / OSS object key 标识,读取必须通过 `/api/assets/read-url` 或业务投影中的签名读 URL。
|
||||
22. 当前旧 `/api/custom-world/entity`、`/api/custom-world/scene-npc`、`/api/custom-world/scene-image`、`/api/custom-world/cover-image`、`/api/custom-world/cover-upload` 不再挂载,RPG 创作资产入口统一使用 `/api/runtime/custom-world/*`。
|
||||
23. 当前旧 `/api/runtime/puzzle/runs/local-next-level` 不再挂载,正式 Puzzle 运行态只通过 `/api/runtime/puzzle/runs/{run_id}/next-level` 进入后端真相源。
|
||||
|
||||
@@ -67,12 +67,6 @@ use crate::{
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
legacy_generated_assets::{
|
||||
proxy_generated_animations, proxy_generated_big_fish_assets,
|
||||
proxy_generated_character_drafts, proxy_generated_characters,
|
||||
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
|
||||
proxy_generated_puzzle_assets, proxy_generated_qwen_sprites,
|
||||
},
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
@@ -81,12 +75,11 @@ use crate::{
|
||||
password_management::{change_password, reset_password},
|
||||
phone_auth::{phone_login, send_phone_code},
|
||||
puzzle::{
|
||||
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
|
||||
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
|
||||
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
||||
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
||||
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
||||
submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work,
|
||||
drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session,
|
||||
get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works,
|
||||
list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message,
|
||||
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
},
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -117,7 +110,8 @@ use crate::{
|
||||
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
|
||||
},
|
||||
story_sessions::{
|
||||
begin_story_session, continue_story, get_story_runtime_projection, get_story_session_state,
|
||||
begin_story_runtime_session, begin_story_session, continue_story,
|
||||
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
|
||||
},
|
||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
||||
};
|
||||
@@ -193,38 +187,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/auth/public-users/by-id/{user_id}",
|
||||
get(get_public_user_by_id),
|
||||
)
|
||||
.route(
|
||||
"/generated-character-drafts/{*path}",
|
||||
get(proxy_generated_character_drafts),
|
||||
)
|
||||
.route(
|
||||
"/generated-characters/{*path}",
|
||||
get(proxy_generated_characters),
|
||||
)
|
||||
.route(
|
||||
"/generated-animations/{*path}",
|
||||
get(proxy_generated_animations),
|
||||
)
|
||||
.route(
|
||||
"/generated-big-fish-assets/{*path}",
|
||||
get(proxy_generated_big_fish_assets),
|
||||
)
|
||||
.route(
|
||||
"/generated-puzzle-assets/{*path}",
|
||||
get(proxy_generated_puzzle_assets),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-scenes/{*path}",
|
||||
get(proxy_generated_custom_world_scenes),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-covers/{*path}",
|
||||
get(proxy_generated_custom_world_covers),
|
||||
)
|
||||
.route(
|
||||
"/generated-qwen-sprites/{*path}",
|
||||
get(proxy_generated_qwen_sprites),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
@@ -738,13 +700,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/runs/local-next-level",
|
||||
post(advance_local_puzzle_next_level).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/runs/{run_id}",
|
||||
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
|
||||
@@ -787,13 +742,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/entity",
|
||||
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/entity",
|
||||
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
|
||||
@@ -801,13 +749,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/scene-npc",
|
||||
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/scene-npc",
|
||||
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
|
||||
@@ -816,19 +757,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/scene-image",
|
||||
"/api/runtime/custom-world/scene-image",
|
||||
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/cover-image",
|
||||
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/cover-image",
|
||||
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
@@ -836,13 +770,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/cover-upload",
|
||||
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/cover-upload",
|
||||
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
@@ -944,6 +871,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/runtime",
|
||||
post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/state",
|
||||
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
|
||||
@@ -958,6 +892,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/actions/resolve",
|
||||
post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/continue",
|
||||
post(continue_story).route_layer(middleware::from_fn_with_state(
|
||||
@@ -1263,6 +1204,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleted_old_routes_are_not_mounted() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
// 中文注释:旧 custom-world 非 runtime 前缀没有任何新路由可匹配,
|
||||
// 因此必须稳定返回 404,避免前端继续误用旧入口。
|
||||
for uri in [
|
||||
"/api/custom-world/entity",
|
||||
"/api/custom-world/scene-npc",
|
||||
"/api/custom-world/scene-image",
|
||||
"/api/custom-world/cover-image",
|
||||
"/api/custom-world/cover-upload",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("deleted old route request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("deleted old route request should be handled");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/puzzle/runs/local-next-level")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("deleted old puzzle route request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("deleted old puzzle route request should be handled");
|
||||
|
||||
// 中文注释:该路径会被现有 GET /runs/{run_id} 的动态段识别,
|
||||
// 但 POST 方法没有挂载,返回 405 代表旧 local-next-level handler 已移除。
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_asset_read_proxy_routes_are_not_mounted() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
// 中文注释:生成资产仍可作为 legacyPublicPath 传给 /api/assets/read-url,
|
||||
// 但不能再通过 /generated-* 同源路由裸读 OSS 对象。
|
||||
for uri in [
|
||||
"/generated-character-drafts/hero/visual/candidate.png",
|
||||
"/generated-characters/hero/visual/master.png",
|
||||
"/generated-animations/hero/idle/frame01.png",
|
||||
"/generated-big-fish-assets/session-1/level/image.png",
|
||||
"/generated-puzzle-assets/session-1/candidate/image.png",
|
||||
"/generated-custom-world-scenes/world-1/camp/scene.png",
|
||||
"/generated-custom-world-covers/world-1/cover.webp",
|
||||
"/generated-qwen-sprites/master/candidate-01.png",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("generated asset proxy route request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("generated asset proxy route request should be handled");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_auth_claims_rejects_missing_bearer_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -442,6 +442,8 @@ pub async fn generate_custom_world_scene_image(
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let normalized = normalize_scene_image_request(payload)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
require_dashscope_settings(&state)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
||||
let asset = execute_billable_asset_operation(
|
||||
&state,
|
||||
@@ -703,6 +705,8 @@ pub async fn generate_custom_world_cover_image(
|
||||
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
|
||||
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
|
||||
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
|
||||
require_dashscope_settings(&state)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
||||
let asset = execute_billable_asset_operation(
|
||||
&state,
|
||||
@@ -2440,10 +2444,16 @@ mod tests {
|
||||
serde_json::from_slice(&body).expect("body should be valid json")
|
||||
}
|
||||
|
||||
fn build_state_without_dashscope_key() -> AppState {
|
||||
let mut config = AppConfig::default();
|
||||
config.dashscope_api_key = None;
|
||||
AppState::new(config).expect("state should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scene_image_returns_service_unavailable_when_dashscope_missing() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let request_context = build_request_context("POST /api/custom-world/scene-image");
|
||||
let state = build_state_without_dashscope_key();
|
||||
let request_context = build_request_context("POST /api/runtime/custom-world/scene-image");
|
||||
let authenticated = build_authenticated(&state);
|
||||
|
||||
let response = generate_custom_world_scene_image(
|
||||
@@ -2512,15 +2522,27 @@ mod tests {
|
||||
};
|
||||
|
||||
let normalized = normalize_scene_image_request(payload).expect("payload should normalize");
|
||||
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
|
||||
profile: SceneImagePromptProfile {
|
||||
name: "雾海群岛",
|
||||
subtitle: "失落航线",
|
||||
tone: "潮湿、神秘、低魔奇幻",
|
||||
player_goal: "找到王冠并阻止海妖复苏",
|
||||
summary: "玩家在雾海中追查沉没王冠。",
|
||||
setting_text: "群岛被永恒雾潮包围。",
|
||||
},
|
||||
landmark: SceneImagePromptLandmark {
|
||||
name: "礁石神殿",
|
||||
description: "古老礁石上的半沉神殿。",
|
||||
},
|
||||
user_prompt: "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。",
|
||||
has_reference_image: false,
|
||||
fallback_landmark_name: Some("礁石神殿"),
|
||||
fallback_world_name: "雾海群岛",
|
||||
});
|
||||
|
||||
assert!(normalized.prompt.contains("世界名:雾海群岛"));
|
||||
assert!(normalized.prompt.contains("世界副标题:失落航线"));
|
||||
assert!(normalized.prompt.contains("场景名称:礁石神殿"));
|
||||
assert!(
|
||||
normalized
|
||||
.prompt
|
||||
.contains("本次想要生成的画面内容:破碎神殿")
|
||||
);
|
||||
assert_eq!(normalized.prompt, manual_prompt);
|
||||
assert!(normalized.prompt.contains("破碎神殿矗立在蓝绿色雾潮中"));
|
||||
assert_ne!(
|
||||
normalized.prompt,
|
||||
"破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"
|
||||
@@ -2585,8 +2607,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cover_image_returns_service_unavailable_when_dashscope_missing() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let request_context = build_request_context("POST /api/custom-world/cover-image");
|
||||
let state = build_state_without_dashscope_key();
|
||||
let request_context = build_request_context("POST /api/runtime/custom-world/cover-image");
|
||||
let authenticated = build_authenticated(&state);
|
||||
|
||||
let response = generate_custom_world_cover_image(
|
||||
@@ -2622,7 +2644,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn cover_upload_rejects_invalid_data_url_before_touching_oss() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let request_context = build_request_context("POST /api/custom-world/cover-upload");
|
||||
let request_context = build_request_context("POST /api/runtime/custom-world/cover-upload");
|
||||
let authenticated = build_authenticated(&state);
|
||||
|
||||
let response = upload_custom_world_cover_image(
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
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, platform_errors::map_oss_error, state::AppState};
|
||||
|
||||
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
|
||||
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
|
||||
|
||||
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_puzzle_assets(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::PuzzleAssets, 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 {
|
||||
map_oss_error(error, "aliyun-oss")
|
||||
}
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@ use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmMessageRole, LlmTextRequest};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::llm::{
|
||||
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
@@ -20,15 +24,7 @@ pub async fn proxy_llm_chat_completions(
|
||||
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 代理"),
|
||||
));
|
||||
}
|
||||
|
||||
) -> Result<Response, Response> {
|
||||
let llm_client = state.llm_client().ok_or_else(|| {
|
||||
llm_error_response(
|
||||
&request_context,
|
||||
@@ -48,6 +44,10 @@ pub async fn proxy_llm_chat_completions(
|
||||
enable_web_search: false,
|
||||
};
|
||||
|
||||
if payload.stream {
|
||||
return Ok(stream_llm_chat_completions(llm_client.clone(), request).into_response());
|
||||
}
|
||||
|
||||
let response = llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
@@ -61,7 +61,78 @@ pub async fn proxy_llm_chat_completions(
|
||||
content: response.content,
|
||||
finish_reason: response.finish_reason,
|
||||
},
|
||||
))
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn stream_llm_chat_completions(
|
||||
llm_client: platform_llm::LlmClient,
|
||||
request: LlmTextRequest,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
let stream = async_stream::stream! {
|
||||
let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
||||
let llm_stream = llm_client.stream_text(request, move |delta| {
|
||||
let _ = delta_tx.send(json!({
|
||||
"delta": delta.delta_text,
|
||||
"content": delta.accumulated_text,
|
||||
"finishReason": delta.finish_reason,
|
||||
}));
|
||||
});
|
||||
tokio::pin!(llm_stream);
|
||||
|
||||
let llm_result = loop {
|
||||
// `platform-llm` 负责上游 SSE 解析;这里尽快把增量转成 API 层 SSE 事件。
|
||||
tokio::select! {
|
||||
result = &mut llm_stream => break result,
|
||||
maybe_delta = delta_rx.recv() => {
|
||||
if let Some(delta) = maybe_delta {
|
||||
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error("delta", delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(delta) = delta_rx.recv().await {
|
||||
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error("delta", delta));
|
||||
}
|
||||
|
||||
match llm_result {
|
||||
Ok(response) => {
|
||||
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error(
|
||||
"complete",
|
||||
json!(LlmChatCompletionResponse {
|
||||
id: response.response_id,
|
||||
model: response.model,
|
||||
content: response.content,
|
||||
finish_reason: response.finish_reason,
|
||||
}),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
let app_error = map_llm_error(error);
|
||||
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error(
|
||||
"error",
|
||||
json!({
|
||||
"code": app_error.code(),
|
||||
"message": app_error.message(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
|
||||
};
|
||||
|
||||
Sse::new(stream)
|
||||
}
|
||||
|
||||
fn llm_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
|
||||
match serde_json::to_string(&payload) {
|
||||
Ok(payload_text) => Event::default().event(event_name).data(payload_text),
|
||||
Err(_) => Event::default()
|
||||
.event("error")
|
||||
.data("{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"SSE payload 序列化失败\"}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
|
||||
@@ -105,6 +176,7 @@ mod tests {
|
||||
status_line: &'static str,
|
||||
content_type: &'static str,
|
||||
body: String,
|
||||
extra_headers: Vec<(&'static str, &'static str)>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -113,6 +185,7 @@ mod tests {
|
||||
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(),
|
||||
extra_headers: Vec::new(),
|
||||
}]);
|
||||
let state = seed_authenticated_state(AppConfig {
|
||||
llm_base_url: server_url,
|
||||
@@ -176,8 +249,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_chat_completions_rejects_stream_mode() {
|
||||
let state = seed_authenticated_state(AppConfig::default()).await;
|
||||
async fn llm_chat_completions_streams_sse_payload() {
|
||||
let server_url = spawn_mock_server(vec![MockResponse {
|
||||
status_line: "200 OK",
|
||||
content_type: "text/event-stream; charset=utf-8",
|
||||
body: concat!(
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\n",
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n",
|
||||
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
.to_string(),
|
||||
extra_headers: vec![("x-request-id", "req_llm_stream_01")],
|
||||
}]);
|
||||
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);
|
||||
|
||||
@@ -188,7 +278,6 @@ mod tests {
|
||||
.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,
|
||||
@@ -203,7 +292,14 @@ mod tests {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
@@ -211,14 +307,15 @@ mod tests {
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
let body_text = String::from_utf8(body.to_vec()).expect("body should be utf8");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["code"],
|
||||
Value::String("NOT_IMPLEMENTED".to_string())
|
||||
);
|
||||
assert!(body_text.contains("event: delta"));
|
||||
assert!(body_text.contains(r#""delta":"你""#));
|
||||
assert!(body_text.contains(r#""content":"你好""#));
|
||||
assert!(body_text.contains("event: complete"));
|
||||
assert!(body_text.contains(r#""id":"req_llm_stream_01""#));
|
||||
assert!(body_text.contains(r#""finishReason":"stop""#));
|
||||
assert!(body_text.contains("data: [DONE]"));
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state(config: AppConfig) -> AppState {
|
||||
@@ -306,13 +403,17 @@ mod tests {
|
||||
|
||||
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{}",
|
||||
let mut raw_response = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
|
||||
response.status_line,
|
||||
response.content_type,
|
||||
body.len(),
|
||||
body
|
||||
body.len()
|
||||
);
|
||||
for (name, value) in response.extra_headers {
|
||||
raw_response.push_str(format!("{name}: {value}\r\n").as_str());
|
||||
}
|
||||
raw_response.push_str("\r\n");
|
||||
raw_response.push_str(body.as_str());
|
||||
|
||||
stream
|
||||
.write_all(raw_response.as_bytes())
|
||||
|
||||
@@ -34,7 +34,6 @@ 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;
|
||||
|
||||
@@ -17,7 +17,7 @@ use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
|
||||
use module_puzzle::PuzzleGeneratedImageCandidate;
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
@@ -36,11 +36,10 @@ use shared_contracts::{
|
||||
},
|
||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||
puzzle_runtime::{
|
||||
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
|
||||
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
||||
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
||||
StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
|
||||
},
|
||||
puzzle_works::{
|
||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||
@@ -52,14 +51,13 @@ use spacetime_client::{
|
||||
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
|
||||
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
@@ -1100,36 +1098,6 @@ pub async fn advance_puzzle_next_level(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn advance_local_puzzle_next_level(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<AdvanceLocalPuzzleNextLevelRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_RUNTIME_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let run = build_local_next_puzzle_run(&state, payload, owner_user_id.as_str())
|
||||
.await
|
||||
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleRunResponse {
|
||||
run: map_puzzle_run_response(run),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_puzzle_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
@@ -1385,98 +1353,6 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRecord {
|
||||
PuzzleRunRecord {
|
||||
run_id: run.run_id,
|
||||
entry_profile_id: run.entry_profile_id,
|
||||
cleared_level_count: run.cleared_level_count,
|
||||
current_level_index: run.current_level_index,
|
||||
current_grid_size: run.current_grid_size,
|
||||
played_profile_ids: run.played_profile_ids,
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_level_request_record),
|
||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||
leaderboard_entries: run
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_request_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_level_request_record(
|
||||
level: PuzzleRuntimeLevelSnapshotResponse,
|
||||
) -> PuzzleRuntimeLevelRecord {
|
||||
PuzzleRuntimeLevelRecord {
|
||||
run_id: level.run_id,
|
||||
level_index: level.level_index,
|
||||
grid_size: level.grid_size,
|
||||
profile_id: level.profile_id,
|
||||
level_name: level.level_name,
|
||||
author_display_name: level.author_display_name,
|
||||
theme_tags: level.theme_tags,
|
||||
cover_image_src: level.cover_image_src,
|
||||
board: map_puzzle_board_request_record(level.board),
|
||||
status: level.status,
|
||||
started_at_ms: level.started_at_ms,
|
||||
cleared_at_ms: level.cleared_at_ms,
|
||||
elapsed_ms: level.elapsed_ms,
|
||||
leaderboard_entries: level
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_request_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_leaderboard_request_record(
|
||||
entry: PuzzleLeaderboardEntryResponse,
|
||||
) -> PuzzleLeaderboardEntryRecord {
|
||||
PuzzleLeaderboardEntryRecord {
|
||||
rank: entry.rank,
|
||||
nickname: entry.nickname,
|
||||
elapsed_ms: entry.elapsed_ms,
|
||||
is_current_player: entry.is_current_player,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> PuzzleBoardRecord {
|
||||
PuzzleBoardRecord {
|
||||
rows: board.rows,
|
||||
cols: board.cols,
|
||||
pieces: board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| PuzzlePieceStateRecord {
|
||||
piece_id: piece.piece_id,
|
||||
correct_row: piece.correct_row,
|
||||
correct_col: piece.correct_col,
|
||||
current_row: piece.current_row,
|
||||
current_col: piece.current_col,
|
||||
merged_group_id: piece.merged_group_id,
|
||||
})
|
||||
.collect(),
|
||||
merged_groups: board
|
||||
.merged_groups
|
||||
.into_iter()
|
||||
.map(|group| PuzzleMergedGroupRecord {
|
||||
group_id: group.group_id,
|
||||
piece_ids: group.piece_ids,
|
||||
occupied_cells: group
|
||||
.occupied_cells
|
||||
.into_iter()
|
||||
.map(|cell| PuzzleCellPositionRecord {
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
selected_piece_id: board.selected_piece_id,
|
||||
all_tiles_resolved: board.all_tiles_resolved,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_runtime_level_response(
|
||||
level: spacetime_client::PuzzleRuntimeLevelRecord,
|
||||
) -> PuzzleRuntimeLevelSnapshotResponse {
|
||||
@@ -1842,343 +1718,6 @@ async fn generate_puzzle_image_candidates(
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn build_local_next_puzzle_run(
|
||||
state: &AppState,
|
||||
payload: AdvanceLocalPuzzleNextLevelRequest,
|
||||
owner_user_id: &str,
|
||||
) -> Result<PuzzleRunRecord, AppError> {
|
||||
let run = map_puzzle_run_request_record(payload.run);
|
||||
let current_level = run.current_level.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": "currentLevel is required",
|
||||
}))
|
||||
})?;
|
||||
if current_level.status != "cleared" {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": "current level is not cleared",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
|
||||
return Ok(build_next_run_from_puzzle_work(run, gallery_item));
|
||||
}
|
||||
|
||||
let source_session_id = payload.source_session_id.unwrap_or_default();
|
||||
if source_session_id.trim().is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": "sourceSessionId is required when gallery has no next puzzle work",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(source_session_id, owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
if let Some(candidate) = session
|
||||
.draft
|
||||
.as_ref()
|
||||
.and_then(|draft| pick_unused_puzzle_candidate(&draft.candidates, &run.played_profile_ids))
|
||||
{
|
||||
return Ok(build_next_run_from_candidate(run, &session, candidate));
|
||||
}
|
||||
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": "puzzle draft is required when gallery has no next puzzle work",
|
||||
}))
|
||||
})?;
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
state,
|
||||
owner_user_id,
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&draft.summary,
|
||||
None,
|
||||
1,
|
||||
draft.candidates.len(),
|
||||
)
|
||||
.await
|
||||
.map_err(|message| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
})?;
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(to_puzzle_generated_image_candidate)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": format!("拼图候选图序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let updated_session = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let candidate = updated_session
|
||||
.draft
|
||||
.as_ref()
|
||||
.and_then(|draft| {
|
||||
draft
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| !candidate.image_src.is_empty())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": "现场生成后没有可用候选图",
|
||||
}))
|
||||
})?;
|
||||
Ok(build_next_run_from_candidate(
|
||||
run,
|
||||
&updated_session,
|
||||
candidate,
|
||||
))
|
||||
}
|
||||
|
||||
async fn resolve_gallery_next_puzzle_work(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.list_puzzle_gallery()
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
Ok(items.into_iter().find(|item| {
|
||||
item.publication_status == "published"
|
||||
&& item
|
||||
.cover_image_src
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& !run.played_profile_ids.contains(&item.profile_id)
|
||||
}))
|
||||
}
|
||||
|
||||
fn pick_unused_puzzle_candidate<'a>(
|
||||
candidates: &'a [PuzzleGeneratedImageCandidateRecord],
|
||||
played_profile_ids: &[String],
|
||||
) -> Option<&'a PuzzleGeneratedImageCandidateRecord> {
|
||||
candidates.iter().find(|candidate| {
|
||||
!candidate.image_src.is_empty()
|
||||
&& !played_profile_ids
|
||||
.iter()
|
||||
.any(|profile_id| profile_id.contains(&candidate.candidate_id))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_next_run_from_puzzle_work(
|
||||
run: PuzzleRunRecord,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleRunRecord {
|
||||
build_next_run_from_parts(
|
||||
run,
|
||||
item.profile_id,
|
||||
item.level_name,
|
||||
item.author_display_name,
|
||||
item.theme_tags,
|
||||
item.cover_image_src,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_candidate(
|
||||
run: PuzzleRunRecord,
|
||||
session: &PuzzleAgentSessionRecord,
|
||||
candidate: &PuzzleGeneratedImageCandidateRecord,
|
||||
) -> PuzzleRunRecord {
|
||||
let draft = session.draft.as_ref();
|
||||
let level_index = run.current_level_index + 1;
|
||||
build_next_run_from_parts(
|
||||
run,
|
||||
format!(
|
||||
"{}-{}-level-{}",
|
||||
session.session_id, candidate.candidate_id, level_index
|
||||
),
|
||||
draft
|
||||
.map(|draft| format!("{} · 候选 {}", draft.level_name, level_index))
|
||||
.unwrap_or_else(|| format!("候选拼图 {level_index}")),
|
||||
"当前草稿".to_string(),
|
||||
draft
|
||||
.map(|draft| draft.theme_tags.clone())
|
||||
.unwrap_or_default(),
|
||||
Some(candidate.image_src.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_parts(
|
||||
run: PuzzleRunRecord,
|
||||
profile_id: String,
|
||||
level_name: String,
|
||||
author_display_name: String,
|
||||
theme_tags: Vec<String>,
|
||||
cover_image_src: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
let next_level_index = run.current_level_index + 1;
|
||||
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
|
||||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||||
if !played_profile_ids.contains(&profile_id) {
|
||||
played_profile_ids.push(profile_id.clone());
|
||||
}
|
||||
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
|
||||
PuzzleRunRecord {
|
||||
run_id: run.run_id.clone(),
|
||||
entry_profile_id: run.entry_profile_id,
|
||||
cleared_level_count: run.cleared_level_count,
|
||||
current_level_index: next_level_index,
|
||||
current_grid_size: grid_size,
|
||||
played_profile_ids,
|
||||
previous_level_tags: theme_tags.clone(),
|
||||
current_level: Some(PuzzleRuntimeLevelRecord {
|
||||
run_id: run.run_id,
|
||||
level_index: next_level_index,
|
||||
grid_size,
|
||||
profile_id,
|
||||
level_name,
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
cover_image_src,
|
||||
board,
|
||||
status: "playing".to_string(),
|
||||
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_local_puzzle_board(
|
||||
grid_size: u32,
|
||||
run_id: &str,
|
||||
profile_id: &str,
|
||||
level_index: u32,
|
||||
) -> PuzzleBoardRecord {
|
||||
let board = module_puzzle::build_initial_board_with_seed(
|
||||
grid_size,
|
||||
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
module_puzzle::build_initial_board_with_seed(3, 1)
|
||||
.expect("fallback puzzle board should use supported grid size")
|
||||
});
|
||||
map_puzzle_board_snapshot_record(board)
|
||||
}
|
||||
|
||||
fn build_local_puzzle_shuffle_seed(
|
||||
run_id: &str,
|
||||
profile_id: &str,
|
||||
level_index: u32,
|
||||
grid_size: u32,
|
||||
) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
|
||||
for byte in run_id
|
||||
.bytes()
|
||||
.chain(profile_id.bytes())
|
||||
.chain(level_index.to_le_bytes())
|
||||
.chain(grid_size.to_le_bytes())
|
||||
{
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
|
||||
PuzzleBoardRecord {
|
||||
rows: board.rows,
|
||||
cols: board.cols,
|
||||
pieces: board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| PuzzlePieceStateRecord {
|
||||
piece_id: piece.piece_id,
|
||||
correct_row: piece.correct_row,
|
||||
correct_col: piece.correct_col,
|
||||
current_row: piece.current_row,
|
||||
current_col: piece.current_col,
|
||||
merged_group_id: piece.merged_group_id,
|
||||
})
|
||||
.collect(),
|
||||
merged_groups: board
|
||||
.merged_groups
|
||||
.into_iter()
|
||||
.map(|group| PuzzleMergedGroupRecord {
|
||||
group_id: group.group_id,
|
||||
piece_ids: group.piece_ids,
|
||||
occupied_cells: group
|
||||
.occupied_cells
|
||||
.into_iter()
|
||||
.map(|cell| PuzzleCellPositionRecord {
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
selected_piece_id: board.selected_piece_id,
|
||||
all_tiles_resolved: board.all_tiles_resolved,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> {
|
||||
board
|
||||
.pieces
|
||||
.iter()
|
||||
.map(|piece| (piece.current_row, piece.current_col))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool {
|
||||
board.pieces.iter().any(|piece| {
|
||||
board.pieces.iter().any(|candidate| {
|
||||
piece.piece_id != candidate.piece_id
|
||||
&& piece.current_row.abs_diff(candidate.current_row)
|
||||
+ piece.current_col.abs_diff(candidate.current_col)
|
||||
== 1
|
||||
&& piece.correct_row.abs_diff(candidate.correct_row)
|
||||
+ piece.correct_col.abs_diff(candidate.correct_col)
|
||||
== 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_next_level_board_shuffle_changes_by_level() {
|
||||
let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2);
|
||||
let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3);
|
||||
|
||||
assert_ne!(board_positions(&second), board_positions(&third));
|
||||
assert!(!has_original_neighbor_pair(&second));
|
||||
assert!(!has_original_neighbor_pair(&third));
|
||||
}
|
||||
}
|
||||
|
||||
struct PuzzleDashScopeSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user