This commit is contained in:
2026-05-01 20:29:09 +08:00
parent 8718472dbd
commit 87fbf41fab
137 changed files with 2922 additions and 989 deletions

View File

@@ -1835,6 +1835,10 @@ mod tests {
payload["user"]["loginMethod"],
Value::String("password".to_string())
);
assert_eq!(
payload["user"]["createdAt"],
Value::String(seed_user.created_at)
);
assert!(payload["token"].as_str().is_some());
}
@@ -2114,6 +2118,7 @@ mod tests {
payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
assert!(payload["user"]["createdAt"].as_str().is_some());
assert_eq!(payload["created"], Value::Bool(true));
assert!(payload["referral"].is_null());
}

View File

@@ -29,7 +29,7 @@ where
}
}
/// 资产操作统一预扣陶泥币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
/// 资产操作统一预扣光点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
async fn consume_asset_operation_points(
state: &AppState,
owner_user_id: &str,
@@ -79,7 +79,7 @@ async fn refund_asset_operation_points(
asset_kind,
asset_id,
error = %error,
"资产操作失败后的陶泥币退款失败"
"资产操作失败后的光点退款失败"
);
}
}
@@ -89,10 +89,10 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
tracing::warn!(
provider = "profile-wallet",
error = %message,
"资产操作陶泥币预扣失败"
"资产操作光点预扣失败"
);
let status = match &error {
SpacetimeClientError::Procedure(message) if message.contains("陶泥币余额不足") => {
SpacetimeClientError::Procedure(message) if message.contains("光点余额不足") => {
StatusCode::CONFLICT
}
_ => StatusCode::BAD_GATEWAY,

View File

@@ -12,6 +12,7 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
created_at: user.created_at,
}
}

View File

@@ -20,7 +20,7 @@ pub async fn get_public_user_by_code(
.get_user_by_public_user_code(&code)
.map_err(map_public_user_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应陶泥号用户")
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应百梦号用户")
})?;
Ok(json_success_body(
@@ -60,7 +60,7 @@ pub async fn get_public_user_by_id(
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
match error {
module_auth::PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("陶泥号格式不正确")
AppError::from_status(StatusCode::BAD_REQUEST).with_message("百梦号格式不正确")
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)

View File

@@ -3462,7 +3462,7 @@ fn resolve_author_public_user_code(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-library",
"message": format!("作者陶泥号读取失败:{error}"),
"message": format!("作者百梦号读取失败:{error}"),
})),
)
})?
@@ -3473,7 +3473,7 @@ fn resolve_author_public_user_code(
request_context,
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "custom-world-library",
"message": "当前登录用户缺少陶泥",
"message": "当前登录用户缺少百梦",
})),
)
})

View File

@@ -41,7 +41,7 @@ pub async fn generate_custom_world_foundation_draft(
emit_foundation_draft_progress(
&mut on_progress,
"整理世界骨架",
"正在根据陶泥主锚点生成第一版世界框架。",
"正在根据百梦主锚点生成第一版世界框架。",
12,
);
let mut framework = request_foundation_json_stage(

View File

@@ -49,6 +49,7 @@ mod prompt;
mod puzzle;
mod puzzle_agent_turn;
mod refresh_session;
mod registration_reward;
mod request_context;
mod response_headers;
mod runtime_browse_history;

View File

@@ -39,6 +39,14 @@ pub async fn password_entry(
state.password_entry_service().execute(input).await
}
.map_err(map_password_entry_error)?;
if result.created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
&request_context,
&result.user.id,
)
.await;
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
state
@@ -80,7 +88,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
"field": "password",
})),
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("陶泥号格式不正确")
.with_message("百梦号格式不正确")
.with_details(json!({
"field": "phone",
})),

View File

@@ -149,6 +149,14 @@ pub async fn phone_login(
}
};
let created = result.created;
if created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
&request_context,
&result.user.id,
)
.await;
}
let referral = if created {
bind_referral_invite_code_on_registration(
&state,

View File

@@ -10,7 +10,7 @@ use crate::creation_agent_anchor_templates::{
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。

View File

@@ -12,7 +12,7 @@ use crate::creation_agent_chat::render_quick_fill_extra_rules;
/// 拼图共创 Agent 的系统提示词。
///
/// 这里作为拼图聊天提示词主源,业务文件只负责调用 LLM、解析结果和写回状态。
pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。
pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。

View File

@@ -14,7 +14,7 @@ pub(crate) const PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS: usize = 500;
const PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS: usize = 40;
const PUZZLE_IMAGE_PROMPT_FALLBACK: &str = "清晰、有辨识度的拼图画面";
/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。
/// 根据拼图关卡名和百梦主输入构造最终发给图片模型的提示词。
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
let level_name =
truncate_puzzle_prompt_segment(level_name.trim(), PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS);

View File

@@ -3620,7 +3620,7 @@ mod tests {
}));
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "陶泥币余额不足",
"message": "光点余额不足",
}));
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));

View File

@@ -0,0 +1,30 @@
#[cfg(not(test))]
use tracing::warn;
use crate::{request_context::RequestContext, state::AppState};
pub async fn grant_new_user_registration_wallet_reward(
state: &AppState,
request_context: &RequestContext,
user_id: &str,
) {
#[cfg(test)]
{
let _ = (state, request_context, user_id);
}
#[cfg(not(test))]
if let Err(error) = state
.spacetime_client()
.grant_new_user_registration_wallet_reward(user_id.to_string())
.await
{
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
user_id = user_id,
error = %error,
"新用户注册光点赠送失败,注册流程继续"
);
}
}

View File

@@ -20,6 +20,7 @@ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
@@ -27,10 +28,10 @@ use shared_contracts::runtime::{
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -110,6 +111,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC
}
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
}
RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
}
@@ -514,6 +518,16 @@ fn build_profile_referral_invite_center_response(
today_inviter_reward_count: record.today_inviter_reward_count,
today_inviter_reward_remaining: record.today_inviter_reward_remaining,
reward_points: record.reward_points,
invited_users: record
.invited_users
.into_iter()
.map(|user| ProfileReferralInvitedUserResponse {
user_id: user.user_id,
display_name: user.display_name,
avatar_url: user.avatar_url,
bound_at: user.bound_at,
})
.collect(),
has_redeemed_code: record.has_redeemed_code,
bound_inviter_user_id: record.bound_inviter_user_id,
bound_at: record.bound_at,
@@ -637,6 +651,12 @@ mod tests {
#[test]
fn profile_wallet_ledger_source_type_formats_backend_values() {
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume

View File

@@ -187,6 +187,14 @@ pub async fn bind_wechat_phone(
)
.await
.map_err(map_wechat_bind_phone_error)?;
if result.activated_new_user {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
&request_context,
&result.user.id,
)
.await;
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,

View File

@@ -53,6 +53,8 @@ pub struct AuthUser {
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
#[serde(default = "default_auth_user_created_at")]
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -227,6 +229,7 @@ pub struct BindWechatPhoneInput {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
pub activated_new_user: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -966,11 +969,14 @@ impl PhoneAuthService {
return Err(PhoneAuthError::UserStateMismatch);
}
let merged_user = self
let (merged_user, activated_new_user) = self
.store
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?;
Ok(BindWechatPhoneResult { user: merged_user })
Ok(BindWechatPhoneResult {
user: merged_user,
activated_new_user,
})
}
}
@@ -1378,6 +1384,7 @@ impl InMemoryAuthStore {
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let created_at = current_auth_user_created_at();
let user = AuthUser {
id: user_id.clone(),
public_user_code,
@@ -1389,6 +1396,7 @@ impl InMemoryAuthStore {
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
created_at,
};
state
.phone_to_user_id
@@ -1426,6 +1434,7 @@ impl InMemoryAuthStore {
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let created_at = current_auth_user_created_at();
let user = AuthUser {
id: user_id.clone(),
public_user_code,
@@ -1437,6 +1446,7 @@ impl InMemoryAuthStore {
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
created_at,
};
state
.phone_to_user_id
@@ -1470,6 +1480,7 @@ impl InMemoryAuthStore {
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("wechat", state.next_user_id);
let created_at = current_auth_user_created_at();
let display_name = profile
.display_name
.as_deref()
@@ -1488,6 +1499,7 @@ impl InMemoryAuthStore {
binding_status: AuthBindingStatus::PendingBindPhone,
wechat_bound: true,
token_version: 1,
created_at,
};
state.users_by_username.insert(
username,
@@ -1863,7 +1875,7 @@ impl InMemoryAuthStore {
&self,
pending_user_id: &str,
phone_number: PhoneNumberSnapshot,
) -> Result<AuthUser, PhoneAuthError> {
) -> Result<(AuthUser, bool), PhoneAuthError> {
let mut state = self
.inner
.lock()
@@ -1910,7 +1922,7 @@ impl InMemoryAuthStore {
let next_user = target_user.user.clone();
self.persist_phone_state(&state)?;
return Ok(next_user);
return Ok((next_user, false));
}
state
@@ -1929,7 +1941,7 @@ impl InMemoryAuthStore {
let next_user = stored_user.user.clone();
self.persist_phone_state(&state)?;
Ok(next_user)
Ok((next_user, true))
}
fn find_session_by_refresh_token_hash(
@@ -2219,7 +2231,7 @@ impl fmt::Display for PasswordEntryError {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("陶泥号格式不正确"),
Self::InvalidPublicUserCode => f.write_str("百梦号格式不正确"),
Self::InvalidDisplayName => {
f.write_str("昵称需要为 2 到 20 位中文、英文、数字或下划线")
}
@@ -2499,7 +2511,7 @@ fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开陶泥号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
// 公开百梦号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
@@ -2527,6 +2539,15 @@ fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value)
}
fn current_auth_user_created_at() -> String {
format_rfc3339(OffsetDateTime::now_utc())
.unwrap_or_else(|_| default_auth_user_created_at())
}
fn default_auth_user_created_at() -> String {
"1970-01-01T00:00:00Z".to_string()
}
fn parse_phone_code_time(value: &str, field_label: &str) -> Result<OffsetDateTime, PhoneAuthError> {
parse_rfc3339(value)
.map_err(|error| PhoneAuthError::Store(format!("短信验证码{field_label}解析失败:{error}")))
@@ -3467,6 +3488,7 @@ mod tests {
assert_eq!(merged.user.id, phone_user.id);
assert_eq!(merged.user.binding_status, AuthBindingStatus::Active);
assert!(merged.user.wechat_bound);
assert!(!merged.activated_new_user);
let reused_wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
@@ -3484,4 +3506,51 @@ mod tests {
assert_eq!(reused_wechat_user.user.id, phone_user.id);
assert!(reused_wechat_user.user.wechat_bound);
}
#[tokio::test]
async fn bind_wechat_phone_activates_pending_wechat_user_for_new_phone() {
let store = build_store();
let wechat_service = WechatAuthService::new(store.clone());
let phone_service = build_phone_service(store);
let now = OffsetDateTime::from_unix_timestamp(1_713_680_000).expect("valid timestamp");
let wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-new-phone".to_string(),
provider_union_id: Some("wx-union-new-phone".to_string()),
display_name: Some("新微信用户".to_string()),
avatar_url: None,
},
})
.await
.expect("wechat login should create pending user")
.user;
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138099".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now + Duration::seconds(1),
)
.await
.expect("bind phone code should send");
let bound = phone_service
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: wechat_user.id.clone(),
phone_number: "13800138099".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(2),
)
.await
.expect("bind phone should activate pending user");
assert_eq!(bound.user.id, wechat_user.id);
assert_eq!(bound.user.binding_status, AuthBindingStatus::Active);
assert!(bound.activated_new_user);
}
}

View File

@@ -3794,7 +3794,7 @@ mod tests {
#[test]
fn puzzle_point_incentive_uses_half_points_and_floor_claimable() {
// 中文注释:累计单位是 half point消耗 1 个陶泥币只让作者获得 0.5 个待结算陶泥币
// 中文注释:累计单位是 half point消耗 1 个光点只让作者获得 0.5 个待结算光点
assert_eq!(puzzle_point_incentive_total_after_spend(0, 1), 1);
assert_eq!(puzzle_point_incentive_claimable_points(1, 0), 0);
assert_eq!(puzzle_point_incentive_claimable_points(2, 0), 1);

View File

@@ -15,6 +15,7 @@ pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::L
pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家";
pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
pub const PROFILE_NEW_USER_INITIAL_WALLET_POINTS: u64 = 10;
pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30;
pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10;
pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
@@ -258,6 +259,7 @@ pub struct RuntimeProfileDashboardGetInput {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileWalletLedgerSourceType {
SnapshotSync,
NewUserRegistrationReward,
InviteInviterReward,
InviteInviteeReward,
PointsRecharge,
@@ -532,6 +534,15 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInvitedUserSnapshot {
pub user_id: String,
pub display_name: String,
pub avatar_url: Option<String>,
pub bound_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot {
@@ -543,6 +554,7 @@ pub struct RuntimeReferralInviteCenterSnapshot {
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub invited_users: Vec<RuntimeReferralInvitedUserSnapshot>,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option<String>,
pub bound_at_micros: Option<i64>,
@@ -958,6 +970,15 @@ pub struct RuntimeProfileInviteCodeRecord {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeReferralInvitedUserRecord {
pub user_id: String,
pub display_name: String,
pub avatar_url: Option<String>,
pub bound_at: String,
pub bound_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeReferralInviteCenterRecord {
pub user_id: String,
@@ -968,6 +989,7 @@ pub struct RuntimeReferralInviteCenterRecord {
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub invited_users: Vec<RuntimeReferralInvitedUserRecord>,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option<String>,
pub bound_at: Option<String>,
@@ -1534,6 +1556,17 @@ pub fn build_runtime_referral_invite_center_record(
today_inviter_reward_count: snapshot.today_inviter_reward_count,
today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining,
reward_points: snapshot.reward_points,
invited_users: snapshot
.invited_users
.into_iter()
.map(|user| RuntimeReferralInvitedUserRecord {
user_id: user.user_id,
display_name: user.display_name,
avatar_url: user.avatar_url,
bound_at: format_utc_micros(user.bound_at_micros),
bound_at_micros: user.bound_at_micros,
})
.collect(),
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),
@@ -1778,6 +1811,7 @@ impl RuntimeProfileWalletLedgerSourceType {
pub fn as_str(&self) -> &'static str {
match self {
Self::SnapshotSync => "snapshot_sync",
Self::NewUserRegistrationReward => "new_user_registration_reward",
Self::InviteInviterReward => "invite_inviter_reward",
Self::InviteInviteeReward => "invite_invitee_reward",
Self::PointsRecharge => "points_recharge",
@@ -1840,57 +1874,57 @@ pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargePr
vec![
build_points_recharge_product(
"points_60",
"60陶泥币",
"60光点",
600,
60,
60,
"首充双倍",
"首充送60陶泥币",
"首充送60光点",
),
build_points_recharge_product(
"points_180",
"180陶泥币",
"180光点",
1800,
180,
180,
"首充双倍",
"首充送180陶泥币",
"首充送180光点",
),
build_points_recharge_product(
"points_300",
"300陶泥币",
"300光点",
3000,
300,
300,
"首充双倍",
"首充送300陶泥币",
"首充送300光点",
),
build_points_recharge_product(
"points_680",
"680陶泥币",
"680光点",
6800,
680,
680,
"首充双倍",
"首充送680陶泥币",
"首充送680光点",
),
build_points_recharge_product(
"points_1280",
"1280陶泥币",
"1280光点",
12800,
1280,
1280,
"首充双倍",
"首充送1280陶泥币",
"首充送1280光点",
),
build_points_recharge_product(
"points_3280",
"3280陶泥币",
"3280光点",
32800,
3280,
3280,
"首充双倍",
"首充送3280陶泥币",
"首充送3280光点",
),
]
}
@@ -1939,7 +1973,7 @@ pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBene
year_value: "¥248".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "陶泥币回合数".to_string(),
benefit_name: "光点回合数".to_string(),
normal_value: "30".to_string(),
month_value: "100".to_string(),
season_value: "100".to_string(),
@@ -2354,6 +2388,10 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
"snapshot_sync"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward.as_str(),
"new_user_registration_reward"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
"points_recharge"
@@ -2379,14 +2417,14 @@ mod tests {
assert_eq!(point_products.len(), 6);
assert_eq!(point_products[0].product_id, "points_60");
assert_eq!(point_products[0].title, "60陶泥币");
assert_eq!(point_products[0].title, "60光点");
assert_eq!(point_products[0].price_cents, 600);
assert_eq!(point_products[0].bonus_points, 60);
assert_eq!(point_products[0].description, "首充送60陶泥币");
assert_eq!(point_products[0].description, "首充送60光点");
assert_eq!(point_products[5].product_id, "points_3280");
assert_eq!(point_products[5].price_cents, 32800);
assert_eq!(point_products[5].bonus_points, 3280);
assert_eq!(point_products[5].description, "首充送3280陶泥币");
assert_eq!(point_products[5].description, "首充送3280光点");
assert_eq!(membership_products.len(), 3);
assert_eq!(membership_products[0].title, "月卡");
assert_eq!(membership_products[0].price_cents, 2800);
@@ -2396,7 +2434,7 @@ mod tests {
assert!(
benefits
.iter()
.any(|benefit| benefit.benefit_name == "陶泥币回合数")
.any(|benefit| benefit.benefit_name == "光点回合数")
);
}

View File

@@ -24,6 +24,7 @@ pub struct AuthUserPayload {
pub login_method: String,
pub binding_status: String,
pub wechat_bound: bool,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]

View File

@@ -4,6 +4,8 @@ pub const RUNTIME_PLATFORM_THEME_LIGHT: &str = "light";
pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark";
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD: &str =
"new_user_registration_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward";
@@ -237,6 +239,15 @@ pub struct CreateProfileRechargeOrderResponse {
pub center: ProfileRechargeCenterResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileReferralInvitedUserResponse {
pub user_id: String,
pub display_name: String,
pub avatar_url: Option<String>,
pub bound_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileReferralInviteCenterResponse {
@@ -247,6 +258,7 @@ pub struct ProfileReferralInviteCenterResponse {
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub invited_users: Vec<ProfileReferralInvitedUserResponse>,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option<String>,
pub bound_at: Option<String>,
@@ -886,13 +898,21 @@ mod tests {
entries: vec![
ProfileWalletLedgerEntryResponse {
id: "ledger-1".to_string(),
amount_delta: 10,
balance_after: 10,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
.to_string(),
created_at: "2026-04-22T09:59:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-2".to_string(),
amount_delta: 12,
balance_after: 80,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
created_at: "2026-04-22T10:00:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-2".to_string(),
id: "ledger-3".to_string(),
amount_delta: 30,
balance_after: 110,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
@@ -900,7 +920,7 @@ mod tests {
created_at: "2026-04-22T10:01:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-3".to_string(),
id: "ledger-4".to_string(),
amount_delta: 30,
balance_after: 140,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
@@ -908,14 +928,14 @@ mod tests {
created_at: "2026-04-22T10:02:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-4".to_string(),
id: "ledger-5".to_string(),
amount_delta: 60,
balance_after: 200,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(),
created_at: "2026-04-22T10:03:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-5".to_string(),
id: "ledger-6".to_string(),
amount_delta: -1,
balance_after: 199,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
@@ -923,7 +943,7 @@ mod tests {
created_at: "2026-04-22T10:04:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-6".to_string(),
id: "ledger-7".to_string(),
amount_delta: 1,
balance_after: 200,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
@@ -931,7 +951,7 @@ mod tests {
created_at: "2026-04-22T10:05:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-7".to_string(),
id: "ledger-8".to_string(),
amount_delta: 2,
balance_after: 202,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
@@ -942,39 +962,43 @@ mod tests {
})
.expect("payload should serialize");
assert_eq!(payload["entries"][0]["amountDelta"], json!(12));
assert_eq!(payload["entries"][0]["balanceAfter"], json!(80));
assert_eq!(payload["entries"][0]["amountDelta"], json!(10));
assert_eq!(payload["entries"][0]["balanceAfter"], json!(10));
assert_eq!(
payload["entries"][0]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC)
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD)
);
assert_eq!(
payload["entries"][1]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD)
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC)
);
assert_eq!(
payload["entries"][2]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD)
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD)
);
assert_eq!(
payload["entries"][3]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE)
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD)
);
assert_eq!(
payload["entries"][4]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME)
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE)
);
assert_eq!(
payload["entries"][5]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME)
);
assert_eq!(
payload["entries"][6]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)
);
assert_eq!(
payload["entries"][7]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM)
);
assert_eq!(
payload["entries"][0]["createdAt"],
json!("2026-04-22T10:00:00Z")
json!("2026-04-22T09:59:00Z")
);
}
@@ -991,14 +1015,14 @@ mod tests {
},
point_products: vec![ProfileRechargeProductResponse {
product_id: "points_60".to_string(),
title: "60陶泥币".to_string(),
title: "60光点".to_string(),
price_cents: 600,
kind: "points".to_string(),
points_amount: 60,
bonus_points: 60,
duration_days: 0,
badge_label: "首充双倍".to_string(),
description: "首充送60陶泥币".to_string(),
description: "首充送60光点".to_string(),
tier: "normal".to_string(),
}],
membership_products: vec![],
@@ -1014,11 +1038,11 @@ mod tests {
json!("2026-05-25T10:00:00Z")
);
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60"));
assert_eq!(payload["pointProducts"][0]["title"], json!("60陶泥币"));
assert_eq!(payload["pointProducts"][0]["title"], json!("60光点"));
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600));
assert_eq!(
payload["pointProducts"][0]["description"],
json!("首充送60陶泥币")
json!("首充送60光点")
);
assert_eq!(payload["hasPointsRecharged"], json!(false));
}

View File

@@ -1897,6 +1897,16 @@ pub(crate) fn map_runtime_referral_invite_center_snapshot(
today_inviter_reward_count: snapshot.today_inviter_reward_count,
today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining,
reward_points: snapshot.reward_points,
invited_users: snapshot
.invited_users
.into_iter()
.map(|user| module_runtime::RuntimeReferralInvitedUserSnapshot {
user_id: user.user_id,
display_name: user.display_name,
avatar_url: user.avatar_url,
bound_at_micros: user.bound_at_micros,
})
.collect(),
has_redeemed_code: snapshot.has_redeemed_code,
bound_inviter_user_id: snapshot.bound_inviter_user_id,
bound_at_micros: snapshot.bound_at_micros,
@@ -3884,6 +3894,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviterReward
}

View File

@@ -55,3 +55,4 @@ impl admin_upsert_profile_invite_code for super::RemoteProcedures {
);
}
}

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult;
use super::runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GrantNewUserRegistrationWalletRewardArgs {
pub input: RuntimeProfileDashboardGetInput,
}
impl __sdk::InModule for GrantNewUserRegistrationWalletRewardArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `grant_new_user_registration_wallet_reward`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait grant_new_user_registration_wallet_reward {
fn grant_new_user_registration_wallet_reward(&self, input: RuntimeProfileDashboardGetInput,
) {
self.grant_new_user_registration_wallet_reward_then(input, |_, _| {});
}
fn grant_new_user_registration_wallet_reward_then(
&self,
input: RuntimeProfileDashboardGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl grant_new_user_registration_wallet_reward for super::RemoteProcedures {
fn grant_new_user_registration_wallet_reward_then(
&self,
input: RuntimeProfileDashboardGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>(
"grant_new_user_registration_wallet_reward",
GrantNewUserRegistrationWalletRewardArgs { input, },
__callback,
);
}
}

View File

@@ -375,6 +375,7 @@ pub mod runtime_profile_wallet_ledger_source_type_type;
pub mod runtime_referral_invite_center_get_input_type;
pub mod runtime_referral_invite_center_procedure_result_type;
pub mod runtime_referral_invite_center_snapshot_type;
pub mod runtime_referral_invited_user_snapshot_type;
pub mod runtime_referral_redeem_input_type;
pub mod runtime_referral_redeem_procedure_result_type;
pub mod runtime_referral_redeem_snapshot_type;
@@ -507,6 +508,7 @@ pub mod get_runtime_inventory_state_procedure;
pub mod get_runtime_setting_or_default_procedure;
pub mod get_runtime_snapshot_procedure;
pub mod get_story_session_state_procedure;
pub mod grant_new_user_registration_wallet_reward_procedure;
pub mod grant_player_progression_experience_and_return_procedure;
pub mod import_auth_store_snapshot_procedure;
pub mod import_database_migration_from_chunks_procedure;
@@ -939,6 +941,7 @@ pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedg
pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput;
pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult;
pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot;
pub use runtime_referral_invited_user_snapshot_type::RuntimeReferralInvitedUserSnapshot;
pub use runtime_referral_redeem_input_type::RuntimeReferralRedeemInput;
pub use runtime_referral_redeem_procedure_result_type::RuntimeReferralRedeemProcedureResult;
pub use runtime_referral_redeem_snapshot_type::RuntimeReferralRedeemSnapshot;
@@ -1071,6 +1074,7 @@ pub use get_runtime_inventory_state_procedure::get_runtime_inventory_state;
pub use get_runtime_setting_or_default_procedure::get_runtime_setting_or_default;
pub use get_runtime_snapshot_procedure::get_runtime_snapshot;
pub use get_story_session_state_procedure::get_story_session_state;
pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_registration_wallet_reward;
pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return;
pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot;
pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks;

View File

@@ -23,3 +23,4 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult {
impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -23,3 +23,4 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
impl __sdk::InModule for RuntimeProfileInviteCodeAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -24,3 +24,4 @@ pub struct RuntimeProfileInviteCodeSnapshot {
impl __sdk::InModule for RuntimeProfileInviteCodeSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -15,6 +15,8 @@ use spacetimedb_sdk::__codegen::{
pub enum RuntimeProfileWalletLedgerSourceType {
SnapshotSync,
NewUserRegistrationReward,
InviteInviterReward,
InviteInviteeReward,

View File

@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{
__ws,
};
use super::runtime_referral_invited_user_snapshot_type::RuntimeReferralInvitedUserSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -21,6 +22,7 @@ pub struct RuntimeReferralInviteCenterSnapshot {
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub invited_users: Vec::<RuntimeReferralInvitedUserSnapshot>,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option::<String>,
pub bound_at_micros: Option::<i64>,

View File

@@ -0,0 +1,26 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeReferralInvitedUserSnapshot {
pub user_id: String,
pub display_name: String,
pub avatar_url: Option::<String>,
pub bound_at_micros: i64,
}
impl __sdk::InModule for RuntimeReferralInvitedUserSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -89,6 +89,30 @@ impl SpacetimeClient {
.await
}
pub async fn grant_new_user_registration_wallet_reward(
&self,
user_id: String,
) -> Result<RuntimeProfileDashboardRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_dashboard_get_input(user_id)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.grant_new_user_registration_wallet_reward_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_wallet_adjustment_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn consume_profile_wallet_points(
&self,
user_id: String,

View File

@@ -2344,7 +2344,7 @@ fn execute_publish_world_action(
draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?,
legacy_result_profile_json,
setting_text,
author_display_name: "陶泥".to_string(),
author_display_name: "百梦".to_string(),
published_at_micros: input.submitted_at_micros,
},
)?;

View File

@@ -327,7 +327,7 @@ pub struct CustomWorldProfile {
owner_user_id: String,
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
public_work_code: Option<String>,
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
// 作者公开百梦号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
author_public_user_code: Option<String>,
source_agent_session_id: Option<String>,
publication_status: CustomWorldPublicationStatus,
@@ -3997,7 +3997,7 @@ fn execute_publish_world_action(
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
.unwrap_or_else(|| "陶泥".to_string());
.unwrap_or_else(|| "百梦".to_string());
let publish_result = publish_custom_world_world_record(
ctx,
CustomWorldPublishWorldInput {

View File

@@ -466,7 +466,7 @@ fn compile_match3d_draft_tx(
profile_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
source_session_id: input.session_id.clone(),
author_display_name: clean_string(&input.author_display_name, "陶泥"),
author_display_name: clean_string(&input.author_display_name, "百梦"),
game_name,
theme_text: config.theme_text.clone(),
summary_text,

View File

@@ -947,7 +947,7 @@ fn save_puzzle_generated_images_tx(
draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥")).publish_ready {
let next_stage = if build_result_preview(&draft, Some("百梦")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -1026,7 +1026,7 @@ fn select_puzzle_cover_image_tx(
};
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥")).publish_ready {
let next_stage = if build_result_preview(&draft, Some("百梦")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -2080,7 +2080,7 @@ fn build_puzzle_agent_session_snapshot(
let messages = list_session_messages(ctx, &row.session_id);
let result_preview = draft
.as_ref()
.map(|value| build_result_preview(value, Some("陶泥")));
.map(|value| build_result_preview(value, Some("百梦")));
Ok(PuzzleAgentSessionSnapshot {
session_id: row.session_id.clone(),
@@ -2268,7 +2268,7 @@ fn upsert_puzzle_draft_work_profile(
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
"陶泥".to_string(),
"百梦".to_string(),
draft,
updated_at_micros,
)
@@ -2565,6 +2565,8 @@ fn upsert_puzzle_profile_save_archive(
};
let world_key = format!("puzzle:{}", run.entry_profile_id);
let target = resolve_puzzle_archive_target(ctx, run, current_level)?;
let work_title = resolve_puzzle_archive_work_title(ctx, &target.profile_id, &target.level_name);
let subtitle = build_puzzle_archive_subtitle(target.level_index, &target.level_name);
// 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。
let game_state_json = json_to_string(&json!({
@@ -2586,8 +2588,8 @@ fn upsert_puzzle_profile_save_archive(
owner_user_id: target.owner_user_id,
profile_id: Some(run.entry_profile_id.clone()),
world_type: Some("PUZZLE".to_string()),
world_name: target.level_name,
subtitle: format!("第 {} 关", target.level_index),
world_name: work_title,
subtitle,
summary_text: puzzle_archive_summary_text(target.status),
cover_image_src: target.cover_image_src,
bottom_tab: "puzzle".to_string(),
@@ -2682,6 +2684,37 @@ fn resolve_puzzle_archive_target(
})
}
fn resolve_puzzle_archive_work_title(
ctx: &TxContext,
profile_id: &str,
fallback_level_name: &str,
) -> String {
// 中文注释:存档主标题必须是作品名;历史数据或异常行缺失作品名时才回退到关卡名。
ctx.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.map(|row| {
let title = row.work_title.trim();
if title.is_empty() {
fallback_level_name.to_string()
} else {
title.to_string()
}
})
.unwrap_or_else(|| fallback_level_name.to_string())
}
fn build_puzzle_archive_subtitle(level_index: u32, level_name: &str) -> String {
let level_label = format!("{level_index}");
let level_name = level_name.trim();
if level_name.is_empty() {
level_label
} else {
format!("{level_label} · {level_name}")
}
}
fn resolve_puzzle_current_owner_user_id(ctx: &TxContext, profile_id: &str) -> Option<String> {
ctx.db
.puzzle_work_profile()
@@ -2745,10 +2778,11 @@ fn accrue_puzzle_point_incentive(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: module_puzzle::puzzle_point_incentive_total_after_spend(
row.point_incentive_total_half_points,
spent_points,
),
point_incentive_total_half_points:
module_puzzle::puzzle_point_incentive_total_after_spend(
row.point_incentive_total_half_points,
spent_points,
),
point_incentive_claimed_points: row.point_incentive_claimed_points,
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,

View File

@@ -2,6 +2,8 @@ use crate::*;
const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
#[spacetimedb::table(accessor = profile_dashboard_state)]
pub struct ProfileDashboardState {
@@ -353,6 +355,26 @@ pub fn list_profile_wallet_ledger(
}
}
// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。
#[spacetimedb::procedure]
pub fn grant_new_user_registration_wallet_reward(
ctx: &mut ProcedureContext,
input: RuntimeProfileDashboardGetInput,
) -> RuntimeProfileWalletAdjustmentProcedureResult {
match ctx.try_with_tx(|tx| grant_new_user_registration_wallet_reward_tx(tx, input.clone())) {
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。
#[spacetimedb::procedure]
pub fn consume_profile_wallet_points_and_return(
@@ -491,7 +513,7 @@ pub fn get_profile_referral_invite_center(
}
}
// 填码绑定、每日邀请者奖励上限和双方陶泥币发放都在同一事务内完成。
// 填码绑定、每日邀请者奖励上限和双方光点发放都在同一事务内完成。
#[spacetimedb::procedure]
pub fn redeem_profile_referral_invite_code(
ctx: &mut ProcedureContext,
@@ -1041,11 +1063,18 @@ 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 has_business_wallet_ledger = has_profile_business_wallet_ledger(ctx, &snapshot.user_id);
let synced_wallet_balance = if has_business_wallet_ledger {
None
} else {
read_optional_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency")))
};
let next_wallet_balance = synced_wallet_balance.unwrap_or(previous_wallet_balance);
let mut next_total_play_time_ms = previous_total_play_time_ms;
if next_wallet_balance != previous_wallet_balance {
if let Some(next_wallet_balance) = synced_wallet_balance
&& next_wallet_balance != previous_wallet_balance
{
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: format!(
"{}:{}:{}",
@@ -1258,6 +1287,10 @@ fn read_non_negative_u64(value: Option<&JsonValue>) -> u64 {
}
}
fn read_optional_non_negative_u64(value: Option<&JsonValue>) -> Option<u64> {
value.map(|raw| read_non_negative_u64(Some(raw)))
}
fn read_string_from_json(value: Option<&JsonValue>) -> Option<String> {
value
.and_then(JsonValue::as_str)
@@ -1986,6 +2019,7 @@ fn build_profile_referral_invite_center_snapshot(
today_inviter_reward_remaining: PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT
.saturating_sub(today_inviter_reward_count),
reward_points: PROFILE_REFERRAL_REWARD_POINTS,
invited_users: list_profile_referral_invited_users(ctx, user_id),
has_redeemed_code: bound_relation.is_some(),
bound_inviter_user_id: bound_relation
.as_ref()
@@ -1997,6 +2031,50 @@ fn build_profile_referral_invite_center_snapshot(
}
}
fn list_profile_referral_invited_users(
ctx: &ReducerContext,
inviter_user_id: &str,
) -> Vec<RuntimeReferralInvitedUserSnapshot> {
// 中文注释:邀请面板只展示最近成功邀请用户,完整统计仍由计数字段承担。
let inviter_user_id = inviter_user_id.to_string();
let mut relations = ctx
.db
.profile_referral_relation()
.by_profile_referral_inviter_user_id()
.filter(&inviter_user_id)
.collect::<Vec<_>>();
relations.sort_by(|left, right| {
right
.bound_at
.to_micros_since_unix_epoch()
.cmp(&left.bound_at.to_micros_since_unix_epoch())
});
relations
.into_iter()
.take(PROFILE_REFERRAL_INVITED_USERS_LIMIT)
.map(|relation| {
let account = ctx
.db
.user_account()
.user_id()
.find(&relation.invitee_user_id);
RuntimeReferralInvitedUserSnapshot {
user_id: relation.invitee_user_id,
display_name: account
.as_ref()
.map(|user| user.display_name.trim())
.filter(|name| !name.is_empty())
.unwrap_or("玩家")
.to_string(),
avatar_url: account.and_then(|user| user.avatar_url),
bound_at_micros: relation.bound_at.to_micros_since_unix_epoch(),
}
})
.collect()
}
fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInviteCode {
if let Some(row) = ctx
.db
@@ -2072,6 +2150,42 @@ fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 {
.unwrap_or(0)
}
fn build_new_user_registration_wallet_ledger_id(user_id: &str) -> String {
format!("{PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX}:{user_id}")
}
fn grant_new_user_registration_wallet_reward_tx(
ctx: &ReducerContext,
input: RuntimeProfileDashboardGetInput,
) -> Result<RuntimeProfileDashboardSnapshot, String> {
let validated_input = build_runtime_profile_dashboard_get_input(input.user_id)
.map_err(|error| error.to_string())?;
let ledger_id = build_new_user_registration_wallet_ledger_id(&validated_input.user_id);
if ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id)
.is_none()
{
apply_profile_wallet_delta(
ctx,
&validated_input.user_id,
PROFILE_NEW_USER_INITIAL_WALLET_POINTS,
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward,
&ledger_id,
ctx.timestamp,
)?;
}
get_profile_dashboard_snapshot(
ctx,
RuntimeProfileDashboardGetInput {
user_id: validated_input.user_id,
},
)
}
fn build_profile_recharge_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
@@ -2291,7 +2405,7 @@ fn apply_profile_wallet_signed_delta(
} else {
previous_balance
.checked_sub(amount_delta.unsigned_abs())
.ok_or_else(|| "陶泥币余额不足".to_string())?
.ok_or_else(|| "光点余额不足".to_string())?
};
let created_state_at = current
.as_ref()
@@ -2343,6 +2457,13 @@ fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
})
}
fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db.profile_wallet_ledger().iter().any(|row| {
row.user_id == user_id
&& row.source_type != RuntimeProfileWalletLedgerSourceType::SnapshotSync
})
}
fn latest_profile_recharge_order(
ctx: &ReducerContext,
user_id: &str,