Integrate unfinished server-rs refactor worklists

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

View File

@@ -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,