Merge remote-tracking branch 'origin/master' into codex/publish-flow
Some checks failed
CI / verify (pull_request) Has been cancelled

# Conflicts:
#	docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md
#	docs/technical/README.md
#	jenkins/Jenkinsfile.database-export
#	jenkins/Jenkinsfile.database-import
This commit is contained in:
2026-05-03 03:46:39 +08:00
1041 changed files with 53757 additions and 49983 deletions

View File

@@ -23,7 +23,7 @@ module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-story = { path = "../module-story" }
platform-auth = { path = "../platform-auth" }

View File

@@ -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` 进入后端真相源。

View File

@@ -0,0 +1,18 @@
{
"provider": "ark",
"protocol": "responses",
"model": "deepseek-v3-2-251201",
"stream": false,
"attempt": 1,
"maxTokens": null,
"messages": [
{
"role": "system",
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
},
{
"role": "user",
"content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息\n世界雾港归航\n副标题失灯旧案\n世界概述守灯人与群岛议会围绕沉船旧案对峙。\n世界基调海雾悬疑\n玩家核心目标查清父亲沉船真相\n主要势力群岛议会、灯塔署\n核心冲突守灯塔的旧档案被人改写。\n开局归处旧灯塔归舍海雾边缘的守灯人旧居。\n关键场景旧灯塔雾中仍亮着错位灯火、沉船湾退潮后露出旧船骨\n本批角色\n- 议长甲 / 群岛议长\n身份遮掩者\n框架描述压住旧档的人\n预设好感-10\n关系切入口旧档案\n标签议会\n- 潮医乙 / 潮汐医师\n身份证人\n框架描述知道沉船伤痕\n预设好感20\n关系切入口救治记录\n标签证人\n出现场景沉船湾\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求\n- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
}
]
}

View File

@@ -0,0 +1 @@
{"error":{"message":"story dossier timeout"}}

View File

@@ -0,0 +1,18 @@
{
"provider": "ark",
"protocol": "responses",
"model": "deepseek-v3-2-251201",
"stream": false,
"attempt": 1,
"maxTokens": null,
"messages": [
{
"role": "system",
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
},
{
"role": "user",
"content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息\n世界雾港归航\n副标题失灯旧案\n世界概述守灯人与群岛议会围绕沉船旧案对峙。\n世界基调海雾悬疑\n玩家核心目标查清父亲沉船真相\n主要势力群岛议会、灯塔署\n核心冲突守灯塔的旧档案被人改写。\n开局归处旧灯塔归舍海雾边缘的守灯人旧居。\n关键场景旧灯塔雾中仍亮着错位灯火、沉船湾退潮后露出旧船骨\n本批角色\n- 雾商丙 / 雾港商人\n身份中间人\n框架描述贩卖航线的人\n预设好感5\n关系切入口伪造海图\n标签商人\n- 灯童丁 / 灯塔学徒\n身份目击者\n框架描述听见夜钟的人\n预设好感30\n关系切入口夜钟\n标签学徒\n出现场景旧灯塔\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求\n- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
}
]
}

View File

@@ -0,0 +1 @@
{"error":{"message":"story dossier timeout"}}

View File

@@ -500,6 +500,7 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
@@ -639,6 +640,129 @@ mod tests {
);
}
#[tokio::test]
async fn ai_task_mutation_routes_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for route in ai_task_mutation_route_cases() {
let (status, _) = post_ai_task_route(app.clone(), route.uri, None, route.body).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{}", route.uri);
}
}
#[tokio::test]
async fn ai_task_mutation_routes_return_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
for route in ai_task_mutation_route_cases() {
let (status, payload) =
post_ai_task_route(app.clone(), route.uri, Some(&token), route.body).await;
assert_eq!(status, StatusCode::BAD_GATEWAY, "{}", route.uri);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string()),
"{}",
route.uri
);
}
}
struct AiTaskRouteCase {
uri: &'static str,
body: Option<Value>,
}
fn ai_task_mutation_route_cases() -> Vec<AiTaskRouteCase> {
vec![
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/start",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/chunks",
body: Some(json!({
"stageKind": "request_model",
"sequence": 1,
"deltaText": "你听见远处的铃声。"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/complete",
body: Some(json!({
"textOutput": "你听见远处的铃声。",
"structuredPayloadJson": "{\"scene\":\"camp\"}",
"warningMessages": []
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/references",
body: Some(json!({
"referenceKind": "story_event",
"referenceId": "storyevt_001",
"label": "营地开场"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/complete",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/fail",
body: Some(json!({
"failureMessage": "模型返回内容为空"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/cancel",
body: None,
},
]
}
async fn post_ai_task_route(
app: Router,
uri: &str,
bearer_token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut request = Request::builder()
.method("POST")
.uri(uri)
.header("x-genarrative-response-envelope", "v1");
if let Some(token) = bearer_token {
request = request.header("authorization", format!("Bearer {token}"));
}
let body = if let Some(payload) = body {
request = request.header("content-type", "application/json");
Body::from(payload.to_string())
} else {
Body::empty()
};
let response = app
.oneshot(request.body(body).expect("request should build"))
.await
.expect("request should succeed");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body).expect("response body should be valid json")
};
(status, payload)
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -30,10 +30,11 @@ use crate::{
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
record_big_fish_gallery_like, record_big_fish_play, remix_big_fish_gallery_work,
stream_big_fish_message, submit_big_fish_message,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -65,12 +66,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,
@@ -88,11 +83,11 @@ use crate::{
phone_auth::{phone_login, send_phone_code},
profile_identity::update_profile_identity,
puzzle::{
advance_local_puzzle_next_level, advance_puzzle_next_level,
claim_puzzle_work_point_incentive, create_puzzle_agent_session, delete_puzzle_work,
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, record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
advance_puzzle_next_level, claim_puzzle_work_point_incentive, 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,
record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard,
swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop,
},
@@ -120,16 +115,14 @@ use crate::{
put_runtime_snapshot, resume_profile_save_archive,
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::{
begin_runtime_story_session, generate_runtime_story_continue,
generate_runtime_story_initial, get_runtime_story_state, resolve_runtime_story_action,
resolve_runtime_story_state,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
},
story_sessions::{begin_story_session, continue_story, get_story_session_state},
story_sessions::{
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},
};
@@ -212,38 +205,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(
@@ -713,6 +674,27 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}",
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}/input",
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions",
post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state(
@@ -918,13 +900,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(
@@ -939,6 +914,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/drag",
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/next-level",
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
@@ -974,13 +956,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(
@@ -988,13 +963,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(
@@ -1003,19 +971,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(
@@ -1023,13 +984,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(
@@ -1037,16 +991,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/browse-history",
get(get_runtime_browse_history)
@@ -1057,13 +1001,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
@@ -1071,13 +1008,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
@@ -1085,13 +1015,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
@@ -1099,13 +1022,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
@@ -1113,13 +1029,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
@@ -1127,13 +1036,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
@@ -1141,13 +1043,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
@@ -1155,20 +1050,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
@@ -1176,13 +1057,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
@@ -1197,48 +1071,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/sessions",
post(begin_runtime_story_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/resolve",
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/{session_id}",
get(get_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/actions/resolve",
post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/initial",
post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/continue",
post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
@@ -1253,6 +1085,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(
@@ -1260,6 +1099,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/runtime-projection",
get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state(
state.clone(),
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(
@@ -1542,6 +1395,132 @@ mod tests {
);
}
#[tokio::test]
async fn runtime_story_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for (method, uri) in [
("POST", "/api/runtime/story/sessions"),
("POST", "/api/runtime/story/state/resolve"),
("GET", "/api/runtime/story/state/runtime-main"),
("POST", "/api/runtime/story/actions/resolve"),
("POST", "/api/runtime/story/initial"),
("POST", "/api/runtime/story/continue"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("legacy runtime story request should build"),
)
.await
.expect("legacy runtime story request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response
.into_body()
.collect()
.await
.expect("legacy runtime story body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("legacy runtime story body should be json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["code"],
Value::String("NOT_FOUND".to_string())
);
assert_eq!(
payload["error"]["message"],
Value::String("资源不存在".to_string())
);
}
}
#[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"));

View File

@@ -19,11 +19,45 @@ pub(crate) async fn execute_billable_asset_operation<T, Fut>(
where
Fut: Future<Output = Result<T, AppError>>,
{
consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?;
execute_billable_asset_operation_with_cost(
state,
owner_user_id,
asset_kind,
asset_id,
ASSET_OPERATION_POINTS_COST,
operation,
)
.await
}
/// 生图等特殊操作可声明独立光点成本,避免修改全局资产操作默认价格。
pub(crate) async fn execute_billable_asset_operation_with_cost<T, Fut>(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
points_cost: u64,
operation: Fut,
) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
let points_consumed =
consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id, points_cost)
.await?;
match operation.await {
Ok(value) => Ok(value),
Err(error) => {
refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await;
if points_consumed {
refund_asset_operation_points(
state,
owner_user_id,
asset_kind,
asset_id,
points_cost,
)
.await;
}
Err(error)
}
}
@@ -35,22 +69,36 @@ async fn consume_asset_operation_points(
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
) -> Result<(), AppError> {
points_cost: u64,
) -> Result<bool, AppError> {
let ledger_id = format!(
"asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id
);
state
match state
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
ASSET_OPERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)
.await
.map(|_| ())
.map_err(map_asset_operation_wallet_error)
{
Ok(_) => Ok(true),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
// 中文注释:外部生图不应被 Maincloud 钱包短暂 503 阻断;此时跳过扣费,让业务链路继续,避免用户重复点击。
tracing::warn!(
owner_user_id,
asset_kind,
asset_id,
error = %error,
"资产操作光点预扣因 SpacetimeDB 连接不可用而降级跳过"
);
Ok(false)
}
Err(error) => Err(map_asset_operation_wallet_error(error)),
}
}
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
@@ -59,6 +107,7 @@ async fn refund_asset_operation_points(
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
points_cost: u64,
) {
let ledger_id = format!(
"asset_operation_refund:{}:{}:{}",
@@ -68,7 +117,7 @@ async fn refund_asset_operation_points(
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
ASSET_OPERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)
@@ -104,6 +153,45 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
}))
}
pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
error: &SpacetimeClientError,
) -> bool {
match error {
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout => true,
SpacetimeClientError::Build(message)
| SpacetimeClientError::Procedure(message)
| SpacetimeClientError::Runtime(message) => {
message.contains("503")
|| message.contains("Service Unavailable")
|| message.contains("Failed to connect")
|| message.contains("WebSocket")
|| message.contains("连接已断开")
|| message.contains("连接在返回结果前已断开")
}
}
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_operation_billing_skips_spacetime_connectivity_errors() {
assert_eq!(ASSET_OPERATION_POINTS_COST, 1);
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::ConnectDropped
));
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Runtime(
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
),
));
assert!(!should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Procedure("光点余额不足".to_string()),
));
}
}

View File

@@ -24,7 +24,7 @@ use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -144,7 +144,7 @@ pub async fn get_asset_history(
AssetHistoryListResponse {
assets: entries
.into_iter()
// 中文注释:Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回HTTP 门面必须兜底做账号隔离。
// 中文注释:旧 wasm 的历史素材 procedure 仍按类型返回HTTP 门面必须兜底做账号隔离。
.filter(|entry| {
is_asset_history_owned_by(
entry.owner_user_id.as_deref(),
@@ -402,17 +402,7 @@ fn map_confirm_asset_object_prepare_error(error: ConfirmAssetObjectPrepareError)
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(platform_oss::OssError::ObjectNotFound(_)) => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(_) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
ConfirmAssetObjectPrepareError::Oss(error) => map_oss_error(error, "aliyun-oss"),
}
}

View File

@@ -23,9 +23,10 @@ use shared_contracts::big_fish::{
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest,
SendBigFishMessageRequest,
BigFishRunResponse, BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse,
BigFishRuntimeSnapshotResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
RecordBigFishPlayRequest, SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
@@ -33,10 +34,11 @@ use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, BigFishMessageSubmitRecordInput,
BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput,
BigFishSessionRecord, BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord,
SpacetimeClientError,
BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput,
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, SpacetimeClientError,
};
use tokio::time::sleep;
@@ -59,6 +61,7 @@ use crate::{
auth::AuthenticatedAccessToken,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
@@ -253,6 +256,35 @@ pub async fn record_big_fish_play(
))
}
pub async fn start_big_fish_run(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let run = state
.spacetime_client()
.start_big_fish_run(BigFishRunStartRecordInput {
run_id: build_prefixed_uuid_id("big-fish-run-"),
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn record_big_fish_gallery_like(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -284,6 +316,73 @@ pub async fn record_big_fish_gallery_like(
))
}
pub async fn get_big_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn submit_big_fish_input(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SubmitBigFishInputRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
big_fish_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, &run_id, "runId")?;
if !payload.x.is_finite() || !payload.y.is_finite() {
return Err(big_fish_bad_request(&request_context, "input is invalid"));
}
let run = state
.spacetime_client()
.submit_big_fish_input(BigFishInputSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
x: payload.x,
y: payload.y,
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn remix_big_fish_gallery_work(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -862,6 +961,51 @@ fn map_big_fish_asset_coverage_response(
}
}
fn map_big_fish_run_response(run: BigFishRuntimeRunRecord) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
@@ -1642,19 +1786,7 @@ fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
}
fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn build_big_fish_level_part(level: Option<u32>) -> String {
@@ -1731,6 +1863,14 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("big_fish_runtime_run 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message) if message.contains("无权访问") => {
StatusCode::FORBIDDEN
}
SpacetimeClientError::Procedure(message)
if message.contains("不能为空")
|| message.contains("尚未编译")

View File

@@ -50,6 +50,7 @@ use crate::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::role_asset_studio::{
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
},
@@ -1639,7 +1640,9 @@ async fn load_workflow_cache(
expire_seconds: Some(60),
}) {
Ok(signed) => signed,
Err(platform_oss::OssError::ObjectNotFound(_)) => return Ok(None),
Err(error) if error.kind() == platform_oss::OssErrorKind::ObjectNotFound => {
return Ok(None);
}
Err(error) => return Err(map_character_animation_oss_error(error)),
};
let response = reqwest::Client::new()
@@ -3303,19 +3306,7 @@ fn map_character_animation_spacetime_error(error: SpacetimeClientError) -> AppEr
}
fn map_character_animation_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn character_animation_error_response(

View File

@@ -1,7 +1,4 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use std::collections::BTreeMap;
use axum::{
Json,
@@ -38,16 +35,20 @@ use crate::{
build_fallback_moderation_safe_character_visual_prompt,
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings,
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
use tokio::time::sleep;
const CHARACTER_VISUAL_MODEL: &str = "wan2.7-image-pro";
const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL;
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500;
const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2;
#[derive(Clone, Debug)]
@@ -78,7 +79,7 @@ pub async fn generate_character_visual(
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let model = resolve_character_visual_model(payload.image_model.as_str());
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
let candidate_count = payload.candidate_count.clamp(1, 4);
@@ -93,8 +94,8 @@ pub async fn generate_character_visual(
.map_err(|error| character_visual_error_response(&request_context, error))?;
let result = async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(&state)?;
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()
@@ -120,6 +121,7 @@ pub async fn generate_character_visual(
"sourceMode": payload.source_mode,
"size": size,
"referenceImageCount": payload.reference_image_data_urls.len(),
"provider": "apimart",
})
.to_string(),
),
@@ -191,7 +193,7 @@ pub async fn generate_character_visual(
),
structured_payload_json: Some(
json!({
"provider": "dashscope",
"provider": "apimart",
"taskId": generated.task_id,
"model": model,
"imageCount": generated.images.len(),
@@ -306,7 +308,7 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let model = resolve_character_visual_model(payload.image_model.as_str());
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
create_visual_task(
state,
@@ -316,8 +318,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&model,
&prompt,
)?;
let settings = require_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()
.start_task(task_id.as_str(), current_utc_micros())
@@ -767,47 +769,17 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob
}
}
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})),
fn resolve_character_visual_model(value: &str) -> String {
// 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-proRPG 主图当前统一归一到 gpt-image-2
let trimmed = value.trim();
if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL {
tracing::warn!(
requested_model = trimmed,
effective_model = CHARACTER_VISUAL_MODEL,
"角色主形象图片模型已归一到 gpt-image-2"
);
}
let api_key = state
.config
.dashscope_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(DashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "dashscope",
"message": format!("构造 DashScope HTTP 客户端失败:{error}"),
}))
})
CHARACTER_VISUAL_MODEL.to_string()
}
async fn resolve_reference_image_as_data_url(
@@ -867,9 +839,7 @@ async fn resolve_reference_image_as_data_url(
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象参考图失败:{error}"))
})?;
.map_err(|error| map_image_request_error(format!("读取角色主形象参考图失败:{error}")))?;
let status = response.status();
let content_type = response
.headers()
@@ -878,7 +848,7 @@ async fn resolve_reference_image_as_data_url(
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}"))
map_image_request_error(format!("读取角色主形象参考图内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
@@ -910,7 +880,7 @@ async fn resolve_reference_image_as_data_url(
async fn create_character_visual_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
settings: &OpenAiImageSettings,
model: &str,
prompt: &str,
fallback_prompt: &str,
@@ -921,12 +891,13 @@ async fn create_character_visual_generation(
let mut active_prompt = prompt;
let mut moderation_fallback_applied = false;
let mut last_moderation_error = String::new();
let model = resolve_character_visual_model(model);
for attempt_index in 0..CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS {
match create_character_visual_generation_once(
http_client,
settings,
model,
model.as_str(),
active_prompt,
size,
candidate_count,
@@ -943,7 +914,7 @@ async fn create_character_visual_generation(
if attempt_index == 0
&& !fallback_prompt.trim().is_empty()
&& fallback_prompt.trim() != prompt.trim()
&& is_dashscope_moderation_error(&error) =>
&& is_image_moderation_error(&error) =>
{
last_moderation_error = error.body_text();
active_prompt = fallback_prompt;
@@ -953,7 +924,7 @@ async fn create_character_visual_generation(
}
}
Err(map_dashscope_request_error(format!(
Err(map_image_request_error(format!(
"角色主形象安全兜底重试未返回结果:{}",
last_moderation_error.if_empty_then("上游内容审核仍未通过。")
)))
@@ -961,183 +932,44 @@ async fn create_character_visual_generation(
async fn create_character_visual_generation_once(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
settings: &OpenAiImageSettings,
_model: &str,
prompt: &str,
size: &str,
candidate_count: u32,
reference_images: &[String],
) -> Result<GeneratedCharacterVisuals, AppError> {
let mut content = vec![json!({ "text": prompt })];
for image in reference_images {
content.push(json!({ "image": image }));
}
let response = http_client
.post(format!(
"{}/services/aigc/image-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&json!({
"model": model,
"input": {
"messages": [
{
"role": "user",
"content": content,
}
],
},
"parameters": {
"n": candidate_count,
"size": size,
"negative_prompt": build_character_visual_negative_prompt(),
"prompt_extend": true,
"watermark": false,
},
}))
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("创建角色主形象任务失败:{error}")))?;
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象任务响应失败:{error}"))
})?;
if !response_status.is_success() {
return Err(map_dashscope_upstream_error(
response_text.as_str(),
"创建角色主形象任务失败。",
));
}
let response_json = parse_json_payload(response_text.as_str(), "创建角色主形象任务失败。")?;
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象任务未返回 task_id",
}))
})?;
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_dashscope_request_error(format!("查询角色主形象任务失败:{error}"))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象任务状态失败:{error}"))
})?;
if !poll_status.is_success() {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
"查询角色主形象任务失败。",
));
}
let poll_json = parse_json_payload(poll_text.as_str(), "查询角色主形象任务失败。")?;
let task_status = find_first_string_by_key(&poll_json.payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_urls = extract_image_urls(&poll_json.payload);
if image_urls.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象生成成功,但没有返回可下载图片。",
})),
);
}
let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls {
images.push(
download_generated_image(
http_client,
image_url.as_str(),
"下载角色主形象候选图失败。",
)
.await?,
);
}
return Ok(GeneratedCharacterVisuals {
task_id,
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images,
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN" | "CANCELED") {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
"角色主形象任务执行失败。",
));
}
sleep(Duration::from_millis(
CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS,
))
.await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象任务执行超时,请稍后重试。",
})),
let generated = create_openai_image_generation(
http_client,
settings,
prompt,
Some(build_character_visual_negative_prompt().as_str()),
size,
candidate_count,
reference_images,
"角色主形象生成失败",
)
.await?;
Ok(GeneratedCharacterVisuals {
task_id: generated.task_id,
actual_prompt: generated.actual_prompt,
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images: generated
.images
.into_iter()
.map(downloaded_openai_to_character_visual_image)
.collect(),
})
}
async fn download_generated_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
) -> Result<DownloadedGeneratedImage, AppError> {
let response = http_client
.get(image_url)
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
let mut bytes = body.to_vec();
let mut extension = mime_to_extension(normalized_mime_type.as_str()).to_string();
let mut mime_type = normalized_mime_type;
fn downloaded_openai_to_character_visual_image(
image: DownloadedOpenAiImage,
) -> DownloadedGeneratedImage {
let mut bytes = image.bytes;
let mut extension = image.extension;
let mut mime_type = image.mime_type;
if mime_type == "image/png"
&& let Some(optimized) = try_apply_background_alpha_to_png(bytes.as_slice())
@@ -1147,11 +979,11 @@ async fn download_generated_image(
mime_type = "image/png".to_string();
}
Ok(DownloadedGeneratedImage {
DownloadedGeneratedImage {
bytes,
mime_type,
extension,
})
}
}
/// 统一的 PNG 透明背景后处理入口。
@@ -1335,94 +1167,35 @@ fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError
}
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn parse_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
if let Some(message) = parsed
.pointer("/error/message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(message) = parsed
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(code) = parsed
.pointer("/error/code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
if let Some(code) = parsed
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
raw_text.trim().to_string()
}
fn map_dashscope_request_error(message: String) -> AppError {
fn map_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"provider": "apimart",
"message": message,
}))
}
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
#[cfg(test)]
fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
let message = match raw_text.trim() {
"" => fallback_message.to_string(),
value => value.to_string(),
};
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_api_error_message(raw_text, fallback_message),
"provider": "apimart",
"message": message,
"raw": raw_text.trim(),
}))
}
fn is_dashscope_moderation_error(error: &AppError) -> bool {
#[cfg(test)]
fn is_image_test_moderation_error(error: &AppError) -> bool {
is_image_moderation_error(error)
}
fn is_image_moderation_error(error: &AppError) -> bool {
let text = error.body_text();
let normalized = text.to_ascii_lowercase();
normalized.contains("ipinfringementsuspect")
@@ -1435,77 +1208,6 @@ fn is_dashscope_moderation_error(error: &AppError) -> bool {
|| text.contains("知识产权")
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key
&& let Some(text) = nested_value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
continue;
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "image", &mut urls);
collect_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let body = value.trim().strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
@@ -2023,12 +1725,6 @@ impl EmptyFallback for String {
}
}
struct DashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct GeneratedCharacterVisuals {
task_id: String,
actual_prompt: Option<String>,
@@ -2043,10 +1739,6 @@ struct DownloadedGeneratedImage {
extension: String,
}
struct ParsedJsonPayload {
payload: Value,
}
struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
@@ -2077,13 +1769,22 @@ mod tests {
}
#[test]
fn dashscope_ip_infringement_error_uses_moderation_fallback() {
let error = map_dashscope_upstream_error(
fn legacy_character_visual_model_normalizes_to_gpt_image_2() {
assert_eq!(
resolve_character_visual_model("wan2.7-image-pro"),
"gpt-image-2"
);
assert_eq!(resolve_character_visual_model(""), "gpt-image-2");
}
#[test]
fn image_ip_infringement_error_uses_moderation_fallback() {
let error = map_image_upstream_error(
r#"{"request_id":"a18fb05d","output":{"task_id":"cb768c95","task_status":"FAILED","code":"IPInfringementSuspect","message":"Input data is suspected of being involved in IP infringement."}}"#,
"角色主形象任务执行失败。",
);
assert!(is_dashscope_moderation_error(&error));
assert!(is_image_test_moderation_error(&error));
}
#[test]

View File

@@ -90,6 +90,9 @@ pub struct AppConfig {
pub dashscope_reference_image_model: String,
pub dashscope_cover_image_model: String,
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
pub draft_asset_generation_max_concurrent_requests: usize,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
@@ -182,6 +185,9 @@ impl Default for AppConfig {
dashscope_reference_image_model: "qwen-image-2.0".to_string(),
dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: "https://api.apimart.ai/v1".to_string(),
apimart_api_key: None,
apimart_image_request_timeout_ms: 180_000,
draft_asset_generation_max_concurrent_requests: 4,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
@@ -415,24 +421,19 @@ impl AppConfig {
config.oss_success_action_status = oss_success_action_status;
}
if let Some(spacetime_server_url) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_SERVER_URL",
"GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL",
]) {
if let Some(spacetime_server_url) =
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"])
{
config.spacetime_server_url = spacetime_server_url;
}
if let Some(spacetime_database) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_DATABASE",
"GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE",
]) {
if let Some(spacetime_database) =
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"])
{
config.spacetime_database = spacetime_database;
}
config.spacetime_token = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_TOKEN",
"GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN",
]);
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
if let Some(spacetime_pool_size) =
read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"])
{
@@ -530,6 +531,18 @@ impl AppConfig {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) {
config.apimart_base_url = apimart_base_url;
}
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
}
if let Some(max_concurrent_requests) = read_first_usize_env(&[
"GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
"DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",

View File

@@ -1,4 +1,4 @@
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use platform_llm::{LlmClient, LlmError, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde_json::Value as JsonValue;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
@@ -33,10 +33,63 @@ where
{
let llm_client =
llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?;
let user_prompt = user_prompt.into();
let turn_output = match request_stream_creation_agent_json_turn(
llm_client,
system_prompt.clone(),
user_prompt.clone(),
enable_web_search,
&mut on_reply_update,
)
.await
{
Ok(turn_output) => Ok(turn_output),
Err(CreationAgentJsonTurnFailure::Stream(error))
if enable_web_search && is_web_search_tool_unavailable(&error) =>
{
tracing::warn!(
error = %error,
"创作 Agent 联网搜索插件不可用,自动降级为无联网搜索重试"
);
request_stream_creation_agent_json_turn(
llm_client,
system_prompt,
user_prompt,
false,
&mut on_reply_update,
)
.await
}
Err(error) => Err(error),
};
turn_output.map_err(|error| match error {
CreationAgentJsonTurnFailure::Stream(_) => {
build_error(messages.generation_failed.to_string())
}
CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()),
})
}
enum CreationAgentJsonTurnFailure {
Stream(LlmError),
Parse,
}
async fn request_stream_creation_agent_json_turn<F>(
llm_client: &LlmClient,
system_prompt: String,
user_prompt: String,
enable_web_search: bool,
on_reply_update: &mut F,
) -> Result<CreationAgentJsonTurnOutput, CreationAgentJsonTurnFailure>
where
F: FnMut(&str),
{
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search),
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
@@ -48,9 +101,9 @@ where
},
)
.await
.map_err(|_| build_error(messages.generation_failed.to_string()))?;
.map_err(CreationAgentJsonTurnFailure::Stream)?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| build_error(messages.parse_failed.to_string()))?;
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
let reply_text = read_reply_text(&parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
@@ -61,6 +114,13 @@ where
Ok(CreationAgentJsonTurnOutput { parsed })
}
fn is_web_search_tool_unavailable(error: &LlmError) -> bool {
let message = error.to_string();
message.contains("ToolNotOpen")
|| message.contains("has not activated web search")
|| message.contains("未开通")
}
fn build_creation_agent_llm_request(
system_prompt: String,
user_prompt: String,
@@ -168,11 +228,23 @@ fn read_reply_text(parsed: &JsonValue) -> Option<String> {
#[cfg(test)]
mod tests {
use std::{
fs,
io::{Read, Write},
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
};
use platform_llm::{LlmConfig, LlmProvider};
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
use super::{
build_creation_agent_llm_request, extract_reply_text_from_partial_json,
parse_json_response_text,
CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request,
extract_reply_text_from_partial_json, is_web_search_tool_unavailable,
parse_json_response_text, stream_creation_agent_json_turn,
};
#[test]
@@ -202,4 +274,214 @@ mod tests {
assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses);
assert_eq!(request.messages.len(), 2);
}
#[test]
fn detects_upstream_web_search_tool_unavailable_error() {
let error = platform_llm::LlmError::Upstream {
status_code: 502,
message: "Your account has not activated web search. code=ToolNotOpen".to_string(),
};
assert!(is_web_search_tool_unavailable(&error));
}
#[tokio::test]
async fn stream_turn_retries_without_web_search_when_tool_is_unavailable() {
let log_dir = std::env::temp_dir().join(format!(
"api-server-creation-agent-raw-log-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let success_json = serde_json::json!({
"replyText": "好,我们先把玩具王国定住。",
"progressPercent": 12,
"nextAnchorContent": {
"worldPromise": "玩具王国初步方向",
"playerFantasy": null,
"themeBoundary": null,
"playerEntryPoint": null,
"coreConflict": null,
"keyRelationships": null,
"hiddenLines": null,
"iconicElements": null
}
})
.to_string();
let server = spawn_capturing_mock_server(vec![
MockResponse {
body: concat!(
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
},
MockResponse {
body: format!(
"data: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": success_json
})
) + "data: {\"type\":\"response.completed\"}\n\n",
},
]);
let config = LlmConfig::new(
LlmProvider::Ark,
server.base_url,
"test-key".to_string(),
"test-model".to_string(),
30_000,
0,
1,
)
.expect("LLM config should build");
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
let mut visible_replies = Vec::new();
let output = stream_creation_agent_json_turn(
Some(&llm_client),
"系统提示".to_string(),
"用户提示",
true,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "模型不可用",
generation_failed: "生成失败",
parse_failed: "解析失败",
},
|text| visible_replies.push(text.to_string()),
|message| message,
)
.await
.expect("web search fallback should succeed");
assert_eq!(
output.parsed["replyText"].as_str(),
Some("好,我们先把玩具王国定住。")
);
assert_eq!(visible_replies, vec!["好,我们先把玩具王国定住。"]);
let requests = server.requests.lock().expect("requests lock").clone();
assert_eq!(requests.len(), 2);
assert!(requests[0].contains("\"tools\""));
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
}
struct MockResponse {
body: String,
}
struct CapturingMockServer {
base_url: String,
requests: Arc<Mutex<Vec<String>>>,
}
fn spawn_capturing_mock_server(responses: Vec<MockResponse>) -> CapturingMockServer {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_for_thread = Arc::clone(&requests);
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
requests_for_thread
.lock()
.expect("requests lock")
.push(request_text);
write_sse_response(&mut stream, response);
}
});
CapturingMockServer {
base_url: format!("http://{address}"),
requests,
}
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be set");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8_lossy(buffer.as_slice()).to_string()
}
fn write_sse_response(stream: &mut std::net::TcpStream, response: MockResponse) {
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.body.len(),
response.body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}

View File

@@ -185,17 +185,22 @@ pub async fn generate_custom_world_profile(
);
// 中文注释profile 生成需要外部 LLM必须留在 Axum/api-serverSpacetimeDB reducer 只接收确定结果。
let result = generate_custom_world_foundation_draft(llm_client, &session, |_| {})
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-profile",
"message": message,
})),
)
})?;
let result = generate_custom_world_foundation_draft(
llm_client,
&session,
state.config.creation_agent_llm_web_search_enabled,
|_| {},
)
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-profile",
"message": message,
})),
)
})?;
let mut profile =
serde_json::from_str::<Value>(&result.draft_profile_json).map_err(|error| {
custom_world_error_response(
@@ -1775,26 +1780,31 @@ fn spawn_custom_world_draft_foundation_job(
Err(error) => Err(format!("已保存底稿序列化失败:{error}")),
}
} else {
generate_custom_world_foundation_draft(&llm_client, &session, move |progress| {
let progress_state = progress_state.clone();
let session_id = progress_session_id.clone();
let owner_user_id = progress_owner_user_id.clone();
let operation_id = progress_operation_id.clone();
tokio::spawn(async move {
let _ = upsert_custom_world_draft_foundation_progress(
&progress_state,
&session_id,
&owner_user_id,
&operation_id,
"running",
progress.phase_label.as_str(),
progress.phase_detail.as_str(),
progress.progress,
None,
)
.await;
});
})
generate_custom_world_foundation_draft(
&llm_client,
&session,
state.config.creation_agent_llm_web_search_enabled,
move |progress| {
let progress_state = progress_state.clone();
let session_id = progress_session_id.clone();
let owner_user_id = progress_owner_user_id.clone();
let operation_id = progress_operation_id.clone();
tokio::spawn(async move {
let _ = upsert_custom_world_draft_foundation_progress(
&progress_state,
&session_id,
&owner_user_id,
&operation_id,
"running",
progress.phase_label.as_str(),
progress.phase_detail.as_str(),
progress.progress,
None,
)
.await;
});
},
)
.await
};

View File

@@ -36,6 +36,11 @@ use crate::{
},
http_error::AppError,
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
prompt::scene_background::{
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
@@ -311,6 +316,8 @@ struct DownloadedRemoteImage {
bytes: Vec<u8>,
}
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
struct CoverPromptContext {
opening_act_title: String,
opening_act_summary: String,
@@ -442,6 +449,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_openai_image_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,
@@ -449,8 +458,8 @@ pub async fn generate_custom_world_scene_image(
"scene_image",
asset_id.as_str(),
async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(&state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
Some(
@@ -465,46 +474,32 @@ pub async fn generate_custom_world_scene_image(
} else {
None
};
let generated = if let Some(reference_image) = reference_image.as_deref() {
create_reference_image_generation(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
normalized.prompt.as_str(),
normalized.size.as_str(),
&[reference_image.to_string()],
Some(normalized.negative_prompt.as_str()),
"创建参考图场景编辑任务失败",
"参考图场景编辑未返回图片地址",
"scene-edit",
)
.await
} else {
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await
}?;
let scene_model = if reference_image.is_some() {
state.config.dashscope_reference_image_model.clone()
} else {
state.config.dashscope_scene_image_model.clone()
};
let downloaded = download_remote_image(
let reference_images = reference_image
.as_ref()
.map(|value| vec![value.clone()])
.unwrap_or_default();
let generated = create_openai_image_generation(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
&settings,
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
1,
&reference_images,
"场景图片生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
@@ -536,7 +531,7 @@ pub async fn generate_custom_world_scene_image(
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(scene_model),
model: Some(RPG_SCENE_IMAGE_MODEL.to_string()),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(normalized.prompt),
@@ -585,27 +580,30 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
}),
};
let normalized = normalize_scene_image_request(payload)?;
let settings = require_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
let generated = create_text_to_image_generation(
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
1,
&[],
"场景图片生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
@@ -630,7 +628,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
let model = state.config.dashscope_scene_image_model.clone();
let model = RPG_SCENE_IMAGE_MODEL.to_string();
let prompt = normalized.prompt.clone();
let asset = persist_custom_world_asset(
state,
@@ -703,6 +701,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,
@@ -1017,19 +1017,7 @@ fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppErr
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
@@ -1641,6 +1629,14 @@ async fn download_remote_image(
})
}
fn downloaded_openai_to_custom_world_image(image: DownloadedOpenAiImage) -> DownloadedRemoteImage {
DownloadedRemoteImage {
extension: image.extension,
mime_type: image.mime_type,
bytes: image.bytes,
}
}
fn optimize_uploaded_cover_image(
parsed_data_url: &ParsedImageDataUrl,
crop_rect: &CustomWorldCoverCropRect,
@@ -2458,10 +2454,22 @@ mod tests {
serde_json::from_slice(&body).expect("body should be valid json")
}
fn build_state_without_apimart_key() -> AppState {
let mut config = AppConfig::default();
config.apimart_api_key = None;
AppState::new(config).expect("state should build")
}
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");
async fn scene_image_returns_service_unavailable_when_apimart_missing() {
let state = build_state_without_apimart_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(
@@ -2483,7 +2491,7 @@ mod tests {
})),
)
.await
.expect_err("missing dashscope should fail");
.expect_err("missing apimart should fail");
let payload = read_error_response(response).await;
assert_eq!(
@@ -2492,7 +2500,7 @@ mod tests {
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("dashscope".to_string())
Value::String("apimart".to_string())
);
}
@@ -2530,15 +2538,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,
"破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"
@@ -2601,10 +2621,15 @@ mod tests {
assert_eq!(normalized.prompt, manual_prompt);
}
#[test]
fn scene_image_response_model_is_gpt_image_2() {
assert_eq!(RPG_SCENE_IMAGE_MODEL, "gpt-image-2");
}
#[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(
@@ -2640,7 +2665,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(

View File

@@ -10,6 +10,7 @@ use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
use tracing::warn;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
@@ -35,6 +36,7 @@ pub struct CustomWorldFoundationDraftProgress {
pub async fn generate_custom_world_foundation_draft(
llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord,
enable_web_search: bool,
mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send,
) -> Result<CustomWorldFoundationDraftResult, String> {
let setting_text = build_foundation_generation_seed_text(session);
@@ -51,6 +53,7 @@ pub async fn generate_custom_world_foundation_draft(
|response_text| build_custom_world_framework_json_repair_prompt(response_text),
"agent-foundation-framework-json-repair",
"世界框架阶段没有返回有效内容。",
enable_web_search,
)
.await?;
normalize_framework_shape(&mut framework, setting_text.as_str());
@@ -61,6 +64,7 @@ pub async fn generate_custom_world_foundation_draft(
"playable",
FOUNDATION_DRAFT_PLAYABLE_COUNT,
(16, 30),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -72,6 +76,7 @@ pub async fn generate_custom_world_foundation_draft(
"story",
FOUNDATION_DRAFT_STORY_COUNT,
(30, 44),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -82,6 +87,7 @@ pub async fn generate_custom_world_foundation_draft(
&framework,
FOUNDATION_DRAFT_LANDMARK_COUNT,
(44, 66),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -94,6 +100,7 @@ pub async fn generate_custom_world_foundation_draft(
&playable_outlines,
"narrative",
(66, 76),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -104,6 +111,7 @@ pub async fn generate_custom_world_foundation_draft(
&playable_narrative,
"dossier",
(76, 84),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -114,6 +122,7 @@ pub async fn generate_custom_world_foundation_draft(
&story_outlines,
"narrative",
(84, 92),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -124,6 +133,7 @@ pub async fn generate_custom_world_foundation_draft(
&story_narrative,
"dossier",
(92, 96),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -155,7 +165,8 @@ const FOUNDATION_DRAFT_PLAYABLE_COUNT: usize = 1;
const FOUNDATION_DRAFT_STORY_COUNT: usize = 8;
const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2;
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2;
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
// 中文注释:单个场景已经包含三幕事件、三幕背景图 prompt 和 NPC 分配;按 1 个场景拆批,避免 landmark seed 大 JSON 在 Responses 请求中超时。
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 1;
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2;
const WORLD_ATTRIBUTE_SLOT_IDS: [&str; 6] =
["axis_a", "axis_b", "axis_c", "axis_d", "axis_e", "axis_f"];
@@ -171,22 +182,19 @@ async fn request_foundation_json_stage<F>(
repair_prompt_builder: F,
repair_debug_label: &str,
empty_response_message: &str,
enable_web_search: bool,
) -> Result<JsonValue, String>
where
F: Fn(&str) -> String,
{
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(true),
)
.await
.map_err(|error| format!("{debug_label} LLM 请求失败:{error}"))?;
let response = request_foundation_text_with_optional_search_fallback(
llm_client,
FOUNDATION_JSON_ONLY_SYSTEM_PROMPT,
user_prompt.as_str(),
debug_label,
enable_web_search,
)
.await?;
let text = response.content.trim();
if text.is_empty() {
return Err(empty_response_message.to_string());
@@ -211,12 +219,69 @@ where
}
}
async fn request_foundation_text_with_optional_search_fallback(
llm_client: &LlmClient,
system_prompt: &str,
user_prompt: &str,
debug_label: &str,
enable_web_search: bool,
) -> Result<platform_llm::LlmTextResponse, String> {
match request_foundation_text(llm_client, system_prompt, user_prompt, enable_web_search).await {
Ok(response) => Ok(response),
Err(error) if enable_web_search && should_retry_foundation_without_web_search(&error) => {
warn!(
error = %error,
debug_label,
"foundation draft 联网搜索增强不可用或超时,自动降级为无联网搜索重试"
);
request_foundation_text(llm_client, system_prompt, user_prompt, false)
.await
.map_err(|retry_error| format!("{debug_label} LLM 请求失败:{retry_error}"))
}
Err(error) => Err(format!("{debug_label} LLM 请求失败:{error}")),
}
}
async fn request_foundation_text(
llm_client: &LlmClient,
system_prompt: &str,
user_prompt: &str,
enable_web_search: bool,
) -> Result<platform_llm::LlmTextResponse, platform_llm::LlmError> {
llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search),
)
.await
}
fn should_retry_foundation_without_web_search(error: &platform_llm::LlmError) -> bool {
match error {
platform_llm::LlmError::Timeout { .. } | platform_llm::LlmError::Connectivity { .. } => {
true
}
platform_llm::LlmError::Upstream { message, .. } => {
message.contains("ToolNotOpen")
|| message.contains("has not activated web search")
|| message.contains("未开通")
}
_ => false,
}
}
async fn generate_foundation_role_outline_entries(
llm_client: &LlmClient,
framework: &JsonValue,
role_type: &str,
total_count: usize,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
@@ -275,6 +340,7 @@ async fn generate_foundation_role_outline_entries(
)
.as_str(),
"角色框架名单阶段没有返回有效内容。",
enable_web_search,
)
.await?;
let key = role_key(role_type);
@@ -305,6 +371,7 @@ async fn generate_foundation_landmark_seed_entries(
framework: &JsonValue,
total_count: usize,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
@@ -352,6 +419,7 @@ async fn generate_foundation_landmark_seed_entries(
)
.as_str(),
"地点框架名单阶段没有返回有效内容。",
enable_web_search,
)
.await?;
merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count));
@@ -486,6 +554,7 @@ async fn expand_foundation_role_entries(
base_entries: &[JsonValue],
stage: &str,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
@@ -518,7 +587,7 @@ async fn expand_foundation_role_entries(
.as_str(),
to_batch_progress(progress_range, processed_count, base_entries.len()),
);
let raw = request_foundation_json_stage(
let raw_result = request_foundation_json_stage(
llm_client,
build_custom_world_role_batch_prompt(framework, role_type, batch, stage),
format!(
@@ -540,9 +609,22 @@ async fn expand_foundation_role_entries(
)
.as_str(),
"角色档案补全阶段没有返回有效内容。",
enable_web_search,
)
.await?;
merged_entries.extend(array_field(&raw, role_key(role_type)));
.await;
match raw_result {
Ok(raw) => merged_entries.extend(array_field(&raw, role_key(role_type))),
Err(error) if stage == "dossier" => {
warn!(
error = %error,
role_type,
batch_index = batch_index + 1,
"foundation draft 角色养成档案 LLM 补全失败,使用本地结构化兜底"
);
merged_entries.extend(build_fallback_role_dossier_entries(batch));
}
Err(error) => return Err(error),
}
processed_count = processed_count
.saturating_add(batch.len())
.min(base_entries.len());
@@ -566,6 +648,103 @@ async fn expand_foundation_role_entries(
Ok(merge_entries_by_name(base_entries, &merged_entries))
}
fn build_fallback_role_dossier_entries(entries: &[JsonValue]) -> Vec<JsonValue> {
entries
.iter()
.enumerate()
.map(|(index, entry)| build_fallback_role_dossier_entry(entry, index))
.collect()
}
fn build_fallback_role_dossier_entry(entry: &JsonValue, index: usize) -> JsonValue {
let name = json_text(entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let title = json_text(entry, "title").unwrap_or_default();
let role = json_text(entry, "role").unwrap_or_else(|| "关键角色".to_string());
let description = json_text(entry, "description").unwrap_or_else(|| role.clone());
let backstory = json_text(entry, "backstory").unwrap_or_else(|| description.clone());
let motivation = json_text(entry, "motivation").unwrap_or_else(|| description.clone());
let tag = json_string_array(entry, "tags")
.and_then(|items| items.first().cloned())
.unwrap_or_else(|| role.clone());
let item_prefix = if title.trim().is_empty() {
name.clone()
} else {
title.clone()
};
json!({
"name": name.clone(),
"backstoryReveal": {
"publicSummary": format!("{name}的公开档案围绕“{description}”展开。"),
"chapters": [
{
"affinityRequired": 15,
"title": "初识",
"summary": format!("{name}以{role}身份进入玩家视野,留下与“{tag}”有关的第一条线索。"),
},
{
"affinityRequired": 30,
"title": "试探",
"summary": format!("{name}开始透露“{backstory}”背后的压力,但仍保留关键隐情。"),
},
{
"affinityRequired": 60,
"title": "共同行动",
"summary": format!("{name}围绕“{motivation}”与玩家形成更明确的合作或冲突。"),
},
{
"affinityRequired": 90,
"title": "真相",
"summary": format!("{name}交出与“{description}”相关的核心选择,关系走向定型。"),
},
],
},
"skills": [
{
"name": format!("{tag}洞察"),
"summary": format!("围绕“{description}”判断局势与隐藏线索。"),
"style": "侦查",
},
{
"name": format!("{item_prefix}协助"),
"summary": format!("以{role}身份为玩家提供行动支援。"),
"style": "支援",
},
{
"name": "临场应变",
"summary": format!("在压力升级时根据“{motivation}”调整行动。"),
"style": "应变",
},
],
"initialItems": [
{
"name": format!("{item_prefix}记录"),
"category": "道具",
"quantity": 1,
"rarity": "common",
"description": format!("记录{name}与“{description}”相关的线索。"),
"tags": [tag.clone()],
},
{
"name": format!("{tag}信物"),
"category": "道具",
"quantity": 1,
"rarity": "common",
"description": format!("能证明{name}身份和立场的随身物。"),
"tags": [role.clone()],
},
{
"name": "备用补给",
"category": "消耗品",
"quantity": 1,
"rarity": "common",
"description": format!("{name}在关键行动前准备的基础补给。"),
"tags": ["补给"],
},
],
})
}
fn emit_foundation_draft_progress(
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
phase_label: &str,
@@ -2047,7 +2226,7 @@ mod tests {
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::Duration as StdDuration,
time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
};
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
@@ -2383,6 +2562,80 @@ mod tests {
);
}
#[test]
fn foundation_search_fallback_handles_tool_unavailable_and_timeout() {
let tool_error = platform_llm::LlmError::Upstream {
status_code: 404,
message: "Your account has not activated web search. code=ToolNotOpen".to_string(),
};
let timeout_error = platform_llm::LlmError::Timeout { attempts: 2 };
assert!(should_retry_foundation_without_web_search(&tool_error));
assert!(should_retry_foundation_without_web_search(&timeout_error));
assert!(!should_retry_foundation_without_web_search(
&platform_llm::LlmError::EmptyResponse
));
}
#[tokio::test]
async fn foundation_json_stage_retries_without_web_search_when_tool_unavailable() {
let log_dir = std::env::temp_dir().join(format!(
"api-server-foundation-raw-log-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
vec![
MockHttpResponse {
status_code: 404,
body: r#"{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search."}}"#.to_string(),
},
MockHttpResponse {
status_code: 200,
body: llm_response(r#"{"name":"无搜索底稿"}"#),
},
],
);
let llm_client = build_test_llm_client(server_url);
let parsed = request_foundation_json_stage(
&llm_client,
"请生成 JSON".to_string(),
"agent-foundation-test",
|_| "修复 JSON".to_string(),
"agent-foundation-test-json-repair",
"空响应",
true,
)
.await
.expect("web search fallback should succeed");
assert_eq!(parsed.get("name"), Some(&json!("无搜索底稿")));
let requests = request_capture
.lock()
.expect("request capture should lock")
.clone();
assert_eq!(requests.len(), 2);
assert!(requests[0].contains("\"tools\""));
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
std::fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
}
#[tokio::test]
async fn role_outline_missing_asset_fields_are_filled_locally_before_details() {
let request_capture = Arc::new(Mutex::new(Vec::<String>::new()));
@@ -2427,6 +2680,60 @@ mod tests {
);
}
#[test]
fn role_dossier_fallback_keeps_names_and_required_fields() {
let entries = vec![json!({
"name": "埃琳娜·沃克",
"title": "深渊学者",
"role": "深海科研联盟成员",
"description": "执着研究深海生物发光现象的年轻科学家",
"backstory": "她长期追踪发光生物与古代遗迹之间的联系。",
"motivation": "用氧气补给换取玩家的目击信息",
"tags": ["科研人员", "偏执学者"]
})];
let fallback = build_fallback_role_dossier_entries(&entries);
let first = fallback.first().expect("fallback entry should exist");
assert_eq!(first.get("name"), Some(&json!("埃琳娜·沃克")));
assert_eq!(
first
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(4)
);
assert_eq!(
first
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(|chapters| {
chapters
.iter()
.filter_map(|chapter| chapter.get("affinityRequired"))
.cloned()
.collect::<Vec<_>>()
}),
Some(vec![json!(15), json!(30), json!(60), json!(90)])
);
assert_eq!(
first
.get("skills")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
assert_eq!(
first
.get("initialItems")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
}
#[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
@@ -2452,7 +2759,10 @@ mod tests {
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
@@ -2471,7 +2781,7 @@ mod tests {
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session, |_| {})
let result = generate_custom_world_foundation_draft(&llm_client, &session, false, |_| {})
.await
.expect("draft generation should succeed");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
@@ -2481,15 +2791,24 @@ mod tests {
.expect("request capture should lock")
.clone();
let request_text = captured_requests.join("\n---request---\n");
let landmark_seed_requests = captured_requests
.iter()
.filter(|request| request.contains("场景框架名单"))
.collect::<Vec<_>>();
assert!(captured_requests.len() >= 17);
assert!(captured_requests.len() >= 18);
assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。"));
assert!(request_text.contains("世界核心骨架"));
assert!(request_text.contains("attributeSchema"));
assert!(request_text.contains("可扮演角色框架名单"));
assert!(request_text.contains("场景角色框架名单"));
assert!(request_text.contains("场景框架名单"));
assert!(request_text.contains("第一条场景必须是玩家进入世界时所在的开局场景"));
assert_eq!(landmark_seed_requests.len(), 2);
assert!(landmark_seed_requests[0].contains("本批场景必须是玩家进入世界时所在的开局场景"));
assert!(landmark_seed_requests[0].contains("必须生成恰好 1 个场景"));
assert!(landmark_seed_requests[1].contains("本批只生成普通关键场景"));
assert!(landmark_seed_requests[1].contains("这些场景已经生成,禁止重复:旧灯塔"));
assert!(!landmark_seed_requests[0].contains("一次性生成开局场景和普通关键场景"));
assert!(request_text.contains("camp 只表示玩家开局时的落脚处占位"));
assert!(!request_text.contains("camp.sceneTaskDescription"));
assert!(!request_text.contains("camp.actBackgroundPromptTexts"));
@@ -2702,6 +3021,150 @@ mod tests {
);
}
#[tokio::test]
async fn role_dossier_timeout_uses_local_fallback_and_keeps_generation_alive() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
vec![
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","visualDescription":"浅灰防潮医袍挽到肘部,药箱铜扣发暗,袖口沾着海盐。","actionDescription":"俯身检查伤痕并快速记录潮汐症状,动作谨慎而利落。","sceneVisualDescription":"他常在沉船湾临时诊台前,背后是湿木棚和摇晃药灯。","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","visualDescription":"暗绿长外套挂满防水口袋,帽檐压低,腰间藏着卷曲海图。","actionDescription":"摊开假海图低声议价,手指总按着袖中短刃。","sceneVisualDescription":"他常站在雾港货棚阴影里,周围堆着封蜡货箱和潮湿灯牌。","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","visualDescription":"瘦小学徒披着过大的灯塔制服,怀里抱黄铜小灯和旧钥匙。","actionDescription":"抱灯快步穿过回廊,听见夜钟时会突然停住回头。","sceneVisualDescription":"他常出现在灯塔螺旋楼梯间,雾光从窄窗切进灰墙。","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","visualDescription":"半透明水渍轮廓披着破碎船员衣,胸口嵌着发暗船钉。","actionDescription":"随潮声漂移抬手指路,情绪激烈时水雾会拉长身影。","sceneVisualDescription":"它常浮在沉船湾退潮泥滩上,身后旧船骨像黑色肋骨。","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","visualDescription":"深蓝巡海甲衣覆着雨水,肩章锋利,手握带灯长枪。","actionDescription":"举枪封路并用灯束扫过海岸,步伐整齐带压迫感。","sceneVisualDescription":"他常立在封锁栈桥尽头,巡海灯和铁链把退路切断。","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstoryReveal":{"publicSummary":"返乡守灯人的旧案羁绊。","chapters":[{"affinityRequired":15,"title":"返乡","summary":"回到旧灯塔。"},{"affinityRequired":30,"title":"旧档","summary":"发现档案错页。"},{"affinityRequired":60,"title":"沉船","summary":"接近沉船湾。"},{"affinityRequired":90,"title":"真相","summary":"直面议会遮掩。"}]},"skills":[{"name":"读灯","summary":"辨认灯火暗号","style":"侦查"}],"initialItems":[{"name":"旧海图","category":"道具","quantity":1,"rarity":"common","description":"父亲留下的海图。","tags":["线索"]}]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"潮医乙","backstory":"他保存着沉船伤痕和潮汐症状的旧记录。","personality":"谨慎利落","motivation":"保住证据","combatStyle":"以医疗知识支援判断"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","backstory":"他长期倒卖雾港航线和假海图。","personality":"圆滑警惕","motivation":"从旧案里脱身","combatStyle":"以情报和交易周旋"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","backstory":"它被沉船旧案困在潮声和船骨之间。","personality":"激烈执拗","motivation":"让真相重新浮上海面","combatStyle":"借潮声与残影指路"}]}"#,
),
},
MockHttpResponse {
status_code: 504,
body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(),
},
MockHttpResponse {
status_code: 504,
body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(),
},
],
);
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session, false, |_| {})
.await
.expect("dossier fallback should keep draft generation alive");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
.expect("draft profile should parse");
let first_story = draft_profile
.get("storyNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.expect("first story role should exist");
assert_eq!(first_story.get("name"), Some(&json!("议长甲")));
assert_eq!(
first_story
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(4)
);
assert_eq!(
first_story
.get("skills")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
assert_eq!(
first_story
.get("initialItems")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
let request_text = request_capture
.lock()
.expect("request capture should lock")
.join("\n---request---\n");
assert!(request_text.contains("请为下面这一批场景角色补全养成档案"));
}
#[test]
fn generated_scene_batch_first_entry_becomes_opening_camp() {
let fallback_camp = json!({
@@ -2739,13 +3202,9 @@ mod tests {
fn llm_response(content: &str) -> String {
json!({
"id": "resp_01",
"choices": [
{
"message": {
"content": content,
}
}
]
"model": CREATION_TEMPLATE_LLM_MODEL,
"output_text": content,
"status": "completed"
})
.to_string()
}
@@ -2814,6 +3273,27 @@ mod tests {
fn spawn_mock_server(
request_capture: Arc<Mutex<Vec<String>>>,
response_bodies: Vec<String>,
) -> String {
spawn_mock_server_with_statuses(
request_capture,
response_bodies
.into_iter()
.map(|body| MockHttpResponse {
status_code: 200,
body,
})
.collect(),
)
}
struct MockHttpResponse {
status_code: u16,
body: String,
}
fn spawn_mock_server_with_statuses(
request_capture: Arc<Mutex<Vec<String>>>,
responses: Vec<MockHttpResponse>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener
@@ -2821,10 +3301,13 @@ mod tests {
.expect("listener should expose address");
thread::spawn(move || {
let mut response_queue = VecDeque::from(response_bodies);
let mut response_queue = VecDeque::from(responses);
for _ in 0..32 {
let response_body = response_queue.pop_front().unwrap_or_else(|| {
llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#)
let response = response_queue.pop_front().unwrap_or_else(|| {
MockHttpResponse {
status_code: 200,
body: llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#),
}
});
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
@@ -2832,7 +3315,7 @@ mod tests {
.lock()
.expect("request capture should lock")
.push(request_text);
write_response(&mut stream, response_body);
write_response(&mut stream, response);
}
});
@@ -2880,11 +3363,18 @@ mod tests {
String::from_utf8(buffer).expect("request should be utf-8")
}
fn write_response(stream: &mut std::net::TcpStream, body: String) {
fn write_response(stream: &mut std::net::TcpStream, response: MockHttpResponse) {
let status_text = if response.status_code == 200 {
"OK"
} else {
"ERROR"
};
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
"HTTP/1.1 {} {}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.status_code,
status_text,
response.body.len(),
response.body
);
stream
.write_all(raw_response.as_bytes())

View File

@@ -34,6 +34,10 @@ impl AppError {
self.code
}
pub fn status_code(&self) -> StatusCode {
self.status_code
}
pub fn message(&self) -> &str {
&self.message
}

View File

@@ -1,258 +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, 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 {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
fn map_legacy_generated_upstream_status(
status: reqwest::StatusCode,
object_key: String,
) -> AppError {
let mapped_status = match status {
reqwest::StatusCode::NOT_FOUND => StatusCode::NOT_FOUND,
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(mapped_status).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
"upstreamStatus": status.as_u16(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_generated_object_key_keeps_supported_prefix() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::Animations,
"hero/animation-set-1/idle/frame01.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-animations/hero/animation-set-1/idle/frame01.png"
);
}
#[test]
fn build_generated_object_key_supports_big_fish_assets() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::BigFishAssets,
"big-fish-session-1/level-main-image/level-1/image.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-big-fish-assets/big-fish-session-1/level-main-image/level-1/image.png"
);
}
#[test]
fn build_generated_object_key_rejects_parent_segment() {
assert!(
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
);
}
}

View File

@@ -2,17 +2,21 @@ use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest};
use serde_json::Value;
use platform_llm::{LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest};
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,
request_context::RequestContext, state::AppState,
platform_errors::map_llm_error, request_context::RequestContext, state::AppState,
};
pub async fn proxy_llm_chat_completions(
@@ -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,
@@ -49,6 +45,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
@@ -62,7 +62,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 {
@@ -75,39 +146,6 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
LlmMessage::new(role, message.content)
}
fn map_llm_error(error: LlmError) -> AppError {
match error {
LlmError::InvalidRequest(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
}
LlmError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
}
LlmError::Upstream {
status_code: 429,
message,
} => AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(message),
LlmError::Upstream { message, .. } => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
LlmError::Timeout { attempts } => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 请求超时,累计尝试 {attempts}")),
LlmError::Connectivity { attempts, message } => {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 连接失败,累计尝试 {attempts} 次:{message}"))
}
LlmError::StreamUnavailable => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 流式响应体不可用")
}
LlmError::EmptyResponse => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 返回内容为空")
}
LlmError::Transport(message) | LlmError::Deserialize(message) => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
}
}
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -139,6 +177,7 @@ mod tests {
status_line: &'static str,
content_type: &'static str,
body: String,
extra_headers: Vec<(&'static str, &'static str)>,
}
#[tokio::test]
@@ -147,6 +186,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,
@@ -210,8 +250,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);
@@ -222,7 +279,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,
@@ -237,7 +293,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()
@@ -245,14 +308,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 {
@@ -340,13 +404,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())

View File

@@ -34,16 +34,17 @@ mod custom_world_rpg_draft_prompts;
mod error_middleware;
mod health;
mod http_error;
mod legacy_generated_assets;
mod llm;
mod llm_model_routing;
mod login_options;
mod logout;
mod logout_all;
mod match3d;
mod openai_image_generation;
mod password_entry;
mod password_management;
mod phone_auth;
mod platform_errors;
mod profile_identity;
mod prompt;
mod puzzle;
@@ -59,7 +60,6 @@ mod runtime_inventory;
mod runtime_profile;
mod runtime_save;
mod runtime_settings;
mod runtime_story;
mod session_client;
mod state;
mod story_battles;
@@ -76,9 +76,10 @@ use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录的 .env / .env.local 加载变量,避免手工逐项导出 OSS 配置。
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");
let _ = dotenvy::from_filename(".env.secrets.local");
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();

View File

@@ -0,0 +1,632 @@
use std::time::{Duration, Instant};
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::header;
use serde_json::{Map, Value, json};
use tokio::time::sleep;
use crate::{http_error::AppError, state::AppState};
pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
#[derive(Clone, Debug)]
pub(crate) struct OpenAiImageSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct OpenAiGeneratedImages {
pub task_id: String,
pub actual_prompt: Option<String>,
pub images: Vec<DownloadedOpenAiImage>,
}
#[derive(Clone, Debug)]
pub(crate) struct DownloadedOpenAiImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub extension: String,
}
// 中文注释RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。
pub(crate) fn require_openai_image_settings(
state: &AppState,
) -> Result<OpenAiImageSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.apimart_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_API_KEY 未配置",
}))
})?;
Ok(OpenAiImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1),
})
}
pub(crate) fn build_openai_image_http_client(
settings: &OpenAiImageSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "apimart",
"message": format!("构造 APIMart 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
pub(crate) async fn create_openai_image_generation(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[String],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let request_body = build_openai_image_request_body(
prompt,
negative_prompt,
size,
candidate_count,
reference_images,
);
let response = http_client
.post(format!("{}/images/generations", settings.base_url))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:创建图片生成任务失败:{error}"
))
})?;
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_openai_image_request_error(format!("{failure_context}:读取图片生成响应失败:{error}"))
})?;
if !response_status.is_success() {
return Err(map_openai_image_upstream_error(
response_status.as_u16(),
response_text.as_str(),
failure_context,
));
}
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
return download_images_from_urls(
http_client,
format!("apimart-{}", current_utc_micros()),
image_urls,
candidate_count,
)
.await;
}
let b64_images = extract_b64_images(&response_json.payload);
if !b64_images.is_empty() {
return Ok(images_from_base64(
format!("apimart-{}", current_utc_micros()),
b64_images,
candidate_count,
));
}
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:上游未返回 task_id 或图片"),
}))
})?;
wait_openai_generated_images(
http_client,
settings,
task_id.as_str(),
candidate_count,
failure_context,
)
.await
}
pub(crate) fn build_openai_image_request_body(
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[String],
) -> Value {
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(GPT_IMAGE_2_MODEL.to_string()),
),
(
"prompt".to_string(),
Value::String(build_prompt_with_negative(prompt, negative_prompt)),
),
("n".to_string(), json!(candidate_count.clamp(1, 4))),
(
"size".to_string(),
Value::String(normalize_image_size(size)),
),
]);
if !reference_images.is_empty() {
body.insert("image_urls".to_string(), json!(reference_images));
}
Value::Object(body)
}
fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String {
let prompt = prompt.trim();
let Some(negative_prompt) = negative_prompt
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return prompt.to_string();
};
format!("{prompt}\n避免:{negative_prompt}")
}
fn normalize_image_size(size: &str) -> String {
match size.trim() {
"1024*1024" | "1024x1024" | "1:1" => "1:1",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" => "16:9",
value if !value.is_empty() => value,
_ => "1:1",
}
.to_string()
}
async fn wait_openai_generated_images(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
task_id: &str,
candidate_count: u32,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
sleep(Duration::from_secs(10)).await;
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:查询图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:读取图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
let poll_json = parse_json_payload(poll_text.as_str(), failure_context)?;
let task_status = find_first_string_by_key(&poll_json.payload, "status")
.or_else(|| find_first_string_by_key(&poll_json.payload, "task_status"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(task_status.as_str(), "completed" | "succeeded" | "success") {
let image_urls = extract_image_urls(&poll_json.payload);
if image_urls.is_empty() {
let b64_images = extract_b64_images(&poll_json.payload);
if b64_images.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "apimart",
"message": format!("{failure_context}:任务成功但未返回图片"),
}),
));
}
let mut generated =
images_from_base64(task_id.to_string(), b64_images, candidate_count);
generated.actual_prompt =
find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
let mut generated = download_images_from_urls(
http_client,
task_id.to_string(),
image_urls,
candidate_count,
)
.await?;
generated.actual_prompt = find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
if matches!(
task_status.as_str(),
"failed" | "error" | "canceled" | "cancelled" | "unknown"
) {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
sleep(Duration::from_secs(3)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:图片生成超时或未返回图片地址"),
})),
)
}
async fn download_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
) -> Result<OpenAiGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images.push(download_remote_image(http_client, image_url.as_str()).await?);
}
Ok(OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
})
}
fn images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> OpenAiGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
.filter_map(|raw| decode_generated_image_base64(raw.as_str()))
.collect();
OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
}
}
fn decode_generated_image_base64(raw: &str) -> Option<DownloadedOpenAiImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_image_mime_type(bytes.as_slice());
Some(DownloadedOpenAiImage {
extension: mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
pub(crate) async fn download_remote_image(
http_client: &reqwest::Client,
image_url: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let response =
http_client.get(image_url).send().await.map_err(|error| {
map_openai_image_request_error(format!("下载生成图片失败:{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_openai_image_request_error(format!("读取生成图片内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "下载生成图片失败",
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedOpenAiImage {
extension: mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
fn parse_json_payload(
raw_text: &str,
failure_context: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:解析响应失败:{error}"),
"rawExcerpt": truncate_raw(raw_text),
}))
})
}
fn map_openai_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": message,
}))
}
fn map_openai_image_upstream_error(
upstream_status: u16,
raw_text: &str,
failure_context: &str,
) -> AppError {
let message = parse_api_error_message(raw_text, failure_context);
tracing::warn!(
provider = "apimart",
upstream_status,
raw_excerpt = %truncate_raw(raw_text),
message,
"APIMart 图片生成上游错误"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": message,
"upstreamStatus": upstream_status,
"rawExcerpt": truncate_raw(raw_text),
}))
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
for pointer in [
"/error/message",
"/message",
"/output/message",
"/data/message",
] {
if let Some(message) = parsed
.pointer(pointer)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
}
for pointer in ["/error/code", "/code", "/output/code", "/data/code"] {
if let Some(code) = parsed
.pointer(pointer)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
}
raw_text.trim().to_string()
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
continue;
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
.or_else(|| find_first_string_by_key(payload, "taskId"))
.or_else(|| find_first_string_by_key(payload, "id"))
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "url", &mut urls);
collect_strings_by_key(payload, "image", &mut urls);
collect_strings_by_key(payload, "image_url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn extract_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_strings_by_key(payload, "b64_json", &mut values);
values
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
fn infer_image_mime_type(bytes: &[u8]) -> String {
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
return "image/png".to_string();
}
if bytes.starts_with(b"\xFF\xD8\xFF") {
return "image/jpeg".to_string();
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp".to_string();
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return "image/gif".to_string();
}
"image/png".to_string()
}
fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
struct ParsedJsonPayload {
payload: Value,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gpt_image_2_request_normalizes_legacy_sizes_and_reference_images() {
let body = build_openai_image_request_body(
"雾海神殿",
Some("文字,水印"),
"1280*720",
2,
&["data:image/png;base64,abcd".to_string()],
);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "16:9");
assert_eq!(body["n"], 2);
assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd");
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
}
#[test]
fn b64_json_response_decodes_png_image() {
let images = images_from_base64(
"task-1".to_string(),
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
1,
);
assert_eq!(images.images.len(), 1);
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
}

View File

@@ -1,7 +1,7 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{
@@ -22,6 +22,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -303,10 +304,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
@@ -315,7 +313,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
map_phone_auth_platform_store_error(error.to_string())
}
}
}

View File

@@ -0,0 +1,133 @@
use axum::http::{HeaderValue, StatusCode};
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
use platform_llm::{LlmError, LlmErrorKind};
use platform_oss::{OssError, OssErrorKind};
use serde_json::json;
use crate::http_error::AppError;
// API 层统一消费 platform 的稳定错误分类,避免各 route 重复 match 具体 provider 分支。
pub fn map_llm_error(error: LlmError) -> AppError {
let message = llm_error_message(&error);
let status = match error.kind() {
LlmErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
LlmErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
LlmErrorKind::Upstream
if matches!(
error,
LlmError::Upstream {
status_code: 429,
..
}
) =>
{
StatusCode::TOO_MANY_REQUESTS
}
LlmErrorKind::Timeout
| LlmErrorKind::Connectivity
| LlmErrorKind::Upstream
| LlmErrorKind::StreamUnavailable
| LlmErrorKind::EmptyResponse
| LlmErrorKind::Transport
| LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_message(message)
}
pub fn map_oss_error(error: OssError, provider: &'static str) -> AppError {
let status = oss_error_status(error.kind());
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
pub fn map_phone_auth_platform_store_error(message: String) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
let status = match error.kind() {
AuthPlatformErrorKind::Disabled
| AuthPlatformErrorKind::MissingCode
| AuthPlatformErrorKind::InvalidCallback => StatusCode::BAD_REQUEST,
AuthPlatformErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
AuthPlatformErrorKind::RequestFailed
| AuthPlatformErrorKind::DeserializeFailed
| AuthPlatformErrorKind::MissingProfile
| AuthPlatformErrorKind::Upstream => StatusCode::BAD_GATEWAY,
AuthPlatformErrorKind::InvalidClaims
| AuthPlatformErrorKind::SignFailed
| AuthPlatformErrorKind::VerifyFailed
| AuthPlatformErrorKind::CookieConfig
| AuthPlatformErrorKind::HashFailed
| AuthPlatformErrorKind::InvalidVerifyCode => StatusCode::INTERNAL_SERVER_ERROR,
};
AppError::from_status(status).with_message(error.to_string())
}
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => error.with_header("retry-after", value),
Err(_) => error,
}
}
fn oss_error_status(kind: OssErrorKind) -> StatusCode {
match kind {
OssErrorKind::InvalidConfig | OssErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
OssErrorKind::ObjectNotFound => StatusCode::NOT_FOUND,
OssErrorKind::Request | OssErrorKind::SerializePolicy | OssErrorKind::Sign => {
StatusCode::BAD_GATEWAY
}
}
}
fn llm_error_message(error: &LlmError) -> String {
match error {
LlmError::InvalidConfig(message)
| LlmError::InvalidRequest(message)
| LlmError::Transport(message)
| LlmError::Deserialize(message) => message.clone(),
LlmError::Timeout { .. }
| LlmError::Connectivity { .. }
| LlmError::Upstream { .. }
| LlmError::StreamUnavailable
| LlmError::EmptyResponse => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_oss_error_uses_stable_kind_for_not_found() {
let error = map_oss_error(
OssError::ObjectNotFound("missing object".to_string()),
"oss",
);
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
}
#[test]
fn map_llm_error_preserves_upstream_rate_limit() {
let error = map_llm_error(LlmError::Upstream {
status_code: 429,
message: "too many requests".to_string(),
});
assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn map_wechat_provider_error_keeps_provider_boundary() {
let error = map_wechat_provider_error(WechatProviderError::MissingCode);
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(error.message(), "缺少微信授权 code");
}
}

View File

@@ -153,7 +153,11 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
[
"请根据下面的世界核心信息,批量生成场景框架名单。".to_string(),
if is_opening_batch {
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
if batch_count == 1 {
"这一步只生成开局场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
} else {
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
}
} else {
"这一步必须一次性生成普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
},
@@ -162,7 +166,11 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if is_opening_batch {
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
if batch_count == 1 {
"本批场景必须是玩家进入世界时所在的开局场景。".to_string()
} else {
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
}
} else {
"本批只生成普通关键场景,不要再生成开局场景。".to_string()
},

File diff suppressed because it is too large Load Diff

View File

@@ -258,7 +258,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.body(Body::empty())
.expect("request should build"),
)
@@ -278,7 +278,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -324,7 +324,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -361,64 +361,6 @@ mod tests {
);
}
#[tokio::test]
async fn runtime_browse_history_compat_route_matches_main_route_error_shape() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -10,10 +10,10 @@ use axum::{
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime_story::RuntimeStorySnapshotPayload;
use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload;
use std::convert::Infallible;
use module_runtime_story_compat::{
use module_runtime_story::{
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
normalize_required_string, read_array_field, read_field, read_i32_field, read_object_field,
read_optional_string_field, read_runtime_session_id,

View File

@@ -10,7 +10,7 @@ use axum::{
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime_story::RuntimeStorySnapshotPayload;
use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload;
use std::convert::Infallible;
use crate::{
@@ -18,7 +18,7 @@ use crate::{
llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::*,
request_context::RequestContext, state::AppState,
};
use module_runtime_story_compat::{
use module_runtime_story::{
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
normalize_required_string, read_array_field, read_field, read_runtime_session_id,
};

View File

@@ -685,7 +685,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/dashboard")
.uri("/api/profile/dashboard")
.body(Body::empty())
.expect("request should build"),
)
@@ -703,7 +703,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/wallet-ledger")
.uri("/api/profile/wallet-ledger")
.body(Body::empty())
.expect("request should build"),
)
@@ -721,7 +721,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/play-stats")
.uri("/api/profile/play-stats")
.body(Body::empty())
.expect("request should build"),
)
@@ -860,90 +860,36 @@ mod tests {
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
async fn runtime_profile_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for uri in [
"/api/runtime/profile/dashboard",
"/api/profile/dashboard",
)
.await;
}
#[tokio::test]
async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/wallet-ledger",
"/api/profile/wallet-ledger",
)
.await;
}
#[tokio::test]
async fn profile_play_stats_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/recharge-center",
"/api/runtime/profile/recharge/orders",
"/api/runtime/profile/referrals/invite-center",
"/api/runtime/profile/referrals/redeem-code",
"/api/runtime/profile/redeem-codes/redeem",
"/api/runtime/profile/play-stats",
"/api/profile/play-stats",
)
.await;
}
"/api/runtime/profile/save-archives",
"/api/runtime/profile/save-archives/world-1",
"/api/runtime/profile/browse-history",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
}
}
async fn seed_authenticated_state() -> AppState {

View File

@@ -4,7 +4,10 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::format_utc_micros;
use module_runtime::{
RuntimeProfileFieldError, build_runtime_save_checkpoint_input,
build_runtime_save_checkpoint_update,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
@@ -52,26 +55,6 @@ pub async fn put_runtime_snapshot(
Json(payload): Json<PutRuntimeSaveCheckpointRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
})),
)
})?;
let bottom_tab = normalize_required_string(payload.bottom_tab.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = payload
.saved_at
@@ -90,6 +73,15 @@ pub async fn put_runtime_snapshot(
.unwrap_or(now);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let checkpoint_input = build_runtime_save_checkpoint_input(
payload.session_id,
payload.bottom_tab,
saved_at_micros,
updated_at_micros,
)
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let existing = state
.get_runtime_snapshot_record(user_id.clone())
@@ -107,16 +99,18 @@ pub async fn put_runtime_snapshot(
)
})?;
validate_checkpoint_snapshot(&request_context, &session_id, &existing.game_state)?;
let game_state = sync_runtime_snapshot_play_time(existing.game_state, updated_at_micros);
let update =
build_runtime_save_checkpoint_update(checkpoint_input, existing).map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let record = state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
existing.current_story,
updated_at_micros,
update.saved_at_micros,
update.bottom_tab,
update.game_state,
update.current_story,
update.updated_at_micros,
)
.await
.map_err(|error| {
@@ -223,132 +217,6 @@ fn build_saved_game_snapshot_response(
}
}
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
game_state
.get("runtimeMode")
.and_then(Value::as_str)
.map(str::trim),
Some("preview") | Some("test")
)
}
fn validate_checkpoint_snapshot(
request_context: &RequestContext,
session_id: &str,
game_state: &Value,
) -> Result<(), Response> {
if is_non_persistent_runtime_snapshot(game_state) {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "预览或测试运行态不能创建正式 checkpoint",
})),
));
}
let persisted_session_id =
read_string_field(game_state, "runtimeSessionId").ok_or_else(|| {
runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "服务端运行时快照缺少 runtimeSessionId无法创建 checkpoint",
})),
)
})?;
if persisted_session_id != session_id {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "checkpoint sessionId 与服务端运行时快照不一致",
"expectedSessionId": persisted_session_id,
"actualSessionId": session_id,
})),
));
}
Ok(())
}
fn sync_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
let Some(game_state_object) = game_state.as_object_mut() else {
return game_state;
};
let now_text = format_utc_micros(now_micros);
let Some(runtime_stats) = game_state_object
.get_mut("runtimeStats")
.and_then(Value::as_object_mut)
else {
game_state_object.insert(
"runtimeStats".to_string(),
json!({
"playTimeMs": 0,
"lastPlayTickAt": now_text,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0,
}),
);
return game_state;
};
let current_play_time = runtime_stats
.get("playTimeMs")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let elapsed_ms = runtime_stats
.get("lastPlayTickAt")
.and_then(Value::as_str)
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
.map(offset_datetime_to_unix_micros)
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
.unwrap_or(0.0);
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
// 中文注释checkpoint 只刷新服务端已有 runtimeStats 的时间水位,
// 不从浏览器接收任何任务、背包、战斗或剧情状态。
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
game_state
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_required_string(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
fn build_profile_save_archive_summary_response(
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
) -> ProfileSaveArchiveSummaryResponse {
@@ -395,6 +263,51 @@ fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError
}))
}
fn map_runtime_save_domain_error(error: RuntimeProfileFieldError) -> AppError {
let message = error.to_string();
match error {
RuntimeProfileFieldError::MissingCheckpointSessionId => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
}))
}
RuntimeProfileFieldError::MissingRuntimeSessionId => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::MissingBottomTab => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
}))
}
RuntimeProfileFieldError::NonPersistentRuntimeSnapshot => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::RuntimeSessionMismatch {
expected_session_id,
actual_session_id,
} => AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
"expectedSessionId": expected_session_id,
"actualSessionId": actual_session_id,
})),
_ => AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"message": message,
})),
}
}
fn runtime_save_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -584,7 +497,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/save-archives")
.uri("/api/profile/save-archives")
.body(Body::empty())
.expect("request should build"),
)
@@ -594,15 +507,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_save_archives_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/save-archives",
"/api/profile/save-archives",
)
.await;
}
#[tokio::test]
async fn resume_profile_save_archive_rejects_blank_world_key() {
let state = seed_authenticated_state().await;
@@ -613,7 +517,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/save-archives/%20%20")
.uri("/api/profile/save-archives/%20%20")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
@@ -625,68 +529,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_payload: Value = serde_json::from_slice(
&main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
let compat_payload: Value = serde_json::from_slice(
&compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,551 +0,0 @@
use super::*;
pub(super) fn resolve_npc_preview_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc_name = current_encounter_name(game_state);
write_bool_field(game_state, "npcInteractionActive", true);
Ok(StoryResolution {
action_text: resolve_action_text("转向眼前角色", request),
result_text: format!("{npc_name} 注意到了你的靠近,正在等你先把话说出来。"),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_affinity_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
default_action_text: &str,
affinity_delta: i32,
fallback_result_text: &str,
) -> Result<StoryResolution, String> {
write_bool_field(game_state, "npcInteractionActive", true);
let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map(
|(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged {
npc_id,
previous_affinity,
next_affinity,
},
);
let mut patches = Vec::new();
if let Some(patch) = affinity_patch {
patches.push(patch);
}
patches.push(build_status_patch(game_state));
Ok(StoryResolution {
action_text: resolve_action_text(default_action_text, request),
result_text: fallback_result_text.to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_chat_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0);
let affinity_gain = (6 - chatted_count).max(2);
let result_text = format!(
"{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。",
current_encounter_name(game_state),
affinity_gain
);
let mut resolution = resolve_npc_affinity_action(
game_state,
request,
"继续交谈",
affinity_gain,
result_text.as_str(),
)?;
write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1));
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state));
Ok(resolution)
}
pub(super) fn resolve_npc_help_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
return Err("当前 NPC 的一次性援手已经用完了".to_string());
}
restore_player_resource(game_state, 10, 8);
write_current_npc_state_bool_field(game_state, "helpUsed", true);
resolve_npc_affinity_action(
game_state,
request,
&format!("{}请求援手", current_encounter_name(game_state)),
4,
&format!(
"{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。",
current_encounter_name(game_state)
),
)
}
pub(super) fn resolve_npc_battle_entry_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
let npc_name = current_encounter_name(game_state);
let battle_mode = if function_id == "npc_spar" {
"spar"
} else {
"fight"
};
let return_encounter = read_object_field(game_state, "currentEncounter").cloned();
let resolved_formation =
resolve_npc_battle_formation(game_state, return_encounter.as_ref(), battle_mode);
write_bool_field(game_state, "inBattle", true);
write_bool_field(game_state, "npcInteractionActive", false);
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "currentEncounter");
ensure_json_object(game_state).insert(
"sceneHostileNpcs".to_string(),
Value::Array(resolved_formation),
);
if let Some(return_encounter) = return_encounter {
ensure_json_object(game_state).insert("sparReturnEncounter".to_string(), return_encounter);
}
Ok(StoryResolution {
action_text: resolve_action_text(
if battle_mode == "spar" {
"点到为止切磋"
} else {
"与对方战斗"
},
request,
),
result_text: format!(
"{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。",
battle_mode_text(battle_mode)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: Some(RuntimeBattlePresentation {
target_id: Some(npc_id),
target_name: Some(npc_name),
damage_dealt: None,
damage_taken: None,
outcome: Some("ongoing".to_string()),
}),
toast: None,
})
}
fn resolve_npc_battle_formation(
game_state: &Value,
encounter: Option<&Value>,
battle_mode: &str,
) -> Vec<Value> {
let visible_formation = read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>();
if !visible_formation.is_empty() {
return visible_formation
.into_iter()
.enumerate()
.map(|(index, monster)| {
normalize_npc_battle_monster(monster, encounter, battle_mode, index)
})
.collect();
}
encounter
.map(|encounter| {
vec![build_npc_battle_monster_from_encounter(
game_state,
encounter,
battle_mode,
3.2,
0,
)]
})
.unwrap_or_default()
}
fn normalize_npc_battle_monster(
mut monster: Value,
fallback_encounter: Option<&Value>,
battle_mode: &str,
index: usize,
) -> Value {
let Some(monster_object) = monster.as_object_mut() else {
return monster;
};
monster_object
.entry("animation".to_string())
.or_insert_with(|| Value::String("idle".to_string()));
monster_object
.entry("facing".to_string())
.or_insert_with(|| Value::String("left".to_string()));
monster_object
.entry("renderKind".to_string())
.or_insert_with(|| Value::String("npc".to_string()));
monster_object
.entry("attackRange".to_string())
.or_insert_with(|| json!(1.8));
monster_object
.entry("speed".to_string())
.or_insert_with(|| json!(7));
let max_hp = monster_object
.get("maxHp")
.and_then(Value::as_i64)
.unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 });
monster_object
.entry("hp".to_string())
.or_insert_with(|| json!(max_hp));
if !monster_object
.get("encounter")
.is_some_and(|value| value.is_object())
&& let Some(fallback_encounter) = fallback_encounter
{
// 中文注释:进入 NPC 战斗时画布已经改由 sceneHostileNpcs 渲染敌方;
// 旧快照里的敌方条目可能只有数值没有形象上下文,必须把当前 NPC encounter 补进去。
let mut battle_encounter = fallback_encounter.clone();
if let Some(entry) = battle_encounter.as_object_mut() {
entry.insert("hostile".to_string(), Value::Bool(true));
if !entry.contains_key("xMeters") {
let x_meters = monster_object
.get("xMeters")
.and_then(Value::as_f64)
.unwrap_or(3.2 + index as f64 * 1.08);
entry.insert("xMeters".to_string(), json!(x_meters));
}
}
monster_object.insert("encounter".to_string(), battle_encounter);
}
monster
}
fn build_npc_battle_monster_from_encounter(
game_state: &Value,
encounter: &Value,
battle_mode: &str,
x_meters: f64,
y_offset: i32,
) -> Value {
let npc_id = read_optional_string_field(encounter, "id")
.unwrap_or_else(|| current_encounter_name(game_state));
let npc_name = current_encounter_name(game_state);
let npc_state =
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str());
let affinity = npc_state
.and_then(|state| read_i32_field(state, "affinity"))
.or_else(|| read_i32_field(encounter, "initialAffinity"))
.unwrap_or(0);
let base_hp = if battle_mode == "spar" {
10
} else {
(80 + affinity).max(24)
};
let monster_id = read_optional_string_field(encounter, "monsterPresetId")
.unwrap_or_else(|| format!("npc-opponent-{npc_id}"));
let mut battle_encounter = encounter.clone();
if let Some(entry) = battle_encounter.as_object_mut() {
entry.insert("hostile".to_string(), Value::Bool(true));
entry.insert("xMeters".to_string(), json!(x_meters));
}
json!({
"id": monster_id,
"name": npc_name,
"action": if battle_mode == "spar" {
"抱拳行礼,准备点到为止地切磋武艺"
} else {
"摆开架势,随时准备出手"
},
"description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(),
"animation": "idle",
"xMeters": x_meters,
"yOffset": y_offset,
"facing": "left",
"attackRange": 1.8,
"speed": 7,
"hp": base_hp,
"maxHp": base_hp,
"renderKind": "npc",
"levelProfile": read_field(encounter, "levelProfile").cloned(),
"experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0),
"encounter": battle_encounter
})
}
pub(super) fn resolve_npc_recruit_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
let npc_name = current_encounter_name(game_state);
let current_affinity = read_current_npc_affinity(game_state);
if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) {
return Err("当前 NPC 已经处于已招募状态".to_string());
}
if current_affinity < 60 {
return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string());
}
let release_npc_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "releaseNpcId"));
let released_companion_name = recruit_companion_to_party(
game_state,
npc_id.as_str(),
current_affinity,
release_npc_id.as_deref(),
)?;
let affinity_patch =
set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| {
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc_id.clone(),
previous_affinity,
next_affinity,
}
});
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
write_bool_field(game_state, "npcInteractionActive", false);
clear_encounter_only(game_state);
write_null_field(game_state, "currentNpcBattleMode");
write_null_field(game_state, "currentNpcBattleOutcome");
write_bool_field(game_state, "inBattle", false);
let mut patches = Vec::new();
if let Some(patch) = affinity_patch {
patches.push(patch);
}
patches.push(build_status_patch(game_state));
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
Ok(StoryResolution {
action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request),
result_text: match released_companion_name {
Some(released_name) => format!(
"{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。"
),
None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"),
},
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: None,
toast: Some(format!("{npc_name} 已加入队伍")),
})
}
/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回,
/// 后续再由真相态 inventory / runtime-item reducer 接管。
pub(super) fn resolve_npc_trade_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let (_npc_id, npc_name) = current_npc_trade_context(game_state)?;
let payload = request.action.payload.as_ref();
let mode = payload
.and_then(|value| read_optional_string_field(value, "mode"))
.ok_or_else(|| "npc_trade 缺少合法 mode需为 buy 或 sell".to_string())?;
if mode != "buy" && mode != "sell" {
return Err("npc_trade 缺少合法 mode需为 buy 或 sell".to_string());
}
let item_id = payload
.and_then(|value| {
read_optional_string_field(value, "itemId")
.or_else(|| read_optional_string_field(value, "selectedNpcItemId"))
.or_else(|| read_optional_string_field(value, "selectedPlayerItemId"))
})
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
let quantity = payload
.and_then(|value| read_i32_field(value, "quantity"))
.unwrap_or(1);
if quantity <= 0 {
return Err("npc_trade.quantity 必须大于 0".to_string());
}
if mode == "buy" {
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "目标商品不存在或库存不足。".to_string())?;
let available_quantity = read_i32_field(&npc_item, "quantity").unwrap_or(0).max(0);
if available_quantity < quantity {
return Err("目标商品不存在或库存不足。".to_string());
}
let total_price = npc_purchase_price(&npc_item, read_current_npc_affinity(game_state))
.saturating_mul(quantity);
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
if player_currency < total_price {
return Err("当前钱币不足,无法完成购买。".to_string());
}
write_i32_field(game_state, "playerCurrency", player_currency - total_price);
add_player_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&npc_item, quantity)],
);
remove_current_npc_inventory_item(game_state, item_id.as_str(), quantity);
mark_current_npc_first_meaningful_contact_resolved(game_state);
let item_name = read_inventory_item_name(&npc_item);
return Ok(StoryResolution {
action_text: resolve_action_text(
&format!(
"{}手里买下{}{}",
npc_name,
item_name,
trade_quantity_suffix(quantity)
),
request,
),
result_text: format!(
"{}收下了{},把{}{}卖给了你。",
npc_name,
format_currency_text(
total_price,
read_optional_string_field(game_state, "worldType").as_deref()
),
item_name,
trade_quantity_suffix(quantity)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
});
}
let player_item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有足够数量的目标物品。".to_string())?;
let available_quantity = read_i32_field(&player_item, "quantity").unwrap_or(0).max(0);
if available_quantity < quantity {
return Err("背包里没有足够数量的目标物品。".to_string());
}
let total_price = npc_buyback_price(&player_item, read_current_npc_affinity(game_state))
.saturating_mul(quantity);
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
write_i32_field(
game_state,
"playerCurrency",
player_currency.saturating_add(total_price),
);
remove_player_inventory_item(game_state, item_id.as_str(), quantity);
add_current_npc_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&player_item, quantity)],
);
mark_current_npc_first_meaningful_contact_resolved(game_state);
let item_name = read_inventory_item_name(&player_item);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!(
"{}{}卖给{}",
item_name,
trade_quantity_suffix(quantity),
npc_name
),
request,
),
result_text: format!(
"{}收下了{}{},付给你{}。",
npc_name,
item_name,
trade_quantity_suffix(quantity),
format_currency_text(
total_price,
read_optional_string_field(game_state, "worldType").as_deref()
)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_gift_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let (npc_id, npc_name) = current_npc_trade_context(game_state)?;
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "npc_gift 缺少 itemId".to_string())?;
let gift_item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有这件可赠送的物品。".to_string())?;
if read_i32_field(&gift_item, "quantity").unwrap_or(0) <= 0 {
return Err("背包里没有这件可赠送的物品。".to_string());
}
let previous_affinity = read_current_npc_affinity(game_state);
let affinity_gain = resolve_npc_gift_affinity_gain(&gift_item);
let next_affinity = (previous_affinity + affinity_gain).clamp(-100, 100);
remove_player_inventory_item(game_state, item_id.as_str(), 1);
add_current_npc_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&gift_item, 1)],
);
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
let next_gifts_given =
read_current_npc_state_i32_field(game_state, "giftsGiven").unwrap_or(0) + 1;
write_current_npc_state_i32_field(game_state, "giftsGiven", next_gifts_given);
mark_current_npc_first_meaningful_contact_resolved(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("{}赠给{}", read_inventory_item_name(&gift_item), npc_name),
request,
),
result_text: build_npc_gift_result_text(
npc_name.as_str(),
&gift_item,
affinity_gain,
next_affinity,
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
npc_id,
previous_affinity,
next_affinity,
}],
battle: None,
toast: None,
})
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
@@ -24,7 +24,7 @@ use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
use crate::wechat_provider::build_wechat_provider;
const ADMIN_ROLE: &str = "admin";
@@ -270,13 +270,13 @@ impl AppState {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????");
info!("已从 SpacetimeDB 表恢复认证快照");
return Self::new_with_auth_store(config, auth_store);
}
}
}
Err(error) => {
warn!(error = %error, "? SpacetimeDB ????????????????");
warn!(error = %error, " SpacetimeDB 表恢复认证快照失败");
}
}
@@ -286,13 +286,13 @@ impl AppState {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????");
info!("已从 SpacetimeDB 快照记录恢复认证快照");
return Self::new_with_auth_store(config, auth_store);
}
}
}
Err(error) => {
warn!(error = %error, "? SpacetimeDB ?????????????????");
warn!(error = %error, " SpacetimeDB 快照记录恢复认证快照失败");
}
}

View File

@@ -8,8 +8,14 @@ use module_combat::{
BattleMode, BattleStateInput, ResolveCombatActionInput, generate_battle_state_id,
};
use module_npc::{NPC_FIGHT_FUNCTION_ID, NPC_SPAR_FUNCTION_ID, ResolveNpcInteractionInput};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::story::{
CreateStoryBattleRequest, CreateStoryNpcBattleRequest, CreateStoryNpcBattleResponse,
ResolveStoryBattleRequest, ResolveStoryBattleResponse, StoryBattleRewardItemPayload,
StoryBattleRewardItemRequest, StoryBattleStatePayload, StoryBattleStateResponse,
StoryCombatActionPayload, StoryNpcInteractionPayload, StoryNpcStanceProfilePayload,
StoryNpcStatePayload,
};
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
use spacetime_client::{ResolveNpcBattleInteractionInput, SpacetimeClientError};
@@ -18,84 +24,6 @@ use crate::{
request_context::RequestContext, state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateStoryBattleRequest {
pub story_session_id: String,
pub runtime_session_id: String,
#[serde(default)]
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: String,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
#[serde(default)]
pub experience_reward: u32,
#[serde(default)]
pub reward_items: Vec<StoryBattleRewardItemRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveStoryBattleRequest {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateStoryNpcBattleRequest {
pub story_session_id: String,
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub interaction_function_id: String,
#[serde(default)]
pub release_npc_id: Option<String>,
#[serde(default)]
pub battle_state_id: Option<String>,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
#[serde(default)]
pub experience_reward: u32,
#[serde(default)]
pub reward_items: Vec<StoryBattleRewardItemRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoryBattleRewardItemRequest {
pub item_id: String,
pub category: String,
pub item_name: String,
#[serde(default)]
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
#[serde(default)]
pub tags: Vec<String>,
pub stackable: bool,
#[serde(default)]
pub stack_key: String,
#[serde(default)]
pub equipment_slot_id: Option<String>,
}
pub async fn create_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -123,6 +51,23 @@ pub async fn create_story_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -152,23 +97,38 @@ pub async fn create_story_battle(
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result),
}),
StoryBattleStateResponse {
battle_state: build_battle_state_payload(&result),
},
))
}
pub async fn resolve_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let battle_state_id = payload.battle_state_id;
let current_battle = state
.spacetime_client()
.get_battle_state(battle_state_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(
&request_context,
&current_battle.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.resolve_combat_action(ResolveCombatActionInput {
battle_state_id: payload.battle_state_id,
battle_state_id,
function_id: payload.function_id,
action_text: payload.action_text,
base_damage: payload.base_damage,
@@ -185,14 +145,14 @@ pub async fn resolve_story_battle(
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result.battle_state),
"combat": {
"damageDealt": result.damage_dealt,
"damageTaken": result.damage_taken,
"outcome": result.outcome,
}
}),
ResolveStoryBattleResponse {
battle_state: build_battle_state_payload(&result.battle_state),
combat: StoryCombatActionPayload {
damage_dealt: result.damage_dealt,
damage_taken: result.damage_taken,
outcome: result.outcome,
},
},
))
}
@@ -200,8 +160,9 @@ pub async fn get_story_battle_state(
State(state): State<AppState>,
Path(battle_state_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_battle_state(battle_state_id)
@@ -209,12 +170,13 @@ pub async fn get_story_battle_state(
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(&request_context, &result.actor_user_id, &actor_user_id)?;
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result),
}),
StoryBattleStateResponse {
battle_state: build_battle_state_payload(&result),
},
))
}
@@ -247,6 +209,23 @@ pub async fn create_story_npc_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -278,58 +257,69 @@ pub async fn create_story_npc_battle(
Ok(json_success_body(
Some(&request_context),
json!({
"npcInteraction": build_npc_interaction_payload(&result.npc_interaction),
"battleState": build_battle_state_payload(&result.battle_state),
}),
CreateStoryNpcBattleResponse {
npc_interaction: build_npc_interaction_payload(&result.npc_interaction),
battle_state: build_battle_state_payload(&result.battle_state),
},
))
}
fn build_battle_state_payload(record: &spacetime_client::BattleStateRecord) -> Value {
json!({
"battleStateId": record.battle_state_id,
"storySessionId": record.story_session_id,
"runtimeSessionId": record.runtime_session_id,
"actorUserId": record.actor_user_id,
"chapterId": record.chapter_id,
"targetNpcId": record.target_npc_id,
"targetName": record.target_name,
"battleMode": record.battle_mode,
"status": record.status,
"playerHp": record.player_hp,
"playerMaxHp": record.player_max_hp,
"playerMana": record.player_mana,
"playerMaxMana": record.player_max_mana,
"targetHp": record.target_hp,
"targetMaxHp": record.target_max_hp,
"experienceReward": record.experience_reward,
"rewardItems": record.reward_items.iter().map(|item| {
json!({
"itemId": item.item_id,
"category": item.category,
"itemName": item.item_name,
"description": item.description,
"quantity": item.quantity,
"rarity": format_runtime_item_reward_item_rarity(item.rarity),
"tags": item.tags,
"stackable": item.stackable,
"stackKey": item.stack_key,
"equipmentSlotId": item
.equipment_slot_id
.map(format_runtime_item_equipment_slot),
})
}).collect::<Vec<_>>(),
"turnIndex": record.turn_index,
"lastActionFunctionId": record.last_action_function_id,
"lastActionText": record.last_action_text,
"lastResultText": record.last_result_text,
"lastDamageDealt": record.last_damage_dealt,
"lastDamageTaken": record.last_damage_taken,
"lastOutcome": record.last_outcome,
"version": record.version,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
})
fn build_battle_state_payload(
record: &spacetime_client::BattleStateRecord,
) -> StoryBattleStatePayload {
StoryBattleStatePayload {
battle_state_id: record.battle_state_id.clone(),
story_session_id: record.story_session_id.clone(),
runtime_session_id: record.runtime_session_id.clone(),
actor_user_id: record.actor_user_id.clone(),
chapter_id: record.chapter_id.clone(),
target_npc_id: record.target_npc_id.clone(),
target_name: record.target_name.clone(),
battle_mode: record.battle_mode.clone(),
status: record.status.clone(),
player_hp: record.player_hp,
player_max_hp: record.player_max_hp,
player_mana: record.player_mana,
player_max_mana: record.player_max_mana,
target_hp: record.target_hp,
target_max_hp: record.target_max_hp,
experience_reward: record.experience_reward,
reward_items: record
.reward_items
.iter()
.map(build_battle_reward_item_payload)
.collect(),
turn_index: record.turn_index,
last_action_function_id: record.last_action_function_id.clone(),
last_action_text: record.last_action_text.clone(),
last_result_text: record.last_result_text.clone(),
last_damage_dealt: record.last_damage_dealt,
last_damage_taken: record.last_damage_taken,
last_outcome: record.last_outcome.clone(),
version: record.version,
created_at: record.created_at.clone(),
updated_at: record.updated_at.clone(),
}
}
fn build_battle_reward_item_payload(
item: &module_runtime_item::RuntimeItemRewardItemSnapshot,
) -> StoryBattleRewardItemPayload {
StoryBattleRewardItemPayload {
item_id: item.item_id.clone(),
category: item.category.clone(),
item_name: item.item_name.clone(),
description: item.description.clone(),
quantity: item.quantity,
rarity: format_runtime_item_reward_item_rarity(item.rarity).to_string(),
tags: item.tags.clone(),
stackable: item.stackable,
stack_key: item.stack_key.clone(),
equipment_slot_id: item
.equipment_slot_id
.map(format_runtime_item_equipment_slot)
.map(ToOwned::to_owned),
}
}
fn format_runtime_item_reward_item_rarity(
@@ -354,51 +344,53 @@ fn format_runtime_item_equipment_slot(
}
}
fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> Value {
json!({
"npcStateId": record.npc_state_id,
"runtimeSessionId": record.runtime_session_id,
"npcId": record.npc_id,
"npcName": record.npc_name,
"affinity": record.affinity,
"relationStance": record.relation_stance,
"helpUsed": record.help_used,
"chattedCount": record.chatted_count,
"giftsGiven": record.gifts_given,
"recruited": record.recruited,
"tradeStockSignature": record.trade_stock_signature,
"revealedFacts": record.revealed_facts,
"knownAttributeRumors": record.known_attribute_rumors,
"firstMeaningfulContactResolved": record.first_meaningful_contact_resolved,
"seenBackstoryChapterIds": record.seen_backstory_chapter_ids,
"stanceProfile": {
"trust": record.trust,
"warmth": record.warmth,
"ideologicalFit": record.ideological_fit,
"fearOrGuard": record.fear_or_guard,
"loyalty": record.loyalty,
"currentConflictTag": record.current_conflict_tag,
"recentApprovals": record.recent_approvals,
"recentDisapprovals": record.recent_disapprovals,
fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> StoryNpcStatePayload {
StoryNpcStatePayload {
npc_state_id: record.npc_state_id.clone(),
runtime_session_id: record.runtime_session_id.clone(),
npc_id: record.npc_id.clone(),
npc_name: record.npc_name.clone(),
affinity: record.affinity,
relation_stance: record.relation_stance.clone(),
help_used: record.help_used,
chatted_count: record.chatted_count,
gifts_given: record.gifts_given,
recruited: record.recruited,
trade_stock_signature: record.trade_stock_signature.clone(),
revealed_facts: record.revealed_facts.clone(),
known_attribute_rumors: record.known_attribute_rumors.clone(),
first_meaningful_contact_resolved: record.first_meaningful_contact_resolved,
seen_backstory_chapter_ids: record.seen_backstory_chapter_ids.clone(),
stance_profile: StoryNpcStanceProfilePayload {
trust: record.trust,
warmth: record.warmth,
ideological_fit: record.ideological_fit,
fear_or_guard: record.fear_or_guard,
loyalty: record.loyalty,
current_conflict_tag: record.current_conflict_tag.clone(),
recent_approvals: record.recent_approvals.clone(),
recent_disapprovals: record.recent_disapprovals.clone(),
},
"createdAt": record.created_at,
"updatedAt": record.updated_at,
})
created_at: record.created_at.clone(),
updated_at: record.updated_at.clone(),
}
}
fn build_npc_interaction_payload(record: &spacetime_client::NpcInteractionRecord) -> Value {
json!({
"npcState": build_npc_state_payload(&record.npc_state),
"interactionStatus": record.interaction_status,
"actionText": record.action_text,
"resultText": record.result_text,
"storyText": record.story_text,
"battleMode": record.battle_mode,
"encounterClosed": record.encounter_closed,
"affinityChanged": record.affinity_changed,
"previousAffinity": record.previous_affinity,
"nextAffinity": record.next_affinity,
})
fn build_npc_interaction_payload(
record: &spacetime_client::NpcInteractionRecord,
) -> StoryNpcInteractionPayload {
StoryNpcInteractionPayload {
npc_state: build_npc_state_payload(&record.npc_state),
interaction_status: record.interaction_status.clone(),
action_text: record.action_text.clone(),
result_text: record.result_text.clone(),
story_text: record.story_text.clone(),
battle_mode: record.battle_mode.clone(),
encounter_closed: record.encounter_closed,
affinity_changed: record.affinity_changed,
previous_affinity: record.previous_affinity,
next_affinity: record.next_affinity,
}
}
fn parse_battle_mode_strict(raw: &str) -> Option<BattleMode> {
@@ -490,6 +482,73 @@ fn story_battles_error_response(request_context: &RequestContext, error: AppErro
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner_for_battle(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-session",
"story session 不属于当前用户,不能创建战斗",
)
}
fn require_story_battle_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-battle",
"battle state 不属于当前用户",
)
}
fn require_story_session_runtime_for_battle(
request_context: &RequestContext,
session_runtime_id: &str,
requested_runtime_id: &str,
) -> Result<(), Response> {
if session_runtime_id == requested_runtime_id {
return Ok(());
}
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-session",
"message": "runtimeSessionId 与 story session 不匹配,不能创建战斗",
})),
))
}
fn require_resource_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
provider: &'static str,
message: &'static str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// API 层只做登录用户与资源属主的边界检查;战斗结算仍由 module-combat 与 SpacetimeDB 承担。
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": provider,
"message": message,
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -501,6 +560,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -513,7 +574,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::{require_story_battle_owner, require_story_session_runtime_for_battle};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn create_story_battle_requires_authentication() {
@@ -707,6 +771,37 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_story_battle_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/battles/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"battleStateId": "battle_001",
"functionId": "battle_attack_basic",
"actionText": "普通攻击",
"baseDamage": 10,
"manaCost": 0,
"heal": 0,
"manaRestore": 0,
"counterMultiplierBasisPoints": 10000
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -794,6 +889,63 @@ mod tests {
);
}
#[test]
fn story_battle_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
let response = require_story_battle_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_battle_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
require_story_battle_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
#[test]
fn story_battle_runtime_guard_rejects_mismatched_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
let response =
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_other")
.expect_err("mismatched runtime session should be bad request");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn story_battle_runtime_guard_accepts_matching_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_owner")
.expect("matching runtime session should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

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,
),
},
))
}
@@ -72,14 +124,29 @@ pub async fn begin_story_session(
pub async fn continue_story(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ContinueStoryRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = payload.story_session_id;
let current_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,
&current_state.session.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.continue_story(
payload.story_session_id,
story_session_id,
module_story::generate_story_event_id(now_micros),
payload.narrative_text,
payload.choice_function_id,
@@ -93,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),
),
},
))
}
@@ -123,8 +267,9 @@ pub async fn get_story_session_state(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_story_session_state(story_session_id)
@@ -132,40 +277,287 @@ pub async fn get_story_session_state(
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&result.session.actor_user_id,
&actor_user_id,
)?;
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>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let source = state
.spacetime_client()
.get_story_runtime_projection_source(story_session_id, actor_user_id)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
module_runtime_story::build_story_runtime_projection(source),
))
}
fn map_story_session_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
@@ -183,6 +575,25 @@ fn story_sessions_error_response(request_context: &RequestContext, error: AppErr
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// 这里只做 HTTP 鉴权边界判断story session 的推进规则仍由 SpacetimeDB 领域层处理。
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "story-session",
"message": "story session 不属于当前用户",
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -194,6 +605,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -206,7 +619,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::require_story_session_owner;
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn begin_story_session_requires_authentication() {
@@ -281,6 +697,182 @@ mod tests {
);
}
#[tokio::test]
async fn continue_story_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/continue")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"narrativeText": "你看见篝火边有人招手。",
"choiceFunctionId": "talk_to_npc"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::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;
@@ -381,6 +973,89 @@ mod tests {
);
}
#[tokio::test]
async fn get_story_runtime_projection_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/sessions/storysess_001/runtime-projection")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_runtime_projection_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/sessions/storysess_001/runtime-projection")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[test]
fn story_session_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
let response = require_story_session_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_session_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
require_story_session_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -1,13 +1,13 @@
use axum::{
Json,
extract::{Extension, Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
WechatAuthScene,
};
use platform_auth::WechatAuthScene;
use shared_contracts::auth::{
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
@@ -23,6 +23,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_wechat_provider_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -50,17 +51,20 @@ pub async fn start_wechat_login(
query.redirect_path.as_deref(),
&state.config.wechat_redirect_path,
),
scene: scene.clone(),
scene: map_wechat_scene_to_domain(&scene),
request_user_agent: user_agent.clone(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_wechat_auth_error)?;
let authorization_url = state.wechat_provider().build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)?;
let authorization_url = state
.wechat_provider()
.build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)
.map_err(map_wechat_provider_error)?;
Ok(json_success_body(
Some(&request_context),
@@ -121,10 +125,12 @@ pub async fn handle_wechat_callback(
{
Ok(profile) => state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
.resolve_login(module_auth::ResolveWechatLoginInput {
profile: map_wechat_profile_to_domain(profile),
})
.await
.map_err(map_wechat_auth_error),
Err(error) => Err(error),
Err(error) => Err(map_wechat_provider_error(error)),
};
match result {
@@ -247,6 +253,24 @@ fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, App
Ok(WechatAuthScene::Desktop)
}
fn map_wechat_scene_to_domain(scene: &WechatAuthScene) -> module_auth::WechatAuthScene {
match scene {
WechatAuthScene::Desktop => module_auth::WechatAuthScene::Desktop,
WechatAuthScene::WechatInApp => module_auth::WechatAuthScene::WechatInApp,
}
}
fn map_wechat_profile_to_domain(
profile: platform_auth::WechatIdentityProfile,
) -> module_auth::WechatIdentityProfile {
module_auth::WechatIdentityProfile {
provider_uid: profile.provider_uid,
provider_union_id: profile.provider_union_id,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
}
}
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string();
@@ -348,10 +372,7 @@ fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
module_auth::PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())

View File

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