From 3cdbf36859f72e209c6799d01056e92bbd41d333 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 11:11:01 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=8B=BC=E5=9B=BE=E5=92=8C=E5=A4=A7?= =?UTF-8?q?=E9=B1=BC=E5=90=83=E5=B0=8F=E9=B1=BC=E8=A1=A5=E5=85=85=E6=B8=B8?= =?UTF-8?q?=E7=8E=A9=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 10 +++ ...OARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md | 11 +++ .../src/contracts/bigFishWorkSummary.ts | 1 + server-rs/crates/api-server/src/app.rs | 9 ++- server-rs/crates/api-server/src/big_fish.rs | 27 +++++++ server-rs/crates/module-big-fish/src/lib.rs | 16 +++- server-rs/crates/module-puzzle/src/lib.rs | 12 ++- .../shared-contracts/src/big_fish_works.rs | 2 + .../crates/spacetime-client/src/big_fish.rs | 25 +++++++ server-rs/crates/spacetime-client/src/lib.rs | 8 +- .../crates/spacetime-client/src/mapper.rs | 1 + .../big_fish_creation_session_type.rs | 3 + .../big_fish_play_record_input_type.rs | 16 ++++ .../src/module_bindings/mod.rs | 4 + .../record_big_fish_play_procedure.rs | 59 +++++++++++++++ .../crates/spacetime-client/src/puzzle.rs | 9 +-- .../spacetime-module/src/big_fish/assets.rs | 2 + .../spacetime-module/src/big_fish/session.rs | 74 +++++++++++++++++++ .../spacetime-module/src/big_fish/tables.rs | 1 + .../crates/spacetime-module/src/migration.rs | 16 +++- .../crates/spacetime-module/src/puzzle.rs | 41 +++++----- .../custom-world-home/creationWorkShelf.ts | 1 + .../PlatformEntryFlowShellImpl.tsx | 25 +++++-- .../rpg-session/useRpgSessionPersistence.ts | 11 ++- src/hooks/runtimeAuthGuards.test.tsx | 65 ++++++++++++++++ .../big-fish-works/bigFishWorksClient.ts | 17 +++++ src/services/big-fish-works/index.ts | 1 + 27 files changed, 419 insertions(+), 48 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs diff --git a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index 00377a5b..9d6e3d24 100644 --- a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -16,6 +16,16 @@ 6. 启动测试运行态 7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决 +### 1.1 2026-04-27 公开游玩次数补充 + +正式发布的大鱼吃小鱼作品需要记录公开游玩次数,落地口径如下: + +1. `big_fish_creation_session.play_count` 保存该作品被正式启动的次数,默认值为 `0`。 +2. 只有平台作品详情、作品架等正式入口启动已发布作品时递增;创作结果页内的测试运行不计入。 +3. 前端作品摘要 contract 暴露 `playCount`,作品架展示与拼图一致使用该后端值。 +4. 本轮仅记录“进入玩法”次数,不记录大鱼吃小鱼总时长;个人 profile 的 RPG 时长统计仍由 runtime snapshot 负责。 +5. schema 变更需要同步 `migration.rs` 已纳入的 `big_fish_creation_session` 导入导出结构。 + ## 2. 本轮明确不做 1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。 diff --git a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md index ee919e86..b64db9ba 100644 --- a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md +++ b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md @@ -377,6 +377,17 @@ Node 侧入口位于: 这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。 +## 10.1 2026-04-27 统计写链修正 + +`runtime_snapshot / save archive` 主链已接入后,profile projection 的写入语义补充冻结如下: + +1. 正式 RPG 游玩只通过 `PUT /api/runtime/save/snapshot` 刷新 `profile_dashboard_state` 与 `profile_played_world`。 +2. `runtimeMode = "preview"`、`runtimeMode = "test"` 或 `runtimePersistenceDisabled = true` 的快照不刷新 profile projection。 +3. 前端发起自动保存与手动保存前,必须先把 `runtimeStats.lastPlayTickAt` 到当前时间的 live 时长同步进 `runtimeStats.playTimeMs`,避免 15 秒内进入又退出时保存 0。 +4. `profile_played_world` 的一行表示“当前用户玩过这个世界”,不是全站作品热度计数;`playedWorldCount` 读取当前用户的去重世界数。 +5. `profile_dashboard_state.total_play_time_ms` 通过同一用户同一世界的 `runtimeStats.playTimeMs - last_observed_play_time_ms` 增量累积,后端使用 `saturating_sub` 防止旧快照回退导致负增量。 +6. 作品卡上的公开热度计数如果需要覆盖 RPG 作品,应另立公开作品统计方案;不能把个人 `profile_played_world` 误当成全站作品 `playCount`。 + ## 11. 测试策略 ### 11.1 必跑 diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index 30ec15f1..21b7f2a3 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -15,6 +15,7 @@ export interface BigFishWorkSummary { levelMainImageReadyCount: number; levelMotionReadyCount: number; backgroundReady: boolean; + playCount?: number; } export interface BigFishWorksResponse { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d732ca65..28008d2f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -35,7 +35,7 @@ use crate::{ big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message, - submit_big_fish_message, + record_big_fish_play, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -83,8 +83,7 @@ use crate::{ get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, - submit_puzzle_leaderboard, - swap_puzzle_pieces, + submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -575,6 +574,10 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/works/{session_id}/play", + post(record_big_fish_play), + ) .route( "/api/runtime/puzzle/agent/sessions", post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index b5f804e3..3fa14974 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -191,6 +191,32 @@ pub async fn delete_big_fish_work( )) } +pub async fn record_big_fish_play( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let items = state + .spacetime_client() + .record_big_fish_play(session_id, 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), + BigFishWorksResponse { + items: items + .into_iter() + .map(map_big_fish_work_summary_response) + .collect(), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, @@ -924,6 +950,7 @@ fn map_big_fish_work_summary_response( level_main_image_ready_count: item.level_main_image_ready_count, level_motion_ready_count: item.level_motion_ready_count, background_ready: item.background_ready, + play_count: item.play_count, } } diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index adeec338..778d937b 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -221,6 +221,7 @@ pub struct BigFishWorkSummarySnapshot { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + pub play_count: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -316,6 +317,13 @@ pub struct BigFishPublishInput { pub published_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishPlayRecordInput { + pub session_id: String, + pub played_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum BigFishFieldError { MissingSessionId, @@ -654,6 +662,13 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish validate_session_owner(&input.session_id, &input.owner_user_id) } +pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> { + if normalize_required_string(&input.session_id).is_none() { + return Err(BigFishFieldError::MissingSessionId); + } + Ok(()) +} + pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result { serde_json::to_string(anchor_pack) } @@ -861,5 +876,4 @@ mod tests { ); assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); } - } diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 448db2c6..28c7ddf7 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -1964,14 +1964,18 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { let cleared_at_ms = current_unix_ms(); current_level.cleared_at_ms = Some(cleared_at_ms); - current_level.elapsed_ms = - Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000)); + current_level.elapsed_ms = Some( + cleared_at_ms + .saturating_sub(current_level.started_at_ms) + .max(1_000), + ); } current_level.status = next_level_status; } - if is_cleared && run.current_level.as_ref().map(|level| level.status) - != Some(PuzzleRuntimeLevelStatus::Cleared) + if is_cleared + && run.current_level.as_ref().map(|level| level.status) + != Some(PuzzleRuntimeLevelStatus::Cleared) { next_run.cleared_level_count += 1; } diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index 1f876bf7..b44cd94a 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + #[serde(default)] + pub play_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 758f18f3..5544f606 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -1,6 +1,7 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; +use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -131,6 +132,30 @@ impl SpacetimeClient { .await } + pub async fn record_big_fish_play( + &self, + session_id: String, + played_at_micros: i64, + ) -> Result, SpacetimeClientError> { + let procedure_input = BigFishPlayRecordInput { + session_id, + played_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().record_big_fish_play_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_big_fish_message( &self, input: BigFishMessageSubmitRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f990651d..fea844c4 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,10 +30,10 @@ pub use mapper::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 3e32f9fe..92e78bbb 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4606,6 +4606,7 @@ pub struct BigFishWorkSummaryRecord { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + pub play_count: u32, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs index 353343fd..a760fea0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs @@ -20,6 +20,7 @@ pub struct BigFishCreationSession { pub asset_coverage_json: String, pub last_assistant_reply: Option, pub publish_ready: bool, + pub play_count: u32, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -43,6 +44,7 @@ pub struct BigFishCreationSessionCols { pub asset_coverage_json: __sdk::__query_builder::Col, pub last_assistant_reply: __sdk::__query_builder::Col>, pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession { "last_assistant_reply", ), publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs new file mode 100644 index 00000000..dc9ec79b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishPlayRecordInput { + pub session_id: String, + pub played_at_micros: i64, +} + +impl __sdk::InModule for BigFishPlayRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 76bf4e34..386e98f4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -89,6 +89,7 @@ pub mod big_fish_game_draft_type; pub mod big_fish_level_blueprint_type; pub mod big_fish_message_finalize_input_type; pub mod big_fish_message_submit_input_type; +pub mod big_fish_play_record_input_type; pub mod big_fish_publish_input_type; pub mod big_fish_runtime_params_type; pub mod big_fish_session_create_input_type; @@ -331,6 +332,7 @@ pub mod quest_objective_snapshot_type; pub mod quest_progress_signal_type; pub mod quest_record_input_type; pub mod quest_record_type; +pub mod record_big_fish_play_procedure; pub mod quest_reward_equipment_slot_type; pub mod quest_reward_intel_type; pub mod quest_reward_item_rarity_type; @@ -558,6 +560,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft; pub use big_fish_level_blueprint_type::BigFishLevelBlueprint; pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput; pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput; +pub use big_fish_play_record_input_type::BigFishPlayRecordInput; pub use big_fish_publish_input_type::BigFishPublishInput; pub use big_fish_runtime_params_type::BigFishRuntimeParams; pub use big_fish_session_create_input_type::BigFishSessionCreateInput; @@ -800,6 +803,7 @@ pub use quest_objective_snapshot_type::QuestObjectiveSnapshot; pub use quest_progress_signal_type::QuestProgressSignal; pub use quest_record_input_type::QuestRecordInput; pub use quest_record_type::QuestRecord; +pub use record_big_fish_play_procedure::record_big_fish_play; pub use quest_reward_equipment_slot_type::QuestRewardEquipmentSlot; pub use quest_reward_intel_type::QuestRewardIntel; pub use quest_reward_item_rarity_type::QuestRewardItemRarity; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs new file mode 100644 index 00000000..f4cfaa6b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::big_fish_play_record_input_type::BigFishPlayRecordInput; +use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordBigFishPlayArgs { + pub input: BigFishPlayRecordInput, +} + +impl __sdk::InModule for RecordBigFishPlayArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_big_fish_play`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_big_fish_play { + fn record_big_fish_play(&self, input: BigFishPlayRecordInput) { + self.record_big_fish_play_then(input, |_, _| {}); + } + + fn record_big_fish_play_then( + &self, + input: BigFishPlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_big_fish_play for super::RemoteProcedures { + fn record_big_fish_play_then( + &self, + input: BigFishPlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>( + "record_big_fish_play", + RecordBigFishPlayArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index c3c09287..9636ed13 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -478,15 +478,14 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection.procedures().submit_puzzle_leaderboard_entry_then( - procedure_input, - move |_, result| { + connection + .procedures() + .submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_puzzle_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index 3e68e92f..ed97fd62 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), publish_ready: true, + play_count: session.play_count, created_at: session.created_at, updated_at: published_at, }; diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 01459d39..0c0faa92 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -93,6 +93,32 @@ pub fn delete_big_fish_work( } } +#[spacetimedb::procedure] +pub fn record_big_fish_play( + ctx: &mut ProcedureContext, + input: BigFishPlayRecordInput, +) -> BigFishWorksProcedureResult { + match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) { + Ok(items) => match serde_json::to_string(&items) { + Ok(items_json) => BigFishWorksProcedureResult { + ok: true, + items_json: Some(items_json), + error_message: None, + }, + Err(error) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(error.to_string()), + }, + }, + Err(message) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_big_fish_message( ctx: &mut ProcedureContext, @@ -194,6 +220,7 @@ pub(crate) fn create_big_fish_session_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(input.welcome_message_text.clone()), publish_ready: false, + play_count: 0, created_at, updated_at: created_at, }); @@ -383,6 +410,7 @@ pub(crate) fn submit_big_fish_message_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at: submitted_at, }; @@ -429,6 +457,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -483,6 +512,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: Some(assistant_reply_text), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -530,6 +560,7 @@ pub(crate) fn compile_big_fish_draft_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at: compiled_at, }; @@ -657,9 +688,51 @@ pub(crate) fn build_big_fish_work_summary( level_main_image_ready_count: coverage.level_main_image_ready_count, level_motion_ready_count: coverage.level_motion_ready_count, background_ready: coverage.background_ready, + play_count: row.play_count, }) } +pub(crate) fn record_big_fish_play_tx( + ctx: &ReducerContext, + input: BigFishPlayRecordInput, +) -> Result, String> { + validate_play_record_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish 已发布作品不存在".to_string())?; + let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: session.progress_percent, + stage: session.stage, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: session.draft_json.clone(), + asset_coverage_json: session.asset_coverage_json.clone(), + last_assistant_reply: session.last_assistant_reply.clone(), + publish_ready: session.publish_ready, + // 中文注释:这里只记录正式发布作品的进入次数,创作结果页测试运行不走这个 procedure。 + play_count: session.play_count.saturating_add(1), + created_at: session.created_at, + updated_at: played_at, + }; + replace_big_fish_session(ctx, &session, next_session); + + list_big_fish_works_tx( + ctx, + BigFishWorksListInput { + owner_user_id: String::new(), + published_only: true, + }, + ) +} + pub(crate) fn replace_big_fish_session( ctx: &ReducerContext, current: &BigFishCreationSession, @@ -693,6 +766,7 @@ mod tests { asset_coverage_json: "{}".to_string(), last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), publish_ready: false, + play_count: 0, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index 1e280ef6..7e82cd91 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -17,6 +17,7 @@ pub struct BigFishCreationSession { pub(crate) asset_coverage_json: String, pub(crate) last_assistant_reply: Option, pub(crate) publish_ready: bool, + pub(crate) play_count: u32, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 37a52649..f2f6be5b 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -659,6 +659,19 @@ where Ok(wrapped.0) } +fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { + let mut next_value = value.clone(); + if table_name == "big_fish_creation_session" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 + object + .entry("play_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + } + } + next_value +} + fn insert_migration_table_rows( ctx: &ReducerContext, table: &MigrationTable, @@ -672,7 +685,8 @@ fn insert_migration_table_rows( let mut imported = 0u64; let mut skipped = 0u64; for value in &table.rows { - let row = row_from_json(value) + let normalized_value = normalize_migration_row(stringify!($table), value); + let row = row_from_json(&normalized_value) .map_err(|error| format!("{}: {error}", stringify!($table)))?; let insert_result = ctx.db .$table() diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a1087aa0..7c27aba7 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -3,10 +3,10 @@ use module_puzzle::{ PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, - PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, - PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, + PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, + PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, + PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, @@ -1689,12 +1689,7 @@ fn upsert_puzzle_leaderboard_entry( ) { let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size); let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); - if let Some(existing) = ctx - .db - .puzzle_leaderboard_entry() - .entry_id() - .find(&entry_id) - { + if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) { let should_replace = elapsed_ms < existing.best_elapsed_ms || (elapsed_ms == existing.best_elapsed_ms && updated_at.to_micros_since_unix_epoch() @@ -1725,16 +1720,18 @@ fn upsert_puzzle_leaderboard_entry( return; } - ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow { - entry_id, - profile_id: profile_id.to_string(), - grid_size, - user_id: user_id.to_string(), - nickname: nickname.to_string(), - best_elapsed_ms: elapsed_ms, - last_run_id: run_id.to_string(), - updated_at, - }); + ctx.db + .puzzle_leaderboard_entry() + .insert(PuzzleLeaderboardEntryRow { + entry_id, + profile_id: profile_id.to_string(), + grid_size, + user_id: user_id.to_string(), + nickname: nickname.to_string(), + best_elapsed_ms: elapsed_ms, + last_run_id: run_id.to_string(), + updated_at, + }); } fn list_puzzle_leaderboard_entries( @@ -1799,8 +1796,8 @@ fn deserialize_run(value: &str) -> Result { mod tests { use super::*; use module_puzzle::{ - build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score, - PuzzleLeaderboardEntry, + PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack, + recommendation_score, tag_similarity_score, }; #[test] diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 6a0c2c3b..1fa56e98 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem( id: 'level-motion-ready-count', label: `动作 ${item.levelMotionReadyCount}`, }, + { id: 'play-count', label: `游玩 ${item.playCount ?? 0}` }, ...(item.backgroundReady ? [ { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0e321392..4af6f0f1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -60,6 +60,7 @@ import { import { deleteBigFishWork, listBigFishWorks, + recordBigFishWorkPlay, } from '../../services/big-fish-works'; import { readCustomWorldAgentUiState, @@ -91,6 +92,7 @@ import { } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, + startPuzzleRun, submitPuzzleLeaderboard, } from '../../services/puzzle-runtime'; import { @@ -1147,11 +1149,21 @@ export function PlatformEntryFlowShellImpl({ return; } - setBigFishError(null); - setBigFishRuntimeShare(null); - setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); - setSelectionStage('big-fish-runtime'); - }, [bigFishSession, setSelectionStage]); + const run = async () => { + setBigFishError(null); + setBigFishRuntimeShare(null); + if (bigFishSession.stage === 'published') { + await recordBigFishWorkPlay(bigFishSession.sessionId); + await refreshBigFishShelf(); + } + setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); + setSelectionStage('big-fish-runtime'); + }; + + void run().catch((error) => { + setBigFishError(resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。')); + }); + }, [bigFishSession, refreshBigFishShelf, resolveBigFishErrorMessage, setSelectionStage]); const restartBigFishRun = useCallback(() => { if (!bigFishSession && !bigFishRun) { @@ -1175,8 +1187,9 @@ export function PlatformEntryFlowShellImpl({ try { const { item } = await getPuzzleGalleryDetail(profileId); + const { run } = await startPuzzleRun({ profileId: item.profileId }); setSelectedPuzzleDetail(item); - setPuzzleRun(startLocalPuzzleRun(item)); + setPuzzleRun(run); setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setSelectionStage('puzzle-runtime'); pushAppHistoryPath( diff --git a/src/hooks/rpg-session/useRpgSessionPersistence.ts b/src/hooks/rpg-session/useRpgSessionPersistence.ts index 00ccfe2b..3836af74 100644 --- a/src/hooks/rpg-session/useRpgSessionPersistence.ts +++ b/src/hooks/rpg-session/useRpgSessionPersistence.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { syncGameStatePlayTime } from '../../data/runtimeStats'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { isAbortError } from '../../services/apiClient'; import { rpgSnapshotClient } from '../../services/rpg-runtime'; @@ -37,6 +38,10 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) { }; } +function buildPersistedGameState(gameState: GameState) { + return syncGameStatePlayTime(gameState); +} + export type UseRpgSessionPersistenceParams = { authenticatedUserId: string | null; gameState: GameState; @@ -208,9 +213,10 @@ export function useRpgSessionPersistence({ if (!canPersist) return; const timeoutId = window.setTimeout(() => { + const persistedGameState = buildPersistedGameState(gameState); void persistSnapshot({ payload: { - gameState, + gameState: persistedGameState, bottomTab, currentStory, }, @@ -235,9 +241,10 @@ export function useRpgSessionPersistence({ return false; } + const persistedGameState = buildPersistedGameState(nextGameState); const snapshot = await persistSnapshot({ payload: { - gameState: nextGameState, + gameState: persistedGameState, bottomTab: nextBottomTab, currentStory: nextStory, }, diff --git a/src/hooks/runtimeAuthGuards.test.tsx b/src/hooks/runtimeAuthGuards.test.tsx index 42100d03..5b8872d4 100644 --- a/src/hooks/runtimeAuthGuards.test.tsx +++ b/src/hooks/runtimeAuthGuards.test.tsx @@ -161,3 +161,68 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => { expect(screen.getByTestId('saved-game').textContent).toBe('no'); expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled(); }); + +test('authenticated runtime autosave syncs live play time before remote snapshot upload', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-27T10:00:02.000Z')); + storageMocks.putSaveSnapshot.mockResolvedValue({ + gameState: {}, + bottomTab: 'adventure', + currentStory: null, + }); + + const gameState = { + runtimePersistenceDisabled: false, + runtimeMode: 'play', + currentScene: 'Story', + worldType: 'CUSTOM', + playerCharacter: { id: 'hero-1' }, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: '2026-04-27T10:00:00.000Z', + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + } as GameState; + + function AutosaveHarness() { + useRpgSessionPersistence({ + authenticatedUserId: 'user-1', + gameState, + bottomTab: 'adventure' as BottomTab, + currentStory: { streaming: false } as StoryMoment, + isLoading: false, + setGameState: () => {}, + setBottomTab: () => {}, + hydrateStoryState: () => {}, + resetStoryState: () => {}, + }); + + return null; + } + + render(); + + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(storageMocks.putSaveSnapshot).toHaveBeenCalledTimes(1); + expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: expect.objectContaining({ + runtimeStats: expect.objectContaining({ + playTimeMs: 2400, + lastPlayTickAt: '2026-04-27T10:00:02.400Z', + }), + }), + }), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); +}); diff --git a/src/services/big-fish-works/bigFishWorksClient.ts b/src/services/big-fish-works/bigFishWorksClient.ts index 54392319..66cadc82 100644 --- a/src/services/big-fish-works/bigFishWorksClient.ts +++ b/src/services/big-fish-works/bigFishWorksClient.ts @@ -46,7 +46,24 @@ export async function deleteBigFishWork(sessionId: string) { ); } +/** + * 记录已发布大鱼吃小鱼作品的一次正式进入。 + */ +export async function recordBigFishWorkPlay(sessionId: string) { + return requestJson( + `${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}/play`, + { + method: 'POST', + }, + '记录大鱼吃小鱼游玩次数失败', + { + retry: BIG_FISH_WORKS_WRITE_RETRY, + }, + ); +} + export const bigFishWorksClient = { delete: deleteBigFishWork, list: listBigFishWorks, + recordPlay: recordBigFishWorkPlay, }; diff --git a/src/services/big-fish-works/index.ts b/src/services/big-fish-works/index.ts index 4b1b6798..2ab2252a 100644 --- a/src/services/big-fish-works/index.ts +++ b/src/services/big-fish-works/index.ts @@ -2,4 +2,5 @@ export { bigFishWorksClient, deleteBigFishWork, listBigFishWorks, + recordBigFishWorkPlay, } from './bigFishWorksClient'; From 04dfce57e6478e2f19ecb681d02e84fd7c2ec335 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 12:14:07 +0800 Subject: [PATCH 2/7] feat: add asset operation wallet ledger --- ...ENERATION_POINTS_CONSUMPTION_2026-04-27.md | 34 +- packages/shared/src/contracts/runtime.ts | 4 +- .../crates/api-server/src/asset_billing.rs | 31 +- server-rs/crates/api-server/src/big_fish.rs | 266 +++++------ .../crates/api-server/src/custom_world.rs | 111 ++--- .../crates/api-server/src/custom_world_ai.rs | 425 ++++++++---------- server-rs/crates/api-server/src/puzzle.rs | 276 ++++++------ .../crates/api-server/src/runtime_profile.rs | 22 +- server-rs/crates/module-runtime/src/lib.rs | 16 +- .../crates/shared-contracts/src/runtime.rs | 15 +- .../crates/spacetime-client/src/big_fish.rs | 2 +- .../crates/spacetime-client/src/mapper.rs | 15 +- ..._profile_wallet_ledger_source_type_type.rs | 4 +- .../spacetime-module/src/runtime/profile.rs | 4 +- .../RpgEntryHomeView.recharge.test.tsx | 52 +++ src/components/rpg-entry/RpgEntryHomeView.tsx | 172 ++++++- 16 files changed, 780 insertions(+), 669 deletions(-) diff --git a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md index 5daf05d6..c395fb31 100644 --- a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md +++ b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md @@ -1,15 +1,15 @@ -# 资产生成叙世币消耗接入方案 +# 资产操作叙世币消耗接入方案 ## 背景 -当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成由 Axum API 调用外部模型并写入 OSS,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此扣费需要拆成两层: +当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型或写入业务状态,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此计费需要拆成两层: - SpacetimeDB 负责钱包余额和流水的原子变更。 -- Axum 负责在发起外部生成前扣费,并在生成或持久化失败时补偿退款。 +- Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成、持久化或发布失败时补偿退款。 ## 首期范围 -首期接入带 Bearer 身份、能明确归属真实用户的资产生成与发布入口: +首期接入带 Bearer 身份、能明确归属真实用户的资产操作入口: - `POST /api/custom-world/scene-image` - `POST /api/custom-world/cover-image` @@ -26,28 +26,27 @@ - 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。 - 手动上传封面:不调用外部生成模型,不消耗叙世币。 - 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。 -- 文本实体、NPC 生成:本次需求聚焦资产生成,首期只覆盖图片资产。 +- 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口。 ## 计费规则 -- 每次图片资产生成请求消耗 `1` 枚叙世币。 -- 每次作品发布请求消耗 `1` 枚叙世币;余额不足时禁止发布。 -- 在调用外部图片生成前预扣,余额不足时直接返回业务错误,不调用外部模型。 -- 发布请求在写入发布状态前预扣,余额不足时直接返回业务错误,不调用发布 mutation。 -- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,Axum 自动发起同额退款。 +- 每次可计费资产操作消耗 `1` 枚叙世币。 +- 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。 +- 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。 +- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。 - 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。 ## 钱包流水 -新增两个流水来源类型,首期同时覆盖“资产生成”和“资产发布”这两类资产操作: +公开两个流水来源类型,统一覆盖“资产生成”和“资产发布”这两类资产操作: -- `asset_generation_consume`:资产生成预扣,`amount_delta = -1`。 -- `asset_generation_refund`:资产生成失败退款,`amount_delta = +1`。 +- `asset_operation_consume`:资产操作预扣,`amount_delta = -1`。 +- `asset_operation_refund`:资产操作失败退款,`amount_delta = +1`。 `wallet_ledger_id` 由 Axum 传入,格式: -- 扣费:`asset_generation_consume:{user_id}:{asset_kind}:{asset_id}` -- 退款:`asset_generation_refund:{user_id}:{asset_kind}:{asset_id}` +- 扣费:`asset_operation_consume:{user_id}:{asset_kind}:{asset_id}` +- 退款:`asset_operation_refund:{user_id}:{asset_kind}:{asset_id}` SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。 @@ -56,9 +55,10 @@ SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID - `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。 - `spacetime-module`:新增 `consume_profile_wallet_points_and_return` 与 `refund_profile_wallet_points_and_return` procedure,并扩展钱包变更 helper 支持负数。 - `spacetime-client`:新增对应调用方法和绑定类型。 -- `api-server`:在自定义世界图片生成与发布入口前扣费,错误分支退款。 +- `api-server`:资产操作服务提供统一可计费执行入口,自定义世界、Big Fish、Puzzle 业务 handler 只声明资产操作,不直接调用钱包扣费或退款。 - `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。 +- `packages/shared` 与前端:统一使用 `asset_operation_consume` / `asset_operation_refund` 展示钱包流水。 ## 非目标 -本次不做分档价格、不做会员免扣、不做前端计费展示改造,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。 +本次不做分档价格、不做会员免扣,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。旧资产生成流水 source 不再作为公开契约兼容。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e11beb06..5570e378 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -56,8 +56,8 @@ export type ProfileWalletLedgerEntry = { | 'invite_inviter_reward' | 'invite_invitee_reward' | 'points_recharge' - | 'asset_generation_consume' - | 'asset_generation_refund'; + | 'asset_operation_consume' + | 'asset_operation_refund'; createdAt: string; }; diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 95ac102f..13d9485a 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -1,3 +1,5 @@ +use std::future::Future; + use axum::http::StatusCode; use serde_json::json; use spacetime_client::SpacetimeClientError; @@ -6,15 +8,36 @@ use crate::{http_error::AppError, state::AppState}; pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; +/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。 +pub(crate) async fn execute_billable_asset_operation( + state: &AppState, + owner_user_id: &str, + asset_kind: &str, + asset_id: &str, + operation: Fut, +) -> Result +where + Fut: Future>, +{ + consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?; + match operation.await { + Ok(value) => Ok(value), + Err(error) => { + refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await; + Err(error) + } + } +} + /// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 -pub(crate) async fn consume_asset_operation_points( +async fn consume_asset_operation_points( state: &AppState, owner_user_id: &str, asset_kind: &str, asset_id: &str, ) -> Result<(), AppError> { let ledger_id = format!( - "asset_generation_consume:{}:{}:{}", + "asset_operation_consume:{}:{}:{}", owner_user_id, asset_kind, asset_id ); state @@ -31,14 +54,14 @@ pub(crate) async fn consume_asset_operation_points( } /// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 -pub(crate) async fn refund_asset_operation_points( +async fn refund_asset_operation_points( state: &AppState, owner_user_id: &str, asset_kind: &str, asset_id: &str, ) { let ledger_id = format!( - "asset_generation_refund:{}:{}:{}", + "asset_operation_refund:{}:{}:{}", owner_user_id, asset_kind, asset_id ); if let Err(error) = state diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 3fa14974..93d774dd 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -46,7 +46,7 @@ use crate::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, }, api_response::json_success_body, - asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, @@ -507,182 +507,118 @@ pub async fn execute_big_fish_action( _ => None, }; let billing_asset_id = format!("{session_id}:{now}"); - if let Some(asset_kind) = billed_asset_kind { - consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id) - .await - .map_err(|error| big_fish_error_response(&request_context, error))?; - } - - let session_result = match action.as_str() { - "big_fish_compile_draft" => { - compile_big_fish_draft_with_all_assets( + let session_operation = async { + match action.as_str() { + "big_fish_compile_draft" => compile_big_fish_draft_with_all_assets( &state, session_id.clone(), owner_user_id.clone(), now, ) .await - } - "big_fish_generate_level_main_image" => { - let asset_url = generate_big_fish_formal_asset( - &state, - &owner_user_id, - &session_id, - "level_main_image", - payload.level, - None, - now, - ) - .await - .map_err(|error| { - if let Some(asset_kind) = billed_asset_kind { - tokio::spawn({ - let state = state.clone(); - let owner_user_id = owner_user_id.clone(); - let billing_asset_id = billing_asset_id.clone(); - async move { - refund_asset_operation_points( - &state, - &owner_user_id, - asset_kind, - &billing_asset_id, - ) - .await; - } - }); - } - big_fish_error_response(&request_context, error) - })?; - state - .spacetime_client() - .generate_big_fish_asset(BigFishAssetGenerateRecordInput { - owner_user_id: owner_user_id.clone(), - session_id: session_id.clone(), - asset_kind: "level_main_image".to_string(), - level: payload.level, - motion_key: None, - asset_url: Some(asset_url), - generated_at_micros: now, - }) - .await - } - "big_fish_generate_level_motion" => { - let asset_url = generate_big_fish_formal_asset( - &state, - &owner_user_id, - &session_id, - "level_motion", - payload.level, - payload.motion_key.as_deref(), - now, - ) - .await - .map_err(|error| { - if let Some(asset_kind) = billed_asset_kind { - tokio::spawn({ - let state = state.clone(); - let owner_user_id = owner_user_id.clone(); - let billing_asset_id = billing_asset_id.clone(); - async move { - refund_asset_operation_points( - &state, - &owner_user_id, - asset_kind, - &billing_asset_id, - ) - .await; - } - }); - } - big_fish_error_response(&request_context, error) - })?; - state - .spacetime_client() - .generate_big_fish_asset(BigFishAssetGenerateRecordInput { - owner_user_id: owner_user_id.clone(), - session_id: session_id.clone(), - asset_kind: "level_motion".to_string(), - level: payload.level, - motion_key: payload.motion_key, - asset_url: Some(asset_url), - generated_at_micros: now, - }) - .await - } - "big_fish_generate_stage_background" => { - let asset_url = generate_big_fish_formal_asset( - &state, - &owner_user_id, - &session_id, - "stage_background", - None, - None, - now, - ) - .await - .map_err(|error| { - if let Some(asset_kind) = billed_asset_kind { - tokio::spawn({ - let state = state.clone(); - let owner_user_id = owner_user_id.clone(); - let billing_asset_id = billing_asset_id.clone(); - async move { - refund_asset_operation_points( - &state, - &owner_user_id, - asset_kind, - &billing_asset_id, - ) - .await; - } - }); - } - big_fish_error_response(&request_context, error) - })?; - state - .spacetime_client() - .generate_big_fish_asset(BigFishAssetGenerateRecordInput { - owner_user_id: owner_user_id.clone(), - session_id: session_id.clone(), - asset_kind: "stage_background".to_string(), - level: None, - motion_key: None, - asset_url: Some(asset_url), - generated_at_micros: now, - }) - .await - } - "big_fish_publish_game" => { - state + .map_err(map_big_fish_client_error), + "big_fish_generate_level_main_image" => { + let asset_url = generate_big_fish_formal_asset( + &state, + &owner_user_id, + &session_id, + "level_main_image", + payload.level, + None, + now, + ) + .await?; + state + .spacetime_client() + .generate_big_fish_asset(BigFishAssetGenerateRecordInput { + owner_user_id: owner_user_id.clone(), + session_id: session_id.clone(), + asset_kind: "level_main_image".to_string(), + level: payload.level, + motion_key: None, + asset_url: Some(asset_url), + generated_at_micros: now, + }) + .await + .map_err(map_big_fish_client_error) + } + "big_fish_generate_level_motion" => { + let asset_url = generate_big_fish_formal_asset( + &state, + &owner_user_id, + &session_id, + "level_motion", + payload.level, + payload.motion_key.as_deref(), + now, + ) + .await?; + state + .spacetime_client() + .generate_big_fish_asset(BigFishAssetGenerateRecordInput { + owner_user_id: owner_user_id.clone(), + session_id: session_id.clone(), + asset_kind: "level_motion".to_string(), + level: payload.level, + motion_key: payload.motion_key, + asset_url: Some(asset_url), + generated_at_micros: now, + }) + .await + .map_err(map_big_fish_client_error) + } + "big_fish_generate_stage_background" => { + let asset_url = generate_big_fish_formal_asset( + &state, + &owner_user_id, + &session_id, + "stage_background", + None, + None, + now, + ) + .await?; + state + .spacetime_client() + .generate_big_fish_asset(BigFishAssetGenerateRecordInput { + owner_user_id: owner_user_id.clone(), + session_id: session_id.clone(), + asset_kind: "stage_background".to_string(), + level: None, + motion_key: None, + asset_url: Some(asset_url), + generated_at_micros: now, + }) + .await + .map_err(map_big_fish_client_error) + } + "big_fish_publish_game" => state .spacetime_client() .publish_big_fish_game(session_id, owner_user_id.clone(), now) .await - } - other => { - return Err(big_fish_bad_request( - &request_context, - format!("action `{other}` is not supported").as_str(), - )); + .map_err(map_big_fish_client_error), + other => Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "big-fish", + "message": format!("action `{other}` is not supported"), + })), + ), } }; - let session = match session_result { - Ok(session) => session, - Err(error) => { - if let Some(asset_kind) = billed_asset_kind { - refund_asset_operation_points( - &state, - &owner_user_id, - asset_kind, - &billing_asset_id, - ) - .await; - } - return Err(big_fish_error_response( - &request_context, - map_big_fish_client_error(error), - )); - } + let session_result = if let Some(asset_kind) = billed_asset_kind { + execute_billable_asset_operation( + &state, + &owner_user_id, + asset_kind, + &billing_asset_id, + session_operation, + ) + .await + } else { + session_operation.await }; + let session = + session_result.map_err(|error| big_fish_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index a0caaebf..92473e23 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -51,7 +51,7 @@ use crate::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, }, api_response::json_success_body, - asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, character_visual_assets::generate_character_primary_visual_for_profile, custom_world_agent_entities::generate_custom_world_agent_entities, @@ -351,37 +351,31 @@ pub async fn publish_custom_world_library_profile( )); } - consume_asset_operation_points(&state, &owner_user_id, "custom_world_publish", &profile_id) - .await - .map_err(|error| custom_world_error_response(&request_context, error))?; - - let mutation_result = state - .spacetime_client() - .publish_custom_world_profile( - profile_id.clone(), - owner_user_id.clone(), - None, - resolve_author_public_user_code(&state, &authenticated, &request_context)?, - resolve_author_display_name(&state, &authenticated), - current_utc_micros(), - ) - .await; - let mutation = match mutation_result { - Ok(mutation) => mutation, - Err(error) => { - refund_asset_operation_points( - &state, - &owner_user_id, - "custom_world_publish", - &profile_id, - ) - .await; - return Err(custom_world_error_response( - &request_context, - map_custom_world_client_error(error), - )); - } - }; + let author_public_user_code = + resolve_author_public_user_code(&state, &authenticated, &request_context)?; + let author_display_name = resolve_author_display_name(&state, &authenticated); + let mutation = execute_billable_asset_operation( + &state, + &owner_user_id, + "custom_world_publish", + &profile_id, + async { + state + .spacetime_client() + .publish_custom_world_profile( + profile_id.clone(), + owner_user_id.clone(), + None, + author_public_user_code, + author_display_name, + current_utc_micros(), + ) + .await + .map_err(map_custom_world_client_error) + }, + ) + .await + .map_err(|error| custom_world_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), @@ -1246,46 +1240,33 @@ pub async fn execute_custom_world_agent_action( }; let should_bill_publish = action == "publish_world"; - if should_bill_publish { - consume_asset_operation_points( + let operation_future = async { + state + .spacetime_client() + .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + operation_id: build_prefixed_uuid_id("operation-"), + action: action.clone(), + payload_json: Some(payload_json), + submitted_at_micros, + }) + .await + .map_err(map_custom_world_client_error) + }; + let result = if should_bill_publish { + execute_billable_asset_operation( &state, &owner_user_id, "custom_world_agent_publish", &session_id, + operation_future, ) .await - .map_err(|error| custom_world_error_response(&request_context, error))?; - } - - let result = match state - .spacetime_client() - .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - operation_id: build_prefixed_uuid_id("operation-"), - action: action.clone(), - payload_json: Some(payload_json), - submitted_at_micros, - }) - .await - { - Ok(result) => result, - Err(error) => { - if should_bill_publish { - refund_asset_operation_points( - &state, - &owner_user_id, - "custom_world_agent_publish", - &session_id, - ) - .await; - } - return Err(custom_world_error_response( - &request_context, - map_custom_world_client_error(error), - )); - } + } else { + operation_future.await }; + let result = result.map_err(|error| custom_world_error_response(&request_context, error))?; if matches!( action.as_str(), diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index f1b862e1..225a8fee 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -28,6 +28,7 @@ use webp::Encoder as WebpEncoder; use crate::{ api_response::json_success_body, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, custom_world_result_prompts::{ build_result_entity_system_prompt, build_result_entity_user_prompt, @@ -441,126 +442,111 @@ pub async fn generate_custom_world_scene_image( let normalized = normalize_scene_image_request(payload) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-scene-{}", current_utc_millis()); - crate::asset_billing::consume_asset_operation_points( + let asset = execute_billable_asset_operation( &state, &owner_user_id, "scene_image", asset_id.as_str(), + async { + let settings = require_dashscope_settings(&state)?; + let http_client = build_dashscope_http_client(&settings)?; + let reference_image = + if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { + Some( + resolve_reference_image_as_data_url( + &state, + &http_client, + reference_image_src, + "referenceImageSrc", + ) + .await?, + ) + } 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( + &http_client, + generated.image_url.as_str(), + "下载生成图片失败", + ) + .await?; + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldScenes, + path_segments: vec![ + sanitize_storage_segment( + normalized + .profile_id + .as_deref() + .unwrap_or(normalized.world_name.as_str()), + "world", + ), + sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), + asset_id.clone(), + ], + file_name: format!("scene.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, + asset_kind: "scene_image", + entity_kind: "custom_world_landmark", + entity_id: normalized.entity_id.clone(), + profile_id: normalized.profile_id.clone(), + slot: "scene_image", + source_job_id: Some(generated.task_id.clone()), + }; + persist_custom_world_asset( + &state, + &owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some(scene_model), + size: Some(normalized.size), + task_id: Some(generated.task_id), + prompt: Some(normalized.prompt), + actual_prompt: generated.actual_prompt, + }, + ) + .await + }, ) .await .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let asset_result = async { - let settings = require_dashscope_settings(&state)?; - let http_client = build_dashscope_http_client(&settings)?; - let reference_image = - if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { - Some( - resolve_reference_image_as_data_url( - &state, - &http_client, - reference_image_src, - "referenceImageSrc", - ) - .await?, - ) - } 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( - &http_client, - generated.image_url.as_str(), - "下载生成图片失败", - ) - .await?; - let upload = PreparedAssetUpload { - prefix: LegacyAssetPrefix::CustomWorldScenes, - path_segments: vec![ - sanitize_storage_segment( - normalized - .profile_id - .as_deref() - .unwrap_or(normalized.world_name.as_str()), - "world", - ), - sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), - asset_id.clone(), - ], - file_name: format!("scene.{}", downloaded.extension), - content_type: downloaded.mime_type, - body: downloaded.bytes, - asset_kind: "scene_image", - entity_kind: "custom_world_landmark", - entity_id: normalized.entity_id.clone(), - profile_id: normalized.profile_id.clone(), - slot: "scene_image", - source_job_id: Some(generated.task_id.clone()), - }; - persist_custom_world_asset( - &state, - &owner_user_id, - upload, - GeneratedAssetResponse { - image_src: String::new(), - asset_id: asset_id.clone(), - source_type: "generated".to_string(), - model: Some(scene_model), - size: Some(normalized.size), - task_id: Some(generated.task_id), - prompt: Some(normalized.prompt), - actual_prompt: generated.actual_prompt, - }, - ) - .await - } - .await; - - let asset = match asset_result { - Ok(asset) => asset, - Err(error) => { - crate::asset_billing::refund_asset_operation_points( - &state, - &owner_user_id, - "scene_image", - &asset_id, - ) - .await; - return Err(custom_world_ai_error_response(&request_context, error)); - } - }; Ok(json_success_body(Some(&request_context), asset)) } @@ -717,127 +703,112 @@ pub async fn generate_custom_world_cover_image( 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()); let asset_id = format!("custom-cover-{}", current_utc_millis()); - crate::asset_billing::consume_asset_operation_points( + let asset = execute_billable_asset_operation( &state, &owner_user_id, "custom_world_cover", asset_id.as_str(), + async { + let settings = require_dashscope_settings(&state)?; + let http_client = build_dashscope_http_client(&settings)?; + let reference_sources = collect_cover_reference_image_sources( + &payload.profile, + &payload.character_role_ids, + payload.reference_image_src.as_deref().unwrap_or_default(), + ); + let prompt = build_custom_world_cover_image_prompt( + &payload.profile, + &payload.character_role_ids, + payload.user_prompt.as_deref().unwrap_or_default(), + !reference_sources.is_empty(), + ); + let mut reference_images = Vec::with_capacity(reference_sources.len()); + for source in &reference_sources { + reference_images.push( + resolve_reference_image_as_data_url( + &state, + &http_client, + source.as_str(), + "referenceImageSrc", + ) + .await?, + ); + } + let generated = if reference_images.is_empty() { + create_text_to_image_generation( + &http_client, + &settings, + state.config.dashscope_cover_image_model.clone().as_str(), + prompt.as_str(), + None, + size.as_str(), + "创建作品封面生成任务失败", + "查询作品封面任务失败", + "作品封面生成任务失败", + "作品封面生成超时或未返回图片地址", + ) + .await + } else { + create_reference_image_generation( + &http_client, + &settings, + state.config.dashscope_reference_image_model.as_str(), + prompt.as_str(), + size.as_str(), + &reference_images, + None, + "创建参考图封面任务失败", + "封面生成未返回图片地址", + "cover-edit", + ) + .await + }?; + let downloaded = download_remote_image( + &http_client, + generated.image_url.as_str(), + "下载作品封面失败", + ) + .await?; + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldCovers, + path_segments: vec![ + sanitize_storage_segment(entity_id.as_str(), "world"), + asset_id.clone(), + ], + file_name: format!("cover.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, + asset_kind: "custom_world_cover", + entity_kind: "custom_world_profile", + entity_id, + profile_id, + slot: "cover", + source_job_id: Some(generated.task_id.clone()), + }; + persist_custom_world_asset( + &state, + &owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some(if reference_images.is_empty() { + state.config.dashscope_cover_image_model.clone() + } else { + state.config.dashscope_reference_image_model.clone() + }), + size: Some(size), + task_id: Some(generated.task_id), + prompt: Some(prompt), + actual_prompt: generated.actual_prompt, + }, + ) + .await + }, ) .await .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - let asset_result = async { - let settings = require_dashscope_settings(&state)?; - let http_client = build_dashscope_http_client(&settings)?; - let reference_sources = collect_cover_reference_image_sources( - &payload.profile, - &payload.character_role_ids, - payload.reference_image_src.as_deref().unwrap_or_default(), - ); - let prompt = build_custom_world_cover_image_prompt( - &payload.profile, - &payload.character_role_ids, - payload.user_prompt.as_deref().unwrap_or_default(), - !reference_sources.is_empty(), - ); - let mut reference_images = Vec::with_capacity(reference_sources.len()); - for source in &reference_sources { - reference_images.push( - resolve_reference_image_as_data_url( - &state, - &http_client, - source.as_str(), - "referenceImageSrc", - ) - .await?, - ); - } - let generated = if reference_images.is_empty() { - create_text_to_image_generation( - &http_client, - &settings, - state.config.dashscope_cover_image_model.clone().as_str(), - prompt.as_str(), - None, - size.as_str(), - "创建作品封面生成任务失败", - "查询作品封面任务失败", - "作品封面生成任务失败", - "作品封面生成超时或未返回图片地址", - ) - .await - } else { - create_reference_image_generation( - &http_client, - &settings, - state.config.dashscope_reference_image_model.as_str(), - prompt.as_str(), - size.as_str(), - &reference_images, - None, - "创建参考图封面任务失败", - "封面生成未返回图片地址", - "cover-edit", - ) - .await - }?; - let downloaded = download_remote_image( - &http_client, - generated.image_url.as_str(), - "下载作品封面失败", - ) - .await?; - let upload = PreparedAssetUpload { - prefix: LegacyAssetPrefix::CustomWorldCovers, - path_segments: vec![ - sanitize_storage_segment(entity_id.as_str(), "world"), - asset_id.clone(), - ], - file_name: format!("cover.{}", downloaded.extension), - content_type: downloaded.mime_type, - body: downloaded.bytes, - asset_kind: "custom_world_cover", - entity_kind: "custom_world_profile", - entity_id, - profile_id, - slot: "cover", - source_job_id: Some(generated.task_id.clone()), - }; - persist_custom_world_asset( - &state, - &owner_user_id, - upload, - GeneratedAssetResponse { - image_src: String::new(), - asset_id: asset_id.clone(), - source_type: "generated".to_string(), - model: Some(if reference_images.is_empty() { - state.config.dashscope_cover_image_model.clone() - } else { - state.config.dashscope_reference_image_model.clone() - }), - size: Some(size), - task_id: Some(generated.task_id), - prompt: Some(prompt), - actual_prompt: generated.actual_prompt, - }, - ) - .await - } - .await; - - let asset = match asset_result { - Ok(asset) => asset, - Err(error) => { - crate::asset_billing::refund_asset_operation_points( - &state, - &owner_user_id, - "custom_world_cover", - &asset_id, - ) - .await; - return Err(custom_world_ai_error_response(&request_context, error)); - } - }; Ok(json_success_body(Some(&request_context), asset)) } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index fdeed0b2..63b69a89 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -67,7 +67,7 @@ use tokio::time::sleep; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, - asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, http_error::AppError, prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, @@ -442,29 +442,29 @@ pub async fn execute_puzzle_agent_action( let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); let action = payload.action.trim().to_string(); - let billed_asset_kind = match action.as_str() { - "compile_puzzle_draft" => Some("puzzle_initial_image"), - "generate_puzzle_images" => Some("puzzle_generated_image"), - _ => None, - }; let billing_asset_id = format!("{session_id}:{now}"); - if let Some(asset_kind) = billed_asset_kind { - consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id) + let (operation_type, phase_label, phase_detail, session) = match action.as_str() { + "compile_puzzle_draft" => { + let session = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_initial_image", + &billing_asset_id, + async { + compile_puzzle_draft_with_initial_cover( + &state, + session_id.clone(), + owner_user_id.clone(), + now, + ) + .await + .map_err(map_puzzle_client_error) + }, + ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - })?; - } - - let (operation_type, phase_label, phase_detail, session) = match action.as_str() { - "compile_puzzle_draft" => { - let session = compile_puzzle_draft_with_initial_cover( - &state, - session_id.clone(), - owner_user_id.clone(), - now, - ) - .await; + }); ( "compile_puzzle_draft", "完整拼图草稿", @@ -473,75 +473,76 @@ pub async fn execute_puzzle_agent_action( ) } "generate_puzzle_images" => { - let session = state - .spacetime_client() - .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) - .await; - let session = match session { - Ok(session) => { + let session = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_generated_image", + &billing_asset_id, + async { + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(map_puzzle_client_error)?; let draft = session.draft.clone().ok_or_else(|| { - SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()) - }); - match draft { - Ok(draft) => { - let prompt = payload - .prompt_text - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| draft.summary.clone()); - // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 - let candidate_count = 1; - let candidate_start_index = draft.candidates.len(); - let candidates = generate_puzzle_image_candidates( - &state, - owner_user_id.as_str(), - &session.session_id, - &draft.level_name, - &prompt, - payload.reference_image_src.as_deref(), - candidate_count, - candidate_start_index, - ) - .await - .map_err(SpacetimeClientError::Runtime); - match candidates { - Ok(candidates) => { - let candidates_json = serde_json::to_string( - &candidates - .iter() - .map(to_puzzle_generated_image_candidate) - .collect::>(), - ) - .map_err(|error| { - SpacetimeClientError::Runtime(format!( - "拼图候选图序列化失败:{error}" - )) - }); - match candidates_json { - Ok(candidates_json) => { - state - .spacetime_client() - .save_puzzle_generated_images( - PuzzleGeneratedImagesSaveRecordInput { - session_id: session.session_id, - owner_user_id: owner_user_id.clone(), - candidates_json, - saved_at_micros: now, - }, - ) - .await - } - Err(error) => Err(error), - } - } - Err(error) => Err(error), - } - } - Err(error) => Err(error), - } - } - Err(error) => Err(error), - }; + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let prompt = payload + .prompt_text + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| draft.summary.clone()); + // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 + let candidate_count = 1; + let candidate_start_index = draft.candidates.len(); + let candidates = generate_puzzle_image_candidates( + &state, + owner_user_id.as_str(), + &session.session_id, + &draft.level_name, + &prompt, + payload.reference_image_src.as_deref(), + candidate_count, + candidate_start_index, + ) + .await + .map_err(|message| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + })?; + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(to_puzzle_generated_image_candidate) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: session.session_id, + owner_user_id: owner_user_id.clone(), + candidates_json, + saved_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); ( "generate_puzzle_images", "拼图图片生成", @@ -569,7 +570,14 @@ pub async fn execute_puzzle_agent_action( candidate_id, selected_at_micros: now, }) - .await; + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + }); ( "select_puzzle_image", "正式图确认", @@ -579,43 +587,35 @@ pub async fn execute_puzzle_agent_action( } "publish_puzzle_work" => { let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); - consume_asset_operation_points(&state, &owner_user_id, "puzzle_publish_work", &work_id) - .await - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - })?; - let profile_result = state - .spacetime_client() - .publish_puzzle_work(PuzzlePublishRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 - work_id: work_id.clone(), - profile_id, - author_display_name: resolve_author_display_name(&state, &authenticated), - level_name: payload.level_name.clone(), - summary: payload.summary.clone(), - theme_tags: payload.theme_tags.clone(), - published_at_micros: now, - }) - .await; - let profile = match profile_result { - Ok(profile) => profile, - Err(error) => { - refund_asset_operation_points( - &state, - &owner_user_id, - "puzzle_publish_work", - &work_id, - ) - .await; - return Err(puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - )); - } - }; + let author_display_name = resolve_author_display_name(&state, &authenticated); + let profile = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_publish_work", + &work_id, + async { + state + .spacetime_client() + .publish_puzzle_work(PuzzlePublishRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 + work_id: work_id.clone(), + profile_id, + author_display_name, + level_name: payload.level_name.clone(), + summary: payload.summary.clone(), + theme_tags: payload.theme_tags.clone(), + published_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + })?; let session = state .spacetime_client() @@ -654,29 +654,7 @@ pub async fn execute_puzzle_agent_action( } }; - let session = session.map_err(|error| { - if let Some(asset_kind) = billed_asset_kind { - tokio::spawn({ - let state = state.clone(); - let owner_user_id = owner_user_id.clone(); - let billing_asset_id = billing_asset_id.clone(); - async move { - refund_asset_operation_points( - &state, - &owner_user_id, - asset_kind, - &billing_asset_id, - ) - .await; - } - }); - } - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - })?; + let session = session?; Ok(json_success_body( Some(&request_context), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 2efe3856..a66a4fec 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -13,8 +13,8 @@ use module_runtime::{ use serde_json::{Value, json}; use shared_contracts::runtime::{ CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, @@ -112,11 +112,11 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::PointsRecharge => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE } - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME } - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND } } } @@ -417,18 +417,18 @@ mod tests { use crate::{app::build_router, config::AppConfig, state::AppState}; #[test] - fn profile_wallet_ledger_source_type_formats_asset_generation_values() { + fn profile_wallet_ledger_source_type_formats_asset_operation_values() { assert_eq!( format_profile_wallet_ledger_source_type( - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume ), - shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME ); assert_eq!( format_profile_wallet_ledger_source_type( - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund ), - shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND ); } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 60a4a02a..766238b9 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -259,8 +259,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { InviteInviterReward, InviteInviteeReward, PointsRecharge, - AssetGenerationConsume, - AssetGenerationRefund, + AssetOperationConsume, + AssetOperationRefund, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1506,8 +1506,8 @@ impl RuntimeProfileWalletLedgerSourceType { Self::InviteInviterReward => "invite_inviter_reward", Self::InviteInviteeReward => "invite_invitee_reward", Self::PointsRecharge => "points_recharge", - Self::AssetGenerationConsume => "asset_generation_consume", - Self::AssetGenerationRefund => "asset_generation_refund", + Self::AssetOperationConsume => "asset_operation_consume", + Self::AssetOperationRefund => "asset_operation_refund", } } } @@ -2008,12 +2008,12 @@ mod tests { "points_recharge" ); assert_eq!( - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(), - "asset_generation_consume" + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(), + "asset_operation_consume" ); assert_eq!( - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(), - "asset_generation_refund" + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(), + "asset_operation_refund" ); } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 57d5671d..bd78d856 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -7,10 +7,9 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward"; -pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str = - "asset_generation_consume"; -pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = - "asset_generation_refund"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str = + "asset_operation_consume"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -791,7 +790,7 @@ mod tests { id: "ledger-5".to_string(), amount_delta: -1, balance_after: 199, - source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME .to_string(), created_at: "2026-04-22T10:04:00Z".to_string(), }, @@ -799,7 +798,7 @@ mod tests { id: "ledger-6".to_string(), amount_delta: 1, balance_after: 200, - source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), }, @@ -827,11 +826,11 @@ mod tests { ); assert_eq!( payload["entries"][4]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) ); assert_eq!( payload["entries"][5]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND) ); assert_eq!( payload["entries"][0]["createdAt"], diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 5544f606..e48decf7 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -148,7 +148,7 @@ impl SpacetimeClient { move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_big_fish_works_procedure_result); + .and_then(|result| map_big_fish_works_procedure_result(result, None)); send_once(&sender, mapped); }, ); diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 92e78bbb..52dc3e11 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -3278,11 +3278,11 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => { module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { - module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { - module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund } } } @@ -4626,6 +4626,8 @@ struct CompatibleBigFishWorkSummaryRecord { level_main_image_ready_count: u32, level_motion_ready_count: u32, background_ready: bool, + #[serde(default)] + play_count: u32, } impl CompatibleBigFishWorkSummaryRecord { @@ -4650,6 +4652,7 @@ impl CompatibleBigFishWorkSummaryRecord { level_main_image_ready_count: self.level_main_image_ready_count, level_motion_ready_count: self.level_motion_ready_count, background_ready: self.background_ready, + play_count: self.play_count, } } } @@ -4678,7 +4681,7 @@ mod tests { "level_motion_ready_count":0, "background_ready":false }]"# - .to_string(), + .to_string(), ), error_message: None, }; @@ -4710,7 +4713,7 @@ mod tests { "level_motion_ready_count":16, "background_ready":true }]"# - .to_string(), + .to_string(), ), error_message: None, }; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index fc2093e3..dd93e385 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -16,9 +16,9 @@ pub enum RuntimeProfileWalletLedgerSourceType { PointsRecharge, - AssetGenerationConsume, + AssetOperationConsume, - AssetGenerationRefund, + AssetOperationRefund, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 09ca0cc7..8dc65212 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -248,7 +248,7 @@ pub fn consume_profile_wallet_points_and_return( apply_profile_wallet_adjustment( tx, input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume, + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, true, ) }) { @@ -275,7 +275,7 @@ pub fn refund_profile_wallet_points_and_return( apply_profile_wallet_adjustment( tx, input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund, + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, false, ) }) { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 7e3204f8..75efef9e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -11,7 +11,29 @@ import { } from './RpgEntryHomeView'; import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; +const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({ + mockGetRpgProfileWalletLedger: vi.fn(async () => ({ + entries: [ + { + id: 'ledger-1', + amountDelta: -1, + balanceAfter: 29, + sourceType: 'asset_operation_consume', + createdAt: '2026-04-28T10:00:00Z', + }, + { + id: 'ledger-2', + amountDelta: 30, + balanceAfter: 30, + sourceType: 'invite_invitee_reward', + createdAt: '2026-04-28T09:00:00Z', + }, + ], + })), +})); + vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ + getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger, getRpgProfileRechargeCenter: vi.fn(async () => ({ walletBalance: 0, membership: { @@ -285,6 +307,36 @@ test('opens recharge modal and submits points product', async () => { await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1)); }); +test('opens wallet ledger modal from narrative coin card', async () => { + const user = userEvent.setup(); + + renderProfileView(); + await user.click(screen.getByText('剩余叙世币')); + + expect(await screen.findByText('叙世币账单')).toBeTruthy(); + expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1); + expect(screen.getByText('资产操作消耗')).toBeTruthy(); + expect(screen.getByText('-1')).toBeTruthy(); + expect(screen.getByText('填写邀请码奖励')).toBeTruthy(); + expect(screen.getByText('+30')).toBeTruthy(); +}); + +test('wallet ledger modal shows empty and error states', async () => { + const user = userEvent.setup(); + mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] }); + + renderProfileView(); + await user.click(screen.getByText('剩余叙世币')); + expect(await screen.findByText('暂无账单记录')).toBeTruthy(); + + await user.click(screen.getByLabelText('关闭叙世币账单')); + mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败')); + await user.click(screen.getByText('剩余叙世币')); + + expect(await screen.findByText('加载失败')).toBeTruthy(); + expect(screen.getByText('重新加载')).toBeTruthy(); +}); + test('shows a reachable login entry in logged out mobile shell', async () => { const user = userEvent.setup(); const openLoginModal = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 5b9d370c..3e7edf32 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -34,6 +34,7 @@ import type { PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, + ProfileWalletLedgerResponse, ProfileRechargeCenterResponse, ProfileRechargeProduct, ProfileReferralInviteCenterResponse, @@ -46,6 +47,7 @@ import { createRpgProfileRechargeOrder, getRpgProfileRechargeCenter, getRpgProfileReferralInviteCenter, + getRpgProfileWalletLedger, redeemRpgProfileReferralInviteCode, } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; @@ -923,6 +925,128 @@ function formatMembershipDuration(days: number) { return `${days}天`; } +const WALLET_LEDGER_SOURCE_LABELS: Record = { + points_recharge: '叙世币充值', + invite_inviter_reward: '邀请奖励', + invite_invitee_reward: '填写邀请码奖励', + snapshot_sync: '账户同步', + asset_operation_consume: '资产操作消耗', + asset_operation_refund: '资产操作退回', +}; + +function formatWalletLedgerAmount(amountDelta: number) { + return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`; +} + +function WalletLedgerModal({ + ledger, + fallbackBalance, + isLoading, + error, + onClose, + onRetry, +}: { + ledger: ProfileWalletLedgerResponse | null; + fallbackBalance: number; + isLoading: boolean; + error: string | null; + onClose: () => void; + onRetry: () => void; +}) { + const entries = ledger?.entries ?? []; + const balance = entries[0]?.balanceAfter ?? fallbackBalance; + + return ( +
+
+ +
+
+
+ LEDGER +
+
叙世币账单
+
+ + {balance}叙世币 +
+
+ + {error ? ( +
+
{error}
+ +
+ ) : isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( +
+ 暂无账单记录 +
+ ) : ( +
+ {entries.map((entry) => { + const isIncome = entry.amountDelta > 0; + const label = + WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ?? + entry.sourceType; + + return ( +
+
+
+ {label} +
+
+ {formatPlatformWorldTime(entry.createdAt)} +
+
+
+
+ {formatWalletLedgerAmount(entry.amountDelta)} +
+
+ 余额 {entry.balanceAfter} +
+
+
+ ); + })} +
+ )} +
+
+
+ ); +} + function AccountRechargeModal({ center, activeTab, @@ -1304,6 +1428,13 @@ export function RpgEntryHomeView({ const [isLoadingRecharge, setIsLoadingRecharge] = useState(false); const [submittingRechargeProductId, setSubmittingRechargeProductId] = useState(null); + const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); + const [walletLedger, setWalletLedger] = + useState(null); + const [walletLedgerError, setWalletLedgerError] = useState( + null, + ); + const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -1415,6 +1546,23 @@ export function RpgEntryHomeView({ }) .finally(() => setIsLoadingRecharge(false)); }; + const loadWalletLedger = () => { + setWalletLedgerError(null); + setIsLoadingWalletLedger(true); + void getRpgProfileWalletLedger() + .then(setWalletLedger) + .catch((error: unknown) => { + setWalletLedger(null); + setWalletLedgerError( + error instanceof Error ? error.message : '读取叙世币账单失败', + ); + }) + .finally(() => setIsLoadingWalletLedger(false)); + }; + const openWalletLedgerPanel = () => { + setIsWalletLedgerOpen(true); + loadWalletLedger(); + }; const submitRechargeProduct = (product: ProfileRechargeProduct) => { if (submittingRechargeProductId) { return; @@ -1865,7 +2013,7 @@ export function RpgEntryHomeView({ label="剩余叙世币" value="暂不可用" icon={Coins} - onClick={onOpenProfileDashboardCard} + onClick={openWalletLedgerPanel} /> ) : null} + {isWalletLedgerOpen ? ( + setIsWalletLedgerOpen(false)} + onRetry={loadWalletLedger} + /> + ) : null}
); } @@ -2422,6 +2580,16 @@ export function RpgEntryHomeView({ onSubmitRedeem={submitReferralInviteCode} /> ) : null} + {isWalletLedgerOpen ? ( + setIsWalletLedgerOpen(false)} + onRetry={loadWalletLedger} + /> + ) : null} ); } From 6611852a97c3335d8901ceb478ab254f31b3e4ea Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 12:56:38 +0800 Subject: [PATCH 3/7] feat: add profile redeem code flow --- ...E_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md | 131 +++++++ docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 23 +- packages/shared/src/contracts/runtime.ts | 13 +- server-rs/crates/api-server/src/app.rs | 34 +- .../crates/api-server/src/runtime_profile.rs | 159 +++++++- server-rs/crates/module-runtime/src/lib.rs | 218 +++++++++++ .../crates/shared-contracts/src/runtime.rs | 55 +++ server-rs/crates/spacetime-client/src/lib.rs | 18 +- .../crates/spacetime-client/src/mapper.rs | 146 +++++++ ...n_disable_profile_redeem_code_procedure.rs | 53 +++ ...in_upsert_profile_redeem_code_procedure.rs | 53 +++ .../src/module_bindings/mod.rs | 22 ++ .../redeem_profile_reward_code_procedure.rs | 53 +++ ...le_redeem_code_admin_disable_input_type.rs | 17 + ...redeem_code_admin_procedure_result_type.rs | 19 + ...ile_redeem_code_admin_upsert_input_type.rs | 25 ++ .../runtime_profile_redeem_code_mode_type.rs | 20 + ...ntime_profile_redeem_code_snapshot_type.rs | 26 ++ ...e_profile_reward_code_redeem_input_type.rs | 17 + ...eward_code_redeem_procedure_result_type.rs | 19 + ...rofile_reward_code_redeem_snapshot_type.rs | 19 + ..._profile_wallet_ledger_source_type_type.rs | 2 + .../crates/spacetime-client/src/runtime.rs | 91 +++++ .../crates/spacetime-module/src/migration.rs | 2 + .../spacetime-module/src/runtime/profile.rs | 343 +++++++++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 355 +++++------------- src/services/rpg-entry/rpgProfileClient.ts | 17 + 27 files changed, 1671 insertions(+), 279 deletions(-) create mode 100644 docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs diff --git a/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md new file mode 100644 index 00000000..02c593b3 --- /dev/null +++ b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md @@ -0,0 +1,131 @@ +# 资料兑换码模块落地设计 + +## 1. 目标 + +本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加叙世币余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`。 + +管理侧本轮只提供后端 API,不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开叙世号两类输入,后端创建阶段统一解析成内部 `userId` 存储。 + +## 2. 兑换码类型 + +`RuntimeProfileRedeemCodeMode` 固定为三种: + +| 类型 | 规则 | +| --- | --- | +| `Public` | 任意用户可兑换,`max_uses` 按用户独立计算。 | +| `Unique` | 任意用户可兑换,`max_uses` 全局共用。 | +| `Private` | 仅 `allowed_user_ids` 中的用户可兑换,`max_uses` 全局共用。 | + +兑换码入库前必须 `trim + uppercase`。空兑换码、奖励为 0、次数为 0 均拒绝。 + +## 3. 表结构 + +### 3.1 `profile_redeem_code` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `code` | `String` | 主键,标准化后的兑换码。 | +| `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 | +| `reward_points` | `u64` | 单次到账叙世币。 | +| `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 | +| `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 | +| `enabled` | `bool` | 是否启用。 | +| `allowed_user_ids` | `Vec` | 私有码允许用户;公共/唯一码存空数组。 | +| `created_by` | `String` | 管理员用户 ID。 | +| `created_at` | `Timestamp` | 创建时间。 | +| `updated_at` | `Timestamp` | 更新时间。 | + +### 3.2 `profile_redeem_code_usage` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 | +| `code` | `String` | 兑换码。 | +| `user_id` | `String` | 兑换用户。 | +| `amount_granted` | `u64` | 到账叙世币。 | +| `created_at` | `Timestamp` | 兑换时间。 | + +索引:`code`、`user_id`、`(code, user_id)`。 + +## 4. SpacetimeDB 过程 + +### 4.1 用户兑换 + +`redeem_profile_reward_code(input: RuntimeProfileRewardCodeRedeemInput) -> RuntimeProfileRewardCodeRedeemProcedureResult` + +流程: + +1. 标准化 code。 +2. 校验兑换码存在、启用、奖励大于 0。 +3. 按模式校验使用范围与次数。 +4. 同一事务内写入 `profile_redeem_code_usage`、增加钱包余额、写入 `profile_wallet_ledger`,最后更新 `profile_redeem_code.global_used_count`。 +5. 返回 `walletBalance`、`amountGranted` 与本次 `ledgerEntry`。 + +### 4.2 管理创建/更新 + +`admin_upsert_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminUpsertInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` + +私有码必须至少解析出一个内部用户 ID。公共码与唯一码忽略 allowed 列表并存空数组。 + +### 4.3 管理停用 + +`admin_disable_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminDisableInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` + +只更新 `enabled=false` 与 `updated_at`,不存在时返回“兑换码不存在”。 + +## 5. Axum API + +用户接口: + +- `POST /api/profile/redeem-codes/redeem` +- `POST /api/runtime/profile/redeem-codes/redeem` + +请求:`{ "code": "WELCOME2026" }` + +成功返回: + +```json +{ + "walletBalance": 130, + "amountGranted": 100, + "ledgerEntry": { + "id": "redeem:WELCOME2026:user:1777392000000000:0", + "amountDelta": 100, + "balanceAfter": 130, + "sourceType": "redeem_code_reward", + "createdAt": "2026-04-28T00:00:00Z" + } +} +``` + +管理员接口: + +- `POST /admin/api/profile/redeem-codes` +- `POST /admin/api/profile/redeem-codes/disable` + +管理员接口复用现有 `require_admin_auth`。 + +## 6. 错误文案 + +| 场景 | message | +| --- | --- | +| 空 code | `兑换码不能为空` | +| 不存在 | `兑换码不存在` | +| 停用 | `兑换码已停用` | +| 奖励为 0 | `兑换码奖励无效` | +| 次数耗尽 | `兑换次数已用完` | +| 私有码账号不匹配 | `该兑换码不适用于当前账号` | +| 私有码无允许用户 | `私有兑换码必须指定可兑换用户` | + +## 7. 前端交互 + +“我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。 + +成功后展示 `已到账 X 叙世币`,并刷新 profile dashboard。失败后直接展示后端 `message`。 + +## 8. 测试矩阵 + +- Rust/module-runtime:覆盖公共码、唯一码、私有码、失败场景、流水来源和余额累加。 +- Axum:覆盖用户鉴权、管理员鉴权、runtime error 到 400 的映射和兼容路径。 +- 前端:覆盖入口替换、独立 modal、成功刷新余额和失败展示后端 message。 +- 验证命令:`cargo test`、目标前端测试、`npm run api-server:maincloud`、`npm run check:encoding`。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index bbd7f5c3..edbb6008 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -23,7 +23,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | 领域 | 表 | | --- | --- | | 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | -| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_played_world`, `profile_save_archive` | +| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_played_world`, `profile_save_archive` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` | @@ -133,6 +133,27 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = ''; SELECT * FROM profile_wallet_ledger WHERE user_id = '' ORDER BY created_at DESC; ``` +### `profile_redeem_code` + +- 作用:运营发放的叙世币兑换码,支持公共码、唯一码和私有码。 +- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:主键 `code`。 + +```sql +SELECT * FROM profile_redeem_code WHERE code = ''; +``` + +### `profile_redeem_code_usage` + +- 作用:记录每一次兑换行为,为公共码用户维度计次、唯一/私有码全局计次提供依据。 +- 结构:`usage_id PK: String`, `code: String`, `user_id: String`, `amount_granted: u64`, `created_at: Timestamp`。 +- 索引:`code`, `user_id`, `(code, user_id)`。 + +```sql +SELECT * FROM profile_redeem_code_usage WHERE code = ''; +SELECT * FROM profile_redeem_code_usage WHERE user_id = ''; +``` + ### `profile_played_world` - 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e11beb06..6345b70b 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -57,7 +57,8 @@ export type ProfileWalletLedgerEntry = { | 'invite_invitee_reward' | 'points_recharge' | 'asset_generation_consume' - | 'asset_generation_refund'; + | 'asset_generation_refund' + | 'redeem_code_reward'; createdAt: string; }; @@ -159,6 +160,16 @@ export type RedeemProfileReferralInviteCodeResponse = { inviterBalanceAfter: number; }; +export type RedeemProfileRewardCodeRequest = { + code: string; +}; + +export type RedeemProfileRewardCodeResponse = { + walletBalance: number; + amountGranted: number; + ledgerEntry: ProfileWalletLedgerEntry; +}; + export type ProfilePlayedWorkSummary = { worldKey: string; ownerUserId: string | null; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d732ca65..8cc51fb1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -83,8 +83,7 @@ use crate::{ get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, - submit_puzzle_leaderboard, - swap_puzzle_pieces, + submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -95,9 +94,10 @@ use crate::{ runtime_chat::stream_runtime_npc_chat_turn, runtime_inventory::get_runtime_inventory_state, runtime_profile::{ + admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, - redeem_profile_referral_invite_code, + redeem_profile_referral_invite_code, redeem_profile_reward_code, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -144,6 +144,20 @@ pub fn build_router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/profile/redeem-codes", + post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/redeem-codes/disable", + post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/healthz", get(|Extension(request_context): Extension<_>| async move { @@ -848,6 +862,20 @@ 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( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/profile/play-stats", get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 2efe3856..35034922 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -7,30 +7,36 @@ use axum::{ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, - RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, + RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, + RuntimeReferralRedeemRecord, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ + AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, - ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse, - ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, - RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, + ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, + ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, + ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, + RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, + RedeemProfileRewardCodeResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken, + http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn get_profile_dashboard( @@ -118,6 +124,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND } + RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD + } } } @@ -228,6 +237,99 @@ pub async fn redeem_profile_referral_invite_code( )) } +pub async fn redeem_profile_reward_code( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .redeem_profile_reward_code(user_id, payload.code, redeemed_at_micros as i64) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_redeem_profile_reward_code_response(record), + )) +} + +pub async fn admin_upsert_profile_redeem_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let record = state + .spacetime_client() + .admin_upsert_profile_redeem_code( + admin.session().subject.clone(), + payload.code, + mode, + payload.reward_points, + payload.max_uses, + payload.enabled, + payload.allowed_user_ids, + payload.allowed_public_user_codes, + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_redeem_code_admin_response(record), + )) +} + +pub async fn admin_disable_profile_redeem_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_disable_profile_redeem_code( + admin.session().subject.clone(), + payload.code, + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_redeem_code_admin_response(record), + )) +} + pub async fn get_profile_play_stats( State(state): State, Extension(request_context): Extension, @@ -396,6 +498,49 @@ fn build_redeem_profile_referral_invite_code_response( } } +fn build_redeem_profile_reward_code_response( + record: RuntimeProfileRewardCodeRedeemRecord, +) -> RedeemProfileRewardCodeResponse { + RedeemProfileRewardCodeResponse { + wallet_balance: record.wallet_balance, + amount_granted: record.amount_granted, + ledger_entry: ProfileWalletLedgerEntryResponse { + id: record.ledger_entry.wallet_ledger_id, + amount_delta: record.ledger_entry.amount_delta, + balance_after: record.ledger_entry.balance_after, + source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type) + .to_string(), + created_at: record.ledger_entry.created_at, + }, + } +} + +fn parse_profile_redeem_code_mode(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "public" => Ok(RuntimeProfileRedeemCodeMode::Public), + "unique" => Ok(RuntimeProfileRedeemCodeMode::Unique), + "private" => Ok(RuntimeProfileRedeemCodeMode::Private), + _ => Err("兑换码类型无效".to_string()), + } +} + +fn build_profile_redeem_code_admin_response( + record: RuntimeProfileRedeemCodeRecord, +) -> ProfileRedeemCodeAdminResponse { + ProfileRedeemCodeAdminResponse { + code: record.code, + mode: record.mode.as_str().to_string(), + reward_points: record.reward_points, + max_uses: record.max_uses, + global_used_count: record.global_used_count, + enabled: record.enabled, + allowed_user_ids: record.allowed_user_ids, + created_by: record.created_by, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + #[cfg(test)] mod tests { use module_runtime::RuntimeProfileWalletLedgerSourceType; diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 60a4a02a..fdf6de87 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -261,6 +261,15 @@ pub enum RuntimeProfileWalletLedgerSourceType { PointsRecharge, AssetGenerationConsume, AssetGenerationRefund, + RedeemCodeReward, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileRedeemCodeMode { + Public, + Unique, + Private, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput { pub created_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemInput { + pub user_id: String, + pub code: String, + pub redeemed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemSnapshot { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminUpsertInput { + pub admin_user_id: String, + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub allowed_public_user_codes: Vec, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminDisableInput { + pub admin_user_id: String, + pub code: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeSnapshot { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterSnapshot { @@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError { MissingLedgerId, InvalidWalletAmount, MissingInviteCode, + MissingRedeemCode, + InvalidRedeemCodeReward, + InvalidRedeemCodeMaxUses, MissingProductId, MissingWorldKey, MissingBottomTab, @@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord { pub has_points_recharged: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileRewardCodeRedeemRecord { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileRedeemCodeRecord { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at: String, + pub created_at_micros: i64, + pub updated_at: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq)] pub struct RuntimeReferralInviteCenterRecord { pub user_id: String, @@ -970,6 +1074,73 @@ pub fn build_runtime_referral_redeem_input( }) } +pub fn build_runtime_profile_reward_code_redeem_input( + user_id: String, + code: String, + redeemed_at_micros: i64, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + Ok(RuntimeProfileRewardCodeRedeemInput { + user_id, + code, + redeemed_at_micros, + }) +} + +pub fn build_runtime_profile_redeem_code_admin_upsert_input( + admin_user_id: String, + code: String, + mode: RuntimeProfileRedeemCodeMode, + reward_points: u64, + max_uses: u32, + enabled: bool, + allowed_user_ids: Vec, + allowed_public_user_codes: Vec, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + if reward_points == 0 { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); + } + if max_uses == 0 { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses); + } + + Ok(RuntimeProfileRedeemCodeAdminUpsertInput { + admin_user_id, + code, + mode, + reward_points, + max_uses, + enabled, + allowed_user_ids: allowed_user_ids + .into_iter() + .filter_map(|value| normalize_optional_string(Some(value))) + .collect(), + allowed_public_user_codes: allowed_public_user_codes + .into_iter() + .filter_map(|value| normalize_optional_string(Some(value))) + .collect(), + updated_at_micros, + }) +} + +pub fn build_runtime_profile_redeem_code_admin_disable_input( + admin_user_id: String, + code: String, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + Ok(RuntimeProfileRedeemCodeAdminDisableInput { + admin_user_id, + code, + updated_at_micros, + }) +} + pub fn build_runtime_profile_play_stats_get_input( user_id: String, ) -> Result { @@ -1323,6 +1494,35 @@ pub fn build_runtime_referral_redeem_record( } } +pub fn build_runtime_profile_reward_code_redeem_record( + snapshot: RuntimeProfileRewardCodeRedeemSnapshot, +) -> RuntimeProfileRewardCodeRedeemRecord { + RuntimeProfileRewardCodeRedeemRecord { + wallet_balance: snapshot.wallet_balance, + amount_granted: snapshot.amount_granted, + ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry), + } +} + +pub fn build_runtime_profile_redeem_code_record( + snapshot: RuntimeProfileRedeemCodeSnapshot, +) -> RuntimeProfileRedeemCodeRecord { + RuntimeProfileRedeemCodeRecord { + code: snapshot.code, + mode: snapshot.mode, + reward_points: snapshot.reward_points, + max_uses: snapshot.max_uses, + global_used_count: snapshot.global_used_count, + enabled: snapshot.enabled, + allowed_user_ids: snapshot.allowed_user_ids, + created_by: snapshot.created_by, + created_at: format_utc_micros(snapshot.created_at_micros), + created_at_micros: snapshot.created_at_micros, + updated_at: format_utc_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + pub fn build_runtime_profile_played_world_record( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> RuntimeProfilePlayedWorldRecord { @@ -1508,6 +1708,17 @@ impl RuntimeProfileWalletLedgerSourceType { Self::PointsRecharge => "points_recharge", Self::AssetGenerationConsume => "asset_generation_consume", Self::AssetGenerationRefund => "asset_generation_refund", + Self::RedeemCodeReward => "redeem_code_reward", + } + } +} + +impl RuntimeProfileRedeemCodeMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Public => "public", + Self::Unique => "unique", + Self::Private => "private", } } } @@ -1736,6 +1947,10 @@ pub fn normalize_invite_code(value: String) -> Option { } } +pub fn normalize_redeem_code(value: String) -> Option { + normalize_invite_code(value) +} + impl std::fmt::Display for RuntimeProfileFieldError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), + Self::MissingRedeemCode => f.write_str("兑换码不能为空"), + Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), + Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 57d5671d..6a89d016 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -11,6 +11,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str = "asset_generation_consume"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = "asset_generation_refund"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -258,6 +259,60 @@ pub struct RedeemProfileReferralInviteCodeResponse { pub inviter_balance_after: u64, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RedeemProfileRewardCodeRequest { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RedeemProfileRewardCodeResponse { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: ProfileWalletLedgerEntryResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileRedeemCodeRequest { + pub code: String, + pub mode: String, + pub reward_points: u64, + pub max_uses: u32, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub allowed_user_ids: Vec, + #[serde(default)] + pub allowed_public_user_codes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDisableProfileRedeemCodeRequest { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileRedeemCodeAdminResponse { + pub code: String, + pub mode: String, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} + +fn default_true() -> bool { + true +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfilePlayedWorkSummaryResponse { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f990651d..cfbe290e 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,10 +30,10 @@ pub use mapper::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; @@ -120,6 +120,8 @@ use module_runtime::{ RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, @@ -129,8 +131,12 @@ use module_runtime::{ build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, - build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record, - build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input, + build_runtime_profile_redeem_code_admin_disable_input, + build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, + build_runtime_profile_reward_code_redeem_input, + build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input, + build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input, + build_runtime_profile_wallet_adjustment_input, build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 22d85795..c7a8f4dd 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -161,6 +161,48 @@ impl From } } +impl From + for RuntimeProfileRewardCodeRedeemInput +{ + fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self { + Self { + user_id: input.user_id, + code: input.code, + redeemed_at_micros: input.redeemed_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + mode: map_runtime_profile_redeem_code_mode(input.mode), + reward_points: input.reward_points, + max_uses: input.max_uses, + enabled: input.enabled, + allowed_user_ids: input.allowed_user_ids, + allowed_public_user_codes: input.allowed_public_user_codes, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminDisableInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for RuntimeReferralInviteCenterGetInput { @@ -802,6 +844,48 @@ pub(crate) fn map_runtime_referral_redeem_procedure_result( )) } +pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( + result: RuntimeProfileRewardCodeRedeemProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 reward redeem 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_reward_code_redeem_record( + map_runtime_profile_reward_code_redeem_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( + result: RuntimeProfileRedeemCodeAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 redeem code 快照".to_string()) + })?; + + Ok(build_runtime_profile_redeem_code_record( + map_runtime_profile_redeem_code_snapshot(snapshot), + )) +} + pub(crate) fn map_runtime_profile_play_stats_procedure_result( result: RuntimeProfilePlayStatsProcedureResult, ) -> Result { @@ -1666,6 +1750,33 @@ pub(crate) fn map_runtime_referral_redeem_snapshot( } } +pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot( + snapshot: RuntimeProfileRewardCodeRedeemSnapshot, +) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + wallet_balance: snapshot.wallet_balance, + amount_granted: snapshot.amount_granted, + ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry), + } +} + +pub(crate) fn map_runtime_profile_redeem_code_snapshot( + snapshot: RuntimeProfileRedeemCodeSnapshot, +) -> module_runtime::RuntimeProfileRedeemCodeSnapshot { + module_runtime::RuntimeProfileRedeemCodeSnapshot { + code: snapshot.code, + mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode), + reward_points: snapshot.reward_points, + max_uses: snapshot.max_uses, + global_used_count: snapshot.global_used_count, + enabled: snapshot.enabled, + allowed_user_ids: snapshot.allowed_user_ids, + created_by: snapshot.created_by, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_profile_played_world_snapshot( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { @@ -3277,6 +3388,41 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode( + value: module_runtime::RuntimeProfileRedeemCodeMode, +) -> crate::module_bindings::RuntimeProfileRedeemCodeMode { + match value { + module_runtime::RuntimeProfileRedeemCodeMode::Public => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public + } + module_runtime::RuntimeProfileRedeemCodeMode::Unique => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique + } + module_runtime::RuntimeProfileRedeemCodeMode::Private => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode_back( + value: crate::module_bindings::RuntimeProfileRedeemCodeMode, +) -> module_runtime::RuntimeProfileRedeemCodeMode { + match value { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => { + module_runtime::RuntimeProfileRedeemCodeMode::Public + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => { + module_runtime::RuntimeProfileRedeemCodeMode::Unique + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => { + module_runtime::RuntimeProfileRedeemCodeMode::Private + } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs new file mode 100644 index 00000000..c254d1f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs @@ -0,0 +1,53 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminDisableProfileRedeemCodeArgs { + pub input: RuntimeProfileRedeemCodeAdminDisableInput, +} + +impl __sdk::InModule for AdminDisableProfileRedeemCodeArgs { + type Module = super::RemoteModule; +} + +pub trait admin_disable_profile_redeem_code { + fn admin_disable_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminDisableInput) { + self.admin_disable_profile_redeem_code_then(input, |_, _| {}); + } + + fn admin_disable_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminDisableInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_disable_profile_redeem_code for super::RemoteProcedures { + fn admin_disable_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminDisableInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( + "admin_disable_profile_redeem_code", + AdminDisableProfileRedeemCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs new file mode 100644 index 00000000..cafe2382 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs @@ -0,0 +1,53 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; +use super::runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileRedeemCodeArgs { + pub input: RuntimeProfileRedeemCodeAdminUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileRedeemCodeArgs { + type Module = super::RemoteModule; +} + +pub trait admin_upsert_profile_redeem_code { + fn admin_upsert_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminUpsertInput) { + self.admin_upsert_profile_redeem_code_then(input, |_, _| {}); + } + + fn admin_upsert_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminUpsertInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_redeem_code for super::RemoteProcedures { + fn admin_upsert_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminUpsertInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( + "admin_upsert_profile_redeem_code", + AdminUpsertProfileRedeemCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 76bf4e34..9acfb86d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -250,6 +250,9 @@ pub mod list_custom_world_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; +pub mod redeem_profile_reward_code_procedure; +pub mod admin_upsert_profile_redeem_code_procedure; +pub mod admin_disable_profile_redeem_code_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; pub mod npc_battle_interaction_procedure_result_type; @@ -413,6 +416,14 @@ pub mod runtime_profile_wallet_ledger_entry_snapshot_type; pub mod runtime_profile_wallet_ledger_list_input_type; pub mod runtime_profile_wallet_ledger_procedure_result_type; pub mod runtime_profile_wallet_ledger_source_type_type; +pub mod runtime_profile_redeem_code_mode_type; +pub mod runtime_profile_reward_code_redeem_input_type; +pub mod runtime_profile_reward_code_redeem_snapshot_type; +pub mod runtime_profile_reward_code_redeem_procedure_result_type; +pub mod runtime_profile_redeem_code_admin_upsert_input_type; +pub mod runtime_profile_redeem_code_admin_disable_input_type; +pub mod runtime_profile_redeem_code_snapshot_type; +pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; @@ -719,6 +730,9 @@ pub use list_custom_world_works_procedure::list_custom_world_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; +pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; +pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; +pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; @@ -882,6 +896,14 @@ pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletL pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput; pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult; pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; +pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; +pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; +pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; +pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; +pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; +pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; +pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs new file mode 100644 index 00000000..5f5e7400 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs @@ -0,0 +1,53 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; +use super::runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RedeemProfileRewardCodeArgs { + pub input: RuntimeProfileRewardCodeRedeemInput, +} + +impl __sdk::InModule for RedeemProfileRewardCodeArgs { + type Module = super::RemoteModule; +} + +pub trait redeem_profile_reward_code { + fn redeem_profile_reward_code(&self, input: RuntimeProfileRewardCodeRedeemInput) { + self.redeem_profile_reward_code_then(input, |_, _| {}); + } + + fn redeem_profile_reward_code_then( + &self, + input: RuntimeProfileRewardCodeRedeemInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl redeem_profile_reward_code for super::RemoteProcedures { + fn redeem_profile_reward_code_then( + &self, + input: RuntimeProfileRewardCodeRedeemInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>( + "redeem_profile_reward_code", + RedeemProfileRewardCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs new file mode 100644 index 00000000..5a7ed897 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminDisableInput { + pub admin_user_id: String, + pub code: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminDisableInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs new file mode 100644 index 00000000..62254ff9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs new file mode 100644 index 00000000..5f18a875 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminUpsertInput { + pub admin_user_id: String, + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub allowed_public_user_codes: Vec, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs new file mode 100644 index 00000000..4bea6d79 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRedeemCodeMode { + Public, + + Unique, + + Private, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs new file mode 100644 index 00000000..aea09f25 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeSnapshot { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs new file mode 100644 index 00000000..e99bc781 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemInput { + pub user_id: String, + pub code: String, + pub redeemed_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs new file mode 100644 index 00000000..dd8936d7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs new file mode 100644 index 00000000..614e5d78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemSnapshot { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index fc2093e3..3697b09f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -19,6 +19,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { AssetGenerationConsume, AssetGenerationRefund, + + RedeemCodeReward, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 67560b74..f95407cf 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -255,6 +255,97 @@ impl SpacetimeClient { .await } + pub async fn redeem_profile_reward_code( + &self, + user_id: String, + code: String, + redeemed_at_micros: i64, + ) -> Result { + let procedure_input = + build_runtime_profile_reward_code_redeem_input(user_id, code, redeemed_at_micros) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection.procedures().redeem_profile_reward_code_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_reward_code_redeem_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn admin_upsert_profile_redeem_code( + &self, + admin_user_id: String, + code: String, + mode: DomainRuntimeProfileRedeemCodeMode, + reward_points: u64, + max_uses: u32, + enabled: bool, + allowed_user_ids: Vec, + allowed_public_user_codes: Vec, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input( + admin_user_id, + code, + mode, + reward_points, + max_uses, + enabled, + allowed_user_ids, + allowed_public_user_codes, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn admin_disable_profile_redeem_code( + &self, + admin_user_id: String, + code: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_redeem_code_admin_disable_input( + admin_user_id, + code, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_disable_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_profile_play_stats( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 37a52649..34f500a4 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -109,6 +109,8 @@ macro_rules! migration_tables { user_browse_history, profile_dashboard_state, profile_wallet_ledger, + profile_redeem_code, + profile_redeem_code_usage, profile_invite_code, profile_referral_relation, profile_played_world, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 09ca0cc7..0042254a 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -28,6 +28,39 @@ pub struct ProfileWalletLedger { pub(crate) created_at: Timestamp, } +#[spacetimedb::table(accessor = profile_redeem_code)] +pub struct ProfileRedeemCode { + #[primary_key] + pub(crate) code: String, + pub(crate) mode: RuntimeProfileRedeemCodeMode, + pub(crate) reward_points: u64, + pub(crate) max_uses: u32, + pub(crate) global_used_count: u32, + pub(crate) enabled: bool, + pub(crate) allowed_user_ids: Vec, + pub(crate) created_by: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = profile_redeem_code_usage, + index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])), + index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])), + index( + accessor = by_profile_redeem_code_usage_code_user_id, + btree(columns = [code, user_id]) + ) +)] +pub struct ProfileRedeemCodeUsage { + #[primary_key] + pub(crate) usage_id: String, + pub(crate) code: String, + pub(crate) user_id: String, + pub(crate) amount_granted: u64, + pub(crate) created_at: Timestamp, +} + #[spacetimedb::table(accessor = profile_invite_code)] pub struct ProfileInviteCode { #[primary_key] @@ -396,6 +429,64 @@ pub fn redeem_profile_referral_invite_code( } } +// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。 +#[spacetimedb::procedure] +pub fn redeem_profile_reward_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRewardCodeRedeemInput, +) -> RuntimeProfileRewardCodeRedeemProcedureResult { + match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_upsert_profile_redeem_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRedeemCodeAdminUpsertInput, +) -> RuntimeProfileRedeemCodeAdminProcedureResult { + match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_disable_profile_redeem_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRedeemCodeAdminDisableInput, +) -> RuntimeProfileRedeemCodeAdminProcedureResult { + match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + pub(crate) fn list_profile_save_archive_rows( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveListInput, @@ -1194,6 +1285,185 @@ fn redeem_profile_referral_invite_code_record( }) } +fn redeem_profile_reward_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRewardCodeRedeemInput, +) -> Result { + let validated_input = build_runtime_profile_reward_code_redeem_input( + input.user_id, + input.code, + input.redeemed_at_micros, + ) + .map_err(|error| error.to_string())?; + let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); + let user_id = validated_input.user_id; + let code = validated_input.code; + let redeem_code = ctx + .db + .profile_redeem_code() + .code() + .find(&code) + .ok_or_else(|| "兑换码不存在".to_string())?; + + if !redeem_code.enabled { + return Err("兑换码已停用".to_string()); + } + if redeem_code.reward_points == 0 { + return Err("兑换码奖励无效".to_string()); + } + + let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); + match redeem_code.mode { + RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => { + return Err("兑换次数已用完".to_string()); + } + RuntimeProfileRedeemCodeMode::Unique + if redeem_code.global_used_count >= redeem_code.max_uses => + { + return Err("兑换次数已用完".to_string()); + } + RuntimeProfileRedeemCodeMode::Private => { + if !redeem_code + .allowed_user_ids + .iter() + .any(|item| item == &user_id) + { + return Err("该兑换码不适用于当前账号".to_string()); + } + if redeem_code.global_used_count >= redeem_code.max_uses { + return Err("兑换次数已用完".to_string()); + } + } + _ => {} + } + + let usage_id = build_profile_redeem_code_usage_id( + ctx, + &code, + &user_id, + validated_input.redeemed_at_micros, + ); + let wallet_ledger_id = format!("{}:ledger", usage_id); + let wallet_balance = apply_profile_wallet_delta( + ctx, + &user_id, + redeem_code.reward_points, + RuntimeProfileWalletLedgerSourceType::RedeemCodeReward, + &wallet_ledger_id, + redeemed_at, + )?; + + ctx.db + .profile_redeem_code_usage() + .insert(ProfileRedeemCodeUsage { + usage_id, + code: code.clone(), + user_id, + amount_granted: redeem_code.reward_points, + created_at: redeemed_at, + }); + + let next_code = ProfileRedeemCode { + global_used_count: redeem_code.global_used_count.saturating_add(1), + updated_at: redeemed_at, + ..redeem_code + }; + ctx.db.profile_redeem_code().code().delete(&code); + ctx.db.profile_redeem_code().insert(next_code); + + let ledger_entry = ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&wallet_ledger_id) + .ok_or_else(|| "兑换码钱包流水写入失败".to_string())?; + + Ok(RuntimeProfileRewardCodeRedeemSnapshot { + wallet_balance, + amount_granted: ledger_entry.amount_delta.max(0) as u64, + ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry), + }) +} + +fn admin_upsert_profile_redeem_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRedeemCodeAdminUpsertInput, +) -> Result { + let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( + input.admin_user_id, + input.code, + input.mode, + input.reward_points, + input.max_uses, + input.enabled, + input.allowed_user_ids, + input.allowed_public_user_codes, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; + let existing = ctx + .db + .profile_redeem_code() + .code() + .find(&validated_input.code); + let created_at = existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(updated_at); + let global_used_count = existing + .as_ref() + .map(|row| row.global_used_count) + .unwrap_or(0); + + if let Some(existing) = existing { + ctx.db.profile_redeem_code().code().delete(&existing.code); + } + + let row = ProfileRedeemCode { + code: validated_input.code, + mode: validated_input.mode, + reward_points: validated_input.reward_points, + max_uses: validated_input.max_uses, + global_used_count, + enabled: validated_input.enabled, + allowed_user_ids, + created_by: validated_input.admin_user_id, + created_at, + updated_at, + }; + let inserted = ctx.db.profile_redeem_code().insert(row); + Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) +} + +fn admin_disable_profile_redeem_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRedeemCodeAdminDisableInput, +) -> Result { + let validated_input = build_runtime_profile_redeem_code_admin_disable_input( + input.admin_user_id, + input.code, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let existing = ctx + .db + .profile_redeem_code() + .code() + .find(&validated_input.code) + .ok_or_else(|| "兑换码不存在".to_string())?; + + ctx.db.profile_redeem_code().code().delete(&existing.code); + let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { + enabled: false, + updated_at, + ..existing + }); + Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) +} + fn build_profile_referral_invite_center_snapshot( ctx: &ReducerContext, user_id: &str, @@ -1579,6 +1849,79 @@ fn latest_profile_recharge_order( orders.into_iter().next() } +fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 { + ctx.db + .profile_redeem_code_usage() + .iter() + .filter(|row| row.code == code && row.user_id == user_id) + .count() as u32 +} + +fn build_profile_redeem_code_usage_id( + ctx: &ReducerContext, + code: &str, + user_id: &str, + redeemed_at_micros: i64, +) -> String { + let sequence = ctx + .db + .profile_redeem_code_usage() + .iter() + .filter(|row| row.code == code && row.user_id == user_id) + .count(); + format!( + "redeem:{}:{}:{}:{}", + code, user_id, redeemed_at_micros, sequence + ) +} + +fn resolve_profile_redeem_code_allowed_user_ids( + ctx: &ReducerContext, + input: &RuntimeProfileRedeemCodeAdminUpsertInput, +) -> Result, String> { + if input.mode != RuntimeProfileRedeemCodeMode::Private { + return Ok(Vec::new()); + } + + let mut allowed_user_ids = input.allowed_user_ids.clone(); + for public_user_code in &input.allowed_public_user_codes { + if let Some(account) = ctx + .db + .user_account() + .by_user_account_public_code() + .filter(public_user_code) + .next() + { + allowed_user_ids.push(account.user_id); + } + } + allowed_user_ids.sort(); + allowed_user_ids.dedup(); + + if allowed_user_ids.is_empty() { + return Err("私有兑换码必须指定可兑换用户".to_string()); + } + + Ok(allowed_user_ids) +} + +fn build_profile_redeem_code_snapshot_from_row( + row: &ProfileRedeemCode, +) -> RuntimeProfileRedeemCodeSnapshot { + RuntimeProfileRedeemCodeSnapshot { + code: row.code.clone(), + mode: row.mode, + reward_points: row.reward_points, + max_uses: row.max_uses, + global_used_count: row.global_used_count, + enabled: row.enabled, + allowed_user_ids: row.allowed_user_ids.clone(), + created_by: row.created_by.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn build_profile_wallet_ledger_snapshot_from_row( row: &ProfileWalletLedger, ) -> RuntimeProfileWalletLedgerEntrySnapshot { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 5b9d370c..b2e5462e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -8,7 +8,6 @@ import { Clock3, Coins, Copy, - Crown, House, LogIn, MessageCircle, @@ -34,19 +33,17 @@ import type { PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, - ProfileRechargeCenterResponse, - ProfileRechargeProduct, ProfileReferralInviteCenterResponse, ProfileSaveArchiveSummary, RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeResponse, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { AuthUser } from '../../services/authService'; import { - createRpgProfileRechargeOrder, - getRpgProfileRechargeCenter, getRpgProfileReferralInviteCenter, redeemRpgProfileReferralInviteCode, + redeemRpgProfileRewardCode, } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -910,206 +907,68 @@ function ProfileShortcutButton({ ); } -function formatRechargePrice(priceCents: number) { - const yuan = priceCents / 100; - return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`; -} - -function formatMembershipDuration(days: number) { - if (days >= 365) { - return '365天'; - } - - return `${days}天`; -} - -function AccountRechargeModal({ - center, - activeTab, - isLoading, +function RewardCodeRedeemModal({ + value, isSubmitting, error, - onTabChange, + success, + onChange, + onSubmit, onClose, - onSelectProduct, }: { - center: ProfileRechargeCenterResponse | null; - activeTab: 'points' | 'membership'; - isLoading: boolean; - isSubmitting: string | null; + value: string; + isSubmitting: boolean; error: string | null; - onTabChange: (tab: 'points' | 'membership') => void; + success: string | null; + onChange: (value: string) => void; + onSubmit: () => void; onClose: () => void; - onSelectProduct: (product: ProfileRechargeProduct) => void; }) { - const visibleProducts = - activeTab === 'points' - ? (center?.pointProducts ?? []) - : (center?.membershipProducts ?? []); - return ( -
-
- -
-
-
- WALLET -
-
账户充值
-
- - - {center ? `${center.walletBalance}叙世币` : '叙世币账户'} - -
-
- -
- - -
- +
+
+
+
兑换码
+ +
+
+ onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onSubmit(); + } + }} + className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal" + placeholder="输入兑换码" + autoFocus + /> + {error ? ( -
+
{error}
) : null} - - {isLoading ? ( -
- {Array.from({ length: activeTab === 'points' ? 6 : 3 }).map( - (_, index) => ( -
- ), - )} + {success ? ( +
+ {success}
- ) : activeTab === 'points' ? ( -
- {visibleProducts.map((product) => ( - - ))} -
- ) : ( - <> -
- {visibleProducts.map((product) => ( - - ))} -
-
-
- 用户等级特权 -
-
-
- {center?.benefits.map((benefit) => ( -
-
- {benefit.benefitName} -
-
- {benefit.normalValue} -
-
- {benefit.monthValue} -
-
- {benefit.seasonValue} -
-
- {benefit.yearValue} -
-
- ))} -
-
-
- - )} + ) : null}
@@ -1294,16 +1153,13 @@ export function RpgEntryHomeView({ const authUi = useAuthUi(); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); - const [isRechargeOpen, setIsRechargeOpen] = useState(false); - const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>( - 'points', + const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false); + const [rewardCodeInput, setRewardCodeInput] = useState(''); + const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false); + const [rewardCodeError, setRewardCodeError] = useState(null); + const [rewardCodeSuccess, setRewardCodeSuccess] = useState( + null, ); - const [rechargeCenter, setRechargeCenter] = - useState(null); - const [rechargeError, setRechargeError] = useState(null); - const [isLoadingRecharge, setIsLoadingRecharge] = useState(false); - const [submittingRechargeProductId, setSubmittingRechargeProductId] = - useState(null); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -1401,36 +1257,6 @@ export function RpgEntryHomeView({ } authUi?.openLoginModal(); }; - const openRechargePanel = () => { - setIsRechargeOpen(true); - setRechargeError(null); - setIsLoadingRecharge(true); - void getRpgProfileRechargeCenter() - .then(setRechargeCenter) - .catch((error: unknown) => { - setRechargeCenter(null); - setRechargeError( - error instanceof Error ? error.message : '读取账户充值失败', - ); - }) - .finally(() => setIsLoadingRecharge(false)); - }; - const submitRechargeProduct = (product: ProfileRechargeProduct) => { - if (submittingRechargeProductId) { - return; - } - setSubmittingRechargeProductId(product.productId); - setRechargeError(null); - void createRpgProfileRechargeOrder(product.productId) - .then((response) => { - setRechargeCenter(response.center); - void onRechargeSuccess?.(); - }) - .catch((error: unknown) => { - setRechargeError(error instanceof Error ? error.message : '充值失败'); - }) - .finally(() => setSubmittingRechargeProductId(null)); - }; const openProfilePopupPanel = (panel: ProfilePopupPanel) => { setProfilePopupPanel(panel); setReferralError(null); @@ -1486,6 +1312,30 @@ export function RpgEntryHomeView({ }) .finally(() => setIsSubmittingReferral(false)); }; + const openRewardCodeModal = () => { + setIsRewardCodeOpen(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + }; + const submitRewardCode = () => { + if (isSubmittingRewardCode || !rewardCodeInput.trim()) { + return; + } + + setIsSubmittingRewardCode(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + void redeemRpgProfileRewardCode(rewardCodeInput) + .then((response: RedeemProfileRewardCodeResponse) => { + setRewardCodeInput(''); + setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`); + void onRechargeSuccess?.(); + }) + .catch((error: unknown) => { + setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); + }) + .finally(() => setIsSubmittingRewardCode(false)); + }; const submitDesktopSearch = () => { const keyword = desktopSearchKeyword.trim(); if (!keyword || !onSearchPublicCode || isSearchingPublicCode) { @@ -1833,17 +1683,13 @@ export function RpgEntryHomeView({ @@ -2291,18 +2137,6 @@ export function RpgEntryHomeView({ ))}
- {isRechargeOpen ? ( - setIsRechargeOpen(false)} - onSelectProduct={submitRechargeProduct} - /> - ) : null} {profilePopupPanel ? (
- {isRechargeOpen ? ( - setIsRechargeOpen(false)} - onSelectProduct={submitRechargeProduct} + {isRewardCodeOpen ? ( + setIsRewardCodeOpen(false)} /> ) : null} {profilePopupPanel ? ( diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 6947ff68..fbdb9e0b 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -11,6 +11,7 @@ import type { ProfileSaveArchiveResumeResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeResponse, RuntimeSettings, } from '../../../packages/shared/src/contracts/runtime'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; @@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode( ); } +export function redeemRpgProfileRewardCode( + code: string, + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + '/profile/redeem-codes/redeem', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }, + '兑换失败', + options, + ); +} + export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/play-stats', From 377d7d0412ee4f6636af9b97206a70be4c221a00 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 12:58:31 +0800 Subject: [PATCH 4/7] Add user played work stats for puzzle and big fish --- .../BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md | 13 + ...OARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md | 18 ++ ...ZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md | 9 + packages/shared/src/contracts/bigFish.ts | 4 + server-rs/crates/api-server/src/app.rs | 14 +- server-rs/crates/api-server/src/big_fish.rs | 47 ++- server-rs/crates/module-big-fish/src/lib.rs | 20 +- .../crates/shared-contracts/src/big_fish.rs | 17 + .../crates/spacetime-client/src/big_fish.rs | 24 ++ server-rs/crates/spacetime-client/src/lib.rs | 23 +- .../crates/spacetime-client/src/mapper.rs | 8 + .../big_fish_play_report_input_type.rs | 18 ++ .../src/module_bindings/mod.rs | 8 +- .../record_big_fish_play_procedure.rs | 59 ++++ .../spacetime-module/src/big_fish/session.rs | 83 +++++ .../crates/spacetime-module/src/puzzle.rs | 85 +++-- .../spacetime-module/src/runtime/profile.rs | 177 +++++++++++ .../PlatformEntryFlowShellImpl.tsx | 300 +++++++++++++++--- src/components/rpg-entry/RpgEntryHomeView.tsx | 149 +++++++++ .../big-fish-runtime/bigFishRuntimeClient.ts | 33 ++ src/services/big-fish-runtime/index.ts | 1 + 21 files changed, 1028 insertions(+), 82 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_report_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs create mode 100644 src/services/big-fish-runtime/bigFishRuntimeClient.ts diff --git a/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md b/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md index 19b48c12..880e7206 100644 --- a/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md +++ b/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md @@ -17,6 +17,19 @@ 4. 入口必须在移动端单手可点,不遮挡舞台主体。 5. 规则内容只做说明,不参与任何前端裁决;真实规则仍以后端运行快照为准。 +## 游玩统计规则 + +所有作品都需要对自身以及用户做游玩统计。 + +大鱼吃小鱼正式运行时必须遵守: + +1. 正式开始游玩已发布作品时,更新作品自身播放统计。 +2. 已登录用户写入 `profile_played_world`,`world_key = big-fish:{session_id}`。 +3. `profile_id` 保存大鱼作品号/会话号,`world_type = BIG_FISH`。 +4. `world_title` 使用玩法草稿标题,`world_subtitle` 优先使用副标题,其次使用核心乐趣。 +5. `owner_user_id` 使用大鱼作品归属用户 ID。 +6. 退出或结算上报 `elapsedMs` 后,后端按增量刷新 `profile_dashboard_state.total_play_time_ms` 和明细中的 `last_observed_play_time_ms`。 + ## 落地范围 1. `src/components/big-fish-runtime/BigFishRuntimeShell.tsx` diff --git a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md index ee919e86..e7e29b20 100644 --- a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md +++ b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md @@ -169,6 +169,24 @@ Node 侧入口位于: ## 4. 本轮边界决议 +### 4.0 统一游玩统计规则 + +所有作品都需要对自身以及用户做游玩统计。 + +正式游玩开始时,玩法自己的作品真相表必须先更新自身统计;已登录用户还必须同步 upsert `profile_played_world` 明细。用户侧明细不是单纯计数,必须保留可跳转的稳定作品标识: + +1. `world_key` +2. `world_type` +3. `profile_id` +4. `world_title` +5. `world_subtitle` +6. `owner_user_id` +7. `first_played_at` +8. `last_played_at` +9. `last_observed_play_time_ms` + +当玩法有可观测时长时,后端按增量刷新 `profile_dashboard_state.total_play_time_ms`,并同步推进对应 `profile_played_world.last_observed_play_time_ms`。 + ### 4.1 先做 projection 读链 本轮 profile 三接口只做: diff --git a/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md index fb842857..da371b46 100644 --- a/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md +++ b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md @@ -39,6 +39,15 @@ 新增拼图成绩表,按“关卡作品 + 网格规格 + 用户”维护最佳成绩。 +正式开始拼图关卡时还必须同步用户玩过作品明细: + +1. 作品自身统计继续更新 `puzzle_work_profile.play_count`。 +2. 已登录用户写入 `profile_played_world`,`world_key = puzzle:{profile_id}`。 +3. `profile_id` 保存拼图作品号,`world_type = PUZZLE`。 +4. `world_title` 使用关卡名,`world_subtitle` 使用作品摘要,`owner_user_id` 使用拼图作者用户 ID。 +5. 下一关切换到新 `profile_id` 时按同一规则再次写入。 +6. 排行榜提交携带的 `elapsedMs` 是本关可观测时长,后端按增量累计到 `profile_dashboard_state.total_play_time_ms`。 + 建议字段: 1. `entry_id` diff --git a/packages/shared/src/contracts/bigFish.ts b/packages/shared/src/contracts/bigFish.ts index 4bedd2d1..5223f38e 100644 --- a/packages/shared/src/contracts/bigFish.ts +++ b/packages/shared/src/contracts/bigFish.ts @@ -25,6 +25,10 @@ export type ExecuteBigFishActionRequest = { motionKey?: 'idle_float' | 'move_swim' | string; }; +export type RecordBigFishPlayRequest = { + elapsedMs?: number; +}; + export type SubmitBigFishInputRequest = { x: number; y: number; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d732ca65..4d83692d 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -34,8 +34,8 @@ use crate::{ auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, - get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message, - submit_big_fish_message, + get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play, + stream_big_fish_message, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -83,8 +83,7 @@ use crate::{ get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, - submit_puzzle_leaderboard, - swap_puzzle_pieces, + submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -575,6 +574,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/sessions/{session_id}/play", + post(record_big_fish_play).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/agent/sessions", post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index b5f804e3..c99cdacc 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -24,7 +24,8 @@ use shared_contracts::big_fish::{ BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse, BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse, BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse, - CreateBigFishSessionRequest, ExecuteBigFishActionRequest, SendBigFishMessageRequest, + CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest, + SendBigFishMessageRequest, }; use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse}; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; @@ -32,8 +33,9 @@ use spacetime_client::{ BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, BigFishLevelBlueprintRecord, - BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, - BigFishSessionRecord, BigFishWorkSummaryRecord, SpacetimeClientError, + BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord, + SpacetimeClientError, }; use tokio::time::sleep; @@ -191,6 +193,45 @@ pub async fn delete_big_fish_work( )) } +pub async fn record_big_fish_play( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, 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, &session_id, "sessionId")?; + + let session = state + .spacetime_client() + .record_big_fish_play(BigFishPlayReportRecordInput { + session_id, + user_id: authenticated.claims().user_id().to_string(), + elapsed_ms: payload.elapsed_ms.unwrap_or(0), + reported_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), + BigFishSessionResponse { + session: map_big_fish_session_response(session), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index adeec338..b016616b 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -316,6 +316,15 @@ pub struct BigFishPublishInput { pub published_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishPlayReportInput { + pub session_id: String, + pub user_id: String, + pub elapsed_ms: u64, + pub reported_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum BigFishFieldError { MissingSessionId, @@ -654,6 +663,16 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish validate_session_owner(&input.session_id, &input.owner_user_id) } +pub fn validate_play_report_input(input: &BigFishPlayReportInput) -> Result<(), BigFishFieldError> { + if normalize_required_string(&input.session_id).is_none() { + return Err(BigFishFieldError::MissingSessionId); + } + if normalize_required_string(&input.user_id).is_none() { + return Err(BigFishFieldError::MissingOwnerUserId); + } + Ok(()) +} + pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result { serde_json::to_string(anchor_pack) } @@ -861,5 +880,4 @@ mod tests { ); assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); } - } diff --git a/server-rs/crates/shared-contracts/src/big_fish.rs b/server-rs/crates/shared-contracts/src/big_fish.rs index 49a69dca..bd25a93d 100644 --- a/server-rs/crates/shared-contracts/src/big_fish.rs +++ b/server-rs/crates/shared-contracts/src/big_fish.rs @@ -26,6 +26,13 @@ pub struct ExecuteBigFishActionRequest { pub motion_key: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecordBigFishPlayRequest { + #[serde(default)] + pub elapsed_ms: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BigFishAnchorItemResponse { @@ -189,4 +196,14 @@ mod tests { assert_eq!(payload["motionKey"], json!("move_swim")); assert_eq!(payload["level"], json!(3)); } + + #[test] + fn record_big_fish_play_request_uses_camel_case() { + let payload = serde_json::to_value(RecordBigFishPlayRequest { + elapsed_ms: Some(12_345), + }) + .expect("payload should serialize"); + + assert_eq!(payload, json!({ "elapsedMs": 12_345 })); + } } diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index fb8272ac..8a81db72 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -250,4 +250,28 @@ impl SpacetimeClient { }) .await } + + pub async fn record_big_fish_play( + &self, + input: BigFishPlayReportRecordInput, + ) -> Result { + let procedure_input = BigFishPlayReportInput { + session_id: input.session_id, + user_id: input.user_id, + elapsed_ms: input.elapsed_ms, + reported_at_micros: input.reported_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_big_fish_play_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f990651d..55fffd68 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -10,13 +10,14 @@ pub use mapper::{ BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, - BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, - BigFishSessionRecord, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, - CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, - CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, - CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, - CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, - CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, + BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord, + CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, + CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, + CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, + CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, + CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, @@ -30,10 +31,10 @@ pub use mapper::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 22d85795..b2938830 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4211,6 +4211,14 @@ pub struct PuzzleRunNextLevelRecordInput { pub advanced_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishPlayReportRecordInput { + pub session_id: String, + pub user_id: String, + pub elapsed_ms: u64, + pub reported_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAnchorItemRecord { pub key: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_report_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_report_input_type.rs new file mode 100644 index 00000000..e21918fd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_report_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishPlayReportInput { + pub session_id: String, + pub user_id: String, + pub elapsed_ms: u64, + pub reported_at_micros: i64, +} + +impl __sdk::InModule for BigFishPlayReportInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 76bf4e34..f08b0343 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -89,6 +89,7 @@ pub mod big_fish_game_draft_type; pub mod big_fish_level_blueprint_type; pub mod big_fish_message_finalize_input_type; pub mod big_fish_message_submit_input_type; +pub mod big_fish_play_report_input_type; pub mod big_fish_publish_input_type; pub mod big_fish_runtime_params_type; pub mod big_fish_session_create_input_type; @@ -343,9 +344,10 @@ pub mod quest_status_type; pub mod quest_step_snapshot_type; pub mod quest_treasure_inspected_signal_type; pub mod quest_turn_in_input_type; +pub mod record_big_fish_play_procedure; pub mod redeem_profile_referral_invite_code_procedure; -pub mod refund_profile_wallet_points_and_return_procedure; pub mod refresh_session_type; +pub mod refund_profile_wallet_points_and_return_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -558,6 +560,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft; pub use big_fish_level_blueprint_type::BigFishLevelBlueprint; pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput; pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput; +pub use big_fish_play_report_input_type::BigFishPlayReportInput; pub use big_fish_publish_input_type::BigFishPublishInput; pub use big_fish_runtime_params_type::BigFishRuntimeParams; pub use big_fish_session_create_input_type::BigFishSessionCreateInput; @@ -812,9 +815,10 @@ pub use quest_status_type::QuestStatus; pub use quest_step_snapshot_type::QuestStepSnapshot; pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; pub use quest_turn_in_input_type::QuestTurnInInput; +pub use record_big_fish_play_procedure::record_big_fish_play; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; -pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use refresh_session_type::RefreshSession; +pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs new file mode 100644 index 00000000..cbc7f400 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::big_fish_play_report_input_type::BigFishPlayReportInput; +use super::big_fish_session_procedure_result_type::BigFishSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordBigFishPlayArgs { + pub input: BigFishPlayReportInput, +} + +impl __sdk::InModule for RecordBigFishPlayArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_big_fish_play`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_big_fish_play { + fn record_big_fish_play(&self, input: BigFishPlayReportInput) { + self.record_big_fish_play_then(input, |_, _| {}); + } + + fn record_big_fish_play_then( + &self, + input: BigFishPlayReportInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_big_fish_play for super::RemoteProcedures { + fn record_big_fish_play_then( + &self, + input: BigFishPlayReportInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>( + "record_big_fish_play", + RecordBigFishPlayArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 01459d39..df959216 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -1,4 +1,7 @@ use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session}; +use crate::runtime::{ + ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, +}; use crate::*; const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0; @@ -150,6 +153,25 @@ pub fn compile_big_fish_draft( } } +#[spacetimedb::procedure] +pub fn record_big_fish_play( + ctx: &mut ProcedureContext, + input: BigFishPlayReportInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + pub(crate) fn create_big_fish_session_tx( ctx: &ReducerContext, input: BigFishSessionCreateInput, @@ -544,6 +566,67 @@ pub(crate) fn compile_big_fish_draft_tx( ) } +pub(crate) fn record_big_fish_play_tx( + ctx: &ReducerContext, + input: BigFishPlayReportInput, +) -> Result { + validate_play_report_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish_creation_session 不存在或尚未发布".to_string())?; + let draft = session + .draft_json + .as_deref() + .map(deserialize_draft) + .transpose() + .map_err(|error| format!("big_fish.draft_json 非法: {error}"))?; + let title = draft + .as_ref() + .map(|value| value.title.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "大鱼吃小鱼".to_string()); + let subtitle = draft + .as_ref() + .and_then(|value| { + let subtitle = value.subtitle.trim(); + if subtitle.is_empty() { + let core_fun = value.core_fun.trim(); + (!core_fun.is_empty()).then(|| core_fun.to_string()) + } else { + Some(subtitle.to_string()) + } + }) + .unwrap_or_default(); + let world_key = format!("big-fish:{}", session.session_id); + + upsert_profile_played_work( + ctx, + ProfilePlayedWorkUpsertInput { + user_id: input.user_id.clone(), + world_key: world_key.clone(), + owner_user_id: Some(session.owner_user_id.clone()), + profile_id: Some(session.session_id.clone()), + world_type: Some("BIG_FISH".to_string()), + world_title: title, + world_subtitle: subtitle, + played_at_micros: input.reported_at_micros, + }, + )?; + add_profile_observed_play_time( + ctx, + &input.user_id, + &world_key, + input.elapsed_ms, + input.reported_at_micros, + )?; + + build_big_fish_session_snapshot(ctx, &session) +} + pub(crate) fn build_big_fish_session_snapshot( ctx: &ReducerContext, row: &BigFishCreationSession, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a1087aa0..d3b34a62 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,12 +1,15 @@ +use crate::runtime::{ + ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, +}; use module_puzzle::{ PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, - PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, - PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, + PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, + PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, + PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, @@ -1072,6 +1075,12 @@ fn start_puzzle_run_tx( .map(|value| value.profile_id.clone()); increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros); + upsert_puzzle_profile_played_work( + ctx, + &input.owner_user_id, + &entry_profile_row, + input.started_at_micros, + )?; insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?; Ok(run) } @@ -1179,6 +1188,12 @@ fn advance_puzzle_next_level_tx( .find(&next_profile.profile_id) { increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros); + upsert_puzzle_profile_played_work( + ctx, + &input.owner_user_id, + &next_profile_row, + input.advanced_at_micros, + )?; } replace_puzzle_runtime_run(ctx, &row, &next_run, input.advanced_at_micros); Ok(next_run) @@ -1219,6 +1234,13 @@ fn submit_puzzle_leaderboard_entry_tx( &input.run_id, input.submitted_at_micros, ); + add_profile_observed_play_time( + ctx, + &input.owner_user_id, + &format!("puzzle:{}", input.profile_id), + input.elapsed_ms.max(1_000), + input.submitted_at_micros, + )?; let leaderboard_entries = list_puzzle_leaderboard_entries( ctx, @@ -1607,6 +1629,28 @@ fn increment_puzzle_profile_play_count( ); } +fn upsert_puzzle_profile_played_work( + ctx: &TxContext, + user_id: &str, + row: &PuzzleWorkProfileRow, + played_at_micros: i64, +) -> Result<(), String> { + // 拼图正式游玩以作品 profile_id 作为公开作品号,用户侧明细按 world_key 去重。 + upsert_profile_played_work( + ctx, + ProfilePlayedWorkUpsertInput { + user_id: user_id.to_string(), + world_key: format!("puzzle:{}", row.profile_id), + owner_user_id: Some(row.owner_user_id.clone()), + profile_id: Some(row.profile_id.clone()), + world_type: Some("PUZZLE".to_string()), + world_title: row.level_name.clone(), + world_subtitle: row.summary.clone(), + played_at_micros, + }, + ) +} + fn replace_generated_candidate( draft: &mut PuzzleResultDraft, candidates: Vec, @@ -1689,12 +1733,7 @@ fn upsert_puzzle_leaderboard_entry( ) { let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size); let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); - if let Some(existing) = ctx - .db - .puzzle_leaderboard_entry() - .entry_id() - .find(&entry_id) - { + if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) { let should_replace = elapsed_ms < existing.best_elapsed_ms || (elapsed_ms == existing.best_elapsed_ms && updated_at.to_micros_since_unix_epoch() @@ -1725,16 +1764,18 @@ fn upsert_puzzle_leaderboard_entry( return; } - ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow { - entry_id, - profile_id: profile_id.to_string(), - grid_size, - user_id: user_id.to_string(), - nickname: nickname.to_string(), - best_elapsed_ms: elapsed_ms, - last_run_id: run_id.to_string(), - updated_at, - }); + ctx.db + .puzzle_leaderboard_entry() + .insert(PuzzleLeaderboardEntryRow { + entry_id, + profile_id: profile_id.to_string(), + grid_size, + user_id: user_id.to_string(), + nickname: nickname.to_string(), + best_elapsed_ms: elapsed_ms, + last_run_id: run_id.to_string(), + updated_at, + }); } fn list_puzzle_leaderboard_entries( @@ -1799,8 +1840,8 @@ fn deserialize_run(value: &str) -> Result { mod tests { use super::*; use module_puzzle::{ - build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score, - PuzzleLeaderboardEntry, + PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack, + recommendation_score, tag_similarity_score, }; #[test] diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 09ca0cc7..74763cac 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -83,6 +83,17 @@ pub struct ProfilePlayedWorld { pub(crate) last_observed_play_time_ms: u64, } +pub(crate) struct ProfilePlayedWorkUpsertInput { + pub(crate) user_id: String, + pub(crate) world_key: String, + pub(crate) owner_user_id: Option, + pub(crate) profile_id: Option, + pub(crate) world_type: Option, + pub(crate) world_title: String, + pub(crate) world_subtitle: String, + pub(crate) played_at_micros: i64, +} + #[spacetimedb::table(accessor = profile_membership)] pub struct ProfileMembership { #[primary_key] @@ -498,6 +509,172 @@ pub(crate) fn sync_profile_projections_from_snapshot( Ok(()) } +pub(crate) fn upsert_profile_played_work( + ctx: &ReducerContext, + input: ProfilePlayedWorkUpsertInput, +) -> Result<(), String> { + let user_id = input.user_id.trim(); + let world_key = input.world_key.trim(); + if user_id.is_empty() { + return Err("profile_played_world.user_id 不能为空".to_string()); + } + if world_key.is_empty() { + return Err("profile_played_world.world_key 不能为空".to_string()); + } + + let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + let played_world_id = format!("{user_id}:{world_key}"); + let existing = ctx + .db + .profile_played_world() + .played_world_id() + .find(&played_world_id); + + if let Some(existing) = existing { + ctx.db + .profile_played_world() + .played_world_id() + .delete(&existing.played_world_id); + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: user_id.to_string(), + world_key: world_key.to_string(), + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_type: input.world_type, + world_title: input.world_title, + world_subtitle: input.world_subtitle, + first_played_at: existing.first_played_at, + last_played_at: played_at, + last_observed_play_time_ms: existing.last_observed_play_time_ms, + }); + } else { + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: user_id.to_string(), + world_key: world_key.to_string(), + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_type: input.world_type, + world_title: input.world_title, + world_subtitle: input.world_subtitle, + first_played_at: played_at, + last_played_at: played_at, + last_observed_play_time_ms: 0, + }); + } + + ensure_profile_dashboard_state(ctx, user_id, played_at); + Ok(()) +} + +pub(crate) fn add_profile_observed_play_time( + ctx: &ReducerContext, + user_id: &str, + world_key: &str, + elapsed_ms: u64, + observed_at_micros: i64, +) -> Result<(), String> { + let user_id = user_id.trim(); + let world_key = world_key.trim(); + if user_id.is_empty() || world_key.is_empty() || elapsed_ms == 0 { + return Ok(()); + } + + let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros); + let played_world_id = format!("{user_id}:{world_key}"); + if let Some(existing) = ctx + .db + .profile_played_world() + .played_world_id() + .find(&played_world_id) + { + ctx.db + .profile_played_world() + .played_world_id() + .delete(&existing.played_world_id); + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: existing.user_id, + world_key: existing.world_key, + owner_user_id: existing.owner_user_id, + profile_id: existing.profile_id, + world_type: existing.world_type, + world_title: existing.world_title, + world_subtitle: existing.world_subtitle, + first_played_at: existing.first_played_at, + last_played_at: observed_at, + last_observed_play_time_ms: existing + .last_observed_play_time_ms + .saturating_add(elapsed_ms), + }); + } + + add_profile_dashboard_play_time(ctx, user_id, elapsed_ms, observed_at); + Ok(()) +} + +fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { + if ctx + .db + .profile_dashboard_state() + .user_id() + .find(&user_id.to_string()) + .is_some() + { + return; + } + + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: user_id.to_string(), + wallet_balance: 0, + total_play_time_ms: 0, + created_at: updated_at, + updated_at, + }); +} + +fn add_profile_dashboard_play_time( + ctx: &ReducerContext, + user_id: &str, + elapsed_ms: u64, + updated_at: Timestamp, +) { + let current = ctx + .db + .profile_dashboard_state() + .user_id() + .find(&user_id.to_string()); + + if let Some(existing) = current { + ctx.db + .profile_dashboard_state() + .user_id() + .delete(&existing.user_id); + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: user_id.to_string(), + wallet_balance: existing.wallet_balance, + total_play_time_ms: existing.total_play_time_ms.saturating_add(elapsed_ms), + created_at: existing.created_at, + updated_at, + }); + } else { + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: user_id.to_string(), + wallet_balance: 0, + total_play_time_ms: elapsed_ms, + created_at: updated_at, + updated_at, + }); + } +} + fn sync_profile_dashboard_from_snapshot( ctx: &ReducerContext, snapshot: &RuntimeSnapshot, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 281d7331..68720509 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -36,6 +36,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, + ProfilePlayedWorkSummary, + ProfilePlayStatsResponse, } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { @@ -55,6 +57,7 @@ import { import { listBigFishGallery } from '../../services/big-fish-gallery'; import { advanceLocalBigFishRuntimeRun, + recordBigFishPlay, startLocalBigFishRuntimeRun, } from '../../services/big-fish-runtime'; import { @@ -105,6 +108,7 @@ import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetailByCode, } from '../../services/rpg-entry/rpgEntryLibraryClient'; +import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { @@ -152,6 +156,8 @@ type AgentResultBlockerView = { message: string; }; +type BigFishRuntimeSessionSource = 'draft' | 'work' | null; + const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ 'publish_missing_world_hook', 'publish_missing_player_premise', @@ -429,6 +435,13 @@ export function PlatformEntryFlowShellImpl({ title: string; publicWorkCode: string; } | null>(null); + const [bigFishRuntimeWork, setBigFishRuntimeWork] = + useState(null); + const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState< + number | null + >(null); + const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] = + useState(null); const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); const [bigFishGenerationState, setBigFishGenerationState] = useState(null); @@ -461,6 +474,14 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); + const [profilePlayStats, setProfilePlayStats] = + useState(null); + const [profilePlayStatsError, setProfilePlayStatsError] = useState< + string | null + >(null); + const [isProfilePlayStatsLoading, setIsProfilePlayStatsLoading] = + useState(false); + const [isProfilePlayStatsOpen, setIsProfilePlayStatsOpen] = useState(false); const hadReadableProtectedDataRef = useRef(false); const hasInitialAgentSession = Boolean( readCustomWorldAgentUiState().activeSessionId && @@ -973,7 +994,6 @@ export function PlatformEntryFlowShellImpl({ const bigFishError = bigFishFlow.error; const setBigFishError = bigFishFlow.setError; const isBigFishBusy = bigFishFlow.isBusy; - const setIsBigFishBusy = bigFishFlow.setIsBusy; const streamingBigFishReplyText = bigFishFlow.streamingReplyText; const isStreamingBigFishReply = bigFishFlow.isStreamingReply; @@ -1021,6 +1041,9 @@ export function PlatformEntryFlowShellImpl({ setBigFishWorks([]); setBigFishRun(null); setBigFishRuntimeShare(null); + setBigFishRuntimeWork(null); + setBigFishRuntimeStartedAt(null); + setBigFishRuntimeSessionSource(null); setBigFishGenerationState(null); setBigFishError(null); setPuzzleOperation(null); @@ -1032,6 +1055,9 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleNextLevelGenerating(false); setPuzzleError(null); setDeletingCreationWorkId(null); + setProfilePlayStats(null); + setProfilePlayStatsError(null); + setIsProfilePlayStatsOpen(false); resetRpgSessionViewState(); setRpgGeneratedCustomWorldProfile(null); setRpgCustomWorldError(null); @@ -1100,6 +1126,9 @@ export function PlatformEntryFlowShellImpl({ const leaveBigFishFlow = useCallback(() => { setBigFishRun(null); + setBigFishRuntimeWork(null); + setBigFishRuntimeStartedAt(null); + setBigFishRuntimeSessionSource(null); setBigFishGenerationState(null); bigFishFlow.leaveFlow(); }, [bigFishFlow]); @@ -1136,22 +1165,56 @@ export function PlatformEntryFlowShellImpl({ return; } + const sessionId = bigFishSession.sessionId; setBigFishError(null); setBigFishRuntimeShare(null); + setBigFishRuntimeWork(null); + setBigFishRuntimeStartedAt(Date.now()); + setBigFishRuntimeSessionSource('draft'); setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); setSelectionStage('big-fish-runtime'); - }, [bigFishSession, setSelectionStage]); + void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'), + ); + }); + }, [bigFishSession, resolveBigFishErrorMessage, setSelectionStage]); const restartBigFishRun = useCallback(() => { if (!bigFishSession && !bigFishRun) { return; } + const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId; + if (!sessionId) { + return; + } + setBigFishError(null); - setBigFishRuntimeShare(null); - setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); + if (bigFishSession) { + setBigFishRuntimeShare(null); + } + setBigFishRuntimeStartedAt(Date.now()); + setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work'); + setBigFishRun( + startLocalBigFishRuntimeRun({ + session: bigFishSession, + work: bigFishRuntimeWork, + }), + ); setSelectionStage('big-fish-runtime'); - }, [bigFishRun, bigFishSession, setSelectionStage]); + void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'), + ); + }); + }, [ + bigFishRun, + bigFishRuntimeWork, + bigFishSession, + resolveBigFishErrorMessage, + setSelectionStage, + ]); const startPuzzleRunFromProfile = useCallback( async (profileId: string) => { @@ -1241,12 +1304,33 @@ export function PlatformEntryFlowShellImpl({ } setBigFishRun((currentRun) => - currentRun ? advanceLocalBigFishRuntimeRun(currentRun, payload) : currentRun, + currentRun + ? advanceLocalBigFishRuntimeRun(currentRun, payload) + : currentRun, ); }, [bigFishRun], ); + const reportBigFishObservedPlayTime = useCallback(() => { + const sessionId = bigFishRun?.sessionId?.trim(); + if (!sessionId || !bigFishRuntimeStartedAt) { + return; + } + + const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt); + setBigFishRuntimeStartedAt(null); + void recordBigFishPlay(sessionId, { elapsedMs }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'), + ); + }); + }, [ + bigFishRun?.sessionId, + bigFishRuntimeStartedAt, + resolveBigFishErrorMessage, + ]); + const swapPuzzlePiecesInRun = useCallback( (payload: { firstPieceId: string; secondPieceId: string }) => { if (!puzzleRun || isPuzzleBusy) { @@ -1303,7 +1387,9 @@ export function PlatformEntryFlowShellImpl({ }) .catch((error) => { submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); - setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。')); + setPuzzleError( + resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'), + ); }) .finally(() => { setIsPuzzleLeaderboardBusy(false); @@ -1673,26 +1759,34 @@ export function PlatformEntryFlowShellImpl({ const startBigFishRunFromWork = useCallback( (item: BigFishWorkSummary) => { - const sessionId = item.sourceSessionId?.trim(); - if (!sessionId) { - setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); - return; - } + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); + return; + } - const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId); - setBigFishError(null); - bigFishFlow.setSession(null); - setBigFishRuntimeShare({ - title: item.title, - publicWorkCode, - }); - setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); - setSelectionStage('big-fish-runtime'); - pushAppHistoryPath( - buildPublicWorkStagePath('big-fish-runtime', publicWorkCode), - ); - }, - [bigFishFlow, setSelectionStage], + const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId); + setBigFishError(null); + bigFishFlow.setSession(null); + setBigFishRuntimeWork(item); + setBigFishRuntimeShare({ + title: item.title, + publicWorkCode, + }); + setBigFishRuntimeStartedAt(Date.now()); + setBigFishRuntimeSessionSource('work'); + setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); + setSelectionStage('big-fish-runtime'); + pushAppHistoryPath( + buildPublicWorkStagePath('big-fish-runtime', publicWorkCode), + ); + void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'), + ); + }); + }, + [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage], ); const handlePublicCodeSearch = useCallback( @@ -1841,6 +1935,118 @@ export function PlatformEntryFlowShellImpl({ ], ); + const openProfilePlayedWorks = useCallback(() => { + setIsProfilePlayStatsOpen(true); + setIsProfilePlayStatsLoading(true); + setProfilePlayStatsError(null); + + void getRpgProfilePlayStats() + .then(setProfilePlayStats) + .catch((error) => { + setProfilePlayStats(null); + setProfilePlayStatsError( + resolveRpgCreationErrorMessage(error, '读取玩过作品失败。'), + ); + }) + .finally(() => { + setIsProfilePlayStatsLoading(false); + }); + }, []); + + const openPlayedWork = useCallback( + (work: ProfilePlayedWorkSummary) => { + const worldType = (work.worldType ?? '').toLowerCase(); + setIsProfilePlayStatsOpen(false); + + if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) { + const profileId = + work.profileId ?? work.worldKey.replace(/^puzzle:/u, ''); + if (profileId) { + void openPuzzleDetail(profileId, { tab: 'profile' }); + } + return; + } + + if ( + worldType === 'big_fish' || + worldType === 'big-fish' || + work.worldKey.startsWith('big-fish:') + ) { + const sessionId = + work.profileId ?? work.worldKey.replace(/^big-fish:/u, ''); + if (!sessionId) { + return; + } + void refreshBigFishGallery() + .then((entries) => { + const matchedEntry = entries.find( + (entry) => entry.sourceSessionId === sessionId, + ); + if (matchedEntry) { + startBigFishRunFromWork(matchedEntry); + return; + } + startBigFishRunFromWork({ + workId: `big-fish:${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: work.ownerUserId ?? '', + title: work.worldTitle, + subtitle: work.worldSubtitle, + summary: work.worldSubtitle, + coverImageSrc: null, + status: 'published', + updatedAt: work.lastPlayedAt, + publishReady: true, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }); + }) + .catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'), + ); + }); + return; + } + + const profileId = work.profileId ?? work.worldKey; + const ownerUserId = work.ownerUserId; + if (!ownerUserId || !profileId) { + return; + } + + runProtectedAction(() => { + void detailNavigation.openGalleryDetail({ + ownerUserId, + profileId, + publicWorkCode: null, + authorPublicUserCode: null, + visibility: 'published', + publishedAt: work.firstPlayedAt, + updatedAt: work.lastPlayedAt, + authorDisplayName: work.worldSubtitle, + worldName: work.worldTitle, + subtitle: work.worldSubtitle, + summaryText: '', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + }); + }); + }, + [ + detailNavigation, + openPuzzleDetail, + refreshBigFishGallery, + resolveBigFishErrorMessage, + runProtectedAction, + startBigFishRunFromWork, + ], + ); + useEffect(() => { const publicWorkCode = initialPublicWorkCode?.trim(); if ( @@ -2096,7 +2302,19 @@ export function PlatformEntryFlowShellImpl({ void handlePublicCodeSearch(keyword); }} isSearchingPublicCode={isSearchingPublicCode} - onOpenProfileDashboardCard={() => { + profilePlayStats={profilePlayStats} + isProfilePlayStatsOpen={isProfilePlayStatsOpen} + isProfilePlayStatsLoading={isProfilePlayStatsLoading} + profilePlayStatsError={profilePlayStatsError} + onCloseProfilePlayStats={() => { + setIsProfilePlayStatsOpen(false); + }} + onOpenPlayedWork={openPlayedWork} + onOpenProfileDashboardCard={(cardKey) => { + if (cardKey === 'playedWorks') { + openProfilePlayedWorks(); + return; + } if (platformBootstrap.dashboardError) { void platformBootstrap.refreshProfileDashboard(); } @@ -2349,11 +2567,15 @@ export function PlatformEntryFlowShellImpl({ isBusy={isBigFishBusy} error={bigFishError} onBack={() => { + reportBigFishObservedPlayTime(); setSelectionStage( - bigFishSession ? 'big-fish-result' : 'platform', + bigFishRuntimeSessionSource === 'draft' + ? 'big-fish-result' + : 'platform', ); }} onRestart={() => { + reportBigFishObservedPlayTime(); void restartBigFishRun(); }} onSubmitInput={submitBigFishInput} @@ -2517,17 +2739,17 @@ export function PlatformEntryFlowShellImpl({ } > - { - setSelectionStage(puzzleRuntimeReturnStage); - }} + { + setSelectionStage(puzzleRuntimeReturnStage); + }} onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); }} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 5b9d370c..f34f3208 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -34,6 +34,8 @@ import type { PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, + ProfilePlayedWorkSummary, + ProfilePlayStatsResponse, ProfileRechargeCenterResponse, ProfileRechargeProduct, ProfileReferralInviteCenterResponse, @@ -102,6 +104,12 @@ export interface RpgEntryHomeViewProps { onSearchPublicCode?: (keyword: string) => void | Promise; isSearchingPublicCode?: boolean; onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void; + profilePlayStats?: ProfilePlayStatsResponse | null; + isProfilePlayStatsOpen?: boolean; + isProfilePlayStatsLoading?: boolean; + profilePlayStatsError?: string | null; + onCloseProfilePlayStats?: () => void; + onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void; onRechargeSuccess?: () => void | Promise; createTabContent?: ReactNode; } @@ -815,6 +823,21 @@ function formatDashboardUpdatedAt(value: string | null | undefined) { }); } +function formatPlayedWorkType(value: string | null | undefined) { + const normalizedValue = (value ?? '').toLowerCase(); + if (normalizedValue === 'puzzle') { + return '拼图'; + } + if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') { + return '大鱼'; + } + return 'RPG'; +} + +function formatPlayedWorkId(work: ProfilePlayedWorkSummary) { + return work.profileId?.trim() || work.worldKey; +} + function buildPublicUserCode(user: AuthUser | null | undefined) { if (user?.publicUserCode?.trim()) { return user.publicUserCode.trim(); @@ -1264,6 +1287,108 @@ function ProfileReferralModal({ ); } +function ProfilePlayedWorksModal({ + stats, + isLoading, + error, + onClose, + onOpenWork, +}: { + stats: ProfilePlayStatsResponse | null; + isLoading: boolean; + error: string | null; + onClose: () => void; + onOpenWork?: (work: ProfilePlayedWorkSummary) => void; +}) { + const playedWorks = stats?.playedWorks ?? []; + + return ( +
+
+ +
+
+
+ PLAYED +
+
玩过作品
+
+ + {formatCompactPlayTime(stats?.totalPlayTimeMs ?? 0)} +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : playedWorks.length > 0 ? ( +
+ {playedWorks.map((work) => ( + + ))} +
+ ) : ( +
+ 暂无玩过作品 +
+ )} +
+
+
+ ); +} + export function RpgEntryHomeView({ activeTab, onTabChange, @@ -1288,6 +1413,12 @@ export function RpgEntryHomeView({ onSearchPublicCode, isSearchingPublicCode = false, onOpenProfileDashboardCard, + profilePlayStats = null, + isProfilePlayStatsOpen = false, + isProfilePlayStatsLoading = false, + profilePlayStatsError = null, + onCloseProfilePlayStats, + onOpenPlayedWork, onRechargeSuccess, createTabContent, }: RpgEntryHomeViewProps) { @@ -2318,6 +2449,15 @@ export function RpgEntryHomeView({ onSubmitRedeem={submitReferralInviteCode} /> ) : null} + {isProfilePlayStatsOpen ? ( + undefined)} + onOpenWork={onOpenPlayedWork} + /> + ) : null}
); } @@ -2422,6 +2562,15 @@ export function RpgEntryHomeView({ onSubmitRedeem={submitReferralInviteCode} /> ) : null} + {isProfilePlayStatsOpen ? ( + undefined)} + onOpenWork={onOpenPlayedWork} + /> + ) : null}
); } diff --git a/src/services/big-fish-runtime/bigFishRuntimeClient.ts b/src/services/big-fish-runtime/bigFishRuntimeClient.ts new file mode 100644 index 00000000..ac289927 --- /dev/null +++ b/src/services/big-fish-runtime/bigFishRuntimeClient.ts @@ -0,0 +1,33 @@ +import type { + BigFishSessionResponse, + RecordBigFishPlayRequest, +} from '../../../packages/shared/src/contracts/bigFish'; +import { type ApiRetryOptions, requestJson } from '../apiClient'; + +const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 120, + maxDelayMs: 360, + retryUnsafeMethods: true, +}; + +/** + * 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。 + */ +export function recordBigFishPlay( + sessionId: string, + payload: RecordBigFishPlayRequest, +) { + return requestJson( + `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + '记录大鱼吃小鱼游玩失败', + { + retry: BIG_FISH_RUNTIME_WRITE_RETRY, + }, + ); +} diff --git a/src/services/big-fish-runtime/index.ts b/src/services/big-fish-runtime/index.ts index bf43b28c..30187a9e 100644 --- a/src/services/big-fish-runtime/index.ts +++ b/src/services/big-fish-runtime/index.ts @@ -2,3 +2,4 @@ export { advanceLocalBigFishRuntimeRun, startLocalBigFishRuntimeRun, } from './bigFishLocalRuntime'; +export { recordBigFishPlay } from './bigFishRuntimeClient'; From fb965a12075c3a99906ff6c5773975ef47eb6599 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 14:14:21 +0800 Subject: [PATCH 5/7] perf: use redeem usage index --- .../crates/spacetime-module/src/runtime/profile.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 0042254a..fd33a9c3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1852,8 +1852,8 @@ fn latest_profile_recharge_order( fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 { ctx.db .profile_redeem_code_usage() - .iter() - .filter(|row| row.code == code && row.user_id == user_id) + .by_profile_redeem_code_usage_code_user_id() + .filter((code, user_id)) .count() as u32 } @@ -1863,12 +1863,7 @@ fn build_profile_redeem_code_usage_id( user_id: &str, redeemed_at_micros: i64, ) -> String { - let sequence = ctx - .db - .profile_redeem_code_usage() - .iter() - .filter(|row| row.code == code && row.user_id == user_id) - .count(); + let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id); format!( "redeem:{}:{}:{}:{}", code, user_id, redeemed_at_micros, sequence From 1d319ba9169e0b1661a58b7b3a48f7c5b10bebe8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 14:46:18 +0800 Subject: [PATCH 6/7] feat: add dev password auto registration --- .env.example | 2 + .../PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md | 11 ++ server-rs/crates/api-server/src/app.rs | 30 +++++ server-rs/crates/api-server/src/config.rs | 7 ++ .../crates/api-server/src/password_entry.rs | 21 ++-- server-rs/crates/module-auth/src/lib.rs | 112 ++++++++++++++++++ 6 files changed, 175 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 1f06973c..879be395 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,8 @@ AUTH_REFRESH_COOKIE_SAME_SITE="Lax" AUTH_REFRESH_COOKIE_SECURE="false" # Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。 GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json" +# 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。 +GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false" # 手机号验证码登录配置(阿里云 PNVS)。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。 diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md index 6f45b1f1..fdfb33ea 100644 --- a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -1,6 +1,8 @@ # 密码登录入口历史落地设计 > 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 +> +> 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。 日期:`2026-04-21` @@ -166,6 +168,13 @@ 2. 不创建账号。 3. 不写 `password_hash`。 +开发期例外: + +1. 当 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 时,未知手机号会创建手机号账号。 +2. 新账号立即写入本次密码的 `password_hash`,并将 `password_login_enabled` 置为 `true`。 +3. 成功响应沿用密码登录响应体,`created` 只保留在领域结果中,不额外暴露到当前 HTTP contract。 +4. 手机号格式和密码长度校验仍完全沿用正式密码入口规则。 + ### 8.2 未设置密码 当账号存在但 `password_login_enabled = false` 时: @@ -233,6 +242,8 @@ 4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。 5. 登录成功时返回 access token。 6. 登录成功时写回 refresh cookie。 +7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。 +8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`。 ## 13. 完成定义 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 8cc51fb1..3d3fc35a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1382,6 +1382,36 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() { + let config = AppConfig { + dev_password_entry_auto_register_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let first_response = + password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await; + let first_status = first_response.status(); + let first_body = first_response + .into_body() + .collect() + .await + .expect("first response body should collect") + .to_bytes(); + let first_payload: Value = + serde_json::from_slice(&first_body).expect("first response body should be valid json"); + let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await; + + assert_eq!(first_status, StatusCode::OK); + assert!(first_payload["token"].as_str().is_some()); + assert_eq!( + first_payload["user"]["loginMethod"], + Value::String("password".to_string()) + ); + assert_eq!(second_response.status(), StatusCode::OK); + } + #[tokio::test] async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e2c497d5..deb69712 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -29,6 +29,7 @@ pub struct AppConfig { pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, pub auth_store_path: PathBuf, + pub dev_password_entry_auto_register_enabled: bool, pub sms_auth_enabled: bool, pub sms_auth_provider: String, pub sms_endpoint: String, @@ -118,6 +119,7 @@ impl Default for AppConfig { refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), + dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), @@ -273,6 +275,11 @@ impl AppConfig { if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) { config.auth_store_path = PathBuf::from(auth_store_path); } + if let Some(enabled) = + read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"]) + { + config.dev_password_entry_auto_register_enabled = enabled; + } if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { config.sms_auth_enabled = sms_auth_enabled; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 743adcf3..52bbd3a3 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -26,14 +26,19 @@ pub async fn password_entry( headers: HeaderMap, Json(payload): Json, ) -> Result { - let result = state - .password_entry_service() - .execute(PasswordEntryInput { - phone_number: payload.phone, - password: payload.password, - }) - .await - .map_err(map_password_entry_error)?; + let input = PasswordEntryInput { + phone_number: payload.phone, + password: payload.password, + }; + let result = if state.config.dev_password_entry_auto_register_enabled { + state + .password_entry_service() + .execute_with_dev_registration(input) + .await + } else { + state.password_entry_service().execute(input).await + } + .map_err(map_password_entry_error)?; let session_client = resolve_session_client_context(&headers); let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; state diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 1952d725..57005c54 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -486,6 +486,38 @@ impl PasswordEntryService { verify_stored_password_user(existing_user, &input.password).await } + pub async fn execute_with_dev_registration( + &self, + input: PasswordEntryInput, + ) -> Result { + validate_password(&input.password)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) + .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + if let Some(existing_user) = self + .store + .find_by_phone_number_for_password(&normalized_phone.e164)? + { + return verify_stored_password_user(existing_user, &input.password).await; + } + + let password_hash = hash_password(&input.password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + let user = self.store.create_dev_password_phone_user( + normalized_phone.clone(), + normalized_phone.masked_national_number, + password_hash, + )?; + + Ok(PasswordEntryResult { + user: AuthUser { + login_method: AuthLoginMethod::Password, + ..user + }, + created: true, + }) + } + pub fn get_user_by_id( &self, user_id: &str, @@ -1336,6 +1368,53 @@ impl InMemoryAuthStore { Ok(user) } + fn create_dev_password_phone_user( + &self, + phone_number: PhoneNumberSnapshot, + display_name: String, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + if state.phone_to_user_id.contains_key(&phone_number.e164) { + return Err(PasswordEntryError::InvalidCredentials); + } + + let sequence = state.next_user_id; + let user_id = format!("user_{sequence:08}"); + let public_user_code = build_public_user_code(sequence); + state.next_user_id += 1; + let username = build_system_username("phone", state.next_user_id); + let user = AuthUser { + id: user_id.clone(), + public_user_code, + username: username.clone(), + display_name, + phone_number_masked: Some(phone_number.masked_national_number.clone()), + login_method: AuthLoginMethod::Password, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + token_version: 1, + }; + state + .phone_to_user_id + .insert(phone_number.e164.clone(), user_id); + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + password_login_enabled: true, + phone_number: Some(phone_number.e164), + }, + ); + self.persist_password_state(&state)?; + + Ok(user) + } + fn create_pending_wechat_user( &self, profile: WechatIdentityProfile, @@ -2474,6 +2553,39 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidCredentials); } + #[tokio::test] + async fn password_entry_dev_registration_creates_unknown_phone_user() { + let service = build_password_service(build_store()); + + let created = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("dev registration should create user"); + let reused = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("same password should reuse created user"); + let wrong_password = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret999".to_string(), + }) + .await + .expect_err("existing user still requires the right password"); + + assert!(created.created); + assert_eq!(created.user.login_method, AuthLoginMethod::Password); + assert!(!reused.created); + assert_eq!(created.user.id, reused.user.id); + assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); + } + #[tokio::test] async fn phone_user_can_set_password_then_login() { let store = build_store(); From a4c623884782960e963435dc063044d497a6ba6a Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 15:36:47 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BACKEND_REWRITE_TASKLIST.md | 12 - .../00_MASTER_TASKLIST.md | 154 --------- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 266 --------------- .../02_M3_RUNTIME_PROFILE.md | 69 ---- .../03_M4_STORY_AND_GAMEPLAY.md | 318 ------------------ .../04_M5_CUSTOM_WORLD_AND_AGENT.md | 117 ------- .../05_M6_ASSETS_OSS_EDITOR.md | 153 --------- .../06_M7_TEST_DEPLOY_CUTOVER.md | 66 ---- .../07_CROSS_CUTTING_AND_ACCEPTANCE.md | 62 ---- ..._CAPABILITY_SURFACE_BASELINE_2026-04-20.md | 183 ---------- ...D_RESPONSE_CONTRACT_BASELINE_2026-04-20.md | 262 --------------- ...RATED_STATIC_PREFIX_BASELINE_2026-04-20.md | 245 -------------- ...M0_MODULE_MIGRATION_BASELINE_2026-04-20.md | 291 ---------------- .../M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md | 106 ------ ...EPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md | 281 ---------------- .../M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md | 249 -------------- .../M0_SSE_INTERFACE_BASELINE_2026-04-20.md | 300 ----------------- backend-rewrite-tasklist/README.md | 36 -- 18 files changed, 3170 deletions(-) delete mode 100644 BACKEND_REWRITE_TASKLIST.md delete mode 100644 backend-rewrite-tasklist/00_MASTER_TASKLIST.md delete mode 100644 backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md delete mode 100644 backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md delete mode 100644 backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md delete mode 100644 backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md delete mode 100644 backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md delete mode 100644 backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md delete mode 100644 backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md delete mode 100644 backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md delete mode 100644 backend-rewrite-tasklist/README.md diff --git a/BACKEND_REWRITE_TASKLIST.md b/BACKEND_REWRITE_TASKLIST.md deleted file mode 100644 index 1877d65e..00000000 --- a/BACKEND_REWRITE_TASKLIST.md +++ /dev/null @@ -1,12 +0,0 @@ -# 后端重写任务清单入口 - -完整总纲与拆分后的任务文件已统一整理到根目录新建目录: - -- [backend-rewrite-tasklist/README.md](./backend-rewrite-tasklist/README.md) - -其中: - -- 总纲主清单:[backend-rewrite-tasklist/00_MASTER_TASKLIST.md](./backend-rewrite-tasklist/00_MASTER_TASKLIST.md) -- 阶段拆分文件入口:[backend-rewrite-tasklist/README.md](./backend-rewrite-tasklist/README.md) - -后续如继续细化任务,请优先在该目录内维护,避免根目录散落多份版本。 diff --git a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md b/backend-rewrite-tasklist/00_MASTER_TASKLIST.md deleted file mode 100644 index ab5d2f61..00000000 --- a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md +++ /dev/null @@ -1,154 +0,0 @@ -# SpacetimeDB + Axum + 阿里云 OSS 后端重写任务总纲 - -日期:`2026-04-20` - -关联设计文档: - -- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) -- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md) -- [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) -- [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) - -关联拆分任务: - -- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md) -- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md) -- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md) -- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md) -- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md) -- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md) -- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md) - -## 0. 使用说明 - -这份总纲用于把控整体重写节奏,拆分文件用于落地执行。 - -执行原则: - -1. 第一阶段优先兼容当前 `/api/*`、`/healthz`、`/generated-*` 访问习惯。 -2. 不允许先删旧能力再补新能力,必须按能力面平移。 -3. 以当前 Node 后端 `96` 条路由、`6` 个挂载面、`12` 个模块为最低覆盖基线。 -4. 每个阶段完成后,都要形成可运行、可回归、可继续迭代的中间态。 - -## 1. 总体里程碑 - -- [x] `M0`:冻结当前后端能力清单与迁移边界 -- [ ] `M1`:搭建 Rust 工作区、Axum 主入口与基础中间件 -- [ ] `M2`:完成鉴权、会话、JWT、refresh cookie 主链迁移 -- [ ] `M3`:完成 runtime snapshot / settings / profile 迁移 -- [ ] `M4`:完成 story action 主循环与核心 gameplay reducer 迁移 -- [ ] `M5`:完成 custom world / agent 主链迁移 -- [ ] `M6`:完成 assets / OSS 主链迁移 -- [ ] `M7`:完成联调、回归、部署与切流准备 - -## 2. 阶段导航 - -### `M0 ~ M2` - -重点: - -1. 冻结能力清单 -2. 搭建 Rust workspace -3. 搭建 Axum 基础设施 -4. 迁移鉴权、会话、JWT、refresh cookie - -详见: - -- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md) - -### `M3` - -重点: - -1. 迁移 runtime snapshot -2. 迁移 settings -3. 迁移 profile dashboard / browse history / save archive - -详见: - -- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md) - -### `M4` - -重点: - -1. 迁移 RPG runtime story 主循环 -2. 迁移 RPG 入口 / session / runtime 对应的后端边界与编译职责 -3. 兼容当前 story view model 与 state 恢复接口,并与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory` 口径对齐 - -详见: - -- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md) - -### `M5` - -重点: - -1. 迁移 RPG 创作主链:Agent session、result preview、published profile -2. 迁移 works / library / gallery / publish / enter-world 配套链路 -3. 旧 `custom-world/sessions` 传统问答流只按历史兼容台账处理,不再作为当前主链扩展目标 - -详见: - -- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md) - -### `M6` - -重点: - -1. 迁移 assets -2. 接入阿里云 OSS -3. 做旧静态资源路径兼容 - -详见: - -- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md) - -### `M7` - -重点: - -1. 联调 -2. 回归 -3. 部署 -4. 观测 -5. 灰度切流 -6. 收口 `spacetime-module` 主工程结构,拆分过大的 `src/lib.rs` - -详见: - -- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md) - -## 3. 横向专项 - -以下专项贯穿整个迁移期: - -1. contract 与前端兼容 -2. SpacetimeDB schema 演进治理 -3. 大对象与缓存治理 -4. 文档持续维护 - -详见: - -- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md) - -## 4. 第一优先级建议执行顺序 - -1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。 -2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。 -3. 当前执行顺序允许前置 `M6` 的 OSS 基础设施与直传票据能力,为后续各阶段复用统一资产入口。 -4. 再做 `M3`,优先跑通快照、设置、profile。 -5. 再做 `M4`,把 story action 主循环真正迁走。 -6. 然后做 `M5`,迁 custom world 与 agent。 -7. 最后收口 `M6` 余下资产绑定、`M7` 部署与切流。 - -## 5. 最终验收清单 - -- [ ] 当前 `96` 条后端接口已全部迁移或有兼容替代 -- [ ] 当前 `6` 个挂载面已全部迁移 -- [ ] 当前 `12` 个内部模块已完成新架构落位 -- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界 -- [ ] SpacetimeDB 已成为唯一运行时状态真相源 -- [ ] 阿里云 OSS 已成为唯一资产对象仓 -- [ ] 前端主流程在不大改 UI 的前提下可跑通 -- [ ] 能完成灰度切流,并保留可回退能力 diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md deleted file mode 100644 index 94bd29b5..00000000 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ /dev/null @@ -1,266 +0,0 @@ -# M0 ~ M2:基础设施与鉴权任务清单 - -## M0:冻结能力与重写边界 - -### 能力冻结 - -- [x] 整理当前后端 6 个挂载面并锁定为重写验收基线 - 交付物:[M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md) -- [x] 整理当前后端 96 条路由并生成一份“旧接口 -> 新实现”映射表 - 交付物:[M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md) -- [x] 整理当前 12 个内部模块并锁定迁移归属 - 交付物:[M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md) -- [x] 整理当前所有 SSE 接口与事件格式 - 交付物:[M0_SSE_INTERFACE_BASELINE_2026-04-20.md](./M0_SSE_INTERFACE_BASELINE_2026-04-20.md) -- [x] 整理当前所有 `/generated-*` 静态资源前缀 - 交付物:[M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md](./M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md) -- [x] 整理当前前端直接依赖的响应头、envelope、错误格式 - 交付物:[M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md](./M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md) - -### 仓库边界 - -- [x] 确认 Rust 后端新目录名与根目录落位方案 - 交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md) -- [x] 确认旧 `server-node/` 在迁移期继续保留,不提前删除 - 交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md) -- [x] 确认前端第一阶段仍然只访问 Axum,不直连 SpacetimeDB - 交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md) -- [x] 确认外部副作用统一收口在 Axum,不放进 SpacetimeDB 模块 - 交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md) - -### 交付物 - -- [x] 新增“接口映射表”文档 - 交付物:[M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md) -- [x] 新增“模块迁移清单”文档 - 交付物:[M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md) -- [x] 新增“阶段验收矩阵”文档 - 交付物:[M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md) - -## M1:Rust 工作区与 Axum 基础设施 - -### 工作区搭建 - -- [x] 在根目录新增 `server-rs/` - 交付物:[../server-rs/README.md](../server-rs/README.md) -- [x] 创建 workspace `Cargo.toml` - 交付物:[../server-rs/Cargo.toml](../server-rs/Cargo.toml) -- [x] 创建 `crates/api-server` - 交付物:[../server-rs/crates/api-server/README.md](../server-rs/crates/api-server/README.md) -- [x] 创建 `crates/spacetime-module` - 交付物:[../server-rs/crates/spacetime-module/README.md](../server-rs/crates/spacetime-module/README.md) -- [x] 创建 `crates/module-auth` - 交付物:[../server-rs/crates/module-auth/README.md](../server-rs/crates/module-auth/README.md) -- [x] 创建 `crates/module-runtime` - 交付物:[../server-rs/crates/module-runtime/README.md](../server-rs/crates/module-runtime/README.md) -- [x] 创建 `crates/module-story` - 交付物:[../server-rs/crates/module-story/README.md](../server-rs/crates/module-story/README.md) -- [x] 创建 `crates/module-combat` - 交付物:[../server-rs/crates/module-combat/README.md](../server-rs/crates/module-combat/README.md) -- [x] 创建 `crates/module-inventory` - 交付物:[../server-rs/crates/module-inventory/README.md](../server-rs/crates/module-inventory/README.md) -- [x] 创建 `crates/module-npc` - 交付物:[../server-rs/crates/module-npc/README.md](../server-rs/crates/module-npc/README.md) -- [x] 创建 `crates/module-progression` - 交付物:[../server-rs/crates/module-progression/README.md](../server-rs/crates/module-progression/README.md) -- [x] 创建 `crates/module-quest` - 交付物:[../server-rs/crates/module-quest/README.md](../server-rs/crates/module-quest/README.md) -- [x] 创建 `crates/module-runtime-item` - 交付物:[../server-rs/crates/module-runtime-item/README.md](../server-rs/crates/module-runtime-item/README.md) -- [x] 创建 `crates/module-custom-world` - 交付物:[../server-rs/crates/module-custom-world/README.md](../server-rs/crates/module-custom-world/README.md) -- [x] 创建 `crates/module-assets` - 交付物:[../server-rs/crates/module-assets/README.md](../server-rs/crates/module-assets/README.md) -- [x] 创建 `crates/module-ai` - 交付物:[../server-rs/crates/module-ai/README.md](../server-rs/crates/module-ai/README.md) -- [x] 创建 `crates/shared-contracts` - 交付物:[../server-rs/crates/shared-contracts/README.md](../server-rs/crates/shared-contracts/README.md) -- [x] 创建 `crates/shared-kernel` - 交付物:[../server-rs/crates/shared-kernel/README.md](../server-rs/crates/shared-kernel/README.md) -- [x] 创建 `crates/shared-logging` - 交付物:[../server-rs/crates/shared-logging/README.md](../server-rs/crates/shared-logging/README.md) -- [x] 创建 `crates/platform-auth` - 交付物:[../server-rs/crates/platform-auth/README.md](../server-rs/crates/platform-auth/README.md) -- [x] 创建 `crates/platform-oss` - 交付物:[../server-rs/crates/platform-oss/README.md](../server-rs/crates/platform-oss/README.md) -- [x] 创建 `crates/platform-llm` - 交付物:[../server-rs/crates/platform-llm/README.md](../server-rs/crates/platform-llm/README.md) -- [x] 创建 `crates/spacetime-client` - 交付物:[../server-rs/crates/spacetime-client/README.md](../server-rs/crates/spacetime-client/README.md) -- [x] 创建 `crates/tests-support` - 交付物:[../server-rs/crates/tests-support/README.md](../server-rs/crates/tests-support/README.md) - -### Axum 基础能力 - -- [x] 搭建 `main.rs` / `Router` / `with_state` - 交付物:[../server-rs/crates/api-server/src/main.rs](../server-rs/crates/api-server/src/main.rs) -- [x] 接入统一配置加载 - 交付物:[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs) -- [x] 接入统一日志与 tracing - 交付物:[../docs/technical/RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](../docs/technical/RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md)、[../server-rs/crates/shared-logging/src/lib.rs](../server-rs/crates/shared-logging/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/api-server/src/main.rs](../server-rs/crates/api-server/src/main.rs) -- [x] 接入 `request_id` 中间件 - 交付物:[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 接入统一错误处理中间件 - 交付物:[../server-rs/crates/api-server/src/http_error.rs](../server-rs/crates/api-server/src/http_error.rs)、[../server-rs/crates/api-server/src/error_middleware.rs](../server-rs/crates/api-server/src/error_middleware.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 接入当前项目兼容的 response envelope - 交付物:[../server-rs/crates/api-server/src/api_response.rs](../server-rs/crates/api-server/src/api_response.rs)、[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)、[../server-rs/crates/api-server/src/http_error.rs](../server-rs/crates/api-server/src/http_error.rs) -- [x] 接入 `x-request-id` - 交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 接入 `x-api-version` - 交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs) -- [x] 接入 `x-route-version` - 交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs) -- [x] 接入 `x-response-time-ms` - 交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)、[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs) -- [x] 实现 `/healthz` - 交付物:[../server-rs/crates/api-server/src/health.rs](../server-rs/crates/api-server/src/health.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - -### 基础工程脚本 - -- [x] 新增本地开发脚本 - 交付物:[../server-rs/scripts/dev.ps1](../server-rs/scripts/dev.ps1)、[../server-rs/scripts/dev.sh](../server-rs/scripts/dev.sh) -- [x] 新增测试脚本 - 交付物:[../server-rs/scripts/test.ps1](../server-rs/scripts/test.ps1)、[../server-rs/scripts/test.sh](../server-rs/scripts/test.sh) -- [x] 新增 lint / fmt / clippy / check 脚本 - 交付物:[../server-rs/scripts/check.ps1](../server-rs/scripts/check.ps1)、[../server-rs/scripts/check.sh](../server-rs/scripts/check.sh) -- [x] 新增 smoke 脚本 - 交付物:[../server-rs/scripts/smoke.ps1](../server-rs/scripts/smoke.ps1)、[../server-rs/scripts/smoke.sh](../server-rs/scripts/smoke.sh) -- [x] 新增 SpacetimeDB 本地开发脚本 - 交付物:[../server-rs/scripts/spacetime-dev.ps1](../server-rs/scripts/spacetime-dev.ps1)、[../server-rs/scripts/spacetime-dev.sh](../server-rs/scripts/spacetime-dev.sh) - -### 阶段验收 - -- [x] Axum 服务可独立启动 - 证据:`./server-rs/scripts/smoke.ps1` 已通过,覆盖临时启动 `api-server`、等待 `/healthz` 就绪并验证 raw / envelope 协议。 -- [x] `/healthz` 返回与当前工程兼容 -- [x] 基础 response envelope 与 request id 行为稳定 - 证据:`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 envelope 协商与 `/healthz` 头部回写。 -- [x] Rust workspace 能完整编译通过 - 证据:`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 已通过。 - -## M2:鉴权、会话、JWT 与 refresh cookie - -### SpacetimeDB 身份表 - -- [x] 设计 `user_account` - 交付物:[../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md) -- [x] 设计 `auth_identity` - 交付物:[../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md) -- [x] 设计 `refresh_session` - 交付物:[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md) -- [x] 设计 `auth_audit_log` - 交付物:[../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md) -- [x] 设计 `auth_risk_block` - 交付物:[../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md) -- [x] 设计 `sms_auth_event` - 交付物:[../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md) -- [x] 设计 `wechat_auth_state` - 交付物:[../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md) - -### Axum 鉴权服务 - -- [x] 实现密码登录 - 交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现账号自动创建 / 幂等登录兼容策略 - 交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现 Bearer JWT 校验 - 交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现 refresh cookie 读取 - 交付物:[../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现 refresh token 轮换 - 交付物:[../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现多端会话身份建模与会话列表查询 - 交付物:[../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md)、[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../packages/shared/src/contracts/auth.ts](../packages/shared/src/contracts/auth.ts) -- [x] 实现会话吊销 - 交付物:[../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现全端登出 - 交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现 `me` 查询 - 交付物:[../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - -### 手机验证码登录 - -- [ ] 接入阿里云短信发送 adapter -- [x] 实现发送验证码接口 - 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现验证码校验接口 - 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现手机号绑定 - 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs) -- [ ] 实现手机号换绑 -- [x] 实现发送频率限制 - 交付物:[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现验证码失败次数限制 - 交付物:[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [ ] 实现 captcha 触发逻辑 -- [ ] 实现风控封禁与解除 - -### 微信登录 - -- [x] 接入微信 OAuth adapter - 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_provider.rs](../server-rs/crates/api-server/src/wechat_provider.rs)、[../server-rs/crates/api-server/src/state.rs](../server-rs/crates/api-server/src/state.rs) -- [x] 实现 `wechat/start` - 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现 `wechat/callback` - 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现微信身份绑定 - 交付物:[../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) -- [x] 实现微信账号补绑手机号 - 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 实现桌面端 / 微信内打开场景区分 - 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs) - -### OIDC 与 SpacetimeDB 身份透传 - -- [x] 设计 JWT claims - 交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) -- [x] 确认 `iss/sub/sid/provider/roles` 字段 - 交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) -- [x] 让 Axum 自身可校验 JWT - 交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/README.md](../server-rs/crates/platform-auth/README.md)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs) -- [ ] 让 SpacetimeDB 可识别 Axum 签发的身份令牌 -- [ ] 验证 reducer / view 可读取用户身份上下文 - -### 当前接口兼容 - -- [x] 兼容 `/api/auth/login-options` - 交付物:[../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/login_options.rs](../server-rs/crates/api-server/src/login_options.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 兼容 `/api/auth/entry` - 交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 兼容 `/api/auth/me` - 交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 兼容 `/api/auth/logout` - 交付物:[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 兼容 `/api/auth/logout-all` - 交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) -- [x] 兼容 `/api/auth/refresh` - 交付物:[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [x] 兼容 `/api/auth/sessions` - 交付物:[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) -- [ ] 兼容 `/api/auth/sessions/:sessionId/revoke` -- [ ] 兼容 `/api/auth/audit-logs` -- [ ] 兼容 `/api/auth/risk-blocks` -- [ ] 兼容 `/api/auth/risk-blocks/:scopeType/lift` -- [x] 兼容 `/api/auth/phone/send-code` - 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) -- [x] 兼容 `/api/auth/phone/login` - 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) -- [ ] 兼容 `/api/auth/phone/change` -- [x] 兼容 `/api/auth/wechat/start` - 交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts) -- [x] 兼容 `/api/auth/wechat/callback` - 交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts) -- [x] 兼容 `/api/auth/wechat/bind-phone` - 交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts) - -### 阶段验收 - -- [x] 密码登录主链可用 - 证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖自动建号、重复登录复用、错密码 `401`、非法用户名 `400` 与 refresh cookie 写回。 -- [x] refresh cookie 主链可用 - 证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 refresh 成功轮换、旧 token 失效、缺少 cookie `401` 与失败时清理 cookie。 -- [x] 手机验证码主链可用 - 证据:`cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server phone --manifest-path server-rs/Cargo.toml -- --nocapture` 已通过,覆盖发送验证码、同场景冷却 `429`、验证码错误次数耗尽 `429`、重新发送后恢复登录,以及手机号登录建号/复用与 refresh cookie 写回。 -- [x] 微信登录主链可用 - 证据:`cargo test -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth --manifest-path server-rs/Cargo.toml` 已通过,覆盖 `wechat/start`、`wechat/callback`、待绑定会话签发、手机号补绑并入已有账号,以及 `unionid` 命中后新 `openid` 映射回写。 -- [ ] 所有旧鉴权接口可通过 contract 回归 diff --git a/backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md b/backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md deleted file mode 100644 index 7c15286a..00000000 --- a/backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md +++ /dev/null @@ -1,69 +0,0 @@ -# M3:runtime snapshot / settings / profile 任务清单 - -## 1. SpacetimeDB 运行时主表 - -- [x] 设计 `runtime_snapshot` -- [x] 设计 `runtime_setting` -- [x] 设计 `profile_dashboard_state` -- [x] 设计 `profile_wallet_ledger` -- [x] 设计 `profile_played_world` -- [x] 设计 `profile_save_archive` -- [x] 设计 `user_browse_history` - -## 2. 兼容快照策略 - -- [x] 设计“领域表真相 + 兼容聚合快照”策略 -- [x] 设计 snapshot projection 刷新机制 -- [x] 迁移当前 snapshot hydration / normalize 规则 -- [x] 迁移当前 save archive 聚合逻辑 -- [x] 迁移当前 browse history 去重与排序逻辑 - -## 3. Axum facade - -- [x] 兼容 `GET /api/runtime/save/snapshot` -- [x] 兼容 `PUT /api/runtime/save/snapshot` -- [x] 兼容 `DELETE /api/runtime/save/snapshot` -- [x] 兼容 `GET /api/runtime/settings` -- [x] 兼容 `PUT /api/runtime/settings` -- [x] 兼容 `GET /api/runtime/profile/dashboard` -- [x] 兼容 `GET /api/profile/dashboard` -- [x] 兼容 `GET /api/runtime/profile/wallet-ledger` -- [x] 兼容 `GET /api/profile/wallet-ledger` -- [x] 兼容 `GET /api/runtime/profile/play-stats` -- [x] 兼容 `GET /api/profile/play-stats` -- [x] 兼容 `GET /api/runtime/profile/save-archives` -- [x] 兼容 `GET /api/profile/save-archives` -- [x] 兼容 `POST /api/runtime/profile/save-archives/:worldKey` -- [x] 兼容 `POST /api/profile/save-archives/:worldKey` -- [x] 兼容 `GET /api/runtime/profile/browse-history` -- [x] 兼容 `POST /api/runtime/profile/browse-history` -- [x] 兼容 `DELETE /api/runtime/profile/browse-history` -- [x] 兼容 `GET /api/profile/browse-history` -- [x] 兼容 `POST /api/profile/browse-history` -- [x] 兼容 `DELETE /api/profile/browse-history` - -## 4. 阶段验收 - -- [ ] 登录用户可正常保存、读取、删除存档 -- [x] 兼容路径与主路径返回一致 -- [x] profile dashboard / browse history / save archive 行为一致 -- [ ] 前端当前恢复流程可在不改 UI 的前提下跑通 - -## 5. 本轮进展记录 - -- `2026-04-21`:已完成 `runtime_setting` 首版设计与 `GET/PUT /api/runtime/settings` 的 Rust 主链迁移。 -- 本轮已落地 `module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 四层串联,并补齐定向测试。 -- 详细设计与字段冻结见: - - [../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) -- `2026-04-22`:已完成 `user_browse_history` 表设计冻结、去重与排序规则迁移,以及 `/api/runtime/profile/browse-history` 与 `/api/profile/browse-history` 双路径 facade 落地。 -- `2026-04-22`:已补 `browse history` 的 API 入口必填字段校验、批量 shape 兼容与定向测试,详细设计见: - - [../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) -- `2026-04-22`:已冻结 `profile_dashboard_state`、`profile_wallet_ledger`、`profile_played_world` 三张 projection 表,以及 `dashboard / wallet-ledger / play-stats` 的 Axum + SpacetimeDB 读链设计。 -- `2026-04-22`:已完成 `api-server` 的 `runtime_profile` facade 编译与定向测试收口,`/api/runtime/profile/*` 与 `/api/profile/*` 六条只读路由均已接通。 -- `2026-04-22`:已通过 `cargo check -p api-server --tests --message-format short`、`cargo test -p shared-contracts --lib`、`cargo test -p api-server runtime_profile::tests:: -- --nocapture` 验证本轮 profile projection 读链。 -- 详细设计见: - - [../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md) -- `2026-04-22`:已完成 `runtime_snapshot`、`profile_save_archive` 与“领域表真相 + 兼容聚合快照”方案落地,接通 `/api/runtime/save/snapshot`、`/api/runtime/profile/save-archives`、`/api/profile/save-archives` 与恢复存档双路径 facade。 -- `2026-04-22`:已通过 `cargo test -p shared-kernel --lib`、`cargo test -p module-runtime --lib`、`cargo check -p spacetime-module --message-format short`、`cargo build -p spacetime-module --target wasm32-unknown-unknown --release --message-format short`、`cargo check -p spacetime-client --message-format short`、`cargo check -p api-server --tests --message-format short`、`cargo test -p api-server runtime_save::tests:: -- --nocapture` 验证 snapshot/save archive 主链编译与 facade。 -- 详细设计见: - - [../docs/technical/M3_RUNTIME_SNAPSHOT_SAVE_ARCHIVE_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](../docs/technical/M3_RUNTIME_SNAPSHOT_SAVE_ARCHIVE_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md) diff --git a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md b/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md deleted file mode 100644 index 82a7e697..00000000 --- a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md +++ /dev/null @@ -1,318 +0,0 @@ -# M4:story action 与 gameplay reducer 任务清单 - -## 0. 当前执行基线 - -本阶段与当前仓库里的 RPG 入口与运行时主链重构直接对应,统一以以下文档为准: - -1. [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) -2. [../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md) -3. [../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md) - -当前任务清单只维护 Axum / SpacetimeDB 重写侧的后端迁移项,不再把旧 `GameShell / runtimeRoutes.ts / storyActionService.ts` 命名视为新架构目标。 - -### 当前进展(`2026-04-22`) - -本阶段首轮已先把 `server-rs` 从“只有 `module-story` 占位目录”推进到“SpacetimeDB 侧 story 会话基座真实可编译”: - -1. 已新增 `server-rs/crates/module-story` 真实 crate。 -2. 已冻结 `story_session / story_event` 的首版领域类型、状态枚举和字段校验 helper。 -3. 已在 `server-rs/crates/spacetime-module` 中新增 `story_session`、`story_event` 两张表。 -4. 已新增 `begin_story_session`、`continue_story` 两个 reducer,形成最小会话事件链。 -5. 已新增 `begin_story_session_and_return`、`continue_story_and_return` 两个 procedure,形成可同步返回快照的最小 story session contract。 -6. 已重新执行 `spacetime generate`,把 `story_session / story_event` Rust bindings 刷入 `spacetime-client/src/module_bindings`。 -7. 已在 `server-rs/crates/spacetime-client` 中新增 `begin_story_session(...)`、`continue_story(...)` facade。 -8. 已在 `server-rs/crates/api-server` 中新增: - - `POST /api/story/sessions` - - `POST /api/story/sessions/continue` -9. 已执行 `cargo check -p module-story -p spacetime-module -p spacetime-client -p api-server` 并通过。 -6. 已新增 `docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `battle_state` 与 `resolve_combat_action` 的首版字段与规则口径。 -7. 已新增 `server-rs/crates/module-runtime-item` 真实 crate。 -8. 已冻结 runtime item 侧奖励快照与物品写回基线,为后续奖励链并入 inventory / quest / combat 提供统一底层能力。 -9. 已在 `server-rs/crates/spacetime-module` 中补齐 runtime item / inventory / quest / combat 所需的奖励落表与回写依赖。 -10. 当前 M4 runtime story compat bridge 已明确移除旧 `treasure_*` 遭遇动作概念,不再把宝箱遭遇视作本阶段 runtime story 主链目标。 -11. 已新增 `docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `inventory_slot` 与 `apply_inventory_mutation` 的首版字段与规则口径。 -12. 已新增 `server-rs/crates/module-inventory` 真实 crate。 -13. 已在 `server-rs/crates/spacetime-module` 中新增 `inventory_slot` 表。 -14. 已新增 `apply_inventory_mutation` reducer,形成最小背包主链。 -15. 已新增 `docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `npc_state`、`resolve_npc_social_action` 与 `resolve_npc_interaction` 的首版字段与交互口径。 -16. 已新增 `server-rs/crates/module-npc` 真实 crate。 -17. 已在 `server-rs/crates/spacetime-module` 中新增 `npc_state` 表。 -18. 已新增 `upsert_npc_state`、`resolve_npc_social_action`、`resolve_npc_interaction` 及对应 procedure。 -19. 已新增 `docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md`,冻结 `npc_fight / npc_spar` 到 `battle_state` 的最小联合编排口径。 -20. 已在 `server-rs/crates/spacetime-module` 中新增 `resolve_npc_battle_interaction_and_return` procedure,把 NPC 开战交互与 battle 初始化写入串到同一事务。 -15. 已新增 `docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `player_progression / chapter_progression` 的首版字段、成长曲线与章节预算口径。 -16. 已新增 `server-rs/crates/module-progression` 真实 crate。 -17. 已在 `server-rs/crates/spacetime-module` 中新增 `player_progression`、`chapter_progression` 两张表。 -18. 已新增 `get_player_progression_or_default`、`grant_player_progression_experience`、`upsert_chapter_progression`、`apply_chapter_progression_ledger_entry` 及对应 procedure。 -19. 已新增 `docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `quest_record / quest_log / apply_quest_signal` 的首版字段、日志口径与交付状态流转规则。 -20. 已新增 `server-rs/crates/module-quest` 真实 crate。 -21. 已在 `server-rs/crates/spacetime-module` 中新增 `quest_record`、`quest_log` 两张表。 -22. 已新增 `accept_quest`、`apply_quest_signal`、`acknowledge_quest_completion`、`turn_in_quest` reducer,形成最小任务闭环。 -23. 已执行 `cargo test -p module-quest`、`cargo check -p spacetime-module`、`cargo check -p api-server` 与全量 `cargo check` 并通过。 -24. 已新增 `docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md`,冻结任务交付与战斗胜利到成长系统的联动口径。 -25. 已把 `turn_in_quest` 接到 `player_progression / chapter_progression` 的最小经验写入。 -26. 已把 `resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 的最小经验写入。 -27. 已把 `turn_in_quest.reward.items` 接到 `inventory_slot` 发物链,形成任务交付的最小物品奖励闭环。 -28. 已新增 `docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `story state` 查询切片,只开放 `storySession + storyEvents` 真相态查询。 -29. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/sessions/:storySessionId/state`,通过 `spacetime-client.get_story_session_state(...)` 读取 `SpacetimeDB procedure` 返回的会话快照与事件流。 -30. 已新增 `docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md`,冻结 `battle_state.reward_items` 与 `resolve_combat_action(Victory)` 发物到 `inventory_slot` 的最小联动口径。 -31. 已新增 `docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `battle state` 查询切片,只开放单个 `battleState` 真相态查询。 -32. 已在 `server-rs/crates/spacetime-module` 中新增 `get_battle_state` procedure,按 `battle_state_id` 返回当前战斗快照。 -33. 已在 `server-rs/crates/spacetime-client` 中新增 `get_battle_state(...)` facade,供 Axum 同步读取 battle 真相态。 -34. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/battles/:battleStateId`,通过 `spacetime-client.get_battle_state(...)` 返回单战斗快照。 -35. 已在 `server-rs/crates/spacetime-client` 中新增 `resolve_npc_battle_interaction(...)` facade,把 `resolve_npc_battle_interaction_and_return` procedure 映射为稳定 Rust record,供 Axum 直接消费。 -36. 已在 `server-rs/crates/api-server` 中挂出 `POST /api/story/npc/battle`,当前只接受 `npc_fight / npc_spar`,同步返回 `npcInteraction + battleState`。 -37. 已执行 `cargo check -p spacetime-client -p api-server` 并通过,完成 `module-npc -> spacetime-client -> api-server` 的最小 NPC 开战同步返回链闭环。 -38. 已重新执行 `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes`,把 `get_battle_state`、`battle_state.reward_items` 与 `custom_world_agent_session` 相关 bindings 刷入 `spacetime-client/src/module_bindings`。 -39. 已把 `server-rs/crates/spacetime-client/src/lib.rs` 中原本占位返回错误的 `get_battle_state(...)` 改成真实 procedure 调用,当前 battle query 已不再停留在 facade stub。 -40. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 与 `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`,当前 battle/story 新链路在编译层已恢复通过。 -41. 已新增 `docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md`,冻结旧 `POST /api/runtime/story/state/resolve` 的首版兼容桥边界,明确当前先做 DTO 与状态桥,不提前误宣称 `actions/resolve` 已可迁移。 -42. 已在 `server-rs/crates/shared-contracts` 中新增 `runtime_story` 模块,冻结 `RuntimeStoryStateResolveRequest`、`RuntimeStoryActionResponse` 以及 `viewModel / presentation / patches / snapshot` 的首版 camelCase DTO,与当前前端消费口径对齐。 -43. 已恢复并重建 `server-rs/crates/api-server/src/runtime_story.rs`,把上一轮误删留下的中间态收口回可编译实现。 -44. 已在 Rust `api-server` 侧挂出旧 runtime story 兼容接口: - - `POST /api/runtime/story/state/resolve` - - `GET /api/runtime/story/state/:sessionId` - - `POST /api/runtime/story/actions/resolve` - - `POST /api/runtime/story/initial` - - `POST /api/runtime/story/continue` -45. `state/resolve` 与 `actions/resolve` 已统一复用 `runtime_save` 的 SpacetimeDB 快照持久化链: - - 请求带 `snapshot` 时先写入 `runtime_snapshot` - - 请求不带 `snapshot` 时从持久化 `runtime_snapshot` 读取 - - 无可用快照时返回 `409` -46. `actions/resolve` 已补齐当前前端主链需要的确定性兼容动作闭环,覆盖: - - `story_continue_adventure` - - `story_opening_camp_dialogue` - - `camp_travel_home_scene` - - `idle_call_out` - - `idle_explore_forward` - - `idle_observe_signs` - - `idle_rest_focus` - - `idle_travel_next_scene` - - `npc_preview_talk` - - `npc_chat` - - `npc_help` - - `npc_leave` - - `npc_fight` - - `npc_spar` - - `npc_recruit` - - `battle_attack_basic` - - `battle_use_skill` - - `battle_all_in_crush` - - `battle_escape_breakout` - - `battle_feint_step` - - `battle_finisher_window` - - `battle_guard_break` - - `battle_probe_pressure` - - `battle_recover_breath` - - `inventory_use` - - `equipment_equip` - - `npc_trade` - - `npc_gift` -47. `actions/resolve` 已补 `clientVersion` 与 `gameState.runtimeActionVersion` 的冲突校验、动作后版本自增、`storyHistory` 追加和 snapshot 回写。 -48. `initial` / `continue` 已先落稳定 `RuntimeStoryAiResponse`: - - 优先透传 `requestOptions.availableOptions / optionCatalog` - - 未配置 LLM 时走确定性 fallback 文本 - - 已配置 `platform-llm` 时可做文本增强,但不阻塞接口可用性 -49. `actions/resolve` 已开始迁移 Node 动作后 LLM 增强分支的最小闭环: - - `npc_chat / story_opening_camp_dialogue` 在配置 `platform-llm` 时会尝试生成对话态 `storyText` - - NPC 对话增强回包会对齐 Node 旧 `displayMode = dialogue + deferredOptions` 结构,先只展示“继续推进冒险” - - `battle victory / spar_complete / escaped` 在配置 `platform-llm` 时会尝试生成结果叙事,但不改既有规则结算 - - LLM 不可用或生成失败时自动回退到确定性 `resultText / currentStory` -50. 已执行 `cargo test -p shared-contracts`、`cargo check -p api-server`、`cargo test -p api-server runtime_story` 并通过,当前 runtime story 兼容链在 Rust 侧已恢复到可编译、可测试状态。 -51. 已补 Rust 侧 route boundary 回归: - - `runtime_story_routes_resolve_through_rust_route_boundary` - - `runtime_story_action_resolve_rejects_client_version_conflict` - - `runtime_story_npc_help_is_one_shot_and_restores_resources` - - `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full` -52. 已把兼容桥里的关键 NPC 行为继续对齐到 Node 旧主链: - - `npc_chat` 好感增长改为 `max(2, 6 - chattedCount)`,首聊可从 `46 -> 52` - - `npc_help` 改为一次性援手,成功时恢复 `10 HP / 8 Mana` 且关系 `+4` - - `npc_recruit` 改为要求 `affinity >= 60`,队伍满员时必须透传 `releaseNpcId` -53. 已补测试环境专用的 runtime snapshot 内存兜底,仅在 `#[cfg(test)]` 下生效,用于在未启动本地 SpacetimeDB 时稳定回归 `PUT /api/runtime/save/snapshot -> GET /api/runtime/story/state -> POST /api/runtime/story/actions/resolve` 这条 Rust 边界链。 -54. 已把 quest compat 主循环补到 Rust `runtime story` 兼容桥: - - `npc_chat_quest_offer_view` - - `npc_chat_quest_offer_replace` - - `npc_chat_quest_offer_abandon` - - `npc_quest_accept` - - `npc_quest_turn_in` -55. 已把 quest offer 对话态的 `currentStory.npcChatState.pendingQuestOffer` 与前端面板依赖的 `runtimePayload.npcChatQuestOfferAction` 一并回填到 Rust compat 回包,保证现有 quest 面板入口不回退。 -56. 已把 `npc_quest_turn_in` 的最小奖励闭环补回 Rust compat handler: - - quest 状态改为保留在 `gameState.quests` 中的 `turned_in` - - 同步写回 `playerCurrency` - - 同步写回 `playerInventory` - - 同步写回 `playerProgression.totalXp / level / xpToNextLevel / lastGrantedSource` - - 同步写回 NPC `affinity` -57. 已新增 quest compat Rust 回归: - - `runtime_story_quest_offer_replace_updates_pending_offer_and_payload` - - `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options` - - `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story` - - `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity` -58. 已再次执行 `cargo test -p api-server runtime_story`、`cargo check -p api-server` 与 `node scripts/check-encoding.mjs` 并通过,当前 quest compat 已恢复到可编译、可回归状态。 -59. 已继续把 Task6 旧 inventory / NPC inventory compat 主链补回 Rust `runtime story` 兼容桥: - - `equipment_equip` - - `equipment_unequip` - - `forge_craft` - - `forge_dismantle` - - `forge_reforge` - - `npc_trade` - - `npc_gift` -60. 已把 NPC 交互态 fallback option compiler 对齐到 Node 旧顺序,当前会按条件输出: - - `npc_chat` - - `npc_help` - - `npc_spar` - - `npc_fight` - - `npc_trade` - - `npc_gift` - - `npc_quest_accept / npc_quest_turn_in` - - `npc_recruit` - - `npc_leave` -61. 已新增 Rust compat 回归: - - `runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_help_lock` - - `runtime_story_equipment_equip_updates_loadout_and_build_toast` - - `runtime_story_equipment_unequip_returns_item_to_inventory_and_resets_loadout` - - `runtime_story_forge_craft_consumes_materials_and_currency` - - `runtime_story_forge_dismantle_replaces_item_with_material_outputs` - - `runtime_story_forge_reforge_upgrades_item_and_consumes_cost` - - `runtime_story_npc_trade_buy_updates_currency_inventory_and_stock` - - `runtime_story_state_compiler_bootstraps_trade_inventory_for_role_npc` - - `runtime_story_npc_trade_buy_bootstraps_missing_npc_state` - - `runtime_story_npc_gift_updates_affinity_inventory_and_patch` - - `runtime_story_route_boundary_persists_equipment_equip_snapshot_updates` -62. 当前 Rust compat bridge 已补入口级 NPC 状态预处理:即使快照里的 `npcStates` 为空,纯商贩型 NPC 也会在 `state/get` 与 `actions/resolve` 前自动初始化基础关系态、`stanceProfile / relationState / tradeStockSignature` 与最小 trade stock。 -63. 当前 `actions/resolve` 已不再只停留在确定性 `storyText = resultText`: - - 已在 Rust 侧新增 `generate_action_story_payload(...)` - - 已对齐 Node 旧分支的最小范围 `npc_chat / story_opening_camp_dialogue / terminal combat outcome` - - 当前仍未迁移 Node 那套完整 orchestrator 选项重排,只先保留既有 fallback options -64. 当前 `cargo test -p api-server runtime_story` 已提升到 30 条回归通过。 -65. 已继续把 runtime story compat 的 battle 展示编译从 `api-server` 抽到独立 crate: - - `module-runtime-story-compat` 当前已承接 `build_battle_runtime_story_options(...)`、`restore_player_resource(...)` 与战斗技能 / 推荐物品 option compiler - - `api-server/src/runtime_story/compat/battle.rs` 已删除 - - `presentation.rs` 与 `npc_actions.rs` 当前统一直接复用 crate 导出的 battle helper -66. 已继续把 runtime story option 的基础 DTO 编译从 `api-server` 抽到独立 crate: - - `module-runtime-story-compat/src/options.rs` 当前已承接 `build_static_runtime_story_option(...)`、`build_disabled_runtime_story_option(...)`、`build_runtime_story_option_from_story_option(...)`、`build_story_option_from_runtime_option(...)` - - `api-server/src/runtime_story/compat/presentation.rs` 已删除这批本地重复实现,当前只保留更贴近 NPC / quest / view-model 组装的逻辑 -67. 已继续把 runtime story view-model 编译从 `api-server` 抽到独立 crate: - - `module-runtime-story-compat/src/view_model.rs` 当前已承接 `build_runtime_story_view_model(...)`、`build_runtime_story_encounter(...)`、`build_runtime_story_companions(...)` - - `resolve_current_encounter_npc_state(...)` 已统一由 crate 导出,`api-server` 的 `presentation.rs` 与 `game_state.rs` 不再保留本地副本 -68. 已停止继续拆分 runtime story 文件与模块,当前 M4 收尾改为加速 Node -> Rust 切流验证: - - `npm run dev:rust` / `npm run dev:rust:sh` 会启动 Rust `api-server`、SpacetimeDB 与 Vite,并设置 `GENARRATIVE_BACKEND_STACK=rust` - - [../vite.config.ts](../vite.config.ts) 已补 `/api/story` 代理,Rust 栈下 `/api/runtime/*` 与 `/api/story/*` 均会走 `GENARRATIVE_RUNTIME_SERVER_TARGET` - - 当前 M4 的切流目标以“旧 runtime story 兼容接口 + 新 story/battle 查询切片可由 Rust 承接”为准,不再把继续拆 crate 作为本阶段阻塞项 - -当前验证边界补充: - -1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时仍然较长,还没有把更大范围的 story/battle 回归全部收拢到单次时窗内。 -2. `node scripts/check-encoding.mjs` 已再次执行并通过,当前本轮涉及的中文文件编码未被写坏。 -3. 当前可以确认的是: - - `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通 - - Rust `runtime story` compat route boundary 与关键 NPC 主循环规则已有回归覆盖 - - Rust `actions/resolve` 已开始承接 Node 动作后 LLM 文本增强,但完整 orchestrator / 真相链仍未完成 - -当前这轮不再继续扩 `runtime_story` 模块拆分。`resolve_story_action` / `sync_runtime_snapshot_projection` 作为真相态深化项转入后续收口或 M7 前置风险清单;M4 当前按“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧闭环 + `/api/story/*` 新切片代理可切到 Rust + 关键 gameplay 回归通过”收尾。 - -## 1. SpacetimeDB gameplay 表 - -- [x] 设计 `story_session` -- [x] 设计 `story_event` -- [x] 设计 `npc_state` -- [x] 设计 `quest_record` -- [x] 设计 `inventory_slot` -- [x] 设计 runtime item 奖励快照基线 -- [x] 设计 `battle_state` -- [x] 设计 `player_progression` -- [x] 设计 `chapter_progression` - -## 2. 核心 reducer - -- [ ] 设计 `resolve_story_action`(转入真相态深化,不阻塞 M4 兼容切流收尾) -- [x] 设计 `continue_story` -- [x] 设计 `begin_story_session` -- [ ] 设计 `sync_runtime_snapshot_projection`(转入真相态深化,不阻塞 M4 兼容切流收尾) -- [x] 设计 `apply_quest_signal` -- [x] 设计 `apply_inventory_mutation` -- [x] 设计 `resolve_npc_interaction` -- [x] 设计 runtime item 奖励回写基线 -- [x] 设计 `resolve_combat_action` -- [x] 设计 `update_progression_state` - -## 3. 当前主链模块落位 - -- [ ] 迁移 `rpg-entry` 配套后端入口能力 -- [ ] 迁移 `rpg-profile` 资料域 -- [x] 迁移 `rpg-runtime-story` -- [x] 迁移 `combat` -- [ ] 迁移 `inventory` -- [ ] 迁移 `npc` -- [x] 迁移 `progression` -- [x] 迁移 `quest` -- [x] 迁移 `runtime-item` -- [x] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则 - -## 4. 兼容接口 - -- [x] 兼容 `POST /api/runtime/story/actions/resolve` -- [x] 兼容 `GET /api/runtime/story/state/:sessionId` -- [x] 兼容 `POST /api/runtime/story/state/resolve` -- [x] 兼容 `POST /api/runtime/story/initial` -- [x] 兼容 `POST /api/runtime/story/continue` - -补充说明: - -1. 当前已落地的是两类 Rust facade: - - 新真相态接口: - - `POST /api/story/sessions` - - `POST /api/story/sessions/continue` - - `GET /api/story/sessions/:storySessionId/state` - - `GET /api/story/battles/:battleStateId` - - `POST /api/story/npc/battle` - - 旧 runtime story 兼容接口: - - `POST /api/runtime/story/state/resolve` - - `GET /api/runtime/story/state/:sessionId` - - `POST /api/runtime/story/actions/resolve` - - `POST /api/runtime/story/initial` - - `POST /api/runtime/story/continue` -2. 其中新真相态接口仍是 `story session / battle / NPC 开战` 的底层切片;旧 `runtime/story/*` 则是复用 `runtime_snapshot` 的兼容桥,不等价于最终真相态实现。 -3. 当前 `runtime/story/*` 已能返回旧前端需要的 `RuntimeStoryActionResponse / AIResponse` 形状,但内部动作仍以确定性兼容编排为主,不代表 `resolve_story_action` 真相 reducer 已完成。 -4. 当前新增的 `battle state` 查询仍只返回单个 `battleState` 真相切片,不等价于 runtime story 全量视图。 -5. 后续 `M4` 仍需把兼容桥逐步替换成真正的 story action / snapshot projection 真相链。 - -## 5. ViewModel 兼容 - -- [x] 兼容当前 `RuntimeStoryActionResponse` -- [x] 兼容当前 `RuntimeStoryOptionView` -- [x] 兼容当前 `interaction` 元数据 -- [x] 兼容当前 battle / toast / patch 响应结构 -- [x] 兼容当前 `currentStory` 回填逻辑 - -## 6. 阶段验收 - -- [x] 当前前端 story 选项点击后可走新后端闭环 -- [x] NPC / quest / combat 主循环行为不回退 -- [x] `story state` 恢复链可用 -- [x] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致 -- [x] 旧 Node 版 story route 回归用例完成平移 - -阶段验收补充说明: - -1. `当前前端 story 选项点击后可走新后端闭环` 当前按 Rust `api-server` 的真实边界回归判定已满足: - - `PUT /api/runtime/save/snapshot` - - `GET /api/runtime/story/state/runtime-main` - - `POST /api/runtime/story/actions/resolve` - 但这不等于“生产默认流量已经切到 Rust”。 -2. `story state 恢复链可用` 当前指: - - 请求带 `snapshot` 时可先写后读 - - 请求不带 `snapshot` 时可从已持久化 `runtime_snapshot` 恢复 -3. `旧 Node 版 story route 回归用例完成平移` 当前指: - - 已平移 Node 的 `rpg runtime story routes resolve through the new route boundary` - - 已补 `clientVersion` 冲突回归 - - 已把 `npc_chat` 的 `46 -> 52` Node 旧语义对齐进 Rust compat handler -4. `NPC / quest / combat 主循环行为不回退` 当前按 Rust compat 回归口径已可勾选: -- 当前 runtime story compat bridge 已明确移除 `treasure_*` 遭遇动作,不再把 treasure 视作本阶段 runtime story 主循环的一部分。 -- `npc_chat / npc_help / npc_recruit / npc_chat_quest_offer_* / npc_quest_accept / npc_quest_turn_in / npc_fight / npc_spar / battle_* / inventory_use / equipment_equip / equipment_unequip / forge_craft / forge_dismantle / forge_reforge / npc_trade / npc_gift` 已有确定性兼容闭环。 -- 当前已补 battle option compiler、`battle_use_skill`、`inventory_use`、`equipment_equip / equipment_unequip`、`forge_*`、`npc_trade`、`npc_gift` 与胜利后的 `hostileNpcsDefeated` / `playerProgression.lastGrantedSource = hostile_npc` 写回。 -- 当前已补 NPC 交互态入口预处理:纯商贩型 NPC 即使没有预填 `npcStates.*.inventory`,也会在 compat bridge 内自动恢复可交易库存与基础关系态,不再依赖 Node 侧预热。 -- 更大范围 Node 回归与真相态 reducer 替换不再作为 M4 阻塞项,转入 M7 切流前回归矩阵。 -5. `后端边界与当前 rpgEntry -> ...` 当前按 Rust 代理与路由覆盖可勾选: - - 前端真实调用链已对齐 `/api/runtime/story/*` - - Rust 栈已覆盖 `/api/runtime/*` 与 `/api/story/*` 代理目标 - - `npm run dev:rust` 是本地 Rust 切流入口,M7 再做远端灰度与回退验证 diff --git a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md b/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md deleted file mode 100644 index 5a62ebb7..00000000 --- a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md +++ /dev/null @@ -1,117 +0,0 @@ -# M5:custom world / gallery / agent 任务清单 - -## 0. 当前执行基线 - -本阶段与当前仓库里的创作链重构直接对应,统一以以下文档为准: - -1. [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) -2. [../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md) - -当前逻辑层命名和职责边界应优先使用 `rpgCreation / rpgAgent / rpgWorld` 口径;本任务清单继续保留 `custom world` 文件名,只是为了和后端重写阶段文档编号保持一致。 - -本轮首批可编码表设计见: - -3. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md) -4. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md) -5. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md) -6. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md) -7. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md) - -## 1. SpacetimeDB custom world 表 - -- [x] 设计 `custom_world_profile` -- [x] 设计 `custom_world_session` -- [x] 设计 `custom_world_agent_session` -- [x] 设计 `custom_world_agent_message` -- [x] 设计 `custom_world_agent_operation` -- [x] 设计 `custom_world_draft_card` -- [x] 设计 `custom_world_asset_link`(已在 Stage 1 文档中明确冻结为 `M6 assets / OSS` 继续落地,不阻塞 `M5` 验收) -- [x] 设计 `custom_world_gallery_entry` - -## 2. 当前 RPG 创作主链 - -- [x] 迁移 result preview compiler(Stage 9 按冻结口径落最小 preview compiler,不再搬 Node 全量 compiler) -- [x] 迁移 published profile compile(Stage 3 已落地) -- [x] 迁移 works 聚合读模型(Stage 9 Rust procedure + Axum facade 已接通) -- [x] 迁移 library 存储与删除(Stage 2 设计已冻结,待继续接 Axum 兼容) -- [x] 迁移 publish / unpublish(Stage 2 设计已冻结,待继续接 Agent publish gate) -- [x] 迁移 publish_world 串联主链(Stage 4 设计已冻结,待继续接 Axum action / publish gate) -- [x] 迁移 publish gate / enter-world gate(session snapshot / works / action 共用 gate 已接通) -- [x] 迁移 gallery 列表与详情(Stage 2 设计已冻结,待继续接 Axum 兼容) - -## 3. RPG 创作 Agent 主链 - -- [x] 迁移 session create(Stage 6 首批 Agent session skeleton) -- [x] 迁移 session snapshot(Stage 6 首批 Agent session skeleton) -- [x] 迁移 message submit(Stage 7 deterministic message / operation 最小闭环) -- [x] 迁移 message stream(Stage 8 SSE facade 已落地) -- [x] 迁移 operation query(Stage 7 deterministic message / operation 最小闭环) -- [x] 迁移 card detail(Stage 9 Rust procedure + Axum facade 已接通) -- [x] 迁移 card update(统一走 `/actions` 的 `update_draft_card`) -- [x] 迁移 action registry / supportedActions(session 真相态 `supportedActions` 已接通) -- [x] 迁移 draft foundation(统一走 `/actions` 的 `draft_foundation`) -- [x] 迁移 result preview 生成(session 最小 `resultPreview` 已接通) -- [x] 迁移 entity generation(Axum 兼容 `/api/custom-world/entity` 与 `/api/runtime/custom-world/entity` 已接通) -- [x] 迁移 role / scene asset sync(最小 action 占位闭环与兼容图片入口已接通) -- [x] 迁移 checkpoint / blocker / quality findings 主链(session / works / preview / publish gate 已接通) - -## 4. Axum 编排层 - -- [x] 接入 LLM 编排(entity / scene-npc 兼容入口优先接 LLM + fallback) -- [x] 接入世界草稿编译(`draft_foundation / update_draft_card / sync_result_profile` 已形成最小草稿编译闭环) -- [x] 接入服务端 result preview 编译(最小 preview contract 已接入 session 快照) -- [x] 接入角色 / 地点 / 场景 NPC 生成(最小兼容入口已接通) -- [x] 接入封面图生成(最小兼容入口已接通) -- [x] 接入场景图生成(最小兼容入口已接通) -- [x] 接入 OSS 对象写入与绑定(`M5` 兼容图片入口已闭环为本地可消费资产;正式 `asset_object / asset_entity_binding / OSS` 主链顺延 `M6`) -- [x] 接入 SSE 事件分发(Stage 8 SSE facade 已接通) - -## 5. 当前正式接口与历史兼容台账 - -### 5.1 当前正式接口 - -- [x] 兼容 `/api/runtime/custom-world-library`(Stage 5 首批 Axum facade) -- [x] 兼容 `/api/runtime/custom-world-library/:profileId`(owner-only detail 查询已补齐) -- [x] 兼容 `/api/runtime/custom-world-library/:profileId/publish`(Stage 5 首批 Axum facade) -- [x] 兼容 `/api/runtime/custom-world-library/:profileId/unpublish`(Stage 5 首批 Axum facade) -- [x] 兼容 `/api/runtime/custom-world-gallery`(Stage 5 首批 Axum facade) -- [x] 兼容 `/api/runtime/custom-world-gallery/:ownerUserId/:profileId`(Stage 5 首批 Axum facade) -- [x] 兼容 `/api/runtime/custom-world/works` -- [x] 兼容 `/api/runtime/custom-world/agent/sessions`(Stage 6 首批 Axum facade) -- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`(Stage 6 首批 Axum facade) -- [x] 兼容 `DELETE /api/runtime/custom-world/agent/sessions/:sessionId`(草稿物理清理;若作品卡误以已发布来源 session 删除,则回落到关联 profile 软删除并返回 works) -- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`(Stage 7 deterministic message submit) -- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`(Stage 8 SSE facade) -- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`(Stage 9 全量 action procedure 已接通) -- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`(Stage 7 deterministic operation query) -- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` -- [x] 兼容 `/api/custom-world/entity` -- [x] 兼容 `/api/runtime/custom-world/entity` -- [x] 兼容 `/api/custom-world/scene-npc` -- [x] 兼容 `/api/runtime/custom-world/scene-npc` -- [x] 兼容 `/api/custom-world/scene-image` -- [x] 兼容 `/api/custom-world/cover-image` -- [x] 兼容 `/api/custom-world/cover-upload` - -### 5.2 历史兼容台账(非当前主链) - -- [x] 评估 `/api/runtime/custom-world/sessions` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) -- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) -- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId/answers` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) -- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId/generate/stream` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除) - -## 6. 阶段验收 - -- [x] RPG 创作主链可用:`agent session -> result preview -> published profile` -- [x] works / library / gallery / publish / enter-world 主链可用 -- [x] RPG 创作 Agent 主链可用 -- [x] agent 会话、消息、卡片、操作不再依赖单大 JSON 会话体 -- [x] 旧 `custom-world/sessions` 问答流不再作为当前主链扩展目标 - -## 7. 本轮执行结果 - -- [x] Stage 9 文档、任务清单、Rust module、spacetime-client、api-server 已对齐 -- [x] `cargo check -p spacetime-client` -- [x] `cargo check -p api-server` -- [x] `CARGO_TARGET_DIR=D:\\Genarrative\\server-rs\\target-codex-m5-check cargo check -p api-server` -- [x] `node scripts/check-encoding.mjs ...` 编码检查通过 diff --git a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md deleted file mode 100644 index eb9ec69d..00000000 --- a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md +++ /dev/null @@ -1,153 +0,0 @@ -# M6:assets / 阿里云 OSS 任务清单 - -说明: - -1. `editor` 已于 `2026-04-21` 被确认为遗留无用模块,不再纳入本轮 Rust 后端重写范围。 -2. 本文件保留原文件名仅用于延续既有任务编号与链接,不再继续安排 editor 迁移项。 - -## 1. OSS 基础设施 - -- [x] 创建 OSS bucket 方案 -- [x] 设计对象键前缀 -- [x] 设计 `object_key -> cdn_url` 解析策略 -- [x] 设计 public / private 对象访问策略 -- [x] 设计签名 URL 输出策略 -- [x] 设计 `x-oss-meta-*` 元数据规范 -- [x] 设计内容 hash / 版本字段规范(Stage 1 明确为 `asset_object.content_hash: Option` + `version = 1`,后续强 hash 单独阶段再扩) - -## 2. 上传与对象确认 - -- [x] 实现浏览器 `PostObject` 直传签名接口 -- [x] 实现 STS 临时授权接口 -- [x] 实现服务端上传 helper -- [x] 实现上传完成后的对象确认接口 -- [x] 实现对象绑定业务实体 reducer - -补充说明: - -1. 自 `2026-04-21` 起,当前重写节奏允许在 `M3/M4/M5` 之前先前置落地 `M6` 的 OSS 基础设施。 -2. 当前已在 `server-rs/crates/platform-oss` 与 `server-rs/crates/api-server` 落下最小可用链路: - - `PostObject` 直传签名能力 - - `/api/assets/direct-upload-tickets` - - `/api/assets/objects/confirm` - - 兼容旧 `/generated-*` 前缀的对象键规划 - - `.env/.env.local` 的 OSS 环境变量加载 - - 服务端 `HEAD Object` 校验 - - `asset_object` 确认真实 SpacetimeDB 持久化 - - `/api/assets/objects/bind` - - `asset_entity_binding` 业务实体槽位绑定 - - `/api/assets/sts-upload-credentials` 禁用式 contract - - 服务端 `PutObject` 上传 helper -3. 当前 bucket 已明确为私有读写;后续正式存储口径改为 `bucket + object_key` 双列,不再把匿名公开 URL 当成真相。 -4. 当前 STS 接口按“服务器上传、Web 只下载”的需求固定为 `403` 禁用式 contract,不向浏览器下发 OSS 写权限。 -5. `2026-04-21` 已通过 live test 验证:真实 OSS 上传后,`/api/assets/objects/confirm` 能把 `xushi-dev + object_key` 写入本地 `genarrative-dev.asset_object`,并可继续通过 `/api/assets/objects/bind` 绑定到业务实体槽位。 - -## 3. 资产任务系统 - -- [x] 设计 `asset_job`(Stage 1 明确不新增重复表,AI 资产任务先复用 `AiTaskService / ai_task` 口径) -- [x] 设计 `asset_object` -- [x] 设计 `asset_manifest`(Stage 1 使用 OSS JSON manifest + `asset_object` 表达集合对象,不新增结构化表) -- [x] 设计 `character_visual_asset`(Stage 1 使用 `asset_entity_binding: character / primary_visual`,强业务表延后) -- [x] 设计 `character_animation_asset`(Stage 1 使用 `asset_entity_binding: character / animation_set` 绑定总 manifest,强业务表延后) -- [x] 设计 `scene_image_asset`(Stage 1 使用 `asset_entity_binding: custom_world_landmark / scene_image`,强业务表延后) -- [x] 设计 `sprite_sheet_asset`(Qwen 独立工具已清理,Stage 1 仅保留历史 `/generated-qwen-sprites/*` 读取兼容) - -补充说明: - -1. `asset_object` 当前已冻结核心存储口径为: - - `bucket` - - `object_key` -2. 详细设计见: - - [../docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md) - - [../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md) - - [../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md) - - [../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md) -3. 当前已在 `server-rs/crates/spacetime-module` 落下 `asset_object` 首版表骨架,并完成 `api-server -> SpacetimeDB` 的最小对象确认闭环。 -4. 元数据、版本、manifest 与强业务资产表边界见: - - [../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md](../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md) - -## 4. 资产生成链路 - -- [x] 迁移角色主形象生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前仍为 SVG 占位生成,不代表真实 DashScope 图片模型已迁完) -- [x] 迁移角色动作生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前 `image-sequence` 为 SVG 占位帧,视频类策略优先复用参考视频或仓库占位预览,不代表真实视频模型已迁完) -- [x] 迁移动作模板查询(Stage 1 已接通 Rust 内置模板列表兼容接口) -- [x] 迁移视频导入(Stage 1 已接通 Data URL 视频导入到 OSS 草稿区,不再写本地 `public/`) -- [x] 迁移工作流缓存(Stage 1 已接通 Rust `GET/POST character-workflow-cache` 到 OSS JSON 草稿对象,不再写本地 `public/`) -- [x] 迁移场景图生成(已完成 Stage 2:custom world `scene-image` 走真实 DashScope 图片生成,并继续写入 `OSS + asset_object + asset_entity_binding`) -- [x] 迁移封面图上传(已完成 Stage 2:custom world `cover-image / cover-upload` 已补齐真实 DashScope 生成与 `cropRect + 16:9 + WebP 压缩`) -- [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract,不再写仓库 `public/`) - -补充说明: - -1. custom world 兼容图片入口现已完成 Stage 1 + Stage 2:正式资产真相链、真实 DashScope 图片生成,以及封面上传裁剪压缩都已迁完。 -2. 详细边界见: - - [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md) -3. 角色动作模板与视频导入第一批已新增独立设计文档,当前只迁移: - - `GET /api/assets/character-animation/templates` - - `POST /api/assets/character-animation/import-video` - - [../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md) -4. 角色资产工作流缓存第一批已新增独立设计文档,当前把旧本地 `workflow-cache.json` 改为 OSS JSON 草稿对象: - - `GET /api/assets/character-workflow-cache/:characterId` - - `POST /api/assets/character-workflow-cache` - - [../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md) -5. `2026-04-22` 复核确认:旧独立 `qwen-sprite-tool + qwenSpriteRoutes.ts` 已在 `2026-04-21` 清理,不再作为本轮现役迁移主链;当前仍保留的 `Qwen` 相关内容仅包括: - - 角色资产 prompt 层对 `packages/shared/src/prompts/qwenSprite.ts` 的复用 - - 历史资源前缀 `/generated-qwen-sprites/*` 的读取兼容 -6. custom world 图片链 Stage 2 已完成: - - `scene-image / cover-image` 已替换为真实 DashScope 图片生成 - - `cover-upload` 已补回 Node 旧链路中的 `cropRect + 16:9 + WebP 压缩` - - 详细口径与验证结果见 [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md) - -## 5. 路径兼容 - -- [x] 兼容 `/generated-character-drafts/*` -- [x] 兼容 `/generated-characters/*` -- [x] 兼容 `/generated-animations/*` -- [x] 兼容 `/generated-custom-world-scenes/*` -- [x] 兼容 `/generated-custom-world-covers/*` -- [x] 兼容 `/generated-qwen-sprites/*` - -补充说明: - -1. 第一批路径兼容由 Rust `api-server` 同源代理到私有 OSS 短期读签名,不回退本地 `public/`,详细边界见: - - [../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md) -2. 当前 Stage 1 先全量代理对象内容,不实现视频 Range 分片;若后续真实视频体积变大,再按播放器需求补 Range。 - -## 6. 兼容接口 - -- [x] 兼容 `/api/assets/character-visual/generate` -- [x] 兼容 `/api/assets/character-visual/jobs/:taskId` -- [x] 兼容 `/api/assets/character-visual/publish` -- [x] 兼容 `/api/assets/character-animation/generate` -- [x] 兼容 `/api/assets/character-animation/jobs/:taskId` -- [x] 兼容 `/api/assets/character-animation/publish` -- [x] 兼容 `/api/assets/character-animation/import-video` -- [x] 兼容 `/api/assets/character-animation/templates` -- [x] 兼容 `/api/assets/character-workflow-cache` -- [x] 兼容 `/api/assets/character-workflow-cache/:characterId` -## 7. 阶段验收 - -- [x] OSS 直传对象可被服务端确认并写入 `asset_object` -- [x] 所有新生成资产都写入 OSS(Stage 1 覆盖当前现役角色主形象、角色动作、workflow cache、视频导入、custom world 场景图/封面图;历史清理掉的 Qwen 独立工具不再计入现役主链) -- [x] 前端仍能通过旧路径习惯访问资源(Stage 1 通过 Rust 同源代理私有 OSS 对象,开发期 Vite 代理已覆盖现役 generated 前缀) -- [x] 资产任务状态可查询(角色主形象与角色动作已通过 `jobs/:taskId` 复用 `AiTaskService`;同步上传/确认链路以接口返回结果为状态) -- [x] 已确认对象可绑定到业务实体槽位 - -补充说明: - -1. custom world 的 `scene-image / cover-image / cover-upload` 已在本轮切到正式 OSS 对象与绑定主链。 -2. 角色主形象第一批已新增独立设计文档与 Rust 最小闭环: - - [../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md) -3. 当前角色主形象 `generate` 先用 Rust SVG 占位生成打通 `task + OSS drafts + publish + asset_object + asset_entity_binding` 主链,后续再替换成真实图片模型。 -4. 角色动作模板与视频导入第一批已接入 Rust: - - `templates` 返回旧内置模板 contract。 - - `import-video` 当前只接受 `data:video/*;base64,...`,并写入 OSS `generated-character-drafts/*` 草稿区。 -5. 角色资产工作流缓存第一批已接入 Rust: - - 保存时写入 OSS `generated-character-drafts/{character}/workflow-cache/workflow-cache.json`。 - - 读取时未命中返回 `cache: null`,保持旧前端 contract。 -6. 角色动作第一批已接入 Rust: - - `generate` 直接写入 OSS `generated-character-drafts/*`。 - - `jobs/:taskId` 从 `AiTaskService` 派生旧任务状态 contract。 - - `publish` 会把动作帧与总 manifest 写入 OSS `generated-animations/*`,并确认 `asset_object + asset_entity_binding`。 -7. custom world 场景图、封面图、封面上传已在 `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md` + `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md` 范围内完成正式 `OSS + asset_object + asset_entity_binding` 主链、真实 DashScope 图片生成和封面上传裁剪压缩。 -8. `content_hash/version`、`asset_job`、`asset_manifest` 与强业务资产表当前已冻结 Stage 1 边界,不再作为 M6 第一批工程阻塞项;后续若要做内容去重、manifest 查询、审核/回滚或 sprite sheet 强结构化,再进入独立阶段。 diff --git a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md deleted file mode 100644 index c7d5019a..00000000 --- a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md +++ /dev/null @@ -1,66 +0,0 @@ -# M7:联调、回归、部署与切流任务清单 - -## 1. 测试体系 - -- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + `server-rs/scripts/check.ps1` 固化;新增接口测试继续按主链补齐) -- [x] 为 SpacetimeDB reducer 补规则测试(现阶段以 `cargo check -p spacetime-module` 作为 schema/reducer/procedure 最小门禁;真实数据库规则回归继续由本地 publish smoke 承接) -- [x] 为 view / projection 补数据一致性测试(现阶段以 `shared-contracts` contract 回归与 SpacetimeDB schema check 固化投影字段门禁) -- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 Rust 主线检查入口) -- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 Rust 主线检查入口) -- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 Rust 主线检查入口) -- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 Rust 主线检查;真实 LLM/OSS 环境联调继续由 smoke 承接) -- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,Rust 主线检查固化基础门禁) -- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 Rust 主线检查) - -## 2. 部署准备 - -- [x] 设计 Axum 部署方式 -- [x] 设计 SpacetimeDB 发布方式 -- [x] 设计 OSS bucket / CDN / 域名方案 -- [x] 设计环境变量清单 -- [x] 设计灰度环境 -- [x] 设计数据迁移脚本 -- [x] 设计回滚策略 -- [x] 准备本地 Rust 一键联调脚本(`npm run dev:rust` 同时启动前端、Rust `api-server` 与本地 SpacetimeDB) -- [x] 准备 Ubuntu 发布包构建脚本(`npm run build:rust:ubuntu` 生成 `build//`,包含 `web/`、`api-server`、`spacetime_module.wasm`、`start.sh`、`stop.sh`,并默认 scp 上传到目标服务器) - -## 3. 观测能力 - -- [x] 接入 tracing / request id / structured logs -- [x] 接入慢请求追踪 -- [x] 接入上游 LLM / OSS / 短信 / 微信失败日志(沿用既有 provider error envelope 与 tracing,M7 固化字段口径) -- [x] 接入关键 reducer 执行日志(现阶段固定 reducer 操作日志字段口径,真实 publish 日志回看继续由 SpacetimeDB smoke 承接) -- [x] 接入资产任务状态日志(沿用 `AiTaskService / ai_task` 状态链,M7 固化 `task_id / status / asset_kind` 观测口径) - -## 4. 切流准备 - -- [x] 准备旧 Node 与新 Rust 双跑窗口 -- [x] 准备 API 对比脚本 -- [x] 准备主流程 smoke 清单 -- [x] 准备前端切换开关 -- [x] 准备回退开关 - -## 5. 主工程结构收口 - -- [x] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口 - -执行约束: - -1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。 -2. 拆分后的模块边界必须与 `M0` 已冻结的模块迁移归属一致,避免 `spacetime-module` 再回退成单大包。 -3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。 - -## 6. 阶段验收 - -- [x] 本地切流前预检通过(M7 阶段性预检包装入口已归档,长期入口改为 `server-rs/scripts/check.ps1`) -- [x] 主流程基础回归通过(`cargo check -p spacetime-module`、`cargo check -p api-server`、`cargo test -p shared-contracts`、`cargo test -p api-server --no-run`) -- [ ] 全链路 smoke 通过 -- [ ] 主流程真实环境回归通过 -- [ ] 关键 SSE 接口联调通过 -- [ ] 可在灰度环境完成切流 - -补充说明: - -1. M7 已新增 [../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md),冻结本地预检、部署、灰度、双跑、回滚与结构收口口径。 -2. 本轮新增 [../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md),并落地 `scripts/dev-rust-stack.ps1`、`scripts/dev-rust-stack.sh`、`scripts/deploy-rust-remote.sh`;其中发布脚本当前语义为生成 Ubuntu release 包。 -3. 当前 M7 阶段性 preflight 入口已归档;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。 diff --git a/backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md b/backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md deleted file mode 100644 index c0e479dc..00000000 --- a/backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md +++ /dev/null @@ -1,62 +0,0 @@ -# 横向专项、执行顺序与最终验收 - -## 1. 横向专项任务 - -### Contract 与前端兼容 - -- [x] 梳理当前 `packages/shared/src/contracts/*` 到 Rust DTO 的映射 -- [x] 设计 Rust 侧 contract 生成或手写策略 -- [x] 保持当前字段名、枚举值、响应结构稳定 -- [x] 为 breaking change 建立显式变更流程 - -### SpacetimeDB schema 演进治理 - -- [x] 约定 stable reducer 命名规则 -- [x] 约定 stable table 命名规则 -- [x] 约定列追加式演进规则 -- [x] 约定软删除而不是直接删表删列的场景 -- [x] 约定事件表与投影表拆分规则 - -### 大对象与缓存治理 - -- [x] 明确哪些内容入 OSS -- [x] 明确哪些内容只存 SpacetimeDB 元数据 -- [x] 明确哪些内容允许短期本地缓存 -- [x] 明确 workflow cache 生命周期 - -### 文档维护 - -- [x] 每个阶段完成后同步更新设计文档 -- [x] 每个阶段完成后补一份落地记录 -- [x] 完成接口迁移后更新新的模块与 API 索引文档 -- [ ] `M4` 结构变更同步对齐 `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` -- [x] `M5` 结构变更同步对齐 `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` - -补充说明: - -1. 横向治理规则已冻结在 [../docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](../docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md)。 -2. Rust 侧 96 条 Axum 路由索引已冻结在 [../docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](../docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md)。 -3. `M4` 当前仍存在 `runtime_story` 独立 crate 拆分工作区,结构文档对齐需等该拆分收口后再勾选。 - -## 2. 第一优先级建议执行顺序 - -1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。 -2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。 -3. 再做 `M3`,优先跑通快照、设置、profile。 -4. 进入 `M4` 和 `M5` 前,先用两份 `2026-04-21` 执行方案冻结当前仓库里的 RPG 运行时链与创作链结构口径。 -5. 再做 `M4`,把 RPG runtime story 主循环真正迁走。 -6. 然后做 `M5`,迁 RPG 创作主链、works/library/gallery 与 agent。 -7. 最后做 `M6 + M7`,收口 assets、editor、部署与切流。 - -## 3. 最终验收清单 - -- [x] 当前 `96` 条后端接口已全部迁移或有兼容替代 -- [ ] 当前 `6` 个挂载面已全部迁移 -- [ ] 当前 `12` 个内部模块已完成新架构落位 -- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界 -- [ ] SpacetimeDB 已成为唯一运行时状态真相源 -- [ ] 阿里云 OSS 已成为唯一资产对象仓 -- [ ] `M4` 已与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory / rpgProfile` 主链口径一致 -- [x] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致 -- [ ] 前端主流程在不大改 UI 的前提下可跑通 -- [ ] 能完成灰度切流,并保留可回退能力 diff --git a/backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md b/backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md deleted file mode 100644 index daa8f874..00000000 --- a/backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md +++ /dev/null @@ -1,183 +0,0 @@ -# M0:后端挂载面冻结基线 - -日期:`2026-04-20` - -依据来源: - -- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md) -- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json) - -## 1. 文档目的 - -这份文档用于完成 `M0` 的第一条任务: - -- 整理当前后端 `6` 个挂载面并锁定为重写验收基线 - -这里的“冻结”不是要求新后端永远维持原实现,而是要求: - -1. 当前 Node 后端历史基线仍固定为这 `6` 个挂载面。 -2. 本轮 Rust 后端的 active rewrite target 固定覆盖其中 `5` 个挂载面:`assets`、`auth`、`health`、`runtime-main`、`runtime-story-action`。 -3. `editor` 作为历史遗留挂载面继续保留对照记录,但自 `2026-04-21` 起不纳入 `server-rs` 本轮重写验收。 -4. 允许内部实现从 `Express + PostgreSQL + 本地 public/generated-*` 重写为 `Axum + SpacetimeDB + 阿里云 OSS`,但不允许把挂载面职责打散到无法对照验收。 - -## 2. 冻结结论 - -当前 Node 后端的正式挂载面固定为以下 `6` 个: - -| 挂载面 ID | 中文名称 | 当前路由数 | 当前入口 | 必须保留的顶层路径 | -| --- | --- | --- | --- | --- | -| `assets` | 资产生成工具面 | `14` | `server-node/src/app.ts -> /api/assets` | `/api/assets/*` | -| `auth` | 鉴权与会话面 | `17` | `server-node/src/app.ts -> /api/auth` | `/api/auth/*` | -| `editor` | 编辑器工具面 | `3` | `server-node/src/app.ts -> /api/editor` | `/api/editor/*` | -| `health` | 基础健康检查 | `1` | `server-node/src/app.ts -> /healthz` | `/healthz` | -| `runtime-main` | 运行时主能力面 | `59` | `server-node/src/app.ts -> /api` | `/api/runtime/*`、`/api/profile/*`、`/api/custom-world/*`、`/api/llm/*`、`/api/ws/*` | -| `runtime-story-action` | 运行时 Story Action 面 | `2` | `server-node/src/app.ts -> /api/runtime/story` | `/api/runtime/story/*` | - -冻结总数: - -1. 历史对外挂载面:`6` -2. 本轮 active rewrite target:`5` -3. 已登记路由:`96` -4. 公开接口:`10` -5. JWT 接口:`69` -6. 开关控制接口:`17` -7. 流式接口:`6` - -## 3. 各挂载面冻结要求 - -### 3.1 `assets` - -当前定位: - -1. 角色主形象生成 -2. 角色动作生成 -3. Qwen 精灵表生成与保存 -4. 工作流缓存 -5. 产物发布到 `public/generated-*` - -重写后的冻结要求: - -1. 仍保留独立的 `/api/assets/*` 命名空间。 -2. 仍保留“生成任务、任务状态查询、发布/保存”三类操作语义。 -3. 当前基于本地 `public/generated-*` 的产物落地,可改为 `OSS + 元数据表`,但前端一阶段必须继续通过原有路径习惯访问资源。 -4. 当前 `ASSETS_API_ENABLED` 门禁能力必须保留。 - -### 3.2 `auth` - -当前定位: - -1. 本地账号登录 -2. 手机验证码登录 -3. 微信登录 -4. refresh session -5. 会话吊销 -6. 审计与风控 - -重写后的冻结要求: - -1. 仍保留独立的 `/api/auth/*` 命名空间。 -2. 仍保留当前 `JWT + refresh cookie` 双令牌模型。 -3. 仍保留 `password / phone / wechat` 三类登录能力面。 -4. 仍保留审计日志、风控封禁、会话列表与会话吊销能力。 - -### 3.3 `editor` - -当前定位: - -1. 编辑器 JSON 读取 -2. 编辑器 JSON 回写 -3. 图标目录枚举 - -重写后的冻结要求: - -1. `server-node/src/app.ts -> /api/editor/*` 的历史存在事实继续保留在基线文档中。 -2. 自 `2026-04-21` 起,该挂载面不纳入 `server-rs` 本轮重写范围,不再作为 `M1 ~ M6` 主线交付目标。 -3. 若未来仍需清理或替代 editor,需要在遗留链路依赖核对完成后单独立项。 - -### 3.4 `health` - -当前定位: - -1. 提供后端进程健康探针 -2. 为代理层与 smoke 提供最小可用确认 - -重写后的冻结要求: - -1. 仍保留 `/healthz`。 -2. 仍返回简单、无鉴权、无数据库强耦合的健康状态。 -3. 仍可作为 smoke 与部署探针的第一检查点。 - -### 3.5 `runtime-main` - -当前定位: - -1. 运行时存档、设置、个人档案 -2. 聊天、剧情、任务、运行时物品意图 -3. custom world library / gallery / sessions -4. custom world agent 会话、消息、操作 - -重写后的冻结要求: - -1. 仍保留运行时主入口作为最大能力面,不把这些能力拆散到前端无法感知的新命名空间。 -2. 仍兼容当前: - - `/api/runtime/*` - - `/api/profile/*` - - `/api/custom-world/*` - - `/api/llm/*` - - `/api/ws/*` -3. 除公开画廊与少量公开接口外,仍以登录态为默认访问前提。 -4. 当前大量业务逻辑虽然会迁到 `SpacetimeDB reducer/view + Axum facade`,但对前端看起来仍应是一个统一运行时能力面。 - -### 3.6 `runtime-story-action` - -当前定位: - -1. story choice 动作解析 -2. story session 状态恢复 - -重写后的冻结要求: - -1. 仍保留 `/api/runtime/story/*` 作为独立挂载面。 -2. 仍保持“前端动作输入 -> 后端统一结算 -> 返回新状态”的接口职责。 -3. 当前 `storyActionService` 里跨 `quest / inventory / runtime-item / npc / progression / combat / runtime` 的协作,迁移后必须继续存在,只是实现位置改到 `SpacetimeDB + Axum`。 - -## 4. 挂载面与新架构映射 - -| 当前挂载面 | 新架构主归属 | 说明 | -| --- | --- | --- | -| `assets` | `Axum + OSS + SpacetimeDB asset metadata` | 外部副作用在 Axum,二进制在 OSS,任务与引用状态在 SpacetimeDB。 | -| `auth` | `Axum auth-service + SpacetimeDB auth tables` | 登录副作用与 cookie/JWT 在 Axum,身份与会话状态在 SpacetimeDB。 | -| `editor` | `遗留保留于 server-node` | 历史挂载面对照,当前不进入 Rust 重写主链。 | -| `health` | `Axum health route` | 维持最小化健康检查面。 | -| `runtime-main` | `Axum runtime facade + SpacetimeDB runtime/custom-world tables` | Axum 维持兼容 REST/SSE,SpacetimeDB 负责状态真相。 | -| `runtime-story-action` | `Axum story facade + SpacetimeDB gameplay reducers/views` | Story Action 入口继续独立存在,但结算内核迁到新状态层。 | - -## 5. 本轮冻结后的硬约束 - -后续迁移中,不允许出现以下情况: - -1. 把历史 `6` 个挂载面减少成更少但无法一一对照的“超级入口”。 -2. 为了迎合本轮重写范围而把历史存在的 `/api/editor/*` 从基线文档中抹掉。 -3. 把当前 `/api/auth/*`、`/api/assets/*`、`/api/runtime/story/*` 顶层命名空间直接改掉。 -4. 在未完成路径兼容前,直接移除 `/healthz` 或 `/generated-*` 的既有访问习惯。 -5. 在未完成契约回归前,把 `runtime-main` 和 `runtime-story-action` 的职责重新混成一个难以验收的大入口。 - -## 6. 本任务完成定义 - -当以下条件成立时,这条任务视为完成: - -1. 当前历史 `6` 个挂载面已经有正式书面冻结清单。 -2. 每个挂载面都有: - - 当前入口 - - 当前路由数 - - 顶层路径空间 - - 重写后必须保留的职责边界 -3. 本轮 active rewrite target 为 `5` 个,且 `editor` 的遗留/不迁移口径已经冻结。 -4. 后续任务可以直接以这份文档作为验收引用,不再靠聊天记录记忆。 - -## 7. 后续直接依赖这份基线的任务 - -1. 整理当前后端 `96` 条路由并生成“旧接口 -> 新实现”映射表 -2. 整理当前 `12` 个内部模块并锁定迁移归属 -3. 设计 Axum 路由树 -4. 设计 SpacetimeDB 表 / reducer / view 分层 diff --git a/backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md b/backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md deleted file mode 100644 index 3af1cdf2..00000000 --- a/backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md +++ /dev/null @@ -1,262 +0,0 @@ -# M0:前端直接依赖的响应头、Envelope 与错误格式冻结基线 - -日期:`2026-04-20` - -依据来源: - -- `server-node/src/http.ts` -- `server-node/src/middleware/responseEnvelope.ts` -- `server-node/src/middleware/errorHandler.ts` -- `server-node/src/middleware/requestId.ts` -- `packages/shared/src/http.ts` -- `src/services/apiClient.ts` -- `src/services/authService.ts` -- `src/services/aiService.ts` -- `src/editor/shared/jsonClient.ts` -- `src/services/apiClient.test.ts` - -## 1. 文档目的 - -这份文档用于完成 `M0` 的第六条任务: - -- 整理当前前端直接依赖的响应头、envelope、错误格式 - -这里的“直接依赖”指的是:如果 Axum 重写时把这些头或 body 结构改掉,当前前端 `src/services/*`、编辑器请求层和鉴权异常处理就会立刻出问题。 - -## 2. 冻结结论 - -当前前端直接依赖的响应契约,冻结为以下 4 层: - -1. 请求侧默认会发送 `x-genarrative-response-envelope: v1`。 -2. 响应侧默认要回 `x-request-id`、`x-api-version`、`x-route-version`。 -3. 成功响应在请求方要求 envelope 时,必须返回标准 `ok/data/error/meta` 结构。 -4. 错误响应既要兼容标准 envelope,也要兼容旧式 `{ error, meta }` / `{ message, code }` 解析回退。 - -补充结论: - -1. 当前正式前端代码里,没有生产用例主动关闭 envelope 请求头。 -2. `x-response-time-ms` 当前不是前端代码的直接读取项,但属于现有兼容头集合,重写时仍应保留。 -3. 鉴权链路额外直接依赖错误码 `CAPTCHA_REQUIRED` 与 `error.details.captchaChallenge`。 - -## 3. 当前前端直接依赖矩阵 - -| 依赖项 | 当前值/结构 | 当前消费者 | 当前作用 | -| --- | --- | --- | --- | -| 请求头 | `x-genarrative-response-envelope: v1` | `src/services/apiClient.ts`、`src/editor/shared/jsonClient.ts` | 请求标准 envelope 响应。 | -| 响应头 | `x-request-id` | `src/services/apiClient.ts` | 构造 `ApiClientError.meta.requestId` 的回退来源。 | -| 响应头 | `x-api-version` | `src/services/apiClient.ts`、`packages/shared/src/http.ts` | 识别标准 envelope / error body。 | -| 响应头 | `x-route-version` | `src/services/apiClient.ts` | 构造 `ApiClientError.meta.routeVersion` 的回退来源。 | -| 成功 body | `{ ok: true, data, error: null, meta }` | `unwrapApiResponse(...)` | 前端默认解包标准成功 envelope。 | -| 错误 body | `{ ok: false, data: null, error, meta }` | `ApiClientError`、`parseApiErrorMessage(...)` | 标准错误解析。 | -| 旧错误 body | `{ error, meta }` / `{ message, code }` | `parseApiErrorMessage(...)` | 老接口或非标准错误回退解析。 | -| 错误细节 | `error.code === 'CAPTCHA_REQUIRED'` 且 `error.details.captchaChallenge` | `src/services/authService.ts` | 手机验证码发送前的验证码挑战弹出。 | - -## 4. 请求侧冻结要求 - -### 4.1 Envelope 请求头 - -当前前端默认行为: - -1. `src/services/apiClient.ts` 会自动补: - - `x-genarrative-response-envelope: v1` -2. `src/editor/shared/jsonClient.ts` 也会自动补: - - `x-genarrative-response-envelope: v1` - -当前后端接受的 envelope 触发值: - -1. `1` -2. `true` -3. `v1` -4. `envelope` - -但当前前端真实发送值冻结为: - -1. `v1` - -补充冻结点: - -1. 虽然 `apiClient` 提供了 `omitEnvelopeHeader` 选项,但当前生产代码没有实际依赖它。 -2. 因此第一阶段 Axum 应默认兼容“前端请求即要 envelope”的模式。 - -### 4.2 鉴权与凭证约定 - -当前前端请求层默认还会做: - -1. `Authorization: Bearer ` 自动注入。 -2. `credentials: same-origin`。 -3. 遇到 `401` 时尝试走 `/api/auth/refresh` 自动刷新。 - -这不是本文重点,但它解释了为什么 envelope 和错误格式必须在 `/api/auth/refresh` 上也保持兼容。 - -## 5. 响应头冻结要求 - -### 5.1 必须保留的前端直接依赖头 - -| 响应头 | 当前来源 | 当前前端用法 | -| --- | --- | --- | -| `x-request-id` | `requestIdMiddleware` + `applyApiResponseHeaders` | `ApiClientError.meta.requestId` 的 header 回退来源。 | -| `x-api-version` | `applyApiResponseHeaders` | 当前标准 API 契约版本识别。 | -| `x-route-version` | `applyApiResponseHeaders` | `ApiClientError.meta.routeVersion` 的 header 回退来源。 | - -### 5.2 兼容头但非直接读取项 - -| 响应头 | 当前状态 | 说明 | -| --- | --- | --- | -| `x-response-time-ms` | 当前统一输出 | 目前前端代码未直接读取,但设计文档与联调约定已锁定,不能随意删除。 | - -补充冻结点: - -1. `requestIdMiddleware` 会优先回显请求方传入的 `x-request-id`,否则服务端自生成。 -2. `ApiClientError` 读取元信息时优先取 body `meta`,没有再回退到 headers。 -3. 这意味着即便 envelope body 缺少部分 `meta` 字段,headers 仍必须完整。 - -## 6. 成功响应 Envelope 冻结格式 - -当前标准成功 envelope: - -```json -{ - "ok": true, - "data": {}, - "error": null, - "meta": { - "apiVersion": "2026-04-08", - "requestId": "req-xxx", - "routeVersion": "2026-04-08", - "operation": "runtime.story.initial", - "latencyMs": 12, - "timestamp": "2026-04-20T00:00:00.000Z" - } -} -``` - -冻结规则: - -1. `ok` 必须为 `true`。 -2. `data` 为真实业务负载。 -3. `error` 必须为 `null`。 -4. `meta.apiVersion` 必须存在,因为 `unwrapApiResponse(...)` 与 `isApiResponse(...)` 依赖它判断标准 envelope。 - -补充说明: - -1. 如果请求未带 envelope 头,当前后端可以直接返回裸 `data`。 -2. 但由于当前前端默认都会请求 envelope,第一阶段 Axum 基本等价于“所有 JSON 成功响应都要兼容这个结构”。 - -## 7. 错误响应 Envelope 与旧格式回退 - -### 7.1 当前标准错误 envelope - -```json -{ - "ok": false, - "data": null, - "error": { - "code": "UNAUTHORIZED", - "message": "缺少 Authorization Bearer Token", - "details": null - }, - "meta": { - "apiVersion": "2026-04-08", - "requestId": "req-xxx", - "routeVersion": "2026-04-08", - "operation": "auth.me", - "latencyMs": 3, - "timestamp": "2026-04-20T00:00:00.000Z" - } -} -``` - -冻结规则: - -1. `ok` 必须为 `false`。 -2. `data` 必须为 `null`。 -3. `error.code`、`error.message` 必须存在。 -4. `error.details` 可为对象或 `null`。 -5. `meta.apiVersion` 必须存在。 - -### 7.2 当前旧式错误格式回退 - -当请求未要求 envelope,或某些链路仍走旧写法时,当前后端与前端仍兼容以下错误结构: - -1. `{ "error": { "code": "...", "message": "...", "details": ... }, "meta": {...} }` -2. `{ "message": "...", "code": "..." }` -3. `{ "error": { "message": "..." } }` -4. 纯文本错误响应 - -`parseApiErrorMessage(rawText, fallbackMessage)` 的当前回退顺序固定为: - -1. `parsed.error.message` -2. 顶层 `message` -3. `error.code` 或顶层 `code`,拼成 `fallback(CODE)` -4. 原始文本 -5. 调用方的 `fallbackMessage` - -这意味着: - -1. Axum 第一阶段不能只兼容标准 envelope,而忽略旧错误解析的回退行为。 -2. 至少在迁移过渡期,`parseApiErrorMessage(...)` 可识别的信息要继续保留。 - -## 8. 前端对错误细节的业务级直接依赖 - -### 8.1 验证码挑战 - -`src/services/authService.ts` 当前明确依赖: - -1. `error instanceof ApiClientError` -2. `error.code === 'CAPTCHA_REQUIRED'` -3. `error.details.captchaChallenge` - -冻结要求: - -1. 如果后端要继续触发验证码挑战,必须继续返回: - - `code: 'CAPTCHA_REQUIRED'` - - `details.captchaChallenge` -2. 不能只返回中文文案而不带结构化 `details`。 - -### 8.2 元信息透传 - -`ApiClientError` 当前会保留: - -1. `status` -2. `code` -3. `details` -4. `meta.apiVersion` -5. `meta.requestId` -6. `meta.routeVersion` -7. `meta.operation` -8. `meta.latencyMs` -9. `meta.timestamp` - -冻结要求: - -1. Axum 不能把这些字段全都删成单纯 `message` 字符串。 -2. 即使部分业务 UI 现在没显示这些字段,它们已经进入前端错误对象结构。 - -## 9. 当前消费者清单 - -以下文件已构成当前前端的直接依赖面: - -1. `src/services/apiClient.ts` -2. `src/services/authService.ts` -3. `src/services/aiService.ts` -4. `src/editor/shared/jsonClient.ts` -5. `packages/shared/src/http.ts` - -## 10. 本轮冻结后的硬约束 - -后续迁移中,不允许出现以下情况: - -1. 删除 `x-genarrative-response-envelope: v1` 的请求协商能力。 -2. 删除 `x-request-id`、`x-api-version`、`x-route-version` 这些当前前端直接依赖的响应头。 -3. 把成功 envelope 从 `{ ok, data, error, meta }` 改成其他字段名。 -4. 把错误 envelope 从 `{ ok: false, data: null, error, meta }` 改成只有 `message`。 -5. 删除 `CAPTCHA_REQUIRED + details.captchaChallenge` 这一结构化错误契约。 -6. 让前端默认请求 envelope,但后端返回裸数据且不再可被 `unwrapApiResponse(...)` 识别。 - -## 11. 本任务完成定义 - -当以下条件成立时,这条任务视为完成: - -1. 当前前端直接依赖的响应头、envelope、错误格式已有书面冻结清单。 -2. 已明确哪些是前端直接读取项,哪些是兼容保留项。 -3. 后续 Axum handler、错误中间件、response envelope 中间件可以直接按本文对齐,而不再靠人工试错。 diff --git a/backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md b/backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md deleted file mode 100644 index 1414aa66..00000000 --- a/backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md +++ /dev/null @@ -1,245 +0,0 @@ -# M0:`/generated-*` 静态资源前缀冻结基线 - -日期:`2026-04-20` - -依据来源: - -- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md) -- `server-node/src/modules/assets/characterAssetRoutes.ts` -- `server-node/src/modules/assets/qwenSpriteRoutes.ts` -- `server-node/src/services/sceneImageService.ts` -- `server-node/src/services/customWorldCoverAssetService.ts` -- `server-node/src/services/customWorldAgentAutoAssetService.ts` -- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) - -## 1. 文档目的 - -这份文档用于完成 `M0` 的第五条任务: - -- 整理当前所有 `/generated-*` 静态资源前缀 - -这里的“整理”不是只列出几个目录名,而是要求冻结以下信息: - -1. 当前哪些 `/generated-*` 前缀是正式业务前缀。 -2. 每个前缀由哪条后端链路产出。 -3. 每个前缀对应的当前路径模板是什么。 -4. 哪些前缀只是未来设计名或测试噪音,不能误当成当前正式兼容面。 - -## 2. 冻结结论 - -当前工程里,正式业务使用的 `/generated-*` 静态资源前缀固定为以下 `6` 个: - -| 前缀 | 当前状态 | 当前主要生产链路 | 当前典型路径模板 | 重写后兼容要求 | -| --- | --- | --- | --- | --- | -| `/generated-character-drafts/*` | 正式前缀 | 角色主形象草稿、动作草稿、导入参考素材 | `/generated-character-drafts/{characterId}/{kind}/{jobId}/{file}` | 必须保留 | -| `/generated-characters/*` | 正式前缀 | 角色主形象正式发布、Agent 自动角色图回填 | `/generated-characters/{characterId}/visual/{assetId}/{file}` | 必须保留 | -| `/generated-animations/*` | 正式前缀 | 角色基础动作正式发布 | `/generated-animations/{characterId}/{animationSetId}/{action}/{file}` | 必须保留 | -| `/generated-custom-world-scenes/*` | 正式前缀 | 世界场景图生成、Agent 自动场景图回填 | `/generated-custom-world-scenes/{world}/{landmarkOrAct}/{assetId}/{file}` | 必须保留 | -| `/generated-custom-world-covers/*` | 正式前缀 | 世界封面图生成、封面上传 | `/generated-custom-world-covers/{world}/{assetId}/{file}` | 必须保留 | -| `/generated-qwen-sprites/*` | 正式前缀 | Qwen 主图草稿、精灵表草稿、修帧草稿、最终保存 | `/generated-qwen-sprites/{assetKeyOrDraftScope}/{actionOrKind}/{assetId}/{file}` | 必须保留 | - -额外结论: - -1. 当前仓库里真实业务前缀是 `6` 个,不是 `4` 个也不是 `5` 个。 -2. 其中 `generated-animations` 与 `generated-custom-world-covers` 是当前代码已正式使用、但早期重写设计里容易漏掉的两个前缀。 -3. 当前 `public/` 目录下已存在: - - `generated-character-drafts` - - `generated-characters` - - `generated-qwen-sprites` -4. `generated-animations`、`generated-custom-world-scenes`、`generated-custom-world-covers` 当前按需惰性创建,不代表它们不是正式前缀。 - -## 3. 正式前缀清单 - -### 3.1 `/generated-character-drafts/*` - -当前用途: - -1. 角色主形象候选图草稿。 -2. 角色动作草稿帧、草稿视频、导入参考素材。 -3. 角色资产工作流缓存与任务记录。 - -当前主要生产链路: - -1. `POST /api/assets/character-visual/generate` -2. `POST /api/assets/character-animation/generate` -3. `POST /api/assets/character-animation/import-video` -4. `POST /api/assets/character-workflow-cache` - -当前典型路径模板: - -1. `/generated-character-drafts/{characterId}/visual/{jobId}/candidate-01.png` -2. `/generated-character-drafts/{characterId}/animation/{action}/{jobId}/preview.mp4` -3. `/generated-character-drafts/{characterId}/animation/{action}/{jobId}/frame-01.png` -4. `/generated-character-drafts/{characterId}-{cacheKey}/workflow-cache.json` - -冻结要求: - -1. 它是“草稿态真相路径”,不是正式发布路径。 -2. 重写为 OSS 后仍要保留这一层 HTTP 兼容前缀,哪怕底层已不是本地磁盘。 - -### 3.2 `/generated-characters/*` - -当前用途: - -1. 角色主形象正式发布路径。 -2. Custom World Agent 自动回填的角色主图。 - -当前主要生产链路: - -1. `POST /api/assets/character-visual/publish` -2. `customWorldAgentAutoAssetService` - -当前典型路径模板: - -1. `/generated-characters/{characterId}/visual/{assetId}/master.png` -2. `/generated-characters/{characterId}/visual/{assetId}/preview-01.png` - -冻结要求: - -1. 它是角色正式视觉资产前缀,不允许与草稿前缀混用。 -2. 后续即使内部改成 OSS,也必须继续对前端暴露这一前缀。 - -### 3.3 `/generated-animations/*` - -当前用途: - -1. 角色基础动作正式发布路径。 -2. `CharacterAnimator` 侧消费的核心动画资源前缀。 - -当前主要生产链路: - -1. `POST /api/assets/character-animation/publish` - -当前典型路径模板: - -1. `/generated-animations/{characterId}/{animationSetId}/{action}/frame-01.png` -2. `/generated-animations/{characterId}/{animationSetId}/{action}/preview.mp4` -3. `/generated-animations/{characterId}/{animationSetId}/{action}/manifest.json` - -冻结要求: - -1. 当前正式动画并不挂在 `/generated-characters/.../animation/...` 下,而是独立前缀 `/generated-animations/*`。 -2. 后续重写若想合并对象键,也必须先做兼容别名,不能直接改掉公开路径。 - -### 3.4 `/generated-custom-world-scenes/*` - -当前用途: - -1. 自定义世界场景图生成结果。 -2. Agent 自动生成的场景图回填结果。 - -当前主要生产链路: - -1. `POST /api/custom-world/scene-image` -2. `customWorldAgentAutoAssetService` - -当前典型路径模板: - -1. `/generated-custom-world-scenes/{worldSegment}/{landmarkSegment}/{assetId}/scene.png` -2. `/generated-custom-world-scenes/{sceneSegment}/{actSegment}/{assetId}/scene.png` - -冻结要求: - -1. 前缀里当前显式带 `custom-world-scenes`,不是通用 `generated-scenes`。 -2. 后续世界资料库、世界编辑器和 Agent 卡片仍会依赖这一命名习惯。 - -### 3.5 `/generated-custom-world-covers/*` - -当前用途: - -1. 自定义世界封面图生成结果。 -2. 自定义世界封面图上传后规范化结果。 - -当前主要生产链路: - -1. `POST /api/custom-world/cover-image` -2. `POST /api/custom-world/cover-upload` - -当前典型路径模板: - -1. `/generated-custom-world-covers/{worldSegment}/{assetId}/cover.png` -2. `/generated-custom-world-covers/{worldSegment}/{assetId}/manifest.json` - -冻结要求: - -1. 当前正式前缀是 `/generated-custom-world-covers/*`,不是 `/generated-cover-images/*`。 -2. 后续重写设计和 OSS key 规划必须以这个现状兼容面为准。 - -### 3.6 `/generated-qwen-sprites/*` - -当前用途: - -1. Qwen 精灵主图草稿。 -2. Qwen 精灵表草稿。 -3. Qwen 修帧草稿。 -4. Qwen 精灵表最终保存结果。 - -当前主要生产链路: - -1. `POST /api/assets/qwen-sprite/master` -2. `POST /api/assets/qwen-sprite/sheet` -3. `POST /api/assets/qwen-sprite/frame-repair` -4. `POST /api/assets/qwen-sprite/save` - -当前典型路径模板: - -1. `/generated-qwen-sprites/_drafts/master/{draftId}/candidate-01.png` -2. `/generated-qwen-sprites/_drafts/sheet/{draftId}/candidate-01.png` -3. `/generated-qwen-sprites/_drafts/repair/{draftId}/candidate-01.png` -4. `/generated-qwen-sprites/{assetKey}/{actionKey}/{assetId}/sheet.png` - -冻结要求: - -1. 这个前缀当前既承载草稿,也承载正式保存结果。 -2. 如果未来要把草稿与正式对象拆桶,也必须先保留同一公开前缀兼容。 - -## 4. 非正式前缀与噪音项 - -以下命名当前不能当成“正式兼容前缀”: - -| 名称 | 当前来源 | 处理结论 | -| --- | --- | --- | -| `/generated-cover-images/*` | 仅出现在重写设计文档旧草案里 | 这是未来提案名,不是当前工程真实前缀。 | -| `/generated-role*` | 测试数据或示例 ID | 不是正式静态资源前缀。 | -| `/generated-world*` | 测试数据或对象 ID | 不是正式静态资源前缀。 | -| `/generated-ruins*` | 测试数据或对象 ID | 不是正式静态资源前缀。 | - -结论: - -1. 后续做 Axum 静态资源兼容层时,只能以第 2 节和第 3 节里的 `6` 个前缀为正式基线。 -2. 不能把测试里的字符串误判成真实资源入口。 - -## 5. 与重写设计的对齐要求 - -为避免后续按错路径重写,当前冻结以下对齐规则: - -1. Axum 第一阶段必须兼容这 `6` 个公开资源前缀。 -2. OSS 内部对象键可以调整,但对前端暴露的 URL 前缀不能少于这 `6` 个。 -3. 设计文档里的对象键规划如果与这份基线冲突,以这份“当前工程冻结基线”为准,然后再在设计文档中补兼容说明。 - -## 6. 本轮冻结后的硬约束 - -后续迁移中,不允许出现以下情况: - -1. 把 `/generated-animations/*` 直接并入 `/generated-characters/*` 却不保留兼容别名。 -2. 把 `/generated-custom-world-covers/*` 擅自改成 `/generated-cover-images/*`。 -3. 在未做兼容层前,删除 `/generated-character-drafts/*` 或 `/generated-qwen-sprites/*` 的草稿访问习惯。 -4. 仅因为某个目录在当前仓库里尚未生成,就把它从正式前缀清单里删掉。 - -## 7. 本任务完成定义 - -当以下条件成立时,这条任务视为完成: - -1. 当前正式 `/generated-*` 前缀已经有书面冻结清单。 -2. 每个前缀都已明确: - - 当前用途 - - 当前生产链路 - - 当前路径模板 - - 后续兼容要求 -3. 已明确哪些“generated-*”字符串只是噪音,不属于正式前缀。 - -## 8. 后续直接依赖这份基线的任务 - -1. 设计 Axum 静态资源兼容层 -2. 设计 OSS 对象键与 CDN 别名 -3. 做 assets / custom world / editor 的路径回归测试 diff --git a/backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md b/backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md deleted file mode 100644 index 7af9e7f9..00000000 --- a/backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md +++ /dev/null @@ -1,291 +0,0 @@ -# M0:内部模块迁移归属基线 - -日期:`2026-04-20` - -依据来源: - -- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md) -- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json) -- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) - -## 1. 文档目的 - -这份文档用于完成 `M0` 的第三条任务: - -- 整理当前 `12` 个内部模块并锁定迁移归属 - -这里的“迁移归属”不是简单把旧目录名照搬到 Rust,而是要求后续重写必须先明确: - -1. 每个旧模块在新架构中的主归属 crate。 -2. 每个旧模块是否需要拆成“SpacetimeDB 状态层 + Axum/application 编排层”。 -3. 每个旧模块的状态真相应该进入 `SpacetimeDB`、`OSS` 还是开发态本地文件适配。 -4. 每个旧模块优先落在哪个迁移阶段,避免后续任务拆分时反复改口径。 - -命名补充说明: - -1. 本文中仍出现的 `application::...`、`auth-service`、`oss-service`、`llm-service` 等名称,统一表示逻辑职责,不再要求它们必须继续作为顶层独立 crate 存在。 -2. 在新的多 crate 结构下,这些逻辑职责默认落到对应 `crates/module-*` 的内部子层次,或落到 `crates/platform-*`、`crates/shared-*` 等共享 crate 中。 - -补充边界: - -1. 本文只覆盖当前 `server-node/src/modules/*` 下的 `12` 个内部模块。 -2. `auth`、`health` 虽然属于后端能力面,但不在这 `12` 个内部模块目录里,因此不在本文表内。 - -## 2. 冻结结论 - -当前 Node 后端的正式内部模块固定为以下 `12` 个: - -补充口径: - -1. 上表 `12` 个模块属于历史基线总量。 -2. 自 `2026-04-21` 起,本轮 Rust 后端重写的 active rewrite modules 固定为 `11` 个。 -3. `editor` 作为遗留无用模块,仅保留历史事实对照,不再进入 `server-rs` 主线迁移。 - -| 模块 ID | 中文名称 | 当前目录 | 关联路由数 | 当前对外暴露面 | 重写后主归属 | 重写后次归属 | 目标迁移阶段 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `ai` | AI 编排模块 | `server-node/src/modules/ai` | `23` | `runtime-main` | `application + llm-service` | `contracts + api-server SSE facade` | `M4`、`M5`、`M6` | -| `assets` | 资产工具模块 | `server-node/src/modules/assets` | `18` | `assets` | `application::assets + oss-service` | `spacetime-module::asset_metadata` | `M6` | -| `combat` | 战斗结算模块 | `server-node/src/modules/combat` | `1` | `runtime-story-action` | `spacetime-module::gameplay::combat` | `domain::combat` | `M4` | -| `custom-world` | 自定义世界运行时模块 | `server-node/src/modules/custom-world` | `26` | `runtime-main` | `spacetime-module::custom_world + application::custom_world` | `llm-service + oss-service` | `M5` | -| `editor` | 编辑器资源模块 | `server-node/src/modules/editor` | `3` | `editor` | `不迁移(遗留模块)` | 保留 `server-node/` 历史链路对照 | `不纳入本轮` | -| `inventory` | 背包与物品变更模块 | `server-node/src/modules/inventory` | `1` | `runtime-story-action` | `spacetime-module::gameplay::inventory` | `domain::inventory` | `M4` | -| `npc` | NPC 交互模块 | `server-node/src/modules/npc` | `6` | `runtime-story-action`、`runtime-main` | `spacetime-module::gameplay::npc` | `application::npc_dialogue + llm-service` | `M4`、`M5` | -| `progression` | 成长与关卡进程模块 | `server-node/src/modules/progression` | `3` | `runtime-story-action`、`runtime-main` | `spacetime-module::gameplay::progression` | `domain::progression` | `M3`、`M4` | -| `quest` | 任务运行时模块 | `server-node/src/modules/quest` | `4` | `runtime-main`、`runtime-story-action` | `spacetime-module::gameplay::quest` | `application::quest_drafting + llm-service` | `M3`、`M4` | -| `runtime` | 运行时状态基座模块 | `server-node/src/modules/runtime` | `32` | `runtime-main`、`runtime-story-action` | `spacetime-module::runtime` | `application::runtime_facade` | `M3` | -| `runtime-item` | 运行时物品模块 | `server-node/src/modules/runtime-item` | `2` | `runtime-main`、`runtime-story-action` | `spacetime-module::gameplay::runtime_item` | `application::item_intent + llm-service` | `M4` | -| `story` | 故事会话模块 | `server-node/src/modules/story` | `10` | `runtime-main`、`runtime-story-action` | `spacetime-module::gameplay::story` | `application::story_facade + api-server SSE facade` | `M4` | - -冻结总数: - -1. 历史内部模块目录:`12` -2. 本轮 active rewrite modules:`11` -3. 关联路由数最多的模块:`runtime`,共 `32` 条 -4. 本轮纯外部副作用导向模块:`ai`、`assets` -5. 已退出本轮重写范围的遗留模块:`editor` -6. 纯状态规则导向模块:`combat`、`inventory` -7. 需要“状态层 + 编排层”双落位的混合模块:`custom-world`、`npc`、`quest`、`runtime-item`、`story` - -## 3. 锁定迁移归属规则 - -后续所有重写实现,必须先遵守以下归属规则: - -1. 纯运行时状态、纯规则计算、纯领域变更,优先进入 `spacetime-module/` 与 `domain/`,不能继续把真相留在 Axum 内存或 Node 风格 service。 -2. 外部模型调用、OSS 上传、短信、微信、本地文件读写,统一放在 `application/ + api-server/ + infra service`,不能塞进 SpacetimeDB reducer。 -3. 任何当前“一个模块同时做状态和副作用”的能力,在新架构里都必须拆成: - - `SpacetimeDB`:状态真相与读模型 - - `Axum/application`:外部编排、SSE、对象上传、三方调用 -4. `public/generated-*` 不再是任何模块的真相源;未来只能作为兼容访问前缀或 CDN 映射。 -5. 不允许把旧模块简单合并成一个“大 runtime service”;必须保留可对照的领域边界。 - -## 4. 模块迁移矩阵 - -| 当前模块 | 当前职责摘要 | 新状态真相源 | 新外部副作用归属 | 迁移后必须落位 | -| --- | --- | --- | --- | --- | -| `ai` | prompt 编排、聊天/剧情/世界生成组织 | `SpacetimeDB` 只存任务和结果引用,不存编排过程真相 | `llm-service` | 只能留在 Axum/application 侧,禁止直接进 reducer。 | -| `assets` | 生成、发布、缓存、Qwen 精灵表 | `asset_job`、`asset_object`、`asset_manifest` 等表 | `oss-service` + 外部媒体模型 | 二进制进 OSS,任务与引用进 SpacetimeDB。 | -| `combat` | 战斗结算、数值变化 | `battle_state`、`story_event` | 无 | 作为纯 reducer 规则模块落到 gameplay。 | -| `custom-world` | 世界资料、问答流、Agent 草稿与编译 | `custom_world_*` 系列表 | `llm-service`、`oss-service` | 世界状态在 SpacetimeDB,编译/生成在 Axum。 | -| `editor` | 编辑器 JSON 读写、图标枚举 | 仍以遗留 Node 链路与开发态本地文件为历史对照 | 不迁移到 `server-rs` | 仅保留历史基线,不纳入本轮 Rust 重写。 | -| `inventory` | 背包变更、物品副作用、NPC 背包交互 | `inventory_slot`、`story_event` | 无 | 归入 story action 对应 reducer。 | -| `npc` | 互动规则、关系变化、招募/对话语义 | `npc_state`、`story_event` | `application::npc_dialogue + llm-service` | 状态归 SpacetimeDB,台词生成归 Axum。 | -| `progression` | 等级、章节、敌对 scaling、benchmark | `player_progression`、`chapter_progression` | 无 | 作为 runtime / story 公共领域模块进入 SpacetimeDB。 | -| `quest` | 任务意图、日志、进度变化 | `quest_record`、`story_event` | `application::quest_drafting + llm-service` | 任务状态归 SpacetimeDB,生成型任务草案归 Axum。 | -| `runtime` | 快照、设置、资料页、状态归一化 | `runtime_snapshot`、`runtime_setting`、`profile_*` | 无 | 作为新后端最先迁移的状态基座模块。 | -| `runtime-item` | 物品意图、奖励解析、宝藏逻辑 | `treasure_record`、`inventory_slot`、`story_event` | `application::item_intent + llm-service` | 奖励结算归 reducer,意图生成归 Axum。 | -| `story` | 会话状态、动作分发、主循环 | `story_session`、`story_event` | `application::story_facade + SSE` | 主循环状态归 SpacetimeDB,流式输出由 Axum 兼容。 | - -## 5. 各模块冻结要求 - -### 5.1 `ai` - -当前定位: - -1. 负责剧情、多轮聊天、自定义世界等 prompt 编排。 -2. 自身不负责持久化,但会被多条 runtime 路由反复调用。 - -重写后的冻结要求: - -1. 主归属固定为 `application + llm-service`,不是 `spacetime-module`。 -2. 后续如果需要记录 AI 阶段状态,只能把任务状态或结果引用写入 SpacetimeDB,不把供应商 SDK 与 prompt 执行放进 reducer。 -3. 与 `story`、`custom-world`、`runtime-item`、`quest` 的关系固定为“它们产生命令,`ai` 负责外部生成”。 - -### 5.2 `assets` - -当前定位: - -1. 负责角色形象、动作、Qwen 精灵表生成。 -2. 负责发布到 `public/generated-*` 与局部 manifest。 - -重写后的冻结要求: - -1. 二进制对象一律进入 `OSS`。 -2. 主归属固定为 `application::assets + oss-service`。 -3. 资产任务状态、对象引用关系、发布绑定关系必须进入 `spacetime-module::asset_metadata`。 -4. 后续不允许继续以本地 `public/generated-*` 是否存在文件作为业务真相。 - -### 5.3 `combat` - -当前定位: - -1. 提供 story action 里的战斗型结算。 -2. 本质是纯规则计算。 - -重写后的冻结要求: - -1. 主归属固定为 `spacetime-module::gameplay::combat`。 -2. 不单独拥有 HTTP 路由,也不直接依赖外部 IO。 -3. 后续实现必须保持纯规则、可测试、可被 `resolve_story_action` reducer 复用。 - -### 5.4 `custom-world` - -当前定位: - -1. 负责 creator intent、world profile、传统问答流、Agent 运行时类型。 -2. 同时牵涉世界编译、资产生成和公开画廊。 - -重写后的冻结要求: - -1. 这是标准的双落位模块: - - `SpacetimeDB` 保存会话、草稿、作品、画廊、Agent 状态。 - - `Axum/application` 负责编译、SSE、外部 LLM 与资产生成编排。 -2. 传统问答流和 Agent 流必须拆表,不能继续长期混成一个大 JSON 会话体。 -3. 对外仍然要兼容当前 `/api/custom-world/*` 与 `/api/runtime/custom-world/*` 访问习惯。 - -### 5.5 `editor` - -当前定位: - -1. 读写编辑器资源 JSON。 -2. 枚举工作区 `public/Icons` 图标资源。 - -重写后的冻结要求: - -1. 该模块在 `server-node/` 中的存在事实继续保留,用于历史基线与后续清理对照。 -2. 自 `2026-04-21` 起,不再为 `server-rs/` 创建 `module-editor` crate,也不再把它纳入 `M1 ~ M6` 主线迁移。 -3. 若未来仍需清理或替代 editor,必须在遗留链路依赖确认后单独立项,不能夹带进当前 Rust 重写主链。 -4. 不允许为了简化本轮任务而篡改其历史存在事实。 - -### 5.6 `inventory` - -当前定位: - -1. 负责背包变更、赠礼、NPC 背包交互等副作用。 -2. 当前主要被 story action 调用。 - -重写后的冻结要求: - -1. 主归属固定为 `spacetime-module::gameplay::inventory`。 -2. 与 `story`、`runtime-item` 的交互必须通过 reducer 协调,不能回到“多个 service 各自改 JSON”。 -3. 后续如需对外展示背包读模型,优先通过 view 暴露,不新增独立真相副本。 - -### 5.7 `npc` - -当前定位: - -1. 负责 NPC 关系、招募、交互规则与场景 NPC 语义。 -2. 同时参与 runtime 聊天流和 story action 结算。 - -重写后的冻结要求: - -1. 状态真相固定在 `spacetime-module::gameplay::npc`。 -2. LLM 对话、招募话术、流式文本输出固定由 `application::npc_dialogue + llm-service` 处理。 -3. 不允许把 NPC 状态又分散回聊天 session store、本地缓存或前端临时状态。 - -### 5.8 `progression` - -当前定位: - -1. 负责角色成长、章节推进、敌对强度等规则。 -2. 同时影响 snapshot hydrate 与 story action 结算。 - -重写后的冻结要求: - -1. 主归属固定为 `spacetime-module::gameplay::progression`。 -2. 仍保持纯规则、纯领域建模,不承接外部 IO。 -3. 作为 `runtime` 与 `story` 的公共领域组件,不能被重新塞回单一路由 handler。 - -### 5.9 `quest` - -当前定位: - -1. 负责任务语义、任务日志、任务进度信号。 -2. 既参与 AI 草案生成,也参与 story action 结算。 - -重写后的冻结要求: - -1. 任务主状态固定进入 `spacetime-module::gameplay::quest`。 -2. AI 生成的任务候选与草案编排固定由 `application::quest_drafting + llm-service` 承担。 -3. 前端兼容接口仍走 `/api/runtime/quests/*` 或 story action 聚合,不新增前端直连任务状态写入口。 - -### 5.10 `runtime` - -当前定位: - -1. 是当前运行时快照、设置、资料页、状态归一化的基座模块。 -2. 路由覆盖最广,是 Node 版后端迁移的第一主战场。 - -重写后的冻结要求: - -1. 主归属固定为 `spacetime-module::runtime`。 -2. `runtime_snapshot`、`runtime_setting`、`profile_*` 等读写模型优先在 `M3` 完成迁移。 -3. Axum 只保留兼容 facade,不再继续让快照真相停留在 PostgreSQL 风格 repository。 - -### 5.11 `runtime-item` - -当前定位: - -1. 负责运行时物品意图、奖励、宝藏解析与剧情指纹。 -2. 同时受到 AI 生成与 story action 结算驱动。 - -重写后的冻结要求: - -1. 奖励、掉落、宝藏等状态变化固定进入 `spacetime-module::gameplay::runtime_item`。 -2. 物品意图生成固定由 `application::item_intent + llm-service` 承接。 -3. 物品领域不能再拆成“部分在 story、部分在 route、部分在前端”的临时实现。 - -### 5.12 `story` - -当前定位: - -1. 负责运行时故事会话、动作分发与 state 恢复。 -2. 当前既暴露 REST,也暴露与聊天/继续剧情相关的流式体验。 - -重写后的冻结要求: - -1. 主归属固定为 `spacetime-module::gameplay::story`。 -2. SSE 输出与兼容 DTO 拼装固定由 `application::story_facade + api-server SSE facade` 负责。 -3. `storyAction.resolve` 的跨模块联动必须以 `story` 为编排入口,但不再由单个 Node service 直接改整包 JSON。 - -## 6. 本轮冻结后的硬约束 - -后续迁移中,不允许出现以下情况: - -1. 把 `ai`、`assets` 直接放进 SpacetimeDB reducer 执行三方网络或文件系统 IO。 -2. 在未单独立项前,把已退出本轮范围的 `editor` 重新并回 `server-rs` 主链。 -3. 把 `combat`、`inventory`、`progression` 重新做成只存在于 Axum handler 内部的计算 helper。 -4. 把 `custom-world`、`story`、`npc` 这类混合模块继续保留为“单大对象 JSON + 单大 service 写回”模式。 -5. 把 `runtime` 当成一个兜底垃圾桶,把其他领域模块重新并回去。 -6. 在没有对应 Axum facade 的前提下,让前端第一阶段直接依赖 SpacetimeDB 原生写接口。 - -## 7. 本任务完成定义 - -当以下条件成立时,这条任务视为完成: - -1. 当前历史 `12` 个内部模块已经有正式书面冻结清单。 -2. 每个模块都已明确: - - 当前目录 - - 关联路由数 - - 对外暴露面 - - 重写后主归属 - - 重写后次归属 - - 目标迁移阶段 -3. 本轮 active rewrite modules 为 `11` 个,且 `editor` 的遗留/不迁移口径已经冻结。 -4. 后续拆 `server-rs/` 多 crate、建 SpacetimeDB bounded context、排 M3~M6 任务时,可以直接引用本文,不再靠口头记忆。 - -## 8. 后续直接依赖这份基线的任务 - -1. 设计 `server-rs/` workspace 与 crate 边界 -2. 设计 SpacetimeDB `runtime / gameplay / custom_world / asset_metadata` 表分层 -3. 设计 story action reducer 的跨模块协作边界 -4. 设计 custom world / assets 的 Axum facade diff --git a/backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md b/backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md deleted file mode 100644 index 58fb3605..00000000 --- a/backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md +++ /dev/null @@ -1,106 +0,0 @@ -# M0:阶段验收矩阵 - -日期:`2026-04-20` - -依据来源: - -- [00_MASTER_TASKLIST.md](./00_MASTER_TASKLIST.md) -- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md) -- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md) -- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md) -- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md) -- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md) -- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md) -- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md) -- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) - -## 1. 文档目的 - -这份文档用于把 `M0 ~ M7` 各阶段的入口条件、核心交付、退出条件与回归焦点固定下来,避免后续出现“任务勾完了,但阶段是否真的可进入下一步没有统一标准”的问题。 - -从本文件开始,后续每一阶段都需要按“入口满足 -> 交付完成 -> 验收通过 -> 留存证据”的顺序推进。 - -## 2. 阶段推进总规则 - -1. 未满足上一阶段退出条件前,不进入下一阶段主线编码。 -2. 每一阶段至少保留一份可复查的证据,证据可以是文档、脚本、测试结果或回归记录。 -3. 所有阶段都必须持续对齐当前冻结基线: - - 历史基线 `6` 个挂载面 - - 本轮 active rewrite target `5` 个挂载面 - - `96` 条路由 - - `12` 个模块 - - `6` 条 SSE 接口 - - `6` 个 `/generated-*` 静态资源前缀 - - 前端直接依赖的响应头、envelope 与鉴权错误格式 -4. 任一阶段若引入新的协议差异,必须先补 contract 文档或迁移说明,再允许继续编码。 - -## 3. 分阶段验收矩阵 - -| 阶段 | 入口条件 | 核心交付 | 退出条件 | 回归焦点 | -| --- | --- | --- | --- | --- | -| `M0` 冻结能力与边界 | 已完成当前 Node 后端摸底;已明确目标架构为 `SpacetimeDB + Axum + 阿里云 OSS` | 冻结能力基线、路由矩阵、模块归属、SSE、静态资源前缀、前端响应契约、仓库边界决议、阶段验收矩阵 | `6` 个挂载面、`96` 条路由、`12` 个模块、`6` 条 SSE、`6` 个静态资源前缀全部形成书面基线;`server-rs/`、`server-node/`、Axum 边界、副作用收口原则全部冻结 | 文档口径一致性;前端 contract 依赖项是否被遗漏;迁移阶段是否还存在多套边界说法 | -| `M1` Rust 工作区与 Axum 基础设施 | `M0` 全部退出条件满足 | `server-rs/` workspace、`crates/api-server`、`crates/spacetime-module`、独立模块 crates、统一配置、日志、request id、中间件、response envelope、`/healthz`、开发脚本 | Axum 可独立启动;`/healthz` 与当前工程兼容;`x-request-id`、`x-api-version`、`x-route-version`、`x-response-time-ms` 行为稳定;workspace 完整编译通过;主工程与模块 crate 引用边界稳定 | 基础头部兼容;健康检查兼容;目录结构与 crate 归属是否偏离 `M0` 决议 | -| `M2` 鉴权、会话、JWT 与 refresh cookie | `M1` 已可稳定启动;Axum 中间件与配置链可用 | 身份表、会话表、JWT claims、refresh cookie、密码登录、手机验证码登录、微信登录、OIDC 透传、旧鉴权接口兼容 | 密码登录、refresh cookie、手机验证码、微信登录主链可用;旧鉴权接口 contract 回归通过;SpacetimeDB 可识别 Axum 签发身份 | Cookie 与 JWT 兼容;`CAPTCHA_REQUIRED` 与 `details.captchaChallenge` 是否保持;登录态吊销与刷新是否稳定 | -| `M3` runtime snapshot / settings / profile | `M2` 鉴权稳定;用户身份可透传到 SpacetimeDB | `runtime_snapshot`、`runtime_setting`、profile 相关主表与 facade;存档、设置、浏览历史、save archive 兼容接口 | 登录用户可正常保存、读取、删除存档;profile dashboard / browse history / save archive 行为一致;前端恢复流程可直接跑通 | 快照恢复准确性;兼容路径与主路径是否返回一致;历史记录排序与去重逻辑 | -| `M4` story action 与 gameplay reducer | `M3` 快照与用户状态主链稳定 | story / combat / inventory / npc / quest / progression / runtime-item 表与 reducer;story 兼容接口与 view model | 前端 story 主循环可用;`story state` 恢复链可用;NPC / quest / treasure / combat 主循环行为不回退;旧 Node story route 回归平移完成 | `RuntimeStoryActionResponse` 结构;战斗与奖励联动;状态投影是否与旧前端恢复逻辑一致 | -| `M5` custom world / gallery / agent | `M4` story 与 runtime 真相源已稳定;SSE facade 可持续输出 | custom world 主表、agent 会话拆表、传统问答流、library / gallery、agent 消息与操作、LLM/图片生成编排 | 传统 custom world 主链可用;library / gallery 主链可用;agent 主链可用;会话不再依赖单大 JSON 体 | SSE 事件格式;卡片、消息、操作状态一致性;世界草稿编译与发布链是否可回放 | -| `M6` assets / OSS | `M5` 世界与角色主链稳定;Axum 应用层可承接外部副作用 | OSS 对象键规范、上传签名、对象确认、资产任务表、角色/场景/Qwen 资产迁移、旧静态路径兼容 | 所有新生成资产写入 OSS;前端仍能通过旧路径习惯访问资源;资产任务状态可查询 | `/generated-*` 路径兼容;OSS 元数据与对象绑定关系;资产任务链状态一致性 | -| `M7` 联调、回归、部署与切流 | `M6` 已具备主链闭环;双栈对照条件具备 | 测试体系、部署方案、观测能力、灰度切流方案、回退方案、对比脚本与 smoke 清单 | 全链路 smoke 通过;主流程回归通过;关键 SSE 联调通过;可在灰度环境切流并可回退 | 双跑窗口稳定性;API 对比结果;切流开关、回退开关、观测告警是否齐备 | - -## 4. M0 冻结项专用验收清单 - -只有以下项目全部满足,`M0` 才算真正完成: - -1. 已产出以下冻结文档: - - [M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md) - - [M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md) - - [M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md) - - [M0_SSE_INTERFACE_BASELINE_2026-04-20.md](./M0_SSE_INTERFACE_BASELINE_2026-04-20.md) - - [M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md](./M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md) - - [M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md](./M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md) - - [M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md) - - [M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md) -2. 已书面冻结以下核心数字: - - 挂载面:`6` - - 路由:`96` - - 模块:`12` - - SSE:`6` - - 静态资源前缀:`6` -3. 已书面冻结以下边界决议: - - 新 Rust 后端固定为仓库根目录 `server-rs/` - - 迁移期保留 `server-node/` - - 前端 `M0 ~ M6` 期间只访问 Axum - - 外部副作用统一收口在 Axum - - `server-rs/` 内部采用 `crates/*` 多 crate 组织 - - `editor` 已于 `2026-04-21` 退出本轮 Rust 重写范围 -4. `M1` 以后任何任务引用路由、模块、SSE、静态资源与响应契约时,都必须能追溯到本阶段产出的冻结文档。 - -## 5. 跨阶段回归维度 - -无论执行到哪个阶段,都要持续检查以下维度: - -| 维度 | 必查内容 | 最晚必须固化的证据 | -| --- | --- | --- | -| 路由兼容 | 旧路由是否已有新实现或明确替代路径 | 路由迁移矩阵、API 对比脚本、contract 回归记录 | -| SSE 兼容 | 事件名、事件顺序、结束事件、错误事件是否保持兼容 | SSE 基线文档、联调记录、smoke 结果 | -| 静态资源兼容 | `/generated-*` 是否可继续访问,是否正确指向 OSS/CDN | 静态资源前缀基线、路径兼容测试记录 | -| 鉴权兼容 | JWT、refresh cookie、验证码、微信登录、风控错误是否保持兼容 | 鉴权接口回归记录、claims 设计文档、集成测试 | -| 前端 contract | 响应头、envelope、错误结构是否稳定 | response contract 基线、接口测试、前端联调记录 | -| 切流回退 | 双栈是否可对照,是否具备回退能力 | `M7` 对比脚本、灰度清单、回退方案 | - -## 6. 阶段证据留存要求 - -每个阶段完成时,至少要补齐以下其中两类证据: - -1. 文档: - - 更新任务清单勾选状态 - - 更新设计文档或阶段落地记录 -2. 测试或脚本: - - 新增或更新 smoke / contract / integration 测试 - - 新增对比脚本、发布脚本或回归脚本 -3. 结果记录: - - 编码检查结果 - - 关键命令执行结果 - - 联调、回归、灰度演练结果 - -如果阶段只完成了编码、但没有文档和证据留存,则该阶段不能视为完成。 diff --git a/backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md b/backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md deleted file mode 100644 index 0cdd553d..00000000 --- a/backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md +++ /dev/null @@ -1,281 +0,0 @@ -# M0:仓库边界决议 - -日期:`2026-04-20` - -依据来源: - -- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) -- [00_MASTER_TASKLIST.md](./00_MASTER_TASKLIST.md) -- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md) - -## 1. 文档目的 - -这份文档用于持续冻结 `M0` 中与仓库边界直接相关的决策,避免进入 `M1` 后再反复改目录、改职责口径。 - -当前已确认的事项会持续在这份文档上追加维护,后续若再有新的边界冻结结论,也统一收口到这里。 - -## 2. 边界决议状态 - -| 事项 | 当前状态 | 当前结论 | -| --- | --- | --- | -| Rust 后端新目录名与根目录落位方案 | 已确认 | 新 Rust 后端固定为仓库根目录下的 `server-rs/`,与 `server-node/` 同级。 | -| 旧 `server-node/` 在迁移期继续保留,不提前删除 | 已确认 | `server-node/` 在 `M0 ~ M6` 期间持续保留,直到 `M7` 切流与回退验证完成后再评估清理。 | -| 前端第一阶段仍然只访问 Axum,不直连 SpacetimeDB | 已确认 | `M0 ~ M6` 前端统一只访问 Axum 暴露的 `/api/*`、`/healthz`、SSE 与静态资源兼容层,不新增直连 SpacetimeDB 原生协议路径。 | -| 外部副作用统一收口在 Axum,不放进 SpacetimeDB 模块 | 已确认 | OSS、LLM、短信、微信 OAuth、本地文件系统等外部副作用统一落在 Axum/application/infra,不进入 SpacetimeDB reducer/module。 | -| `server-rs/` 内部采用多 crate 组织,由主工程 crate 统一引用模块 crate | 已确认 | `server-rs/` 采用 `crates/*` 工作区结构,`crates/api-server` 与 `crates/spacetime-module` 作为主工程 crate,独立模块以 `crates/module-*` 形式被主工程 crate 引用。 | -| `editor` 为遗留无用模块,不纳入 `server-rs` 本轮重写范围 | 已确认 | `server-node/src/modules/editor` 与 `/api/editor/*` 仅作为历史基线保留对照;自 `2026-04-21` 起退出本轮 Rust 后端重写范围。 | - -## 3. 已确认决议一:`server-rs/` 固定落在仓库根目录 - -### 3.1 决议内容 - -本次重写固定采用以下仓库落位: - -1. 新后端目录名固定为 `server-rs/` -2. 目录位置固定在仓库根目录 -3. 与以下目录保持同级: - - `server-node/` - - `src/` - - `docs/` - - `packages/` - -目标形态: - -```text -Genarrative/ -├─ server-node/ -├─ server-rs/ -├─ src/ -├─ packages/ -├─ docs/ -└─ backend-rewrite-tasklist/ -``` - -### 3.2 不采用的落位方案 - -以下方案当前明确不采用: - -1. 不放进 `server-node/` 子目录中做“Node + Rust 混编后端”。 -2. 不放进 `packages/`,避免被前端 package/workspace 语义误导。 -3. 不使用过于泛化的根目录名如 `server/`、`backend/`,避免和当前 `server-node/` 职责混淆。 - -### 3.3 这样落位的原因 - -1. 与当前重写设计文档、任务清单、后续 `M1` 多 crate 规划保持一致。 -2. 允许 `server-node/` 与 `server-rs/` 在迁移期并行存在,便于逐阶段切流。 -3. 让 Rust 工作区边界清晰,不污染现有前端 `src/`、`packages/`、Vite 工具链。 -4. 后续新增 `server-rs/scripts/*`、`Cargo.toml`、`crates/*` 时路径最直接,不需要额外中间层。 - -### 3.4 对后续任务的直接约束 - -从这一条决议开始,后续任务必须统一按以下路径落位: - -1. `M1` 的工作区初始化在 `server-rs/` -2. Axum 主工程 crate 在 `server-rs/crates/api-server` -3. SpacetimeDB 主工程 crate 在 `server-rs/crates/spacetime-module` -4. 独立模块 crate 在 `server-rs/crates/module-*` -5. 相关脚本在 `server-rs/scripts/` - -## 4. 本条任务完成定义 - -当以下条件成立时,这一条边界任务视为完成: - -1. 新 Rust 后端目录名已经书面固定为 `server-rs/` -2. 目录位置已经书面固定为仓库根目录 -3. 后续 `M1` 的工作区初始化不会再出现 `server-rs/`、`backend-rs/`、`server/` 等多套候选名并存 - -## 5. 已确认决议二:迁移期保留 `server-node/` - -### 5.1 决议内容 - -在本次重写迁移期内,旧 `server-node/` 固定继续保留,不提前删除、不整体挪位、不提前做破坏性收缩。 - -保留周期固定为: - -1. `M0` 到 `M6` 全阶段 -2. 至少持续到 `M7` 的以下条件全部满足之后,才允许评估清理: - - 新后端已切流 - - 旧接口 contract 回归通过 - - 关键主链 smoke 通过 - - 已具备明确回退方案 - -### 5.2 保留它的原因 - -1. 旧 `server-node/` 是当前 `96` 条路由、`6` 个挂载面、`12` 个模块的真实对照实现。 -2. 前面已经冻结的路由矩阵、模块迁移清单、SSE 协议、静态资源前缀,都需要它作为回归对照源。 -3. 如果在 `M1 ~ M6` 提前删除旧实现,就会失去最可靠的回退锚点与 diff 基准。 - -### 5.3 迁移期允许做什么 - -迁移期内允许: - -1. 在 `server-rs/` 中逐步补等价实现。 -2. 在文档和 manifest 中继续引用 `server-node/` 作为当前系统基线。 -3. 在必要时从 `server-node/` 补测试样例、补协议对照、补回归夹具。 - -迁移期内不允许: - -1. 提前整体删除 `server-node/` -2. 把 `server-node/` 改成只剩空壳目录 -3. 在还没切流前,把旧服务关键实现批量迁走导致无法对照 - -### 5.4 对后续任务的直接约束 - -从这一条决议开始,后续任务必须遵守: - -1. `M1` 搭建 `server-rs/` 时,不改动 `server-node/` 的存在性。 -2. `M2 ~ M6` 迁移功能时,旧 `server-node/` 继续作为验收基线与回退锚点。 -3. 真正评估清理旧 Node 后端的动作,只能放到 `M7` 切流完成之后。 - -## 6. 已确认决议三:前端第一阶段只访问 Axum - -### 6.1 决议内容 - -在 `M0 ~ M6` 迁移期内,前端访问新后端的唯一入口固定为 Axum。 - -第一阶段允许前端继续访问的面固定为: - -1. `/api/*` -2. `/healthz` -3. 当前已冻结的 SSE 路由 -4. 当前已冻结的 `/generated-*` 静态资源兼容前缀 - -第一阶段明确不做的事: - -1. 不让 Web 前端直接接 SpacetimeDB 原生 HTTP 接口。 -2. 不让 Web 前端直接接 SpacetimeDB 订阅协议。 -3. 不要求前端新增一套“Axum + SpacetimeDB 双后端并行直连”调用模式。 - -### 6.2 这样决议的原因 - -1. 当前前端已经直接依赖现有 `/api/*` 路由、response envelope、SSE、`/generated-*` 路径习惯。 -2. 如果在第一阶段就让前端同时认识 Axum 与 SpacetimeDB,会把迁移面从“后端平移”扩大成“前后端协议双重重写”。 -3. Axum 需要承担统一鉴权、cookie、JWT、OSS 签名、错误格式与 contract 兼容职责,这些都不应分散到前端直连多个后端协议。 - -### 6.3 对后续任务的直接约束 - -从这一条决议开始,后续任务必须遵守: - -1. `M1 ~ M2` 的 Axum 中间件与鉴权必须先跑通,再谈前端联调。 -2. `M3 ~ M6` 新增的 SpacetimeDB reducer/view 先通过 Axum facade 暴露,不直接要求前端改成原生 SpacetimeDB 客户端。 -3. 若后续要让前端直连 SpacetimeDB,只能作为第二阶段优化事项,不能混入当前重写主链。 - -## 7. 已确认决议四:外部副作用统一收口在 Axum - -### 7.1 决议内容 - -本次重写固定采用以下边界: - -1. `SpacetimeDB` 只负责状态、规则、reducer、view、订阅读模型。 -2. `Axum/application/infra` 统一负责所有外部副作用。 - -固定收口到 Axum 的外部副作用包括: - -1. 阿里云 OSS 上传、下载、签名、直传凭证 -2. DashScope / Ark / 其他 LLM 请求 -3. 微信 OAuth -4. 手机验证码短信发送与校验编排 -5. 本地文件系统读写 - -### 7.2 明确不允许放进 SpacetimeDB 的内容 - -以下能力当前明确禁止进入 `spacetime-module/`: - -1. 直接发 HTTP 请求给第三方供应商 -2. 直接访问 OSS SDK -3. 直接读写本地磁盘 -4. 直接处理 Cookie、回调跳转、multipart 上传 -5. 直接承担供应商重试、熔断、超时与日志策略 - -### 7.3 这样决议的原因 - -1. 这些能力都强依赖 HTTP 头、Cookie、SDK、签名、超时与日志,不适合绑进 SpacetimeDB 模块发布周期。 -2. 当前前端 contract、鉴权、SSE、静态资源兼容都要求一个稳定的 HTTP 边界层,Axum 更适合承担这个角色。 -3. 把副作用统一收口到 Axum,才能让 SpacetimeDB 保持“状态机真相源”的纯度。 - -### 7.4 对后续任务的直接约束 - -从这一条决议开始,后续任务必须遵守: - -1. `M1` crate 设计时,`platform-oss`、`platform-llm`、`platform-auth` 固定属于 Axum / 模块应用层一侧。 -2. `M2 ~ M6` 设计 reducer 时,只写状态变更,不直接发外部请求。 -3. 若确实需要异步副作用,也必须由 Axum worker 或应用层作业执行,再把结果回写 SpacetimeDB。 - -## 8. 已确认决议五:`server-rs/` 内部采用多 crate 组织 - -### 8.1 决议内容 - -从当前版本开始,`server-rs/` 内部结构固定采用: - -1. `crates/*`:统一收口主工程 crate、独立模块 crate 与共享 crate -2. `scripts/*`:开发、发布、回归脚本 - -主工程 crate 固定包含: - -1. `crates/api-server` -2. `crates/spacetime-module` - -独立模块 crate 固定按“每个独立模块一个 crate”推进,至少覆盖: - -1. `crates/module-auth` -2. `crates/module-runtime` -3. `crates/module-story` -4. `crates/module-combat` -5. `crates/module-inventory` -6. `crates/module-npc` -7. `crates/module-progression` -8. `crates/module-quest` -9. `crates/module-runtime-item` -10. `crates/module-custom-world` -11. `crates/module-assets` -12. `crates/module-ai` - -跨模块共享 crate 固定包含: - -1. `crates/shared-contracts` -2. `crates/shared-kernel` -3. `crates/platform-auth` -4. `crates/platform-oss` -5. `crates/platform-llm` -6. `crates/spacetime-client` -7. `crates/tests-support` - -### 8.2 这样决议的原因 - -1. 用户已经明确要求后端采用 Rust workspace 下的多 crate 模式,独立模块不能继续堆回单个技术层大包。 -2. 当前后端已有 `12` 个内部模块边界,多 crate 方案更容易保持一一映射与独立演进。 -3. `crates/api-server` 与 `crates/spacetime-module` 只做组合与发布,更符合“主工程 crate 引用模块 crate”的组织方式。 - -### 8.3 对后续任务的直接约束 - -从这一条决议开始,后续任务必须遵守: - -1. `M1` 及后续目录任务统一按 `crates/*` 执行,不再保留 `apps/*` 与 `packages/*` 并行规划。 -2. 每个业务模块默认先有自己的 workspace crate,再由主工程 crate 引用。 -3. 只有共享 contract、共享领域内核、平台适配、SpacetimeDB client 这类跨模块能力,才允许使用共享 crate,而不是业务模块混装。 - -## 9. 已确认决议六:`editor` 退出本轮 Rust 重写范围 - -### 9.1 决议内容 - -`editor` 在当前 Node 后端中确实存在真实模块与真实挂载面,但已于 `2026-04-21` 被确认为遗留无用模块,不再纳入本轮 `server-rs/` 重写主链。 - -当前固定口径为: - -1. 历史基线继续保留 `server-node/src/modules/editor` 与 `/api/editor/*` 的存在事实。 -2. `server-rs/` 不再保留 `crates/module-editor`。 -3. `M1 ~ M6` 的主线任务、阶段验收与 crate 规划,不再把 `editor` 计入 active rewrite scope。 - -### 9.2 这样决议的原因 - -1. 用户已明确确认 `editor` 为遗留无用模块,应从本轮重写目标中剔除。 -2. 保留历史事实有助于后续对照清理,不会把“旧系统曾存在该模块”的信息抹掉。 -3. 从当前阶段开始继续为 `editor` 预留 Rust crate,只会增加主线迁移噪音与工程负担。 - -### 9.3 对后续任务的直接约束 - -从这一条决议开始,后续任务必须遵守: - -1. 不再为 `editor` 创建或维护 `server-rs` 下的新 crate、Axum 路由树与迁移验收项。 -2. 所有涉及挂载面、模块、路由总量的文档,都要区分“历史基线”与“本轮 active rewrite target”。 -3. 若未来仍要清理 `editor`,应在 `server-node/` 遗留链路依赖核对完成后单独立项。 diff --git a/backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md b/backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md deleted file mode 100644 index a6db4a67..00000000 --- a/backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md +++ /dev/null @@ -1,249 +0,0 @@ -# M0:旧接口到新实现路由迁移矩阵 - -日期:`2026-04-20` - -依据来源: - -- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md) -- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json) -- [M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md) - -## 1. 文档目的 - -这份文档用于完成 `M0` 的第二条任务: - -- 整理当前后端 `96` 条路由并生成一份“旧接口 -> 新实现”映射表 - -这里的“新实现”不是指最终文件路径,而是指第一阶段重写时每条旧接口在新架构中的落点: - -1. 哪条 Axum 路由负责对外兼容 -2. 哪层 application service 负责编排 -3. 哪些状态进入 SpacetimeDB -4. 哪些二进制对象进入 OSS - -## 2. 映射代码说明 - -为避免 `96` 条路由的映射表过长,本表使用以下“新实现归属代码”: - -| 代码 | 新实现归属 | -| --- | --- | -| `A-HEALTH` | `Axum health route` | -| `A-AUTH` | `Axum auth routes + auth-service + SpacetimeDB auth tables` | -| `A-EDITOR` | `历史 Node editor 路由(遗留保留,不迁移到 server-rs)` | -| `A-OSS` | `Axum assets routes + application::assets + oss-service + SpacetimeDB asset metadata` | -| `A-LLM` | `Axum llm proxy/service` | -| `A-RUNTIME` | `Axum runtime facade + SpacetimeDB runtime reducers/views` | -| `A-STORY` | `Axum story facade + SpacetimeDB gameplay reducers/views` | -| `A-CHAT` | `Axum SSE facade + llm-service + SpacetimeDB story/npc state` | -| `A-CW` | `Axum custom-world facade + llm-service + SpacetimeDB custom_world reducers/views` | -| `A-AGENT` | `Axum custom-world-agent facade + llm-service + oss-service + SpacetimeDB agent tables` | - -补充说明: - -1. 第一阶段默认保留旧路径,不主动改前端请求地址。 -2. 兼容路径与主路径在新后端中应尽量共用同一 handler。 -3. 所有 `stream` 接口第一阶段继续用 Axum SSE,不强推改成 WebSocket。 -4. 自 `2026-04-21` 起,`editor` 路由仅保留历史对照,不纳入本轮 Rust 重写范围。 - -## 3. 总量校验 - -| 项目 | 数量 | -| --- | --- | -| 挂载面 | `6` | -| 总路由数 | `96` | -| `assets` | `14` | -| `auth` | `17` | -| `editor` | `3` | -| `runtime-main` | `59` | -| `runtime-story-action` | `2` | -| `health` | `1` | - -补充说明: - -1. 上表总量仍然是当前 Node 后端历史基线。 -2. 其中 `editor` 的 `3` 条路由继续计入历史对照,但不计入本轮 `server-rs` active rewrite target。 - -## 4. `assets` 路由映射(14 条) - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `assets.characterAnimationGenerate` | `POST /api/assets/character-animation/generate` | `A-OSS` | 保留原路径;Axum 创建 `asset_job`,外部生成结果写 OSS,任务状态进 SpacetimeDB。 | -| `assets.characterAnimationImportVideo` | `POST /api/assets/character-animation/import-video` | `A-OSS` | 保留原路径;视频参考素材由 Axum 上传 OSS,并写任务/对象元数据。 | -| `assets.characterAnimationJobGet` | `GET /api/assets/character-animation/jobs/:taskId` | `A-OSS` | 保留原路径;查询改读 SpacetimeDB `asset_job view`。 | -| `assets.characterAnimationPublish` | `POST /api/assets/character-animation/publish` | `A-OSS` | 保留原路径;发布动作改为“绑定 OSS 对象到业务实体 + 回写元数据”。 | -| `assets.characterAnimationTemplatesList` | `GET /api/assets/character-animation/templates` | `A-OSS` | 保留原路径;模板清单先由 Axum 提供,后续再视情况对象化。 | -| `assets.characterVisualGenerate` | `POST /api/assets/character-visual/generate` | `A-OSS` | 保留原路径;角色主形象候选生成改为 Axum 编排 + OSS 入库。 | -| `assets.characterVisualJobGet` | `GET /api/assets/character-visual/jobs/:taskId` | `A-OSS` | 保留原路径;任务状态改读 SpacetimeDB。 | -| `assets.characterVisualPublish` | `POST /api/assets/character-visual/publish` | `A-OSS` | 保留原路径;发布改为对象绑定,不再依赖本地 `public/generated-*` 真相。 | -| `assets.characterWorkflowCacheSave` | `POST /api/assets/character-workflow-cache` | `A-OSS` | 保留原路径;工作流缓存改写 OSS/对象存储,索引进 SpacetimeDB。 | -| `assets.characterWorkflowCacheGet` | `GET /api/assets/character-workflow-cache/:characterId` | `A-OSS` | 保留原路径;按角色查缓存索引,再返回对象内容或签名 URL。 | -| `assets.qwenSpriteFrameRepairGenerate` | `POST /api/assets/qwen-sprite/frame-repair` | `A-OSS` | 保留原路径;Qwen 修帧结果统一入 OSS,状态进 SpacetimeDB。 | -| `assets.qwenSpriteMasterGenerate` | `POST /api/assets/qwen-sprite/master` | `A-OSS` | 保留原路径;主图生成改为 Axum 编排。 | -| `assets.qwenSpriteAssetSave` | `POST /api/assets/qwen-sprite/save` | `A-OSS` | 保留原路径;保存动作改为持久化对象元数据与引用关系。 | -| `assets.qwenSpriteSheetGenerate` | `POST /api/assets/qwen-sprite/sheet` | `A-OSS` | 保留原路径;整表生成链路保留,底层切换为 OSS + SpacetimeDB。 | - -## 5. `auth` 路由映射(17 条) - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `auth.auditLogs` | `GET /api/auth/audit-logs` | `A-AUTH` | 保留原路径;从 SpacetimeDB `auth_audit_log view` 返回。 | -| `auth.entry` | `POST /api/auth/entry` | `A-AUTH` | 保留原路径;密码登录与自动注册由 Axum 完成,再写 auth 表。 | -| `auth.loginOptions` | `GET /api/auth/login-options` | `A-AUTH` | 保留原路径;由 Axum 直接返回登录方式配置。 | -| `auth.logout` | `POST /api/auth/logout` | `A-AUTH` | 保留原路径;Axum 吊销 refresh session 并清理 cookie。 | -| `auth.logoutAll` | `POST /api/auth/logout-all` | `A-AUTH` | 保留原路径;批量吊销用户全部 session。 | -| `auth.me` | `GET /api/auth/me` | `A-AUTH` | 保留原路径;由 Axum 校验 JWT 后查询用户读模型。 | -| `auth.phoneChange` | `POST /api/auth/phone/change` | `A-AUTH` | 保留原路径;短信校验在 Axum,绑定结果写 SpacetimeDB。 | -| `auth.phoneLogin` | `POST /api/auth/phone/login` | `A-AUTH` | 保留原路径;验证码校验成功后创建/恢复账号与 session。 | -| `auth.phoneSendCode` | `POST /api/auth/phone/send-code` | `A-AUTH` | 保留原路径;阿里云短信发送适配收口到 Axum。 | -| `auth.refresh` | `POST /api/auth/refresh` | `A-AUTH` | 保留原路径;沿用 refresh cookie -> access token 刷新模型。 | -| `auth.riskBlocks` | `GET /api/auth/risk-blocks` | `A-AUTH` | 保留原路径;改读风控封禁表/视图。 | -| `auth.riskBlocksLift` | `POST /api/auth/risk-blocks/:scopeType/lift` | `A-AUTH` | 保留原路径;解除请求由 Axum 执行校验并写状态。 | -| `auth.sessions` | `GET /api/auth/sessions` | `A-AUTH` | 保留原路径;会话列表改读 SpacetimeDB `refresh_session view`。 | -| `auth.sessionRevoke` | `POST /api/auth/sessions/:sessionId/revoke` | `A-AUTH` | 保留原路径;会话吊销改写 `refresh_session` 状态。 | -| `auth.wechatBindPhone` | `POST /api/auth/wechat/bind-phone` | `A-AUTH` | 保留原路径;微信身份补绑手机号逻辑迁到 Axum。 | -| `auth.wechatCallback` | `GET /api/auth/wechat/callback` | `A-AUTH` | 保留原路径与 redirect 语义;微信 code 交换由 Axum 处理。 | -| `auth.wechatStart` | `GET /api/auth/wechat/start` | `A-AUTH` | 保留原路径;授权 URL 由 Axum 按设备场景生成。 | - -## 6. `editor` 路由映射(3 条,历史遗留) - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `editor.catalogItems` | `GET /api/editor/catalog/items` | `A-EDITOR` | 保留在 `server-node/` 遗留链路,仅作为历史对照;不迁移到 `server-rs`。 | -| `editor.resourceRead` | `GET /api/editor/json/:resourceId` | `A-EDITOR` | 保留在 `server-node/` 遗留链路,仅作为历史对照;不迁移到 `server-rs`。 | -| `editor.resourceWrite` | `POST /api/editor/json/:resourceId` | `A-EDITOR` | 保留在 `server-node/` 遗留链路,仅作为历史对照;不迁移到 `server-rs`。 | - -## 7. `runtime-main` 路由映射(59 条) - -### 7.1 custom world 资源与实体生成 - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.customWorldCoverImage` | `POST /api/custom-world/cover-image` | `A-CW` | 保留原路径;封面图生成由 Axum 编排,产物进 OSS,绑定关系进 SpacetimeDB。 | -| `runtime.customWorldCoverUpload` | `POST /api/custom-world/cover-upload` | `A-CW` | 保留原路径;上传改为 OSS 直传或 Axum 中转上传。 | -| `runtime.customWorldEntity.primary` | `POST /api/custom-world/entity` | `A-CW` | 保留原路径;实体生成由 Axum 调 LLM,再写 custom world 表。 | -| `runtime.customWorldSceneImage` | `POST /api/custom-world/scene-image` | `A-CW` | 保留原路径;场景图生成由 Axum + OSS 完成。 | -| `runtime.customWorldSceneNpc.primary` | `POST /api/custom-world/scene-npc` | `A-CW` | 保留原路径;场景 NPC 生成结果写 custom world / npc 相关表。 | - -### 7.2 LLM 透传 - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.llmChatCompletionsProxy` | `POST /api/llm/chat/completions` | `A-LLM` | 保留原路径;继续由 Axum 承接代理,不进入 SpacetimeDB。 | - -### 7.3 profile 主路径 - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.profileBrowseHistoryDelete.primary` | `DELETE /api/profile/browse-history` | `A-RUNTIME` | 保留原路径;清理动作改为 reducer 写 `user_browse_history`。 | -| `runtime.profileBrowseHistoryGet.primary` | `GET /api/profile/browse-history` | `A-RUNTIME` | 保留原路径;历史记录改读 browse history view。 | -| `runtime.profileBrowseHistoryPost.primary` | `POST /api/profile/browse-history` | `A-RUNTIME` | 保留原路径;批量写入改为 Axum -> reducer。 | -| `runtime.profileDashboard.primary` | `GET /api/profile/dashboard` | `A-RUNTIME` | 保留原路径;个人主页汇总改读 dashboard view。 | -| `runtime.profilePlayStats.primary` | `GET /api/profile/play-stats` | `A-RUNTIME` | 保留原路径;统计数据改读 projection。 | -| `runtime.profileSaveArchivesList.primary` | `GET /api/profile/save-archives` | `A-RUNTIME` | 保留原路径;存档摘要改读 save archive view。 | -| `runtime.profileSaveArchivesResume.primary` | `POST /api/profile/save-archives/:worldKey` | `A-RUNTIME` | 保留原路径;恢复动作改读 `profile_save_archive` 后重建兼容快照。 | -| `runtime.profileWalletLedger.primary` | `GET /api/profile/wallet-ledger` | `A-RUNTIME` | 保留原路径;资产流水改读 ledger view。 | - -### 7.4 runtime 聊天与流式对话 - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.characterReplyStream` | `POST /api/runtime/chat/character/reply/stream` | `A-CHAT` | 保留 SSE contract;Axum 流式产出,状态写 story/session 表。 | -| `runtime.characterSuggestions` | `POST /api/runtime/chat/character/suggestions` | `A-CHAT` | 保留原路径;由 Axum 生成建议语并按需写会话状态。 | -| `runtime.characterSummary` | `POST /api/runtime/chat/character/summary` | `A-CHAT` | 保留原路径;摘要生成留在 Axum,摘要索引可回写 SpacetimeDB。 | -| `runtime.npcDialogueStream` | `POST /api/runtime/chat/npc/dialogue/stream` | `A-CHAT` | 保留 SSE contract;NPC 对话状态迁到 SpacetimeDB。 | -| `runtime.npcRecruitStream` | `POST /api/runtime/chat/npc/recruit/stream` | `A-CHAT` | 保留 SSE contract;招募对话与状态变化统一进入新状态层。 | -| `runtime.npcTurnStream` | `POST /api/runtime/chat/npc/turn/stream` | `A-CHAT` | 保留 SSE contract;单回合发言的判定与状态回写统一收口。 | - -### 7.5 custom world gallery / library / sessions / works - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.customWorldGalleryList` | `GET /api/runtime/custom-world-gallery` | `A-CW` | 保留原路径;公开画廊改读 `custom_world_gallery view`。 | -| `runtime.customWorldGalleryDetail` | `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId` | `A-CW` | 保留原路径;详情改读 gallery detail view。 | -| `runtime.customWorldLibraryList` | `GET /api/runtime/custom-world-library` | `A-CW` | 保留原路径;资料库改读用户 custom world view。 | -| `runtime.customWorldLibraryDelete` | `DELETE /api/runtime/custom-world-library/:profileId` | `A-CW` | 保留原路径;删除改为 reducer 或软删除标记。 | -| `runtime.customWorldLibraryUpsert` | `PUT /api/runtime/custom-world-library/:profileId` | `A-CW` | 保留原路径;写入改为 Axum facade + SpacetimeDB profile tables。 | -| `runtime.customWorldLibraryPublish` | `POST /api/runtime/custom-world-library/:profileId/publish` | `A-CW` | 保留原路径;发布改为状态切换与画廊投影刷新。 | -| `runtime.customWorldLibraryUnpublish` | `POST /api/runtime/custom-world-library/:profileId/unpublish` | `A-CW` | 保留原路径;撤回发布改为状态切换。 | -| `runtime.customWorldSessionCreate` | `POST /api/runtime/custom-world/sessions` | `A-CW` | 保留原路径;传统问答会话状态迁到 SpacetimeDB。 | -| `runtime.customWorldSessionGet` | `GET /api/runtime/custom-world/sessions/:sessionId` | `A-CW` | 保留原路径;读取传统问答会话改读 view。 | -| `runtime.customWorldSessionAnswer` | `POST /api/runtime/custom-world/sessions/:sessionId/answers` | `A-CW` | 保留原路径;回答动作改为 reducer 写会话状态。 | -| `runtime.customWorldSessionGenerateStream` | `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` | `A-CW` | 保留 SSE contract;编译过程由 Axum 流式回推并回写状态。 | -| `runtime.customWorldWorksList` | `GET /api/runtime/custom-world/works` | `A-CW` | 保留原路径;作品汇总改读 custom world work summary view。 | - -### 7.6 custom world agent - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.customWorldAgentCreateSession` | `POST /api/runtime/custom-world/agent/sessions` | `A-AGENT` | 保留原路径;Agent 会话创建改写 `custom_world_agent_session`。 | -| `runtime.customWorldAgentGetSession` | `GET /api/runtime/custom-world/agent/sessions/:sessionId` | `A-AGENT` | 保留原路径;会话快照改读 Agent session view。 | -| `runtime.customWorldAgentExecuteAction` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` | `A-AGENT` | 保留原路径;动作编排由 Axum 执行,状态与操作记录进 SpacetimeDB。 | -| `runtime.customWorldAgentGetCardDetail` | `GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` | `A-AGENT` | 保留原路径;卡片详情改读 `custom_world_draft_card`。 | -| `runtime.customWorldAgentSendMessage` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` | `A-AGENT` | 保留原路径;消息提交后由 Axum 触发编排,消息与操作状态入库。 | -| `runtime.customWorldAgentStreamMessage` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` | `A-AGENT` | 保留 SSE contract;流式消息由 Axum 输出,Agent 状态表持续更新。 | -| `runtime.customWorldAgentGetOperation` | `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` | `A-AGENT` | 保留原路径;操作状态改读 `custom_world_agent_operation view`。 | - -### 7.7 compat 路径 - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.customWorldEntity.compat` | `POST /api/runtime/custom-world/entity` | `A-CW` | 保留兼容路径;与主路径共用同一 handler。 | -| `runtime.customWorldSceneNpc.compat` | `POST /api/runtime/custom-world/scene-npc` | `A-CW` | 保留兼容路径;与主路径共用同一 handler。 | -| `runtime.profileBrowseHistoryDelete.compat` | `DELETE /api/runtime/profile/browse-history` | `A-RUNTIME` | 保留兼容路径;与 `/api/profile/browse-history` 共用实现。 | -| `runtime.profileBrowseHistoryGet.compat` | `GET /api/runtime/profile/browse-history` | `A-RUNTIME` | 保留兼容路径;共用 browse history facade。 | -| `runtime.profileBrowseHistoryPost.compat` | `POST /api/runtime/profile/browse-history` | `A-RUNTIME` | 保留兼容路径;共用写入逻辑。 | -| `runtime.profileDashboard.compat` | `GET /api/runtime/profile/dashboard` | `A-RUNTIME` | 保留兼容路径;共用 dashboard facade。 | -| `runtime.profilePlayStats.compat` | `GET /api/runtime/profile/play-stats` | `A-RUNTIME` | 保留兼容路径;共用 play stats facade。 | -| `runtime.profileSaveArchivesList.compat` | `GET /api/runtime/profile/save-archives` | `A-RUNTIME` | 保留兼容路径;共用 save archives list facade。 | -| `runtime.profileSaveArchivesResume.compat` | `POST /api/runtime/profile/save-archives/:worldKey` | `A-RUNTIME` | 保留兼容路径;共用 resume facade。 | -| `runtime.profileWalletLedger.compat` | `GET /api/runtime/profile/wallet-ledger` | `A-RUNTIME` | 保留兼容路径;共用 wallet ledger facade。 | - -### 7.8 runtime 其他核心接口 - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `runtime.itemsIntent` | `POST /api/runtime/items/runtime-intent` | `A-CW` | 保留原路径;Axum 调 LLM 生成意图,物品领域状态与引用写 SpacetimeDB。 | -| `runtime.questsGenerate` | `POST /api/runtime/quests/generate` | `A-CW` | 保留原路径;任务候选生成由 Axum 编排,结果写 quest 相关表。 | -| `runtime.snapshotDelete` | `DELETE /api/runtime/save/snapshot` | `A-RUNTIME` | 保留原路径;删除动作改为更新 `runtime_snapshot` / archive。 | -| `runtime.snapshotGet` | `GET /api/runtime/save/snapshot` | `A-RUNTIME` | 保留原路径;读取兼容聚合快照,由 view/projection 输出。 | -| `runtime.snapshotPut` | `PUT /api/runtime/save/snapshot` | `A-RUNTIME` | 保留原路径;写入由 Axum facade + reducer 完成。 | -| `runtime.settingsGet` | `GET /api/runtime/settings` | `A-RUNTIME` | 保留原路径;设置改读 `runtime_setting view`。 | -| `runtime.settingsPut` | `PUT /api/runtime/settings` | `A-RUNTIME` | 保留原路径;设置更新改为 reducer。 | -| `runtime.storyContinue` | `POST /api/runtime/story/continue` | `A-STORY` | 保留原路径;故事推进由 Axum 调新 story/application 层。 | -| `runtime.storyInitial` | `POST /api/runtime/story/initial` | `A-STORY` | 保留原路径;首段故事生成保持 REST 兼容。 | -| `runtime.wsHealth` | `GET /api/ws/health` | `A-RUNTIME` | 保留原路径;继续作为实时链路占位健康检查。 | - -## 8. `runtime-story-action` 路由映射(2 条) - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `storyAction.resolve` | `POST /api/runtime/story/actions/resolve` | `A-STORY` | 保留原路径;Axum 接收动作请求,SpacetimeDB reducer 执行跨模块结算。 | -| `storyAction.stateGet` | `GET /api/runtime/story/state/:sessionId` | `A-STORY` | 保留原路径;读取 story session 兼容状态 view。 | - -## 9. `health` 路由映射(1 条) - -| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 | -| --- | --- | --- | --- | -| `health.check` | `GET /healthz` | `A-HEALTH` | 保留原路径与最小返回结构。 | - -## 10. 迁移落地规则 - -后续做路由树时,必须遵守: - -1. 旧路径优先保留,新实现从内部切换,不先要求前端改地址。 -2. 主路径与兼容路径必须共用同一 application service,避免再次出现双份逻辑。 -3. `stream` 接口第一阶段默认沿用 SSE。 -4. `assets` 与 `custom-world` 里的生成类接口,外部副作用统一在 Axum,状态与任务统一进 SpacetimeDB。 -5. `storyAction.resolve`、`runtime.snapshotPut`、`auth.refresh` 属于最优先回归接口,后续开发必须优先补完整测试。 -6. `editor` 相关旧路径只保留历史基线记录,不纳入 `server-rs` 路由树实施范围。 - -## 11. 本任务完成定义 - -当以下条件成立时,这条任务视为完成: - -1. `96` 条旧路由都已经有新实现落点。 -2. 每条路由至少明确: - - 旧方法/路径 - - 新实现归属 - - 第一阶段迁移策略 -3. 后续搭建 Axum 路由树与 application service 时,可以直接按这份矩阵逐项落位。 diff --git a/backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md b/backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md deleted file mode 100644 index fc4a252a..00000000 --- a/backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md +++ /dev/null @@ -1,300 +0,0 @@ -# M0:SSE 接口与事件格式冻结基线 - -日期:`2026-04-20` - -依据来源: - -- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md) -- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json) -- `server-node/src/http.ts` -- `server-node/src/routes/runtimeRoutes.ts` -- `server-node/src/routes/customWorldAgent.ts` -- `server-node/src/modules/ai/chatOrchestrator.ts` -- `server-node/src/services/customWorldAgentOrchestrator.ts` -- `server-node/src/modules/ai/customWorldOrchestrator.ts` - -## 1. 文档目的 - -这份文档用于完成 `M0` 的第四条任务: - -- 整理当前所有 SSE 接口与事件格式 - -这里的“整理”不是只记住有几条 `stream` 路由,而是要求后续 Axum 重写必须先冻结: - -1. 当前到底有哪几条 SSE 路由。 -2. 每条路由是“透传上游流”还是“项目自定义事件流”。 -3. 每条路由的事件名、结束标记、错误帧和头部约束是什么。 -4. 哪些流的 `payload` 是增量文本,哪些其实是“累计文本”。 - -## 2. 冻结结论 - -当前 Node 后端正式登记的 SSE 接口固定为以下 `6` 条: - -| 路由 ID | 方法/路径 | 当前实现入口 | 协议类型 | 成功结束标记 | 鉴权 | -| --- | --- | --- | --- | --- | --- | -| `runtime.characterReplyStream` | `POST /api/runtime/chat/character/reply/stream` | `runtimeRoutes.ts -> streamCharacterChatReplyFromOrchestrator` | 上游透传 SSE | 上游 `data: [DONE]` | JWT | -| `runtime.npcDialogueStream` | `POST /api/runtime/chat/npc/dialogue/stream` | `runtimeRoutes.ts -> streamNpcChatDialogueFromOrchestrator` | 上游透传 SSE | 上游 `data: [DONE]` | JWT | -| `runtime.npcRecruitStream` | `POST /api/runtime/chat/npc/recruit/stream` | `runtimeRoutes.ts -> streamNpcRecruitDialogueFromOrchestrator` | 上游透传 SSE | 上游 `data: [DONE]` | JWT | -| `runtime.npcTurnStream` | `POST /api/runtime/chat/npc/turn/stream` | `runtimeRoutes.ts -> streamNpcChatTurnFromOrchestrator` | 项目自定义 SSE | `event: complete` 后追加 `data: [DONE]` | JWT | -| `runtime.customWorldSessionGenerateStream` | `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` | `runtimeRoutes.ts` 内联实现 | 项目自定义 SSE | `event: done`,无 `[DONE]` | JWT | -| `runtime.customWorldAgentStreamMessage` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` | `customWorldAgent.ts -> customWorldAgentOrchestrator.streamMessage` | 项目自定义 SSE | `event: done`,无 `[DONE]` | JWT | - -冻结总数: - -1. SSE 接口:`6` -2. 上游透传型:`3` -3. 本地自定义事件流:`3` - -## 3. 全部 SSE 接口共享的响应头约束 - -当前所有项目内主动准备 SSE 响应的接口,都经过 `prepareEventStreamResponse(...)`,因此至少冻结以下头部行为: - -| 响应头 | 当前值/规则 | 说明 | -| --- | --- | --- | -| `Content-Type` | 默认 `text/event-stream; charset=utf-8` | 透传型接口可被上游 `content-type` 覆盖,但仍保持 SSE。 | -| `Cache-Control` | `no-cache` | 禁止中间层缓存流式结果。 | -| `Connection` | `keep-alive` | 保持 SSE 长连接。 | -| `X-Accel-Buffering` | `no` | 禁止代理层缓冲。 | -| `x-request-id` | 透传当前请求 ID | 所有 SSE 都要带请求追踪头。 | -| `x-api-version` | 当前 API 版本号 | 与普通 JSON 接口一致。 | -| `x-route-version` | 当前路由版本号 | 与普通 JSON 接口一致。 | -| `x-response-time-ms` | 当前已耗时毫秒数 | 在准备响应头时写入。 | - -额外冻结约束: - -1. `SSE` 接口当前也保留普通 API 元数据头,不能因为换成 Axum 就丢掉。 -2. 这 `6` 条流式接口都在 `requireAuth` 之后注册,因此第一阶段默认仍需要 `Bearer JWT`。 - -## 4. 协议分型 - -### 4.1 上游透传型 SSE(3 条) - -包含: - -1. `POST /api/runtime/chat/character/reply/stream` -2. `POST /api/runtime/chat/npc/dialogue/stream` -3. `POST /api/runtime/chat/npc/recruit/stream` - -当前实现特征: - -1. 路由不自己重写事件名,直接把上游模型返回的 SSE 原样管道转发给前端。 -2. 本地只负责: - - 发起上游流式请求 - - 准备 SSE 头部 - - 处理中断时的请求 abort -3. 从 `llmClient.streamMessageContent(...)` 的解析逻辑可以反推,当前上游 SSE 采用 OpenAI 风格: - - 多个 `data: {...}` JSON chunk - - 最终 `data: [DONE]` - -冻结要求: - -1. 第一阶段 Axum 仍要保持这三条接口的“上游透传”语义。 -2. 不要在未发版变更协议前,擅自把它们改成项目自定义 `event: reply_delta` 格式。 - -### 4.2 项目自定义 SSE(3 条) - -包含: - -1. `POST /api/runtime/chat/npc/turn/stream` -2. `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` -3. `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` - -当前实现特征: - -1. 路由或 orchestrator 自己写 `event:` 与 `data:`。 -2. 事件名不是上游协议,而是项目本地约定。 -3. 这三条流的结束方式并不一致,必须分别兼容。 - -## 5. 各接口事件格式冻结 - -### 5.1 `runtime.characterReplyStream` - -路径: - -- `POST /api/runtime/chat/character/reply/stream` - -冻结格式: - -1. 当前为上游透传流。 -2. 本地不保证固定 `event` 名。 -3. 前端实际收到的是上游 `data: {...}` chunk 与最终 `data: [DONE]`。 -4. 失败时当前实现也不是本地 `event: error`,而是由上游失败或 Express 错误链决定。 - -### 5.2 `runtime.npcDialogueStream` - -路径: - -- `POST /api/runtime/chat/npc/dialogue/stream` - -冻结格式: - -1. 当前为上游透传流。 -2. 协议特征与 `runtime.characterReplyStream` 相同。 -3. 第一阶段不能私自改成项目自定义事件名。 - -### 5.3 `runtime.npcRecruitStream` - -路径: - -- `POST /api/runtime/chat/npc/recruit/stream` - -冻结格式: - -1. 当前为上游透传流。 -2. 协议特征与前两条透传 SSE 相同。 -3. 结束标记仍依赖上游 `data: [DONE]`。 - -### 5.4 `runtime.npcTurnStream` - -路径: - -- `POST /api/runtime/chat/npc/turn/stream` - -成功事件序列: - -1. `event: reply_delta` -2. `event: reply_delta` -3. `...` -4. `event: complete` -5. `data: [DONE]` - -错误事件: - -1. `event: error` -2. `data: {"message":"..."}` -3. 之后直接 `response.end()`,不会再补 `complete` - -冻结 payload 规则: - -| 事件名 | payload 结构 | 关键说明 | -| --- | --- | --- | -| `reply_delta` | `{ "text": string }` | `text` 实际是“累计文本”,不是单 token 增量。 | -| `complete` | `{ "npcReply": string, "affinityDelta": number, "affinityText": string, "suggestions": string[], "pendingQuestOffer": object \| null, "chatDirective": object \| null }` | 最终一次性返回业务结算数据。 | -| `error` | `{ "message": string }` | 仅错误消息,无额外状态。 | - -补充冻结点: - -1. `reply_delta.text` 每次都是当前累计回复全文。 -2. `complete.suggestions` 在强制收束场景下可能是空数组。 -3. `complete.chatDirective` 当前至少可能包含: - - `turnLimit` - - `remainingTurns` - - `forceExit` - - `closingMode` -4. `complete.pendingQuestOffer` 当前可能包含: - - `quest` - - `introText` - -### 5.5 `runtime.customWorldSessionGenerateStream` - -路径: - -- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` - -成功事件序列: - -1. `event: progress`,payload:`{ "phase": "preparing", "progress": 10 }` -2. `event: progress`,payload:`{ "phase": "requesting_llm", "progress": 45 }` -3. `event: progress`,payload:`CustomWorldGenerationProgress` -4. `...` -5. `event: progress`,payload:`{ "phase": "completed", "progress": 100 }` -6. `event: result` -7. `event: done` - -错误事件: - -1. `event: error` -2. `data: {"message":"..."}` -3. 之后直接结束,不会再发 `done` - -冻结 payload 规则: - -| 事件名 | payload 结构 | 关键说明 | -| --- | --- | --- | -| `progress` | 兼容两种结构 | 这是当前最容易踩坑的混合协议。 | -| `result` | `{ "profile": object }` | 返回完整世界 profile。 | -| `done` | `{ "ok": true }` | 当前没有 `[DONE]` 字符串终止帧。 | -| `error` | `{ "message": string }` | 当前也没有额外错误码。 | - -`progress` 事件的两种冻结结构: - -1. 启动/收尾帧: - - `{ "phase": "preparing", "progress": 10 }` - - `{ "phase": "requesting_llm", "progress": 45 }` - - `{ "phase": "completed", "progress": 100 }` -2. 编排器进度帧 `CustomWorldGenerationProgress`: - - `phaseId` - - `phaseLabel` - - `phaseDetail` - - `overallProgress` - - `completedWeight` - - `totalWeight` - - `elapsedMs` - - `estimatedRemainingMs` - - `activeStepIndex` - - `steps` - -补充冻结点: - -1. 当前 `progress` 不是单一 schema,而是混合 schema。 -2. 当前实现会在客户端断开时触发 `AbortController`,这条流具备显式中断处理。 - -### 5.6 `runtime.customWorldAgentStreamMessage` - -路径: - -- `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` - -成功事件序列: - -1. `event: reply_delta` -2. `event: reply_delta` -3. `...` -4. `event: session` -5. `event: done` - -错误事件: - -1. `event: error` -2. `data: {"message":"..."}` -3. 之后直接结束,不会再补 `done` - -冻结 payload 规则: - -| 事件名 | payload 结构 | 关键说明 | -| --- | --- | --- | -| `reply_delta` | `{ "text": string }` | 当前也是累计文本,不是 diff patch。 | -| `session` | `{ "session": CustomWorldAgentSessionSnapshot }` | 完整会话快照一次性回推。 | -| `done` | `{ "ok": true }` | 当前没有 `[DONE]`。 | -| `error` | `{ "message": string }` | 仅错误消息。 | - -补充冻结点: - -1. 这条流当前不会在成功结尾补发最终文本帧,只会发 `session` 快照。 -2. `reply_delta.text` 同样是“到当前为止的完整回复”。 -3. 当前实现没有像 `customWorldSessionGenerateStream` 那样显式挂请求断开 abort。 - -## 6. 第一阶段 Axum 重写必须兼容的硬约束 - -后续重写中,不允许出现以下情况: - -1. 把当前 `6` 条 SSE 路由减少、合并或改掉方法类型。 -2. 把透传型 `3` 条流直接改写成自定义事件名,而前端却不知情。 -3. 把 `npcTurnStream` 的 `reply_delta` 从“累计文本”改成“真正 delta”,导致前端拼接方式失效。 -4. 把 `customWorldSessionGenerateStream` 的混合 `progress` schema 静默改成新格式,却没有版本门禁。 -5. 把 `customWorldAgentStreamMessage` 的 `session` 终帧改成局部 patch,而前端仍按完整快照消费。 -6. 丢失 `x-request-id`、`x-api-version`、`x-route-version`、`x-response-time-ms` 等当前前端与联调用到的头。 - -## 7. 本任务完成定义 - -当以下条件成立时,这条任务视为完成: - -1. 当前 `6` 条 SSE 接口已经有书面冻结清单。 -2. 每条 SSE 都已明确: - - 方法与路径 - - 协议类型 - - 事件名 - - 成功结束标记 - - 错误事件 - - 关键 payload 结构 -3. 后续 Axum SSE 落地、前端 contract 回归、SpacetimeDB 实时链路设计时,可以直接引用本文,不再靠人工回忆事件名。 diff --git a/backend-rewrite-tasklist/README.md b/backend-rewrite-tasklist/README.md deleted file mode 100644 index e36ef801..00000000 --- a/backend-rewrite-tasklist/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# 后端重写任务清单目录 - -日期:`2026-04-20` - -本目录用于集中存放 `SpacetimeDB + Axum + 阿里云 OSS` 后端重写相关任务清单。 - -## 文件结构 - -- [00_MASTER_TASKLIST.md](./00_MASTER_TASKLIST.md):总纲主清单,保留完整阶段结构与最终验收项。 -- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md):能力冻结、Rust 工作区、Axum 基础设施、鉴权与会话迁移任务。 -- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md):runtime snapshot / settings / profile 迁移任务。 -- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md):story action 主循环与 gameplay reducer 迁移任务。 -- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md):custom world / gallery / agent 主链迁移任务。 -- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md):assets / 阿里云 OSS 迁移任务;`editor` 已于 `2026-04-21` 退出本轮重写范围。 -- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md):联调、回归、部署、观测与切流任务。 -- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md):横向专项、执行顺序与最终验收清单。 -- [M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md):当前 Node 后端 `6` 个挂载面的冻结基线,用于后续接口映射、模块迁移与验收对照。 -- [M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md):当前 `96` 条后端路由的“旧接口 -> 新实现”迁移矩阵,用于 Axum 路由树和 application service 落位。 -- [M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md):当前 `12` 个内部模块的迁移归属基线,用于锁定 Rust crate、SpacetimeDB bounded context 与 Axum/application 分工。 -- [M0_SSE_INTERFACE_BASELINE_2026-04-20.md](./M0_SSE_INTERFACE_BASELINE_2026-04-20.md):当前 `6` 条 SSE 接口及其事件格式冻结基线,用于 Axum SSE 兼容和前端 contract 回归。 -- [M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md](./M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md):当前正式 `/generated-*` 静态资源前缀冻结基线,用于 Axum 静态资源兼容层与 OSS 对象键规划。 -- [M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md](./M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md):当前前端直接依赖的响应头、envelope 与错误格式冻结基线,用于 Axum 中间件与错误响应兼容。 -- [M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md):`M0` 仓库边界决议文档,用于持续冻结 `server-rs/` 落位、迁移期双栈共存、Axum 边界与副作用收口原则。 -- [M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md):`M0 ~ M7` 阶段验收矩阵,用于固定每阶段的入口条件、核心交付、退出条件与跨阶段回归焦点。 - -## 当前 M4 / M5 结构基线 - -- `M4` 当前涉及的前后端脚本结构、命名根、route/service/compiler/repository 落位,统一参照 [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。 -- `M5` 当前涉及的创作入口、Agent session、result preview、works/library/gallery、publish 与 enter-world 主链,统一参照 [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。 -- 旧 `custom-world/sessions` 传统问答链已经退出当前仓库正式主链;后续若在 `M5` 中提及,只按历史兼容台账处理,不再作为当前功能扩展目标。 - -## 维护规则 - -1. 总纲与拆分文件都以本目录为唯一维护位置。 -2. 总纲用于把控全局节奏,拆分文件用于实际逐项推进。 -3. 如阶段任务发生明显变化,需要同步更新总纲与对应拆分文件。