Integrate unfinished server-rs refactor worklists
This commit is contained in:
86
server-rs/crates/spacetime-module/src/auth/mapper.rs
Normal file
86
server-rs/crates/spacetime-module/src/auth/mapper.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(super) fn sanitize_identity_component(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
character
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
sanitized.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(super) struct PersistentAuthStoreSnapshot {
|
||||
#[serde(default = "default_next_user_id")]
|
||||
pub(super) next_user_id: u64,
|
||||
pub(super) users_by_username: std::collections::HashMap<String, StoredPasswordUserSnapshot>,
|
||||
#[serde(default)]
|
||||
pub(super) phone_to_user_id: std::collections::HashMap<String, String>,
|
||||
pub(super) sessions_by_id: std::collections::HashMap<String, StoredRefreshSessionSnapshot>,
|
||||
#[serde(default)]
|
||||
pub(super) session_id_by_refresh_token_hash: std::collections::HashMap<String, String>,
|
||||
pub(super) wechat_identity_by_provider_uid:
|
||||
std::collections::HashMap<String, StoredWechatIdentitySnapshot>,
|
||||
#[serde(default)]
|
||||
pub(super) user_id_by_provider_union_id: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_next_user_id() -> u64 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(super) struct StoredPasswordUserSnapshot {
|
||||
pub(super) user: AuthUserSnapshot,
|
||||
pub(super) password_hash: String,
|
||||
#[serde(default)]
|
||||
pub(super) password_login_enabled: bool,
|
||||
pub(super) phone_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(super) struct AuthUserSnapshot {
|
||||
pub(super) id: String,
|
||||
pub(super) public_user_code: String,
|
||||
pub(super) username: String,
|
||||
pub(super) display_name: String,
|
||||
pub(super) phone_number_masked: Option<String>,
|
||||
pub(super) login_method: String,
|
||||
pub(super) binding_status: String,
|
||||
pub(super) wechat_bound: bool,
|
||||
pub(super) token_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(super) struct StoredWechatIdentitySnapshot {
|
||||
pub(super) user_id: String,
|
||||
pub(super) provider_uid: String,
|
||||
pub(super) provider_union_id: Option<String>,
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(super) struct StoredRefreshSessionSnapshot {
|
||||
pub(super) session: RefreshSessionSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(super) struct RefreshSessionSnapshot {
|
||||
pub(super) session_id: String,
|
||||
pub(super) user_id: String,
|
||||
pub(super) refresh_token_hash: String,
|
||||
pub(super) issued_by_provider: String,
|
||||
pub(super) client_info: Value,
|
||||
pub(super) expires_at: String,
|
||||
pub(super) revoked_at: Option<String>,
|
||||
pub(super) created_at: String,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) last_seen_at: String,
|
||||
}
|
||||
6
server-rs/crates/spacetime-module/src/auth/mod.rs
Normal file
6
server-rs/crates/spacetime-module/src/auth/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod mapper;
|
||||
mod procedures;
|
||||
mod tables;
|
||||
|
||||
pub use procedures::*;
|
||||
pub use tables::*;
|
||||
@@ -1,6 +1,16 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
|
||||
|
||||
use super::{
|
||||
mapper::{
|
||||
AuthUserSnapshot, PersistentAuthStoreSnapshot, RefreshSessionSnapshot,
|
||||
StoredPasswordUserSnapshot, StoredRefreshSessionSnapshot, StoredWechatIdentitySnapshot,
|
||||
sanitize_identity_component,
|
||||
},
|
||||
tables::{
|
||||
AuthIdentity, AuthStoreSnapshot, RefreshSession, UserAccount, auth_identity,
|
||||
auth_store_snapshot, refresh_session, user_account,
|
||||
},
|
||||
};
|
||||
|
||||
const AUTH_STORE_SNAPSHOT_ID: &str = "default";
|
||||
|
||||
@@ -37,71 +47,6 @@ pub struct AuthStoreSnapshotImportProcedureResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = auth_store_snapshot)]
|
||||
pub struct AuthStoreSnapshot {
|
||||
#[primary_key]
|
||||
pub(crate) snapshot_id: String,
|
||||
pub(crate) snapshot_json: String,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = user_account,
|
||||
index(accessor = by_user_account_username, btree(columns = [username])),
|
||||
index(accessor = by_user_account_public_code, btree(columns = [public_user_code]))
|
||||
)]
|
||||
pub struct UserAccount {
|
||||
#[primary_key]
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) public_user_code: String,
|
||||
pub(crate) username: String,
|
||||
pub(crate) display_name: String,
|
||||
pub(crate) phone_number_masked: Option<String>,
|
||||
pub(crate) phone_number_e164: Option<String>,
|
||||
pub(crate) login_method: String,
|
||||
pub(crate) binding_status: String,
|
||||
pub(crate) wechat_bound: bool,
|
||||
pub(crate) password_hash: String,
|
||||
pub(crate) password_login_enabled: bool,
|
||||
pub(crate) token_version: u64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = auth_identity,
|
||||
index(accessor = by_auth_identity_user_id, btree(columns = [user_id])),
|
||||
index(accessor = by_auth_identity_provider_uid, btree(columns = [provider, provider_uid]))
|
||||
)]
|
||||
pub struct AuthIdentity {
|
||||
#[primary_key]
|
||||
pub(crate) identity_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) provider: String,
|
||||
pub(crate) provider_uid: String,
|
||||
pub(crate) provider_union_id: Option<String>,
|
||||
pub(crate) phone_e164: Option<String>,
|
||||
pub(crate) display_name: Option<String>,
|
||||
pub(crate) avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = refresh_session,
|
||||
index(accessor = by_refresh_session_user_id, btree(columns = [user_id])),
|
||||
index(accessor = by_refresh_session_token_hash, btree(columns = [refresh_token_hash]))
|
||||
)]
|
||||
pub struct RefreshSession {
|
||||
#[primary_key]
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) refresh_token_hash: String,
|
||||
pub(crate) issued_by_provider: String,
|
||||
pub(crate) client_info_json: String,
|
||||
pub(crate) expires_at: String,
|
||||
pub(crate) revoked_at: Option<String>,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) updated_at: String,
|
||||
pub(crate) last_seen_at: String,
|
||||
}
|
||||
|
||||
// Axum 启动恢复认证状态时读取当前快照;记录不存在代表尚未产生登录态。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotProcedureResult {
|
||||
@@ -157,7 +102,7 @@ pub fn import_auth_store_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
// Axum ??????????????? module-auth ????????????????
|
||||
// Axum 启动时可从正式表重新导出 module-auth 使用的整份认证快照。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn export_auth_store_snapshot_from_tables(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -409,8 +354,8 @@ fn export_auth_store_snapshot_from_tables_tx(
|
||||
let mut sessions_by_id = std::collections::HashMap::new();
|
||||
let mut session_id_by_refresh_token_hash = std::collections::HashMap::new();
|
||||
for session in sessions {
|
||||
let client_info = serde_json::from_str::<Value>(&session.client_info_json)
|
||||
.map_err(|error| format!("refresh session ????? JSON ?????{error}"))?;
|
||||
let client_info = serde_json::from_str::<serde_json::Value>(&session.client_info_json)
|
||||
.map_err(|error| format!("refresh session 客户端信息 JSON 解析失败:{error}"))?;
|
||||
session_id_by_refresh_token_hash.insert(
|
||||
session.refresh_token_hash.clone(),
|
||||
session.session_id.clone(),
|
||||
@@ -443,8 +388,8 @@ fn export_auth_store_snapshot_from_tables_tx(
|
||||
wechat_identity_by_provider_uid,
|
||||
user_id_by_provider_union_id,
|
||||
};
|
||||
let snapshot_json =
|
||||
serde_json::to_string_pretty(&snapshot).map_err(|error| format!("?????????????{error}"))?;
|
||||
let snapshot_json = serde_json::to_string_pretty(&snapshot)
|
||||
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
|
||||
|
||||
Ok(AuthStoreSnapshotRecord {
|
||||
snapshot_json: Some(snapshot_json),
|
||||
@@ -469,87 +414,3 @@ fn clear_auth_target_tables(ctx: &ReducerContext) {
|
||||
ctx.db.user_account().user_id().delete(&row.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_identity_component(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
character
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
sanitized.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PersistentAuthStoreSnapshot {
|
||||
#[serde(default = "default_next_user_id")]
|
||||
next_user_id: u64,
|
||||
users_by_username: std::collections::HashMap<String, StoredPasswordUserSnapshot>,
|
||||
#[serde(default)]
|
||||
phone_to_user_id: std::collections::HashMap<String, String>,
|
||||
sessions_by_id: std::collections::HashMap<String, StoredRefreshSessionSnapshot>,
|
||||
#[serde(default)]
|
||||
session_id_by_refresh_token_hash: std::collections::HashMap<String, String>,
|
||||
wechat_identity_by_provider_uid:
|
||||
std::collections::HashMap<String, StoredWechatIdentitySnapshot>,
|
||||
#[serde(default)]
|
||||
user_id_by_provider_union_id: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_next_user_id() -> u64 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct StoredPasswordUserSnapshot {
|
||||
user: AuthUserSnapshot,
|
||||
password_hash: String,
|
||||
#[serde(default)]
|
||||
password_login_enabled: bool,
|
||||
phone_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct AuthUserSnapshot {
|
||||
id: String,
|
||||
public_user_code: String,
|
||||
username: String,
|
||||
display_name: String,
|
||||
phone_number_masked: Option<String>,
|
||||
login_method: String,
|
||||
binding_status: String,
|
||||
wechat_bound: bool,
|
||||
token_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct StoredWechatIdentitySnapshot {
|
||||
user_id: String,
|
||||
provider_uid: String,
|
||||
provider_union_id: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct StoredRefreshSessionSnapshot {
|
||||
session: RefreshSessionSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct RefreshSessionSnapshot {
|
||||
session_id: String,
|
||||
user_id: String,
|
||||
refresh_token_hash: String,
|
||||
issued_by_provider: String,
|
||||
client_info: Value,
|
||||
expires_at: String,
|
||||
revoked_at: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
last_seen_at: String,
|
||||
}
|
||||
66
server-rs/crates/spacetime-module/src/auth/tables.rs
Normal file
66
server-rs/crates/spacetime-module/src/auth/tables.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::Timestamp;
|
||||
|
||||
#[spacetimedb::table(accessor = auth_store_snapshot)]
|
||||
pub struct AuthStoreSnapshot {
|
||||
#[primary_key]
|
||||
pub(crate) snapshot_id: String,
|
||||
pub(crate) snapshot_json: String,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = user_account,
|
||||
index(accessor = by_user_account_username, btree(columns = [username])),
|
||||
index(accessor = by_user_account_public_code, btree(columns = [public_user_code]))
|
||||
)]
|
||||
pub struct UserAccount {
|
||||
#[primary_key]
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) public_user_code: String,
|
||||
pub(crate) username: String,
|
||||
pub(crate) display_name: String,
|
||||
pub(crate) phone_number_masked: Option<String>,
|
||||
pub(crate) phone_number_e164: Option<String>,
|
||||
pub(crate) login_method: String,
|
||||
pub(crate) binding_status: String,
|
||||
pub(crate) wechat_bound: bool,
|
||||
pub(crate) password_hash: String,
|
||||
pub(crate) password_login_enabled: bool,
|
||||
pub(crate) token_version: u64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = auth_identity,
|
||||
index(accessor = by_auth_identity_user_id, btree(columns = [user_id])),
|
||||
index(accessor = by_auth_identity_provider_uid, btree(columns = [provider, provider_uid]))
|
||||
)]
|
||||
pub struct AuthIdentity {
|
||||
#[primary_key]
|
||||
pub(crate) identity_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) provider: String,
|
||||
pub(crate) provider_uid: String,
|
||||
pub(crate) provider_union_id: Option<String>,
|
||||
pub(crate) phone_e164: Option<String>,
|
||||
pub(crate) display_name: Option<String>,
|
||||
pub(crate) avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = refresh_session,
|
||||
index(accessor = by_refresh_session_user_id, btree(columns = [user_id])),
|
||||
index(accessor = by_refresh_session_token_hash, btree(columns = [refresh_token_hash]))
|
||||
)]
|
||||
pub struct RefreshSession {
|
||||
#[primary_key]
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) refresh_token_hash: String,
|
||||
pub(crate) issued_by_provider: String,
|
||||
pub(crate) client_info_json: String,
|
||||
pub(crate) expires_at: String,
|
||||
pub(crate) revoked_at: Option<String>,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) updated_at: String,
|
||||
pub(crate) last_seen_at: String,
|
||||
}
|
||||
@@ -37,7 +37,10 @@ pub(crate) fn emit_big_fish_publish_readiness_event(
|
||||
publish_ready,
|
||||
blockers,
|
||||
occurred_at_micros,
|
||||
} = event;
|
||||
} = event
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let blockers_json = serde_json::to_string(&blockers)
|
||||
.map_err(|error| format!("big_fish.publish_readiness.blockers 序列化失败: {error}"))?;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mod assets;
|
||||
mod events;
|
||||
mod runtime;
|
||||
mod session;
|
||||
mod tables;
|
||||
|
||||
pub use assets::*;
|
||||
pub(crate) use events::*;
|
||||
pub use runtime::*;
|
||||
pub use session::*;
|
||||
pub use tables::*;
|
||||
|
||||
231
server-rs/crates/spacetime-module/src/big_fish/runtime.rs
Normal file
231
server-rs/crates/spacetime-module/src/big_fish/runtime.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use crate::big_fish::tables::{
|
||||
BigFishCreationSession, BigFishRuntimeRun, big_fish_creation_session, big_fish_runtime_run,
|
||||
};
|
||||
use crate::*;
|
||||
use module_big_fish::{
|
||||
StartBigFishRunCommand, SubmitBigFishInputCommand, deserialize_runtime_snapshot,
|
||||
serialize_runtime_snapshot, start_big_fish_run as start_big_fish_run_domain,
|
||||
submit_big_fish_input as submit_big_fish_input_domain,
|
||||
};
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn start_big_fish_run(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: BigFishRunStartInput,
|
||||
) -> BigFishRunProcedureResult {
|
||||
match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) {
|
||||
Ok(run) => BigFishRunProcedureResult {
|
||||
ok: true,
|
||||
run_json: Some(serialize_big_fish_run_json(&run)),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => BigFishRunProcedureResult {
|
||||
ok: false,
|
||||
run_json: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_big_fish_run(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: BigFishRunGetInput,
|
||||
) -> BigFishRunProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) {
|
||||
Ok(run) => BigFishRunProcedureResult {
|
||||
ok: true,
|
||||
run_json: Some(serialize_big_fish_run_json(&run)),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => BigFishRunProcedureResult {
|
||||
ok: false,
|
||||
run_json: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn submit_big_fish_input(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: BigFishInputSubmitInput,
|
||||
) -> BigFishRunProcedureResult {
|
||||
match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) {
|
||||
Ok(run) => BigFishRunProcedureResult {
|
||||
ok: true,
|
||||
run_json: Some(serialize_big_fish_run_json(&run)),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => BigFishRunProcedureResult {
|
||||
ok: false,
|
||||
run_json: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_big_fish_run_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishRunStartInput,
|
||||
) -> Result<BigFishRuntimeSnapshot, String> {
|
||||
validate_run_start_input(&input).map_err(|error| error.to_string())?;
|
||||
if ctx
|
||||
.db
|
||||
.big_fish_runtime_run()
|
||||
.run_id()
|
||||
.find(&input.run_id)
|
||||
.is_some()
|
||||
{
|
||||
return Err("big_fish_runtime_run.run_id 已存在".to_string());
|
||||
}
|
||||
|
||||
let session = ctx
|
||||
.db
|
||||
.big_fish_creation_session()
|
||||
.session_id()
|
||||
.find(&input.session_id)
|
||||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||||
ensure_big_fish_session_playable(&session, &input.owner_user_id)?;
|
||||
let draft = session
|
||||
.draft_json
|
||||
.as_deref()
|
||||
.map(deserialize_draft)
|
||||
.transpose()
|
||||
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
|
||||
let work_level_count = draft
|
||||
.as_ref()
|
||||
.map(|value| value.runtime_params.level_count)
|
||||
.or_else(|| Some(BIG_FISH_DEFAULT_LEVEL_COUNT));
|
||||
let result = start_big_fish_run_domain(StartBigFishRunCommand {
|
||||
run_id: input.run_id,
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
draft,
|
||||
work_level_count,
|
||||
started_at_micros: input.started_at_micros,
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
insert_big_fish_runtime_run(
|
||||
ctx,
|
||||
&result.snapshot,
|
||||
&input.owner_user_id,
|
||||
input.started_at_micros,
|
||||
)?;
|
||||
Ok(result.snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn get_big_fish_run_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishRunGetInput,
|
||||
) -> Result<BigFishRuntimeSnapshot, String> {
|
||||
validate_run_get_input(&input).map_err(|error| error.to_string())?;
|
||||
let row = get_owned_big_fish_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
||||
deserialize_runtime_snapshot(&row.snapshot_json)
|
||||
.map_err(|error| format!("big_fish.runtime_snapshot_json 非法: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn submit_big_fish_input_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishInputSubmitInput,
|
||||
) -> Result<BigFishRuntimeSnapshot, String> {
|
||||
validate_input_submit_input(&input).map_err(|error| error.to_string())?;
|
||||
let row = get_owned_big_fish_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
||||
let current_snapshot = deserialize_runtime_snapshot(&row.snapshot_json)
|
||||
.map_err(|error| format!("big_fish.runtime_snapshot_json 非法: {error}"))?;
|
||||
let result = submit_big_fish_input_domain(SubmitBigFishInputCommand {
|
||||
owner_user_id: input.owner_user_id,
|
||||
x: input.x,
|
||||
y: input.y,
|
||||
submitted_at_micros: input.submitted_at_micros,
|
||||
current_snapshot,
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
replace_big_fish_runtime_run(ctx, &row, &result.snapshot, input.submitted_at_micros)?;
|
||||
Ok(result.snapshot)
|
||||
}
|
||||
|
||||
fn ensure_big_fish_session_playable(
|
||||
session: &BigFishCreationSession,
|
||||
player_user_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if session.owner_user_id == player_user_id {
|
||||
return Ok(());
|
||||
}
|
||||
if session.stage == BigFishCreationStage::Published {
|
||||
return Ok(());
|
||||
}
|
||||
Err("未发布的大鱼吃小鱼作品不允许非作者启动运行态".to_string())
|
||||
}
|
||||
|
||||
fn get_owned_big_fish_run_row(
|
||||
ctx: &ReducerContext,
|
||||
run_id: &str,
|
||||
owner_user_id: &str,
|
||||
) -> Result<BigFishRuntimeRun, String> {
|
||||
let row = ctx
|
||||
.db
|
||||
.big_fish_runtime_run()
|
||||
.run_id()
|
||||
.find(&run_id.to_string())
|
||||
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
|
||||
if row.owner_user_id != owner_user_id {
|
||||
return Err("无权访问该 big_fish_runtime_run".to_string());
|
||||
}
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn insert_big_fish_runtime_run(
|
||||
ctx: &ReducerContext,
|
||||
run: &BigFishRuntimeSnapshot,
|
||||
owner_user_id: &str,
|
||||
created_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
let timestamp = Timestamp::from_micros_since_unix_epoch(created_at_micros);
|
||||
ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun {
|
||||
run_id: run.run_id.clone(),
|
||||
session_id: run.session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
status: run.status,
|
||||
snapshot_json: serialize_runtime_snapshot(run)
|
||||
.map_err(|error| format!("big_fish.runtime_snapshot 序列化失败: {error}"))?,
|
||||
last_input_x: run.last_input.x,
|
||||
last_input_y: run.last_input.y,
|
||||
tick: run.tick,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_big_fish_runtime_run(
|
||||
ctx: &ReducerContext,
|
||||
current: &BigFishRuntimeRun,
|
||||
run: &BigFishRuntimeSnapshot,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
ctx.db
|
||||
.big_fish_runtime_run()
|
||||
.run_id()
|
||||
.delete(¤t.run_id);
|
||||
ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun {
|
||||
run_id: run.run_id.clone(),
|
||||
session_id: run.session_id.clone(),
|
||||
owner_user_id: current.owner_user_id.clone(),
|
||||
status: run.status,
|
||||
snapshot_json: serialize_runtime_snapshot(run)
|
||||
.map_err(|error| format!("big_fish.runtime_snapshot 序列化失败: {error}"))?,
|
||||
last_input_x: run.last_input.x,
|
||||
last_input_y: run.last_input.y,
|
||||
tick: run.tick,
|
||||
created_at: current.created_at,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serialize_big_fish_run_json(run: &BigFishRuntimeSnapshot) -> String {
|
||||
serialize_runtime_snapshot(run).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
|
||||
use crate::big_fish::tables::{
|
||||
big_fish_agent_message, big_fish_creation_session, big_fish_runtime_run,
|
||||
};
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
|
||||
};
|
||||
@@ -326,7 +328,7 @@ pub(crate) fn delete_big_fish_work_tx(
|
||||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||||
|
||||
// 删除作品时同步清理 Agent 消息与素材槽;最终游玩模拟已经迁到前端,不再写后端运行快照。
|
||||
// 中文注释:删除作品时同步清理 Agent 消息、素材槽和后端运行态快照,避免失去来源会话的 run 残留。
|
||||
ctx.db
|
||||
.big_fish_creation_session()
|
||||
.session_id()
|
||||
@@ -352,6 +354,15 @@ pub(crate) fn delete_big_fish_work_tx(
|
||||
{
|
||||
ctx.db.big_fish_asset_slot().slot_id().delete(&slot.slot_id);
|
||||
}
|
||||
for run in ctx
|
||||
.db
|
||||
.big_fish_runtime_run()
|
||||
.iter()
|
||||
.filter(|row| row.session_id == input.session_id)
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db.big_fish_runtime_run().run_id().delete(&run.run_id);
|
||||
}
|
||||
list_big_fish_works_tx(
|
||||
ctx,
|
||||
BigFishWorksListInput {
|
||||
|
||||
@@ -52,3 +52,22 @@ pub struct BigFishAssetSlot {
|
||||
pub(crate) prompt_snapshot: String,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = big_fish_runtime_run,
|
||||
index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])),
|
||||
index(accessor = by_big_fish_run_session_id, btree(columns = [session_id]))
|
||||
)]
|
||||
pub struct BigFishRuntimeRun {
|
||||
#[primary_key]
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) status: BigFishRunStatus,
|
||||
pub(crate) snapshot_json: String,
|
||||
pub(crate) last_input_x: f32,
|
||||
pub(crate) last_input_y: f32,
|
||||
pub(crate) tick: u64,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,12 @@
|
||||
use crate::*;
|
||||
use module_combat::resolve_combat_action as resolve_battle_state_action;
|
||||
use module_inventory::apply_inventory_mutation as apply_inventory_slot_mutation;
|
||||
use module_npc::resolve_npc_interaction as resolve_npc_interaction_domain;
|
||||
use module_quest::{
|
||||
acknowledge_quest_completion as acknowledge_quest_record_completion,
|
||||
apply_quest_signal as apply_quest_record_signal,
|
||||
};
|
||||
|
||||
#[spacetimedb::table(accessor = player_progression)]
|
||||
pub struct PlayerProgression {
|
||||
#[primary_key]
|
||||
@@ -874,6 +883,79 @@ pub fn get_story_session_state(
|
||||
}
|
||||
}
|
||||
|
||||
fn continue_story_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: StoryContinueInput,
|
||||
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> {
|
||||
validate_story_continue_input(&input).map_err(|error| error.to_string())?;
|
||||
|
||||
let current = ctx
|
||||
.db
|
||||
.story_session()
|
||||
.story_session_id()
|
||||
.find(&input.story_session_id)
|
||||
.ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?;
|
||||
|
||||
let current_snapshot = build_story_session_snapshot_from_row(¤t);
|
||||
let (next_snapshot, event_snapshot) =
|
||||
apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?;
|
||||
|
||||
ctx.db
|
||||
.story_session()
|
||||
.story_session_id()
|
||||
.delete(¤t.story_session_id);
|
||||
ctx.db.story_session().insert(StorySession {
|
||||
story_session_id: next_snapshot.story_session_id.clone(),
|
||||
runtime_session_id: next_snapshot.runtime_session_id.clone(),
|
||||
actor_user_id: next_snapshot.actor_user_id.clone(),
|
||||
world_profile_id: next_snapshot.world_profile_id.clone(),
|
||||
initial_prompt: next_snapshot.initial_prompt.clone(),
|
||||
opening_summary: next_snapshot.opening_summary.clone(),
|
||||
latest_narrative_text: next_snapshot.latest_narrative_text.clone(),
|
||||
latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(),
|
||||
status: next_snapshot.status,
|
||||
version: next_snapshot.version,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros),
|
||||
});
|
||||
|
||||
ctx.db.story_event().insert(StoryEvent {
|
||||
event_id: event_snapshot.event_id.clone(),
|
||||
story_session_id: event_snapshot.story_session_id.clone(),
|
||||
event_kind: event_snapshot.event_kind,
|
||||
narrative_text: event_snapshot.narrative_text.clone(),
|
||||
choice_function_id: event_snapshot.choice_function_id.clone(),
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros),
|
||||
});
|
||||
|
||||
Ok((next_snapshot, event_snapshot))
|
||||
}
|
||||
|
||||
fn get_story_session_state_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: StorySessionStateInput,
|
||||
) -> Result<(StorySessionSnapshot, Vec<StoryEventSnapshot>), String> {
|
||||
validate_story_session_state_input(&input).map_err(|error| error.to_string())?;
|
||||
|
||||
let session = ctx
|
||||
.db
|
||||
.story_session()
|
||||
.story_session_id()
|
||||
.find(&input.story_session_id)
|
||||
.ok_or_else(|| "story_session 不存在".to_string())?;
|
||||
|
||||
let session_snapshot = build_story_session_snapshot_from_row(&session);
|
||||
let mut events = ctx
|
||||
.db
|
||||
.story_event()
|
||||
.iter()
|
||||
.filter(|row| row.story_session_id == input.story_session_id)
|
||||
.map(|row| build_story_event_snapshot_from_row(&row))
|
||||
.collect::<Vec<_>>();
|
||||
events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone()));
|
||||
|
||||
Ok((session_snapshot, events))
|
||||
}
|
||||
|
||||
// 当前阶段先把 quest_record / quest_log 立成最小任务真相源,后续再把奖励结算和 story action 总分发接进来。
|
||||
#[spacetimedb::reducer]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ use spacetimedb_lib::sats::de::serde::DeserializeWrapper;
|
||||
use spacetimedb_lib::sats::ser::serde::SerializeWrapper;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::big_fish::big_fish_runtime_run;
|
||||
use crate::puzzle::{
|
||||
puzzle_agent_message, puzzle_agent_session, puzzle_event, puzzle_leaderboard_entry,
|
||||
puzzle_runtime_run, puzzle_work_profile,
|
||||
@@ -147,6 +148,7 @@ macro_rules! migration_tables {
|
||||
big_fish_creation_session,
|
||||
big_fish_agent_message,
|
||||
big_fish_asset_slot,
|
||||
big_fish_runtime_run,
|
||||
big_fish_event
|
||||
}
|
||||
};
|
||||
|
||||
@@ -590,7 +590,7 @@ pub(crate) fn sync_profile_projections_from_snapshot(
|
||||
let game_state_object = game_state.as_object();
|
||||
let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros);
|
||||
|
||||
if is_non_persistent_runtime_snapshot(&game_state) {
|
||||
if module_runtime::is_non_persistent_runtime_snapshot(&game_state) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -614,7 +614,7 @@ pub(crate) fn upsert_profile_played_work(
|
||||
}
|
||||
|
||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||
let played_world_id = format!("{user_id}:{world_key}");
|
||||
let played_world_id = build_runtime_profile_played_world_id(user_id, world_key);
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_played_world()
|
||||
@@ -673,7 +673,7 @@ pub(crate) fn add_profile_observed_play_time(
|
||||
}
|
||||
|
||||
let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros);
|
||||
let played_world_id = format!("{user_id}:{world_key}");
|
||||
let played_world_id = build_runtime_profile_played_world_id(user_id, world_key);
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.profile_played_world()
|
||||
@@ -785,15 +785,17 @@ fn sync_profile_dashboard_from_snapshot(
|
||||
.as_ref()
|
||||
.map(|row| row.total_play_time_ms)
|
||||
.unwrap_or(0);
|
||||
let next_wallet_balance =
|
||||
read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency")));
|
||||
let next_wallet_balance = module_runtime::read_runtime_json_non_negative_u64(
|
||||
game_state.and_then(|state| state.get("playerCurrency")),
|
||||
);
|
||||
let mut next_total_play_time_ms = previous_total_play_time_ms;
|
||||
|
||||
if next_wallet_balance != previous_wallet_balance {
|
||||
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
|
||||
wallet_ledger_id: format!(
|
||||
"{}:{}:{}",
|
||||
snapshot.user_id, snapshot.saved_at_micros, next_wallet_balance
|
||||
wallet_ledger_id: build_runtime_profile_snapshot_wallet_ledger_id(
|
||||
&snapshot.user_id,
|
||||
snapshot.saved_at_micros,
|
||||
next_wallet_balance,
|
||||
),
|
||||
user_id: snapshot.user_id.clone(),
|
||||
amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64,
|
||||
@@ -803,14 +805,17 @@ fn sync_profile_dashboard_from_snapshot(
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(world_meta) = resolve_profile_world_snapshot_meta(game_state) {
|
||||
let current_play_time_ms = read_non_negative_u64(
|
||||
if let Some(world_meta) =
|
||||
module_runtime::resolve_runtime_profile_world_snapshot_meta(game_state)
|
||||
{
|
||||
let current_play_time_ms = module_runtime::read_runtime_json_non_negative_u64(
|
||||
game_state
|
||||
.and_then(|state| state.get("runtimeStats"))
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|stats| stats.get("playTimeMs")),
|
||||
);
|
||||
let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key);
|
||||
let played_world_id =
|
||||
build_runtime_profile_played_world_id(&snapshot.user_id, &world_meta.world_key);
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_played_world()
|
||||
@@ -893,13 +898,15 @@ fn sync_profile_save_archive_from_snapshot(
|
||||
game_state: &JsonValue,
|
||||
saved_at: Timestamp,
|
||||
) -> Result<(), String> {
|
||||
let Some(archive_meta) =
|
||||
resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref())
|
||||
else {
|
||||
let Some(archive_meta) = module_runtime::resolve_runtime_profile_save_archive_meta(
|
||||
game_state,
|
||||
snapshot.current_story_json.as_deref(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key);
|
||||
let archive_id =
|
||||
build_runtime_profile_save_archive_id(&snapshot.user_id, &archive_meta.world_key);
|
||||
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
|
||||
let created_at = existing
|
||||
.as_ref()
|
||||
@@ -935,28 +942,6 @@ fn sync_profile_save_archive_from_snapshot(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ProfileWorldSnapshotMeta {
|
||||
world_key: String,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
world_type: Option<String>,
|
||||
world_title: String,
|
||||
world_subtitle: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ProfileSaveArchiveMeta {
|
||||
world_key: String,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
world_type: Option<String>,
|
||||
world_name: String,
|
||||
subtitle: String,
|
||||
summary_text: String,
|
||||
cover_image_src: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_profile_save_archive_snapshot_from_row(
|
||||
row: &ProfileSaveArchive,
|
||||
) -> RuntimeProfileSaveArchiveSnapshot {
|
||||
@@ -980,196 +965,6 @@ pub(crate) fn build_profile_save_archive_snapshot_from_row(
|
||||
}
|
||||
}
|
||||
|
||||
fn read_non_negative_u64(value: Option<&JsonValue>) -> u64 {
|
||||
match value {
|
||||
Some(JsonValue::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(JsonValue::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_string_from_json(value: Option<&JsonValue>) -> Option<String> {
|
||||
value
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn resolve_profile_world_snapshot_meta(
|
||||
game_state: Option<&serde_json::Map<String, JsonValue>>,
|
||||
) -> Option<ProfileWorldSnapshotMeta> {
|
||||
let game_state = game_state?;
|
||||
let custom_world_profile = game_state
|
||||
.get("customWorldProfile")
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let profile_id = read_string_from_json(custom_world_profile.get("id"));
|
||||
let world_title = read_string_from_json(custom_world_profile.get("name"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("title")));
|
||||
if profile_id.is_some() || world_title.is_some() {
|
||||
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
|
||||
return Some(ProfileWorldSnapshotMeta {
|
||||
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_string_from_json(custom_world_profile.get("summary"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let world_type = read_string_from_json(game_state.get("worldType"))?;
|
||||
let current_scene_preset = game_state
|
||||
.get("currentScenePreset")
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
Some(ProfileWorldSnapshotMeta {
|
||||
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_string_from_json(preset.get("name")))
|
||||
.unwrap_or_else(|| build_builtin_world_title(&world_type)),
|
||||
world_subtitle: current_scene_preset
|
||||
.and_then(|preset| {
|
||||
read_string_from_json(preset.get("summary"))
|
||||
.or_else(|| read_string_from_json(preset.get("description")))
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_profile_save_archive_meta(
|
||||
game_state: &JsonValue,
|
||||
current_story_json: Option<&str>,
|
||||
) -> Option<ProfileSaveArchiveMeta> {
|
||||
if is_non_persistent_runtime_snapshot(game_state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let game_state_object = game_state.as_object();
|
||||
let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?;
|
||||
let story_engine_memory = game_state_object
|
||||
.and_then(|state| state.get("storyEngineMemory"))
|
||||
.and_then(JsonValue::as_object);
|
||||
let continue_game_digest = story_engine_memory
|
||||
.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
|
||||
let current_story_text = parse_optional_json_str(current_story_json)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|story| story.as_object().cloned())
|
||||
.and_then(|story| read_string_from_json(story.get("text")));
|
||||
let custom_world_profile = game_state_object
|
||||
.and_then(|state| state.get("customWorldProfile"))
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let world_name = read_string_from_json(custom_world_profile.get("name"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("title")))
|
||||
.unwrap_or_else(|| world_meta.world_title.clone());
|
||||
let subtitle = read_string_from_json(custom_world_profile.get("summary"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("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(ProfileSaveArchiveMeta {
|
||||
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_string_from_json(custom_world_profile.get("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(JsonValue::as_object);
|
||||
|
||||
Some(ProfileSaveArchiveMeta {
|
||||
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.clone(),
|
||||
summary_text,
|
||||
cover_image_src: current_scene_preset
|
||||
.and_then(|preset| read_string_from_json(preset.get("imageSrc"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
read_string_from_json(game_state.get("runtimeMode")).as_deref(),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_builtin_world_title(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "武侠世界".to_string(),
|
||||
"XIANXIA" => "仙侠世界".to_string(),
|
||||
_ => "叙事世界".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_dashboard_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileDashboardGetInput,
|
||||
@@ -1307,20 +1102,17 @@ fn create_profile_recharge_order_record(
|
||||
let (points_delta, membership_expires_at) = match product.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => {
|
||||
let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id);
|
||||
let bonus_points = if has_recharged {
|
||||
0
|
||||
} else {
|
||||
product.bonus_points
|
||||
};
|
||||
let points_delta = product.points_amount.saturating_add(bonus_points);
|
||||
let points_delta =
|
||||
resolve_runtime_profile_points_recharge_delta(&product, has_recharged);
|
||||
apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
points_delta,
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
|
||||
&format!(
|
||||
"{}:{}:{}",
|
||||
validated_input.user_id, validated_input.created_at_micros, product.product_id
|
||||
&build_runtime_profile_recharge_wallet_ledger_id(
|
||||
&validated_input.user_id,
|
||||
validated_input.created_at_micros,
|
||||
&product.product_id,
|
||||
),
|
||||
created_at,
|
||||
)?;
|
||||
@@ -1339,9 +1131,10 @@ fn create_profile_recharge_order_record(
|
||||
};
|
||||
|
||||
let order = ProfileRechargeOrder {
|
||||
order_id: format!(
|
||||
"recharge:{}:{}:{}",
|
||||
validated_input.user_id, validated_input.created_at_micros, product.product_id
|
||||
order_id: build_runtime_profile_recharge_order_id(
|
||||
&validated_input.user_id,
|
||||
validated_input.created_at_micros,
|
||||
&product.product_id,
|
||||
),
|
||||
user_id: validated_input.user_id.clone(),
|
||||
product_id: product.product_id.clone(),
|
||||
@@ -1416,25 +1209,25 @@ fn redeem_profile_referral_invite_code_record(
|
||||
&invitee_user_id,
|
||||
PROFILE_REFERRAL_REWARD_POINTS,
|
||||
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward,
|
||||
&format!(
|
||||
"invitee:{}:{}",
|
||||
invitee_user_id, validated_input.updated_at_micros
|
||||
&build_runtime_profile_referral_invitee_ledger_id(
|
||||
&invitee_user_id,
|
||||
validated_input.updated_at_micros,
|
||||
),
|
||||
bound_at,
|
||||
)?;
|
||||
let today_inviter_reward_count =
|
||||
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at);
|
||||
let inviter_reward_granted =
|
||||
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT;
|
||||
should_grant_runtime_profile_inviter_reward(today_inviter_reward_count);
|
||||
let inviter_balance_after = if inviter_reward_granted {
|
||||
apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&inviter_code.user_id,
|
||||
PROFILE_REFERRAL_REWARD_POINTS,
|
||||
RuntimeProfileWalletLedgerSourceType::InviteInviterReward,
|
||||
&format!(
|
||||
"inviter:{}:{}",
|
||||
inviter_code.user_id, validated_input.updated_at_micros
|
||||
&build_runtime_profile_referral_inviter_ledger_id(
|
||||
&inviter_code.user_id,
|
||||
validated_input.updated_at_micros,
|
||||
),
|
||||
bound_at,
|
||||
)?
|
||||
@@ -1482,45 +1275,21 @@ fn redeem_profile_reward_code_record(
|
||||
.find(&code)
|
||||
.ok_or_else(|| "兑换码不存在".to_string())?;
|
||||
|
||||
if !redeem_code.enabled {
|
||||
return Err("兑换码已停用".to_string());
|
||||
}
|
||||
if redeem_code.reward_points == 0 {
|
||||
return Err("兑换码奖励无效".to_string());
|
||||
}
|
||||
|
||||
let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id);
|
||||
match redeem_code.mode {
|
||||
RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => {
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Unique
|
||||
if redeem_code.global_used_count >= redeem_code.max_uses =>
|
||||
{
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Private => {
|
||||
if !redeem_code
|
||||
.allowed_user_ids
|
||||
.iter()
|
||||
.any(|item| item == &user_id)
|
||||
{
|
||||
return Err("该兑换码不适用于当前账号".to_string());
|
||||
}
|
||||
if redeem_code.global_used_count >= redeem_code.max_uses {
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
validate_runtime_profile_redeem_code_usage(
|
||||
&build_profile_redeem_code_snapshot_from_row(&redeem_code),
|
||||
&user_id,
|
||||
user_used_count,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let usage_id = build_profile_redeem_code_usage_id(
|
||||
ctx,
|
||||
let usage_id = build_runtime_profile_redeem_code_usage_id(
|
||||
&code,
|
||||
&user_id,
|
||||
validated_input.redeemed_at_micros,
|
||||
user_used_count,
|
||||
);
|
||||
let wallet_ledger_id = format!("{}:ledger", usage_id);
|
||||
let wallet_ledger_id = build_runtime_profile_redeem_code_ledger_id(&usage_id);
|
||||
let wallet_balance = apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&user_id,
|
||||
@@ -1669,7 +1438,7 @@ fn build_profile_referral_invite_center_snapshot(
|
||||
RuntimeReferralInviteCenterSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
invite_code: code.invite_code.clone(),
|
||||
invite_link_path: format!("/?inviteCode={}", code.invite_code),
|
||||
invite_link_path: build_runtime_profile_invite_link_path(&code.invite_code),
|
||||
invited_count,
|
||||
rewarded_invite_count,
|
||||
today_inviter_reward_count,
|
||||
@@ -1697,7 +1466,7 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
|
||||
return row;
|
||||
}
|
||||
|
||||
let mut invite_code = build_profile_invite_code(user_id, 0);
|
||||
let mut invite_code = build_runtime_profile_invite_code(user_id, 0);
|
||||
let mut salt = 1;
|
||||
while ctx
|
||||
.db
|
||||
@@ -1706,7 +1475,7 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
|
||||
.find(&invite_code)
|
||||
.is_some()
|
||||
{
|
||||
invite_code = build_profile_invite_code(user_id, salt);
|
||||
invite_code = build_runtime_profile_invite_code(user_id, salt);
|
||||
salt += 1;
|
||||
}
|
||||
|
||||
@@ -1718,21 +1487,12 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
|
||||
})
|
||||
}
|
||||
|
||||
fn build_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)
|
||||
}
|
||||
|
||||
fn count_today_profile_referral_inviter_rewards(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
now: Timestamp,
|
||||
) -> u32 {
|
||||
let day_start_micros = (now.to_micros_since_unix_epoch() / 86_400_000_000) * 86_400_000_000;
|
||||
let day_start_micros = runtime_profile_day_start_micros(now.to_micros_since_unix_epoch());
|
||||
ctx.db
|
||||
.profile_wallet_ledger()
|
||||
.iter()
|
||||
@@ -1831,24 +1591,25 @@ fn apply_profile_membership_purchase(
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
let purchased_at_micros = purchased_at.to_micros_since_unix_epoch();
|
||||
let start_at_micros = current
|
||||
.as_ref()
|
||||
.map(|row| row.expires_at.to_micros_since_unix_epoch())
|
||||
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
|
||||
.unwrap_or(purchased_at_micros);
|
||||
let expires_at = Timestamp::from_micros_since_unix_epoch(
|
||||
start_at_micros.saturating_add(duration_days as i64 * 86_400_000_000),
|
||||
let purchase_update = resolve_runtime_profile_membership_purchase_update(
|
||||
current
|
||||
.as_ref()
|
||||
.map(|row| row.started_at.to_micros_since_unix_epoch()),
|
||||
current
|
||||
.as_ref()
|
||||
.map(|row| row.expires_at.to_micros_since_unix_epoch()),
|
||||
purchased_at_micros,
|
||||
duration_days,
|
||||
);
|
||||
let created_at = current
|
||||
.as_ref()
|
||||
.map(|row| row.started_at)
|
||||
.unwrap_or(purchased_at);
|
||||
let expires_at = Timestamp::from_micros_since_unix_epoch(purchase_update.expires_at_micros);
|
||||
let created_at = Timestamp::from_micros_since_unix_epoch(purchase_update.started_at_micros);
|
||||
let current = current.map(|row| row.user_id);
|
||||
|
||||
if let Some(existing) = current {
|
||||
if let Some(existing_user_id) = current {
|
||||
ctx.db
|
||||
.profile_membership()
|
||||
.user_id()
|
||||
.delete(&existing.user_id);
|
||||
.delete(&existing_user_id);
|
||||
}
|
||||
|
||||
ctx.db.profile_membership().insert(ProfileMembership {
|
||||
@@ -1871,8 +1632,8 @@ fn apply_profile_wallet_delta(
|
||||
ledger_id: &str,
|
||||
created_at: Timestamp,
|
||||
) -> Result<u64, String> {
|
||||
let amount_delta =
|
||||
i64::try_from(amount_delta).map_err(|_| "profile.wallet_amount 超出上限".to_string())?;
|
||||
let amount_delta = convert_runtime_profile_wallet_unsigned_delta(amount_delta)
|
||||
.map_err(|error| error.to_string())?;
|
||||
apply_profile_wallet_signed_delta(
|
||||
ctx,
|
||||
user_id,
|
||||
@@ -1898,10 +1659,12 @@ fn apply_profile_wallet_adjustment(
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
|
||||
let unsigned_delta = convert_runtime_profile_wallet_unsigned_delta(validated_input.amount)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let amount_delta = if consume {
|
||||
-(validated_input.amount as i64)
|
||||
-unsigned_delta
|
||||
} else {
|
||||
validated_input.amount as i64
|
||||
unsigned_delta
|
||||
};
|
||||
|
||||
apply_profile_wallet_signed_delta(
|
||||
@@ -1947,15 +1710,8 @@ fn apply_profile_wallet_signed_delta(
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
|
||||
let next_balance = if amount_delta >= 0 {
|
||||
previous_balance
|
||||
.checked_add(amount_delta as u64)
|
||||
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?
|
||||
} else {
|
||||
previous_balance
|
||||
.checked_sub(amount_delta.unsigned_abs())
|
||||
.ok_or_else(|| "叙世币余额不足".to_string())?
|
||||
};
|
||||
let next_balance = calculate_runtime_profile_wallet_balance(previous_balance, amount_delta)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let created_state_at = current
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
@@ -2034,19 +1790,6 @@ fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_i
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_usage_id(
|
||||
ctx: &ReducerContext,
|
||||
code: &str,
|
||||
user_id: &str,
|
||||
redeemed_at_micros: i64,
|
||||
) -> String {
|
||||
let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id);
|
||||
format!(
|
||||
"redeem:{}:{}:{}:{}",
|
||||
code, user_id, redeemed_at_micros, sequence
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_profile_redeem_code_allowed_user_ids(
|
||||
ctx: &ReducerContext,
|
||||
input: &RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
Reference in New Issue
Block a user