Implement registration invite code flow and admin invite codes
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
更新时间:`2026-04-16`
|
更新时间:`2026-04-16`
|
||||||
|
|
||||||
|
> 2026-04-30 更新:用户侧邀请码填写入口已迁到注册环节,当前落地以 `docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md` 为准;“我的 Tab”不再保留填邀请码入口。
|
||||||
|
|
||||||
## 0. 目标
|
## 0. 目标
|
||||||
|
|
||||||
把“填邀请码”做成用户激活早期的一次性绑定动作,完成:
|
把“填邀请码”做成用户激活早期的一次性绑定动作,完成:
|
||||||
|
|||||||
@@ -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:*` 虚拟主体的钱包流水。
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
## 文档列表
|
## 文档列表
|
||||||
|
|
||||||
- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。
|
- [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`、固定底部锚点和安全区占位的修复口径。
|
- [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 自动迁移回灌和导入脚本参数。
|
- [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 规避参数。
|
- [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 规避参数。
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export type AuthPasswordResetResponse = {
|
|||||||
|
|
||||||
export type AuthPhoneSendCodeRequest = {
|
export type AuthPhoneSendCodeRequest = {
|
||||||
phone: string;
|
phone: string;
|
||||||
scene?: 'login' | 'bind_phone' | 'change_phone';
|
scene?: 'login' | 'bind_phone' | 'change_phone' | 'reset_password';
|
||||||
captchaChallengeId?: string;
|
captchaChallengeId?: string;
|
||||||
captchaAnswer?: string;
|
captchaAnswer?: string;
|
||||||
};
|
};
|
||||||
@@ -80,11 +80,23 @@ export type AuthPhoneSendCodeResponse = {
|
|||||||
export type AuthPhoneLoginRequest = {
|
export type AuthPhoneLoginRequest = {
|
||||||
phone: string;
|
phone: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
inviteCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthPhoneLoginResponse = {
|
export type AuthPhoneLoginResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
user: AuthUser;
|
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 = {
|
export type AuthMeResponse = {
|
||||||
|
|||||||
@@ -176,6 +176,19 @@ export type RedeemProfileRewardCodeResponse = {
|
|||||||
ledgerEntry: ProfileWalletLedgerEntry;
|
ledgerEntry: ProfileWalletLedgerEntry;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminUpsertProfileInviteCodeRequest = {
|
||||||
|
inviteCode: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileInviteCodeAdminResponse = {
|
||||||
|
userId: string;
|
||||||
|
inviteCode: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProfilePlayedWorkSummary = {
|
export type ProfilePlayedWorkSummary = {
|
||||||
worldKey: string;
|
worldKey: string;
|
||||||
ownerUserId: string | null;
|
ownerUserId: string | null;
|
||||||
|
|||||||
@@ -876,6 +876,28 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>API 调试</h2>
|
<h2>API 调试</h2>
|
||||||
@@ -950,6 +972,8 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
|||||||
const overviewTablesEl = document.getElementById('overview-tables');
|
const overviewTablesEl = document.getElementById('overview-tables');
|
||||||
const overviewErrorsEl = document.getElementById('overview-errors');
|
const overviewErrorsEl = document.getElementById('overview-errors');
|
||||||
const debugResultEl = document.getElementById('debug-result');
|
const debugResultEl = document.getElementById('debug-result');
|
||||||
|
const inviteCodeMessageEl = document.getElementById('invite-code-message');
|
||||||
|
const inviteCodeResultEl = document.getElementById('invite-code-result');
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return window.localStorage.getItem(TOKEN_KEY) || '';
|
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() {
|
async function loadMe() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
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);
|
loadMe().then(loadOverview);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -103,10 +103,10 @@ use crate::{
|
|||||||
},
|
},
|
||||||
runtime_inventory::get_runtime_inventory_state,
|
runtime_inventory::get_runtime_inventory_state,
|
||||||
runtime_profile::{
|
runtime_profile::{
|
||||||
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
|
admin_disable_profile_redeem_code, admin_upsert_profile_invite_code,
|
||||||
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
|
admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard,
|
||||||
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
|
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
|
||||||
redeem_profile_referral_invite_code, redeem_profile_reward_code,
|
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
|
||||||
},
|
},
|
||||||
runtime_save::{
|
runtime_save::{
|
||||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||||
@@ -168,6 +168,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_admin_auth,
|
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(
|
.route(
|
||||||
"/healthz",
|
"/healthz",
|
||||||
get(|Extension(request_context): Extension<_>| async move {
|
get(|Extension(request_context): Extension<_>| async move {
|
||||||
@@ -1845,6 +1852,8 @@ mod tests {
|
|||||||
payload["user"]["phoneNumberMasked"],
|
payload["user"]["phoneNumberMasked"],
|
||||||
Value::String("138****8000".to_string())
|
Value::String("138****8000".to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(payload["created"], Value::Bool(true));
|
||||||
|
assert!(payload["referral"].is_null());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1951,6 +1960,175 @@ mod tests {
|
|||||||
serde_json::from_slice(&second_body).expect("second login payload should be json");
|
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["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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use module_auth::{
|
|||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use shared_contracts::auth::{
|
use shared_contracts::auth::{
|
||||||
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse,
|
PhoneLoginReferralResponse, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
|
||||||
|
PhoneSendCodeResponse,
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -110,6 +111,7 @@ pub async fn phone_login(
|
|||||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let invite_code = payload.invite_code.clone();
|
||||||
let result = match state
|
let result = match state
|
||||||
.phone_auth_service()
|
.phone_auth_service()
|
||||||
.login(
|
.login(
|
||||||
@@ -146,6 +148,18 @@ pub async fn phone_login(
|
|||||||
return Err(map_phone_auth_error(error));
|
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 session_client = resolve_session_client_context(&headers);
|
||||||
let signed_session = create_auth_session(
|
let signed_session = create_auth_session(
|
||||||
&state,
|
&state,
|
||||||
@@ -174,11 +188,55 @@ pub async fn phone_login(
|
|||||||
PhoneLoginResponse {
|
PhoneLoginResponse {
|
||||||
token: signed_session.access_token,
|
token: signed_session.access_token,
|
||||||
user: map_auth_user_payload(result.user),
|
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> {
|
fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> {
|
||||||
match raw_scene.unwrap_or("login").trim() {
|
match raw_scene.unwrap_or("login").trim() {
|
||||||
"login" => Ok(PhoneAuthScene::Login),
|
"login" => Ok(PhoneAuthScene::Login),
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ use shared_contracts::{
|
|||||||
},
|
},
|
||||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||||
puzzle_runtime::{
|
puzzle_runtime::{
|
||||||
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
|
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse,
|
||||||
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||||
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||||
StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
|
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||||
UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||||
},
|
},
|
||||||
puzzle_works::{
|
puzzle_works::{
|
||||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||||
@@ -57,10 +57,10 @@ use spacetime_client::{
|
|||||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
|
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||||
PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput,
|
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||||
SpacetimeClientError,
|
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||||
};
|
};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|||||||
@@ -5,31 +5,30 @@ use axum::{
|
|||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use module_runtime::{
|
use module_runtime::{
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
|
||||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
|
||||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
|
||||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
|
||||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType,
|
||||||
RuntimeReferralRedeemRecord,
|
RuntimeReferralInviteCenterRecord,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use shared_contracts::runtime::{
|
use shared_contracts::runtime::{
|
||||||
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
|
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest,
|
||||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||||
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
|
||||||
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
|
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
|
||||||
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
||||||
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
||||||
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
||||||
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
|
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
|
||||||
RedeemProfileRewardCodeResponse,
|
|
||||||
};
|
};
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -213,27 +212,14 @@ pub async fn get_profile_referral_invite_center(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn redeem_profile_referral_invite_code(
|
pub async fn redeem_profile_referral_invite_code(
|
||||||
State(state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
|
Json(_payload): Json<RedeemProfileReferralInviteCodeRequest>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let user_id = authenticated.claims().user_id().to_string();
|
Err(runtime_profile_error_response(
|
||||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
&request_context,
|
||||||
let record = state
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"),
|
||||||
.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),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(
|
pub async fn get_profile_play_stats(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
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(
|
fn build_redeem_profile_reward_code_response(
|
||||||
record: RuntimeProfileRewardCodeRedeemRecord,
|
record: RuntimeProfileRewardCodeRedeemRecord,
|
||||||
) -> RedeemProfileRewardCodeResponse {
|
) -> 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> {
|
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
|
||||||
match raw.trim().to_ascii_lowercase().as_str() {
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
|
"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(
|
fn build_profile_redeem_code_admin_response(
|
||||||
record: RuntimeProfileRedeemCodeRecord,
|
record: RuntimeProfileRedeemCodeRecord,
|
||||||
) -> ProfileRedeemCodeAdminResponse {
|
) -> ProfileRedeemCodeAdminResponse {
|
||||||
@@ -545,7 +588,7 @@ fn build_profile_redeem_code_admin_response(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
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::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -705,6 +748,60 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
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]
|
#[tokio::test]
|
||||||
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
|
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
|
||||||
assert_compat_route_matches_main_route_error_shape(
|
assert_compat_route_matches_main_route_error_shape(
|
||||||
|
|||||||
@@ -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_WALLET_LEDGER_LIST_LIMIT: usize = 50;
|
||||||
pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30;
|
pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30;
|
||||||
pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10;
|
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 SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||||
@@ -502,6 +504,33 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
|
|||||||
pub error_message: Option<String>,
|
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))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeReferralInviteCenterSnapshot {
|
pub struct RuntimeReferralInviteCenterSnapshot {
|
||||||
@@ -615,6 +644,7 @@ pub enum RuntimeProfileFieldError {
|
|||||||
MissingLedgerId,
|
MissingLedgerId,
|
||||||
InvalidWalletAmount,
|
InvalidWalletAmount,
|
||||||
MissingInviteCode,
|
MissingInviteCode,
|
||||||
|
InvalidInviteCodeMetadata,
|
||||||
MissingRedeemCode,
|
MissingRedeemCode,
|
||||||
InvalidRedeemCodeReward,
|
InvalidRedeemCodeReward,
|
||||||
InvalidRedeemCodeMaxUses,
|
InvalidRedeemCodeMaxUses,
|
||||||
@@ -916,6 +946,17 @@ pub struct RuntimeProfileRedeemCodeRecord {
|
|||||||
pub updated_at_micros: i64,
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct RuntimeReferralInviteCenterRecord {
|
pub struct RuntimeReferralInviteCenterRecord {
|
||||||
pub user_id: String,
|
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(
|
pub fn build_runtime_profile_play_stats_get_input(
|
||||||
user_id: String,
|
user_id: String,
|
||||||
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
) -> 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(
|
pub fn build_runtime_profile_played_world_record(
|
||||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||||
) -> RuntimeProfilePlayedWorldRecord {
|
) -> 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> {
|
pub fn normalize_redeem_code(value: String) -> Option<String> {
|
||||||
normalize_invite_code(value)
|
normalize_invite_code(value)
|
||||||
}
|
}
|
||||||
@@ -1958,6 +2051,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
|||||||
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
||||||
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
||||||
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
||||||
|
Self::InvalidInviteCodeMetadata => {
|
||||||
|
f.write_str("邀请码 metadata 必须是 JSON 对象且不超过 4096 bytes")
|
||||||
|
}
|
||||||
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
||||||
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
|
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
|
||||||
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
|
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]
|
#[test]
|
||||||
fn profile_dashboard_record_formats_optional_timestamp() {
|
fn profile_dashboard_record_formats_optional_timestamp() {
|
||||||
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {
|
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ pub struct PhoneSendCodeResponse {
|
|||||||
pub struct PhoneLoginRequest {
|
pub struct PhoneLoginRequest {
|
||||||
pub phone: String,
|
pub phone: String,
|
||||||
pub code: String,
|
pub code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub invite_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -171,6 +173,19 @@ pub struct PhoneLoginRequest {
|
|||||||
pub struct PhoneLoginResponse {
|
pub struct PhoneLoginResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub user: AuthUserPayload,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -296,6 +296,14 @@ pub struct AdminUpsertProfileRedeemCodeRequest {
|
|||||||
pub allowed_public_user_codes: Vec<String>,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AdminDisableProfileRedeemCodeRequest {
|
pub struct AdminDisableProfileRedeemCodeRequest {
|
||||||
@@ -317,6 +325,16 @@ pub struct ProfileRedeemCodeAdminResponse {
|
|||||||
pub updated_at: String,
|
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 {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ use module_puzzle::{
|
|||||||
};
|
};
|
||||||
use module_runtime::{
|
use module_runtime::{
|
||||||
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
|
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
|
||||||
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
|
RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
|
||||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||||
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
|
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
|
||||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||||
@@ -130,7 +130,8 @@ use module_runtime::{
|
|||||||
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
|
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
|
||||||
build_runtime_browse_history_list_input, build_runtime_browse_history_record,
|
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_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_play_stats_record, build_runtime_profile_recharge_center_get_input,
|
||||||
build_runtime_profile_recharge_center_record,
|
build_runtime_profile_recharge_center_record,
|
||||||
build_runtime_profile_recharge_order_create_input,
|
build_runtime_profile_recharge_order_create_input,
|
||||||
|
|||||||
@@ -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>
|
impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
|
||||||
for 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(
|
pub(crate) fn map_runtime_profile_play_stats_procedure_result(
|
||||||
result: RuntimeProfilePlayStatsProcedureResult,
|
result: RuntimeProfilePlayStatsProcedureResult,
|
||||||
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
|
) -> 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(
|
pub(crate) fn map_runtime_profile_played_world_snapshot(
|
||||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||||
) -> module_runtime::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(
|
pub(crate) fn map_puzzle_runtime_level_snapshot(
|
||||||
snapshot: DomainPuzzleRuntimeLevelSnapshot,
|
snapshot: DomainPuzzleRuntimeLevelSnapshot,
|
||||||
) -> PuzzleRuntimeLevelRecord {
|
) -> 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 {
|
PuzzleRuntimeLevelRecord {
|
||||||
run_id: snapshot.run_id,
|
run_id: snapshot.run_id,
|
||||||
level_index: snapshot.level_index,
|
level_index: snapshot.level_index,
|
||||||
@@ -2438,7 +2490,7 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
|
|||||||
cover_image_src: snapshot.cover_image_src,
|
cover_image_src: snapshot.cover_image_src,
|
||||||
board: map_puzzle_board_snapshot(snapshot.board),
|
board: map_puzzle_board_snapshot(snapshot.board),
|
||||||
status: snapshot.status.as_str().to_string(),
|
status: snapshot.status.as_str().to_string(),
|
||||||
started_at_ms: snapshot.started_at_ms,
|
started_at_ms,
|
||||||
cleared_at_ms: snapshot.cleared_at_ms,
|
cleared_at_ms: snapshot.cleared_at_ms,
|
||||||
elapsed_ms: snapshot.elapsed_ms,
|
elapsed_ms: snapshot.elapsed_ms,
|
||||||
time_limit_ms: snapshot.time_limit_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(
|
pub(crate) fn map_puzzle_leaderboard_entry(
|
||||||
snapshot: module_puzzle::PuzzleLeaderboardEntry,
|
snapshot: module_puzzle::PuzzleLeaderboardEntry,
|
||||||
) -> PuzzleLeaderboardEntryRecord {
|
) -> PuzzleLeaderboardEntryRecord {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
|||||||
pub mod accept_quest_reducer;
|
pub mod accept_quest_reducer;
|
||||||
pub mod acknowledge_quest_completion_reducer;
|
pub mod acknowledge_quest_completion_reducer;
|
||||||
pub mod admin_disable_profile_redeem_code_procedure;
|
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 admin_upsert_profile_redeem_code_procedure;
|
||||||
pub mod advance_puzzle_next_level_procedure;
|
pub mod advance_puzzle_next_level_procedure;
|
||||||
pub mod ai_result_reference_input_type;
|
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_get_input_type;
|
||||||
pub mod runtime_profile_dashboard_procedure_result_type;
|
pub mod runtime_profile_dashboard_procedure_result_type;
|
||||||
pub mod runtime_profile_dashboard_snapshot_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_benefit_snapshot_type;
|
||||||
pub mod runtime_profile_membership_snapshot_type;
|
pub mod runtime_profile_membership_snapshot_type;
|
||||||
pub mod runtime_profile_membership_status_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 accept_quest_reducer::accept_quest;
|
||||||
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
|
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_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 admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
|
||||||
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
|
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
|
||||||
pub use ai_result_reference_input_type::AiResultReferenceInput;
|
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_get_input_type::RuntimeProfileDashboardGetInput;
|
||||||
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
|
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
|
||||||
pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
|
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_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot;
|
||||||
pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot;
|
pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot;
|
||||||
pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;
|
pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
|||||||
pub struct ProfileInviteCode {
|
pub struct ProfileInviteCode {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub invite_code: String,
|
pub invite_code: String,
|
||||||
|
pub metadata_json: String,
|
||||||
pub created_at: __sdk::Timestamp,
|
pub created_at: __sdk::Timestamp,
|
||||||
pub updated_at: __sdk::Timestamp,
|
pub updated_at: __sdk::Timestamp,
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ impl __sdk::InModule for ProfileInviteCode {
|
|||||||
pub struct ProfileInviteCodeCols {
|
pub struct ProfileInviteCodeCols {
|
||||||
pub user_id: __sdk::__query_builder::Col<ProfileInviteCode, String>,
|
pub user_id: __sdk::__query_builder::Col<ProfileInviteCode, String>,
|
||||||
pub invite_code: __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 created_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
|
||||||
pub updated_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 {
|
ProfileInviteCodeCols {
|
||||||
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
|
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
|
||||||
invite_code: __sdk::__query_builder::Col::new(table_name, "invite_code"),
|
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"),
|
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -346,6 +346,35 @@ impl SpacetimeClient {
|
|||||||
.await
|
.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(
|
pub async fn get_profile_play_stats(
|
||||||
&self,
|
&self,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -2856,7 +2856,9 @@ fn list_custom_world_profile_snapshots(
|
|||||||
Ok(entries)
|
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);
|
let mut snapshot = build_custom_world_profile_snapshot(row);
|
||||||
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
|
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
|
||||||
snapshot
|
snapshot
|
||||||
|
|||||||
@@ -1094,6 +1094,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
|||||||
.or_insert(serde_json::Value::Null);
|
.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 table_name == "big_fish_creation_session" {
|
||||||
if let Some(object) = next_value.as_object_mut() {
|
if let Some(object) = next_value.as_object_mut() {
|
||||||
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
||||||
|
|||||||
@@ -1261,8 +1261,9 @@ fn start_puzzle_run_tx(
|
|||||||
}
|
}
|
||||||
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
||||||
let started_at_ms = micros_to_millis(input.started_at_micros);
|
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)
|
let mut run =
|
||||||
.map_err(|error| error.to_string())?;
|
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_grid_size = run.current_grid_size;
|
||||||
let current_profile_id = entry_profile.profile_id.clone();
|
let current_profile_id = entry_profile.profile_id.clone();
|
||||||
hydrate_puzzle_leaderboard_entries(
|
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 row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
||||||
let current_run = deserialize_run(&row.snapshot_json)?;
|
let current_run = deserialize_run(&row.snapshot_json)?;
|
||||||
let next_run = match input.prop_kind.as_str() {
|
let next_run = match input.prop_kind.as_str() {
|
||||||
"freezeTime" | "freeze_time" => {
|
"freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at(
|
||||||
module_puzzle::apply_puzzle_freeze_time_at(
|
¤t_run,
|
||||||
¤t_run,
|
micros_to_millis(input.used_at_micros),
|
||||||
micros_to_millis(input.used_at_micros),
|
)
|
||||||
)
|
.map_err(|error| error.to_string())?,
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
}
|
|
||||||
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
||||||
¤t_run,
|
¤t_run,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ pub struct ProfileInviteCode {
|
|||||||
pub(crate) user_id: String,
|
pub(crate) user_id: String,
|
||||||
#[unique]
|
#[unique]
|
||||||
pub(crate) invite_code: String,
|
pub(crate) invite_code: String,
|
||||||
|
pub(crate) metadata_json: String,
|
||||||
pub(crate) created_at: Timestamp,
|
pub(crate) created_at: Timestamp,
|
||||||
pub(crate) updated_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(
|
pub(crate) fn list_profile_save_archive_rows(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: RuntimeProfileSaveArchiveListInput,
|
input: RuntimeProfileSaveArchiveListInput,
|
||||||
@@ -1534,10 +1554,14 @@ fn redeem_profile_referral_invite_code_record(
|
|||||||
),
|
),
|
||||||
bound_at,
|
bound_at,
|
||||||
)?;
|
)?;
|
||||||
let today_inviter_reward_count =
|
let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id);
|
||||||
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at);
|
let today_inviter_reward_count = if is_admin_invite_code {
|
||||||
let inviter_reward_granted =
|
0
|
||||||
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT;
|
} 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 {
|
let inviter_balance_after = if inviter_reward_granted {
|
||||||
apply_profile_wallet_delta(
|
apply_profile_wallet_delta(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1753,6 +1777,56 @@ fn admin_disable_profile_redeem_code_record(
|
|||||||
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
|
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(
|
fn build_profile_referral_invite_center_snapshot(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
user_id: &str,
|
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 {
|
ctx.db.profile_invite_code().insert(ProfileInviteCode {
|
||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
invite_code,
|
invite_code,
|
||||||
|
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
|
||||||
created_at: ctx.timestamp,
|
created_at: ctx.timestamp,
|
||||||
updated_at: ctx.timestamp,
|
updated_at: ctx.timestamp,
|
||||||
})
|
})
|
||||||
@@ -1856,6 +1931,14 @@ fn count_today_profile_referral_inviter_rewards(
|
|||||||
.count() as u32
|
.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 {
|
fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 {
|
||||||
ctx.db
|
ctx.db
|
||||||
.profile_dashboard_state()
|
.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(
|
fn build_profile_wallet_ledger_snapshot_from_row(
|
||||||
row: &ProfileWalletLedger,
|
row: &ProfileWalletLedger,
|
||||||
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
||||||
|
|||||||
@@ -88,13 +88,19 @@ const mockUser: AuthUser = {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||||
user: null,
|
user: null,
|
||||||
availableLoginMethods: ['phone'],
|
availableLoginMethods: ['phone'],
|
||||||
});
|
});
|
||||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
authMocks.loginWithPhoneCode.mockResolvedValue({
|
||||||
|
token: 'jwt-phone',
|
||||||
|
user: mockUser,
|
||||||
|
created: false,
|
||||||
|
referral: null,
|
||||||
|
});
|
||||||
authMocks.authEntry.mockResolvedValue(mockUser);
|
authMocks.authEntry.mockResolvedValue(mockUser);
|
||||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
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(
|
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||||
'13800000000',
|
'13800000000',
|
||||||
'123456',
|
'123456',
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||||
expect(onAuthenticated).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();
|
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(
|
||||||
|
<AuthGate>
|
||||||
|
<div>公开内容</div>
|
||||||
|
</AuthGate>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ type AuthStatus =
|
|||||||
|
|
||||||
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
|
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(
|
function normalizeAvailableLoginMethods(
|
||||||
methods: AuthLoginMethod[] | null | undefined,
|
methods: AuthLoginMethod[] | null | undefined,
|
||||||
): AuthLoginMethod[] {
|
): AuthLoginMethod[] {
|
||||||
@@ -83,6 +91,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const [bindingPhone, setBindingPhone] = useState(false);
|
const [bindingPhone, setBindingPhone] = useState(false);
|
||||||
const [wechatLoading, setWechatLoading] = useState(false);
|
const [wechatLoading, setWechatLoading] = useState(false);
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [loginInitialMode, setLoginInitialMode] = useState<
|
||||||
|
'login' | 'register'
|
||||||
|
>('login');
|
||||||
|
const [pendingInviteCode, setPendingInviteCode] = useState('');
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||||
const [settingsEntryMode, setSettingsEntryMode] = useState<
|
const [settingsEntryMode, setSettingsEntryMode] = useState<
|
||||||
'settings' | 'account'
|
'settings' | 'account'
|
||||||
@@ -102,6 +114,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||||
useState<AuthCaptchaChallenge | null>(null);
|
useState<AuthCaptchaChallenge | null>(null);
|
||||||
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
|
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
|
||||||
|
const autoOpenedInviteCodeRef = useRef<string | null>(null);
|
||||||
const hasRenderedPlatformContentRef = useRef(false);
|
const hasRenderedPlatformContentRef = useRef(false);
|
||||||
const canKeepPlatformContentMounted =
|
const canKeepPlatformContentMounted =
|
||||||
hasRenderedPlatformContentRef.current &&
|
hasRenderedPlatformContentRef.current &&
|
||||||
@@ -169,6 +182,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const closeLoginModal = useCallback(() => {
|
const closeLoginModal = useCallback(() => {
|
||||||
pendingProtectedActionRef.current = null;
|
pendingProtectedActionRef.current = null;
|
||||||
setShowLoginModal(false);
|
setShowLoginModal(false);
|
||||||
|
setLoginInitialMode('login');
|
||||||
|
setPendingInviteCode('');
|
||||||
setLoginCaptchaChallenge(null);
|
setLoginCaptchaChallenge(null);
|
||||||
setError('');
|
setError('');
|
||||||
}, []);
|
}, []);
|
||||||
@@ -187,6 +202,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||||
|
setLoginInitialMode('login');
|
||||||
|
setPendingInviteCode('');
|
||||||
setShowLoginModal(true);
|
setShowLoginModal(true);
|
||||||
},
|
},
|
||||||
[readyUser],
|
[readyUser],
|
||||||
@@ -224,6 +241,24 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
openLoginModal();
|
openLoginModal();
|
||||||
}, [openLoginModal, readyUser]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
|
|
||||||
@@ -703,6 +738,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
wechatLoading={wechatLoading}
|
wechatLoading={wechatLoading}
|
||||||
error={error}
|
error={error}
|
||||||
captchaChallenge={loginCaptchaChallenge}
|
captchaChallenge={loginCaptchaChallenge}
|
||||||
|
initialMode={loginInitialMode}
|
||||||
|
initialInviteCode={pendingInviteCode}
|
||||||
onClose={closeLoginModal}
|
onClose={closeLoginModal}
|
||||||
onSendCode={async (phone, scene, captcha) => {
|
onSendCode={async (phone, scene, captcha) => {
|
||||||
setSendingCode(true);
|
setSendingCode(true);
|
||||||
@@ -727,14 +764,21 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setSendingCode(false);
|
setSendingCode(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPhoneSubmit={async (phone, code) => {
|
onPhoneSubmit={async (phone, code, inviteCode) => {
|
||||||
setLoggingIn(true);
|
setLoggingIn(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const nextUser = await loginWithPhoneCode(phone, code);
|
const response = await loginWithPhoneCode(
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
inviteCode,
|
||||||
|
);
|
||||||
setStoredLastLoginPhone(phone);
|
setStoredLastLoginPhone(phone);
|
||||||
setLoginCaptchaChallenge(null);
|
setLoginCaptchaChallenge(null);
|
||||||
activateReadyUser(nextUser);
|
if (response.referral && !response.referral.ok) {
|
||||||
|
setError(response.referral.message || '邀请码未绑定');
|
||||||
|
}
|
||||||
|
activateReadyUser(response.user);
|
||||||
} catch (loginError) {
|
} catch (loginError) {
|
||||||
setError(
|
setError(
|
||||||
loginError instanceof Error
|
loginError instanceof Error
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
|
|||||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||||
|
|
||||||
type SmsScene = 'login' | 'reset_password';
|
type SmsScene = 'login' | 'reset_password';
|
||||||
type LoginTab = 'phone' | 'password';
|
type LoginTab = 'phone' | 'password' | 'register';
|
||||||
|
|
||||||
type LoginScreenProps = {
|
type LoginScreenProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,6 +21,8 @@ type LoginScreenProps = {
|
|||||||
wechatLoading: boolean;
|
wechatLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
captchaChallenge: AuthCaptchaChallenge | null;
|
captchaChallenge: AuthCaptchaChallenge | null;
|
||||||
|
initialMode?: 'login' | 'register';
|
||||||
|
initialInviteCode?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendCode: (
|
onSendCode: (
|
||||||
phone: string,
|
phone: string,
|
||||||
@@ -33,7 +35,11 @@ type LoginScreenProps = {
|
|||||||
cooldownSeconds: number;
|
cooldownSeconds: number;
|
||||||
expiresInSeconds: number;
|
expiresInSeconds: number;
|
||||||
}>;
|
}>;
|
||||||
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
|
onPhoneSubmit: (
|
||||||
|
phone: string,
|
||||||
|
code: string,
|
||||||
|
inviteCode?: string,
|
||||||
|
) => Promise<void>;
|
||||||
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
|
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
|
||||||
onResetPassword: (
|
onResetPassword: (
|
||||||
phone: string,
|
phone: string,
|
||||||
@@ -52,6 +58,8 @@ export function LoginScreen({
|
|||||||
wechatLoading,
|
wechatLoading,
|
||||||
error,
|
error,
|
||||||
captchaChallenge,
|
captchaChallenge,
|
||||||
|
initialMode = 'login',
|
||||||
|
initialInviteCode = '',
|
||||||
onClose,
|
onClose,
|
||||||
onSendCode,
|
onSendCode,
|
||||||
onPhoneSubmit,
|
onPhoneSubmit,
|
||||||
@@ -66,6 +74,7 @@ export function LoginScreen({
|
|||||||
const [resetPhone, setResetPhone] = useState('');
|
const [resetPhone, setResetPhone] = useState('');
|
||||||
const [resetCode, setResetCode] = useState('');
|
const [resetCode, setResetCode] = useState('');
|
||||||
const [resetPasswordValue, setResetPasswordValue] = useState('');
|
const [resetPasswordValue, setResetPasswordValue] = useState('');
|
||||||
|
const [inviteCode, setInviteCode] = useState(initialInviteCode);
|
||||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||||
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
|
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
|
||||||
@@ -88,16 +97,23 @@ export function LoginScreen({
|
|||||||
setResetPhone('');
|
setResetPhone('');
|
||||||
setResetCode('');
|
setResetCode('');
|
||||||
setResetPasswordValue('');
|
setResetPasswordValue('');
|
||||||
|
setInviteCode(initialInviteCode);
|
||||||
setCaptchaAnswer('');
|
setCaptchaAnswer('');
|
||||||
setCooldownSeconds(0);
|
setCooldownSeconds(0);
|
||||||
setResetCooldownSeconds(0);
|
setResetCooldownSeconds(0);
|
||||||
setHint('');
|
setHint('');
|
||||||
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
|
setActiveLoginTab(
|
||||||
}, [isOpen, phoneLoginEnabled]);
|
initialMode === 'register' && phoneLoginEnabled
|
||||||
|
? 'register'
|
||||||
|
: phoneLoginEnabled
|
||||||
|
? 'phone'
|
||||||
|
: 'password',
|
||||||
|
);
|
||||||
|
}, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
activeLoginTab === 'phone' &&
|
(activeLoginTab === 'phone' || activeLoginTab === 'register') &&
|
||||||
!phoneLoginEnabled &&
|
!phoneLoginEnabled &&
|
||||||
passwordLoginEnabled
|
passwordLoginEnabled
|
||||||
) {
|
) {
|
||||||
@@ -196,9 +212,11 @@ export function LoginScreen({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-5 px-5 py-5">
|
<div className="flex flex-col gap-5 px-5 py-5">
|
||||||
{phoneLoginEnabled && passwordLoginEnabled ? (
|
{phoneLoginEnabled ? (
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-2 gap-2"
|
className={`grid gap-2 ${
|
||||||
|
passwordLoginEnabled ? 'grid-cols-3' : 'grid-cols-2'
|
||||||
|
}`}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="登录方式"
|
aria-label="登录方式"
|
||||||
>
|
>
|
||||||
@@ -208,11 +226,19 @@ export function LoginScreen({
|
|||||||
>
|
>
|
||||||
短信登录
|
短信登录
|
||||||
</LoginTabButton>
|
</LoginTabButton>
|
||||||
|
{passwordLoginEnabled ? (
|
||||||
|
<LoginTabButton
|
||||||
|
active={activeLoginTab === 'password'}
|
||||||
|
onClick={() => setActiveLoginTab('password')}
|
||||||
|
>
|
||||||
|
密码登录
|
||||||
|
</LoginTabButton>
|
||||||
|
) : null}
|
||||||
<LoginTabButton
|
<LoginTabButton
|
||||||
active={activeLoginTab === 'password'}
|
active={activeLoginTab === 'register'}
|
||||||
onClick={() => setActiveLoginTab('password')}
|
onClick={() => setActiveLoginTab('register')}
|
||||||
>
|
>
|
||||||
密码登录
|
注册
|
||||||
</LoginTabButton>
|
</LoginTabButton>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -312,6 +338,42 @@ export function LoginScreen({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{phoneLoginEnabled && activeLoginTab === 'register' ? (
|
||||||
|
<PhoneCodeForm
|
||||||
|
phone={phone}
|
||||||
|
code={code}
|
||||||
|
inviteCode={inviteCode}
|
||||||
|
captchaAnswer={captchaAnswer}
|
||||||
|
captchaChallenge={captchaChallenge}
|
||||||
|
cooldownSeconds={cooldownSeconds}
|
||||||
|
sendingCode={sendingCode}
|
||||||
|
loggingIn={loggingIn}
|
||||||
|
error={error}
|
||||||
|
hint={hint}
|
||||||
|
submitLabel="注册"
|
||||||
|
enabled={phoneLoginEnabled}
|
||||||
|
showPhoneField
|
||||||
|
showInviteCodeField
|
||||||
|
onPhoneChange={setPhone}
|
||||||
|
onCodeChange={setCode}
|
||||||
|
onInviteCodeChange={setInviteCode}
|
||||||
|
onCaptchaAnswerChange={setCaptchaAnswer}
|
||||||
|
onSendCode={async () => {
|
||||||
|
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 &&
|
{!passwordLoginEnabled &&
|
||||||
!phoneLoginEnabled &&
|
!phoneLoginEnabled &&
|
||||||
!wechatLoginEnabled ? (
|
!wechatLoginEnabled ? (
|
||||||
@@ -358,6 +420,7 @@ function LoginTabButton({
|
|||||||
function PhoneCodeForm({
|
function PhoneCodeForm({
|
||||||
phone,
|
phone,
|
||||||
code,
|
code,
|
||||||
|
inviteCode = '',
|
||||||
captchaAnswer,
|
captchaAnswer,
|
||||||
captchaChallenge,
|
captchaChallenge,
|
||||||
cooldownSeconds,
|
cooldownSeconds,
|
||||||
@@ -368,14 +431,17 @@ function PhoneCodeForm({
|
|||||||
submitLabel,
|
submitLabel,
|
||||||
enabled,
|
enabled,
|
||||||
showPhoneField,
|
showPhoneField,
|
||||||
|
showInviteCodeField = false,
|
||||||
onPhoneChange,
|
onPhoneChange,
|
||||||
onCodeChange,
|
onCodeChange,
|
||||||
|
onInviteCodeChange,
|
||||||
onCaptchaAnswerChange,
|
onCaptchaAnswerChange,
|
||||||
onSendCode,
|
onSendCode,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
phone: string;
|
phone: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
inviteCode?: string;
|
||||||
captchaAnswer: string;
|
captchaAnswer: string;
|
||||||
captchaChallenge: AuthCaptchaChallenge | null;
|
captchaChallenge: AuthCaptchaChallenge | null;
|
||||||
cooldownSeconds: number;
|
cooldownSeconds: number;
|
||||||
@@ -386,8 +452,10 @@ function PhoneCodeForm({
|
|||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
showPhoneField: boolean;
|
showPhoneField: boolean;
|
||||||
|
showInviteCodeField?: boolean;
|
||||||
onPhoneChange: (value: string) => void;
|
onPhoneChange: (value: string) => void;
|
||||||
onCodeChange: (value: string) => void;
|
onCodeChange: (value: string) => void;
|
||||||
|
onInviteCodeChange?: (value: string) => void;
|
||||||
onCaptchaAnswerChange: (value: string) => void;
|
onCaptchaAnswerChange: (value: string) => void;
|
||||||
onSendCode: () => Promise<void>;
|
onSendCode: () => Promise<void>;
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
@@ -418,6 +486,19 @@ function PhoneCodeForm({
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showInviteCodeField ? (
|
||||||
|
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||||
|
<span>邀请码</span>
|
||||||
|
<input
|
||||||
|
className="platform-input"
|
||||||
|
autoComplete="off"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(event) => onInviteCodeChange?.(event.target.value)}
|
||||||
|
placeholder="邀请码"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||||
<span>验证码</span>
|
<span>验证码</span>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import type {
|
|||||||
ProfileReferralInviteCenterResponse,
|
ProfileReferralInviteCenterResponse,
|
||||||
ProfileSaveArchiveSummary,
|
ProfileSaveArchiveSummary,
|
||||||
ProfileWalletLedgerResponse,
|
ProfileWalletLedgerResponse,
|
||||||
RedeemProfileReferralInviteCodeResponse,
|
|
||||||
RedeemProfileRewardCodeResponse,
|
RedeemProfileRewardCodeResponse,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
@@ -57,7 +56,6 @@ import { copyTextToClipboard } from '../../services/clipboard';
|
|||||||
import {
|
import {
|
||||||
getRpgProfileReferralInviteCenter,
|
getRpgProfileReferralInviteCenter,
|
||||||
getRpgProfileWalletLedger,
|
getRpgProfileWalletLedger,
|
||||||
redeemRpgProfileReferralInviteCode,
|
|
||||||
redeemRpgProfileRewardCode,
|
redeemRpgProfileRewardCode,
|
||||||
} from '../../services/rpg-entry/rpgProfileClient';
|
} from '../../services/rpg-entry/rpgProfileClient';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
@@ -144,7 +142,7 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
|||||||
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
const AVATAR_OUTPUT_SIZE = 256;
|
const AVATAR_OUTPUT_SIZE = 256;
|
||||||
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
type ProfilePopupPanel = 'invite' | 'community';
|
||||||
type MobileHomeChannel = 'recommend' | 'today' | 'category';
|
type MobileHomeChannel = 'recommend' | 'today' | 'category';
|
||||||
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
|
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
|
||||||
|
|
||||||
@@ -1841,34 +1839,21 @@ function RewardCodeRedeemModal({
|
|||||||
function ProfileReferralModal({
|
function ProfileReferralModal({
|
||||||
panel,
|
panel,
|
||||||
center,
|
center,
|
||||||
inviteCodeInput,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isSubmitting,
|
|
||||||
error,
|
error,
|
||||||
success,
|
success,
|
||||||
onClose,
|
onClose,
|
||||||
onInputChange,
|
|
||||||
onCopyInvite,
|
onCopyInvite,
|
||||||
onSubmitRedeem,
|
|
||||||
}: {
|
}: {
|
||||||
panel: ProfilePopupPanel;
|
panel: ProfilePopupPanel;
|
||||||
center: ProfileReferralInviteCenterResponse | null;
|
center: ProfileReferralInviteCenterResponse | null;
|
||||||
inviteCodeInput: string;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSubmitting: boolean;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
success: string | null;
|
success: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onInputChange: (value: string) => void;
|
|
||||||
onCopyInvite: () => void;
|
onCopyInvite: () => void;
|
||||||
onSubmitRedeem: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const title =
|
const title = panel === 'invite' ? '邀请好友' : '玩家社区';
|
||||||
panel === 'invite'
|
|
||||||
? '邀请好友'
|
|
||||||
: panel === 'redeem'
|
|
||||||
? '填邀请码'
|
|
||||||
: '玩家社区';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
|
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
|
||||||
@@ -1911,7 +1896,7 @@ function ProfileReferralModal({
|
|||||||
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
||||||
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
|
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
|
||||||
</div>
|
</div>
|
||||||
) : panel === 'invite' ? (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
|
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
|
||||||
<div className="text-[11px] font-bold text-zinc-500">
|
<div className="text-[11px] font-bold text-zinc-500">
|
||||||
@@ -1951,31 +1936,6 @@ function ProfileReferralModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{center?.hasRedeemedCode ? (
|
|
||||||
<div className="rounded-xl bg-emerald-50 px-4 py-4 text-center text-sm font-bold text-emerald-700">
|
|
||||||
已填写邀请码
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
value={inviteCodeInput}
|
|
||||||
onChange={(event) => onInputChange(event.target.value)}
|
|
||||||
placeholder="输入邀请码"
|
|
||||||
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black tracking-[0.14em] outline-none focus:border-[#ff4056]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSubmitRedeem}
|
|
||||||
disabled={isSubmitting || !inviteCodeInput.trim()}
|
|
||||||
className="w-full rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{isSubmitting ? '提交中' : '确认填写'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -2153,10 +2113,8 @@ export function RpgEntryHomeView({
|
|||||||
const [referralCenter, setReferralCenter] =
|
const [referralCenter, setReferralCenter] =
|
||||||
useState<ProfileReferralInviteCenterResponse | null>(null);
|
useState<ProfileReferralInviteCenterResponse | null>(null);
|
||||||
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
|
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
|
||||||
const [isSubmittingReferral, setIsSubmittingReferral] = useState(false);
|
|
||||||
const [referralError, setReferralError] = useState<string | null>(null);
|
const [referralError, setReferralError] = useState<string | null>(null);
|
||||||
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
|
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
|
||||||
const [inviteCodeInput, setInviteCodeInput] = useState('');
|
|
||||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -2519,30 +2477,6 @@ export function RpgEntryHomeView({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const submitReferralInviteCode = () => {
|
|
||||||
if (isSubmittingReferral || !inviteCodeInput.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmittingReferral(true);
|
|
||||||
setReferralError(null);
|
|
||||||
setReferralSuccess(null);
|
|
||||||
void redeemRpgProfileReferralInviteCode(inviteCodeInput)
|
|
||||||
.then((response: RedeemProfileReferralInviteCodeResponse) => {
|
|
||||||
setReferralCenter(response.center);
|
|
||||||
setInviteCodeInput('');
|
|
||||||
setReferralSuccess(
|
|
||||||
response.inviteeRewardGranted ? '已获得30陶泥币' : '填写成功',
|
|
||||||
);
|
|
||||||
void onRechargeSuccess?.();
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
setReferralError(
|
|
||||||
error instanceof Error ? error.message : '填写邀请码失败',
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => setIsSubmittingReferral(false));
|
|
||||||
};
|
|
||||||
const openRewardCodeModal = () => {
|
const openRewardCodeModal = () => {
|
||||||
setIsRewardCodeOpen(true);
|
setIsRewardCodeOpen(true);
|
||||||
setRewardCodeError(null);
|
setRewardCodeError(null);
|
||||||
@@ -3061,17 +2995,12 @@ export function RpgEntryHomeView({
|
|||||||
|
|
||||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||||
<SectionHeader title="常用功能" detail="快捷入口" />
|
<SectionHeader title="常用功能" detail="快捷入口" />
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<ProfileShortcutButton
|
<ProfileShortcutButton
|
||||||
label="邀请好友"
|
label="邀请好友"
|
||||||
icon={UserPlus}
|
icon={UserPlus}
|
||||||
onClick={() => openProfilePopupPanel('invite')}
|
onClick={() => openProfilePopupPanel('invite')}
|
||||||
/>
|
/>
|
||||||
<ProfileShortcutButton
|
|
||||||
label="填邀请码"
|
|
||||||
icon={Ticket}
|
|
||||||
onClick={() => openProfilePopupPanel('redeem')}
|
|
||||||
/>
|
|
||||||
<ProfileShortcutButton
|
<ProfileShortcutButton
|
||||||
label="玩家社区"
|
label="玩家社区"
|
||||||
icon={MessageCircle}
|
icon={MessageCircle}
|
||||||
@@ -3506,15 +3435,11 @@ export function RpgEntryHomeView({
|
|||||||
<ProfileReferralModal
|
<ProfileReferralModal
|
||||||
panel={profilePopupPanel}
|
panel={profilePopupPanel}
|
||||||
center={referralCenter}
|
center={referralCenter}
|
||||||
inviteCodeInput={inviteCodeInput}
|
|
||||||
isLoading={isLoadingReferral}
|
isLoading={isLoadingReferral}
|
||||||
isSubmitting={isSubmittingReferral}
|
|
||||||
error={referralError}
|
error={referralError}
|
||||||
success={referralSuccess}
|
success={referralSuccess}
|
||||||
onClose={() => setProfilePopupPanel(null)}
|
onClose={() => setProfilePopupPanel(null)}
|
||||||
onInputChange={setInviteCodeInput}
|
|
||||||
onCopyInvite={copyInviteInfo}
|
onCopyInvite={copyInviteInfo}
|
||||||
onSubmitRedeem={submitReferralInviteCode}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{rewardCodeModal}
|
{rewardCodeModal}
|
||||||
@@ -3628,15 +3553,11 @@ export function RpgEntryHomeView({
|
|||||||
<ProfileReferralModal
|
<ProfileReferralModal
|
||||||
panel={profilePopupPanel}
|
panel={profilePopupPanel}
|
||||||
center={referralCenter}
|
center={referralCenter}
|
||||||
inviteCodeInput={inviteCodeInput}
|
|
||||||
isLoading={isLoadingReferral}
|
isLoading={isLoadingReferral}
|
||||||
isSubmitting={isSubmittingReferral}
|
|
||||||
error={referralError}
|
error={referralError}
|
||||||
success={referralSuccess}
|
success={referralSuccess}
|
||||||
onClose={() => setProfilePopupPanel(null)}
|
onClose={() => setProfilePopupPanel(null)}
|
||||||
onInputChange={setInviteCodeInput}
|
|
||||||
onCopyInvite={copyInviteInfo}
|
onCopyInvite={copyInviteInfo}
|
||||||
onSubmitRedeem={submitReferralInviteCode}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isProfilePlayStatsOpen ? (
|
{isProfilePlayStatsOpen ? (
|
||||||
|
|||||||
@@ -219,15 +219,20 @@ describe('authService', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
const response = await loginWithPhoneCode(
|
||||||
|
'13800138000',
|
||||||
|
'123456',
|
||||||
|
'spring-2026',
|
||||||
|
);
|
||||||
|
|
||||||
expect(user.username).toBe('138****8000');
|
expect(response.user.username).toBe('138****8000');
|
||||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||||
'/api/auth/phone/login',
|
'/api/auth/phone/login',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
phone: '13800138000',
|
phone: '13800138000',
|
||||||
code: '123456',
|
code: '123456',
|
||||||
|
inviteCode: 'SPRING2026',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
'登录失败',
|
'登录失败',
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ export function normalizePhoneInput(phoneInput: string) {
|
|||||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeInviteCodeInput(inviteCode: string | undefined) {
|
||||||
|
return (inviteCode ?? '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[^0-9a-z]/gi, '')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function getStoredLastLoginPhone() {
|
export function getStoredLastLoginPhone() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return '';
|
return '';
|
||||||
@@ -145,7 +152,12 @@ export async function sendPhoneLoginCode(
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithPhoneCode(phone: string, code: string) {
|
export async function loginWithPhoneCode(
|
||||||
|
phone: string,
|
||||||
|
code: string,
|
||||||
|
inviteCode?: string,
|
||||||
|
) {
|
||||||
|
const normalizedInviteCode = normalizeInviteCodeInput(inviteCode);
|
||||||
const response = await requestJson<AuthPhoneLoginResponse>(
|
const response = await requestJson<AuthPhoneLoginResponse>(
|
||||||
'/api/auth/phone/login',
|
'/api/auth/phone/login',
|
||||||
{
|
{
|
||||||
@@ -154,6 +166,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
phone: normalizePhoneInput(phone),
|
phone: normalizePhoneInput(phone),
|
||||||
code: code.trim(),
|
code: code.trim(),
|
||||||
|
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'登录失败',
|
'登录失败',
|
||||||
@@ -161,7 +174,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setStoredAccessToken(response.token, { emit: false });
|
setStoredAccessToken(response.token, { emit: false });
|
||||||
return response.user;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bindWechatPhone(phone: string, code: string) {
|
export async function bindWechatPhone(phone: string, code: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user