Implement registration invite code flow and admin invite codes
This commit is contained in:
@@ -17,6 +17,8 @@ pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
|
||||
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
|
||||
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 = "{}";
|
||||
const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096;
|
||||
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||
@@ -502,6 +504,33 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileInviteCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileInviteCodeSnapshot {
|
||||
pub user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileInviteCodeAdminProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileInviteCodeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeReferralInviteCenterSnapshot {
|
||||
@@ -615,6 +644,7 @@ pub enum RuntimeProfileFieldError {
|
||||
MissingLedgerId,
|
||||
InvalidWalletAmount,
|
||||
MissingInviteCode,
|
||||
InvalidInviteCodeMetadata,
|
||||
MissingRedeemCode,
|
||||
InvalidRedeemCodeReward,
|
||||
InvalidRedeemCodeMaxUses,
|
||||
@@ -916,6 +946,17 @@ pub struct RuntimeProfileRedeemCodeRecord {
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileInviteCodeRecord {
|
||||
pub user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub created_at: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeReferralInviteCenterRecord {
|
||||
pub user_id: String,
|
||||
@@ -1141,6 +1182,25 @@ pub fn build_runtime_profile_redeem_code_admin_disable_input(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_invite_code_admin_upsert_input(
|
||||
admin_user_id: String,
|
||||
invite_code: String,
|
||||
metadata_json: String,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileInviteCodeAdminUpsertInput, RuntimeProfileFieldError> {
|
||||
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
||||
let invite_code =
|
||||
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
|
||||
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
|
||||
|
||||
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
|
||||
admin_user_id,
|
||||
invite_code,
|
||||
metadata_json,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
||||
@@ -1523,6 +1583,20 @@ pub fn build_runtime_profile_redeem_code_record(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_invite_code_record(
|
||||
snapshot: RuntimeProfileInviteCodeSnapshot,
|
||||
) -> RuntimeProfileInviteCodeRecord {
|
||||
RuntimeProfileInviteCodeRecord {
|
||||
user_id: snapshot.user_id,
|
||||
invite_code: snapshot.invite_code,
|
||||
metadata_json: snapshot.metadata_json,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at: format_utc_micros(snapshot.updated_at_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_record(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> RuntimeProfilePlayedWorldRecord {
|
||||
@@ -1947,6 +2021,25 @@ pub fn normalize_invite_code(value: String) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_invite_code_metadata_json(
|
||||
value: String,
|
||||
) -> Result<String, RuntimeProfileFieldError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string());
|
||||
}
|
||||
if trimmed.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES {
|
||||
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
|
||||
}
|
||||
|
||||
let parsed = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
|
||||
if !parsed.is_object() {
|
||||
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
|
||||
}
|
||||
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
|
||||
}
|
||||
|
||||
pub fn normalize_redeem_code(value: String) -> Option<String> {
|
||||
normalize_invite_code(value)
|
||||
}
|
||||
@@ -1958,6 +2051,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
||||
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
||||
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
||||
Self::InvalidInviteCodeMetadata => {
|
||||
f.write_str("邀请码 metadata 必须是 JSON 对象且不超过 4096 bytes")
|
||||
}
|
||||
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
||||
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
|
||||
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
|
||||
@@ -2202,6 +2298,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_code_metadata_defaults_to_empty_object() {
|
||||
assert_eq!(
|
||||
normalize_invite_code_metadata_json(" ".to_string()).expect("blank metadata defaults"),
|
||||
"{}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_code_metadata_requires_json_object() {
|
||||
assert_eq!(
|
||||
normalize_invite_code_metadata_json("[]".to_string()).expect_err("array rejects"),
|
||||
RuntimeProfileFieldError::InvalidInviteCodeMetadata
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_invite_code_metadata_json("{bad".to_string()).expect_err("bad json rejects"),
|
||||
RuntimeProfileFieldError::InvalidInviteCodeMetadata
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_admin_invite_code_input_normalizes_code_and_compacts_metadata() {
|
||||
let input = build_runtime_profile_invite_code_admin_upsert_input(
|
||||
" admin-user ".to_string(),
|
||||
" spring-2026 ".to_string(),
|
||||
r#"{ "channel": "spring", "batch": 1 }"#.to_string(),
|
||||
1_776_000_000_000_000,
|
||||
)
|
||||
.expect("admin invite input should build");
|
||||
|
||||
assert_eq!(input.admin_user_id, "admin-user");
|
||||
assert_eq!(input.invite_code, "SPRING2026");
|
||||
assert_eq!(input.metadata_json, r#"{"batch":1,"channel":"spring"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_dashboard_record_formats_optional_timestamp() {
|
||||
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user