From 42aab671ed076ccda7c69635354b511a0ace5f40 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 30 Apr 2026 20:49:38 +0800 Subject: [PATCH 1/6] Implement registration invite code flow and admin invite codes --- ...B_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md | 2 + ..._CODE_REGISTRATION_AND_ADMIN_2026-04-30.md | 55 +++++ docs/technical/README.md | 1 + packages/shared/src/contracts/auth.ts | 14 +- packages/shared/src/contracts/runtime.ts | 13 ++ server-rs/crates/api-server/src/admin.rs | 54 +++++ server-rs/crates/api-server/src/app.rs | 186 ++++++++++++++++- server-rs/crates/api-server/src/phone_auth.rs | 60 +++++- server-rs/crates/api-server/src/puzzle.rs | 18 +- .../crates/api-server/src/runtime_profile.rs | 189 +++++++++++++----- server-rs/crates/module-runtime/src/lib.rs | 131 ++++++++++++ server-rs/crates/shared-contracts/src/auth.rs | 15 ++ .../crates/shared-contracts/src/runtime.rs | 18 ++ server-rs/crates/spacetime-client/src/lib.rs | 5 +- .../crates/spacetime-client/src/mapper.rs | 61 +++++- ...in_upsert_profile_invite_code_procedure.rs | 59 ++++++ .../src/module_bindings/mod.rs | 8 + .../profile_invite_code_type.rs | 3 + ...invite_code_admin_procedure_result_type.rs | 19 ++ ...ile_invite_code_admin_upsert_input_type.rs | 18 ++ ...ntime_profile_invite_code_snapshot_type.rs | 19 ++ .../crates/spacetime-client/src/runtime.rs | 29 +++ server-rs/crates/spacetime-module/src/lib.rs | 4 +- .../crates/spacetime-module/src/migration.rs | 8 + .../crates/spacetime-module/src/puzzle.rs | 17 +- .../spacetime-module/src/runtime/profile.rs | 103 +++++++++- src/components/auth/AuthGate.test.tsx | 47 ++++- src/components/auth/AuthGate.tsx | 50 ++++- src/components/auth/LoginScreen.tsx | 101 +++++++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 87 +------- src/services/authService.test.ts | 9 +- src/services/authService.ts | 17 +- 32 files changed, 1241 insertions(+), 179 deletions(-) create mode 100644 docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs diff --git a/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md b/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md index 2b2fd408..8e96a1a2 100644 --- a/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md @@ -2,6 +2,8 @@ 更新时间:`2026-04-16` +> 2026-04-30 更新:用户侧邀请码填写入口已迁到注册环节,当前落地以 `docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md` 为准;“我的 Tab”不再保留填邀请码入口。 + ## 0. 目标 把“填邀请码”做成用户激活早期的一次性绑定动作,完成: diff --git a/docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md b/docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md new file mode 100644 index 00000000..cf9b3de4 --- /dev/null +++ b/docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md @@ -0,0 +1,55 @@ +# 注册环节邀请码与管理员邀请码方案 + +更新时间:`2026-04-30` + +## 背景 + +旧版“我的 Tab 填邀请码”设计把邀请码绑定放在登录后的个人面板中,容易让老账号重复发现入口,也不利于承接带邀请码的分享链接。本方案将邀请码填写收口到注册链路:未登录用户打开带 `inviteCode` 或 `invite_code` 查询参数的链接时,前端自动打开注册弹窗并预填邀请码。 + +## 落地边界 + +1. 注册入口复用当前手机号验证码登录自动建号能力,不新增独立注册系统。 +2. 已登录用户不自动弹注册弹窗;登录后的“我的 Tab”只保留“邀请好友”,不再提供“填邀请码”入口。 +3. 邀请码只在本次手机号验证码登录创建新账号时尝试绑定。老账号登录时即使请求体带邀请码,也不会绑定。 +4. 链接邀请码无效或不可用时不阻断注册,登录响应返回短错误提示,由前端展示;不写邀请关系、不发邀请奖励。 +5. 普通登录态下的 `/api/profile/referrals/redeem-code` 不再允许手动填码,统一返回“邀请码仅注册时填写”。 + +## 数据与接口 + +`profile_invite_code` 增加 `metadata_json` 字段,默认 `{}`,用于保存渠道、活动、批次等元数据。旧迁移导入数据缺失该字段时由 `migration.rs` 补 `{}`。 + +新增管理员接口: + +- `POST /admin/api/profile/invite-codes` + +请求: + +```json +{ + "inviteCode": "SPRING2026", + "metadata": { + "campaign": "spring" + } +} +``` + +管理员邀请码写入 SpacetimeDB 时使用虚拟主体: + +```text +admin:{管理员用户名}:{邀请码} +``` + +管理员码只做归因和被邀请人奖励,不给虚拟主体写邀请人钱包流水。 + +手机号登录响应新增: + +- `created`:本次登录是否创建新账号。 +- `referral`:注册邀请码绑定结果;仅当本次提交了邀请码时返回。 + +## 验收标准 + +1. 未登录用户访问 `/?inviteCode=ABC123` 自动打开注册弹窗并预填 `ABC123`。 +2. 有效邀请码注册成功后,被邀请人获得陶泥币奖励,邀请关系落库。 +3. 无效邀请码注册成功但不绑定,并返回短提示。 +4. 管理员可添加邀请码并写入 metadata,重复提交同管理员同码更新 metadata。 +5. 管理员邀请码被使用时不产生 `admin:*` 虚拟主体的钱包流水。 diff --git a/docs/technical/README.md b/docs/technical/README.md index ff7e53d8..a67cfc31 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 +- [PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md](./PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md):冻结邀请码从“我的 Tab 填写”迁到注册环节的前后端边界、`profile_invite_code.metadata_json` 表结构扩展、管理员邀请码虚拟主体和奖励规则。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 - [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。 diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 11f518c1..ea4c274a 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -65,7 +65,7 @@ export type AuthPasswordResetResponse = { export type AuthPhoneSendCodeRequest = { phone: string; - scene?: 'login' | 'bind_phone' | 'change_phone'; + scene?: 'login' | 'bind_phone' | 'change_phone' | 'reset_password'; captchaChallengeId?: string; captchaAnswer?: string; }; @@ -80,11 +80,23 @@ export type AuthPhoneSendCodeResponse = { export type AuthPhoneLoginRequest = { phone: string; code: string; + inviteCode?: string; }; export type AuthPhoneLoginResponse = { token: string; user: AuthUser; + created: boolean; + referral: AuthPhoneLoginReferral | null; +}; + +export type AuthPhoneLoginReferral = { + ok: boolean; + message: string | null; + inviteeRewardGranted: boolean; + inviterRewardGranted: boolean; + inviteeBalanceAfter: number | null; + inviterBalanceAfter: number | null; }; export type AuthMeResponse = { diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index cc18ec9c..830a1f41 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -176,6 +176,19 @@ export type RedeemProfileRewardCodeResponse = { ledgerEntry: ProfileWalletLedgerEntry; }; +export type AdminUpsertProfileInviteCodeRequest = { + inviteCode: string; + metadata?: Record | null; +}; + +export type ProfileInviteCodeAdminResponse = { + userId: string; + inviteCode: string; + metadata: Record; + createdAt: string; + updatedAt: string; +}; + export type ProfilePlayedWorkSummary = { worldKey: string; ownerUserId: string | null; diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 327687e0..0f32d8b1 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -876,6 +876,28 @@ static ADMIN_CONSOLE_HTML: &str = r#" +
+
+

邀请码管理

+

创建或更新管理员邀请码。

+
+
+
+ + +
+ +
+
+ +
+
+
+

API 调试

@@ -950,6 +972,8 @@ static ADMIN_CONSOLE_HTML: &str = r#" 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#" `; } + function renderInviteCodeResult(result) { + inviteCodeResultEl.style.display = 'grid'; + inviteCodeResultEl.innerHTML = ` +
User ID:${result.userId || '-'}
+
邀请码:${result.inviteCode || '-'}
+
更新时间:${result.updatedAt || '-'}
+
Metadata
${JSON.stringify(result.metadata || {}, null, 2)}
+ `; + } + async function loadMe() { const token = getToken(); if (!token) { @@ -1107,6 +1141,26 @@ static ADMIN_CONSOLE_HTML: &str = r#" } }); + 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); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index db4e12e3..bd67250c 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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] diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 39cb8f2b..524d50e8 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -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, +) -> Option { + 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 { match raw_scene.unwrap_or("login").trim() { "login" => Ok(PhoneAuthScene::Login), diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 3a4e3d20..3474b9d7 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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; diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c9cc5c7c..648539c5 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -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, + State(_state): State, Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, + Extension(_authenticated): Extension, + Json(_payload): Json, ) -> Result, 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, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, 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, Extension(request_context): Extension, @@ -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) -> Result { + 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 { 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 ProfileInviteCodeAdminResponse { + let metadata = + serde_json::from_str::(&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( diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index aaa30767..a029f313 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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, } +#[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, + pub error_message: Option, +} + #[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 { + 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 { @@ -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 { } } +pub fn normalize_invite_code_metadata_json( + value: String, +) -> Result { + 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::(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 { 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 { diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 07ec16d0..14da7fe5 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -164,6 +164,8 @@ pub struct PhoneSendCodeResponse { pub struct PhoneLoginRequest { pub phone: String, pub code: String, + #[serde(default)] + pub invite_code: Option, } #[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, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PhoneLoginReferralResponse { + pub ok: bool, + pub message: Option, + pub invitee_reward_granted: bool, + pub inviter_reward_granted: bool, + pub invitee_balance_after: Option, + pub inviter_balance_after: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 28f8510a..f5f343ab 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -296,6 +296,14 @@ pub struct AdminUpsertProfileRedeemCodeRequest { pub allowed_public_user_codes: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileInviteCodeRequest { + pub invite_code: String, + #[serde(default)] + pub metadata: Option, +} + #[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 } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index b7e6458a..5d4f75b7 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c535ffbc..29a6faaf 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -203,6 +203,19 @@ impl From } } +impl From + 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 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 { + 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 { @@ -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 { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs new file mode 100644 index 00000000..3601be97 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>( + "admin_upsert_profile_invite_code", + AdminUpsertProfileInviteCodeArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 3be6524b..21fbad25 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs index e556d2f7..3ded53b9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs @@ -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, pub invite_code: __sdk::__query_builder::Col, + pub metadata_json: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -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"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs new file mode 100644 index 00000000..70f54300 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs new file mode 100644 index 00000000..77daf412 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs new file mode 100644 index 00000000..36ea09ee --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index f95407cf..49f79325 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -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 { + 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, diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index c7003157..43805c3f 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -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 diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 2cd8dea2..47dc35b1 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -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 兼容。 diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index bd44dfe5..38be4b6c 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -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( - ¤t_run, - micros_to_millis(input.used_at_micros), - ) - .map_err(|error| error.to_string())? - } + "freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at( + ¤t_run, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, "hint" => module_puzzle::set_puzzle_run_paused_at( ¤t_run, false, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 272e0b1b..9d5d2db4 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -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 { + 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 { diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 52484249..c3feca1a 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -88,13 +88,19 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); + window.history.replaceState(null, '', '/'); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], }); - authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); + authMocks.loginWithPhoneCode.mockResolvedValue({ + token: 'jwt-phone', + user: mockUser, + created: false, + referral: null, + }); authMocks.authEntry.mockResolvedValue(mockUser); authMocks.changePassword.mockResolvedValue(mockUser); authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); @@ -287,6 +293,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( '13800000000', '123456', + undefined, ); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); expect(onAuthenticated).toHaveBeenCalledTimes(1); @@ -295,6 +302,44 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); +test('auth gate opens register tab and preloads invite code from url', async () => { + const user = userEvent.setup(); + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone'], + }); + + render( + +
公开内容
+
, + ); + + const dialog = await screen.findByRole('dialog', { name: '账号入口' }); + await waitFor(() => { + expect( + within(dialog) + .getByRole('tab', { name: '注册' }) + .getAttribute('aria-selected'), + ).toBe('true'); + }); + expect( + (within(dialog).getByLabelText('邀请码') as HTMLInputElement).value, + ).toBe('SPRING2026'); + + await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); + await user.type(within(dialog).getByLabelText('验证码'), '123456'); + await user.click(within(dialog).getByRole('button', { name: '注册' })); + + await waitFor(() => { + expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( + '13800000000', + '123456', + 'SPRING2026', + ); + }); +}); + test('auth state refresh keeps mounted platform content and local tab state', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index ed6e2b26..1a60c3d2 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -59,6 +59,14 @@ type AuthStatus = const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password']; +function readInviteCodeFromLocation(): string { + const params = new URLSearchParams(window.location.search || ''); + return (params.get('inviteCode') || params.get('invite_code') || '') + .trim() + .replace(/[^0-9a-z]/gi, '') + .toUpperCase(); +} + function normalizeAvailableLoginMethods( methods: AuthLoginMethod[] | null | undefined, ): AuthLoginMethod[] { @@ -83,6 +91,10 @@ export function AuthGate({ children }: AuthGateProps) { const [bindingPhone, setBindingPhone] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); + const [loginInitialMode, setLoginInitialMode] = useState< + 'login' | 'register' + >('login'); + const [pendingInviteCode, setPendingInviteCode] = useState(''); const [showSettingsModal, setShowSettingsModal] = useState(false); const [settingsEntryMode, setSettingsEntryMode] = useState< 'settings' | 'account' @@ -102,6 +114,7 @@ export function AuthGate({ children }: AuthGateProps) { const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = useState(null); const pendingProtectedActionRef = useRef<(() => void) | null>(null); + const autoOpenedInviteCodeRef = useRef(null); const hasRenderedPlatformContentRef = useRef(false); const canKeepPlatformContentMounted = hasRenderedPlatformContentRef.current && @@ -169,6 +182,8 @@ export function AuthGate({ children }: AuthGateProps) { const closeLoginModal = useCallback(() => { pendingProtectedActionRef.current = null; setShowLoginModal(false); + setLoginInitialMode('login'); + setPendingInviteCode(''); setLoginCaptchaChallenge(null); setError(''); }, []); @@ -187,6 +202,8 @@ export function AuthGate({ children }: AuthGateProps) { } pendingProtectedActionRef.current = postLoginAction ?? null; + setLoginInitialMode('login'); + setPendingInviteCode(''); setShowLoginModal(true); }, [readyUser], @@ -224,6 +241,24 @@ export function AuthGate({ children }: AuthGateProps) { openLoginModal(); }, [openLoginModal, readyUser]); + useEffect(() => { + if (status !== 'unauthenticated' || readyUser || showLoginModal) { + return; + } + const inviteCode = readInviteCodeFromLocation(); + if (!inviteCode) { + return; + } + if (autoOpenedInviteCodeRef.current === inviteCode) { + return; + } + autoOpenedInviteCodeRef.current = inviteCode; + pendingProtectedActionRef.current = null; + setPendingInviteCode(inviteCode); + setLoginInitialMode('register'); + setShowLoginModal(true); + }, [readyUser, showLoginModal, status]); + useEffect(() => { let isActive = true; @@ -703,6 +738,8 @@ export function AuthGate({ children }: AuthGateProps) { wechatLoading={wechatLoading} error={error} captchaChallenge={loginCaptchaChallenge} + initialMode={loginInitialMode} + initialInviteCode={pendingInviteCode} onClose={closeLoginModal} onSendCode={async (phone, scene, captcha) => { setSendingCode(true); @@ -727,14 +764,21 @@ export function AuthGate({ children }: AuthGateProps) { setSendingCode(false); } }} - onPhoneSubmit={async (phone, code) => { + onPhoneSubmit={async (phone, code, inviteCode) => { setLoggingIn(true); setError(''); try { - const nextUser = await loginWithPhoneCode(phone, code); + const response = await loginWithPhoneCode( + phone, + code, + inviteCode, + ); setStoredLastLoginPhone(phone); setLoginCaptchaChallenge(null); - activateReadyUser(nextUser); + if (response.referral && !response.referral.ok) { + setError(response.referral.message || '邀请码未绑定'); + } + activateReadyUser(response.user); } catch (loginError) { setError( loginError instanceof Error diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 3c7577b8..1629bdcc 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService'; import { CaptchaChallengeField } from './CaptchaChallengeField'; type SmsScene = 'login' | 'reset_password'; -type LoginTab = 'phone' | 'password'; +type LoginTab = 'phone' | 'password' | 'register'; type LoginScreenProps = { isOpen: boolean; @@ -21,6 +21,8 @@ type LoginScreenProps = { wechatLoading: boolean; error: string; captchaChallenge: AuthCaptchaChallenge | null; + initialMode?: 'login' | 'register'; + initialInviteCode?: string; onClose: () => void; onSendCode: ( phone: string, @@ -33,7 +35,11 @@ type LoginScreenProps = { cooldownSeconds: number; expiresInSeconds: number; }>; - onPhoneSubmit: (phone: string, code: string) => Promise; + onPhoneSubmit: ( + phone: string, + code: string, + inviteCode?: string, + ) => Promise; onPasswordSubmit: (phone: string, password: string) => Promise; onResetPassword: ( phone: string, @@ -52,6 +58,8 @@ export function LoginScreen({ wechatLoading, error, captchaChallenge, + initialMode = 'login', + initialInviteCode = '', onClose, onSendCode, onPhoneSubmit, @@ -66,6 +74,7 @@ export function LoginScreen({ const [resetPhone, setResetPhone] = useState(''); const [resetCode, setResetCode] = useState(''); const [resetPasswordValue, setResetPasswordValue] = useState(''); + const [inviteCode, setInviteCode] = useState(initialInviteCode); const [captchaAnswer, setCaptchaAnswer] = useState(''); const [cooldownSeconds, setCooldownSeconds] = useState(0); const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0); @@ -88,16 +97,23 @@ export function LoginScreen({ setResetPhone(''); setResetCode(''); setResetPasswordValue(''); + setInviteCode(initialInviteCode); setCaptchaAnswer(''); setCooldownSeconds(0); setResetCooldownSeconds(0); setHint(''); - setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password'); - }, [isOpen, phoneLoginEnabled]); + setActiveLoginTab( + initialMode === 'register' && phoneLoginEnabled + ? 'register' + : phoneLoginEnabled + ? 'phone' + : 'password', + ); + }, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]); useEffect(() => { if ( - activeLoginTab === 'phone' && + (activeLoginTab === 'phone' || activeLoginTab === 'register') && !phoneLoginEnabled && passwordLoginEnabled ) { @@ -196,9 +212,11 @@ export function LoginScreen({ /> ) : (
- {phoneLoginEnabled && passwordLoginEnabled ? ( + {phoneLoginEnabled ? (
@@ -208,11 +226,19 @@ export function LoginScreen({ > 短信登录 + {passwordLoginEnabled ? ( + setActiveLoginTab('password')} + > + 密码登录 + + ) : null} setActiveLoginTab('password')} + active={activeLoginTab === 'register'} + onClick={() => setActiveLoginTab('register')} > - 密码登录 + 注册
) : null} @@ -312,6 +338,42 @@ export function LoginScreen({ /> ) : null} + {phoneLoginEnabled && activeLoginTab === 'register' ? ( + { + setHint(''); + const result = await onSendCode(phone, 'login', { + challengeId: captchaChallenge?.challengeId, + answer: captchaAnswer, + }); + setCooldownSeconds(result.cooldownSeconds); + setHint( + `短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`, + ); + setCaptchaAnswer(''); + }} + onSubmit={() => onPhoneSubmit(phone, code, inviteCode)} + /> + ) : null} + {!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? ( @@ -358,6 +420,7 @@ function LoginTabButton({ function PhoneCodeForm({ phone, code, + inviteCode = '', captchaAnswer, captchaChallenge, cooldownSeconds, @@ -368,14 +431,17 @@ function PhoneCodeForm({ submitLabel, enabled, showPhoneField, + showInviteCodeField = false, onPhoneChange, onCodeChange, + onInviteCodeChange, onCaptchaAnswerChange, onSendCode, onSubmit, }: { phone: string; code: string; + inviteCode?: string; captchaAnswer: string; captchaChallenge: AuthCaptchaChallenge | null; cooldownSeconds: number; @@ -386,8 +452,10 @@ function PhoneCodeForm({ submitLabel: string; enabled: boolean; showPhoneField: boolean; + showInviteCodeField?: boolean; onPhoneChange: (value: string) => void; onCodeChange: (value: string) => void; + onInviteCodeChange?: (value: string) => void; onCaptchaAnswerChange: (value: string) => void; onSendCode: () => Promise; onSubmit: () => Promise; @@ -418,6 +486,19 @@ function PhoneCodeForm({ ) : null} + {showInviteCodeField ? ( + + ) : null} +