Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb

This commit is contained in:
2026-04-22 20:37:56 +08:00
82 changed files with 26950 additions and 1312 deletions

View File

@@ -51,11 +51,35 @@
5. `story-sessions/begin`
6. `story-sessions/continue`
当前阶段新增 Stage6 `character visual` 兼容 DTO
1. `assets/character-visual/generate`
2. `assets/character-visual/jobs/:taskId`
3. `assets/character-visual/publish`
当前阶段新增 Stage7 `character animation` 模板与导入兼容 DTO
1. `assets/character-animation/templates`
2. `assets/character-animation/import-video`
当前阶段新增 Stage8 `character workflow cache` 第一批兼容 DTO
1. `assets/character-workflow-cache`
2. `assets/character-workflow-cache/:characterId`
当前阶段新增 Stage9 `character animation` 主链兼容 DTO
1. `assets/character-animation/generate`
2. `assets/character-animation/jobs/:taskId`
3. `assets/character-animation/publish`
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
1. `runtime/story/state/resolve` 请求 DTO
2. `runtime/story/actions/resolve``runtime/story/initial``runtime/story/continue` 请求 DTO
2. `RuntimeStoryActionResponse` 兼容响应 DTO
3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构
4. `RuntimeStoryAiResponse` 兼容响应 DTO
当前仍刻意未做:

View File

@@ -4,6 +4,7 @@ use platform_oss::{
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -83,6 +84,289 @@ pub struct BindAssetObjectRequest {
pub profile_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CharacterVisualSourceMode {
TextToImage,
ImageToImage,
Upload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterVisualGenerateRequest {
pub character_id: String,
pub source_mode: CharacterVisualSourceMode,
pub prompt_text: String,
#[serde(default)]
pub character_brief_text: Option<String>,
#[serde(default)]
pub reference_image_data_urls: Vec<String>,
pub candidate_count: u32,
pub image_model: String,
pub size: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterVisualDraftPayload {
pub id: String,
pub label: String,
pub image_src: String,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterVisualGenerateResponse {
pub ok: bool,
pub task_id: String,
pub model: String,
pub prompt: String,
pub drafts: Vec<CharacterVisualDraftPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CharacterAssetJobStatusText {
Queued,
Running,
Completed,
Failed,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAssetJobStatusPayload {
pub task_id: String,
pub kind: String,
pub status: CharacterAssetJobStatusText,
pub character_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub animation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strategy: Option<String>,
pub model: String,
pub prompt: String,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterVisualPublishRequest {
pub character_id: String,
pub source_mode: CharacterVisualSourceMode,
#[serde(default)]
pub prompt_text: Option<String>,
pub selected_preview_source: String,
#[serde(default)]
pub preview_sources: Vec<String>,
pub width: u32,
pub height: u32,
#[serde(default)]
pub update_character_override: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterVisualPublishResponse {
pub ok: bool,
pub asset_id: String,
pub portrait_path: String,
pub override_map: Value,
pub save_message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationTemplatePayload {
pub id: String,
pub label: String,
pub animation: String,
pub prompt_suffix: String,
pub notes: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationTemplatesResponse {
pub ok: bool,
pub templates: Vec<CharacterAnimationTemplatePayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationImportVideoRequest {
pub character_id: String,
pub animation: String,
pub video_source: String,
#[serde(default)]
pub source_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationImportVideoResponse {
pub ok: bool,
pub imported_video_path: String,
pub draft_id: String,
pub save_message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CharacterAnimationStrategy {
ImageSequence,
ImageToVideo,
MotionTransfer,
ReferenceToVideo,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationGenerateRequest {
pub character_id: String,
pub strategy: CharacterAnimationStrategy,
pub animation: String,
pub prompt_text: String,
#[serde(default)]
pub character_brief_text: Option<String>,
#[serde(default)]
pub action_template_id: Option<String>,
pub visual_source: String,
#[serde(default)]
pub reference_image_data_urls: Vec<String>,
#[serde(default)]
pub reference_video_data_urls: Vec<String>,
#[serde(default)]
pub last_frame_image_data_url: Option<String>,
pub frame_count: u32,
pub fps: u32,
pub duration_seconds: u32,
#[serde(rename = "loop")]
pub loop_: bool,
pub use_chroma_key: bool,
pub resolution: String,
pub ratio: String,
pub image_sequence_model: String,
pub video_model: String,
pub reference_video_model: String,
pub motion_transfer_model: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationGenerateResponse {
pub ok: bool,
pub task_id: String,
pub strategy: CharacterAnimationStrategy,
pub model: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub image_sources: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_video_path: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationDraftPayload {
pub frames_data_urls: Vec<String>,
pub fps: u32,
#[serde(rename = "loop")]
pub loop_: bool,
pub frame_width: u32,
pub frame_height: u32,
#[serde(default)]
pub preview_video_path: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationPublishRequest {
pub character_id: String,
pub visual_asset_id: String,
pub animations: BTreeMap<String, CharacterAnimationDraftPayload>,
#[serde(default)]
pub update_character_override: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationPublishResponse {
pub ok: bool,
pub animation_set_id: String,
pub override_map: Value,
pub animation_map: Value,
pub save_message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCachePayload {
pub character_id: String,
pub visual_prompt_text: String,
pub animation_prompt_text: String,
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
pub selected_visual_draft_id: String,
pub selected_animation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_src: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generated_visual_asset_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generated_animation_set_id: Option<String>,
#[serde(default)]
pub animation_map: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCacheSaveRequest {
pub character_id: String,
#[serde(default)]
pub visual_prompt_text: Option<String>,
#[serde(default)]
pub animation_prompt_text: Option<String>,
#[serde(default)]
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
#[serde(default)]
pub selected_visual_draft_id: Option<String>,
#[serde(default)]
pub selected_animation: Option<String>,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub generated_visual_asset_id: Option<String>,
#[serde(default)]
pub generated_animation_set_id: Option<String>,
#[serde(default)]
pub animation_map: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCacheGetResponse {
pub ok: bool,
pub cache: Option<CharacterWorkflowCachePayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCacheSaveResponse {
pub ok: bool,
pub cache: CharacterWorkflowCachePayload,
pub save_message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectUploadTicketResponse {
@@ -358,4 +642,177 @@ mod tests {
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
}
#[test]
fn character_visual_source_mode_uses_legacy_kebab_case() {
let payload = serde_json::to_value(CharacterVisualSourceMode::ImageToImage)
.expect("source mode should serialize");
assert_eq!(payload, json!("image-to-image"));
}
#[test]
fn character_visual_generate_response_keeps_legacy_shape() {
let payload = serde_json::to_value(CharacterVisualGenerateResponse {
ok: true,
task_id: "visual_1".to_string(),
model: "rust-svg-character-visual".to_string(),
prompt: "角色提示词".to_string(),
drafts: vec![CharacterVisualDraftPayload {
id: "candidate-1".to_string(),
label: "候选 1".to_string(),
image_src: "/generated-character-drafts/hero/visual/visual_1/candidate-01.svg"
.to_string(),
width: 1024,
height: 1024,
}],
})
.expect("response should serialize");
assert_eq!(payload["ok"], json!(true));
assert_eq!(payload["taskId"], json!("visual_1"));
assert_eq!(
payload["drafts"][0]["imageSrc"],
json!("/generated-character-drafts/hero/visual/visual_1/candidate-01.svg")
);
}
#[test]
fn character_animation_templates_response_keeps_legacy_shape() {
let payload = serde_json::to_value(CharacterAnimationTemplatesResponse {
ok: true,
templates: vec![CharacterAnimationTemplatePayload {
id: "idle_loop".to_string(),
label: "待机循环".to_string(),
animation: "idle".to_string(),
prompt_suffix: "保持呼吸感。".to_string(),
notes: "默认待机模板。".to_string(),
}],
})
.expect("response should serialize");
assert_eq!(payload["ok"], json!(true));
assert_eq!(payload["templates"][0]["id"], json!("idle_loop"));
assert_eq!(
payload["templates"][0]["promptSuffix"],
json!("保持呼吸感。")
);
}
#[test]
fn character_animation_import_video_response_keeps_legacy_shape() {
let payload =
serde_json::to_value(CharacterAnimationImportVideoResponse {
ok: true,
imported_video_path:
"/generated-character-drafts/hero/animation/idle/import-1/reference.mp4"
.to_string(),
draft_id: "animation-import-1".to_string(),
save_message: "参考视频已导入 OSS 草稿区。".to_string(),
})
.expect("response should serialize");
assert_eq!(
payload["importedVideoPath"],
json!("/generated-character-drafts/hero/animation/idle/import-1/reference.mp4")
);
assert_eq!(payload["draftId"], json!("animation-import-1"));
}
#[test]
fn character_workflow_cache_response_keeps_legacy_shape() {
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
ok: true,
cache: CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
visual_prompt_text: "主形象".to_string(),
animation_prompt_text: "待机".to_string(),
visual_drafts: vec![CharacterVisualDraftPayload {
id: "draft-1".to_string(),
label: "候选 1".to_string(),
image_src: "/generated-character-drafts/hero/visual/job/candidate.svg"
.to_string(),
width: 1024,
height: 1536,
}],
selected_visual_draft_id: "draft-1".to_string(),
selected_animation: "idle".to_string(),
image_src: Some("/generated-characters/hero/master.png".to_string()),
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: Some(json!({ "idle": { "frames": 4 } })),
updated_at: Some("2026-04-22T12:00:00Z".to_string()),
},
save_message: "角色形象生成缓存已更新。".to_string(),
})
.expect("response should serialize");
assert_eq!(payload["ok"], json!(true));
assert_eq!(payload["cache"]["characterId"], json!("hero"));
assert_eq!(
payload["cache"]["visualDrafts"][0]["imageSrc"],
json!("/generated-character-drafts/hero/visual/job/candidate.svg")
);
assert_eq!(payload["cache"]["animationMap"]["idle"]["frames"], json!(4));
}
#[test]
fn character_animation_strategy_uses_legacy_kebab_case() {
let payload = serde_json::to_value(CharacterAnimationStrategy::MotionTransfer)
.expect("strategy should serialize");
assert_eq!(payload, json!("motion-transfer"));
}
#[test]
fn character_animation_generate_response_keeps_image_sequence_shape() {
let payload = serde_json::to_value(CharacterAnimationGenerateResponse {
ok: true,
task_id: "animation_1".to_string(),
strategy: CharacterAnimationStrategy::ImageSequence,
model: "rust-svg-animation-sequence".to_string(),
prompt: "待机动作".to_string(),
image_sources: vec![
"/generated-character-drafts/hero/animation/idle/job/frame-01.svg".to_string(),
],
preview_video_path: None,
})
.expect("response should serialize");
assert_eq!(payload["ok"], json!(true));
assert_eq!(payload["taskId"], json!("animation_1"));
assert_eq!(payload["strategy"], json!("image-sequence"));
assert_eq!(
payload["imageSources"][0],
json!("/generated-character-drafts/hero/animation/idle/job/frame-01.svg")
);
}
#[test]
fn character_animation_publish_response_keeps_legacy_shape() {
let payload = serde_json::to_value(CharacterAnimationPublishResponse {
ok: true,
animation_set_id: "animation-set-1".to_string(),
override_map: json!({}),
animation_map: json!({
"idle": {
"folder": "idle",
"prefix": "frame",
"frames": 2,
"startFrame": 1,
"extension": "svg",
"basePath": "/generated-animations/hero/animation-set-1/idle",
"frameWidth": 192,
"frameHeight": 256,
"fps": 8,
"loop": true
}
}),
save_message: "基础动作资源已写入 OSS 并绑定当前角色。".to_string(),
})
.expect("response should serialize");
assert_eq!(payload["animationSetId"], json!("animation-set-1"));
assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2));
}
}

View File

@@ -4,7 +4,8 @@ use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStorySnapshotPayload {
pub saved_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub saved_at: Option<String>,
pub bottom_tab: String,
pub game_state: Value,
#[serde(default)]
@@ -21,6 +22,72 @@ pub struct RuntimeStoryStateResolveRequest {
pub snapshot: Option<RuntimeStorySnapshotPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryChoiceAction {
#[serde(rename = "type")]
pub action_type: String,
pub function_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryActionRequest {
pub session_id: String,
#[serde(default)]
pub client_version: Option<u32>,
pub action: RuntimeStoryChoiceAction,
#[serde(default)]
pub snapshot: Option<RuntimeStorySnapshotPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiRequestOptions {
#[serde(default)]
pub available_options: Vec<Value>,
#[serde(default)]
pub option_catalog: Vec<Value>,
}
impl Default for RuntimeStoryAiRequestOptions {
fn default() -> Self {
Self {
available_options: Vec::new(),
option_catalog: Vec::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiRequest {
pub world_type: String,
pub character: Value,
#[serde(default)]
pub monsters: Vec<Value>,
#[serde(default)]
pub history: Vec<Value>,
#[serde(default)]
pub choice: String,
pub context: Value,
#[serde(default)]
pub request_options: RuntimeStoryAiRequestOptions,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiResponse {
pub story_text: String,
pub options: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encounter: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryOptionView {
@@ -49,10 +116,6 @@ pub enum RuntimeStoryOptionInteraction {
#[serde(default, skip_serializing_if = "Option::is_none")]
quest_id: Option<String>,
},
#[serde(rename_all = "camelCase")]
Treasure {
action: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -197,30 +260,72 @@ mod tests {
use serde_json::json;
#[test]
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
fn runtime_story_state_resolve_request_accepts_missing_saved_at() {
let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({
"sessionId": "runtime-main",
"clientVersion": 7,
"snapshot": {
"bottomTab": "adventure",
"gameState": { "runtimeSessionId": "runtime-main" },
"currentStory": { "text": "营地里的火光还没有熄灭。" }
}
}))
.expect("payload should deserialize");
assert_eq!(payload.session_id, "runtime-main");
assert_eq!(payload.client_version, Some(7));
assert_eq!(
payload.snapshot.expect("snapshot should exist").saved_at,
None
);
}
#[test]
fn runtime_story_action_request_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryActionRequest {
session_id: "runtime-main".to_string(),
client_version: Some(7),
client_version: Some(8),
action: RuntimeStoryChoiceAction {
action_type: "story_choice".to_string(),
function_id: "npc_chat".to_string(),
target_id: Some("npc_camp_firekeeper".to_string()),
payload: Some(json!({ "optionText": "继续交谈" })),
},
snapshot: Some(RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
current_story: None,
}),
})
.expect("payload should serialize");
assert_eq!(payload["sessionId"], json!("runtime-main"));
assert_eq!(payload["clientVersion"], json!(7));
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
assert_eq!(payload["clientVersion"], json!(8));
assert_eq!(payload["action"]["type"], json!("story_choice"));
assert_eq!(payload["action"]["functionId"], json!("npc_chat"));
assert_eq!(payload["action"]["targetId"], json!("npc_camp_firekeeper"));
assert_eq!(
payload["snapshot"]["currentStory"]["text"],
json!("营地里的火光还没有熄灭。")
payload["snapshot"]["savedAt"],
json!("2026-04-22T12:00:00.000Z")
);
}
#[test]
fn runtime_story_ai_request_defaults_optional_arrays() {
let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({
"worldType": "martial",
"character": { "name": "林迟" },
"context": { "scene": "camp" }
}))
.expect("payload should deserialize");
assert_eq!(payload.world_type, "martial");
assert!(payload.monsters.is_empty());
assert!(payload.history.is_empty());
assert!(payload.request_options.available_options.is_empty());
}
#[test]
fn runtime_story_action_response_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryActionResponse {
@@ -297,7 +402,7 @@ mod tests {
current_npc_battle_outcome: None,
}],
snapshot: RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({