Merge remote-tracking branch 'origin/master' into codex/publish-flow
Some checks failed
CI / verify (pull_request) Has been cancelled
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:
@@ -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" }
|
||||
|
||||
@@ -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` 进入后端真相源。
|
||||
|
||||
@@ -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 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":{"message":"story dossier timeout"}}
|
||||
@@ -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 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":{"message":"story dossier timeout"}}
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("尚未编译")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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-pro;RPG 主图当前统一归一到 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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,17 +185,22 @@ pub async fn generate_custom_world_profile(
|
||||
);
|
||||
|
||||
// 中文注释:profile 生成需要外部 LLM,必须留在 Axum/api-server;SpacetimeDB 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
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -34,6 +34,10 @@ impl AppError {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
self.status_code
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
632
server-rs/crates/api-server/src/openai_image_generation.rs
Normal file
632
server-rs/crates/api-server/src/openai_image_generation.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
server-rs/crates/api-server/src/platform_errors.rs
Normal file
133
server-rs/crates/api-server/src/platform_errors.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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} 的局势随之向下一步展开。")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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 快照记录恢复认证快照失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user