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

@@ -89,8 +89,8 @@ pub fn build_runtime_tracking_event_input(
) -> Result<RuntimeTrackingEventInput, RuntimeProfileFieldError> {
let event_id = normalize_required_string(event_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingEventId)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let scope_id = normalize_required_string(scope_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
let metadata_json = normalize_tracking_metadata_json(metadata_json)?;
@@ -151,8 +151,12 @@ pub fn build_runtime_profile_task_config_admin_upsert_input(
let task_id = normalize_profile_task_id(task_id)?;
let title =
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingTaskTitle)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
// 中文注释:个人任务首版只按用户维度累计,避免 site/work/module 误复用用户桶。
if scope_kind != RuntimeTrackingScopeKind::User {
return Err(RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind);
}
if threshold == 0 {
return Err(RuntimeProfileFieldError::InvalidTaskThreshold);
}
@@ -326,17 +330,25 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
metadata_json: String,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
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)?;
crate::commands::validate_runtime_profile_invite_code_validity_window(
starts_at_micros,
expires_at_micros,
)?;
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
admin_user_id,
invite_code,
metadata_json,
starts_at_micros,
expires_at_micros,
updated_at_micros,
})
}
@@ -639,6 +651,40 @@ pub fn normalize_invite_code_metadata_json(
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
pub fn validate_runtime_profile_invite_code_validity_window(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
) -> Result<(), RuntimeProfileFieldError> {
if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at > expires_at)
{
return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow);
}
Ok(())
}
pub fn resolve_runtime_profile_invite_code_status(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
now_micros: i64,
) -> RuntimeProfileInviteCodeStatus {
if starts_at_micros
.map(|starts_at| now_micros < starts_at)
.unwrap_or(false)
{
return RuntimeProfileInviteCodeStatus::Pending;
}
if expires_at_micros
.map(|expires_at| now_micros >= expires_at)
.unwrap_or(false)
{
return RuntimeProfileInviteCodeStatus::Expired;
}
RuntimeProfileInviteCodeStatus::Active
}
fn normalize_tracking_metadata_json(value: String) -> Result<String, RuntimeProfileFieldError> {
let trimmed = value.trim();
if trimmed.is_empty() {