Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("尚未编译")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
server-rs/crates/api-server/src/platform_errors.rs
Normal file
133
server-rs/crates/api-server/src/platform_errors.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
|
||||
use platform_llm::{LlmError, LlmErrorKind};
|
||||
use platform_oss::{OssError, OssErrorKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::http_error::AppError;
|
||||
|
||||
// API 层统一消费 platform 的稳定错误分类,避免各 route 重复 match 具体 provider 分支。
|
||||
pub fn map_llm_error(error: LlmError) -> AppError {
|
||||
let message = llm_error_message(&error);
|
||||
let status = match error.kind() {
|
||||
LlmErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
|
||||
LlmErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
|
||||
LlmErrorKind::Upstream
|
||||
if matches!(
|
||||
error,
|
||||
LlmError::Upstream {
|
||||
status_code: 429,
|
||||
..
|
||||
}
|
||||
) =>
|
||||
{
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
}
|
||||
LlmErrorKind::Timeout
|
||||
| LlmErrorKind::Connectivity
|
||||
| LlmErrorKind::Upstream
|
||||
| LlmErrorKind::StreamUnavailable
|
||||
| LlmErrorKind::EmptyResponse
|
||||
| LlmErrorKind::Transport
|
||||
| LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_message(message)
|
||||
}
|
||||
|
||||
pub fn map_oss_error(error: OssError, provider: &'static str) -> AppError {
|
||||
let status = oss_error_status(error.kind());
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn map_phone_auth_platform_store_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
|
||||
}
|
||||
|
||||
pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
|
||||
let status = match error.kind() {
|
||||
AuthPlatformErrorKind::Disabled
|
||||
| AuthPlatformErrorKind::MissingCode
|
||||
| AuthPlatformErrorKind::InvalidCallback => StatusCode::BAD_REQUEST,
|
||||
AuthPlatformErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
|
||||
AuthPlatformErrorKind::RequestFailed
|
||||
| AuthPlatformErrorKind::DeserializeFailed
|
||||
| AuthPlatformErrorKind::MissingProfile
|
||||
| AuthPlatformErrorKind::Upstream => StatusCode::BAD_GATEWAY,
|
||||
AuthPlatformErrorKind::InvalidClaims
|
||||
| AuthPlatformErrorKind::SignFailed
|
||||
| AuthPlatformErrorKind::VerifyFailed
|
||||
| AuthPlatformErrorKind::CookieConfig
|
||||
| AuthPlatformErrorKind::HashFailed
|
||||
| AuthPlatformErrorKind::InvalidVerifyCode => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_message(error.to_string())
|
||||
}
|
||||
|
||||
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
|
||||
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
|
||||
Ok(value) => error.with_header("retry-after", value),
|
||||
Err(_) => error,
|
||||
}
|
||||
}
|
||||
|
||||
fn oss_error_status(kind: OssErrorKind) -> StatusCode {
|
||||
match kind {
|
||||
OssErrorKind::InvalidConfig | OssErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
|
||||
OssErrorKind::ObjectNotFound => StatusCode::NOT_FOUND,
|
||||
OssErrorKind::Request | OssErrorKind::SerializePolicy | OssErrorKind::Sign => {
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn llm_error_message(error: &LlmError) -> String {
|
||||
match error {
|
||||
LlmError::InvalidConfig(message)
|
||||
| LlmError::InvalidRequest(message)
|
||||
| LlmError::Transport(message)
|
||||
| LlmError::Deserialize(message) => message.clone(),
|
||||
LlmError::Timeout { .. }
|
||||
| LlmError::Connectivity { .. }
|
||||
| LlmError::Upstream { .. }
|
||||
| LlmError::StreamUnavailable
|
||||
| LlmError::EmptyResponse => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn map_oss_error_uses_stable_kind_for_not_found() {
|
||||
let error = map_oss_error(
|
||||
OssError::ObjectNotFound("missing object".to_string()),
|
||||
"oss",
|
||||
);
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_llm_error_preserves_upstream_rate_limit() {
|
||||
let error = map_llm_error(LlmError::Upstream {
|
||||
status_code: 429,
|
||||
message: "too many requests".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_wechat_provider_error_keeps_provider_boundary() {
|
||||
let error = map_wechat_provider_error(WechatProviderError::MissingCode);
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error.message(), "缺少微信授权 code");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤t_battle.actor_user_id,
|
||||
&actor_user_id,
|
||||
)?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.resolve_combat_action(ResolveCombatActionInput {
|
||||
battle_state_id: payload.battle_state_id,
|
||||
battle_state_id,
|
||||
function_id: payload.function_id,
|
||||
action_text: payload.action_text,
|
||||
base_damage: payload.base_damage,
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
¤t_state.session.actor_user_id,
|
||||
&actor_user_id,
|
||||
)?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.continue_story(
|
||||
payload.story_session_id,
|
||||
story_session_id,
|
||||
module_story::generate_story_event_id(now_micros),
|
||||
payload.narrative_text,
|
||||
payload.choice_function_id,
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 当前仍未进入的范围
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
14
server-rs/crates/module-ai/src/application/result.rs
Normal file
14
server-rs/crates/module-ai/src/application/result.rs
Normal 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>,
|
||||
}
|
||||
250
server-rs/crates/module-ai/src/application/service.rs
Normal file
250
server-rs/crates/module-ai/src/application/service.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
138
server-rs/crates/module-ai/src/application/store.rs
Normal file
138
server-rs/crates/module-ai/src/application/store.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
87
server-rs/crates/module-ai/src/commands/inputs.rs
Normal file
87
server-rs/crates/module-ai/src/commands/inputs.rs
Normal 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,
|
||||
}
|
||||
40
server-rs/crates/module-ai/src/commands/validation.rs
Normal file
40
server-rs/crates/module-ai/src/commands/validation.rs
Normal 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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
41
server-rs/crates/module-ai/src/domain/ids.rs
Normal file
41
server-rs/crates/module-ai/src/domain/ids.rs
Normal 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)
|
||||
}
|
||||
77
server-rs/crates/module-ai/src/domain/stages.rs
Normal file
77
server-rs/crates/module-ai/src/domain/stages.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
124
server-rs/crates/module-ai/src/domain/types.rs
Normal file
124
server-rs/crates/module-ai/src/domain/types.rs
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
220
server-rs/crates/module-ai/src/tests.rs
Normal file
220
server-rs/crates/module-ai/src/tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
当前还已补齐:
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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` 仍保留进程内仓储和文件持久化支撑,但不再继续拥有命令、结果、错误、事件和纯领域值对象定义。
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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(¤t_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("收编"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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. 边界约束
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 直接相关的任务包括:
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
51
server-rs/crates/module-puzzle/README.md
Normal file
51
server-rs/crates/module-puzzle/README.md
Normal 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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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 新接口,并删除运行代码中的旧入口命名。
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
//! runtime story 兼容写入命令过渡落位。
|
||||
//! runtime story 写入命令过渡落位。
|
||||
//!
|
||||
//! 用于表达旧剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。
|
||||
//! 用于表达剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。
|
||||
|
||||
@@ -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 时可以继续复用同一批状态读写函数。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! runtime story 兼容领域模型过渡落位。
|
||||
//! runtime story 领域模型过渡落位。
|
||||
//!
|
||||
//! 当前 crate 用于旧运行时剧情桥的纯规则兼容。后续迁移时仍只能保留 JSON 规则、
|
||||
//! 当前 crate 用于运行时剧情主链的纯规则收口。后续迁移时仍只能保留 JSON 规则、
|
||||
//! 选项生成和视图模型转换,不引入 Axum、LLM 或 SpacetimeDB。
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
//! runtime story 兼容领域错误过渡落位。
|
||||
//! runtime story 领域错误过渡落位。
|
||||
//!
|
||||
//! 错误只表达兼容规则失败,不能直接绑定 HTTP 或数据库错误模型。
|
||||
//! 错误只表达运行时剧情规则失败,不能直接绑定 HTTP 或数据库错误模型。
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
//! runtime story 兼容领域事件过渡落位。
|
||||
//! runtime story 领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达旧剧情快照变化、战斗表现变化和物品/成长待同步等事实。
|
||||
//! 用于表达剧情快照变化、战斗表现变化和物品/成长待同步等事实。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('弓')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 中。
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t_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
@@ -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
@@ -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 真相。
|
||||
|
||||
@@ -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<_>>(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user