Implement registration invite code flow and admin invite codes

This commit is contained in:
2026-04-30 20:49:38 +08:00
parent 2aef81e51d
commit 42aab671ed
32 changed files with 1241 additions and 179 deletions

View File

@@ -876,6 +876,28 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>邀请码管理</h2>
<p>创建或更新管理员邀请码。</p>
</div>
<div class="panel-body">
<form id="invite-code-form" class="form">
<label>邀请码
<input id="invite-code-value" autocomplete="off" />
</label>
<label>Metadata JSON
<textarea id="invite-code-metadata">{}</textarea>
</label>
<div class="btn-row">
<button id="invite-code-submit" type="submit">保存邀请码</button>
</div>
<div id="invite-code-message" class="hint"></div>
<div id="invite-code-result" class="result-panel" style="display:none;"></div>
</form>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>API 调试</h2>
@@ -950,6 +972,8 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
const overviewTablesEl = document.getElementById('overview-tables');
const overviewErrorsEl = document.getElementById('overview-errors');
const debugResultEl = document.getElementById('debug-result');
const inviteCodeMessageEl = document.getElementById('invite-code-message');
const inviteCodeResultEl = document.getElementById('invite-code-result');
function getToken() {
return window.localStorage.getItem(TOKEN_KEY) || '';
@@ -1030,6 +1054,16 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
`;
}
function renderInviteCodeResult(result) {
inviteCodeResultEl.style.display = 'grid';
inviteCodeResultEl.innerHTML = `
<div><strong>User ID</strong>${result.userId || '-'}</div>
<div><strong>邀请码:</strong>${result.inviteCode || '-'}</div>
<div><strong>更新时间:</strong>${result.updatedAt || '-'}</div>
<div><strong>Metadata</strong><pre>${JSON.stringify(result.metadata || {}, null, 2)}</pre></div>
`;
}
async function loadMe() {
const token = getToken();
if (!token) {
@@ -1107,6 +1141,26 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
}
});
document.getElementById('invite-code-form').addEventListener('submit', async (event) => {
event.preventDefault();
inviteCodeMessageEl.textContent = '正在保存...';
try {
const rawMetadata = document.getElementById('invite-code-metadata').value.trim() || '{}';
const metadata = JSON.parse(rawMetadata);
const result = await request('/admin/api/profile/invite-codes', {
method: 'POST',
json: {
inviteCode: document.getElementById('invite-code-value').value,
metadata,
},
});
inviteCodeMessageEl.textContent = '已保存';
renderInviteCodeResult(result);
} catch (error) {
inviteCodeMessageEl.textContent = error.message;
}
});
loadMe().then(loadOverview);
</script>
</body>

View File

@@ -103,10 +103,10 @@ use crate::{
},
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code, redeem_profile_reward_code,
admin_disable_profile_redeem_code, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -168,6 +168,13 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth,
)),
)
.route(
"/admin/api/profile/invite-codes",
post(admin_upsert_profile_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
@@ -1845,6 +1852,8 @@ mod tests {
payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
assert_eq!(payload["created"], Value::Bool(true));
assert!(payload["referral"].is_null());
}
#[tokio::test]
@@ -1951,6 +1960,175 @@ mod tests {
serde_json::from_slice(&second_body).expect("second login payload should be json");
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
assert_eq!(first_payload["created"], Value::Bool(true));
assert_eq!(second_payload["created"], Value::Bool(false));
assert!(second_payload["referral"].is_null());
}
#[tokio::test]
async fn phone_login_invite_code_failure_does_not_block_created_user() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13600136000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13600136000",
"code": "123456",
"inviteCode": "SPRING2026"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
let body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let payload: Value = serde_json::from_slice(&body).expect("login payload should be json");
assert!(payload["token"].as_str().is_some());
assert_eq!(payload["created"], Value::Bool(true));
assert_eq!(payload["referral"]["ok"], Value::Bool(false));
assert_eq!(
payload["referral"]["message"],
Value::String("邀请码无效,已继续注册".to_string())
);
}
#[tokio::test]
async fn phone_login_existing_user_ignores_invite_code() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let first_send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(first_send_code_response.status(), StatusCode::OK);
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"code": "123456"
})
.to_string(),
))
.expect("first login request should build"),
)
.await
.expect("first login request should succeed");
assert_eq!(first_login_response.status(), StatusCode::OK);
let second_send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(second_send_code_response.status(), StatusCode::OK);
let second_login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"code": "123456",
"inviteCode": "SPRING2026"
})
.to_string(),
))
.expect("second login request should build"),
)
.await
.expect("second login request should succeed");
assert_eq!(second_login_response.status(), StatusCode::OK);
let body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("second login payload should be json");
assert_eq!(payload["created"], Value::Bool(false));
assert!(payload["referral"].is_null());
}
#[tokio::test]

View File

@@ -9,7 +9,8 @@ use module_auth::{
};
use serde_json::json;
use shared_contracts::auth::{
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse,
PhoneLoginReferralResponse, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
PhoneSendCodeResponse,
};
use time::OffsetDateTime;
use tracing::{info, warn};
@@ -110,6 +111,7 @@ pub async fn phone_login(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let invite_code = payload.invite_code.clone();
let result = match state
.phone_auth_service()
.login(
@@ -146,6 +148,18 @@ pub async fn phone_login(
return Err(map_phone_auth_error(error));
}
};
let created = result.created;
let referral = if created {
bind_referral_invite_code_on_registration(
&state,
&request_context,
result.user.id.clone(),
invite_code,
)
.await
} else {
None
};
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
@@ -174,11 +188,55 @@ pub async fn phone_login(
PhoneLoginResponse {
token: signed_session.access_token,
user: map_auth_user_payload(result.user),
created,
referral,
},
),
))
}
async fn bind_referral_invite_code_on_registration(
state: &AppState,
request_context: &RequestContext,
user_id: String,
invite_code: Option<String>,
) -> Option<PhoneLoginReferralResponse> {
let invite_code = invite_code
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
match state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, invite_code, updated_at_micros as i64)
.await
{
Ok(record) => Some(PhoneLoginReferralResponse {
ok: true,
message: Some("邀请码已绑定".to_string()),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: Some(record.invitee_balance_after),
inviter_balance_after: Some(record.inviter_balance_after),
}),
Err(error) => {
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
error = %error,
"注册邀请码绑定失败,登录流程继续"
);
Some(PhoneLoginReferralResponse {
ok: false,
message: Some("邀请码无效,已继续注册".to_string()),
invitee_reward_granted: false,
inviter_reward_granted: false,
invitee_balance_after: None,
inviter_balance_after: None,
})
}
}
}
fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> {
match raw_scene.unwrap_or("login").trim() {
"login" => Ok(PhoneAuthScene::Login),

View File

@@ -36,11 +36,11 @@ use shared_contracts::{
},
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
puzzle_runtime::{
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -57,10 +57,10 @@ use spacetime_client::{
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;

View File

@@ -5,31 +5,30 @@ use axum::{
response::Response,
};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest,
CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
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_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -213,27 +212,14 @@ pub async fn get_profile_referral_invite_center(
}
pub async fn redeem_profile_referral_invite_code(
State(state): State<AppState>,
State(_state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(_payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_referral_invite_code_response(record),
Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"),
))
}
@@ -330,6 +316,37 @@ pub async fn admin_disable_profile_redeem_code(
))
}
pub async fn admin_upsert_profile_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
let metadata_json = normalize_admin_invite_code_metadata(payload.metadata)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_upsert_profile_invite_code(
admin.session().username.clone(),
payload.invite_code,
metadata_json,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_invite_code_admin_response(record),
))
}
pub async fn get_profile_play_stats(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -486,18 +503,6 @@ fn build_profile_referral_invite_center_response(
}
}
fn build_redeem_profile_referral_invite_code_response(
record: RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
fn build_redeem_profile_reward_code_response(
record: RuntimeProfileRewardCodeRedeemRecord,
) -> RedeemProfileRewardCodeResponse {
@@ -515,6 +520,30 @@ fn build_redeem_profile_reward_code_response(
}
}
fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<String, AppError> {
let metadata = match metadata {
Some(Value::Null) | None => json!({}),
Some(value) if value.is_object() => value,
Some(_) => {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("邀请码 metadata 必须是 JSON 对象")
.with_details(json!({ "field": "metadata" })));
}
};
let metadata_json = serde_json::to_string(&metadata).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("邀请码 metadata 序列化失败:{error}"))
.with_details(json!({ "field": "metadata" }))
})?;
if metadata_json.len() > 4096 {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("邀请码 metadata 不能超过 4096 bytes")
.with_details(json!({ "field": "metadata" })));
}
Ok(metadata_json)
}
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
@@ -524,6 +553,20 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeM
}
}
fn build_profile_invite_code_admin_response(
record: RuntimeProfileInviteCodeRecord,
) -> ProfileInviteCodeAdminResponse {
let metadata =
serde_json::from_str::<Value>(&record.metadata_json).unwrap_or_else(|_| json!({}));
ProfileInviteCodeAdminResponse {
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn build_profile_redeem_code_admin_response(
record: RuntimeProfileRedeemCodeRecord,
) -> ProfileRedeemCodeAdminResponse {
@@ -545,7 +588,7 @@ fn build_profile_redeem_code_admin_response(
mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType;
use super::format_profile_wallet_ledger_source_type;
use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata};
use axum::{
body::Body,
@@ -705,6 +748,60 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_redeem_code_rejects_authenticated_manual_fill() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/referrals/redeem-code")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
Value::String("邀请码仅注册时填写".to_string())
);
}
#[test]
fn admin_invite_code_metadata_accepts_only_json_object() {
assert_eq!(
normalize_admin_invite_code_metadata(None).expect("empty metadata should default"),
"{}"
);
assert_eq!(
normalize_admin_invite_code_metadata(Some(serde_json::json!({
"channel": "spring",
"source": "banner"
})))
.expect("object metadata should serialize"),
r#"{"channel":"spring","source":"banner"}"#
);
let error = normalize_admin_invite_code_metadata(Some(serde_json::json!("spring")))
.expect_err("non-object metadata should reject");
assert_eq!(error.message(), "邀请码 metadata 必须是 JSON 对象");
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(

View File

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

View File

@@ -164,6 +164,8 @@ pub struct PhoneSendCodeResponse {
pub struct PhoneLoginRequest {
pub phone: String,
pub code: String,
#[serde(default)]
pub invite_code: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -171,6 +173,19 @@ pub struct PhoneLoginRequest {
pub struct PhoneLoginResponse {
pub token: String,
pub user: AuthUserPayload,
pub created: bool,
pub referral: Option<PhoneLoginReferralResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginReferralResponse {
pub ok: bool,
pub message: Option<String>,
pub invitee_reward_granted: bool,
pub inviter_reward_granted: bool,
pub invitee_balance_after: Option<u64>,
pub inviter_balance_after: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]

View File

@@ -296,6 +296,14 @@ pub struct AdminUpsertProfileRedeemCodeRequest {
pub allowed_public_user_codes: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileInviteCodeRequest {
pub invite_code: String,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDisableProfileRedeemCodeRequest {
@@ -317,6 +325,16 @@ pub struct ProfileRedeemCodeAdminResponse {
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInviteCodeAdminResponse {
pub user_id: String,
pub invite_code: String,
pub metadata: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
fn default_true() -> bool {
true
}

View File

@@ -121,7 +121,7 @@ use module_puzzle::{
};
use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
@@ -130,7 +130,8 @@ use module_runtime::{
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
build_runtime_browse_history_list_input, build_runtime_browse_history_record,
build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input,
build_runtime_profile_dashboard_record, build_runtime_profile_play_stats_get_input,
build_runtime_profile_dashboard_record, build_runtime_profile_invite_code_admin_upsert_input,
build_runtime_profile_invite_code_record, build_runtime_profile_play_stats_get_input,
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input,
build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input,

View File

@@ -203,6 +203,19 @@ impl From<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
}
}
impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
for RuntimeProfileInviteCodeAdminUpsertInput
{
fn from(input: module_runtime::RuntimeProfileInviteCodeAdminUpsertInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
invite_code: input.invite_code,
metadata_json: input.metadata_json,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
for RuntimeReferralInviteCenterGetInput
{
@@ -886,6 +899,26 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
))
}
pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result(
result: RuntimeProfileInviteCodeAdminProcedureResult,
) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 invite code 快照".to_string())
})?;
Ok(build_runtime_profile_invite_code_record(
map_runtime_profile_invite_code_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1784,6 +1817,18 @@ pub(crate) fn map_runtime_profile_redeem_code_snapshot(
}
}
pub(crate) fn map_runtime_profile_invite_code_snapshot(
snapshot: RuntimeProfileInviteCodeSnapshot,
) -> module_runtime::RuntimeProfileInviteCodeSnapshot {
module_runtime::RuntimeProfileInviteCodeSnapshot {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_played_world_snapshot(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
@@ -2427,6 +2472,13 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz
pub(crate) fn map_puzzle_runtime_level_snapshot(
snapshot: DomainPuzzleRuntimeLevelSnapshot,
) -> PuzzleRuntimeLevelRecord {
let started_at_ms = if snapshot.started_at_ms == 0 {
// 中文注释:旧 run_json 没有计时字段时只补一个可用开始时间,其余限时字段保持旧默认值。
current_unix_millis_for_legacy_puzzle_snapshot()
} else {
snapshot.started_at_ms
};
PuzzleRuntimeLevelRecord {
run_id: snapshot.run_id,
level_index: snapshot.level_index,
@@ -2438,7 +2490,7 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
cover_image_src: snapshot.cover_image_src,
board: map_puzzle_board_snapshot(snapshot.board),
status: snapshot.status.as_str().to_string(),
started_at_ms: snapshot.started_at_ms,
started_at_ms,
cleared_at_ms: snapshot.cleared_at_ms,
elapsed_ms: snapshot.elapsed_ms,
time_limit_ms: snapshot.time_limit_ms,
@@ -2456,6 +2508,13 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
}
}
fn current_unix_millis_for_legacy_puzzle_snapshot() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(1)
}
pub(crate) fn map_puzzle_leaderboard_entry(
snapshot: module_puzzle::PuzzleLeaderboardEntry,
) -> PuzzleLeaderboardEntryRecord {

View File

@@ -0,0 +1,59 @@
// 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_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult;
use super::runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminUpsertProfileInviteCodeArgs {
pub input: RuntimeProfileInviteCodeAdminUpsertInput,
}
impl __sdk::InModule for AdminUpsertProfileInviteCodeArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_upsert_profile_invite_code`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_upsert_profile_invite_code {
fn admin_upsert_profile_invite_code(&self, input: RuntimeProfileInviteCodeAdminUpsertInput) {
self.admin_upsert_profile_invite_code_then(input, |_, _| {});
}
fn admin_upsert_profile_invite_code_then(
&self,
input: RuntimeProfileInviteCodeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_upsert_profile_invite_code for super::RemoteProcedures {
fn admin_upsert_profile_invite_code_then(
&self,
input: RuntimeProfileInviteCodeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>(
"admin_upsert_profile_invite_code",
AdminUpsertProfileInviteCodeArgs { input },
__callback,
);
}
}

View File

@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub mod accept_quest_reducer;
pub mod acknowledge_quest_completion_reducer;
pub mod admin_disable_profile_redeem_code_procedure;
pub mod admin_upsert_profile_invite_code_procedure;
pub mod admin_upsert_profile_redeem_code_procedure;
pub mod advance_puzzle_next_level_procedure;
pub mod ai_result_reference_input_type;
@@ -405,6 +406,9 @@ pub mod runtime_platform_theme_type;
pub mod runtime_profile_dashboard_get_input_type;
pub mod runtime_profile_dashboard_procedure_result_type;
pub mod runtime_profile_dashboard_snapshot_type;
pub mod runtime_profile_invite_code_admin_procedure_result_type;
pub mod runtime_profile_invite_code_admin_upsert_input_type;
pub mod runtime_profile_invite_code_snapshot_type;
pub mod runtime_profile_membership_benefit_snapshot_type;
pub mod runtime_profile_membership_snapshot_type;
pub mod runtime_profile_membership_status_type;
@@ -506,6 +510,7 @@ pub mod user_browse_history_type;
pub use accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code;
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
pub use ai_result_reference_input_type::AiResultReferenceInput;
@@ -902,6 +907,9 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme;
pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput;
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult;
pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput;
pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot;
pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot;
pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot;
pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;

View File

@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub struct ProfileInviteCode {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
@@ -23,6 +24,7 @@ impl __sdk::InModule for ProfileInviteCode {
pub struct ProfileInviteCodeCols {
pub user_id: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub invite_code: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub metadata_json: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub created_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
}
@@ -33,6 +35,7 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode {
ProfileInviteCodeCols {
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
invite_code: __sdk::__query_builder::Col::new(table_name, "invite_code"),
metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}

View File

@@ -0,0 +1,19 @@
// 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_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileInviteCodeAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,18 @@
// 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 RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// 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 RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileInviteCodeSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -346,6 +346,35 @@ impl SpacetimeClient {
.await
}
pub async fn admin_upsert_profile_invite_code(
&self,
admin_user_id: String,
invite_code: String,
metadata_json: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id,
invite_code,
metadata_json,
updated_at_micros,
)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_upsert_profile_invite_code_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_invite_code_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_play_stats(
&self,
user_id: String,

View File

@@ -2856,7 +2856,9 @@ fn list_custom_world_profile_snapshots(
Ok(entries)
}
fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
fn build_custom_world_profile_list_snapshot(
row: &CustomWorldProfile,
) -> CustomWorldProfileSnapshot {
let mut snapshot = build_custom_world_profile_snapshot(row);
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
snapshot

View File

@@ -1094,6 +1094,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "profile_invite_code" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:邀请码 metadata 晚于邀请表加入,旧迁移包按空对象兼容。
object
.entry("metadata_json".to_string())
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
}
}
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。

View File

@@ -1261,8 +1261,9 @@ fn start_puzzle_run_tx(
}
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
let started_at_ms = micros_to_millis(input.started_at_micros);
let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
.map_err(|error| error.to_string())?;
let mut run =
module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
.map_err(|error| error.to_string())?;
let current_grid_size = run.current_grid_size;
let current_profile_id = entry_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
@@ -1502,13 +1503,11 @@ fn use_puzzle_runtime_prop_tx(
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = match input.prop_kind.as_str() {
"freezeTime" | "freeze_time" => {
module_puzzle::apply_puzzle_freeze_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?
}
"freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"hint" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
false,

View File

@@ -70,6 +70,7 @@ pub struct ProfileInviteCode {
pub(crate) user_id: String,
#[unique]
pub(crate) invite_code: String,
pub(crate) metadata_json: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
@@ -528,6 +529,25 @@ pub fn admin_disable_profile_redeem_code(
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_invite_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileInviteCodeAdminUpsertInput,
) -> RuntimeProfileInviteCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_upsert_profile_invite_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileInviteCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileInviteCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput,
@@ -1534,10 +1554,14 @@ fn redeem_profile_referral_invite_code_record(
),
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;
let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id);
let today_inviter_reward_count = if is_admin_invite_code {
0
} else {
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at)
};
let inviter_reward_granted = !is_admin_invite_code
&& today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT;
let inviter_balance_after = if inviter_reward_granted {
apply_profile_wallet_delta(
ctx,
@@ -1753,6 +1777,56 @@ fn admin_disable_profile_redeem_code_record(
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
}
fn admin_upsert_profile_invite_code_record(
ctx: &ReducerContext,
input: RuntimeProfileInviteCodeAdminUpsertInput,
) -> Result<RuntimeProfileInviteCodeSnapshot, String> {
let validated_input = build_runtime_profile_invite_code_admin_upsert_input(
input.admin_user_id,
input.invite_code,
input.metadata_json,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let user_id = build_admin_profile_invite_code_user_id(
&validated_input.admin_user_id,
&validated_input.invite_code,
);
if let Some(existing) = ctx
.db
.profile_invite_code()
.invite_code()
.find(&validated_input.invite_code)
{
if existing.user_id != user_id {
return Err("邀请码已被其他用户占用".to_string());
}
ctx.db
.profile_invite_code()
.user_id()
.delete(&existing.user_id);
let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id,
invite_code: validated_input.invite_code,
metadata_json: validated_input.metadata_json,
created_at: existing.created_at,
updated_at,
});
return Ok(build_profile_invite_code_snapshot_from_row(&inserted));
}
let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id,
invite_code: validated_input.invite_code,
metadata_json: validated_input.metadata_json,
created_at: updated_at,
updated_at,
});
Ok(build_profile_invite_code_snapshot_from_row(&inserted))
}
fn build_profile_referral_invite_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
@@ -1825,6 +1899,7 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id: user_id.to_string(),
invite_code,
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
created_at: ctx.timestamp,
updated_at: ctx.timestamp,
})
@@ -1856,6 +1931,14 @@ fn count_today_profile_referral_inviter_rewards(
.count() as u32
}
fn is_admin_profile_invite_code_user_id(user_id: &str) -> bool {
user_id.starts_with("admin:")
}
fn build_admin_profile_invite_code_user_id(admin_user_id: &str, invite_code: &str) -> String {
format!("admin:{}:{}", admin_user_id, invite_code)
}
fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 {
ctx.db
.profile_dashboard_state()
@@ -2206,6 +2289,18 @@ fn build_profile_redeem_code_snapshot_from_row(
}
}
fn build_profile_invite_code_snapshot_from_row(
row: &ProfileInviteCode,
) -> RuntimeProfileInviteCodeSnapshot {
RuntimeProfileInviteCodeSnapshot {
user_id: row.user_id.clone(),
invite_code: row.invite_code.clone(),
metadata_json: row.metadata_json.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_wallet_ledger_snapshot_from_row(
row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot {