Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

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

View File

@@ -33,9 +33,10 @@ use crate::{
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play,
stream_big_fish_message, submit_big_fish_message,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -652,6 +653,27 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}",
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}/input",
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
@@ -828,16 +850,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/browse-history",
get(get_runtime_browse_history)
@@ -848,13 +860,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
@@ -862,13 +867,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
@@ -876,13 +874,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
@@ -890,13 +881,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
@@ -904,13 +888,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
@@ -918,13 +895,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
@@ -932,13 +902,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
@@ -946,20 +909,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
@@ -967,13 +916,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(

View File

@@ -23,8 +23,8 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
api_response::json_success_body, http_error::AppError, platform_errors::map_oss_error,
request_context::RequestContext, state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -377,17 +377,7 @@ fn map_confirm_asset_object_prepare_error(error: ConfirmAssetObjectPrepareError)
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(platform_oss::OssError::ObjectNotFound(_)) => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(_) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
ConfirmAssetObjectPrepareError::Oss(error) => map_oss_error(error, "aliyun-oss"),
}
}

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ use crate::{
build_fallback_moderation_safe_character_visual_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
@@ -1335,19 +1336,7 @@ fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError
}
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn parse_json_payload(

View File

@@ -35,6 +35,7 @@ use crate::{
build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::scene_background::{
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
@@ -1016,19 +1017,7 @@ fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppErr
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {

View File

@@ -34,6 +34,11 @@ impl AppError {
self.code
}
#[cfg(test)]
pub fn status_code(&self) -> StatusCode {
self.status_code
}
pub fn message(&self) -> &str {
&self.message
}

View File

@@ -6,7 +6,7 @@ use axum::{
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
use serde_json::json;
use crate::{http_error::AppError, state::AppState};
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
@@ -183,19 +183,7 @@ fn is_invalid_path_segment(segment: &str) -> bool {
}
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn map_legacy_generated_upstream_status(

View File

@@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
response::Response,
};
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest};
use platform_llm::{LlmMessage, LlmMessageRole, LlmTextRequest};
use serde_json::Value;
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
@@ -12,7 +12,7 @@ use shared_contracts::llm::{
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
platform_errors::map_llm_error, request_context::RequestContext, state::AppState,
};
pub async fn proxy_llm_chat_completions(
@@ -74,39 +74,6 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
LlmMessage::new(role, message.content)
}
fn map_llm_error(error: LlmError) -> AppError {
match error {
LlmError::InvalidRequest(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
}
LlmError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
}
LlmError::Upstream {
status_code: 429,
message,
} => AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(message),
LlmError::Upstream { message, .. } => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
LlmError::Timeout { attempts } => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 请求超时,累计尝试 {attempts}")),
LlmError::Connectivity { attempts, message } => {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 连接失败,累计尝试 {attempts} 次:{message}"))
}
LlmError::StreamUnavailable => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 流式响应体不可用")
}
LlmError::EmptyResponse => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 返回内容为空")
}
LlmError::Transport(message) | LlmError::Deserialize(message) => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
}
}
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}

View File

@@ -42,6 +42,7 @@ mod logout_all;
mod password_entry;
mod password_management;
mod phone_auth;
mod platform_errors;
mod prompt;
mod puzzle;
mod puzzle_agent_turn;

View File

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

View File

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

View File

@@ -70,6 +70,7 @@ use crate::{
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_oss_error,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
@@ -2942,10 +2943,7 @@ fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> Str
}
fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError {

View File

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

View File

@@ -551,12 +551,6 @@ mod tests {
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
@@ -585,7 +579,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/dashboard")
.uri("/api/profile/dashboard")
.body(Body::empty())
.expect("request should build"),
)
@@ -603,7 +597,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/wallet-ledger")
.uri("/api/profile/wallet-ledger")
.body(Body::empty())
.expect("request should build"),
)
@@ -621,7 +615,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/play-stats")
.uri("/api/profile/play-stats")
.body(Body::empty())
.expect("request should build"),
)
@@ -706,118 +700,35 @@ mod tests {
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
async fn runtime_profile_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for uri in [
"/api/runtime/profile/dashboard",
"/api/profile/dashboard",
)
.await;
}
#[tokio::test]
async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/wallet-ledger",
"/api/profile/wallet-ledger",
)
.await;
}
#[tokio::test]
async fn profile_play_stats_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/recharge-center",
"/api/runtime/profile/recharge/orders",
"/api/runtime/profile/referrals/invite-center",
"/api/runtime/profile/referrals/redeem-code",
"/api/runtime/profile/redeem-codes/redeem",
"/api/runtime/profile/play-stats",
"/api/profile/play-stats",
)
.await;
}
"/api/runtime/profile/save-archives",
"/api/runtime/profile/save-archives/world-1",
"/api/runtime/profile/browse-history",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_profile".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("资料页用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
}
}
}

View File

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

View File

@@ -14,7 +14,7 @@ use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
@@ -24,7 +24,7 @@ use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
use crate::wechat_provider::build_wechat_provider;
const ADMIN_ROLE: &str = "admin";

View File

@@ -51,6 +51,23 @@ pub async fn create_story_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -89,14 +106,29 @@ pub async fn create_story_battle(
pub async fn resolve_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let battle_state_id = payload.battle_state_id;
let current_battle = state
.spacetime_client()
.get_battle_state(battle_state_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(
&request_context,
&current_battle.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.resolve_combat_action(ResolveCombatActionInput {
battle_state_id: payload.battle_state_id,
battle_state_id,
function_id: payload.function_id,
action_text: payload.action_text,
base_damage: payload.base_damage,
@@ -128,8 +160,9 @@ pub async fn get_story_battle_state(
State(state): State<AppState>,
Path(battle_state_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_battle_state(battle_state_id)
@@ -137,6 +170,7 @@ pub async fn get_story_battle_state(
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(&request_context, &result.actor_user_id, &actor_user_id)?;
Ok(json_success_body(
Some(&request_context),
@@ -175,6 +209,23 @@ pub async fn create_story_npc_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -431,6 +482,73 @@ fn story_battles_error_response(request_context: &RequestContext, error: AppErro
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner_for_battle(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-session",
"story session 不属于当前用户,不能创建战斗",
)
}
fn require_story_battle_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-battle",
"battle state 不属于当前用户",
)
}
fn require_story_session_runtime_for_battle(
request_context: &RequestContext,
session_runtime_id: &str,
requested_runtime_id: &str,
) -> Result<(), Response> {
if session_runtime_id == requested_runtime_id {
return Ok(());
}
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-session",
"message": "runtimeSessionId 与 story session 不匹配,不能创建战斗",
})),
))
}
fn require_resource_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
provider: &'static str,
message: &'static str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// API 层只做登录用户与资源属主的边界检查;战斗结算仍由 module-combat 与 SpacetimeDB 承担。
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": provider,
"message": message,
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -442,6 +560,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -454,7 +574,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::{require_story_battle_owner, require_story_session_runtime_for_battle};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn create_story_battle_requires_authentication() {
@@ -648,6 +771,37 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_story_battle_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/battles/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"battleStateId": "battle_001",
"functionId": "battle_attack_basic",
"actionText": "普通攻击",
"baseDamage": 10,
"manaCost": 0,
"heal": 0,
"manaRestore": 0,
"counterMultiplierBasisPoints": 10000
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -735,6 +889,63 @@ mod tests {
);
}
#[test]
fn story_battle_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
let response = require_story_battle_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_battle_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
require_story_battle_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
#[test]
fn story_battle_runtime_guard_rejects_mismatched_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
let response =
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_other")
.expect_err("mismatched runtime session should be bad request");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn story_battle_runtime_guard_accepts_matching_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_owner")
.expect("matching runtime session should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -72,14 +72,29 @@ pub async fn begin_story_session(
pub async fn continue_story(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ContinueStoryRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = payload.story_session_id;
let current_state = state
.spacetime_client()
.get_story_session_state(story_session_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&current_state.session.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.continue_story(
payload.story_session_id,
story_session_id,
module_story::generate_story_event_id(now_micros),
payload.narrative_text,
payload.choice_function_id,
@@ -123,8 +138,9 @@ pub async fn get_story_session_state(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_story_session_state(story_session_id)
@@ -132,6 +148,11 @@ pub async fn get_story_session_state(
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&result.session.actor_user_id,
&actor_user_id,
)?;
Ok(json_success_body(
Some(&request_context),
@@ -204,6 +225,25 @@ fn story_sessions_error_response(request_context: &RequestContext, error: AppErr
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// 这里只做 HTTP 鉴权边界判断story session 的推进规则仍由 SpacetimeDB 领域层处理。
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "story-session",
"message": "story session 不属于当前用户",
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -215,6 +255,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -227,7 +269,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::require_story_session_owner;
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn begin_story_session_requires_authentication() {
@@ -302,6 +347,32 @@ mod tests {
);
}
#[tokio::test]
async fn continue_story_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/continue")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"narrativeText": "你看见篝火边有人招手。",
"choiceFunctionId": "talk_to_npc"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -457,6 +528,34 @@ mod tests {
);
}
#[test]
fn story_session_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
let response = require_story_session_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_session_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
require_story_session_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

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

View File

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

View File

@@ -16,10 +16,18 @@
当前提交已完成:
1. `module-ai``Cargo.toml`
2. DDD 分层文件:
2. DDD 分层文件与内部子模块
- `src/domain.rs`
- `src/domain/types.rs`
- `src/domain/stages.rs`
- `src/domain/ids.rs`
- `src/commands.rs`
- `src/commands/inputs.rs`
- `src/commands/validation.rs`
- `src/application.rs`
- `src/application/service.rs`
- `src/application/store.rs`
- `src/application/result.rs`
- `src/events.rs`
- `src/errors.rs`
3. 首版核心类型:
@@ -40,7 +48,7 @@
- `AiTaskFinishInput`
- `AiTaskCancelInput`
- `AiTaskFailureInput`
8. 基础单元测试
8. `src/tests.rs` 中的基础单元测试
首版详细设计见:
@@ -48,6 +56,7 @@
2. [../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md)
4. [../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md)
5. [../../../docs/technical/SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md)
## 3. 当前仍未进入的范围

View File

@@ -1,393 +1,12 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
mod result;
mod service;
mod store;
use shared_kernel::normalize_required_string;
pub use result::AiTaskProcedureResult;
pub use service::AiTaskService;
pub use store::InMemoryAiTaskStore;
use crate::commands::validate_task_create_input;
use crate::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCreateInput,
AiTaskFieldError, AiTaskServiceError, AiTaskSnapshot, AiTaskStageSnapshot, AiTaskStageStatus,
AiTaskStatus, AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id,
generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskProcedureResult {
pub ok: bool,
pub task: Option<AiTaskSnapshot>,
pub text_chunk: Option<AiTextChunkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryAiTaskStore {
inner: Arc<Mutex<InMemoryAiTaskStoreState>>,
}
#[derive(Debug, Default)]
struct InMemoryAiTaskStoreState {
tasks: HashMap<String, AiTaskSnapshot>,
text_chunks: HashMap<String, Vec<AiTextChunkSnapshot>>,
}
#[derive(Clone, Debug)]
pub struct AiTaskService {
store: InMemoryAiTaskStore,
}
impl AiTaskService {
pub fn new(store: InMemoryAiTaskStore) -> Self {
Self { store }
}
pub fn create_task(
&self,
input: AiTaskCreateInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?;
let snapshot = AiTaskSnapshot {
task_id: input.task_id.clone(),
task_kind: input.task_kind,
owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(),
request_label: normalize_required_string(input.request_label).unwrap_or_default(),
source_module: normalize_required_string(input.source_module).unwrap_or_default(),
source_entity_id: normalize_optional_text(input.source_entity_id),
request_payload_json: normalize_optional_text(input.request_payload_json),
status: AiTaskStatus::Pending,
failure_message: None,
stages: input
.stages
.into_iter()
.map(|stage| AiTaskStageSnapshot {
stage_kind: stage.stage_kind,
label: normalize_required_string(stage.label).unwrap_or_default(),
detail: normalize_required_string(stage.detail).unwrap_or_default(),
order: stage.order,
status: AiTaskStageStatus::Pending,
text_output: None,
structured_payload_json: None,
warning_messages: Vec::new(),
started_at_micros: None,
completed_at_micros: None,
})
.collect(),
result_references: Vec::new(),
latest_text_output: None,
latest_structured_payload_json: None,
version: INITIAL_AI_TASK_VERSION,
created_at_micros: input.created_at_micros,
started_at_micros: None,
completed_at_micros: None,
updated_at_micros: input.created_at_micros,
};
self.store.insert_task(snapshot)
}
pub fn start_task(
&self,
task_id: &str,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn start_stage(
&self,
task_id: &str,
stage_kind: crate::AiTaskStageKind,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn append_text_chunk(
&self,
task_id: &str,
stage_kind: crate::AiTaskStageKind,
sequence: u32,
delta_text: String,
created_at_micros: i64,
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> {
if delta_text.trim().is_empty() {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingChunkText,
));
}
if sequence == 0 {
return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence));
}
let chunk = AiTextChunkSnapshot {
chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence),
task_id: normalize_required_string(task_id).unwrap_or_default(),
stage_kind,
sequence,
delta_text: normalize_required_string(delta_text).unwrap_or_default(),
created_at_micros,
};
let task = self.store.append_text_chunk(chunk.clone())?;
Ok((task, chunk))
}
pub fn complete_stage(
&self,
input: AiStageCompletionInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(&input.task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == input.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Completed;
stage.completed_at_micros = Some(input.completed_at_micros);
stage.text_output = normalize_optional_text(input.text_output.clone());
stage.structured_payload_json =
normalize_optional_text(input.structured_payload_json.clone());
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
task.latest_text_output = stage.text_output.clone();
task.latest_structured_payload_json = stage.structured_payload_json.clone();
task.updated_at_micros = input.completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn attach_result_reference(
&self,
task_id: &str,
reference_kind: AiResultReferenceKind,
reference_id: String,
label: Option<String>,
created_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(reference_id) = normalize_required_string(reference_id) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingReferenceId,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.result_references.push(AiResultReferenceSnapshot {
result_ref_id: generate_ai_result_ref_id(created_at_micros),
task_id: task.task_id.clone(),
reference_kind,
reference_id: reference_id.clone(),
label: normalize_optional_text(label.clone()),
created_at_micros,
});
task.updated_at_micros = created_at_micros;
task.version += 1;
Ok(())
})
}
pub fn complete_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Completed;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn fail_task(
&self,
task_id: &str,
failure_message: String,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(failure_message) = normalize_required_string(failure_message) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingFailureMessage,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Failed;
task.failure_message = Some(failure_message.clone());
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn cancel_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Cancelled;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.get_task(task_id)
}
}
impl InMemoryAiTaskStore {
fn insert_task(&self, task: AiTaskSnapshot) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
if state.tasks.contains_key(&task.task_id) {
return Err(AiTaskServiceError::TaskAlreadyExists);
}
state.text_chunks.insert(task.task_id.clone(), Vec::new());
state.tasks.insert(task.task_id.clone(), task.clone());
Ok(task)
}
fn update_task<F>(
&self,
task_id: &str,
mut apply: F,
) -> Result<AiTaskSnapshot, AiTaskServiceError>
where
F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>,
{
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
let task = state
.tasks
.get_mut(task_id.trim())
.ok_or(AiTaskServiceError::TaskNotFound)?;
apply(task)?;
Ok(task.clone())
}
fn append_text_chunk(
&self,
chunk: AiTextChunkSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
{
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
if stage.status == AiTaskStageStatus::Pending {
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros = Some(chunk.created_at_micros);
}
task.status = AiTaskStatus::Running;
task.started_at_micros
.get_or_insert(chunk.created_at_micros);
}
let chunks = state
.text_chunks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
chunks.push(chunk.clone());
chunks.sort_by_key(|value| value.sequence);
let aggregated_text = chunks
.iter()
.filter(|value| value.stage_kind == chunk.stage_kind)
.map(|value| value.delta_text.as_str())
.collect::<Vec<_>>()
.join("");
let normalized_output = if aggregated_text.trim().is_empty() {
None
} else {
Some(aggregated_text)
};
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.text_output = normalized_output.clone();
task.latest_text_output = normalized_output;
task.updated_at_micros = chunk.created_at_micros;
task.version += 1;
Ok(task.clone())
}
fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
state
.tasks
.get(task_id.trim())
.cloned()
.ok_or(AiTaskServiceError::TaskNotFound)
}
}
use crate::{AiTaskFieldError, AiTaskServiceError, AiTaskStatus};
fn ensure_task_is_not_terminal(status: AiTaskStatus) -> Result<(), AiTaskServiceError> {
if status.is_terminal() {

View File

@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{AiTaskSnapshot, AiTextChunkSnapshot};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskProcedureResult {
pub ok: bool,
pub task: Option<AiTaskSnapshot>,
pub text_chunk: Option<AiTextChunkSnapshot>,
pub error_message: Option<String>,
}

View File

@@ -0,0 +1,250 @@
use shared_kernel::normalize_required_string;
use crate::commands::validate_task_create_input;
use crate::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCreateInput,
AiTaskFieldError, AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStageSnapshot,
AiTaskStageStatus, AiTaskStatus, AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION,
generate_ai_result_ref_id, generate_ai_text_chunk_id, normalize_optional_text,
normalize_string_list,
};
use super::{InMemoryAiTaskStore, ensure_task_is_not_terminal};
#[derive(Clone, Debug)]
pub struct AiTaskService {
store: InMemoryAiTaskStore,
}
impl AiTaskService {
pub fn new(store: InMemoryAiTaskStore) -> Self {
Self { store }
}
pub fn create_task(
&self,
input: AiTaskCreateInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?;
let snapshot = AiTaskSnapshot {
task_id: input.task_id.clone(),
task_kind: input.task_kind,
owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(),
request_label: normalize_required_string(input.request_label).unwrap_or_default(),
source_module: normalize_required_string(input.source_module).unwrap_or_default(),
source_entity_id: normalize_optional_text(input.source_entity_id),
request_payload_json: normalize_optional_text(input.request_payload_json),
status: AiTaskStatus::Pending,
failure_message: None,
stages: input
.stages
.into_iter()
.map(|stage| AiTaskStageSnapshot {
stage_kind: stage.stage_kind,
label: normalize_required_string(stage.label).unwrap_or_default(),
detail: normalize_required_string(stage.detail).unwrap_or_default(),
order: stage.order,
status: AiTaskStageStatus::Pending,
text_output: None,
structured_payload_json: None,
warning_messages: Vec::new(),
started_at_micros: None,
completed_at_micros: None,
})
.collect(),
result_references: Vec::new(),
latest_text_output: None,
latest_structured_payload_json: None,
version: INITIAL_AI_TASK_VERSION,
created_at_micros: input.created_at_micros,
started_at_micros: None,
completed_at_micros: None,
updated_at_micros: input.created_at_micros,
};
self.store.insert_task(snapshot)
}
pub fn start_task(
&self,
task_id: &str,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn start_stage(
&self,
task_id: &str,
stage_kind: AiTaskStageKind,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn append_text_chunk(
&self,
task_id: &str,
stage_kind: AiTaskStageKind,
sequence: u32,
delta_text: String,
created_at_micros: i64,
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> {
if delta_text.trim().is_empty() {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingChunkText,
));
}
if sequence == 0 {
return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence));
}
let chunk = AiTextChunkSnapshot {
chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence),
task_id: normalize_required_string(task_id).unwrap_or_default(),
stage_kind,
sequence,
delta_text: normalize_required_string(delta_text).unwrap_or_default(),
created_at_micros,
};
let task = self.store.append_text_chunk(chunk.clone())?;
Ok((task, chunk))
}
pub fn complete_stage(
&self,
input: AiStageCompletionInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(&input.task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == input.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Completed;
stage.completed_at_micros = Some(input.completed_at_micros);
stage.text_output = normalize_optional_text(input.text_output.clone());
stage.structured_payload_json =
normalize_optional_text(input.structured_payload_json.clone());
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
task.latest_text_output = stage.text_output.clone();
task.latest_structured_payload_json = stage.structured_payload_json.clone();
task.updated_at_micros = input.completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn attach_result_reference(
&self,
task_id: &str,
reference_kind: AiResultReferenceKind,
reference_id: String,
label: Option<String>,
created_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(reference_id) = normalize_required_string(reference_id) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingReferenceId,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.result_references.push(AiResultReferenceSnapshot {
result_ref_id: generate_ai_result_ref_id(created_at_micros),
task_id: task.task_id.clone(),
reference_kind,
reference_id: reference_id.clone(),
label: normalize_optional_text(label.clone()),
created_at_micros,
});
task.updated_at_micros = created_at_micros;
task.version += 1;
Ok(())
})
}
pub fn complete_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Completed;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn fail_task(
&self,
task_id: &str,
failure_message: String,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(failure_message) = normalize_required_string(failure_message) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingFailureMessage,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Failed;
task.failure_message = Some(failure_message.clone());
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn cancel_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Cancelled;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.get_task(task_id)
}
}

View File

@@ -0,0 +1,138 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use crate::{
AiTaskServiceError, AiTaskSnapshot, AiTaskStageStatus, AiTaskStatus, AiTextChunkSnapshot,
};
use super::ensure_task_is_not_terminal;
#[derive(Clone, Debug, Default)]
pub struct InMemoryAiTaskStore {
inner: Arc<Mutex<InMemoryAiTaskStoreState>>,
}
#[derive(Debug, Default)]
struct InMemoryAiTaskStoreState {
tasks: HashMap<String, AiTaskSnapshot>,
text_chunks: HashMap<String, Vec<AiTextChunkSnapshot>>,
}
impl InMemoryAiTaskStore {
pub(super) fn insert_task(
&self,
task: AiTaskSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
if state.tasks.contains_key(&task.task_id) {
return Err(AiTaskServiceError::TaskAlreadyExists);
}
state.text_chunks.insert(task.task_id.clone(), Vec::new());
state.tasks.insert(task.task_id.clone(), task.clone());
Ok(task)
}
pub(super) fn update_task<F>(
&self,
task_id: &str,
mut apply: F,
) -> Result<AiTaskSnapshot, AiTaskServiceError>
where
F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>,
{
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
let task = state
.tasks
.get_mut(task_id.trim())
.ok_or(AiTaskServiceError::TaskNotFound)?;
apply(task)?;
Ok(task.clone())
}
pub(super) fn append_text_chunk(
&self,
chunk: AiTextChunkSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
{
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
if stage.status == AiTaskStageStatus::Pending {
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros = Some(chunk.created_at_micros);
}
task.status = AiTaskStatus::Running;
task.started_at_micros
.get_or_insert(chunk.created_at_micros);
}
let chunks = state
.text_chunks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
chunks.push(chunk.clone());
chunks.sort_by_key(|value| value.sequence);
let aggregated_text = chunks
.iter()
.filter(|value| value.stage_kind == chunk.stage_kind)
.map(|value| value.delta_text.as_str())
.collect::<Vec<_>>()
.join("");
let normalized_output = if aggregated_text.trim().is_empty() {
None
} else {
Some(aggregated_text)
};
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.text_output = normalized_output.clone();
task.latest_text_output = normalized_output;
task.updated_at_micros = chunk.created_at_micros;
task.version += 1;
Ok(task.clone())
}
pub(super) fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
state
.tasks
.get(task_id.trim())
.cloned()
.ok_or(AiTaskServiceError::TaskNotFound)
}
}

View File

@@ -1,125 +1,9 @@
use std::collections::HashMap;
mod inputs;
mod validation;
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{
AiResultReferenceKind, AiTaskFieldError, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind,
pub use inputs::{
AiResultReferenceInput, AiStageCompletionInput, AiTaskCancelInput, AiTaskCreateInput,
AiTaskFailureInput, AiTaskFinishInput, AiTaskStageStartInput, AiTaskStartInput,
AiTextChunkAppendInput,
};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCreateInput {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub stages: Vec<AiTaskStageBlueprint>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStartInput {
pub task_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageStartInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkAppendInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiStageCompletionInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceInput {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFinishInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCancelInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFailureInput {
pub task_id: String,
pub failure_message: String,
pub completed_at_micros: i64,
}
pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> {
if normalize_required_string(&input.task_id).is_none() {
return Err(AiTaskFieldError::MissingTaskId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(AiTaskFieldError::MissingOwnerUserId);
}
if normalize_required_string(&input.request_label).is_none() {
return Err(AiTaskFieldError::MissingRequestLabel);
}
if normalize_required_string(&input.source_module).is_none() {
return Err(AiTaskFieldError::MissingSourceModule);
}
if input.stages.is_empty() {
return Err(AiTaskFieldError::MissingStageBlueprints);
}
let mut seen = HashMap::new();
for stage in &input.stages {
if normalize_required_string(&stage.label).is_none()
|| normalize_required_string(&stage.detail).is_none()
{
return Err(AiTaskFieldError::MissingStageBlueprints);
}
if seen.insert(stage.stage_kind, true).is_some() {
return Err(AiTaskFieldError::DuplicateStageBlueprint);
}
}
Ok(())
}
pub use validation::validate_task_create_input;

View File

@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{AiResultReferenceKind, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCreateInput {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub stages: Vec<AiTaskStageBlueprint>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStartInput {
pub task_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageStartInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkAppendInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiStageCompletionInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceInput {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFinishInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCancelInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFailureInput {
pub task_id: String,
pub failure_message: String,
pub completed_at_micros: i64,
}

View File

@@ -0,0 +1,40 @@
use std::collections::HashMap;
use shared_kernel::normalize_required_string;
use crate::{AiTaskFieldError, AiTaskStageKind};
use super::inputs::AiTaskCreateInput;
pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> {
if normalize_required_string(&input.task_id).is_none() {
return Err(AiTaskFieldError::MissingTaskId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(AiTaskFieldError::MissingOwnerUserId);
}
if normalize_required_string(&input.request_label).is_none() {
return Err(AiTaskFieldError::MissingRequestLabel);
}
if normalize_required_string(&input.source_module).is_none() {
return Err(AiTaskFieldError::MissingSourceModule);
}
if input.stages.is_empty() {
return Err(AiTaskFieldError::MissingStageBlueprints);
}
let mut seen: HashMap<AiTaskStageKind, bool> = HashMap::new();
for stage in &input.stages {
if normalize_required_string(&stage.label).is_none()
|| normalize_required_string(&stage.detail).is_none()
{
return Err(AiTaskFieldError::MissingStageBlueprints);
}
if seen.insert(stage.stage_kind, true).is_some() {
return Err(AiTaskFieldError::DuplicateStageBlueprint);
}
}
Ok(())
}

View File

@@ -1,239 +1,15 @@
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string,
normalize_string_list as normalize_shared_string_list,
mod ids;
mod stages;
mod types;
pub use ids::{
AI_RESULT_REF_ID_PREFIX, AI_TASK_ID_PREFIX, AI_TASK_STAGE_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX,
INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_id,
generate_ai_task_stage_id, generate_ai_text_chunk_id, normalize_optional_text,
normalize_string_list,
};
pub use types::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiTaskKind, AiTaskSnapshot,
AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStatus, AiTaskStatus,
AiTextChunkSnapshot,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const AI_TASK_ID_PREFIX: &str = "aitask_";
pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_";
pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_";
pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_";
pub const INITIAL_AI_TASK_VERSION: u32 = 1;
// AI 编排类型与当前正式运行时主链保持一致,具体 prompt 策略留给上层模块。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskKind {
StoryGeneration,
CharacterChat,
NpcChat,
CustomWorldGeneration,
QuestIntent,
RuntimeItemIntent,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AiTaskStageKind {
PreparePrompt,
RequestModel,
RepairResponse,
NormalizeResult,
PersistResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStageStatus {
Pending,
Running,
Completed,
Skipped,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiResultReferenceKind {
StorySession,
StoryEvent,
CustomWorldProfile,
QuestRecord,
RuntimeItemRecord,
AssetObject,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageBlueprint {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageSnapshot {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
pub status: AiTaskStageStatus,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskSnapshot {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub status: AiTaskStatus,
pub failure_message: Option<String>,
pub stages: Vec<AiTaskStageSnapshot>,
pub result_references: Vec<AiResultReferenceSnapshot>,
pub latest_text_output: Option<String>,
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at_micros: i64,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkSnapshot {
pub chunk_id: String,
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceSnapshot {
pub result_ref_id: String,
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}
impl AiTaskKind {
pub fn default_stage_blueprints(self) -> Vec<AiTaskStageBlueprint> {
let ordered_kinds = match self {
Self::StoryGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
],
Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => {
vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::NormalizeResult,
]
}
Self::CustomWorldGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
AiTaskStageKind::PersistResult,
],
};
ordered_kinds
.into_iter()
.enumerate()
.map(|(index, stage_kind)| AiTaskStageBlueprint {
stage_kind,
label: stage_kind.default_label().to_string(),
detail: stage_kind.default_detail().to_string(),
order: index as u32,
})
.collect()
}
}
impl AiTaskStageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::PreparePrompt => "prepare_prompt",
Self::RequestModel => "request_model",
Self::RepairResponse => "repair_response",
Self::NormalizeResult => "normalize_result",
Self::PersistResult => "persist_result",
}
}
pub fn default_label(self) -> &'static str {
match self {
Self::PreparePrompt => "整理提示词",
Self::RequestModel => "请求模型",
Self::RepairResponse => "修复响应",
Self::NormalizeResult => "归一结果",
Self::PersistResult => "回写结果",
}
}
pub fn default_detail(self) -> &'static str {
match self {
Self::PreparePrompt => "整理输入上下文并构建本轮提示词。",
Self::RequestModel => "向上游模型发起正式推理请求。",
Self::RepairResponse => "对非严格输出做补救修复或二次编排。",
Self::NormalizeResult => "把模型输出归一成模块可消费结构。",
Self::PersistResult => "把结果引用或聚合状态回写到下游模块。",
}
}
}
impl AiTaskStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
}
pub fn generate_ai_task_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros)
}
pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String {
format!(
"{}{}_{}",
AI_TASK_STAGE_ID_PREFIX,
task_id.trim(),
stage_kind.as_str()
)
}
pub fn generate_ai_result_ref_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros)
}
pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String {
format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX)
}
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}

View File

@@ -0,0 +1,41 @@
use shared_kernel::{
build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string,
normalize_string_list as normalize_shared_string_list,
};
use super::types::AiTaskStageKind;
pub const AI_TASK_ID_PREFIX: &str = "aitask_";
pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_";
pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_";
pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_";
pub const INITIAL_AI_TASK_VERSION: u32 = 1;
pub fn generate_ai_task_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros)
}
pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String {
format!(
"{}{}_{}",
AI_TASK_STAGE_ID_PREFIX,
task_id.trim(),
stage_kind.as_str()
)
}
pub fn generate_ai_result_ref_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros)
}
pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String {
format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX)
}
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}

View File

@@ -0,0 +1,77 @@
use super::types::{AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStatus};
impl AiTaskKind {
pub fn default_stage_blueprints(self) -> Vec<AiTaskStageBlueprint> {
let ordered_kinds = match self {
Self::StoryGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
],
Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => {
vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::NormalizeResult,
]
}
Self::CustomWorldGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
AiTaskStageKind::PersistResult,
],
};
ordered_kinds
.into_iter()
.enumerate()
.map(|(index, stage_kind)| AiTaskStageBlueprint {
stage_kind,
label: stage_kind.default_label().to_string(),
detail: stage_kind.default_detail().to_string(),
order: index as u32,
})
.collect()
}
}
impl AiTaskStageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::PreparePrompt => "prepare_prompt",
Self::RequestModel => "request_model",
Self::RepairResponse => "repair_response",
Self::NormalizeResult => "normalize_result",
Self::PersistResult => "persist_result",
}
}
pub fn default_label(self) -> &'static str {
match self {
Self::PreparePrompt => "整理提示词",
Self::RequestModel => "请求模型",
Self::RepairResponse => "修复响应",
Self::NormalizeResult => "归一结果",
Self::PersistResult => "回写结果",
}
}
pub fn default_detail(self) -> &'static str {
match self {
Self::PreparePrompt => "整理输入上下文并构建本轮提示词。",
Self::RequestModel => "向上游模型发起正式推理请求。",
Self::RepairResponse => "对非严格输出做补救修复或二次编排。",
Self::NormalizeResult => "把模型输出归一成模块可消费结构。",
Self::PersistResult => "把结果引用或聚合状态回写到下游模块。",
}
}
}
impl AiTaskStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
}

View File

@@ -0,0 +1,124 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
// AI 编排类型只表达领域意图,具体 prompt 策略留给业务模块和平台层。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskKind {
StoryGeneration,
CharacterChat,
NpcChat,
CustomWorldGeneration,
QuestIntent,
RuntimeItemIntent,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AiTaskStageKind {
PreparePrompt,
RequestModel,
RepairResponse,
NormalizeResult,
PersistResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStageStatus {
Pending,
Running,
Completed,
Skipped,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiResultReferenceKind {
StorySession,
StoryEvent,
CustomWorldProfile,
QuestRecord,
RuntimeItemRecord,
AssetObject,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageBlueprint {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageSnapshot {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
pub status: AiTaskStageStatus,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskSnapshot {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub status: AiTaskStatus,
pub failure_message: Option<String>,
pub stages: Vec<AiTaskStageSnapshot>,
pub result_references: Vec<AiResultReferenceSnapshot>,
pub latest_text_output: Option<String>,
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at_micros: i64,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkSnapshot {
pub chunk_id: String,
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceSnapshot {
pub result_ref_id: String,
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}

View File

@@ -22,225 +22,4 @@ pub use errors::{AiTaskFieldError, AiTaskServiceError};
pub use events::AiTaskDomainEvent;
#[cfg(test)]
mod tests {
use super::*;
fn build_service() -> AiTaskService {
AiTaskService::new(InMemoryAiTaskStore::default())
}
fn build_create_input(task_kind: AiTaskKind) -> AiTaskCreateInput {
AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_000),
task_kind,
owner_user_id: "user_001".to_string(),
request_label: "首轮故事生成".to_string(),
source_module: "story".to_string(),
source_entity_id: Some("storysess_001".to_string()),
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
stages: task_kind.default_stage_blueprints(),
created_at_micros: 1_713_680_000_000_000,
}
}
#[test]
fn default_stage_blueprints_match_story_baseline() {
let stages = AiTaskKind::StoryGeneration.default_stage_blueprints();
assert_eq!(stages.len(), 4);
assert_eq!(stages[0].stage_kind, AiTaskStageKind::PreparePrompt);
assert_eq!(stages[1].stage_kind, AiTaskStageKind::RequestModel);
assert_eq!(stages[2].stage_kind, AiTaskStageKind::RepairResponse);
assert_eq!(stages[3].stage_kind, AiTaskStageKind::NormalizeResult);
}
#[test]
fn create_task_rejects_duplicate_stage_blueprints() {
let mut input = build_create_input(AiTaskKind::StoryGeneration);
input.stages.push(AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::PreparePrompt,
label: "重复阶段".to_string(),
detail: "重复阶段".to_string(),
order: 99,
});
let error = validate_task_create_input(&input).expect_err("duplicate stages should fail");
assert_eq!(error, AiTaskFieldError::DuplicateStageBlueprint);
}
#[test]
fn generate_ai_task_stage_id_contains_task_and_stage_slug() {
let stage_id = generate_ai_task_stage_id("aitask_demo", AiTaskStageKind::NormalizeResult);
assert_eq!(stage_id, "aistage_aitask_demo_normalize_result");
}
#[test]
fn create_and_start_task_updates_status() {
let service = build_service();
let created = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let started = service
.start_task(&created.task_id, created.created_at_micros + 1)
.expect("task should start");
assert_eq!(created.status, AiTaskStatus::Pending);
assert_eq!(started.status, AiTaskStatus::Running);
assert_eq!(
started.started_at_micros,
Some(created.created_at_micros + 1)
);
assert_eq!(started.version, INITIAL_AI_TASK_VERSION + 1);
}
#[test]
fn append_text_chunk_aggregates_stream_output_by_stage() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CharacterChat))
.expect("task should create");
service
.start_stage(
&task.task_id,
AiTaskStageKind::RequestModel,
task.created_at_micros + 10,
)
.expect("stage should start");
let (after_first, _) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
1,
"".to_string(),
task.created_at_micros + 20,
)
.expect("first chunk should append");
let (after_second, second_chunk) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
2,
"好。".to_string(),
task.created_at_micros + 30,
)
.expect("second chunk should append");
assert_eq!(after_first.latest_text_output.as_deref(), Some(""));
assert_eq!(after_second.latest_text_output.as_deref(), Some("你好。"));
assert_eq!(second_chunk.sequence, 2);
}
#[test]
fn complete_stage_updates_latest_outputs() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::StoryGeneration))
.expect("task should create");
let completed = service
.complete_stage(AiStageCompletionInput {
task_id: task.task_id.clone(),
stage_kind: AiTaskStageKind::NormalizeResult,
text_output: Some("营地前的篝火重新亮了起来。".to_string()),
structured_payload_json: Some("{\"choices\":3}".to_string()),
warning_messages: vec!["使用了 fallback 选项池".to_string()],
completed_at_micros: task.created_at_micros + 50,
})
.expect("stage should complete");
let stage = completed
.stages
.iter()
.find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult)
.expect("normalize stage should exist");
assert_eq!(stage.status, AiTaskStageStatus::Completed);
assert_eq!(
completed.latest_text_output.as_deref(),
Some("营地前的篝火重新亮了起来。")
);
assert_eq!(
completed.latest_structured_payload_json.as_deref(),
Some("{\"choices\":3}")
);
assert_eq!(stage.warning_messages, vec!["使用了 fallback 选项池"]);
}
#[test]
fn attach_result_reference_appends_binding() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CustomWorldGeneration))
.expect("task should create");
let updated = service
.attach_result_reference(
&task.task_id,
AiResultReferenceKind::CustomWorldProfile,
"profile_001".to_string(),
Some("主世界档案".to_string()),
task.created_at_micros + 10,
)
.expect("reference should attach");
assert_eq!(updated.result_references.len(), 1);
assert_eq!(
updated.result_references[0].reference_kind,
AiResultReferenceKind::CustomWorldProfile
);
assert_eq!(updated.result_references[0].reference_id, "profile_001");
}
#[test]
fn fail_and_cancel_task_move_into_terminal_states() {
let service = build_service();
let first = service
.create_task(build_create_input(AiTaskKind::NpcChat))
.expect("task should create");
let failed = service
.fail_task(
&first.task_id,
"上游模型超时".to_string(),
first.created_at_micros + 10,
)
.expect("task should fail");
assert_eq!(failed.status, AiTaskStatus::Failed);
assert_eq!(failed.failure_message.as_deref(), Some("上游模型超时"));
let second = service
.create_task(AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_999),
..build_create_input(AiTaskKind::RuntimeItemIntent)
})
.expect("second task should create");
let cancelled = service
.cancel_task(&second.task_id, second.created_at_micros + 20)
.expect("task should cancel");
assert_eq!(cancelled.status, AiTaskStatus::Cancelled);
assert_eq!(
cancelled.completed_at_micros,
Some(second.created_at_micros + 20)
);
}
#[test]
fn complete_task_marks_terminal_success() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let completed = service
.complete_task(&task.task_id, task.created_at_micros + 100)
.expect("task should complete");
assert_eq!(completed.status, AiTaskStatus::Completed);
assert_eq!(
completed.completed_at_micros,
Some(task.created_at_micros + 100)
);
}
}
mod tests;

View File

@@ -0,0 +1,220 @@
use super::*;
fn build_service() -> AiTaskService {
AiTaskService::new(InMemoryAiTaskStore::default())
}
fn build_create_input(task_kind: AiTaskKind) -> AiTaskCreateInput {
AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_000),
task_kind,
owner_user_id: "user_001".to_string(),
request_label: "首轮故事生成".to_string(),
source_module: "story".to_string(),
source_entity_id: Some("storysess_001".to_string()),
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
stages: task_kind.default_stage_blueprints(),
created_at_micros: 1_713_680_000_000_000,
}
}
#[test]
fn default_stage_blueprints_match_story_baseline() {
let stages = AiTaskKind::StoryGeneration.default_stage_blueprints();
assert_eq!(stages.len(), 4);
assert_eq!(stages[0].stage_kind, AiTaskStageKind::PreparePrompt);
assert_eq!(stages[1].stage_kind, AiTaskStageKind::RequestModel);
assert_eq!(stages[2].stage_kind, AiTaskStageKind::RepairResponse);
assert_eq!(stages[3].stage_kind, AiTaskStageKind::NormalizeResult);
}
#[test]
fn create_task_rejects_duplicate_stage_blueprints() {
let mut input = build_create_input(AiTaskKind::StoryGeneration);
input.stages.push(AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::PreparePrompt,
label: "重复阶段".to_string(),
detail: "重复阶段".to_string(),
order: 99,
});
let error = validate_task_create_input(&input).expect_err("duplicate stages should fail");
assert_eq!(error, AiTaskFieldError::DuplicateStageBlueprint);
}
#[test]
fn generate_ai_task_stage_id_contains_task_and_stage_slug() {
let stage_id = generate_ai_task_stage_id("aitask_demo", AiTaskStageKind::NormalizeResult);
assert_eq!(stage_id, "aistage_aitask_demo_normalize_result");
}
#[test]
fn create_and_start_task_updates_status() {
let service = build_service();
let created = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let started = service
.start_task(&created.task_id, created.created_at_micros + 1)
.expect("task should start");
assert_eq!(created.status, AiTaskStatus::Pending);
assert_eq!(started.status, AiTaskStatus::Running);
assert_eq!(
started.started_at_micros,
Some(created.created_at_micros + 1)
);
assert_eq!(started.version, INITIAL_AI_TASK_VERSION + 1);
}
#[test]
fn append_text_chunk_aggregates_stream_output_by_stage() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CharacterChat))
.expect("task should create");
service
.start_stage(
&task.task_id,
AiTaskStageKind::RequestModel,
task.created_at_micros + 10,
)
.expect("stage should start");
let (after_first, _) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
1,
"".to_string(),
task.created_at_micros + 20,
)
.expect("first chunk should append");
let (after_second, second_chunk) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
2,
"好。".to_string(),
task.created_at_micros + 30,
)
.expect("second chunk should append");
assert_eq!(after_first.latest_text_output.as_deref(), Some(""));
assert_eq!(after_second.latest_text_output.as_deref(), Some("你好。"));
assert_eq!(second_chunk.sequence, 2);
}
#[test]
fn complete_stage_updates_latest_outputs() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::StoryGeneration))
.expect("task should create");
let completed = service
.complete_stage(AiStageCompletionInput {
task_id: task.task_id.clone(),
stage_kind: AiTaskStageKind::NormalizeResult,
text_output: Some("营地前的篝火重新亮了起来。".to_string()),
structured_payload_json: Some("{\"choices\":3}".to_string()),
warning_messages: vec!["使用了 fallback 选项池".to_string()],
completed_at_micros: task.created_at_micros + 50,
})
.expect("stage should complete");
let stage = completed
.stages
.iter()
.find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult)
.expect("normalize stage should exist");
assert_eq!(stage.status, AiTaskStageStatus::Completed);
assert_eq!(
completed.latest_text_output.as_deref(),
Some("营地前的篝火重新亮了起来。")
);
assert_eq!(
completed.latest_structured_payload_json.as_deref(),
Some("{\"choices\":3}")
);
assert_eq!(stage.warning_messages, vec!["使用了 fallback 选项池"]);
}
#[test]
fn attach_result_reference_appends_binding() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CustomWorldGeneration))
.expect("task should create");
let updated = service
.attach_result_reference(
&task.task_id,
AiResultReferenceKind::CustomWorldProfile,
"profile_001".to_string(),
Some("主世界档案".to_string()),
task.created_at_micros + 10,
)
.expect("reference should attach");
assert_eq!(updated.result_references.len(), 1);
assert_eq!(
updated.result_references[0].reference_kind,
AiResultReferenceKind::CustomWorldProfile
);
assert_eq!(updated.result_references[0].reference_id, "profile_001");
}
#[test]
fn fail_and_cancel_task_move_into_terminal_states() {
let service = build_service();
let first = service
.create_task(build_create_input(AiTaskKind::NpcChat))
.expect("task should create");
let failed = service
.fail_task(
&first.task_id,
"上游模型超时".to_string(),
first.created_at_micros + 10,
)
.expect("task should fail");
assert_eq!(failed.status, AiTaskStatus::Failed);
assert_eq!(failed.failure_message.as_deref(), Some("上游模型超时"));
let second = service
.create_task(AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_999),
..build_create_input(AiTaskKind::RuntimeItemIntent)
})
.expect("second task should create");
let cancelled = service
.cancel_task(&second.task_id, second.created_at_micros + 20)
.expect("task should cancel");
assert_eq!(cancelled.status, AiTaskStatus::Cancelled);
assert_eq!(
cancelled.completed_at_micros,
Some(second.created_at_micros + 20)
);
}
#[test]
fn complete_task_marks_terminal_success() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let completed = service
.complete_task(&task.task_id, task.created_at_micros + 100)
.expect("task should complete");
assert_eq!(completed.status, AiTaskStatus::Completed);
assert_eq!(
completed.completed_at_micros,
Some(task.created_at_micros + 100)
);
}

View File

@@ -25,12 +25,14 @@
- `assetobj_` ID 前缀与初始版本常量
- `asset_entity_binding` 输入、快照、返回记录与字段校验 helper
- `assetbind_` ID 前缀
5. `WP-AS Assets` 资产对象类型归位已完成,领域快照、命令 DTO、应用返回 DTO 和字段错误已分别落到 DDD 骨架文件中。
当前 `asset_object` 表的字段、索引与可编码约束见:
1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
3. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
4. [../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md)
当前还已补齐:

View File

@@ -1,8 +1,45 @@
//! 资产应用编排落位
//! 资产应用编排返回类型
//!
//! 这里只组合纯校验与应用结果;对象探测、签名和持久化由 adapter 层完成。
pub use crate::asset_object_core::{
AssetEntityBindingProcedureResult, AssetHistoryListResult, AssetObjectProcedureResult,
ConfirmAssetObjectResult, build_asset_entity_binding_input, build_asset_object_upsert_input,
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::{
AssetEntityBindingSnapshot, AssetHistoryEntrySnapshot, AssetObjectRecord,
AssetObjectUpsertSnapshot,
};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListResult {
pub ok: bool,
pub entries: Vec<AssetHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingProcedureResult {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
}
pub use crate::asset_object_core::{
build_asset_entity_binding_input, build_asset_object_upsert_input,
};

View File

@@ -1,223 +1,18 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_seed_id, format_timestamp_micros, normalize_optional_string,
normalize_required_string,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
// 资产对象访问策略先冻结为枚举,避免后续在 reducer、HTTP DTO 和脚本里散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetObjectFieldError {
MissingBucket,
MissingObjectKey,
MissingAssetKind,
MissingAssetObjectId,
MissingBindingId,
MissingEntityKind,
MissingEntityId,
MissingSlot,
InvalidVersion,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectInput {
pub bucket: Option<String>,
pub object_key: String,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_hash: Option<String>,
pub asset_kind: String,
pub access_policy: Option<AssetObjectAccessPolicy>,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListInput {
pub asset_kind: String,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListResult {
pub ok: bool,
pub entries: Vec<AssetHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingProcedureResult {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetObjectRecord {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetEntityBindingRecord {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
use crate::{
commands::{AssetEntityBindingInput, AssetObjectUpsertInput},
domain::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION,
},
errors::AssetObjectFieldError,
};
// 资产核心对象字段需要继续保留模块自己的错误语义,但基础必填字符串归一化统一走 shared-kernel。
fn normalize_required_asset_field(
@@ -420,24 +215,6 @@ pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_optional_string(value)
}
impl fmt::Display for AssetObjectFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
}
}
}
impl Error for AssetObjectFieldError {}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,7 +1,105 @@
//! 资产写入命令落位
//! 资产写入命令。
//!
//! 用于表达确认资产对象、绑定实体槽位和查询资产历史的输入,不直接访问 OSS。
pub use crate::asset_object_core::{
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput,
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::{
AssetEntityBindingSnapshot, AssetObjectAccessPolicy, AssetObjectUpsertSnapshot,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectInput {
pub bucket: Option<String>,
pub object_key: String,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_hash: Option<String>,
pub asset_kind: String,
pub access_policy: Option<AssetObjectAccessPolicy>,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListInput {
pub asset_kind: String,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
impl From<AssetObjectUpsertInput> for AssetObjectUpsertSnapshot {
fn from(value: AssetObjectUpsertInput) -> Self {
Self {
asset_object_id: value.asset_object_id,
bucket: value.bucket,
object_key: value.object_key,
access_policy: value.access_policy,
content_type: value.content_type,
content_length: value.content_length,
content_hash: value.content_hash,
version: value.version,
source_job_id: value.source_job_id,
owner_user_id: value.owner_user_id,
profile_id: value.profile_id,
entity_id: value.entity_id,
asset_kind: value.asset_kind,
created_at_micros: value.updated_at_micros,
updated_at_micros: value.updated_at_micros,
}
}
}
impl From<AssetEntityBindingInput> for AssetEntityBindingSnapshot {
fn from(value: AssetEntityBindingInput) -> Self {
Self {
binding_id: value.binding_id,
asset_object_id: value.asset_object_id,
entity_kind: value.entity_kind,
entity_id: value.entity_id,
slot: value.slot,
asset_kind: value.asset_kind,
owner_user_id: value.owner_user_id,
profile_id: value.profile_id,
created_at_micros: value.updated_at_micros,
updated_at_micros: value.updated_at_micros,
}
}
}

View File

@@ -1,15 +1,128 @@
//! 资产领域模型落位
//! 资产领域模型。
//!
//! 当前先通过本文件承接对外领域 API 分层导出,旧实现仍留在
//! `asset_object_core.rs` 内部文件中,后续再逐段搬入本文件或 `domain/` 子目录
//! 本层只允许保留资产对象、实体绑定、访问策略、版本和业务归属等纯规则。
//! 本层只保留资产对象、实体绑定、访问策略、版本和业务归属等纯领域事实。
//! OSS 对象探测、HTTP DTO 映射和 SpacetimeDB row 写入都属于外层 adapter
pub use crate::asset_object_core::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_record,
build_asset_history_entry_record, build_asset_object_record, generate_asset_binding_id,
generate_asset_object_id, normalize_optional_value, validate_asset_entity_binding_fields,
validate_asset_object_fields,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
// 资产对象访问策略先冻结为枚举,避免 reducer、HTTP DTO 和脚本里散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
/// SpacetimeDB 写入前的资产对象快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
/// 资产历史列表的领域快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
/// 业务实体与资产对象的绑定快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
/// 面向 API 与前端展示的资产对象记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetObjectRecord {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
pub updated_at: String,
}
/// 面向 API 与前端展示的资产历史记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 面向 API 与前端展示的实体绑定记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetEntityBindingRecord {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}

View File

@@ -1,5 +1,36 @@
//! 资产领域错误落位
//! 资产领域错误。
//!
//! 字段错误和业务错误在这里收口HTTP 状态码与 SpacetimeDB 字符串错误由 adapter 映射。
pub use crate::asset_object_core::AssetObjectFieldError;
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetObjectFieldError {
MissingBucket,
MissingObjectKey,
MissingAssetKind,
MissingAssetObjectId,
MissingBindingId,
MissingEntityKind,
MissingEntityId,
MissingSlot,
InvalidVersion,
}
impl fmt::Display for AssetObjectFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
}
}
}
impl Error for AssetObjectFieldError {}

View File

@@ -23,9 +23,12 @@ pub use domain::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_record,
build_asset_history_entry_record, build_asset_object_record, generate_asset_binding_id,
generate_asset_object_id, normalize_optional_value, validate_asset_entity_binding_fields,
validate_asset_object_fields,
INITIAL_ASSET_OBJECT_VERSION,
};
pub use errors::AssetObjectFieldError;
pub use asset_object_core::{
build_asset_entity_binding_record, build_asset_history_entry_record, build_asset_object_record,
generate_asset_binding_id, generate_asset_object_id, normalize_optional_value,
validate_asset_entity_binding_fields, validate_asset_object_fields,
};

View File

@@ -18,15 +18,18 @@
1. JWT claims 设计与 `platform-auth` 落地。
2. refresh cookie 读取适配。
3. `module-auth` 真实 crate 与首版密码登录用例落地。
4. 微信登录链路暂缓执行,不进入当前连续实现顺序
4. `WP-A Auth` DDD 分层收口,账号、会话、验证码、微信 state/绑定规则、命令输入、应用返回、领域错误和领域事件已归位到 `domain / commands / application / errors / events`
5. `api-server / platform-auth / spacetime-module` 认证边界已核查:真实短信、微信 OAuth、JWT、cookie 和密码哈希仍由平台层或 BFF 装配承接SpacetimeDB 侧只保留快照与表适配。
当前连续实现优先顺序固定为
当前已覆盖的鉴权用例
1. 密码登录
2. refresh token 轮换
3. `me` 查询
4. 会话吊销
5. 手机验证码登录
6. 微信登录 state 创建/消费
7. 微信身份解析与手机号绑定
## 3. 当前已冻结文档
@@ -44,6 +47,7 @@
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
14. [../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
15. [../../../docs/technical/SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md)
## 4. 边界约束
@@ -56,3 +60,4 @@
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
9. 当前手机号验证码真实 provider 由 `platform-auth` 注入,`module-auth` 只保留冷却、TTL、失败次数和账号编排不保存验证码明文。
10. 当前 `lib.rs` 仍保留进程内仓储和文件持久化支撑,但不再继续拥有命令、结果、错误、事件和纯领域值对象定义。

View File

@@ -1,3 +1,112 @@
//! 认证应用编排过渡落位
//! 认证应用返回类型
//!
//! 这里只返回纯应用结果与领域事件;短信 provider、JWT 签发和持久化由外层 adapter 完成。
use serde::{Deserialize, Serialize};
use crate::domain::{
AuthStoreSnapshotRecord, AuthUser, RefreshSessionRecord, WechatAuthStateRecord,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicUserSearchResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordResult {
pub user: AuthUser,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeResult {
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
pub provider_out_id: Option<String>,
pub provider: String,
pub scene: String,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginResult {
pub user: AuthUser,
pub created: bool,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumeWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionResult {
pub session: RefreshSessionRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionResult {
pub session: RefreshSessionRecord,
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListActiveRefreshSessionsResult {
pub sessions: Vec<RefreshSessionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotProcedureResult {
pub ok: bool,
pub record: Option<AuthStoreSnapshotRecord>,
pub error_message: Option<String>,
}

View File

@@ -1,3 +1,92 @@
//! 认证写入命令过渡落位
//! 认证写入命令。
//!
//! 用于表达密码入口、手机号验证码、微信登录、刷新会话签发和吊销等用例输入。
use serde::{Deserialize, Serialize};
use crate::domain::{
AuthLoginMethod, PhoneAuthScene, RefreshSessionClientInfo, WechatAuthScene,
WechatIdentityProfile,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub phone_number: String,
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordInput {
pub user_id: String,
pub current_password: Option<String>,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordInput {
pub phone_number: String,
pub verify_code: String,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,
pub scene: PhoneAuthScene,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginInput {
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginInput {
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateInput {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneInput {
pub user_id: String,
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionInput {
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionInput {
pub refresh_token_hash: String,
pub next_refresh_token_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionInput {
pub user_id: String,
pub refresh_token_hash: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsInput {
pub user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotUpsertInput {
pub snapshot_json: String,
pub updated_at_micros: i64,
}

View File

@@ -1,4 +1,252 @@
//! 认证领域模型过渡落位
//! 认证领域模型。
//!
//! 后续迁移账号、刷新会话、验证码和微信绑定聚合时,只保留认证规则;
//! 文件持久化、真实短信发送、cookie 写入和 HTTP 上下文都属于领域核心
//! 这里只保留账号、登录方式、绑定状态等纯领域事实。文件持久化、真实短信发送、
//! cookie 写入、JWT 签发和 HTTP 上下文都属于外层 adapter
use serde::{Deserialize, Serialize};
use crate::errors::{PasswordEntryError, PhoneAuthError};
pub const PASSWORD_MIN_LENGTH: usize = 6;
pub const PASSWORD_MAX_LENGTH: usize = 128;
pub const SMS_CODE_LENGTH: usize = 6;
pub const SMS_CODE_TTL_MINUTES: i64 = 5;
pub const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
pub const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
/// 用户最近一次完成认证的入口类型。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthLoginMethod {
Password,
Phone,
Wechat,
}
impl AuthLoginMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Phone => "phone",
Self::Wechat => "wechat",
}
}
}
/// 账号是否已经完成必要绑定。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthBindingStatus {
Active,
PendingBindPhone,
}
impl AuthBindingStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingBindPhone => "pending_bind_phone",
}
}
}
/// 认证用户快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthUser {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
}
/// 规范化后的手机号快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneNumberSnapshot {
pub e164: String,
pub masked_national_number: String,
}
/// 手机验证码使用场景。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthScene {
Login,
BindPhone,
ChangePhone,
ResetPassword,
}
impl PhoneAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::BindPhone => "bind_phone",
Self::ChangePhone => "change_phone",
Self::ResetPassword => "reset_password",
}
}
}
/// 微信授权入口场景。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene {
Desktop,
WechatInApp,
}
impl WechatAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Desktop => "desktop",
Self::WechatInApp => "wechat_in_app",
}
}
}
/// 微信身份资料快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityProfile {
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
/// 微信授权 state 快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatAuthStateRecord {
pub wechat_state_id: String,
pub state_token: String,
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// refresh session 的客户端环境快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionClientInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
/// refresh session 快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionRecord {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
/// Auth store 持久化快照记录。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotRecord {
pub snapshot_json: Option<String>,
pub updated_at_micros: Option<i64>,
}
pub fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
return Err(PasswordEntryError::InvalidPasswordLength);
}
Ok(())
}
pub fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
let verify_code = verify_code.trim();
if verify_code.len() != SMS_CODE_LENGTH
|| !verify_code
.chars()
.all(|character| character.is_ascii_digit())
{
return Err(PhoneAuthError::InvalidVerifyCode);
}
Ok(())
}
pub fn normalize_mainland_china_phone_number(
raw_phone_number: &str,
) -> Result<PhoneNumberSnapshot, PhoneAuthError> {
let digits = raw_phone_number
.trim()
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 11 || !digits.starts_with('1') {
return Err(PhoneAuthError::InvalidPhoneNumber);
}
Ok(PhoneNumberSnapshot {
e164: format!("+86{digits}"),
masked_national_number: mask_phone_number(&digits),
})
}
pub fn mask_phone_number(phone_number: &str) -> String {
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
}
pub fn build_national_phone_number(e164_phone_number: &str) -> Result<String, PhoneAuthError> {
let digits = e164_phone_number.trim().trim_start_matches('+');
if let Some(national) = digits.strip_prefix("86")
&& national.len() == 11
{
return Ok(national.to_string());
}
Err(PhoneAuthError::InvalidPhoneNumber)
}
pub fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开叙世号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
pub fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
pub fn normalize_public_user_code(input: &str) -> Result<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("SY").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return Err(PasswordEntryError::InvalidPublicUserCode);
}
Ok(format!("SY-{digits:0>8}"))
}
pub fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
format!("{}:{}", phone_number.trim(), scene.as_str())
}

View File

@@ -1,3 +1,179 @@
//! 认证领域错误过渡落位
//! 认证领域错误。
//!
//! 领域错误保持可测试、可映射,不能直接依赖 Axum、cookie 或平台 provider 错误模型。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
UserNotFound,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthError {
InvalidPhoneNumber,
InvalidVerifyCode,
VerifyCodeNotFound,
VerifyCodeExpired,
SendCoolingDown { retry_after_seconds: u64 },
VerifyAttemptsExceeded,
UserNotFound,
UserStateMismatch,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthError {
MissingProfile,
StateNotFound,
StateExpired,
StateConsumed,
UserNotFound,
MissingWechatIdentity,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshSessionError {
MissingToken,
SessionNotFound,
SessionExpired,
UserNotFound,
Store(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogoutError {
UserNotFound,
Store(String),
}
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PasswordEntryError {}
impl fmt::Display for PhoneAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidVerifyCode => f.write_str("验证码错误"),
Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"),
Self::VerifyCodeExpired => f.write_str("验证码已过期"),
Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"),
Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PhoneAuthError {}
impl fmt::Display for WechatAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfile => f.write_str("缺少微信身份信息"),
Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"),
Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"),
Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for WechatAuthError {}
impl fmt::Display for RefreshSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingToken => f.write_str("缺少刷新会话"),
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
f.write_str("当前登录态已失效,请重新登录")
}
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for RefreshSessionError {}
impl fmt::Display for LogoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for LogoutError {}
pub(crate) fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
}
}
}
pub(crate) fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError {
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
}
}
pub(crate) fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
}
pub(crate) fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
match error {
RefreshSessionError::Store(message) => LogoutError::Store(message),
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
}
}

View File

@@ -1,3 +1,27 @@
//! 认证领域事件过渡落位
//! 认证领域事件。
//!
//! 用于表达用户创建、会话签发/吊销、手机号验证通过和微信身份绑定等事实。
use crate::domain::AuthLoginMethod;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthDomainEvent {
UserCreated {
user_id: String,
login_method: AuthLoginMethod,
},
RefreshSessionIssued {
session_id: String,
user_id: String,
},
RefreshSessionRevoked {
session_id: String,
user_id: String,
},
PhoneVerified {
user_id: String,
},
WechatIdentityBound {
user_id: String,
},
}

View File

@@ -4,10 +4,15 @@ mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
use std::{
collections::HashMap,
error::Error,
fmt, fs,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
@@ -24,351 +29,6 @@ use shared_kernel::{
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
const PASSWORD_MIN_LENGTH: usize = 6;
const PASSWORD_MAX_LENGTH: usize = 128;
const SMS_CODE_LENGTH: usize = 6;
const SMS_CODE_TTL_MINUTES: i64 = 5;
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthLoginMethod {
Password,
Phone,
Wechat,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthBindingStatus {
Active,
PendingBindPhone,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthUser {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicUserSearchResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub phone_number: String,
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordInput {
pub user_id: String,
pub current_password: Option<String>,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordInput {
pub phone_number: String,
pub verify_code: String,
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordResult {
pub user: AuthUser,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthScene {
Login,
BindPhone,
ChangePhone,
ResetPassword,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneNumberSnapshot {
pub e164: String,
pub masked_national_number: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,
pub scene: PhoneAuthScene,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeResult {
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
pub provider_out_id: Option<String>,
pub provider: String,
pub scene: String,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginInput {
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginResult {
pub user: AuthUser,
pub created: bool,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityProfile {
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginInput {
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene {
Desktop,
WechatInApp,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateInput {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatAuthStateRecord {
pub wechat_state_id: String,
pub state_token: String,
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumeWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneInput {
pub user_id: String,
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionInput {
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionClientInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionRecord {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionResult {
pub session: RefreshSessionRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionInput {
pub refresh_token_hash: String,
pub next_refresh_token_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionResult {
pub session: RefreshSessionRecord,
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListActiveRefreshSessionsResult {
pub sessions: Vec<RefreshSessionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionInput {
pub user_id: String,
pub refresh_token_hash: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsInput {
pub user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotRecord {
pub snapshot_json: Option<String>,
pub updated_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotUpsertInput {
pub snapshot_json: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotProcedureResult {
pub ok: bool,
pub record: Option<AuthStoreSnapshotRecord>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
UserNotFound,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthError {
InvalidPhoneNumber,
InvalidVerifyCode,
VerifyCodeNotFound,
VerifyCodeExpired,
SendCoolingDown { retry_after_seconds: u64 },
VerifyAttemptsExceeded,
UserNotFound,
UserStateMismatch,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthError {
MissingProfile,
StateNotFound,
StateExpired,
StateConsumed,
UserNotFound,
MissingWechatIdentity,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshSessionError {
MissingToken,
SessionNotFound,
SessionExpired,
UserNotFound,
Store(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogoutError {
UserNotFound,
Store(String),
}
#[derive(Clone, Debug)]
pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>,
@@ -2126,137 +1786,6 @@ impl InMemoryAuthStore {
}
}
impl AuthLoginMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Phone => "phone",
Self::Wechat => "wechat",
}
}
}
impl AuthBindingStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingBindPhone => "pending_bind_phone",
}
}
}
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PasswordEntryError {}
impl fmt::Display for PhoneAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidVerifyCode => f.write_str("验证码错误"),
Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"),
Self::VerifyCodeExpired => f.write_str("验证码已过期"),
Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"),
Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PhoneAuthError {}
impl fmt::Display for WechatAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfile => f.write_str("缺少微信身份信息"),
Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"),
Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"),
Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for WechatAuthError {}
impl fmt::Display for RefreshSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingToken => f.write_str("缺少刷新会话"),
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
f.write_str("当前登录态已失效,请重新登录")
}
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for RefreshSessionError {}
impl fmt::Display for LogoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for LogoutError {}
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
}
}
}
fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError {
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
}
}
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
}
fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError {
match error {
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
@@ -2266,25 +1795,6 @@ fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthEr
}
}
fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
match error {
RefreshSessionError::Store(message) => LogoutError::Store(message),
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
}
}
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
return Err(PasswordEntryError::InvalidPasswordLength);
}
Ok(())
}
async fn verify_stored_password_user(
existing_user: StoredPasswordUser,
password: &str,
@@ -2309,51 +1819,6 @@ async fn verify_stored_password_user(
})
}
fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
let verify_code = verify_code.trim();
if verify_code.len() != SMS_CODE_LENGTH
|| !verify_code
.chars()
.all(|character| character.is_ascii_digit())
{
return Err(PhoneAuthError::InvalidVerifyCode);
}
Ok(())
}
fn normalize_mainland_china_phone_number(
raw_phone_number: &str,
) -> Result<PhoneNumberSnapshot, PhoneAuthError> {
let digits = raw_phone_number
.trim()
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 11 || !digits.starts_with('1') {
return Err(PhoneAuthError::InvalidPhoneNumber);
}
Ok(PhoneNumberSnapshot {
e164: format!("+86{digits}"),
masked_national_number: mask_phone_number(&digits),
})
}
fn mask_phone_number(phone_number: &str) -> String {
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
}
fn build_national_phone_number(e164_phone_number: &str) -> Result<String, PhoneAuthError> {
let digits = e164_phone_number.trim().trim_start_matches('+');
if let Some(national) = digits.strip_prefix("86")
&& national.len() == 11
{
return Ok(national.to_string());
}
Err(PhoneAuthError::InvalidPhoneNumber)
}
fn build_random_password_seed() -> String {
format!(
"seed_{}_{}",
@@ -2362,34 +1827,6 @@ fn build_random_password_seed() -> String {
)
}
fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开叙世号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
pub fn normalize_public_user_code(input: &str) -> Result<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("SY").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return Err(PasswordEntryError::InvalidPublicUserCode);
}
Ok(format!("SY-{digits:0>8}"))
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value)
}
@@ -2404,10 +1841,6 @@ fn seconds_until(now: OffsetDateTime, target: OffsetDateTime) -> u64 {
u64::try_from(seconds.max(1)).unwrap_or(1)
}
fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
format!("{}:{}", phone_number.trim(), scene.as_str())
}
fn create_wechat_state_token() -> String {
new_uuid_simple_string()
}
@@ -2428,26 +1861,6 @@ fn parse_rfc3339_with_context(
.map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}")))
}
impl PhoneAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::BindPhone => "bind_phone",
Self::ChangePhone => "change_phone",
Self::ResetPassword => "reset_password",
}
}
}
impl WechatAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Desktop => "desktop",
Self::WechatInApp => "wechat_in_app",
}
}
}
#[cfg(test)]
mod tests {
use platform_auth::{

View File

@@ -5,11 +5,29 @@
use shared_kernel::normalize_required_string;
use crate::{
BigFishAssetSlotSnapshot, build_asset_coverage,
commands::EvaluateBigFishPublishReadinessCommand, domain::BigFishPublishReadiness,
errors::BigFishApplicationError, events::BigFishDomainEvent,
BIG_FISH_DEFAULT_LEVEL_COUNT, BIG_FISH_TARGET_WILD_COUNT, BigFishAssetSlotSnapshot,
build_asset_coverage,
commands::{
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
},
domain::{
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
BigFishRuntimeSnapshot, BigFishVector2,
},
errors::BigFishApplicationError,
events::BigFishDomainEvent,
};
const VIEW_WIDTH: f32 = 720.0;
const VIEW_HEIGHT: f32 = 1280.0;
const WORLD_HALF_WIDTH: f32 = 1400.0;
const WORLD_HALF_HEIGHT: f32 = 2400.0;
const DEFAULT_WILD_COUNT: usize = 28;
const LEADER_SPEED: f32 = 210.0;
const FOLLOWER_SPEED: f32 = 170.0;
const WILD_SPEED: f32 = 74.0;
const TICK_SECONDS: f32 = 0.1;
/// 发布门禁应用结果,供 adapter 持久化快照或转换成 API DTO。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EvaluateBigFishPublishReadinessResult {
@@ -17,6 +35,13 @@ pub struct EvaluateBigFishPublishReadinessResult {
pub events: Vec<BigFishDomainEvent>,
}
/// 运行态推进应用结果。
#[derive(Clone, Debug, PartialEq)]
pub struct BigFishRuntimeResult {
pub snapshot: BigFishRuntimeSnapshot,
pub events: Vec<BigFishDomainEvent>,
}
/// 评估 Big Fish 作品是否具备发布条件。
///
/// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图
@@ -51,6 +76,508 @@ pub fn evaluate_publish_readiness(
})
}
/// 开始一局 Big Fish 运行态。
///
/// 领域层生成初始实体池adapter 只负责把快照序列化并写入运行表。
pub fn start_big_fish_run(
command: StartBigFishRunCommand,
) -> Result<BigFishRuntimeResult, BigFishApplicationError> {
let run_id =
normalize_required_string(command.run_id).ok_or(BigFishApplicationError::MissingRunId)?;
let session_id = normalize_required_string(command.session_id)
.ok_or(BigFishApplicationError::MissingSessionId)?;
let owner_user_id = normalize_required_string(command.owner_user_id)
.ok_or(BigFishApplicationError::MissingOwnerUserId)?;
let win_level = command
.draft
.as_ref()
.map(|draft| draft.runtime_params.win_level)
.or(command.work_level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT)
.clamp(1, 32);
let wild_count = command
.draft
.as_ref()
.map(|draft| draft.runtime_params.spawn_target_count as usize)
.unwrap_or(BIG_FISH_TARGET_WILD_COUNT)
.max(DEFAULT_WILD_COUNT);
let leader = build_entity("owned-1".to_string(), 1, 0.0, 0.0);
let mut wild_entities = vec![
build_entity("wild-open-1".to_string(), 1, 92.0, 0.0),
build_entity("wild-open-2".to_string(), 1, -118.0, 46.0),
];
while wild_entities.len() < wild_count {
wild_entities.push(build_wild_entity(
0,
wild_entities.len() as u64,
1,
win_level,
&leader.position,
));
}
let snapshot = BigFishRuntimeSnapshot {
run_id: run_id.clone(),
session_id: session_id.clone(),
status: BigFishRunStatus::Running,
tick: 0,
player_level: 1,
win_level,
leader_entity_id: Some(leader.entity_id.clone()),
owned_entities: vec![leader.clone()],
wild_entities,
camera_center: leader.position,
last_input: BigFishVector2 { x: 0.0, y: 0.0 },
event_log: vec!["开局生成同级可收编目标".to_string()],
updated_at_micros: command.started_at_micros,
};
Ok(BigFishRuntimeResult {
snapshot,
events: vec![BigFishDomainEvent::RuntimeRunStarted {
run_id,
session_id,
owner_user_id,
occurred_at_micros: command.started_at_micros,
}],
})
}
/// 根据最新输入推进一帧运行态。
///
/// 这里是 Big Fish 运行态真相源;前端只能提交输入并渲染返回快照。
pub fn submit_big_fish_input(
command: SubmitBigFishInputCommand,
) -> Result<BigFishRuntimeResult, BigFishApplicationError> {
let owner_user_id = normalize_required_string(command.owner_user_id)
.ok_or(BigFishApplicationError::MissingOwnerUserId)?;
if !command.x.is_finite() || !command.y.is_finite() {
return Err(BigFishApplicationError::InvalidRuntimeInput);
}
let mut snapshot = command.current_snapshot;
if snapshot.status != BigFishRunStatus::Running {
return Ok(BigFishRuntimeResult {
snapshot,
events: Vec::new(),
});
}
let next_tick = snapshot.tick.saturating_add(1);
let normalized_input = normalize_vector(command.x, command.y);
let mut sorted_owned = refresh_leader(std::mem::take(&mut snapshot.owned_entities));
let Some(current_leader) = sorted_owned.first().cloned() else {
snapshot.status = BigFishRunStatus::Failed;
snapshot.event_log = tail_events(vec!["己方实体归零,本局失败".to_string()]);
snapshot.updated_at_micros = command.submitted_at_micros;
return Ok(BigFishRuntimeResult {
events: settlement_events(&snapshot, owner_user_id, command.submitted_at_micros),
snapshot,
});
};
let next_leader = move_leader(&current_leader, &normalized_input);
let mut owned_entities = vec![next_leader.clone()];
for (index, follower) in sorted_owned.drain(1..).enumerate() {
owned_entities.push(move_follower(&follower, &next_leader, index + 1));
}
let mut wild_entities = snapshot
.wild_entities
.into_iter()
.map(|entity| move_wild_entity(&entity, next_tick))
.collect::<Vec<_>>();
let mut events = snapshot.event_log;
let mut removed_wild = Vec::<String>::new();
let mut removed_owned = Vec::<String>::new();
let mut newly_owned = Vec::<BigFishRuntimeEntitySnapshot>::new();
for owned in &owned_entities {
if removed_owned.contains(&owned.entity_id) {
continue;
}
for wild in &wild_entities {
if removed_wild.contains(&wild.entity_id) {
continue;
}
if distance(owned, wild) > owned.radius + wild.radius {
continue;
}
if owned.level >= wild.level {
removed_wild.push(wild.entity_id.clone());
newly_owned.push(build_entity(
format!("owned-from-{}-{next_tick}", wild.entity_id),
wild.level,
wild.position.x,
wild.position.y,
));
events.push(format!("收编 {} 级实体", wild.level));
} else {
removed_owned.push(owned.entity_id.clone());
events.push(format!(
"{} 级己方实体被 {} 级野生实体吃掉",
owned.level, wild.level
));
}
}
}
owned_entities.retain(|entity| !removed_owned.contains(&entity.entity_id));
owned_entities.extend(newly_owned);
wild_entities.retain(|entity| !removed_wild.contains(&entity.entity_id));
let merge_result = merge_owned_entities(owned_entities, next_tick);
owned_entities = refresh_leader(merge_result.owned_entities);
events.extend(merge_result.events);
let player_level = owned_entities
.iter()
.map(|entity| entity.level)
.max()
.unwrap_or(0);
let leader = owned_entities.first().cloned();
let camera_center = leader
.as_ref()
.map(|entity| entity.position.clone())
.unwrap_or(snapshot.camera_center);
wild_entities = wild_entities
.into_iter()
.filter_map(|entity| {
let should_cull = entity.level == player_level
|| entity.level >= player_level.saturating_add(3)
|| entity.level.saturating_add(3) <= player_level;
let offscreen_seconds = if should_cull && is_offscreen(&entity, &camera_center) {
entity.offscreen_seconds + TICK_SECONDS
} else {
0.0
};
(offscreen_seconds < 3.0).then_some(BigFishRuntimeEntitySnapshot {
offscreen_seconds,
..entity
})
})
.collect();
while wild_entities.len() < DEFAULT_WILD_COUNT {
wild_entities.push(build_wild_entity(
next_tick,
wild_entities.len() as u64 + next_tick,
player_level.max(1),
snapshot.win_level,
&camera_center,
));
}
let status = if owned_entities.is_empty() {
events.push("己方实体归零,本局失败".to_string());
BigFishRunStatus::Failed
} else if player_level >= snapshot.win_level {
events.push("获得最高等级实体,通关".to_string());
BigFishRunStatus::Won
} else {
BigFishRunStatus::Running
};
let next_snapshot = BigFishRuntimeSnapshot {
run_id: snapshot.run_id,
session_id: snapshot.session_id,
status,
tick: next_tick,
player_level,
win_level: snapshot.win_level,
leader_entity_id: leader.map(|entity| entity.entity_id),
owned_entities,
wild_entities,
camera_center,
last_input: normalized_input,
event_log: tail_events(events),
updated_at_micros: command.submitted_at_micros,
};
let events = settlement_events(&next_snapshot, owner_user_id, command.submitted_at_micros);
Ok(BigFishRuntimeResult {
snapshot: next_snapshot,
events,
})
}
pub fn serialize_runtime_snapshot(
snapshot: &BigFishRuntimeSnapshot,
) -> Result<String, serde_json::Error> {
serde_json::to_string(snapshot)
}
pub fn deserialize_runtime_snapshot(
value: &str,
) -> Result<BigFishRuntimeSnapshot, serde_json::Error> {
serde_json::from_str(value)
}
fn build_entity(entity_id: String, level: u32, x: f32, y: f32) -> BigFishRuntimeEntitySnapshot {
BigFishRuntimeEntitySnapshot {
entity_id,
level,
position: BigFishVector2 { x, y },
radius: entity_radius(level),
offscreen_seconds: 0.0,
}
}
fn entity_radius(level: u32) -> f32 {
18.0 + level as f32 * 4.0
}
fn normalize_vector(x: f32, y: f32) -> BigFishVector2 {
let length = (x * x + y * y).sqrt();
if length <= 0.001 {
return BigFishVector2 { x: 0.0, y: 0.0 };
}
let capped = length.min(1.0);
BigFishVector2 {
x: (x / length) * capped,
y: (y / length) * capped,
}
}
fn distance(first: &BigFishRuntimeEntitySnapshot, second: &BigFishRuntimeEntitySnapshot) -> f32 {
let x = first.position.x - second.position.x;
let y = first.position.y - second.position.y;
(x * x + y * y).sqrt()
}
fn clamp(value: f32, min: f32, max: f32) -> f32 {
value.max(min).min(max)
}
fn spawn_level(player_level: u32, win_level: u32, index: u64) -> u32 {
if player_level <= 1 && index % 4 < 2 {
return 1;
}
let deltas = [-2_i32, -1, 1, 2];
let delta = deltas[(index as usize) % deltas.len()];
(player_level as i32 + delta).clamp(1, win_level as i32) as u32
}
fn spawn_position(center: &BigFishVector2, index: u64) -> BigFishVector2 {
let side = index % 4;
let offset = ((index * 97) % 980) as f32 - 490.0;
match side {
0 => BigFishVector2 {
x: center.x - VIEW_WIDTH * 0.72,
y: center.y + offset,
},
1 => BigFishVector2 {
x: center.x + VIEW_WIDTH * 0.72,
y: center.y + offset,
},
2 => BigFishVector2 {
x: center.x + offset,
y: center.y - VIEW_HEIGHT * 0.64,
},
_ => BigFishVector2 {
x: center.x + offset,
y: center.y + VIEW_HEIGHT * 0.64,
},
}
}
fn build_wild_entity(
tick: u64,
index: u64,
player_level: u32,
win_level: u32,
center: &BigFishVector2,
) -> BigFishRuntimeEntitySnapshot {
let level = spawn_level(player_level, win_level, index);
let position = spawn_position(center, index);
build_entity(
format!("wild-{tick}-{index}"),
level,
position.x,
position.y,
)
}
fn move_leader(
leader: &BigFishRuntimeEntitySnapshot,
input: &BigFishVector2,
) -> BigFishRuntimeEntitySnapshot {
BigFishRuntimeEntitySnapshot {
position: BigFishVector2 {
x: clamp(
leader.position.x + input.x * LEADER_SPEED * TICK_SECONDS,
-WORLD_HALF_WIDTH,
WORLD_HALF_WIDTH,
),
y: clamp(
leader.position.y + input.y * LEADER_SPEED * TICK_SECONDS,
-WORLD_HALF_HEIGHT,
WORLD_HALF_HEIGHT,
),
},
..leader.clone()
}
}
fn move_follower(
follower: &BigFishRuntimeEntitySnapshot,
leader: &BigFishRuntimeEntitySnapshot,
index: usize,
) -> BigFishRuntimeEntitySnapshot {
let slot_y = (index as f32 * 0.7).sin() * 42.0;
let target = BigFishVector2 {
x: leader.position.x - 52.0 - index as f32 * 10.0,
y: leader.position.y + slot_y,
};
let delta_x = target.x - follower.position.x;
let delta_y = target.y - follower.position.y;
let direction = normalize_vector(delta_x, delta_y);
let step = (FOLLOWER_SPEED * TICK_SECONDS).min((delta_x * delta_x + delta_y * delta_y).sqrt());
BigFishRuntimeEntitySnapshot {
position: BigFishVector2 {
x: follower.position.x + direction.x * step,
y: follower.position.y + direction.y * step,
},
..follower.clone()
}
}
fn move_wild_entity(
entity: &BigFishRuntimeEntitySnapshot,
tick: u64,
) -> BigFishRuntimeEntitySnapshot {
let phase =
tick as f32 * 0.23 + entity.level as f32 * 0.91 + entity.entity_id.len() as f32 * 0.13;
BigFishRuntimeEntitySnapshot {
position: BigFishVector2 {
x: clamp(
entity.position.x
+ phase.cos() * (WILD_SPEED + entity.level as f32 * 3.0) * TICK_SECONDS,
-WORLD_HALF_WIDTH,
WORLD_HALF_WIDTH,
),
y: clamp(
entity.position.y
+ (phase * 0.72).sin()
* (WILD_SPEED + entity.level as f32 * 3.0)
* TICK_SECONDS,
-WORLD_HALF_HEIGHT,
WORLD_HALF_HEIGHT,
),
},
..entity.clone()
}
}
#[derive(Clone, Debug)]
struct MergeOwnedEntitiesResult {
owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
events: Vec<String>,
}
fn merge_owned_entities(
mut owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
tick: u64,
) -> MergeOwnedEntitiesResult {
let mut events = Vec::new();
let mut changed = true;
while changed {
changed = false;
for level in 1..32 {
let same_level = owned_entities
.iter()
.enumerate()
.filter(|(_, entity)| entity.level == level)
.take(3)
.map(|(index, entity)| (index, entity.clone()))
.collect::<Vec<_>>();
if same_level.len() < 3 {
continue;
}
let center =
same_level
.iter()
.fold(BigFishVector2 { x: 0.0, y: 0.0 }, |acc, (_, entity)| {
BigFishVector2 {
x: acc.x + entity.position.x / 3.0,
y: acc.y + entity.position.y / 3.0,
}
});
let remove_indices = same_level
.iter()
.map(|(index, _)| *index)
.collect::<Vec<_>>();
owned_entities = owned_entities
.into_iter()
.enumerate()
.filter_map(|(index, entity)| (!remove_indices.contains(&index)).then_some(entity))
.collect();
owned_entities.push(build_entity(
format!("owned-merge-{}-{tick}", level + 1),
level + 1,
center.x,
center.y,
));
events.push(format!("3 个 {level} 级实体合成 {}", level + 1));
changed = true;
break;
}
}
MergeOwnedEntitiesResult {
owned_entities,
events,
}
}
fn is_offscreen(entity: &BigFishRuntimeEntitySnapshot, camera_center: &BigFishVector2) -> bool {
entity.position.x + entity.radius < camera_center.x - VIEW_WIDTH / 2.0
|| entity.position.x - entity.radius > camera_center.x + VIEW_WIDTH / 2.0
|| entity.position.y + entity.radius < camera_center.y - VIEW_HEIGHT / 2.0
|| entity.position.y - entity.radius > camera_center.y + VIEW_HEIGHT / 2.0
}
fn refresh_leader(
mut owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
) -> Vec<BigFishRuntimeEntitySnapshot> {
owned_entities.sort_by(|left, right| {
right
.level
.cmp(&left.level)
.then_with(|| left.entity_id.cmp(&right.entity_id))
});
owned_entities
}
fn tail_events(events: Vec<String>) -> Vec<String> {
events
.into_iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
fn settlement_events(
snapshot: &BigFishRuntimeSnapshot,
owner_user_id: String,
occurred_at_micros: i64,
) -> Vec<BigFishDomainEvent> {
if snapshot.status == BigFishRunStatus::Running {
return Vec::new();
}
vec![BigFishDomainEvent::RuntimeRunSettled {
run_id: snapshot.run_id.clone(),
session_id: snapshot.session_id.clone(),
owner_user_id,
status: snapshot.status.as_str().to_string(),
occurred_at_micros,
}]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -133,4 +660,63 @@ mod tests {
assert!(result.readiness.publish_ready);
assert!(result.readiness.blockers.is_empty());
}
#[test]
fn start_big_fish_run_builds_server_owned_initial_snapshot() {
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
let result = start_big_fish_run(StartBigFishRunCommand {
run_id: "big-fish-run-1".to_string(),
session_id: "big-fish-session-1".to_string(),
owner_user_id: "user-1".to_string(),
draft: Some(draft),
work_level_count: None,
started_at_micros: 1,
})
.expect("run");
assert_eq!(result.snapshot.status, BigFishRunStatus::Running);
assert_eq!(result.snapshot.player_level, 1);
assert_eq!(result.snapshot.win_level, 8);
assert!(!result.snapshot.wild_entities.is_empty());
assert_eq!(result.events.len(), 1);
}
#[test]
fn submit_big_fish_input_advances_and_keeps_runtime_truth_in_domain() {
let mut result = start_big_fish_run(StartBigFishRunCommand {
run_id: "big-fish-run-2".to_string(),
session_id: "big-fish-session-2".to_string(),
owner_user_id: "user-1".to_string(),
draft: None,
work_level_count: Some(3),
started_at_micros: 1,
})
.expect("run");
result.snapshot.wild_entities = vec![BigFishRuntimeEntitySnapshot {
entity_id: "wild-touching".to_string(),
level: 1,
position: BigFishVector2 { x: 10.0, y: 0.0 },
radius: 22.0,
offscreen_seconds: 0.0,
}];
let advanced = submit_big_fish_input(SubmitBigFishInputCommand {
owner_user_id: "user-1".to_string(),
x: 0.0,
y: 0.0,
submitted_at_micros: 2,
current_snapshot: result.snapshot,
})
.expect("advanced");
assert_eq!(advanced.snapshot.tick, 1);
assert!(advanced.snapshot.owned_entities.len() >= 2);
assert!(
advanced
.snapshot
.event_log
.iter()
.any(|event| event.contains("收编"))
);
}
}

View File

@@ -2,7 +2,7 @@
//!
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
use crate::BigFishGameDraft;
use crate::{BigFishGameDraft, domain::BigFishRuntimeSnapshot};
/// 评估作品是否可以发布的纯领域命令。
///
@@ -15,3 +15,24 @@ pub struct EvaluateBigFishPublishReadinessCommand {
pub draft: Option<BigFishGameDraft>,
pub evaluated_at_micros: i64,
}
/// 开始一局 Big Fish 运行态的纯领域命令。
#[derive(Clone, Debug, PartialEq)]
pub struct StartBigFishRunCommand {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub draft: Option<BigFishGameDraft>,
pub work_level_count: Option<u32>,
pub started_at_micros: i64,
}
/// 提交方向输入并推进一帧的纯领域命令。
#[derive(Clone, Debug, PartialEq)]
pub struct SubmitBigFishInputCommand {
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
pub current_snapshot: BigFishRuntimeSnapshot,
}

View File

@@ -3,6 +3,10 @@
//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则;
//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 发布门禁的领域判定结果。
///
/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。
@@ -14,3 +18,62 @@ pub struct BigFishPublishReadiness {
pub blockers: Vec<String>,
pub evaluated_at_micros: i64,
}
/// 运行态一局的状态。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishRunStatus {
Running,
Won,
Failed,
}
/// 运行态二维坐标。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishVector2 {
pub x: f32,
pub y: f32,
}
/// 运行态实体快照。
///
/// 只表达服务端结算后的事实,前端不能据此反推规则并本地裁决。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeEntitySnapshot {
pub entity_id: String,
pub level: u32,
pub position: BigFishVector2,
pub radius: f32,
pub offscreen_seconds: f32,
}
/// 运行态一局快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeSnapshot {
pub run_id: String,
pub session_id: String,
pub status: BigFishRunStatus,
pub tick: u64,
pub player_level: u32,
pub win_level: u32,
pub leader_entity_id: Option<String>,
pub owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
pub wild_entities: Vec<BigFishRuntimeEntitySnapshot>,
pub camera_center: BigFishVector2,
pub last_input: BigFishVector2,
pub event_log: Vec<String>,
pub updated_at_micros: i64,
}
impl BigFishRunStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Running => "running",
Self::Won => "won",
Self::Failed => "failed",
}
}
}

View File

@@ -11,6 +11,8 @@ use std::{error::Error, fmt};
pub enum BigFishApplicationError {
MissingSessionId,
MissingOwnerUserId,
MissingRunId,
InvalidRuntimeInput,
}
impl fmt::Display for BigFishApplicationError {
@@ -18,6 +20,8 @@ impl fmt::Display for BigFishApplicationError {
match self {
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}

View File

@@ -15,4 +15,17 @@ pub enum BigFishDomainEvent {
blockers: Vec<String>,
occurred_at_micros: i64,
},
RuntimeRunStarted {
run_id: String,
session_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RuntimeRunSettled {
run_id: String,
session_id: String,
owner_user_id: String,
status: String,
occurred_at_micros: i64,
},
}

View File

@@ -4,9 +4,18 @@ mod domain;
mod errors;
mod events;
pub use application::{EvaluateBigFishPublishReadinessResult, evaluate_publish_readiness};
pub use commands::EvaluateBigFishPublishReadinessCommand;
pub use domain::BigFishPublishReadiness;
pub use application::{
BigFishRuntimeResult, EvaluateBigFishPublishReadinessResult, deserialize_runtime_snapshot,
evaluate_publish_readiness, serialize_runtime_snapshot, start_big_fish_run,
submit_big_fish_input,
};
pub use commands::{
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
};
pub use domain::{
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
BigFishRuntimeSnapshot, BigFishVector2,
};
pub use errors::BigFishApplicationError;
pub use events::BigFishDomainEvent;
@@ -343,6 +352,40 @@ pub struct BigFishPlayRecordInput {
pub played_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunStartInput {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishInputSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError {
MissingSessionId,
@@ -352,6 +395,8 @@ pub enum BigFishFieldError {
MissingDraft,
InvalidLevel,
InvalidAssetKind,
MissingRunId,
InvalidRuntimeInput,
}
impl BigFishCreationStage {
@@ -691,6 +736,39 @@ pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(),
Ok(())
}
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
Ok(())
}
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_input_submit_input(
input: &BigFishInputSubmitInput,
) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.run_id).is_none() {
return Err(BigFishFieldError::MissingRunId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
if !input.x.is_finite() || !input.y.is_finite() {
return Err(BigFishFieldError::InvalidRuntimeInput);
}
Ok(())
}
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
serde_json::to_string(anchor_pack)
}
@@ -903,6 +981,8 @@ impl fmt::Display for BigFishFieldError {
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}

View File

@@ -6,7 +6,7 @@ license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
spacetime-types = ["dep:spacetimedb", "module-runtime-item/spacetime-types"]
[dependencies]
module-runtime-item = { path = "../module-runtime-item", default-features = false }

View File

@@ -15,7 +15,7 @@
当前已经真实落地:
1. `BattleMode / BattleStatus / CombatOutcome`
1. `src/domain.rs` 承接战斗 ID 前缀、版本、伤害、切磋保底生命、旧攻击 function 列表和 `BattleMode / BattleStatus / CombatOutcome`
2. `BattleStateInput / BattleStateSnapshot / BattleStateQueryInput`
3. `ResolveCombatActionInput / ResolveCombatActionResult`
4. `BattleStateProcedureResult / ResolveCombatActionProcedureResult`
@@ -34,11 +34,12 @@
落地依据见:
1. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
2. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md)
4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
5. [../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md)
1. [../../../docs/technical/SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
4. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md)
5. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
6. [../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md)
## 4. 边界约束

View File

@@ -2,3 +2,84 @@
//!
//! 后续迁移 `BattleState` 与行动结算规则时,只保留单聚合内部状态变化;
//! 背包奖励、成长记账和任务联动由应用服务或 SpacetimeDB 事务 adapter 编排。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 战斗状态 ID 的稳定前缀,由领域层统一持有,避免应用层重复拼接规则。
pub const BATTLE_STATE_ID_PREFIX: &str = "battle_";
/// 新建战斗状态的初始版本号,用于乐观更新和快照投影。
pub const INITIAL_BATTLE_VERSION: u32 = 1;
/// 普通战斗中敌方反击伤害占玩家输出的比例。
pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14;
/// 普通战斗中敌方反击的最低伤害,保证战斗有稳定消耗。
pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4;
/// 切磋模式保底生命值,避免非生死战把玩家扣到 0。
pub const SPAR_MIN_HP: i32 = 1;
/// 旧版战斗动作 function id 白名单,仍由结算规则用于识别攻击类动作。
pub(crate) const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [
"battle_all_in_crush",
"battle_guard_break",
"battle_probe_pressure",
"battle_feint_step",
"battle_finisher_window",
];
/// 战斗模式,决定结算时是否允许击败玩家。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleMode {
Fight,
Spar,
}
impl BattleMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fight => "fight",
Self::Spar => "spar",
}
}
}
/// 战斗状态,用于标记战斗是否仍可继续接收行动。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleStatus {
Ongoing,
Resolved,
Aborted,
}
impl BattleStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Resolved => "resolved",
Self::Aborted => "aborted",
}
}
}
/// 单次战斗行动结算后的领域结果。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatOutcome {
Ongoing,
Victory,
SparComplete,
Escaped,
}
impl CombatOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Victory => "victory",
Self::SparComplete => "spar_complete",
Self::Escaped => "escaped",
}
}
}

View File

@@ -4,8 +4,11 @@ mod domain;
mod errors;
mod events;
pub use domain::*;
use std::{error::Error, fmt};
use crate::domain::LEGACY_ATTACK_FUNCTION_IDS;
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
@@ -14,74 +17,6 @@ use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const BATTLE_STATE_ID_PREFIX: &str = "battle_";
pub const INITIAL_BATTLE_VERSION: u32 = 1;
pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14;
pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4;
pub const SPAR_MIN_HP: i32 = 1;
const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [
"battle_all_in_crush",
"battle_guard_break",
"battle_probe_pressure",
"battle_feint_step",
"battle_finisher_window",
];
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleMode {
Fight,
Spar,
}
impl BattleMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fight => "fight",
Self::Spar => "spar",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleStatus {
Ongoing,
Resolved,
Aborted,
}
impl BattleStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Resolved => "resolved",
Self::Aborted => "aborted",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatOutcome {
Ongoing,
Victory,
SparComplete,
Escaped,
}
impl CombatOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Victory => "victory",
Self::SparComplete => "spar_complete",
Self::Escaped => "escaped",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CombatFieldError {
MissingBattleStateId,

View File

@@ -19,7 +19,7 @@
当前已落地:
1. 真实 `Cargo.toml` crate scaffold
2. `CustomWorldPublicationStatus``CustomWorldThemeMode``CustomWorldGenerationMode`
2. `src/domain.rs` 承接 `CustomWorldPublicationStatus``CustomWorldThemeMode``CustomWorldGenerationMode`
3. `CustomWorldSessionStatus``RpgAgentStage`
4. `RpgAgentMessageRole``RpgAgentMessageKind`
5. `RpgAgentOperationType``RpgAgentOperationStatus`
@@ -31,7 +31,7 @@
当前 crate 仍然只承接:
1. 共享枚举与类型口径
1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出
2. 字段校验与字符串归一化
3. published profile compile 的最小编译摘要 contract
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
@@ -45,10 +45,11 @@
当前设计依据:
1. [../../../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)
2. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_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)
1. [../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../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)
3. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
4. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)
5. [../../../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)
后续与本 package 直接相关的任务包括:

View File

@@ -2,3 +2,305 @@
//!
//! 后续迁移 profile、Agent 会话、草稿卡、发布门禁和画廊投影规则时,
//! 只保留纯领域结构LLM 推理、SSE 和 OSS 均留在外层 adapter。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const MAX_PROGRESS_PERCENT: u32 = 100;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldPublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldThemeMode {
Martial,
Arcane,
Machina,
Tide,
Rift,
Mythic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldGenerationMode {
Fast,
Full,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldSessionStatus {
Clarifying,
ReadyToGenerate,
Generating,
Completed,
GenerationError,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentStage {
CollectingIntent,
Clarifying,
FoundationReview,
ObjectRefining,
VisualRefining,
LongTailReview,
ReadyToPublish,
Published,
Error,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageKind {
Chat,
Clarification,
Summary,
Checkpoint,
Warning,
ActionResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationType {
ProcessMessage,
DraftFoundation,
UpdateDraftCard,
SyncResultProfile,
GenerateCharacters,
GenerateLandmarks,
DeleteCharacters,
DeleteLandmarks,
GenerateRoleAssets,
SyncRoleAssets,
GenerateSceneAssets,
SyncSceneAssets,
ExpandLongTail,
PublishWorld,
RevertCheckpoint,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationStatus {
Queued,
Running,
Completed,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardKind {
World,
Camp,
Faction,
Character,
Landmark,
Thread,
Chapter,
SceneChapter,
Carrier,
SidequestSeed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardStatus {
Suggested,
Confirmed,
Locked,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldRoleAssetStatus {
Missing,
VisualReady,
AnimationsReady,
Complete,
}
impl CustomWorldPublicationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl CustomWorldThemeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Martial => "martial",
Self::Arcane => "arcane",
Self::Machina => "machina",
Self::Tide => "tide",
Self::Rift => "rift",
Self::Mythic => "mythic",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"martial" => Some(Self::Martial),
"arcane" => Some(Self::Arcane),
"machina" => Some(Self::Machina),
"tide" => Some(Self::Tide),
"rift" => Some(Self::Rift),
"mythic" => Some(Self::Mythic),
_ => None,
}
}
}
impl CustomWorldGenerationMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Full => "full",
}
}
}
impl CustomWorldSessionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Clarifying => "clarifying",
Self::ReadyToGenerate => "ready_to_generate",
Self::Generating => "generating",
Self::Completed => "completed",
Self::GenerationError => "generation_error",
}
}
}
impl RpgAgentStage {
pub fn as_str(&self) -> &'static str {
match self {
Self::CollectingIntent => "collecting_intent",
Self::Clarifying => "clarifying",
Self::FoundationReview => "foundation_review",
Self::ObjectRefining => "object_refining",
Self::VisualRefining => "visual_refining",
Self::LongTailReview => "long_tail_review",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
Self::Error => "error",
}
}
}
impl RpgAgentMessageRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl RpgAgentMessageKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Clarification => "clarification",
Self::Summary => "summary",
Self::Checkpoint => "checkpoint",
Self::Warning => "warning",
Self::ActionResult => "action_result",
}
}
}
impl RpgAgentOperationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::ProcessMessage => "process_message",
Self::DraftFoundation => "draft_foundation",
Self::UpdateDraftCard => "update_draft_card",
Self::SyncResultProfile => "sync_result_profile",
Self::GenerateCharacters => "generate_characters",
Self::GenerateLandmarks => "generate_landmarks",
Self::DeleteCharacters => "delete_characters",
Self::DeleteLandmarks => "delete_landmarks",
Self::GenerateRoleAssets => "generate_role_assets",
Self::SyncRoleAssets => "sync_role_assets",
Self::GenerateSceneAssets => "generate_scene_assets",
Self::SyncSceneAssets => "sync_scene_assets",
Self::ExpandLongTail => "expand_long_tail",
Self::PublishWorld => "publish_world",
Self::RevertCheckpoint => "revert_checkpoint",
}
}
}
impl RpgAgentOperationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
}
}
}
impl RpgAgentDraftCardKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::World => "world",
Self::Camp => "camp",
Self::Faction => "faction",
Self::Character => "character",
Self::Landmark => "landmark",
Self::Thread => "thread",
Self::Chapter => "chapter",
Self::SceneChapter => "scene_chapter",
Self::Carrier => "carrier",
Self::SidequestSeed => "sidequest_seed",
}
}
}
impl RpgAgentDraftCardStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Suggested => "suggested",
Self::Confirmed => "confirmed",
Self::Locked => "locked",
Self::Warning => "warning",
}
}
}
impl CustomWorldRoleAssetStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Missing => "missing",
Self::VisualReady => "visual_ready",
Self::AnimationsReady => "animations_ready",
Self::Complete => "complete",
}
}
}

View File

@@ -4,6 +4,8 @@ mod domain;
mod errors;
mod events;
pub use domain::*;
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
@@ -11,138 +13,6 @@ use serde_json::{Map, Value};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const MAX_PROGRESS_PERCENT: u32 = 100;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldPublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldThemeMode {
Martial,
Arcane,
Machina,
Tide,
Rift,
Mythic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldGenerationMode {
Fast,
Full,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldSessionStatus {
Clarifying,
ReadyToGenerate,
Generating,
Completed,
GenerationError,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentStage {
CollectingIntent,
Clarifying,
FoundationReview,
ObjectRefining,
VisualRefining,
LongTailReview,
ReadyToPublish,
Published,
Error,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageKind {
Chat,
Clarification,
Summary,
Checkpoint,
Warning,
ActionResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationType {
ProcessMessage,
DraftFoundation,
UpdateDraftCard,
SyncResultProfile,
GenerateCharacters,
GenerateLandmarks,
DeleteCharacters,
DeleteLandmarks,
GenerateRoleAssets,
SyncRoleAssets,
GenerateSceneAssets,
SyncSceneAssets,
ExpandLongTail,
PublishWorld,
RevertCheckpoint,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationStatus {
Queued,
Running,
Completed,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardKind {
World,
Camp,
Faction,
Character,
Landmark,
Thread,
Chapter,
SceneChapter,
Carrier,
SidequestSeed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardStatus {
Suggested,
Confirmed,
Locked,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldRoleAssetStatus {
Missing,
VisualReady,
AnimationsReady,
Complete,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CustomWorldFieldError {
MissingProfileId,
@@ -688,172 +558,6 @@ pub struct CustomWorldPublishWorldResult {
pub error_message: Option<String>,
}
impl CustomWorldPublicationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl CustomWorldThemeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Martial => "martial",
Self::Arcane => "arcane",
Self::Machina => "machina",
Self::Tide => "tide",
Self::Rift => "rift",
Self::Mythic => "mythic",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"martial" => Some(Self::Martial),
"arcane" => Some(Self::Arcane),
"machina" => Some(Self::Machina),
"tide" => Some(Self::Tide),
"rift" => Some(Self::Rift),
"mythic" => Some(Self::Mythic),
_ => None,
}
}
}
impl CustomWorldGenerationMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Full => "full",
}
}
}
impl CustomWorldSessionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Clarifying => "clarifying",
Self::ReadyToGenerate => "ready_to_generate",
Self::Generating => "generating",
Self::Completed => "completed",
Self::GenerationError => "generation_error",
}
}
}
impl RpgAgentStage {
pub fn as_str(&self) -> &'static str {
match self {
Self::CollectingIntent => "collecting_intent",
Self::Clarifying => "clarifying",
Self::FoundationReview => "foundation_review",
Self::ObjectRefining => "object_refining",
Self::VisualRefining => "visual_refining",
Self::LongTailReview => "long_tail_review",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
Self::Error => "error",
}
}
}
impl RpgAgentMessageRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl RpgAgentMessageKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Clarification => "clarification",
Self::Summary => "summary",
Self::Checkpoint => "checkpoint",
Self::Warning => "warning",
Self::ActionResult => "action_result",
}
}
}
impl RpgAgentOperationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::ProcessMessage => "process_message",
Self::DraftFoundation => "draft_foundation",
Self::UpdateDraftCard => "update_draft_card",
Self::SyncResultProfile => "sync_result_profile",
Self::GenerateCharacters => "generate_characters",
Self::GenerateLandmarks => "generate_landmarks",
Self::DeleteCharacters => "delete_characters",
Self::DeleteLandmarks => "delete_landmarks",
Self::GenerateRoleAssets => "generate_role_assets",
Self::SyncRoleAssets => "sync_role_assets",
Self::GenerateSceneAssets => "generate_scene_assets",
Self::SyncSceneAssets => "sync_scene_assets",
Self::ExpandLongTail => "expand_long_tail",
Self::PublishWorld => "publish_world",
Self::RevertCheckpoint => "revert_checkpoint",
}
}
}
impl RpgAgentOperationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
}
}
}
impl RpgAgentDraftCardKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::World => "world",
Self::Camp => "camp",
Self::Faction => "faction",
Self::Character => "character",
Self::Landmark => "landmark",
Self::Thread => "thread",
Self::Chapter => "chapter",
Self::SceneChapter => "scene_chapter",
Self::Carrier => "carrier",
Self::SidequestSeed => "sidequest_seed",
}
}
}
impl RpgAgentDraftCardStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Suggested => "suggested",
Self::Confirmed => "confirmed",
Self::Locked => "locked",
Self::Warning => "warning",
}
}
}
impl CustomWorldRoleAssetStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Missing => "missing",
Self::VisualReady => "visual_ready",
Self::AnimationsReady => "animations_ready",
Self::Complete => "complete",
}
}
}
pub fn validate_custom_world_profile_fields(
profile_id: &str,
owner_user_id: &str,

View File

@@ -0,0 +1,51 @@
# module-puzzle 独立模块 package 说明
日期:`2026-04-29`
## 1. package 职责
`module-puzzle` 是拼图创作、作品 profile 与运行态规则模块 package后续负责
1. Puzzle Agent 会话、消息、锚点包与结果草稿的纯领域模型。
2. 拼图作品 profile、发布门禁、标签规则与作品列表投影。
3. 拼图运行态开局、交换、拖动、合并、拆分、过关和下一关推荐规则。
4.`spacetime-module` 的拼图表、reducer、procedure 和事件聚合对接。
## 2. 当前阶段说明
当前阶段已经不再只是目录占位,已先固定拼图领域 contract 与最小规则函数。
当前已落地:
1. `src/domain.rs` 承接 Puzzle 基础 ID 前缀、标签数量、洗牌次数常量、基础枚举、Agent session、message、anchor、result draft、work profile、runtime board/run 等领域类型。
2. `src/commands.rs` 承接 SpacetimeDB procedure/reducer 写入输入。
3. `src/application.rs` 承接 procedure 返回包装、标签归一化、草稿编译、发布覆盖、开局、交换、拖动、合并、拆分和下一关推荐的纯规则。
4. `src/errors.rs` 承接拼图字段错误与中文错误文案。
5. `src/events.rs` 承接草稿变化、作品发布和运行态推进的最小领域事件。
6. `spacetime-types` feature 下可供 SpacetimeDB 绑定复用的类型派生。
当前 crate 仍然只承接:
1. 拼图领域常量、枚举、快照类型和纯规则。
2. 字段校验、标签归一化与运行态规则。
3. 后续 `spacetime-module` 聚合表时需要复用的领域边界。
当前阶段明确不提前进入:
1. 图片生成、OSS 上传或 AI prompt 编排。
2. Axum 路由、SSE 或前端展示状态。
3. SpacetimeDB table、reducer、procedure 的直接定义。
当前设计依据:
1. [../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_PZ_DOMAIN_SPLIT_2026-04-29.md)
3. [../../../docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md](../../../docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md)
4. [../../../docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](../../../docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md)
## 3. 边界约束
1. `module-puzzle` 不直接调用图片生成、OSS、HTTP、SSE 或 SpacetimeDB SDK。
2. 领域函数不依赖前端临时状态,拼图运行态真相最终由 SpacetimeDB 表承载。
3. `api-server` 负责 LLM、图片生成和请求响应映射`spacetime-module` 负责表、reducer、procedure 与事件。
4. 后续迁移必须优先复用现有 `domain.rs``commands.rs``application.rs``events.rs``errors.rs` 骨架。

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,181 @@
//! 拼图写入命令过渡落位
//! 拼图写入命令。
//!
//! 用于表达会话消息、作品更新、发布、开局、交换拼图块和过关推进等输入。
//! 命令只表达 SpacetimeDB procedure/reducer 需要写入的意图和参数,
//! 不包含 HTTP、前端展示或 SpacetimeDB table 操作。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::PuzzleAgentStage;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionCreateInput {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub welcome_message_id: String,
pub welcome_message_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentMessageSubmitInput {
pub session_id: String,
pub owner_user_id: String,
pub user_message_id: String,
pub user_message_text: String,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: PuzzleAgentStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleDraftCompileInput {
pub session_id: String,
pub owner_user_id: String,
pub compiled_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleGeneratedImagesSaveInput {
pub session_id: String,
pub owner_user_id: String,
pub candidates_json: String,
pub saved_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleSelectCoverImageInput {
pub session_id: String,
pub owner_user_id: String,
pub candidate_id: String,
pub selected_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzlePublishInput {
pub session_id: String,
pub owner_user_id: String,
pub work_id: String,
pub profile_id: String,
pub author_display_name: String,
pub level_name: Option<String>,
pub summary: Option<String>,
pub theme_tags: Option<Vec<String>>,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkGetInput {
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkDeleteInput {
pub profile_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkUpsertInput {
pub profile_id: String,
pub owner_user_id: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunStartInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunSwapInput {
pub run_id: String,
pub owner_user_id: String,
pub first_piece_id: String,
pub second_piece_id: String,
pub swapped_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunDragInput {
pub run_id: String,
pub owner_user_id: String,
pub piece_id: String,
pub target_row: u32,
pub target_col: u32,
pub dragged_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunNextLevelInput {
pub run_id: String,
pub owner_user_id: String,
pub advanced_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
pub submitted_at_micros: i64,
}

View File

@@ -2,3 +2,353 @@
//!
//! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则;
//! 图片生成、发布 HTTP shape 和排行榜适配留在外层。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const PUZZLE_AGENT_SESSION_ID_PREFIX: &str = "puzzle-session-";
pub const PUZZLE_AGENT_MESSAGE_ID_PREFIX: &str = "puzzle-message-";
pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-";
pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-";
pub const PUZZLE_MIN_TAG_COUNT: usize = 3;
pub const PUZZLE_MAX_TAG_COUNT: usize = 6;
pub(crate) const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleAgentStage {
CollectingAnchors,
DraftReady,
ImageRefining,
ReadyToPublish,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleAnchorStatus {
Missing,
Inferred,
Confirmed,
Locked,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleAgentMessageKind {
Chat,
Summary,
ActionResult,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzlePublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleRuntimeLevelStatus {
Playing,
Cleared,
}
impl PuzzleAgentStage {
pub fn as_str(self) -> &'static str {
match self {
Self::CollectingAnchors => "collecting_anchors",
Self::DraftReady => "draft_ready",
Self::ImageRefining => "image_refining",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
}
}
}
impl PuzzleAnchorStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Inferred => "inferred",
Self::Confirmed => "confirmed",
Self::Locked => "locked",
}
}
}
impl PuzzleAgentMessageRole {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl PuzzleAgentMessageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Summary => "summary",
Self::ActionResult => "action_result",
Self::Warning => "warning",
}
}
}
impl PuzzlePublicationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl PuzzleRuntimeLevelStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Playing => "playing",
Self::Cleared => "cleared",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAnchorItem {
pub key: String,
pub label: String,
pub value: String,
pub status: PuzzleAnchorStatus,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAnchorPack {
pub theme_promise: PuzzleAnchorItem,
pub visual_subject: PuzzleAnchorItem,
pub visual_mood: PuzzleAnchorItem,
pub composition_hooks: PuzzleAnchorItem,
pub tags_and_forbidden: PuzzleAnchorItem,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleCreatorIntent {
pub source_mode: String,
pub raw_messages_summary: String,
pub theme_promise: String,
pub visual_subject: String,
pub visual_mood: Vec<String>,
pub composition_hooks: Vec<String>,
pub theme_tags: Vec<String>,
pub forbidden_directives: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleGeneratedImageCandidate {
pub candidate_id: String,
pub image_src: String,
pub asset_id: String,
pub prompt: String,
pub actual_prompt: Option<String>,
pub source_type: String,
pub selected: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleResultDraft {
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub forbidden_directives: Vec<String>,
pub creator_intent: Option<PuzzleCreatorIntent>,
pub anchor_pack: PuzzleAnchorPack,
pub candidates: Vec<PuzzleGeneratedImageCandidate>,
pub selected_candidate_id: Option<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub generation_status: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleResultPreviewBlocker {
pub id: String,
pub code: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleResultPreviewFinding {
pub id: String,
pub severity: String,
pub code: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleResultPreviewEnvelope {
pub draft: PuzzleResultDraft,
pub blockers: Vec<PuzzleResultPreviewBlocker>,
pub quality_findings: Vec<PuzzleResultPreviewFinding>,
pub publish_ready: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: PuzzleAgentMessageRole,
pub kind: PuzzleAgentMessageKind,
pub text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSuggestedAction {
pub id: String,
pub action_type: String,
pub label: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: PuzzleAgentStage,
pub anchor_pack: PuzzleAnchorPack,
pub draft: Option<PuzzleResultDraft>,
pub messages: Vec<PuzzleAgentMessageSnapshot>,
pub last_assistant_reply: Option<String>,
pub published_profile_id: Option<String>,
pub suggested_actions: Vec<PuzzleAgentSuggestedAction>,
pub result_preview: Option<PuzzleResultPreviewEnvelope>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkProfile {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub author_display_name: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub publication_status: PuzzlePublicationStatus,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub play_count: u32,
pub publish_ready: bool,
pub anchor_pack: PuzzleAnchorPack,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleCellPosition {
pub row: u32,
pub col: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzlePieceState {
pub piece_id: String,
pub correct_row: u32,
pub correct_col: u32,
pub current_row: u32,
pub current_col: u32,
pub merged_group_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleMergedGroupState {
pub group_id: String,
pub piece_ids: Vec<String>,
pub occupied_cells: Vec<PuzzleCellPosition>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardEntry {
pub rank: u32,
pub nickname: String,
pub elapsed_ms: u64,
pub is_current_player: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleBoardSnapshot {
pub rows: u32,
pub cols: u32,
pub pieces: Vec<PuzzlePieceState>,
pub merged_groups: Vec<PuzzleMergedGroupState>,
pub selected_piece_id: Option<String>,
pub all_tiles_resolved: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRuntimeLevelSnapshot {
pub run_id: String,
pub level_index: u32,
pub grid_size: u32,
pub profile_id: String,
pub level_name: String,
pub author_display_name: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub board: PuzzleBoardSnapshot,
pub status: PuzzleRuntimeLevelStatus,
pub started_at_ms: u64,
pub cleared_at_ms: Option<u64>,
pub elapsed_ms: Option<u64>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunSnapshot {
pub run_id: String,
pub entry_profile_id: String,
pub cleared_level_count: u32,
pub current_level_index: u32,
pub current_grid_size: u32,
pub played_profile_ids: Vec<String>,
pub previous_level_tags: Vec<String>,
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
pub recommended_next_profile_id: Option<String>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}

View File

@@ -1,3 +1,39 @@
//! 拼图领域错误过渡落位
//! 拼图领域错误。
//!
//! 错误只表达拼图业务失败,例如标签不足、移动非法或运行态不存在
//! 错误只表达玩法规则失败,例如标签不足、移动非法或运行态不存在
//! HTTP 状态码与 SpacetimeDB 字符串错误由 adapter 映射。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PuzzleFieldError {
MissingText,
MissingSessionId,
MissingProfileId,
MissingRunId,
MissingPieceId,
MissingAuthorDisplayName,
InvalidTagCount,
InvalidGridSize,
InvalidTargetCell,
InvalidOperation,
}
impl fmt::Display for PuzzleFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingText => write!(f, "必填文本缺失"),
Self::MissingSessionId => write!(f, "session_id 缺失"),
Self::MissingProfileId => write!(f, "profile_id 缺失"),
Self::MissingRunId => write!(f, "run_id 缺失"),
Self::MissingPieceId => write!(f, "piece_id 缺失"),
Self::MissingAuthorDisplayName => write!(f, "author_display_name 缺失"),
Self::InvalidTagCount => write!(f, "标签数量不合法"),
Self::InvalidGridSize => write!(f, "网格规格不合法"),
Self::InvalidTargetCell => write!(f, "目标格子不合法"),
Self::InvalidOperation => write!(f, "操作不合法"),
}
}
}
impl Error for PuzzleFieldError {}

View File

@@ -1,3 +1,27 @@
//! 拼图领域事件过渡落位。
//!
//! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。
/// 拼图领域事件。
///
/// 事件只描述已经发生的领域事实,持久化、订阅投影和 HTTP/SSE 通知
/// 均由 SpacetimeDB adapter 或 BFF 决定。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PuzzleDomainEvent {
DraftChanged {
session_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
WorkPublished {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RunAdvanced {
run_id: String,
owner_user_id: String,
level_index: u32,
occurred_at_micros: i64,
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
`module-runtime-story` 承接 RPG runtime story 的纯领域规则、应用用例、事件和错误模型,不依赖 HTTP / `AppState` / SpacetimeDB。
当前已经迁入的历史兼容纯逻辑会继续收口为 session scoped 新主链:
当前已经迁入的历史快照态纯逻辑会继续收口为 session scoped 新主链:
1. action 结算结果结构。
2. action response 组装参数结构。
@@ -10,4 +10,4 @@
4. functionId / 队伍上限常量。
5. 少量只依赖 `serde_json::Value``shared-contracts` 的纯 helper。
后续 WP-RS 继续按 battle / forge / NPC / quest / presentation 的顺序,把旧 `/api/runtime/story/*` 兼容桥中剩余纯规则迁入本 crate,并删除运行代码中的 compat 命名。
后续 WP-RS 继续按 battle / forge / NPC / quest / presentation 的顺序,把旧 `/api/runtime/story/*` 写侧能力迁到 session scoped 新接口,并删除运行代码中的旧入口命名。

View File

@@ -14,7 +14,7 @@ use crate::{
write_i32_field, write_null_field, write_string_field,
};
/// 战斗 compat 纯结算链已经不依赖 HTTP / AppState。
/// 战斗纯结算链已经不依赖 HTTP / AppState。
///
/// 这里同时承接 battle action 的状态结算、资源恢复和战斗选项编译,
/// 让 `api-server` 只保留 HTTP 外壳与最终响应拼装。
@@ -56,7 +56,7 @@ struct BattleInventoryItemView {
use_profile: Option<BattleInventoryUseProfile>,
}
/// 兼容战斗结算的胜负状态。
/// 战斗结算的胜负状态。
///
/// 这里显式补齐失败分支,避免“玩家已死但敌方也被打空时”被错误归类成胜利。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]

View File

@@ -1,3 +1,3 @@
//! runtime story 兼容写入命令过渡落位。
//! runtime story 写入命令过渡落位。
//!
//! 用于表达剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。
//! 用于表达剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。

View File

@@ -2,7 +2,7 @@ use serde_json::{Map, Value, json};
use shared_kernel::format_rfc3339;
use time::OffsetDateTime;
/// Runtime story compat 的纯 JSON 快照工具层。
/// Runtime story 的纯 JSON 快照工具层。
///
/// 这里不允许引入 HTTP、AppState 或持久化依赖,保证后续 battle/forge/npc/quest
/// 规则迁入独立 crate 时可以继续复用同一批状态读写函数。

View File

@@ -1,4 +1,4 @@
//! runtime story 兼容领域模型过渡落位。
//! runtime story 领域模型过渡落位。
//!
//! 当前 crate 用于运行时剧情的纯规则兼容。后续迁移时仍只能保留 JSON 规则、
//! 当前 crate 用于运行时剧情主链的纯规则收口。后续迁移时仍只能保留 JSON 规则、
//! 选项生成和视图模型转换,不引入 Axum、LLM 或 SpacetimeDB。

View File

@@ -1,3 +1,3 @@
//! runtime story 兼容领域错误过渡落位。
//! runtime story 领域错误过渡落位。
//!
//! 错误只表达兼容规则失败,不能直接绑定 HTTP 或数据库错误模型。
//! 错误只表达运行时剧情规则失败,不能直接绑定 HTTP 或数据库错误模型。

View File

@@ -1,3 +1,3 @@
//! runtime story 兼容领域事件过渡落位。
//! runtime story 领域事件过渡落位。
//!
//! 用于表达剧情快照变化、战斗表现变化和物品/成长待同步等事实。
//! 用于表达剧情快照变化、战斗表现变化和物品/成长待同步等事实。

View File

@@ -6,7 +6,7 @@ use crate::{
remove_inventory_item_from_list, resolve_equipment_slot_for_item,
};
/// 这批定义只服务 runtime story compat 的确定性锻造链。
/// 这批定义只服务 runtime story 的确定性锻造链。
///
/// 当前仍然保持旧快照态结算口径,不引入 HTTP / AppState / 持久化边界。
pub(crate) struct ForgeRequirementDefinition {

View File

@@ -18,7 +18,7 @@ use super::forge::{
/// 锻造动作编排已经不再依赖 `api-server` 的 HTTP 边界。
///
/// 这里继续沿用 compat 快照态结算,后续可直接被 `api-server` 外壳或真相态桥接层复用。
/// 这里继续沿用快照态结算,后续可直接被 `api-server` 外壳或真相态桥接层复用。
pub fn resolve_forge_craft_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,

View File

@@ -5,7 +5,7 @@ use crate::{
read_field, read_i32_field, read_object_field, read_optional_string_field, write_i32_field,
};
/// 这批 helper 只负责 runtime story compat 的纯快照读写。
/// 这批 helper 只负责 runtime story 的纯快照读写。
///
/// 目标是先把 encounter / inventory / equipment 的基础状态工具从 `api-server`
/// 边界模块里收口出来,后续 battle / forge / equipment 规则迁移时直接复用。
@@ -186,7 +186,7 @@ pub fn normalize_equipment_slot_id(slot_id: &str) -> Option<&'static str> {
"armor" => Some("armor"),
"relic" | "accessory" => Some("relic"),
_ => {
// 兼容旧 payload 里直接传中文槽位名或物品类别文案的情况。
// 支持历史 payload 里直接传中文槽位名或物品类别文案的情况。
if slot_id.contains("武器")
|| slot_id.contains('剑')
|| slot_id.contains('弓')

View File

@@ -365,7 +365,7 @@ fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option<Va
Some(items.remove(index))
}
/// compat bridge 先只维护一个轻量队伍名单,继续复用前端的满员换队语义。
/// 当前主链先只维护一个轻量队伍名单,继续复用既有前端的满员换队语义。
pub fn recruit_companion_to_party(
game_state: &mut Value,
npc_id: &str,

View File

@@ -14,7 +14,7 @@
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入表结构、projection 与接口实现。
当前已进入 DDD 分层拆分阶段,但仍以小切片推进,不提前改动未冻结的表结构、projection 与接口实现。
后续与本 package 直接相关的任务包括:
@@ -23,6 +23,22 @@
3. 设计 `profile_played_world``profile_save_archive``user_browse_history`
4. 落地存档、设置、资料页兼容接口
已落地的拆分切片:
1. `runtime settings` 的默认值、平台主题值对象与设置聚合已迁入 `src/domain.rs`,根入口通过 `pub use domain::*` 保持原有 crate API。
2. runtime snapshot、profile dashboard、wallet、recharge、referral、played world、play stats、save archive 的快照、输入、过程结果与记录投影类型已迁入 `src/domain.rs`
3. settings、browse history、profile/save 三组字段错误和中文错误文案已迁入 `src/errors.rs`
4. settings、browse history、profile/save 等输入构造和写入归一化函数已迁入 `src/commands.rs`
5. settings、browse history、profile/save 等记录投影 builder 已迁入 `src/application.rs`
6. checkpoint、profile/save archive meta、充值/邀请/兑换/钱包等剩余纯规则已迁入 `src/application.rs``spacetime-module` 只保留表事务读写,`api-server` 只保留 HTTP/BFF 映射。
7. 详细边界与验收记录见:
- `docs/technical/SERVER_RS_DDD_WP_RT_RUNTIME_SETTINGS_DOMAIN_REFACTOR_2026-04-29.md`
- `docs/technical/SERVER_RS_DDD_WP_RT_DOMAIN_SNAPSHOT_RECORD_REFACTOR_2026-04-29.md`
- `docs/technical/SERVER_RS_DDD_WP_RT_ERROR_LAYER_REFACTOR_2026-04-29.md`
- `docs/technical/SERVER_RS_DDD_WP_RT_COMMANDS_REFACTOR_2026-04-29.md`
- `docs/technical/SERVER_RS_DDD_WP_RT_APPLICATION_RECORD_REFACTOR_2026-04-29.md`
- `docs/technical/SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md`
## 3. 边界约束
1. `module-runtime` 负责运行时状态真相与模块级 facade 编排,不把主状态继续留在旧式大 JSON repository 中。

View File

@@ -1,3 +1,798 @@
//! 运行时应用编排过渡落位。
//!
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
use serde_json::Value;
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use crate::domain::*;
use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
RuntimeSettingsRecord {
user_id: snapshot.user_id,
music_volume: snapshot.music_volume,
platform_theme: snapshot.platform_theme,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_browse_history_record(
snapshot: RuntimeBrowseHistorySnapshot,
) -> RuntimeBrowseHistoryRecord {
RuntimeBrowseHistoryRecord {
browse_history_id: snapshot.browse_history_id,
user_id: snapshot.user_id,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_name: snapshot.world_name,
subtitle: snapshot.subtitle,
summary_text: snapshot.summary_text,
cover_image_src: snapshot.cover_image_src,
theme_mode: snapshot.theme_mode,
author_display_name: snapshot.author_display_name,
visited_at: format_utc_micros(snapshot.visited_at_micros),
visited_at_micros: snapshot.visited_at_micros,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_dashboard_record(
snapshot: RuntimeProfileDashboardSnapshot,
) -> RuntimeProfileDashboardRecord {
RuntimeProfileDashboardRecord {
user_id: snapshot.user_id,
wallet_balance: snapshot.wallet_balance,
total_play_time_ms: snapshot.total_play_time_ms,
played_world_count: snapshot.played_world_count,
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_wallet_ledger_entry_record(
snapshot: RuntimeProfileWalletLedgerEntrySnapshot,
) -> RuntimeProfileWalletLedgerEntryRecord {
RuntimeProfileWalletLedgerEntryRecord {
wallet_ledger_id: snapshot.wallet_ledger_id,
user_id: snapshot.user_id,
amount_delta: snapshot.amount_delta,
balance_after: snapshot.balance_after,
source_type: snapshot.source_type,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
}
}
pub fn build_runtime_profile_recharge_center_record(
snapshot: RuntimeProfileRechargeCenterSnapshot,
) -> RuntimeProfileRechargeCenterRecord {
RuntimeProfileRechargeCenterRecord {
user_id: snapshot.user_id,
wallet_balance: snapshot.wallet_balance,
membership: build_runtime_profile_membership_record(snapshot.membership),
point_products: snapshot
.point_products
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
membership_products: snapshot
.membership_products
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
benefits: snapshot
.benefits
.into_iter()
.map(build_runtime_profile_membership_benefit_record)
.collect(),
latest_order: snapshot
.latest_order
.map(build_runtime_profile_recharge_order_record),
has_points_recharged: snapshot.has_points_recharged,
}
}
pub fn build_runtime_profile_recharge_product_record(
snapshot: RuntimeProfileRechargeProductSnapshot,
) -> RuntimeProfileRechargeProductRecord {
RuntimeProfileRechargeProductRecord {
product_id: snapshot.product_id,
title: snapshot.title,
price_cents: snapshot.price_cents,
kind: snapshot.kind,
points_amount: snapshot.points_amount,
bonus_points: snapshot.bonus_points,
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: snapshot.tier,
}
}
pub fn build_runtime_profile_membership_benefit_record(
snapshot: RuntimeProfileMembershipBenefitSnapshot,
) -> RuntimeProfileMembershipBenefitRecord {
RuntimeProfileMembershipBenefitRecord {
benefit_name: snapshot.benefit_name,
normal_value: snapshot.normal_value,
month_value: snapshot.month_value,
season_value: snapshot.season_value,
year_value: snapshot.year_value,
}
}
pub fn build_runtime_profile_membership_record(
snapshot: RuntimeProfileMembershipSnapshot,
) -> RuntimeProfileMembershipRecord {
RuntimeProfileMembershipRecord {
user_id: snapshot.user_id,
status: snapshot.status,
tier: snapshot.tier,
started_at: snapshot.started_at_micros.map(format_utc_micros),
started_at_micros: snapshot.started_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
expires_at_micros: snapshot.expires_at_micros,
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_recharge_order_record(
snapshot: RuntimeProfileRechargeOrderSnapshot,
) -> RuntimeProfileRechargeOrderRecord {
RuntimeProfileRechargeOrderRecord {
order_id: snapshot.order_id,
user_id: snapshot.user_id,
product_id: snapshot.product_id,
product_title: snapshot.product_title,
kind: snapshot.kind,
amount_cents: snapshot.amount_cents,
status: snapshot.status,
payment_channel: snapshot.payment_channel,
paid_at: format_utc_micros(snapshot.paid_at_micros),
paid_at_micros: snapshot.paid_at_micros,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
points_delta: snapshot.points_delta,
membership_expires_at: snapshot.membership_expires_at_micros.map(format_utc_micros),
membership_expires_at_micros: snapshot.membership_expires_at_micros,
}
}
pub fn build_runtime_referral_invite_center_record(
snapshot: RuntimeReferralInviteCenterSnapshot,
) -> RuntimeReferralInviteCenterRecord {
RuntimeReferralInviteCenterRecord {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
invite_link_path: snapshot.invite_link_path,
invited_count: snapshot.invited_count,
rewarded_invite_count: snapshot.rewarded_invite_count,
today_inviter_reward_count: snapshot.today_inviter_reward_count,
today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining,
reward_points: snapshot.reward_points,
has_redeemed_code: snapshot.has_redeemed_code,
bound_inviter_user_id: snapshot.bound_inviter_user_id,
bound_at: snapshot.bound_at_micros.map(format_utc_micros),
bound_at_micros: snapshot.bound_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_referral_redeem_record(
snapshot: RuntimeReferralRedeemSnapshot,
) -> RuntimeReferralRedeemRecord {
RuntimeReferralRedeemRecord {
center: build_runtime_referral_invite_center_record(snapshot.center),
invitee_reward_granted: snapshot.invitee_reward_granted,
inviter_reward_granted: snapshot.inviter_reward_granted,
invitee_balance_after: snapshot.invitee_balance_after,
inviter_balance_after: snapshot.inviter_balance_after,
}
}
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 {
RuntimeProfilePlayedWorldRecord {
played_world_id: snapshot.played_world_id,
user_id: snapshot.user_id,
world_key: snapshot.world_key,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_type: snapshot.world_type,
world_title: snapshot.world_title,
world_subtitle: snapshot.world_subtitle,
first_played_at: format_utc_micros(snapshot.first_played_at_micros),
first_played_at_micros: snapshot.first_played_at_micros,
last_played_at: format_utc_micros(snapshot.last_played_at_micros),
last_played_at_micros: snapshot.last_played_at_micros,
last_observed_play_time_ms: snapshot.last_observed_play_time_ms,
}
}
pub fn build_runtime_profile_play_stats_record(
snapshot: RuntimeProfilePlayStatsSnapshot,
) -> RuntimeProfilePlayStatsRecord {
RuntimeProfilePlayStatsRecord {
user_id: snapshot.user_id,
total_play_time_ms: snapshot.total_play_time_ms,
played_works: snapshot
.played_works
.into_iter()
.map(build_runtime_profile_played_world_record)
.collect(),
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_snapshot_record(
snapshot: RuntimeSnapshot,
) -> Result<RuntimeSnapshotRecord, RuntimeProfileFieldError> {
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story = parse_optional_json_value(
snapshot.current_story_json.as_deref(),
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)?;
Ok(RuntimeSnapshotRecord {
user_id: snapshot.user_id,
version: snapshot.version,
saved_at: format_utc_micros(snapshot.saved_at_micros),
saved_at_micros: snapshot.saved_at_micros,
bottom_tab: snapshot.bottom_tab,
game_state,
current_story,
game_state_json: snapshot.game_state_json,
current_story_json: snapshot.current_story_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
})
}
pub fn build_runtime_profile_save_archive_record(
snapshot: RuntimeProfileSaveArchiveSnapshot,
) -> Result<RuntimeProfileSaveArchiveRecord, RuntimeProfileFieldError> {
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story = parse_optional_json_value(
snapshot.current_story_json.as_deref(),
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)?;
Ok(RuntimeProfileSaveArchiveRecord {
archive_id: snapshot.archive_id,
user_id: snapshot.user_id,
world_key: snapshot.world_key,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_type: snapshot.world_type,
world_name: snapshot.world_name,
subtitle: snapshot.subtitle,
summary_text: snapshot.summary_text,
cover_image_src: snapshot.cover_image_src,
saved_at: format_utc_micros(snapshot.saved_at_micros),
saved_at_micros: snapshot.saved_at_micros,
bottom_tab: snapshot.bottom_tab,
game_state,
current_story,
game_state_json: snapshot.game_state_json,
current_story_json: snapshot.current_story_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
})
}
pub fn build_runtime_save_checkpoint_update(
input: RuntimeSaveCheckpointInput,
existing: RuntimeSnapshotRecord,
) -> Result<RuntimeSaveCheckpointSnapshotUpdate, RuntimeProfileFieldError> {
if is_non_persistent_runtime_snapshot(&existing.game_state) {
return Err(RuntimeProfileFieldError::NonPersistentRuntimeSnapshot);
}
let persisted_session_id =
read_runtime_json_string_field(&existing.game_state, "runtimeSessionId")
.ok_or(RuntimeProfileFieldError::MissingRuntimeSessionId)?;
if persisted_session_id != input.session_id {
return Err(RuntimeProfileFieldError::RuntimeSessionMismatch {
expected_session_id: persisted_session_id,
actual_session_id: input.session_id,
});
}
Ok(RuntimeSaveCheckpointSnapshotUpdate {
saved_at_micros: input.saved_at_micros,
bottom_tab: input.bottom_tab,
game_state: refresh_runtime_snapshot_play_time(
existing.game_state,
input.updated_at_micros,
),
current_story: existing.current_story,
updated_at_micros: input.updated_at_micros,
})
}
pub fn build_runtime_profile_played_world_id(user_id: &str, world_key: &str) -> String {
format!("{}:{}", user_id.trim(), world_key.trim())
}
pub fn build_runtime_profile_snapshot_wallet_ledger_id(
user_id: &str,
saved_at_micros: i64,
next_wallet_balance: u64,
) -> String {
format!(
"{}:{}:{}",
user_id.trim(),
saved_at_micros,
next_wallet_balance
)
}
pub fn build_runtime_profile_save_archive_id(user_id: &str, world_key: &str) -> String {
format!("{}:{}", user_id.trim(), world_key.trim())
}
pub fn build_runtime_profile_recharge_wallet_ledger_id(
user_id: &str,
created_at_micros: i64,
product_id: &str,
) -> String {
format!(
"{}:{}:{}",
user_id.trim(),
created_at_micros,
product_id.trim()
)
}
pub fn build_runtime_profile_recharge_order_id(
user_id: &str,
created_at_micros: i64,
product_id: &str,
) -> String {
format!(
"recharge:{}",
build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id)
)
}
pub fn resolve_runtime_profile_points_recharge_delta(
product: &RuntimeProfileRechargeProductSnapshot,
has_points_recharged: bool,
) -> u64 {
let bonus_points = if has_points_recharged {
0
} else {
product.bonus_points
};
product.points_amount.saturating_add(bonus_points)
}
pub fn resolve_runtime_profile_membership_purchase_update(
current_started_at_micros: Option<i64>,
current_expires_at_micros: Option<i64>,
purchased_at_micros: i64,
duration_days: u32,
) -> RuntimeProfileMembershipPurchaseUpdate {
let start_at_micros = current_expires_at_micros
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
.unwrap_or(purchased_at_micros);
let expires_at_micros = start_at_micros
.saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS));
RuntimeProfileMembershipPurchaseUpdate {
started_at_micros: current_started_at_micros.unwrap_or(purchased_at_micros),
expires_at_micros,
}
}
pub fn build_runtime_profile_invite_code(user_id: &str, salt: u32) -> String {
let mut hash = 14_695_981_039_346_656_037u64;
for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) {
hash ^= byte as u64;
hash = hash.wrapping_mul(1_099_511_628_211);
}
format!("SY{:08X}", hash as u32)
}
pub fn build_runtime_profile_invite_link_path(invite_code: &str) -> String {
format!("/?inviteCode={}", invite_code.trim())
}
pub fn runtime_profile_day_start_micros(now_micros: i64) -> i64 {
now_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS) * PROFILE_RUNTIME_DAY_MICROS
}
pub fn should_grant_runtime_profile_inviter_reward(today_inviter_reward_count: u32) -> bool {
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT
}
pub fn build_runtime_profile_referral_invitee_ledger_id(
invitee_user_id: &str,
updated_at_micros: i64,
) -> String {
format!("invitee:{}:{}", invitee_user_id.trim(), updated_at_micros)
}
pub fn build_runtime_profile_referral_inviter_ledger_id(
inviter_user_id: &str,
updated_at_micros: i64,
) -> String {
format!("inviter:{}:{}", inviter_user_id.trim(), updated_at_micros)
}
pub fn validate_runtime_profile_redeem_code_usage(
code: &RuntimeProfileRedeemCodeSnapshot,
user_id: &str,
user_used_count: u32,
) -> Result<(), RuntimeProfileFieldError> {
if !code.enabled {
return Err(RuntimeProfileFieldError::RedeemCodeDisabled);
}
if code.reward_points == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
}
match code.mode {
RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Unique if code.global_used_count >= code.max_uses => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Private => {
if !code.allowed_user_ids.iter().any(|item| item == user_id) {
return Err(RuntimeProfileFieldError::RedeemCodeNotAllowedForUser);
}
if code.global_used_count >= code.max_uses {
return Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted);
}
Ok(())
}
_ => Ok(()),
}
}
pub fn build_runtime_profile_redeem_code_usage_id(
code: &str,
user_id: &str,
redeemed_at_micros: i64,
sequence: u32,
) -> String {
format!(
"redeem:{}:{}:{}:{}",
code.trim(),
user_id.trim(),
redeemed_at_micros,
sequence
)
}
pub fn build_runtime_profile_redeem_code_ledger_id(usage_id: &str) -> String {
format!("{}:ledger", usage_id.trim())
}
pub fn convert_runtime_profile_wallet_unsigned_delta(
amount_delta: u64,
) -> Result<i64, RuntimeProfileFieldError> {
i64::try_from(amount_delta).map_err(|_| RuntimeProfileFieldError::WalletAmountOverflow)
}
pub fn calculate_runtime_profile_wallet_balance(
previous_balance: u64,
amount_delta: i64,
) -> Result<u64, RuntimeProfileFieldError> {
if amount_delta >= 0 {
previous_balance
.checked_add(amount_delta as u64)
.ok_or(RuntimeProfileFieldError::WalletBalanceOverflow)
} else {
previous_balance
.checked_sub(amount_delta.unsigned_abs())
.ok_or(RuntimeProfileFieldError::InsufficientWalletBalance)
}
}
pub fn refresh_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
let Some(game_state_object) = game_state.as_object_mut() else {
return game_state;
};
let now_text = format_utc_micros(now_micros);
let Some(runtime_stats) = game_state_object
.get_mut("runtimeStats")
.and_then(Value::as_object_mut)
else {
game_state_object.insert(
"runtimeStats".to_string(),
serde_json::json!({
"playTimeMs": 0,
"lastPlayTickAt": now_text,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0,
}),
);
return game_state;
};
let current_play_time = runtime_stats
.get("playTimeMs")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let elapsed_ms = runtime_stats
.get("lastPlayTickAt")
.and_then(Value::as_str)
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
.map(offset_datetime_to_unix_micros)
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
.unwrap_or(0.0);
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
// checkpoint 只刷新服务端已有 runtimeStats 的时间水位,不接收浏览器上传的剧情、背包或战斗真相。
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
game_state
}
pub fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
read_runtime_json_string_field_from_map(game_state, "runtimeMode").as_deref(),
Some("preview") | Some("test")
)
}
pub fn resolve_runtime_profile_world_snapshot_meta(
game_state: Option<&serde_json::Map<String, Value>>,
) -> Option<RuntimeProfileWorldSnapshotMeta> {
let game_state = game_state?;
let custom_world_profile = game_state
.get("customWorldProfile")
.and_then(Value::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let profile_id = read_runtime_json_string_field_from_map(custom_world_profile, "id");
let world_title = read_runtime_json_string_field_from_map(custom_world_profile, "name")
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"));
if profile_id.is_some() || world_title.is_some() {
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
return Some(RuntimeProfileWorldSnapshotMeta {
world_key: profile_id
.as_ref()
.map(|profile_id| format!("custom:{profile_id}"))
.unwrap_or_else(|| format!("custom:{world_title}")),
owner_user_id: None,
profile_id,
world_type: Some("CUSTOM".to_string()),
world_title,
world_subtitle: read_runtime_json_string_field_from_map(
custom_world_profile,
"summary",
)
.or_else(|| {
read_runtime_json_string_field_from_map(custom_world_profile, "settingText")
})
.unwrap_or_default(),
});
}
}
let world_type = read_runtime_json_string_field_from_map(game_state, "worldType")?;
let current_scene_preset = game_state
.get("currentScenePreset")
.and_then(Value::as_object);
Some(RuntimeProfileWorldSnapshotMeta {
world_key: format!("builtin:{world_type}"),
owner_user_id: None,
profile_id: None,
world_type: Some(world_type.clone()),
world_title: current_scene_preset
.and_then(|preset| read_runtime_json_string_field_from_map(preset, "name"))
.unwrap_or_else(|| build_runtime_builtin_world_title(&world_type)),
world_subtitle: current_scene_preset
.and_then(|preset| {
read_runtime_json_string_field_from_map(preset, "summary")
.or_else(|| read_runtime_json_string_field_from_map(preset, "description"))
})
.unwrap_or_default(),
})
}
pub fn resolve_runtime_profile_save_archive_meta(
game_state: &Value,
current_story_json: Option<&str>,
) -> Option<RuntimeProfileSaveArchiveMeta> {
if is_non_persistent_runtime_snapshot(game_state) {
return None;
}
let game_state_object = game_state.as_object();
let world_meta = resolve_runtime_profile_world_snapshot_meta(game_state_object)?;
let story_engine_memory = game_state_object
.and_then(|state| state.get("storyEngineMemory"))
.and_then(Value::as_object);
let continue_game_digest = story_engine_memory
.and_then(|memory| read_runtime_json_string_field_from_map(memory, "continueGameDigest"));
let current_story_text = parse_optional_json_value(
current_story_json,
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)
.ok()
.flatten()
.and_then(|story| story.as_object().cloned())
.and_then(|story| read_runtime_json_string_field_from_map(&story, "text"));
let custom_world_profile = game_state_object
.and_then(|state| state.get("customWorldProfile"))
.and_then(Value::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let world_name = read_runtime_json_string_field_from_map(custom_world_profile, "name")
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"))
.unwrap_or_else(|| world_meta.world_title.clone());
let subtitle = read_runtime_json_string_field_from_map(custom_world_profile, "summary")
.or_else(|| {
read_runtime_json_string_field_from_map(custom_world_profile, "settingText")
})
.unwrap_or_else(|| world_meta.world_subtitle.clone());
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if subtitle.is_empty() {
None
} else {
Some(subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
return Some(RuntimeProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name,
subtitle,
summary_text,
cover_image_src: read_runtime_json_string_field_from_map(
custom_world_profile,
"coverImageSrc",
),
});
}
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if world_meta.world_subtitle.is_empty() {
None
} else {
Some(world_meta.world_subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
let current_scene_preset = game_state_object
.and_then(|state| state.get("currentScenePreset"))
.and_then(Value::as_object);
Some(RuntimeProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name: world_meta.world_title,
subtitle: world_meta.world_subtitle,
summary_text,
cover_image_src: current_scene_preset
.and_then(|preset| read_runtime_json_string_field_from_map(preset, "imageSrc")),
})
}
pub fn read_runtime_json_non_negative_u64(value: Option<&Value>) -> u64 {
match value {
Some(Value::Number(number)) => {
if let Some(raw) = number.as_u64() {
raw
} else if let Some(raw) = number.as_i64() {
raw.max(0) as u64
} else if let Some(raw) = number.as_f64() {
if raw.is_finite() && raw > 0.0 {
raw.floor() as u64
} else {
0
}
} else {
0
}
}
Some(Value::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
_ => 0,
}
}
pub fn read_runtime_json_string_field(value: &Value, field: &str) -> Option<String> {
read_runtime_json_string_field_from_map(value.as_object()?, field)
}
pub fn read_runtime_json_string_field_from_map(
value: &serde_json::Map<String, Value>,
field: &str,
) -> Option<String> {
value
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn build_runtime_builtin_world_title(world_type: &str) -> String {
match world_type {
"WUXIA" => "武侠世界".to_string(),
"XIANXIA" => "仙侠世界".to_string(),
_ => "叙事世界".to_string(),
}
}
fn parse_optional_json_value(
raw: Option<&str>,
error: RuntimeProfileFieldError,
) -> Result<Option<Value>, RuntimeProfileFieldError> {
match raw.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => serde_json::from_str::<Value>(value)
.map(Some)
.map_err(|_| error),
None => Ok(None),
}
}

View File

@@ -1,3 +1,472 @@
//! 运行时写入命令过渡落位。
//!
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。
use std::collections::HashSet;
use serde_json::Value;
use shared_kernel::{
normalize_optional_string, normalize_required_string, parse_rfc3339 as parse_shared_rfc3339,
};
use crate::domain::*;
use crate::errors::*;
use crate::{format_utc_micros, runtime_profile_recharge_product_by_id};
// 统一把共享必填字符串归一化映射到 runtime 各自的字段错误,避免输入构造函数重复 trim + 判空。
fn normalize_runtime_settings_user_id(
user_id: String,
) -> Result<String, RuntimeSettingsFieldError> {
normalize_required_string(user_id).ok_or(RuntimeSettingsFieldError::MissingUserId)
}
fn normalize_runtime_browse_history_user_id(
user_id: String,
) -> Result<String, RuntimeBrowseHistoryFieldError> {
normalize_required_string(user_id).ok_or(RuntimeBrowseHistoryFieldError::MissingUserId)
}
fn normalize_runtime_profile_user_id(user_id: String) -> Result<String, RuntimeProfileFieldError> {
normalize_required_string(user_id).ok_or(RuntimeProfileFieldError::MissingUserId)
}
pub fn build_runtime_setting_get_input(
user_id: String,
) -> Result<RuntimeSettingGetInput, RuntimeSettingsFieldError> {
let user_id = normalize_runtime_settings_user_id(user_id)?;
Ok(RuntimeSettingGetInput { user_id })
}
pub fn build_runtime_setting_upsert_input(
user_id: String,
music_volume: f32,
platform_theme: RuntimePlatformTheme,
updated_at_micros: i64,
) -> Result<RuntimeSettingUpsertInput, RuntimeSettingsFieldError> {
let user_id = normalize_runtime_settings_user_id(user_id)?;
let normalized = RuntimeSettings::normalized(music_volume, platform_theme);
Ok(RuntimeSettingUpsertInput {
user_id,
music_volume: normalized.music_volume,
platform_theme: normalized.platform_theme,
updated_at_micros,
})
}
pub fn build_runtime_browse_history_list_input(
user_id: String,
) -> Result<RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryFieldError> {
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
Ok(RuntimeBrowseHistoryListInput { user_id })
}
pub fn build_runtime_profile_dashboard_get_input(
user_id: String,
) -> Result<RuntimeProfileDashboardGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileDashboardGetInput { user_id })
}
pub fn build_runtime_profile_wallet_ledger_list_input(
user_id: String,
) -> Result<RuntimeProfileWalletLedgerListInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileWalletLedgerListInput { user_id })
}
pub fn build_runtime_profile_wallet_adjustment_input(
user_id: String,
amount: u64,
ledger_id: String,
created_at_micros: i64,
) -> Result<RuntimeProfileWalletAdjustmentInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let ledger_id =
normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?;
if amount == 0 || amount > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidWalletAmount);
}
Ok(RuntimeProfileWalletAdjustmentInput {
user_id,
amount,
ledger_id,
created_at_micros,
})
}
pub fn build_runtime_profile_recharge_center_get_input(
user_id: String,
) -> Result<RuntimeProfileRechargeCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileRechargeCenterGetInput { user_id })
}
pub fn build_runtime_profile_recharge_order_create_input(
user_id: String,
product_id: String,
payment_channel: String,
created_at_micros: i64,
) -> Result<RuntimeProfileRechargeOrderCreateInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let product_id =
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
if runtime_profile_recharge_product_by_id(&product_id).is_none() {
return Err(RuntimeProfileFieldError::UnknownRechargeProduct);
}
let payment_channel = normalize_required_string(payment_channel)
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
Ok(RuntimeProfileRechargeOrderCreateInput {
user_id,
product_id,
payment_channel,
created_at_micros,
})
}
pub fn build_runtime_referral_invite_center_get_input(
user_id: String,
) -> Result<RuntimeReferralInviteCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeReferralInviteCenterGetInput { user_id })
}
pub fn build_runtime_referral_redeem_input(
user_id: String,
invite_code: String,
updated_at_micros: i64,
) -> Result<RuntimeReferralRedeemInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
Ok(RuntimeReferralRedeemInput {
user_id,
invite_code,
updated_at_micros,
})
}
pub fn build_runtime_profile_reward_code_redeem_input(
user_id: String,
code: String,
redeemed_at_micros: i64,
) -> Result<RuntimeProfileRewardCodeRedeemInput, RuntimeProfileFieldError> {
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<String>,
allowed_public_user_codes: Vec<String>,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
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<RuntimeProfileRedeemCodeAdminDisableInput, RuntimeProfileFieldError> {
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<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfilePlayStatsGetInput { user_id })
}
pub fn build_runtime_snapshot_get_input(
user_id: String,
) -> Result<RuntimeSnapshotGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeSnapshotGetInput { user_id })
}
pub fn build_runtime_snapshot_delete_input(
user_id: String,
) -> Result<RuntimeSnapshotDeleteInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeSnapshotDeleteInput { user_id })
}
pub fn build_runtime_profile_save_archive_list_input(
user_id: String,
) -> Result<RuntimeProfileSaveArchiveListInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileSaveArchiveListInput { user_id })
}
pub fn build_runtime_profile_save_archive_resume_input(
user_id: String,
world_key: String,
) -> Result<RuntimeProfileSaveArchiveResumeInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let world_key =
normalize_required_string(world_key).ok_or(RuntimeProfileFieldError::MissingWorldKey)?;
Ok(RuntimeProfileSaveArchiveResumeInput { user_id, world_key })
}
pub fn build_runtime_save_checkpoint_input(
session_id: String,
bottom_tab: String,
saved_at_micros: i64,
updated_at_micros: i64,
) -> Result<RuntimeSaveCheckpointInput, RuntimeProfileFieldError> {
let session_id = normalize_required_string(session_id)
.ok_or(RuntimeProfileFieldError::MissingCheckpointSessionId)?;
let bottom_tab =
normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?;
Ok(RuntimeSaveCheckpointInput {
session_id,
bottom_tab,
saved_at_micros,
updated_at_micros,
})
}
pub fn build_runtime_browse_history_clear_input(
user_id: String,
) -> Result<RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryFieldError> {
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
Ok(RuntimeBrowseHistoryClearInput { user_id })
}
pub fn build_runtime_snapshot_upsert_input(
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotUpsertInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let bottom_tab =
normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?;
let game_state_json = serde_json::to_string(&game_state)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story_json = normalize_current_story_json(current_story)?;
Ok(RuntimeSnapshotUpsertInput {
user_id,
saved_at_micros,
bottom_tab,
game_state_json,
current_story_json,
updated_at_micros,
})
}
pub fn build_runtime_browse_history_sync_input(
user_id: String,
entries: Vec<RuntimeBrowseHistoryWriteInput>,
updated_at_micros: i64,
) -> Result<RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryFieldError> {
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE {
return Err(RuntimeBrowseHistoryFieldError::TooManyEntries);
}
let mut normalized_entries = Vec::with_capacity(entries.len());
for entry in entries {
let Some(owner_user_id) = normalize_required_string(entry.owner_user_id) else {
continue;
};
let Some(profile_id) = normalize_required_string(entry.profile_id) else {
continue;
};
let Some(world_name) = normalize_required_string(entry.world_name) else {
continue;
};
// 与旧 Node 仓储保持一致:单条缺少关键字段时静默过滤,不让整批请求失败。
let visited_at_micros = entry
.visited_at
.as_deref()
.and_then(parse_utc_rfc3339_to_micros)
.unwrap_or(updated_at_micros);
normalized_entries.push(RuntimeBrowseHistoryWriteInput {
owner_user_id,
profile_id,
world_name,
subtitle: normalize_optional_string(entry.subtitle),
summary_text: normalize_optional_string(entry.summary_text),
cover_image_src: normalize_optional_string(entry.cover_image_src),
theme_mode: normalize_optional_string(entry.theme_mode),
author_display_name: normalize_optional_string(entry.author_display_name),
// 统一把 visitedAt 收口成 RFC3339避免后续排序与回包格式继续漂移。
visited_at: Some(format_utc_micros(visited_at_micros)),
});
}
Ok(RuntimeBrowseHistorySyncInput {
user_id,
entries: normalized_entries,
updated_at_micros,
})
}
pub fn prepare_runtime_browse_history_entries(
input: RuntimeBrowseHistorySyncInput,
) -> Result<Vec<RuntimeBrowseHistoryPreparedEntry>, RuntimeBrowseHistoryFieldError> {
let validated_input = build_runtime_browse_history_sync_input(
input.user_id,
input.entries,
input.updated_at_micros,
)?;
let mut prepared_entries = validated_input
.entries
.into_iter()
.map(|entry| {
let visited_at_micros = entry
.visited_at
.as_deref()
.and_then(parse_utc_rfc3339_to_micros)
.unwrap_or(validated_input.updated_at_micros);
RuntimeBrowseHistoryPreparedEntry {
browse_history_id: build_runtime_browse_history_id(
&validated_input.user_id,
&entry.owner_user_id,
&entry.profile_id,
),
user_id: validated_input.user_id.clone(),
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
world_name: entry.world_name,
subtitle: entry.subtitle.unwrap_or_default(),
summary_text: entry.summary_text.unwrap_or_default(),
cover_image_src: entry.cover_image_src,
theme_mode: RuntimeBrowseHistoryThemeMode::from_client_str(
entry.theme_mode.as_deref().unwrap_or("mythic"),
),
author_display_name: entry
.author_display_name
.unwrap_or_else(|| DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME.to_string()),
visited_at_micros,
updated_at_micros: validated_input.updated_at_micros,
}
})
.collect::<Vec<_>>();
// 与旧 Node 仓储保持一致:先按 visitedAt 倒序,再按 owner/profile 去重,只保留最近一次访问。
prepared_entries.sort_by(|left, right| {
right
.visited_at_micros
.cmp(&left.visited_at_micros)
.then_with(|| left.browse_history_id.cmp(&right.browse_history_id))
});
let mut seen_ids = HashSet::new();
prepared_entries.retain(|entry| seen_ids.insert(entry.browse_history_id.clone()));
Ok(prepared_entries)
}
pub fn build_runtime_browse_history_id(
user_id: &str,
owner_user_id: &str,
profile_id: &str,
) -> String {
format!("{user_id}:{owner_user_id}:{profile_id}")
}
fn parse_utc_rfc3339_to_micros(value: &str) -> Option<i64> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let nanos = parse_shared_rfc3339(trimmed).ok()?.unix_timestamp_nanos();
i64::try_from(nanos / 1_000).ok()
}
fn normalize_bottom_tab(value: String) -> Option<String> {
let trimmed = normalize_required_string(value)?;
let normalized = match trimmed.as_str() {
"character" | "inventory" => trimmed,
_ => "adventure".to_string(),
};
Some(normalized)
}
fn normalize_current_story_json(
current_story: Option<Value>,
) -> Result<Option<String>, RuntimeProfileFieldError> {
let Some(current_story) = current_story else {
return Ok(None);
};
if !current_story.is_object() {
return Ok(None);
}
serde_json::to_string(&current_story)
.map(Some)
.map_err(|_| RuntimeProfileFieldError::InvalidCurrentStoryJson)
}
pub fn normalize_invite_code(value: String) -> Option<String> {
let normalized = value
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.map(|character| character.to_ascii_uppercase())
.collect::<String>();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
pub fn normalize_redeem_code(value: String) -> Option<String> {
normalize_invite_code(value)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,107 @@
//! 运行时领域错误过渡落位。
//!
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。
use crate::MAX_BROWSE_HISTORY_BATCH_SIZE;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeSettingsFieldError {
MissingUserId,
}
impl std::fmt::Display for RuntimeSettingsFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingUserId => f.write_str("runtime_setting.user_id 不能为空"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeBrowseHistoryFieldError {
MissingUserId,
TooManyEntries,
}
impl std::fmt::Display for RuntimeBrowseHistoryFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingUserId => f.write_str("browse_history.user_id 不能为空"),
Self::TooManyEntries => write!(
f,
"browse_history.entries 单次最多只允许 {} 条",
MAX_BROWSE_HISTORY_BATCH_SIZE
),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeProfileFieldError {
MissingUserId,
MissingLedgerId,
InvalidWalletAmount,
WalletAmountOverflow,
WalletBalanceOverflow,
InsufficientWalletBalance,
MissingInviteCode,
MissingRedeemCode,
RedeemCodeDisabled,
RedeemCodeUsesExhausted,
RedeemCodeNotAllowedForUser,
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
MissingProductId,
MissingWorldKey,
MissingBottomTab,
MissingCheckpointSessionId,
UnknownRechargeProduct,
InvalidGameStateJson,
InvalidCurrentStoryJson,
MissingRuntimeSessionId,
RuntimeSessionMismatch {
expected_session_id: String,
actual_session_id: String,
},
NonPersistentRuntimeSnapshot,
}
impl std::fmt::Display for RuntimeProfileFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"),
Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"),
Self::InsufficientWalletBalance => f.write_str("叙世币余额不足"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
Self::RedeemCodeDisabled => f.write_str("兑换码已停用"),
Self::RedeemCodeUsesExhausted => f.write_str("兑换次数已用完"),
Self::RedeemCodeNotAllowedForUser => 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 不能为空"),
Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"),
Self::UnknownRechargeProduct => f.write_str("recharge.product_id 不存在"),
Self::InvalidGameStateJson => {
f.write_str("runtime_snapshot.game_state 必须是合法 JSON")
}
Self::InvalidCurrentStoryJson => {
f.write_str("runtime_snapshot.current_story 必须是合法 JSON object 或 null")
}
Self::MissingRuntimeSessionId => {
f.write_str("服务端运行时快照缺少 runtimeSessionId无法创建 checkpoint")
}
Self::RuntimeSessionMismatch { .. } => {
f.write_str("checkpoint sessionId 与服务端运行时快照不一致")
}
Self::NonPersistentRuntimeSnapshot => {
f.write_str("预览或测试运行态不能创建正式 checkpoint")
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,33 @@
# module-story 独立模块 package 占位说明
# module-story 剧情领域模块说明
日期:`2026-04-20`
日期:`2026-04-29`
## 1. package 职责
`module-story`故事主循环模块 package后续负责:
`module-story` RPG story session 的纯领域模块,当前负责:
1. `story_session``story_event` 等故事会话状态模型
2. story action 主循环与状态推进规则
3. `currentStory`、story state、兼容视图模型的模块级拼装
4. `apps/api-server` 的 story facade 与 SSE 输出对接
5.`apps/spacetime-module` 的 story 表、reducer、view 聚合对接
1. `StorySessionSnapshot``StoryEventSnapshot` 等故事会话与事件快照。
2. `begin / continue / state query` 相关输入命令的字段归一化与基础校验。
3. 剧情会话开局、续写事件追加、版本推进和只读记录投影。
4. `spacetime-module``spacetime-client``api-server` 提供可复用的纯 Rust 规则。
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入 reducer、view 与 SSE 兼容实现。
当前模块已经完成 DDD 薄层物理拆分,不再是目录占位:
后续与本 package 直接相关的任务包括:
1. `src/domain.rs`:会话领域快照、状态和值对象。
2. `src/commands.rs`story session scoped 输入命令与校验。
3. `src/events.rs`:剧情事件类型、事件快照和事件 ID 生成。
4. `src/application.rs`:快照构造、续写应用服务和读模型记录映射。
5. `src/errors.rs`:剧情字段错误与中文错误文案。
6. `src/lib.rs`:只保留模块公开导出,保持 `module_story::*` 对外 API 稳定。
1. 设计 `story_session``story_event`
2. 设计 `resolve_story_action``continue_story``begin_story_session`
3. 对齐 `RuntimeStoryActionResponse``RuntimeStoryOptionView`
4. 落地 `/api/runtime/story/*` 兼容链路
当前仍未扩到完整运行态动作结算。`inventory action`、NPC interaction、forge、battle、quest 等写侧闭环继续归入 `WP-RS Runtime Story 去兼容层``WP-RPG Gameplay 域` 后续切片。
## 3. 边界约束
1. `module-story` 负责故事状态真相与主循环规则,不把外部 LLM、OSS、短信、微信等副作用塞进模块内部
2. 流式文本输出与 HTTP 协议兼容由 `apps/api-server` 暴露,但阶段状态与故事真相必须回写到 `apps/spacetime-module` 聚合的状态模型中
3. 跨模块联动通过明确的 reducer 与模块边界协作,不回到单大 service 直接改整包 JSON 的旧实现方式。
1. `module-story` 不调用 LLM、OSS、HTTP、SpacetimeDB client 或旧 Node 服务
2. `module-story` 不恢复旧 `/api/runtime/story/*` 兼容路由HTTP 主链固定走 G1 冻结的 `/api/story/*` session scoped route
3. SpacetimeDB 表、reducer、procedure 和 row mapper 只在 `spacetime-module` adapter 中落地。
4. `api-server` 只负责 BFF、鉴权、SSE、DTO 映射和平台能力编排,不复制本模块的领域规则。
5. 前端只消费后端投影和新 contract不在 UI 或 hooks 中重建 story session 真相。

View File

@@ -1,3 +1,167 @@
//! 剧情应用编排过渡落位
//! 剧情应用服务与读模型映射
//!
//! 这里只返回剧情快照、事件和待投影结果,不直接调用模型或数据库。
//! 应用层负责把命令变成快照、事件和前端可消费记录;它不直接调用模型、HTTP、
//! SpacetimeDB 或旧 Node 兼容服务。
use crate::commands::{StoryContinueInput, StorySessionInput, normalize_optional_value};
use crate::domain::{INITIAL_STORY_SESSION_VERSION, StorySessionSnapshot, StorySessionStatus};
use crate::errors::StorySessionFieldError;
use crate::events::{StoryEventKind, StoryEventSnapshot};
use serde::{Deserialize, Serialize};
use shared_kernel::format_timestamp_micros;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionProcedureResult {
pub ok: bool,
pub session: Option<StorySessionSnapshot>,
pub event: Option<StoryEventSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionStateProcedureResult {
pub ok: bool,
pub session: Option<StorySessionSnapshot>,
pub events: Vec<StoryEventSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StorySessionRecord {
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
pub opening_summary: Option<String>,
pub latest_narrative_text: String,
pub latest_choice_function_id: Option<String>,
pub status: String,
pub version: u32,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoryEventRecord {
pub event_id: String,
pub story_session_id: String,
pub event_kind: String,
pub narrative_text: String,
pub choice_function_id: Option<String>,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StorySessionResultRecord {
pub session: StorySessionRecord,
pub event: StoryEventRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StorySessionStateRecord {
pub session: StorySessionRecord,
pub events: Vec<StoryEventRecord>,
}
pub fn build_story_session_snapshot(input: StorySessionInput) -> StorySessionSnapshot {
StorySessionSnapshot {
story_session_id: input.story_session_id,
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
world_profile_id: input.world_profile_id,
initial_prompt: input.initial_prompt,
opening_summary: normalize_optional_value(input.opening_summary),
latest_narrative_text: String::new(),
latest_choice_function_id: None,
status: StorySessionStatus::Active,
version: INITIAL_STORY_SESSION_VERSION,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
}
}
pub fn apply_story_continue(
current: StorySessionSnapshot,
input: StoryContinueInput,
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), StorySessionFieldError> {
crate::commands::validate_story_continue_input(&input)?;
if current.version == 0 {
return Err(StorySessionFieldError::InvalidVersion);
}
let event = StoryEventSnapshot {
event_id: input.event_id,
story_session_id: current.story_session_id.clone(),
event_kind: StoryEventKind::StoryContinued,
narrative_text: input.narrative_text.clone(),
choice_function_id: normalize_optional_value(input.choice_function_id),
created_at_micros: input.updated_at_micros,
};
let next = StorySessionSnapshot {
latest_narrative_text: input.narrative_text,
latest_choice_function_id: event.choice_function_id.clone(),
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok((next, event))
}
pub fn build_story_session_record(snapshot: StorySessionSnapshot) -> StorySessionRecord {
StorySessionRecord {
story_session_id: snapshot.story_session_id,
runtime_session_id: snapshot.runtime_session_id,
actor_user_id: snapshot.actor_user_id,
world_profile_id: snapshot.world_profile_id,
initial_prompt: snapshot.initial_prompt,
opening_summary: snapshot.opening_summary,
latest_narrative_text: snapshot.latest_narrative_text,
latest_choice_function_id: snapshot.latest_choice_function_id,
status: snapshot.status.as_str().to_string(),
version: snapshot.version,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
pub fn build_story_event_record(snapshot: StoryEventSnapshot) -> StoryEventRecord {
StoryEventRecord {
event_id: snapshot.event_id,
story_session_id: snapshot.story_session_id,
event_kind: snapshot.event_kind.as_str().to_string(),
narrative_text: snapshot.narrative_text,
choice_function_id: snapshot.choice_function_id,
created_at: format_timestamp_micros(snapshot.created_at_micros),
}
}
pub fn build_story_session_result_record(
session: StorySessionSnapshot,
event: StoryEventSnapshot,
) -> StorySessionResultRecord {
StorySessionResultRecord {
session: build_story_session_record(session),
event: build_story_event_record(event),
}
}
pub fn build_story_session_state_record(
session: StorySessionSnapshot,
events: Vec<StoryEventSnapshot>,
) -> StorySessionStateRecord {
StorySessionStateRecord {
session: build_story_session_record(session),
events: events
.into_iter()
.map(build_story_event_record)
.collect::<Vec<_>>(),
}
}

View File

@@ -1,3 +1,148 @@
//! 剧情写入命令过渡落位
//! 剧情写入命令与输入归一化
//!
//! 用于表达开启剧情会话、继续剧情和归档会话等输入。
//! 命令层只处理 story session scoped 的输入结构、字段裁剪和基础校验,不读取数据库,
//! 也不兼容旧 `/api/runtime/story/*` 总入口。
use crate::errors::StorySessionFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionInput {
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
pub opening_summary: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoryContinueInput {
pub story_session_id: String,
pub event_id: String,
pub narrative_text: String,
pub choice_function_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionStateInput {
pub story_session_id: String,
}
pub fn build_story_session_input(
story_session_id: String,
runtime_session_id: String,
actor_user_id: String,
world_profile_id: String,
initial_prompt: String,
opening_summary: Option<String>,
created_at_micros: i64,
) -> Result<StorySessionInput, StorySessionFieldError> {
let input = StorySessionInput {
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
runtime_session_id: normalize_required_string(runtime_session_id).unwrap_or_default(),
actor_user_id: normalize_required_string(actor_user_id).unwrap_or_default(),
world_profile_id: normalize_required_string(world_profile_id).unwrap_or_default(),
initial_prompt: normalize_required_string(initial_prompt).unwrap_or_default(),
opening_summary: normalize_optional_value(opening_summary),
created_at_micros,
};
validate_story_session_input(&input)?;
Ok(input)
}
pub fn build_story_session_state_input(
story_session_id: String,
) -> Result<StorySessionStateInput, StorySessionFieldError> {
let input = StorySessionStateInput {
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
};
validate_story_session_state_input(&input)?;
Ok(input)
}
pub fn build_story_continue_input(
story_session_id: String,
event_id: String,
narrative_text: String,
choice_function_id: Option<String>,
updated_at_micros: i64,
) -> Result<StoryContinueInput, StorySessionFieldError> {
let input = StoryContinueInput {
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
event_id: normalize_required_string(event_id).unwrap_or_default(),
narrative_text: normalize_required_string(narrative_text).unwrap_or_default(),
choice_function_id: normalize_optional_value(choice_function_id),
updated_at_micros,
};
validate_story_continue_input(&input)?;
Ok(input)
}
pub fn validate_story_session_input(
input: &StorySessionInput,
) -> Result<(), StorySessionFieldError> {
if normalize_required_string(&input.story_session_id).is_none() {
return Err(StorySessionFieldError::MissingSessionId);
}
if normalize_required_string(&input.runtime_session_id).is_none() {
return Err(StorySessionFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(&input.actor_user_id).is_none() {
return Err(StorySessionFieldError::MissingActorUserId);
}
if normalize_required_string(&input.world_profile_id).is_none() {
return Err(StorySessionFieldError::MissingWorldProfileId);
}
if normalize_required_string(&input.initial_prompt).is_none() {
return Err(StorySessionFieldError::MissingInitialPrompt);
}
Ok(())
}
pub fn validate_story_session_state_input(
input: &StorySessionStateInput,
) -> Result<(), StorySessionFieldError> {
if normalize_required_string(&input.story_session_id).is_none() {
return Err(StorySessionFieldError::MissingSessionId);
}
Ok(())
}
pub fn validate_story_continue_input(
input: &StoryContinueInput,
) -> Result<(), StorySessionFieldError> {
if normalize_required_string(&input.story_session_id).is_none() {
return Err(StorySessionFieldError::MissingSessionId);
}
if normalize_required_string(&input.event_id).is_none() {
return Err(StorySessionFieldError::MissingEventId);
}
if normalize_required_string(&input.narrative_text).is_none() {
return Err(StorySessionFieldError::MissingNarrativeText);
}
Ok(())
}
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}

View File

@@ -1,4 +1,55 @@
//! 剧情领域模型过渡落位
//! 剧情会话领域模型。
//!
//! 后续迁移 `StorySession`、`StoryEvent` 和剧情推进规则时,只保留剧情聚合内部变化;
//! LLM 生成和 SpacetimeDB 写回外层 adapter 处理
//! 这里仅保存 RPG story session 聚合内的稳定状态和值对象LLM 生成、HTTP 回包和
//! SpacetimeDB 写回都留给外层 adapter,不在领域模型里直接发生副作用
use serde::{Deserialize, Serialize};
use shared_kernel::build_prefixed_seed_id;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 剧情会话 ID 的稳定前缀,统一放在领域层,避免 adapter 重复拼接。
pub const STORY_SESSION_ID_PREFIX: &str = "storysess_";
/// 新建剧情会话快照的初始版本号。
pub const INITIAL_STORY_SESSION_VERSION: u32 = 1;
/// 剧情会话状态,用于判断后续 story command 是否还能继续推进。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StorySessionStatus {
Active,
Completed,
Archived,
}
impl StorySessionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Completed => "completed",
Self::Archived => "archived",
}
}
}
/// story session 的领域快照SpacetimeDB row 与 HTTP DTO 都从它映射。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionSnapshot {
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
pub opening_summary: Option<String>,
pub latest_narrative_text: String,
pub latest_choice_function_id: Option<String>,
pub status: StorySessionStatus,
pub version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
pub fn generate_story_session_id(seed_micros: i64) -> String {
build_prefixed_seed_id(STORY_SESSION_ID_PREFIX, seed_micros)
}

View File

@@ -1,3 +1,36 @@
//! 剧情领域错误过渡落位
//! 剧情领域错误。
//!
//! 错误保持纯剧情规则语义,例如会话不存在、状态不允许或输入为空
//! 错误保持纯 story session 规则语义,例如会话字段缺失、事件内容为空或版本非法
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StorySessionFieldError {
MissingSessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingWorldProfileId,
MissingInitialPrompt,
MissingNarrativeText,
MissingEventId,
InvalidVersion,
}
impl fmt::Display for StorySessionFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingSessionId => f.write_str("story_session.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("story_session.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("story_session.actor_user_id 不能为空"),
Self::MissingWorldProfileId => f.write_str("story_session.world_profile_id 不能为空"),
Self::MissingInitialPrompt => f.write_str("story_session.initial_prompt 不能为空"),
Self::MissingNarrativeText => f.write_str("story_event.narrative_text 不能为空"),
Self::MissingEventId => f.write_str("story_event.event_id 不能为空"),
Self::InvalidVersion => f.write_str("story_session.version 必须大于 0"),
}
}
}
impl Error for StorySessionFieldError {}

View File

@@ -1,3 +1,59 @@
//! 剧情领域事件过渡落位
//! 剧情领域事件。
//!
//! 用于表达剧情会话已开启、剧情已推进和剧情事件已追加等事实。
//! 事件只表达 story session 已经发生的领域事实;是否写入 SpacetimeDB event table 或
//! 映射成 HTTP/SSE 输出,由外层 adapter 决定。
use crate::domain::StorySessionSnapshot;
use serde::{Deserialize, Serialize};
use shared_kernel::build_prefixed_seed_id;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 剧情事件 ID 的稳定前缀。
pub const STORY_EVENT_ID_PREFIX: &str = "storyevt_";
/// 剧情事件类型,当前覆盖开局和续写两条最小主链。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StoryEventKind {
SessionStarted,
StoryContinued,
}
impl StoryEventKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::SessionStarted => "session_started",
Self::StoryContinued => "story_continued",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoryEventSnapshot {
pub event_id: String,
pub story_session_id: String,
pub event_kind: StoryEventKind,
pub narrative_text: String,
pub choice_function_id: Option<String>,
pub created_at_micros: i64,
}
pub fn build_story_started_event(snapshot: &StorySessionSnapshot) -> StoryEventSnapshot {
StoryEventSnapshot {
event_id: generate_story_event_id(snapshot.created_at_micros),
story_session_id: snapshot.story_session_id.clone(),
event_kind: StoryEventKind::SessionStarted,
narrative_text: snapshot
.opening_summary
.clone()
.unwrap_or_else(|| snapshot.initial_prompt.clone()),
choice_function_id: None,
created_at_micros: snapshot.created_at_micros,
}
}
pub fn generate_story_event_id(seed_micros: i64) -> String {
build_prefixed_seed_id(STORY_EVENT_ID_PREFIX, seed_micros)
}

View File

@@ -4,424 +4,11 @@ mod domain;
mod errors;
mod events;
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_seed_id, format_timestamp_micros,
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const STORY_SESSION_ID_PREFIX: &str = "storysess_";
pub const STORY_EVENT_ID_PREFIX: &str = "storyevt_";
pub const INITIAL_STORY_SESSION_VERSION: u32 = 1;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StorySessionStatus {
Active,
Completed,
Archived,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StoryEventKind {
SessionStarted,
StoryContinued,
}
impl StorySessionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Completed => "completed",
Self::Archived => "archived",
}
}
}
impl StoryEventKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::SessionStarted => "session_started",
Self::StoryContinued => "story_continued",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StorySessionFieldError {
MissingSessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingWorldProfileId,
MissingInitialPrompt,
MissingNarrativeText,
MissingEventId,
InvalidVersion,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionInput {
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
pub opening_summary: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionSnapshot {
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
pub opening_summary: Option<String>,
pub latest_narrative_text: String,
pub latest_choice_function_id: Option<String>,
pub status: StorySessionStatus,
pub version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoryContinueInput {
pub story_session_id: String,
pub event_id: String,
pub narrative_text: String,
pub choice_function_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionStateInput {
pub story_session_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoryEventSnapshot {
pub event_id: String,
pub story_session_id: String,
pub event_kind: StoryEventKind,
pub narrative_text: String,
pub choice_function_id: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionProcedureResult {
pub ok: bool,
pub session: Option<StorySessionSnapshot>,
pub event: Option<StoryEventSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionStateProcedureResult {
pub ok: bool,
pub session: Option<StorySessionSnapshot>,
pub events: Vec<StoryEventSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StorySessionRecord {
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
pub opening_summary: Option<String>,
pub latest_narrative_text: String,
pub latest_choice_function_id: Option<String>,
pub status: String,
pub version: u32,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoryEventRecord {
pub event_id: String,
pub story_session_id: String,
pub event_kind: String,
pub narrative_text: String,
pub choice_function_id: Option<String>,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StorySessionResultRecord {
pub session: StorySessionRecord,
pub event: StoryEventRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StorySessionStateRecord {
pub session: StorySessionRecord,
pub events: Vec<StoryEventRecord>,
}
pub fn build_story_session_input(
story_session_id: String,
runtime_session_id: String,
actor_user_id: String,
world_profile_id: String,
initial_prompt: String,
opening_summary: Option<String>,
created_at_micros: i64,
) -> Result<StorySessionInput, StorySessionFieldError> {
let input = StorySessionInput {
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
runtime_session_id: normalize_required_string(runtime_session_id).unwrap_or_default(),
actor_user_id: normalize_required_string(actor_user_id).unwrap_or_default(),
world_profile_id: normalize_required_string(world_profile_id).unwrap_or_default(),
initial_prompt: normalize_required_string(initial_prompt).unwrap_or_default(),
opening_summary: normalize_optional_value(opening_summary),
created_at_micros,
};
validate_story_session_input(&input)?;
Ok(input)
}
pub fn build_story_session_state_input(
story_session_id: String,
) -> Result<StorySessionStateInput, StorySessionFieldError> {
let input = StorySessionStateInput {
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
};
validate_story_session_state_input(&input)?;
Ok(input)
}
pub fn build_story_continue_input(
story_session_id: String,
event_id: String,
narrative_text: String,
choice_function_id: Option<String>,
updated_at_micros: i64,
) -> Result<StoryContinueInput, StorySessionFieldError> {
let input = StoryContinueInput {
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
event_id: normalize_required_string(event_id).unwrap_or_default(),
narrative_text: normalize_required_string(narrative_text).unwrap_or_default(),
choice_function_id: normalize_optional_value(choice_function_id),
updated_at_micros,
};
validate_story_continue_input(&input)?;
Ok(input)
}
pub fn validate_story_session_input(
input: &StorySessionInput,
) -> Result<(), StorySessionFieldError> {
if normalize_required_string(&input.story_session_id).is_none() {
return Err(StorySessionFieldError::MissingSessionId);
}
if normalize_required_string(&input.runtime_session_id).is_none() {
return Err(StorySessionFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(&input.actor_user_id).is_none() {
return Err(StorySessionFieldError::MissingActorUserId);
}
if normalize_required_string(&input.world_profile_id).is_none() {
return Err(StorySessionFieldError::MissingWorldProfileId);
}
if normalize_required_string(&input.initial_prompt).is_none() {
return Err(StorySessionFieldError::MissingInitialPrompt);
}
Ok(())
}
pub fn validate_story_session_state_input(
input: &StorySessionStateInput,
) -> Result<(), StorySessionFieldError> {
if normalize_required_string(&input.story_session_id).is_none() {
return Err(StorySessionFieldError::MissingSessionId);
}
Ok(())
}
pub fn validate_story_continue_input(
input: &StoryContinueInput,
) -> Result<(), StorySessionFieldError> {
if normalize_required_string(&input.story_session_id).is_none() {
return Err(StorySessionFieldError::MissingSessionId);
}
if normalize_required_string(&input.event_id).is_none() {
return Err(StorySessionFieldError::MissingEventId);
}
if normalize_required_string(&input.narrative_text).is_none() {
return Err(StorySessionFieldError::MissingNarrativeText);
}
Ok(())
}
pub fn build_story_session_snapshot(input: StorySessionInput) -> StorySessionSnapshot {
StorySessionSnapshot {
story_session_id: input.story_session_id,
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
world_profile_id: input.world_profile_id,
initial_prompt: input.initial_prompt,
opening_summary: normalize_optional_value(input.opening_summary),
latest_narrative_text: String::new(),
latest_choice_function_id: None,
status: StorySessionStatus::Active,
version: INITIAL_STORY_SESSION_VERSION,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
}
}
pub fn build_story_started_event(snapshot: &StorySessionSnapshot) -> StoryEventSnapshot {
StoryEventSnapshot {
event_id: generate_story_event_id(snapshot.created_at_micros),
story_session_id: snapshot.story_session_id.clone(),
event_kind: StoryEventKind::SessionStarted,
narrative_text: snapshot
.opening_summary
.clone()
.unwrap_or_else(|| snapshot.initial_prompt.clone()),
choice_function_id: None,
created_at_micros: snapshot.created_at_micros,
}
}
pub fn apply_story_continue(
current: StorySessionSnapshot,
input: StoryContinueInput,
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), StorySessionFieldError> {
validate_story_continue_input(&input)?;
if current.version == 0 {
return Err(StorySessionFieldError::InvalidVersion);
}
let event = StoryEventSnapshot {
event_id: input.event_id,
story_session_id: current.story_session_id.clone(),
event_kind: StoryEventKind::StoryContinued,
narrative_text: input.narrative_text.clone(),
choice_function_id: normalize_optional_value(input.choice_function_id),
created_at_micros: input.updated_at_micros,
};
let next = StorySessionSnapshot {
latest_narrative_text: input.narrative_text,
latest_choice_function_id: event.choice_function_id.clone(),
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok((next, event))
}
pub fn generate_story_session_id(seed_micros: i64) -> String {
build_prefixed_seed_id(STORY_SESSION_ID_PREFIX, seed_micros)
}
pub fn generate_story_event_id(seed_micros: i64) -> String {
build_prefixed_seed_id(STORY_EVENT_ID_PREFIX, seed_micros)
}
pub fn build_story_session_record(snapshot: StorySessionSnapshot) -> StorySessionRecord {
StorySessionRecord {
story_session_id: snapshot.story_session_id,
runtime_session_id: snapshot.runtime_session_id,
actor_user_id: snapshot.actor_user_id,
world_profile_id: snapshot.world_profile_id,
initial_prompt: snapshot.initial_prompt,
opening_summary: snapshot.opening_summary,
latest_narrative_text: snapshot.latest_narrative_text,
latest_choice_function_id: snapshot.latest_choice_function_id,
status: snapshot.status.as_str().to_string(),
version: snapshot.version,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
pub fn build_story_event_record(snapshot: StoryEventSnapshot) -> StoryEventRecord {
StoryEventRecord {
event_id: snapshot.event_id,
story_session_id: snapshot.story_session_id,
event_kind: snapshot.event_kind.as_str().to_string(),
narrative_text: snapshot.narrative_text,
choice_function_id: snapshot.choice_function_id,
created_at: format_timestamp_micros(snapshot.created_at_micros),
}
}
pub fn build_story_session_result_record(
session: StorySessionSnapshot,
event: StoryEventSnapshot,
) -> StorySessionResultRecord {
StorySessionResultRecord {
session: build_story_session_record(session),
event: build_story_event_record(event),
}
}
pub fn build_story_session_state_record(
session: StorySessionSnapshot,
events: Vec<StoryEventSnapshot>,
) -> StorySessionStateRecord {
StorySessionStateRecord {
session: build_story_session_record(session),
events: events
.into_iter()
.map(build_story_event_record)
.collect::<Vec<_>>(),
}
}
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
impl fmt::Display for StorySessionFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingSessionId => f.write_str("story_session.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("story_session.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("story_session.actor_user_id 不能为空"),
Self::MissingWorldProfileId => f.write_str("story_session.world_profile_id 不能为空"),
Self::MissingInitialPrompt => f.write_str("story_session.initial_prompt 不能为空"),
Self::MissingNarrativeText => f.write_str("story_event.narrative_text 不能为空"),
Self::MissingEventId => f.write_str("story_event.event_id 不能为空"),
Self::InvalidVersion => f.write_str("story_session.version 必须大于 0"),
}
}
}
impl Error for StorySessionFieldError {}
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
#[cfg(test)]
mod tests {

View File

@@ -18,6 +18,7 @@ serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["std"] }
tracing = "0.1"
url = "2"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }

View File

@@ -19,6 +19,7 @@ use sha2::{Digest, Sha256};
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
use url::Url;
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
@@ -35,6 +36,12 @@ pub const DEFAULT_SMS_VALID_TIME_SECONDS: u64 = 300;
pub const DEFAULT_SMS_INTERVAL_SECONDS: u64 = 60;
pub const DEFAULT_SMS_DUPLICATE_POLICY: u8 = 1;
pub const DEFAULT_SMS_CASE_AUTH_POLICY: u8 = 1;
pub const DEFAULT_WECHAT_AUTHORIZE_ENDPOINT: &str = "https://open.weixin.qq.com/connect/qrconnect";
pub const DEFAULT_WECHAT_IN_APP_AUTHORIZE_ENDPOINT: &str =
"https://open.weixin.qq.com/connect/oauth2/authorize";
pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
"https://api.weixin.qq.com/sns/oauth2/access_token";
pub const DEFAULT_WECHAT_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo";
type HmacSha1 = Hmac<Sha1>;
@@ -159,6 +166,60 @@ pub struct SmsVerifyCodeRequest {
pub provider_out_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene {
Desktop,
WechatInApp,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatAuthConfig {
pub enabled: bool,
pub provider: String,
pub app_id: Option<String>,
pub app_secret: Option<String>,
pub authorize_endpoint: String,
pub access_token_endpoint: String,
pub user_info_endpoint: String,
pub mock_user_id: String,
pub mock_union_id: Option<String>,
pub mock_display_name: String,
pub mock_avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityProfile {
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub enum WechatProvider {
Disabled,
Mock(MockWechatProvider),
Real(RealWechatProvider),
}
#[derive(Clone, Debug)]
pub struct MockWechatProvider {
mock_user_id: String,
mock_union_id: Option<String>,
mock_display_name: String,
mock_avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct RealWechatProvider {
client: Client,
app_id: String,
app_secret: String,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
}
#[derive(Clone, Debug)]
pub enum SmsAuthProvider {
Mock(MockSmsAuthProvider),
@@ -202,6 +263,54 @@ pub enum SmsProviderError {
Upstream(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum WechatProviderError {
Disabled,
MissingCode,
InvalidConfig(String),
InvalidCallback(String),
RequestFailed(String),
DeserializeFailed(String),
Upstream(String),
MissingProfile(String),
}
// 鉴权平台错误统一先归类api-server 再决定 HTTP status 和错误 envelope。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthPlatformErrorKind {
InvalidConfig,
InvalidClaims,
SignFailed,
VerifyFailed,
CookieConfig,
HashFailed,
InvalidVerifyCode,
Disabled,
MissingCode,
InvalidCallback,
RequestFailed,
DeserializeFailed,
MissingProfile,
Upstream,
}
#[derive(Debug, Deserialize)]
struct WechatAccessTokenResponse {
access_token: Option<String>,
openid: Option<String>,
unionid: Option<String>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatUserInfoResponse {
openid: Option<String>,
unionid: Option<String>,
nickname: Option<String>,
headimgurl: Option<String>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AliyunSendSmsVerifyCodeResponse {
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
@@ -512,6 +621,257 @@ impl SmsAuthProvider {
}
}
impl WechatAuthConfig {
#[allow(clippy::too_many_arguments)]
pub fn new(
enabled: bool,
provider: String,
app_id: Option<String>,
app_secret: Option<String>,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
mock_user_id: String,
mock_union_id: Option<String>,
mock_display_name: String,
mock_avatar_url: Option<String>,
) -> Self {
Self {
enabled,
provider,
app_id,
app_secret,
authorize_endpoint,
access_token_endpoint,
user_info_endpoint,
mock_user_id,
mock_union_id,
mock_display_name,
mock_avatar_url,
}
}
}
impl WechatProvider {
pub fn new(config: WechatAuthConfig) -> Self {
if !config.enabled {
return Self::Disabled;
}
if config.provider.trim().eq_ignore_ascii_case("mock") {
return Self::Mock(MockWechatProvider {
mock_user_id: config.mock_user_id,
mock_union_id: config.mock_union_id,
mock_display_name: config.mock_display_name,
mock_avatar_url: config.mock_avatar_url,
});
}
let Some(app_id) = config.app_id else {
return Self::Disabled;
};
let Some(app_secret) = config.app_secret else {
return Self::Disabled;
};
Self::Real(RealWechatProvider {
client: Client::new(),
app_id,
app_secret,
authorize_endpoint: config.authorize_endpoint,
access_token_endpoint: config.access_token_endpoint,
user_info_endpoint: config.user_info_endpoint,
})
}
pub fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, WechatProviderError> {
match self {
Self::Disabled => Err(WechatProviderError::Disabled),
Self::Mock(_) => build_mock_wechat_authorization_url(callback_url, state),
Self::Real(provider) => provider.build_authorization_url(callback_url, state, scene),
}
}
pub async fn resolve_callback_profile(
&self,
code: Option<&str>,
mock_code: Option<&str>,
) -> Result<WechatIdentityProfile, WechatProviderError> {
match self {
Self::Disabled => Err(WechatProviderError::Disabled),
Self::Mock(provider) => Ok(provider.resolve_callback_profile(mock_code)),
Self::Real(provider) => provider.resolve_callback_profile(code).await,
}
}
}
impl MockWechatProvider {
fn resolve_callback_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile {
let provider_uid = mock_code
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(self.mock_user_id.as_str())
.to_string();
WechatIdentityProfile {
provider_uid,
provider_union_id: self.mock_union_id.clone(),
display_name: Some(self.mock_display_name.clone()),
avatar_url: self.mock_avatar_url.clone(),
}
}
}
impl RealWechatProvider {
fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, WechatProviderError> {
let endpoint = match scene {
WechatAuthScene::Desktop => &self.authorize_endpoint,
WechatAuthScene::WechatInApp => DEFAULT_WECHAT_IN_APP_AUTHORIZE_ENDPOINT,
};
let mut url = Url::parse(endpoint).map_err(|error| {
WechatProviderError::InvalidConfig(format!("微信授权地址非法:{error}"))
})?;
url.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("redirect_uri", callback_url)
.append_pair("response_type", "code")
.append_pair(
"scope",
match scene {
WechatAuthScene::Desktop => "snsapi_login",
WechatAuthScene::WechatInApp => "snsapi_userinfo",
},
)
.append_pair("state", state);
Ok(format!("{url}#wechat_redirect"))
}
async fn resolve_callback_profile(
&self,
code: Option<&str>,
) -> Result<WechatIdentityProfile, WechatProviderError> {
let code = code
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(WechatProviderError::MissingCode)?;
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
WechatProviderError::InvalidConfig(format!("微信 access_token 地址非法:{error}"))
})?;
access_token_url
.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("secret", &self.app_secret)
.append_pair("code", code)
.append_pair("grant_type", "authorization_code");
let access_token_payload = self
.client
.get(access_token_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 请求失败");
WechatProviderError::RequestFailed(
"微信登录失败access_token 请求失败".to_string(),
)
})?
.json::<WechatAccessTokenResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 响应解析失败");
WechatProviderError::DeserializeFailed(
"微信登录失败access_token 响应非法".to_string(),
)
})?;
let access_token = access_token_payload
.access_token
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
WechatProviderError::Upstream(format!(
"微信登录失败:{}",
access_token_payload
.errmsg
.unwrap_or_else(|| "缺少 access_token".to_string())
))
})?;
let openid = access_token_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
WechatProviderError::MissingProfile("微信登录失败:缺少 openid".to_string())
})?;
let mut user_info_url = Url::parse(&self.user_info_endpoint).map_err(|error| {
WechatProviderError::InvalidConfig(format!("微信用户信息地址非法:{error}"))
})?;
user_info_url
.query_pairs_mut()
.append_pair("access_token", &access_token)
.append_pair("openid", &openid)
.append_pair("lang", "zh_CN");
let user_info_payload = self
.client
.get(user_info_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息请求失败");
WechatProviderError::RequestFailed("微信登录失败:用户信息请求失败".to_string())
})?
.json::<WechatUserInfoResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息响应解析失败");
WechatProviderError::DeserializeFailed("微信登录失败:用户信息响应非法".to_string())
})?;
let provider_uid = user_info_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
WechatProviderError::Upstream(format!(
"微信登录失败:{}",
user_info_payload
.errmsg
.unwrap_or_else(|| "缺少 openid".to_string())
))
})?;
Ok(WechatIdentityProfile {
provider_uid,
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
display_name: user_info_payload.nickname,
avatar_url: user_info_payload.headimgurl,
})
}
}
fn build_mock_wechat_authorization_url(
callback_url: &str,
state: &str,
) -> Result<String, WechatProviderError> {
let mut callback = Url::parse(callback_url).map_err(|error| {
WechatProviderError::InvalidCallback(format!("微信回调地址非法:{error}"))
})?;
callback
.query_pairs_mut()
.append_pair("mock_code", "wx-mock-code")
.append_pair("state", state);
Ok(callback.to_string())
}
impl MockSmsAuthProvider {
async fn send_code(
&self,
@@ -1262,10 +1622,154 @@ impl fmt::Display for SmsProviderError {
impl Error for SmsProviderError {}
impl fmt::Display for WechatProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disabled => f.write_str("微信登录暂未启用"),
Self::MissingCode => f.write_str("缺少微信授权 code"),
Self::InvalidConfig(message)
| Self::InvalidCallback(message)
| Self::RequestFailed(message)
| Self::DeserializeFailed(message)
| Self::Upstream(message)
| Self::MissingProfile(message) => f.write_str(message),
}
}
}
impl Error for WechatProviderError {}
impl JwtError {
pub fn kind(&self) -> AuthPlatformErrorKind {
match self {
Self::InvalidConfig(_) => AuthPlatformErrorKind::InvalidConfig,
Self::InvalidClaims(_) => AuthPlatformErrorKind::InvalidClaims,
Self::SignFailed(_) => AuthPlatformErrorKind::SignFailed,
Self::VerifyFailed(_) => AuthPlatformErrorKind::VerifyFailed,
}
}
}
impl RefreshCookieError {
pub fn kind(&self) -> AuthPlatformErrorKind {
match self {
Self::InvalidConfig(_) => AuthPlatformErrorKind::CookieConfig,
}
}
}
impl PasswordHashError {
pub fn kind(&self) -> AuthPlatformErrorKind {
match self {
Self::HashFailed(_) => AuthPlatformErrorKind::HashFailed,
Self::VerifyFailed(_) => AuthPlatformErrorKind::VerifyFailed,
}
}
}
impl SmsProviderError {
pub fn kind(&self) -> AuthPlatformErrorKind {
match self {
Self::InvalidConfig(_) => AuthPlatformErrorKind::InvalidConfig,
Self::InvalidVerifyCode => AuthPlatformErrorKind::InvalidVerifyCode,
Self::Upstream(_) => AuthPlatformErrorKind::Upstream,
}
}
}
impl WechatProviderError {
pub fn kind(&self) -> AuthPlatformErrorKind {
match self {
Self::Disabled => AuthPlatformErrorKind::Disabled,
Self::MissingCode => AuthPlatformErrorKind::MissingCode,
Self::InvalidConfig(_) => AuthPlatformErrorKind::InvalidConfig,
Self::InvalidCallback(_) => AuthPlatformErrorKind::InvalidCallback,
Self::RequestFailed(_) => AuthPlatformErrorKind::RequestFailed,
Self::DeserializeFailed(_) => AuthPlatformErrorKind::DeserializeFailed,
Self::Upstream(_) => AuthPlatformErrorKind::Upstream,
Self::MissingProfile(_) => AuthPlatformErrorKind::MissingProfile,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_platform_error_kind_is_stable_for_adapter_mapping() {
assert_eq!(
JwtError::InvalidClaims("JWT roles 至少包含一个角色").kind(),
AuthPlatformErrorKind::InvalidClaims
);
assert_eq!(
PasswordHashError::VerifyFailed("密码校验失败".to_string()).kind(),
AuthPlatformErrorKind::VerifyFailed
);
assert_eq!(
SmsProviderError::InvalidVerifyCode.kind(),
AuthPlatformErrorKind::InvalidVerifyCode
);
assert_eq!(
WechatProviderError::MissingCode.kind(),
AuthPlatformErrorKind::MissingCode
);
}
#[test]
fn mock_wechat_provider_builds_callback_authorization_url() {
let provider = WechatProvider::new(WechatAuthConfig::new(
true,
"mock".to_string(),
None,
None,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
"wx-user-001".to_string(),
Some("wx-union-001".to_string()),
"微信测试用户".to_string(),
Some("https://example.test/avatar.png".to_string()),
));
let authorization_url = provider
.build_authorization_url(
"http://127.0.0.1:3000/api/auth/wechat/callback",
"state_001",
&WechatAuthScene::Desktop,
)
.expect("mock authorization url should build");
assert!(authorization_url.contains("mock_code=wx-mock-code"));
assert!(authorization_url.contains("state=state_001"));
}
#[tokio::test]
async fn mock_wechat_provider_resolves_identity_profile() {
let provider = WechatProvider::new(WechatAuthConfig::new(
true,
"mock".to_string(),
None,
None,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
"wx-user-001".to_string(),
Some("wx-union-001".to_string()),
"微信测试用户".to_string(),
None,
));
let profile = provider
.resolve_callback_profile(None, Some("wx-code-001"))
.await
.expect("mock profile should resolve");
assert_eq!(profile.provider_uid, "wx-code-001");
assert_eq!(profile.provider_union_id.as_deref(), Some("wx-union-001"));
assert_eq!(profile.display_name.as_deref(), Some("微信测试用户"));
}
fn build_jwt_config() -> JwtConfig {
JwtConfig::new(
"https://auth.genarrative.local".to_string(),

View File

@@ -109,6 +109,20 @@ pub enum LlmError {
Deserialize(String),
}
// 平台层只暴露稳定错误分类HTTP status 和业务文案由 api-server 再映射。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LlmErrorKind {
InvalidConfig,
InvalidRequest,
Timeout,
Connectivity,
Upstream,
StreamUnavailable,
EmptyResponse,
Transport,
Deserialize,
}
// 统一 OpenAI 兼容文本网关 client。
#[derive(Clone, Debug)]
pub struct LlmClient {
@@ -397,6 +411,22 @@ impl fmt::Display for LlmError {
impl Error for LlmError {}
impl LlmError {
pub fn kind(&self) -> LlmErrorKind {
match self {
Self::InvalidConfig(_) => LlmErrorKind::InvalidConfig,
Self::InvalidRequest(_) => LlmErrorKind::InvalidRequest,
Self::Timeout { .. } => LlmErrorKind::Timeout,
Self::Connectivity { .. } => LlmErrorKind::Connectivity,
Self::Upstream { .. } => LlmErrorKind::Upstream,
Self::StreamUnavailable => LlmErrorKind::StreamUnavailable,
Self::EmptyResponse => LlmErrorKind::EmptyResponse,
Self::Transport(_) => LlmErrorKind::Transport,
Self::Deserialize(_) => LlmErrorKind::Deserialize,
}
}
}
impl LlmClient {
pub fn new(config: LlmConfig) -> Result<Self, LlmError> {
let http_client = Client::builder().build().map_err(|error| {
@@ -1108,6 +1138,23 @@ mod tests {
use super::*;
#[test]
fn llm_error_kind_is_stable_for_adapter_mapping() {
assert_eq!(
LlmError::InvalidConfig("bad config".to_string()).kind(),
LlmErrorKind::InvalidConfig
);
assert_eq!(
LlmError::Upstream {
status_code: 429,
message: "too many requests".to_string(),
}
.kind(),
LlmErrorKind::Upstream
);
assert_eq!(LlmError::EmptyResponse.kind(), LlmErrorKind::EmptyResponse);
}
struct MockResponse {
status_line: &'static str,
content_type: &'static str,

View File

@@ -196,6 +196,17 @@ pub enum OssError {
Sign(String),
}
// 平台 OSS 错误只先归类,不在 platform 层绑定 HTTP status。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OssErrorKind {
InvalidConfig,
InvalidRequest,
ObjectNotFound,
Request,
SerializePolicy,
Sign,
}
impl LegacyAssetPrefix {
pub fn parse(raw: &str) -> Option<Self> {
let normalized = raw
@@ -620,6 +631,19 @@ impl fmt::Display for OssError {
impl Error for OssError {}
impl OssError {
pub fn kind(&self) -> OssErrorKind {
match self {
Self::InvalidConfig(_) => OssErrorKind::InvalidConfig,
Self::InvalidRequest(_) => OssErrorKind::InvalidRequest,
Self::ObjectNotFound(_) => OssErrorKind::ObjectNotFound,
Self::Request(_) => OssErrorKind::Request,
Self::SerializePolicy(_) => OssErrorKind::SerializePolicy,
Self::Sign(_) => OssErrorKind::Sign,
}
}
}
fn build_policy_json(
bucket: &str,
object_key: &str,
@@ -1011,6 +1035,22 @@ fn encode_url_query_value(value: &str) -> String {
mod tests {
use super::*;
#[test]
fn oss_error_kind_is_stable_for_adapter_mapping() {
assert_eq!(
OssError::InvalidConfig("bad config".to_string()).kind(),
OssErrorKind::InvalidConfig
);
assert_eq!(
OssError::ObjectNotFound("missing".to_string()).kind(),
OssErrorKind::ObjectNotFound
);
assert_eq!(
OssError::Request("network".to_string()).kind(),
OssErrorKind::Request
);
}
fn build_client() -> OssClient {
OssClient::new(
OssConfig::new(

View File

@@ -33,6 +33,13 @@ pub struct RecordBigFishPlayRequest {
pub elapsed_ms: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SubmitBigFishInputRequest {
pub x: f32,
pub y: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishAnchorItemResponse {
@@ -173,6 +180,47 @@ pub struct BigFishActionResponse {
pub session: BigFishSessionSnapshotResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishVector2Response {
pub x: f32,
pub y: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishRuntimeEntityResponse {
pub entity_id: String,
pub level: u32,
pub position: BigFishVector2Response,
pub radius: f32,
pub offscreen_seconds: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishRuntimeSnapshotResponse {
pub run_id: String,
pub session_id: String,
pub status: String,
pub tick: u64,
pub player_level: u32,
pub win_level: u32,
pub leader_entity_id: Option<String>,
pub owned_entities: Vec<BigFishRuntimeEntityResponse>,
pub wild_entities: Vec<BigFishRuntimeEntityResponse>,
pub camera_center: BigFishVector2Response,
pub last_input: BigFishVector2Response,
pub event_log: Vec<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishRunResponse {
pub run: BigFishRuntimeSnapshotResponse,
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,7 +1,10 @@
use super::*;
use crate::mapper::*;
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work;
use crate::module_bindings::get_big_fish_run_procedure::get_big_fish_run;
use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play;
use crate::module_bindings::start_big_fish_run_procedure::start_big_fish_run;
use crate::module_bindings::submit_big_fish_input_procedure::submit_big_fish_input;
impl SpacetimeClient {
pub async fn create_big_fish_session(
@@ -290,4 +293,77 @@ impl SpacetimeClient {
})
.await
}
pub async fn start_big_fish_run(
&self,
input: BigFishRunStartRecordInput,
) -> Result<BigFishRuntimeRunRecord, SpacetimeClientError> {
let procedure_input = BigFishRunStartInput {
run_id: input.run_id,
session_id: input.session_id,
owner_user_id: input.owner_user_id,
started_at_micros: input.started_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.start_big_fish_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_big_fish_run_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_big_fish_run(
&self,
run_id: String,
owner_user_id: String,
) -> Result<BigFishRuntimeRunRecord, SpacetimeClientError> {
let procedure_input = BigFishRunGetInput {
run_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.get_big_fish_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_big_fish_run_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn submit_big_fish_input(
&self,
input: BigFishInputSubmitRecordInput,
) -> Result<BigFishRuntimeRunRecord, SpacetimeClientError> {
let procedure_input = BigFishInputSubmitInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
x: input.x,
y: input.y,
submitted_at_micros: input.submitted_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().submit_big_fish_input_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_big_fish_run_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
}

Some files were not shown because too many files have changed in this diff Show More