Implement registration invite code flow and admin invite codes
This commit is contained in:
@@ -876,6 +876,28 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>邀请码管理</h2>
|
||||
<p>创建或更新管理员邀请码。</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="invite-code-form" class="form">
|
||||
<label>邀请码
|
||||
<input id="invite-code-value" autocomplete="off" />
|
||||
</label>
|
||||
<label>Metadata JSON
|
||||
<textarea id="invite-code-metadata">{}</textarea>
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button id="invite-code-submit" type="submit">保存邀请码</button>
|
||||
</div>
|
||||
<div id="invite-code-message" class="hint"></div>
|
||||
<div id="invite-code-result" class="result-panel" style="display:none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>API 调试</h2>
|
||||
@@ -950,6 +972,8 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
const overviewTablesEl = document.getElementById('overview-tables');
|
||||
const overviewErrorsEl = document.getElementById('overview-errors');
|
||||
const debugResultEl = document.getElementById('debug-result');
|
||||
const inviteCodeMessageEl = document.getElementById('invite-code-message');
|
||||
const inviteCodeResultEl = document.getElementById('invite-code-result');
|
||||
|
||||
function getToken() {
|
||||
return window.localStorage.getItem(TOKEN_KEY) || '';
|
||||
@@ -1030,6 +1054,16 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInviteCodeResult(result) {
|
||||
inviteCodeResultEl.style.display = 'grid';
|
||||
inviteCodeResultEl.innerHTML = `
|
||||
<div><strong>User ID:</strong>${result.userId || '-'}</div>
|
||||
<div><strong>邀请码:</strong>${result.inviteCode || '-'}</div>
|
||||
<div><strong>更新时间:</strong>${result.updatedAt || '-'}</div>
|
||||
<div><strong>Metadata</strong><pre>${JSON.stringify(result.metadata || {}, null, 2)}</pre></div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
@@ -1107,6 +1141,26 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('invite-code-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
inviteCodeMessageEl.textContent = '正在保存...';
|
||||
try {
|
||||
const rawMetadata = document.getElementById('invite-code-metadata').value.trim() || '{}';
|
||||
const metadata = JSON.parse(rawMetadata);
|
||||
const result = await request('/admin/api/profile/invite-codes', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
inviteCode: document.getElementById('invite-code-value').value,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
inviteCodeMessageEl.textContent = '已保存';
|
||||
renderInviteCodeResult(result);
|
||||
} catch (error) {
|
||||
inviteCodeMessageEl.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
loadMe().then(loadOverview);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -103,10 +103,10 @@ use crate::{
|
||||
},
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
|
||||
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
|
||||
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
|
||||
redeem_profile_referral_invite_code, redeem_profile_reward_code,
|
||||
admin_disable_profile_redeem_code, admin_upsert_profile_invite_code,
|
||||
admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard,
|
||||
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
|
||||
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
|
||||
},
|
||||
runtime_save::{
|
||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||
@@ -168,6 +168,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/invite-codes",
|
||||
post(admin_upsert_profile_invite_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/healthz",
|
||||
get(|Extension(request_context): Extension<_>| async move {
|
||||
@@ -1845,6 +1852,8 @@ mod tests {
|
||||
payload["user"]["phoneNumberMasked"],
|
||||
Value::String("138****8000".to_string())
|
||||
);
|
||||
assert_eq!(payload["created"], Value::Bool(true));
|
||||
assert!(payload["referral"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1951,6 +1960,175 @@ mod tests {
|
||||
serde_json::from_slice(&second_body).expect("second login payload should be json");
|
||||
|
||||
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
|
||||
assert_eq!(first_payload["created"], Value::Bool(true));
|
||||
assert_eq!(second_payload["created"], Value::Bool(false));
|
||||
assert!(second_payload["referral"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phone_login_invite_code_failure_does_not_block_created_user() {
|
||||
let config = AppConfig {
|
||||
sms_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let send_code_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/send-code")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13600136000",
|
||||
"scene": "login"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("send code request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("send code request should succeed");
|
||||
assert_eq!(send_code_response.status(), StatusCode::OK);
|
||||
|
||||
let login_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13600136000",
|
||||
"code": "123456",
|
||||
"inviteCode": "SPRING2026"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
|
||||
assert_eq!(login_response.status(), StatusCode::OK);
|
||||
let body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value = serde_json::from_slice(&body).expect("login payload should be json");
|
||||
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
assert_eq!(payload["created"], Value::Bool(true));
|
||||
assert_eq!(payload["referral"]["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["referral"]["message"],
|
||||
Value::String("邀请码无效,已继续注册".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phone_login_existing_user_ignores_invite_code() {
|
||||
let config = AppConfig {
|
||||
sms_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let first_send_code_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/send-code")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13500135000",
|
||||
"scene": "login"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("send code request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("send code request should succeed");
|
||||
assert_eq!(first_send_code_response.status(), StatusCode::OK);
|
||||
|
||||
let first_login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13500135000",
|
||||
"code": "123456"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("first login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first login request should succeed");
|
||||
assert_eq!(first_login_response.status(), StatusCode::OK);
|
||||
|
||||
let second_send_code_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/send-code")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13500135000",
|
||||
"scene": "login"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("send code request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("send code request should succeed");
|
||||
assert_eq!(second_send_code_response.status(), StatusCode::OK);
|
||||
|
||||
let second_login_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13500135000",
|
||||
"code": "123456",
|
||||
"inviteCode": "SPRING2026"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("second login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second login request should succeed");
|
||||
|
||||
assert_eq!(second_login_response.status(), StatusCode::OK);
|
||||
let body = second_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("second login body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("second login payload should be json");
|
||||
|
||||
assert_eq!(payload["created"], Value::Bool(false));
|
||||
assert!(payload["referral"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -9,7 +9,8 @@ use module_auth::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use shared_contracts::auth::{
|
||||
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse,
|
||||
PhoneLoginReferralResponse, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
|
||||
PhoneSendCodeResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
@@ -110,6 +111,7 @@ pub async fn phone_login(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
|
||||
);
|
||||
}
|
||||
let invite_code = payload.invite_code.clone();
|
||||
let result = match state
|
||||
.phone_auth_service()
|
||||
.login(
|
||||
@@ -146,6 +148,18 @@ pub async fn phone_login(
|
||||
return Err(map_phone_auth_error(error));
|
||||
}
|
||||
};
|
||||
let created = result.created;
|
||||
let referral = if created {
|
||||
bind_referral_invite_code_on_registration(
|
||||
&state,
|
||||
&request_context,
|
||||
result.user.id.clone(),
|
||||
invite_code,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
@@ -174,11 +188,55 @@ pub async fn phone_login(
|
||||
PhoneLoginResponse {
|
||||
token: signed_session.access_token,
|
||||
user: map_auth_user_payload(result.user),
|
||||
created,
|
||||
referral,
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
async fn bind_referral_invite_code_on_registration(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
invite_code: Option<String>,
|
||||
) -> Option<PhoneLoginReferralResponse> {
|
||||
let invite_code = invite_code
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
match state
|
||||
.spacetime_client()
|
||||
.redeem_profile_referral_invite_code(user_id, invite_code, updated_at_micros as i64)
|
||||
.await
|
||||
{
|
||||
Ok(record) => Some(PhoneLoginReferralResponse {
|
||||
ok: true,
|
||||
message: Some("邀请码已绑定".to_string()),
|
||||
invitee_reward_granted: record.invitee_reward_granted,
|
||||
inviter_reward_granted: record.inviter_reward_granted,
|
||||
invitee_balance_after: Some(record.invitee_balance_after),
|
||||
inviter_balance_after: Some(record.inviter_balance_after),
|
||||
}),
|
||||
Err(error) => {
|
||||
warn!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
error = %error,
|
||||
"注册邀请码绑定失败,登录流程继续"
|
||||
);
|
||||
Some(PhoneLoginReferralResponse {
|
||||
ok: false,
|
||||
message: Some("邀请码无效,已继续注册".to_string()),
|
||||
invitee_reward_granted: false,
|
||||
inviter_reward_granted: false,
|
||||
invitee_balance_after: None,
|
||||
inviter_balance_after: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> {
|
||||
match raw_scene.unwrap_or("login").trim() {
|
||||
"login" => Ok(PhoneAuthScene::Login),
|
||||
|
||||
@@ -36,11 +36,11 @@ use shared_contracts::{
|
||||
},
|
||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||
puzzle_runtime::{
|
||||
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
|
||||
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
||||
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
||||
StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
|
||||
UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||
},
|
||||
puzzle_works::{
|
||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||
@@ -57,10 +57,10 @@ use spacetime_client::{
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
|
||||
PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -5,31 +5,30 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeReferralRedeemRecord,
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
|
||||
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
|
||||
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
|
||||
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
|
||||
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType,
|
||||
RuntimeReferralInviteCenterRecord,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
|
||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest,
|
||||
CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
||||
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
|
||||
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
||||
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
|
||||
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
|
||||
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
||||
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
||||
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
||||
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use time::OffsetDateTime;
|
||||
@@ -213,27 +212,14 @@ pub async fn get_profile_referral_invite_center(
|
||||
}
|
||||
|
||||
pub async fn redeem_profile_referral_invite_code(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(_payload): Json<RedeemProfileReferralInviteCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_redeem_profile_referral_invite_code_response(record),
|
||||
Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -330,6 +316,37 @@ pub async fn admin_disable_profile_redeem_code(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_upsert_profile_invite_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertProfileInviteCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let metadata_json = normalize_admin_invite_code_metadata(payload.metadata)
|
||||
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.admin_upsert_profile_invite_code(
|
||||
admin.session().username.clone(),
|
||||
payload.invite_code,
|
||||
metadata_json,
|
||||
updated_at_micros as i64,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_profile_invite_code_admin_response(record),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_profile_play_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -486,18 +503,6 @@ fn build_profile_referral_invite_center_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_redeem_profile_referral_invite_code_response(
|
||||
record: RuntimeReferralRedeemRecord,
|
||||
) -> RedeemProfileReferralInviteCodeResponse {
|
||||
RedeemProfileReferralInviteCodeResponse {
|
||||
center: build_profile_referral_invite_center_response(record.center),
|
||||
invitee_reward_granted: record.invitee_reward_granted,
|
||||
inviter_reward_granted: record.inviter_reward_granted,
|
||||
invitee_balance_after: record.invitee_balance_after,
|
||||
inviter_balance_after: record.inviter_balance_after,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_redeem_profile_reward_code_response(
|
||||
record: RuntimeProfileRewardCodeRedeemRecord,
|
||||
) -> RedeemProfileRewardCodeResponse {
|
||||
@@ -515,6 +520,30 @@ fn build_redeem_profile_reward_code_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<String, AppError> {
|
||||
let metadata = match metadata {
|
||||
Some(Value::Null) | None => json!({}),
|
||||
Some(value) if value.is_object() => value,
|
||||
Some(_) => {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("邀请码 metadata 必须是 JSON 对象")
|
||||
.with_details(json!({ "field": "metadata" })));
|
||||
}
|
||||
};
|
||||
let metadata_json = serde_json::to_string(&metadata).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(format!("邀请码 metadata 序列化失败:{error}"))
|
||||
.with_details(json!({ "field": "metadata" }))
|
||||
})?;
|
||||
if metadata_json.len() > 4096 {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("邀请码 metadata 不能超过 4096 bytes")
|
||||
.with_details(json!({ "field": "metadata" })));
|
||||
}
|
||||
|
||||
Ok(metadata_json)
|
||||
}
|
||||
|
||||
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
|
||||
@@ -524,6 +553,20 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeM
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_invite_code_admin_response(
|
||||
record: RuntimeProfileInviteCodeRecord,
|
||||
) -> ProfileInviteCodeAdminResponse {
|
||||
let metadata =
|
||||
serde_json::from_str::<Value>(&record.metadata_json).unwrap_or_else(|_| json!({}));
|
||||
ProfileInviteCodeAdminResponse {
|
||||
user_id: record.user_id,
|
||||
invite_code: record.invite_code,
|
||||
metadata,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_admin_response(
|
||||
record: RuntimeProfileRedeemCodeRecord,
|
||||
) -> ProfileRedeemCodeAdminResponse {
|
||||
@@ -545,7 +588,7 @@ fn build_profile_redeem_code_admin_response(
|
||||
mod tests {
|
||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||
|
||||
use super::format_profile_wallet_ledger_source_type;
|
||||
use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -705,6 +748,60 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_referral_redeem_code_rejects_authenticated_manual_fill() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/profile/referrals/redeem-code")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
Value::String("邀请码仅注册时填写".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_invite_code_metadata_accepts_only_json_object() {
|
||||
assert_eq!(
|
||||
normalize_admin_invite_code_metadata(None).expect("empty metadata should default"),
|
||||
"{}"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_admin_invite_code_metadata(Some(serde_json::json!({
|
||||
"channel": "spring",
|
||||
"source": "banner"
|
||||
})))
|
||||
.expect("object metadata should serialize"),
|
||||
r#"{"channel":"spring","source":"banner"}"#
|
||||
);
|
||||
|
||||
let error = normalize_admin_invite_code_metadata(Some(serde_json::json!("spring")))
|
||||
.expect_err("non-object metadata should reject");
|
||||
assert_eq!(error.message(), "邀请码 metadata 必须是 JSON 对象");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
|
||||
assert_compat_route_matches_main_route_error_shape(
|
||||
|
||||
Reference in New Issue
Block a user