feat: add invite code validity controls

- Add invite code starts/expires fields across contracts, API, Spacetime bindings, and admin UI
- Enforce pending/expired invite code redemption behavior and expose admin status
- Add admin write-operation confirmation guard and documentation
- Add invite code contract/runtime tests
This commit is contained in:
2026-05-04 12:29:33 +08:00
parent 1142e90a35
commit 9f3e34e81a
27 changed files with 1465 additions and 97 deletions

View File

@@ -1,3 +1,4 @@
use crate::runtime::analytics_date_dimension::analytics_date_dimension;
use crate::*;
use serde::{Deserialize, Serialize};
use spacetimedb_lib::sats::de::serde::DeserializeWrapper;
@@ -161,6 +162,7 @@ macro_rules! migration_tables {
user_browse_history,
profile_dashboard_state,
profile_wallet_ledger,
analytics_date_dimension,
tracking_event,
tracking_daily_stat,
profile_task_config,

View File

@@ -1,8 +1,10 @@
pub mod analytics_date_dimension;
mod browse_history;
mod profile;
mod settings;
mod snapshots;
pub use analytics_date_dimension::*;
pub use browse_history::*;
pub use profile::*;
pub use settings::*;

View File

@@ -6,7 +6,6 @@ const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task";
const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login";
const PROFILE_TRACKING_SITE_SCOPE_ID: &str = "site";
const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile";
#[spacetimedb::table(accessor = profile_dashboard_state)]
@@ -188,6 +187,10 @@ pub struct ProfileInviteCode {
pub(crate) metadata_json: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
#[default(None::<Timestamp>)]
pub(crate) starts_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expires_at: Option<Timestamp>,
}
#[spacetimedb::table(
@@ -1902,6 +1905,7 @@ fn redeem_profile_referral_invite_code_record(
.invite_code()
.find(&invite_code)
.ok_or_else(|| "邀请码不存在".to_string())?;
validate_profile_invite_code_redeem_time(&inviter_code, validated_input.updated_at_micros)?;
if inviter_code.user_id == invitee_user_id {
return Err("不能填写自己的邀请码".to_string());
}
@@ -2124,6 +2128,8 @@ fn admin_upsert_profile_invite_code_record(
input.admin_user_id,
input.invite_code,
input.metadata_json,
input.starts_at_micros,
input.expires_at_micros,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
@@ -2152,6 +2158,12 @@ fn admin_upsert_profile_invite_code_record(
metadata_json: validated_input.metadata_json,
created_at: existing.created_at,
updated_at,
starts_at: validated_input
.starts_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
});
return Ok(build_profile_invite_code_snapshot_from_row(&inserted));
}
@@ -2162,6 +2174,12 @@ fn admin_upsert_profile_invite_code_record(
metadata_json: validated_input.metadata_json,
created_at: updated_at,
updated_at,
starts_at: validated_input
.starts_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
});
Ok(build_profile_invite_code_snapshot_from_row(&inserted))
}
@@ -2286,9 +2304,34 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
created_at: ctx.timestamp,
updated_at: ctx.timestamp,
starts_at: None,
expires_at: None,
})
}
fn validate_profile_invite_code_redeem_time(
invite_code: &ProfileInviteCode,
now_micros: i64,
) -> Result<(), String> {
if invite_code
.starts_at
.map(|starts_at| now_micros < starts_at.to_micros_since_unix_epoch())
.unwrap_or(false)
{
return Err("邀请码未生效".to_string());
}
if invite_code
.expires_at
.map(|expires_at| now_micros >= expires_at.to_micros_since_unix_epoch())
.unwrap_or(false)
{
return Err("邀请码已过期".to_string());
}
Ok(())
}
fn count_today_profile_referral_inviter_rewards(
ctx: &ReducerContext,
user_id: &str,
@@ -2397,11 +2440,7 @@ fn get_profile_task_center_snapshot(
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
}
Ok(build_profile_task_center_snapshot(
ctx,
&validated_input.user_id,
ctx.timestamp,
))
build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)
}
fn claim_profile_task_reward_record(
@@ -2438,7 +2477,7 @@ fn claim_profile_task_reward_record(
return Err(RuntimeProfileFieldError::TaskAlreadyClaimed.to_string());
}
let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config);
let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config)?;
if progress_count < config.threshold {
return Err(RuntimeProfileFieldError::TaskNotClaimable.to_string());
}
@@ -2469,7 +2508,7 @@ fn claim_profile_task_reward_record(
claimed_at: ctx.timestamp,
});
refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key);
refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key)?;
let ledger_entry = ctx
.db
.profile_wallet_ledger()
@@ -2484,7 +2523,7 @@ fn claim_profile_task_reward_record(
reward_points: claim.reward_points,
wallet_balance,
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp),
center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)?,
})
}
@@ -2640,7 +2679,7 @@ fn build_profile_task_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
updated_at: Timestamp,
) -> RuntimeProfileTaskCenterSnapshot {
) -> Result<RuntimeProfileTaskCenterSnapshot, String> {
ensure_default_profile_task_config(ctx);
let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch());
let mut configs = ctx.db.profile_task_config().iter().collect::<Vec<_>>();
@@ -2649,43 +2688,42 @@ fn build_profile_task_center_snapshot(
.cmp(&right.sort_order)
.then_with(|| left.task_id.cmp(&right.task_id))
});
let tasks = configs
.into_iter()
.map(|config| {
let progress_count = profile_task_progress_count(ctx, user_id, &config);
refresh_profile_task_progress(ctx, user_id, &config, day_key);
let claim = ctx.db.profile_task_reward_claim().claim_id().find(
&build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key),
);
RuntimeProfileTaskItemSnapshot {
task_id: config.task_id,
title: config.title,
description: config.description,
event_key: config.event_key,
cycle: config.cycle,
threshold: config.threshold,
let mut tasks = Vec::with_capacity(configs.len());
for config in configs {
validate_profile_task_user_scope(&config)?;
let progress_count = profile_task_progress_count(ctx, user_id, &config)?;
refresh_profile_task_progress(ctx, user_id, &config, day_key)?;
let claim = ctx.db.profile_task_reward_claim().claim_id().find(
&build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key),
);
tasks.push(RuntimeProfileTaskItemSnapshot {
task_id: config.task_id,
title: config.title,
description: config.description,
event_key: config.event_key,
cycle: config.cycle,
threshold: config.threshold,
progress_count,
reward_points: config.reward_points,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
reward_points: config.reward_points,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
config.threshold,
claim.is_some(),
),
day_key,
claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()),
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
}
})
.collect();
config.threshold,
claim.is_some(),
),
day_key,
claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()),
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
});
}
RuntimeProfileTaskCenterSnapshot {
Ok(RuntimeProfileTaskCenterSnapshot {
user_id: user_id.to_string(),
day_key,
wallet_balance: profile_wallet_balance(ctx, user_id),
tasks,
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
}
})
}
fn refresh_profile_task_progress(
@@ -2693,7 +2731,7 @@ fn refresh_profile_task_progress(
user_id: &str,
config: &ProfileTaskConfig,
day_key: i64,
) -> ProfileTaskProgress {
) -> Result<ProfileTaskProgress, String> {
let progress_id = build_runtime_profile_task_progress_id(user_id, &config.task_id, day_key);
if let Some(existing) = ctx
.db
@@ -2706,7 +2744,7 @@ fn refresh_profile_task_progress(
.progress_id()
.delete(&existing.progress_id);
}
let progress_count = profile_task_progress_count(ctx, user_id, config);
let progress_count = profile_task_progress_count(ctx, user_id, config)?;
let claimed = ctx
.db
.profile_task_reward_claim()
@@ -2717,7 +2755,7 @@ fn refresh_profile_task_progress(
day_key,
))
.is_some();
ctx.db.profile_task_progress().insert(ProfileTaskProgress {
Ok(ctx.db.profile_task_progress().insert(ProfileTaskProgress {
progress_id,
user_id: user_id.to_string(),
task_id: config.task_id.clone(),
@@ -2731,17 +2769,19 @@ fn refresh_profile_task_progress(
claimed,
),
updated_at: ctx.timestamp,
})
}))
}
fn profile_task_progress_count(
ctx: &ReducerContext,
user_id: &str,
config: &ProfileTaskConfig,
) -> u32 {
) -> Result<u32, String> {
validate_profile_task_user_scope(config)?;
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let scope_id = profile_task_tracking_scope_id(user_id, config);
ctx.db
let scope_id = profile_task_tracking_scope_id(user_id, config)?;
Ok(ctx
.db
.tracking_daily_stat()
.stat_id()
.find(&build_runtime_tracking_daily_stat_id(
@@ -2751,15 +2791,26 @@ fn profile_task_progress_count(
day_key,
))
.map(|row| row.count)
.unwrap_or(0)
.unwrap_or(0))
}
fn profile_task_tracking_scope_id(user_id: &str, config: &ProfileTaskConfig) -> String {
match config.scope_kind {
RuntimeTrackingScopeKind::Site => PROFILE_TRACKING_SITE_SCOPE_ID.to_string(),
RuntimeTrackingScopeKind::Module => PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string(),
RuntimeTrackingScopeKind::User => user_id.to_string(),
RuntimeTrackingScopeKind::Work => user_id.to_string(),
fn profile_task_tracking_scope_id(
user_id: &str,
config: &ProfileTaskConfig,
) -> Result<String, String> {
validate_profile_task_user_scope(config)?;
Ok(user_id.to_string())
}
fn validate_profile_task_user_scope(config: &ProfileTaskConfig) -> Result<(), String> {
if config.scope_kind == RuntimeTrackingScopeKind::User {
Ok(())
} else {
Err(format!(
"个人任务 scope_kind 首版仅支持 user当前 task_id={} scope_kind={}",
config.task_id,
config.scope_kind.as_str()
))
}
}
@@ -3242,6 +3293,12 @@ fn build_profile_invite_code_snapshot_from_row(
user_id: row.user_id.clone(),
invite_code: row.invite_code.clone(),
metadata_json: row.metadata_json.clone(),
starts_at_micros: row
.starts_at
.map(|value| value.to_micros_since_unix_epoch()),
expires_at_micros: row
.expires_at
.map(|value| value.to_micros_since_unix_epoch()),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}