Merge remote-tracking branch 'origin/master'
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -39,43 +39,6 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
"transfer-encoding",
|
||||
"expect",
|
||||
];
|
||||
// 数据库概览首版只统计受控白名单表,禁止后台页面直接输入任意 SQL。
|
||||
const DATABASE_OVERVIEW_TABLES: &[&str] = &[
|
||||
"runtime_setting",
|
||||
"runtime_snapshot",
|
||||
"user_browse_history",
|
||||
"profile_dashboard_state",
|
||||
"profile_wallet_ledger",
|
||||
"profile_played_world",
|
||||
"profile_save_archive",
|
||||
"story_session",
|
||||
"story_event",
|
||||
"battle_state",
|
||||
"inventory_slot",
|
||||
"quest_record",
|
||||
"quest_log",
|
||||
"treasure_record",
|
||||
"npc_state",
|
||||
"custom_world_profile",
|
||||
"custom_world_gallery_entry",
|
||||
"custom_world_agent_session",
|
||||
"custom_world_agent_message",
|
||||
"custom_world_agent_operation",
|
||||
"custom_world_draft_card",
|
||||
"big_fish_creation_session",
|
||||
"big_fish_agent_message",
|
||||
"big_fish_asset_slot",
|
||||
"puzzle_work_profile",
|
||||
"puzzle_agent_session",
|
||||
"puzzle_agent_message",
|
||||
"puzzle_runtime_run",
|
||||
"ai_task",
|
||||
"ai_task_stage",
|
||||
"ai_text_chunk",
|
||||
"ai_result_reference",
|
||||
"asset_object",
|
||||
"asset_entity_binding",
|
||||
];
|
||||
// SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。
|
||||
// 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。
|
||||
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
|
||||
@@ -283,7 +246,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let mut schema_table_names = schema
|
||||
let schema_table_names = schema
|
||||
.as_ref()
|
||||
.and_then(|value| value.tables.as_ref())
|
||||
.map(|tables| {
|
||||
@@ -300,31 +263,33 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut table_stats = Vec::new();
|
||||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||||
for table_name in &schema_table_names {
|
||||
if !is_safe_spacetime_table_name(table_name) {
|
||||
table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: table_name.clone(),
|
||||
row_count: None,
|
||||
error_message: Some("表名不适合 SQL 统计".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}");
|
||||
match fetch_spacetime_sql_count(&client, server_root, database, token, &sql).await {
|
||||
Ok(row_count) => table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: (*table_name).to_string(),
|
||||
table_name: table_name.clone(),
|
||||
row_count: Some(row_count),
|
||||
error_message: None,
|
||||
}),
|
||||
Err(error) => {
|
||||
table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: (*table_name).to_string(),
|
||||
table_name: table_name.clone(),
|
||||
row_count: None,
|
||||
error_message: Some(error),
|
||||
error_message: Some(normalize_table_count_error(&error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||||
if !schema_table_names.iter().any(|name| name == table_name) {
|
||||
schema_table_names.push((*table_name).to_string());
|
||||
}
|
||||
}
|
||||
schema_table_names.sort();
|
||||
|
||||
AdminDatabaseOverviewPayload {
|
||||
database_identity: database_info
|
||||
.as_ref()
|
||||
@@ -345,6 +310,27 @@ fn build_spacetime_schema_url(server_root: &str, database: &str) -> String {
|
||||
format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}")
|
||||
}
|
||||
|
||||
// 表名来自 schema,但进入 SQL 前仍做最小标识符校验,避免未来 schema 来源变化时扩大风险面。
|
||||
fn is_safe_spacetime_table_name(table_name: &str) -> bool {
|
||||
let mut chars = table_name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
// private 表在 SpacetimeDB SQL 下会表现为不可见,后台只展示可理解状态,不暴露整段 HTTP 噪音。
|
||||
fn normalize_table_count_error(error: &str) -> String {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
if normalized.contains("marked private") || normalized.contains("no such table") {
|
||||
return "不可统计(private 或当前身份不可见)".to_string();
|
||||
}
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
async fn fetch_spacetime_json<T>(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
@@ -662,7 +648,8 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_body_preview, build_debug_base_url, build_spacetime_schema_url, normalize_debug_path,
|
||||
build_body_preview, build_debug_base_url, build_spacetime_schema_url,
|
||||
is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error,
|
||||
parse_spacetime_sql_count_response, trim_preview,
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
@@ -722,6 +709,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_spacetime_table_name_accepts_schema_identifiers() {
|
||||
assert!(is_safe_spacetime_table_name("runtime_setting"));
|
||||
assert!(is_safe_spacetime_table_name("_private_table"));
|
||||
assert!(is_safe_spacetime_table_name("AiTaskStage2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_spacetime_table_name_rejects_sql_fragments() {
|
||||
assert!(!is_safe_spacetime_table_name(""));
|
||||
assert!(!is_safe_spacetime_table_name("bad-name"));
|
||||
assert!(!is_safe_spacetime_table_name("1bad"));
|
||||
assert!(!is_safe_spacetime_table_name("runtime_setting;DROP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_table_count_error_hides_private_table_http_noise() {
|
||||
let error = "HTTP 400:no such table: `runtime_setting`. If the table exists, it may be marked private.";
|
||||
|
||||
assert_eq!(
|
||||
normalize_table_count_error(error),
|
||||
"不可统计(private 或当前身份不可见)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_table_count_error_keeps_other_errors() {
|
||||
let error = "SQL 请求失败:connection refused";
|
||||
|
||||
assert_eq!(normalize_table_count_error(error), error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spacetime_sql_count_response_accepts_statement_array_rows() {
|
||||
let payload = json!([
|
||||
|
||||
@@ -1835,6 +1835,10 @@ mod tests {
|
||||
payload["user"]["loginMethod"],
|
||||
Value::String("password".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["user"]["createdAt"],
|
||||
Value::String(seed_user.created_at)
|
||||
);
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
}
|
||||
|
||||
@@ -2114,6 +2118,7 @@ mod tests {
|
||||
payload["user"]["phoneNumberMasked"],
|
||||
Value::String("138****8000".to_string())
|
||||
);
|
||||
assert!(payload["user"]["createdAt"].as_str().is_some());
|
||||
assert_eq!(payload["created"], Value::Bool(true));
|
||||
assert!(payload["referral"].is_null());
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// 资产操作统一预扣陶泥币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||
/// 资产操作统一预扣光点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||
async fn consume_asset_operation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -79,7 +79,7 @@ async fn refund_asset_operation_points(
|
||||
asset_kind,
|
||||
asset_id,
|
||||
error = %error,
|
||||
"资产操作失败后的陶泥币退款失败"
|
||||
"资产操作失败后的光点退款失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,10 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
|
||||
tracing::warn!(
|
||||
provider = "profile-wallet",
|
||||
error = %message,
|
||||
"资产操作陶泥币预扣失败"
|
||||
"资产操作光点预扣失败"
|
||||
);
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Procedure(message) if message.contains("陶泥币余额不足") => {
|
||||
SpacetimeClientError::Procedure(message) if message.contains("光点余额不足") => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
|
||||
@@ -12,6 +12,7 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
|
||||
login_method: user.login_method.as_str().to_string(),
|
||||
binding_status: user.binding_status.as_str().to_string(),
|
||||
wechat_bound: user.wechat_bound,
|
||||
created_at: user.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ pub async fn get_public_user_by_code(
|
||||
.get_user_by_public_user_code(&code)
|
||||
.map_err(map_public_user_search_error)?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应陶泥号用户")
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应百梦号用户")
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
@@ -60,7 +60,7 @@ pub async fn get_public_user_by_id(
|
||||
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
module_auth::PasswordEntryError::InvalidPublicUserCode => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("陶泥号格式不正确")
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("百梦号格式不正确")
|
||||
}
|
||||
module_auth::PasswordEntryError::Store(_)
|
||||
| module_auth::PasswordEntryError::PasswordHash(_)
|
||||
|
||||
@@ -3462,7 +3462,7 @@ fn resolve_author_public_user_code(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-library",
|
||||
"message": format!("作者陶泥号读取失败:{error}"),
|
||||
"message": format!("作者百梦号读取失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?
|
||||
@@ -3473,7 +3473,7 @@ fn resolve_author_public_user_code(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
|
||||
"provider": "custom-world-library",
|
||||
"message": "当前登录用户缺少陶泥号",
|
||||
"message": "当前登录用户缺少百梦号",
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
emit_foundation_draft_progress(
|
||||
&mut on_progress,
|
||||
"整理世界骨架",
|
||||
"正在根据陶泥主锚点生成第一版世界框架。",
|
||||
"正在根据百梦主锚点生成第一版世界框架。",
|
||||
12,
|
||||
);
|
||||
let mut framework = request_foundation_json_stage(
|
||||
|
||||
@@ -49,6 +49,7 @@ mod prompt;
|
||||
mod puzzle;
|
||||
mod puzzle_agent_turn;
|
||||
mod refresh_session;
|
||||
mod registration_reward;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod runtime_browse_history;
|
||||
|
||||
@@ -39,6 +39,14 @@ pub async fn password_entry(
|
||||
state.password_entry_service().execute(input).await
|
||||
}
|
||||
.map_err(map_password_entry_error)?;
|
||||
if result.created {
|
||||
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
||||
&state,
|
||||
&request_context,
|
||||
&result.user.id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||
state
|
||||
@@ -80,7 +88,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
"field": "password",
|
||||
})),
|
||||
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("陶泥号格式不正确")
|
||||
.with_message("百梦号格式不正确")
|
||||
.with_details(json!({
|
||||
"field": "phone",
|
||||
})),
|
||||
|
||||
@@ -149,6 +149,14 @@ pub async fn phone_login(
|
||||
}
|
||||
};
|
||||
let created = result.created;
|
||||
if created {
|
||||
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
||||
&state,
|
||||
&request_context,
|
||||
&result.user.id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let referral = if created {
|
||||
bind_referral_invite_code_on_registration(
|
||||
&state,
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::creation_agent_anchor_templates::{
|
||||
};
|
||||
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
|
||||
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||||
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||||
|
||||
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
/// 拼图共创 Agent 的系统提示词。
|
||||
///
|
||||
/// 这里作为拼图聊天提示词主源,业务文件只负责调用 LLM、解析结果和写回状态。
|
||||
pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。
|
||||
pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创拼图画面的中文创意策划。
|
||||
|
||||
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ pub(crate) const PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS: usize = 500;
|
||||
const PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS: usize = 40;
|
||||
const PUZZLE_IMAGE_PROMPT_FALLBACK: &str = "清晰、有辨识度的拼图画面";
|
||||
|
||||
/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。
|
||||
/// 根据拼图关卡名和百梦主输入构造最终发给图片模型的提示词。
|
||||
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
let level_name =
|
||||
truncate_puzzle_prompt_segment(level_name.trim(), PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS);
|
||||
|
||||
@@ -1396,9 +1396,13 @@ pub async fn use_puzzle_runtime_prop(
|
||||
));
|
||||
}
|
||||
};
|
||||
let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time");
|
||||
let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros());
|
||||
let reducer_owner_user_id = owner_user_id.clone();
|
||||
let run = execute_billable_asset_operation(
|
||||
let reducer_run_id = run_id.clone();
|
||||
let fallback_run_id = run_id.clone();
|
||||
let fallback_owner_user_id = owner_user_id.clone();
|
||||
let run_result = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
billing_asset_kind,
|
||||
@@ -1407,7 +1411,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
state
|
||||
.spacetime_client()
|
||||
.use_puzzle_runtime_prop(PuzzleRunPropRecordInput {
|
||||
run_id,
|
||||
run_id: reducer_run_id,
|
||||
owner_user_id: reducer_owner_user_id,
|
||||
prop_kind,
|
||||
used_at_micros: current_utc_micros(),
|
||||
@@ -1417,8 +1421,30 @@ pub async fn use_puzzle_runtime_prop(
|
||||
.map_err(map_puzzle_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
|
||||
.await;
|
||||
|
||||
let run = match run_result {
|
||||
Ok(run) => run,
|
||||
Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => {
|
||||
// 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。
|
||||
// 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。
|
||||
state
|
||||
.spacetime_client()
|
||||
.get_puzzle_run(fallback_run_id, fallback_owner_user_id)
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error)
|
||||
})?
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_RUNTIME_PROVIDER,
|
||||
error,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -2503,6 +2529,10 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool {
|
||||
is_freeze_time && error.body_text().contains("操作不合法")
|
||||
}
|
||||
|
||||
fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
|
||||
matches!(error, SpacetimeClientError::Procedure(message) if
|
||||
message.contains("save_puzzle_form_draft")
|
||||
@@ -3580,6 +3610,26 @@ mod tests {
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||
let invalid_operation =
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "操作不合法",
|
||||
}));
|
||||
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "光点余额不足",
|
||||
}));
|
||||
|
||||
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
|
||||
assert!(!should_sync_puzzle_freeze_boundary(
|
||||
&invalid_operation,
|
||||
false
|
||||
));
|
||||
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
|
||||
}
|
||||
}
|
||||
|
||||
struct PuzzleDashScopeSettings {
|
||||
|
||||
30
server-rs/crates/api-server/src/registration_reward.rs
Normal file
30
server-rs/crates/api-server/src/registration_reward.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
#[cfg(not(test))]
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{request_context::RequestContext, state::AppState};
|
||||
|
||||
pub async fn grant_new_user_registration_wallet_reward(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: &str,
|
||||
) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = (state, request_context, user_id);
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.grant_new_user_registration_wallet_reward(user_id.to_string())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
user_id = user_id,
|
||||
error = %error,
|
||||
"新用户注册光点赠送失败,注册流程继续"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use shared_contracts::runtime::{
|
||||
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_NEW_USER_REGISTRATION_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
||||
@@ -27,8 +28,9 @@ use shared_contracts::runtime::{
|
||||
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
|
||||
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
|
||||
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
||||
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
||||
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
||||
ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse,
|
||||
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -109,6 +111,9 @@ fn format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
|
||||
}
|
||||
@@ -216,14 +221,27 @@ 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> {
|
||||
Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"),
|
||||
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),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -500,6 +518,16 @@ fn build_profile_referral_invite_center_response(
|
||||
today_inviter_reward_count: record.today_inviter_reward_count,
|
||||
today_inviter_reward_remaining: record.today_inviter_reward_remaining,
|
||||
reward_points: record.reward_points,
|
||||
invited_users: record
|
||||
.invited_users
|
||||
.into_iter()
|
||||
.map(|user| ProfileReferralInvitedUserResponse {
|
||||
user_id: user.user_id,
|
||||
display_name: user.display_name,
|
||||
avatar_url: user.avatar_url,
|
||||
bound_at: user.bound_at,
|
||||
})
|
||||
.collect(),
|
||||
has_redeemed_code: record.has_redeemed_code,
|
||||
bound_inviter_user_id: record.bound_inviter_user_id,
|
||||
bound_at: record.bound_at,
|
||||
@@ -507,6 +535,18 @@ fn build_profile_referral_invite_center_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_redeem_profile_referral_invite_code_response(
|
||||
record: module_runtime::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 {
|
||||
@@ -603,6 +643,7 @@ mod tests {
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -610,6 +651,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn profile_wallet_ledger_source_type_formats_backend_values() {
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward
|
||||
),
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
|
||||
);
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
|
||||
@@ -759,7 +806,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_referral_redeem_code_rejects_authenticated_manual_fill() {
|
||||
async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
@@ -777,7 +824,7 @@ mod tests {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -787,8 +834,8 @@ mod tests {
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
Value::String("邀请码仅注册时填写".to_string())
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -900,7 +947,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build");
|
||||
state
|
||||
.seed_test_phone_user_with_password("13800138104", "secret123")
|
||||
.await
|
||||
@@ -908,6 +955,13 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
fn fast_spacetime_timeout_config() -> AppConfig {
|
||||
AppConfig {
|
||||
spacetime_procedure_timeout: Duration::from_secs(1),
|
||||
..AppConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
|
||||
@@ -187,6 +187,14 @@ pub async fn bind_wechat_phone(
|
||||
)
|
||||
.await
|
||||
.map_err(map_wechat_bind_phone_error)?;
|
||||
if result.activated_new_user {
|
||||
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
||||
&state,
|
||||
&request_context,
|
||||
&result.user.id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
|
||||
Reference in New Issue
Block a user