@@ -34,8 +34,8 @@ use crate::{
|
||||
auth_sessions::auth_sessions,
|
||||
big_fish::{
|
||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message,
|
||||
submit_big_fish_message,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play,
|
||||
stream_big_fish_message, submit_big_fish_message,
|
||||
},
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
@@ -101,9 +101,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_referral_invite_code, redeem_profile_reward_code,
|
||||
},
|
||||
runtime_save::{
|
||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||
@@ -151,6 +152,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/redeem-codes",
|
||||
post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/redeem-codes/disable",
|
||||
post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/healthz",
|
||||
get(|Extension(request_context): Extension<_>| async move {
|
||||
@@ -626,6 +641,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/big-fish/sessions/{session_id}/play",
|
||||
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/big-fish/works/{session_id}/play",
|
||||
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/agent/sessions",
|
||||
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
@@ -906,6 +935,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/redeem-codes/redeem",
|
||||
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/redeem-codes/redeem",
|
||||
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/play-stats",
|
||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||
@@ -1419,6 +1462,36 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() {
|
||||
let config = AppConfig {
|
||||
dev_password_entry_auto_register_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let first_response =
|
||||
password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await;
|
||||
let first_status = first_response.status();
|
||||
let first_body = first_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first response body should collect")
|
||||
.to_bytes();
|
||||
let first_payload: Value =
|
||||
serde_json::from_slice(&first_body).expect("first response body should be valid json");
|
||||
let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await;
|
||||
|
||||
assert_eq!(first_status, StatusCode::OK);
|
||||
assert!(first_payload["token"].as_str().is_some());
|
||||
assert_eq!(
|
||||
first_payload["user"]["loginMethod"],
|
||||
Value::String("password".to_string())
|
||||
);
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::future::Future;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -6,15 +8,36 @@ use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
|
||||
|
||||
/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。
|
||||
pub(crate) async fn execute_billable_asset_operation<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
operation: Fut,
|
||||
) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?;
|
||||
match operation.await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) => {
|
||||
refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||
pub(crate) async fn consume_asset_operation_points(
|
||||
async fn consume_asset_operation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let ledger_id = format!(
|
||||
"asset_generation_consume:{}:{}:{}",
|
||||
"asset_operation_consume:{}:{}:{}",
|
||||
owner_user_id, asset_kind, asset_id
|
||||
);
|
||||
state
|
||||
@@ -31,14 +54,14 @@ pub(crate) async fn consume_asset_operation_points(
|
||||
}
|
||||
|
||||
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
|
||||
pub(crate) async fn refund_asset_operation_points(
|
||||
async fn refund_asset_operation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
) {
|
||||
let ledger_id = format!(
|
||||
"asset_generation_refund:{}:{}:{}",
|
||||
"asset_operation_refund:{}:{}:{}",
|
||||
owner_user_id, asset_kind, asset_id
|
||||
);
|
||||
if let Err(error) = state
|
||||
|
||||
@@ -24,7 +24,8 @@ use shared_contracts::big_fish::{
|
||||
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
|
||||
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
|
||||
BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
|
||||
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, SendBigFishMessageRequest,
|
||||
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest,
|
||||
SendBigFishMessageRequest,
|
||||
};
|
||||
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
@@ -32,9 +33,9 @@ use spacetime_client::{
|
||||
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
|
||||
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
|
||||
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
|
||||
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord,
|
||||
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
|
||||
SpacetimeClientError,
|
||||
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput,
|
||||
BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
|
||||
BigFishWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -53,7 +54,7 @@ use crate::{
|
||||
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
|
||||
},
|
||||
api_response::json_success_body,
|
||||
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
character_visual_assets::try_apply_background_alpha_to_png,
|
||||
http_error::AppError,
|
||||
@@ -208,6 +209,48 @@ pub async fn delete_big_fish_work(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn record_big_fish_play(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<RecordBigFishPlayRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
big_fish_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.record_big_fish_play(BigFishPlayReportRecordInput {
|
||||
session_id,
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
elapsed_ms: payload.elapsed_ms.unwrap_or(0),
|
||||
reported_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
BigFishWorksResponse {
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(map_big_fish_work_summary_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_big_fish_message(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -498,177 +541,115 @@ pub async fn execute_big_fish_action(
|
||||
_ => None,
|
||||
};
|
||||
let billing_asset_id = format!("{session_id}:{now}");
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id)
|
||||
.await
|
||||
.map_err(|error| big_fish_error_response(&request_context, error))?;
|
||||
}
|
||||
|
||||
let session_result = match action.as_str() {
|
||||
"big_fish_compile_draft" => {
|
||||
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
}
|
||||
"big_fish_generate_level_main_image" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_main_image",
|
||||
payload.level,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
big_fish_error_response(&request_context, error)
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_main_image".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
}
|
||||
"big_fish_generate_level_motion" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_motion",
|
||||
payload.level,
|
||||
payload.motion_key.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
big_fish_error_response(&request_context, error)
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_motion".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: payload.motion_key,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
}
|
||||
"big_fish_generate_stage_background" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"stage_background",
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
big_fish_error_response(&request_context, error)
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "stage_background".to_string(),
|
||||
level: None,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
}
|
||||
"big_fish_publish_game" => {
|
||||
state
|
||||
let session_operation = async {
|
||||
match action.as_str() {
|
||||
"big_fish_compile_draft" => {
|
||||
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_generate_level_main_image" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_main_image",
|
||||
payload.level,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_main_image".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_generate_level_motion" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_motion",
|
||||
payload.level,
|
||||
payload.motion_key.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_motion".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: payload.motion_key,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_generate_stage_background" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"stage_background",
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "stage_background".to_string(),
|
||||
level: None,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_publish_game" => state
|
||||
.spacetime_client()
|
||||
.publish_big_fish_game(session_id, owner_user_id.clone(), now)
|
||||
.await
|
||||
}
|
||||
other => {
|
||||
return Err(big_fish_bad_request(
|
||||
&request_context,
|
||||
format!("action `{other}` is not supported").as_str(),
|
||||
));
|
||||
.map_err(map_big_fish_client_error),
|
||||
other => Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": format!("action `{other}` is not supported"),
|
||||
})),
|
||||
),
|
||||
}
|
||||
};
|
||||
let session = match session_result {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(big_fish_error_response(
|
||||
&request_context,
|
||||
map_big_fish_client_error(error),
|
||||
));
|
||||
}
|
||||
let session_result = if let Some(asset_kind) = billed_asset_kind {
|
||||
execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
session_operation,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
session_operation.await
|
||||
};
|
||||
let session =
|
||||
session_result.map_err(|error| big_fish_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -930,6 +911,7 @@ fn map_big_fish_work_summary_response(
|
||||
level_main_image_ready_count: item.level_main_image_ready_count,
|
||||
level_motion_ready_count: item.level_motion_ready_count,
|
||||
background_ready: item.background_ready,
|
||||
play_count: item.play_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct AppConfig {
|
||||
pub refresh_cookie_same_site: String,
|
||||
pub refresh_session_ttl_days: u32,
|
||||
pub auth_store_path: PathBuf,
|
||||
pub dev_password_entry_auto_register_enabled: bool,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub sms_auth_provider: String,
|
||||
pub sms_endpoint: String,
|
||||
@@ -118,6 +119,7 @@ impl Default for AppConfig {
|
||||
refresh_cookie_same_site: "Lax".to_string(),
|
||||
refresh_session_ttl_days: 30,
|
||||
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
|
||||
dev_password_entry_auto_register_enabled: false,
|
||||
sms_auth_enabled: false,
|
||||
sms_auth_provider: "mock".to_string(),
|
||||
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
|
||||
@@ -273,6 +275,11 @@ impl AppConfig {
|
||||
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
|
||||
config.auth_store_path = PathBuf::from(auth_store_path);
|
||||
}
|
||||
if let Some(enabled) =
|
||||
read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"])
|
||||
{
|
||||
config.dev_password_entry_auto_register_enabled = enabled;
|
||||
}
|
||||
|
||||
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
|
||||
config.sms_auth_enabled = sms_auth_enabled;
|
||||
|
||||
@@ -53,7 +53,7 @@ use crate::{
|
||||
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
|
||||
},
|
||||
api_response::json_success_body,
|
||||
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
character_visual_assets::generate_character_primary_visual_for_profile,
|
||||
custom_world_agent_entities::generate_custom_world_agent_entities,
|
||||
@@ -599,37 +599,31 @@ pub async fn publish_custom_world_library_profile(
|
||||
));
|
||||
}
|
||||
|
||||
consume_asset_operation_points(&state, &owner_user_id, "custom_world_publish", &profile_id)
|
||||
.await
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
|
||||
let mutation_result = state
|
||||
.spacetime_client()
|
||||
.publish_custom_world_profile(
|
||||
profile_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
None,
|
||||
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
|
||||
resolve_author_display_name(&state, &authenticated),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await;
|
||||
let mutation = match mutation_result {
|
||||
Ok(mutation) => mutation,
|
||||
Err(error) => {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_publish",
|
||||
&profile_id,
|
||||
)
|
||||
.await;
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
map_custom_world_client_error(error),
|
||||
));
|
||||
}
|
||||
};
|
||||
let author_public_user_code =
|
||||
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let mutation = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_publish",
|
||||
&profile_id,
|
||||
async {
|
||||
state
|
||||
.spacetime_client()
|
||||
.publish_custom_world_profile(
|
||||
profile_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
None,
|
||||
author_public_user_code,
|
||||
author_display_name,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_custom_world_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -1525,46 +1519,33 @@ pub async fn execute_custom_world_agent_action(
|
||||
};
|
||||
|
||||
let should_bill_publish = action == "publish_world";
|
||||
if should_bill_publish {
|
||||
consume_asset_operation_points(
|
||||
let operation_future = async {
|
||||
state
|
||||
.spacetime_client()
|
||||
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
operation_id: build_prefixed_uuid_id("operation-"),
|
||||
action: action.clone(),
|
||||
payload_json: Some(payload_json),
|
||||
submitted_at_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_custom_world_client_error)
|
||||
};
|
||||
let result = if should_bill_publish {
|
||||
execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_agent_publish",
|
||||
&session_id,
|
||||
operation_future,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
}
|
||||
|
||||
let result = match state
|
||||
.spacetime_client()
|
||||
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
operation_id: build_prefixed_uuid_id("operation-"),
|
||||
action: action.clone(),
|
||||
payload_json: Some(payload_json),
|
||||
submitted_at_micros,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
if should_bill_publish {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_agent_publish",
|
||||
&session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
map_custom_world_client_error(error),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
operation_future.await
|
||||
};
|
||||
let result = result.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
|
||||
if matches!(
|
||||
action.as_str(),
|
||||
|
||||
@@ -28,6 +28,7 @@ use webp::Encoder as WebpEncoder;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
custom_world_result_prompts::{
|
||||
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
||||
@@ -441,126 +442,111 @@ pub async fn generate_custom_world_scene_image(
|
||||
let normalized = normalize_scene_image_request(payload)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
||||
crate::asset_billing::consume_asset_operation_points(
|
||||
let asset = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"scene_image",
|
||||
asset_id.as_str(),
|
||||
async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_image =
|
||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||
Some(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
reference_image_src,
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let generated = if let Some(reference_image) = reference_image.as_deref() {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
normalized.size.as_str(),
|
||||
&[reference_image.to_string()],
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
"创建参考图场景编辑任务失败",
|
||||
"参考图场景编辑未返回图片地址",
|
||||
"scene-edit",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_scene_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
normalized.size.as_str(),
|
||||
"创建场景图片生成任务失败",
|
||||
"查询场景图片任务失败",
|
||||
"场景图片生成任务失败",
|
||||
"场景图片生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let scene_model = if reference_image.is_some() {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_scene_image_model.clone()
|
||||
};
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载生成图片失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("scene.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "scene_image",
|
||||
entity_kind: "custom_world_landmark",
|
||||
entity_id: normalized.entity_id.clone(),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: "scene_image",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(scene_model),
|
||||
size: Some(normalized.size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(normalized.prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_result = async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_image =
|
||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||
Some(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
reference_image_src,
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let generated = if let Some(reference_image) = reference_image.as_deref() {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
normalized.size.as_str(),
|
||||
&[reference_image.to_string()],
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
"创建参考图场景编辑任务失败",
|
||||
"参考图场景编辑未返回图片地址",
|
||||
"scene-edit",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_scene_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
normalized.size.as_str(),
|
||||
"创建场景图片生成任务失败",
|
||||
"查询场景图片任务失败",
|
||||
"场景图片生成任务失败",
|
||||
"场景图片生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let scene_model = if reference_image.is_some() {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_scene_image_model.clone()
|
||||
};
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载生成图片失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("scene.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "scene_image",
|
||||
entity_kind: "custom_world_landmark",
|
||||
entity_id: normalized.entity_id.clone(),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: "scene_image",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(scene_model),
|
||||
size: Some(normalized.size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(normalized.prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
let asset = match asset_result {
|
||||
Ok(asset) => asset,
|
||||
Err(error) => {
|
||||
crate::asset_billing::refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"scene_image",
|
||||
&asset_id,
|
||||
)
|
||||
.await;
|
||||
return Err(custom_world_ai_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
@@ -717,127 +703,112 @@ pub async fn generate_custom_world_cover_image(
|
||||
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
|
||||
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
|
||||
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
||||
crate::asset_billing::consume_asset_operation_points(
|
||||
let asset = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_cover",
|
||||
asset_id.as_str(),
|
||||
async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_sources = collect_cover_reference_image_sources(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.reference_image_src.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let prompt = build_custom_world_cover_image_prompt(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.user_prompt.as_deref().unwrap_or_default(),
|
||||
!reference_sources.is_empty(),
|
||||
);
|
||||
let mut reference_images = Vec::with_capacity(reference_sources.len());
|
||||
for source in &reference_sources {
|
||||
reference_images.push(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
source.as_str(),
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
let generated = if reference_images.is_empty() {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_cover_image_model.clone().as_str(),
|
||||
prompt.as_str(),
|
||||
None,
|
||||
size.as_str(),
|
||||
"创建作品封面生成任务失败",
|
||||
"查询作品封面任务失败",
|
||||
"作品封面生成任务失败",
|
||||
"作品封面生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
prompt.as_str(),
|
||||
size.as_str(),
|
||||
&reference_images,
|
||||
None,
|
||||
"创建参考图封面任务失败",
|
||||
"封面生成未返回图片地址",
|
||||
"cover-edit",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载作品封面失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(entity_id.as_str(), "world"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("cover.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "custom_world_cover",
|
||||
entity_kind: "custom_world_profile",
|
||||
entity_id,
|
||||
profile_id,
|
||||
slot: "cover",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(if reference_images.is_empty() {
|
||||
state.config.dashscope_cover_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
}),
|
||||
size: Some(size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_result = async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_sources = collect_cover_reference_image_sources(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.reference_image_src.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let prompt = build_custom_world_cover_image_prompt(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.user_prompt.as_deref().unwrap_or_default(),
|
||||
!reference_sources.is_empty(),
|
||||
);
|
||||
let mut reference_images = Vec::with_capacity(reference_sources.len());
|
||||
for source in &reference_sources {
|
||||
reference_images.push(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
source.as_str(),
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
let generated = if reference_images.is_empty() {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_cover_image_model.clone().as_str(),
|
||||
prompt.as_str(),
|
||||
None,
|
||||
size.as_str(),
|
||||
"创建作品封面生成任务失败",
|
||||
"查询作品封面任务失败",
|
||||
"作品封面生成任务失败",
|
||||
"作品封面生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
prompt.as_str(),
|
||||
size.as_str(),
|
||||
&reference_images,
|
||||
None,
|
||||
"创建参考图封面任务失败",
|
||||
"封面生成未返回图片地址",
|
||||
"cover-edit",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载作品封面失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(entity_id.as_str(), "world"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("cover.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "custom_world_cover",
|
||||
entity_kind: "custom_world_profile",
|
||||
entity_id,
|
||||
profile_id,
|
||||
slot: "cover",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(if reference_images.is_empty() {
|
||||
state.config.dashscope_cover_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
}),
|
||||
size: Some(size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
let asset = match asset_result {
|
||||
Ok(asset) => asset,
|
||||
Err(error) => {
|
||||
crate::asset_billing::refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_cover",
|
||||
&asset_id,
|
||||
)
|
||||
.await;
|
||||
return Err(custom_world_ai_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
|
||||
@@ -26,14 +26,19 @@ pub async fn password_entry(
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PasswordEntryRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
.password_entry_service()
|
||||
.execute(PasswordEntryInput {
|
||||
phone_number: payload.phone,
|
||||
password: payload.password,
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_entry_error)?;
|
||||
let input = PasswordEntryInput {
|
||||
phone_number: payload.phone,
|
||||
password: payload.password,
|
||||
};
|
||||
let result = if state.config.dev_password_entry_auto_register_enabled {
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute_with_dev_registration(input)
|
||||
.await
|
||||
} else {
|
||||
state.password_entry_service().execute(input).await
|
||||
}
|
||||
.map_err(map_password_entry_error)?;
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||
state
|
||||
|
||||
@@ -67,7 +67,7 @@ use tokio::time::sleep;
|
||||
use crate::{
|
||||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||||
api_response::json_success_body,
|
||||
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
@@ -442,29 +442,29 @@ pub async fn execute_puzzle_agent_action(
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let now = current_utc_micros();
|
||||
let action = payload.action.trim().to_string();
|
||||
let billed_asset_kind = match action.as_str() {
|
||||
"compile_puzzle_draft" => Some("puzzle_initial_image"),
|
||||
"generate_puzzle_images" => Some("puzzle_generated_image"),
|
||||
_ => None,
|
||||
};
|
||||
let billing_asset_id = format!("{session_id}:{now}");
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id)
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
})?;
|
||||
}
|
||||
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let session = compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"完整拼图草稿",
|
||||
@@ -473,75 +473,76 @@ pub async fn execute_puzzle_agent_action(
|
||||
)
|
||||
}
|
||||
"generate_puzzle_images" => {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await;
|
||||
let session = match session {
|
||||
Ok(session) => {
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&billing_asset_id,
|
||||
async {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string())
|
||||
});
|
||||
match draft {
|
||||
Ok(draft) => {
|
||||
let prompt = payload
|
||||
.prompt_text
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = draft.candidates.len();
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
candidate_count,
|
||||
candidate_start_index,
|
||||
)
|
||||
.await
|
||||
.map_err(SpacetimeClientError::Runtime);
|
||||
match candidates {
|
||||
Ok(candidates) => {
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(to_puzzle_generated_image_candidate)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
SpacetimeClientError::Runtime(format!(
|
||||
"拼图候选图序列化失败:{error}"
|
||||
))
|
||||
});
|
||||
match candidates_json {
|
||||
Ok(candidates_json) => {
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(
|
||||
PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let prompt = payload
|
||||
.prompt_text
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = draft.candidates.len();
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
candidate_count,
|
||||
candidate_start_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|message| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
})?;
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(to_puzzle_generated_image_candidate)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图候选图序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"generate_puzzle_images",
|
||||
"拼图图片生成",
|
||||
@@ -569,7 +570,14 @@ pub async fn execute_puzzle_agent_action(
|
||||
candidate_id,
|
||||
selected_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
});
|
||||
(
|
||||
"select_puzzle_image",
|
||||
"正式图确认",
|
||||
@@ -579,43 +587,35 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
"publish_puzzle_work" => {
|
||||
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
|
||||
consume_asset_operation_points(&state, &owner_user_id, "puzzle_publish_work", &work_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
})?;
|
||||
let profile_result = state
|
||||
.spacetime_client()
|
||||
.publish_puzzle_work(PuzzlePublishRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
// 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。
|
||||
work_id: work_id.clone(),
|
||||
profile_id,
|
||||
author_display_name: resolve_author_display_name(&state, &authenticated),
|
||||
level_name: payload.level_name.clone(),
|
||||
summary: payload.summary.clone(),
|
||||
theme_tags: payload.theme_tags.clone(),
|
||||
published_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
let profile = match profile_result {
|
||||
Ok(profile) => profile,
|
||||
Err(error) => {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_publish_work",
|
||||
&work_id,
|
||||
)
|
||||
.await;
|
||||
return Err(puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
));
|
||||
}
|
||||
};
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let profile = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_publish_work",
|
||||
&work_id,
|
||||
async {
|
||||
state
|
||||
.spacetime_client()
|
||||
.publish_puzzle_work(PuzzlePublishRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
// 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。
|
||||
work_id: work_id.clone(),
|
||||
profile_id,
|
||||
author_display_name,
|
||||
level_name: payload.level_name.clone(),
|
||||
summary: payload.summary.clone(),
|
||||
theme_tags: payload.theme_tags.clone(),
|
||||
published_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
})?;
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
@@ -654,29 +654,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
};
|
||||
|
||||
let session = session.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let session = session?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
|
||||
@@ -7,30 +7,36 @@ use axum::{
|
||||
use module_runtime::{
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeReferralRedeemRecord,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
|
||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
|
||||
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, ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
||||
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
||||
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
||||
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
||||
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
|
||||
http_error::AppError, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
@@ -112,11 +118,14 @@ fn format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +237,99 @@ pub async fn redeem_profile_referral_invite_code(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn redeem_profile_reward_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RedeemProfileRewardCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.redeem_profile_reward_code(user_id, payload.code, redeemed_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_reward_code_response(record),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_upsert_profile_redeem_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertProfileRedeemCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
||||
)
|
||||
})?;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.admin_upsert_profile_redeem_code(
|
||||
admin.session().subject.clone(),
|
||||
payload.code,
|
||||
mode,
|
||||
payload.reward_points,
|
||||
payload.max_uses,
|
||||
payload.enabled,
|
||||
payload.allowed_user_ids,
|
||||
payload.allowed_public_user_codes,
|
||||
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_redeem_code_admin_response(record),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_disable_profile_redeem_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminDisableProfileRedeemCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.admin_disable_profile_redeem_code(
|
||||
admin.session().subject.clone(),
|
||||
payload.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_profile_redeem_code_admin_response(record),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_profile_play_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -396,6 +498,49 @@ fn build_redeem_profile_referral_invite_code_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_redeem_profile_reward_code_response(
|
||||
record: RuntimeProfileRewardCodeRedeemRecord,
|
||||
) -> RedeemProfileRewardCodeResponse {
|
||||
RedeemProfileRewardCodeResponse {
|
||||
wallet_balance: record.wallet_balance,
|
||||
amount_granted: record.amount_granted,
|
||||
ledger_entry: ProfileWalletLedgerEntryResponse {
|
||||
id: record.ledger_entry.wallet_ledger_id,
|
||||
amount_delta: record.ledger_entry.amount_delta,
|
||||
balance_after: record.ledger_entry.balance_after,
|
||||
source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type)
|
||||
.to_string(),
|
||||
created_at: record.ledger_entry.created_at,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
|
||||
"unique" => Ok(RuntimeProfileRedeemCodeMode::Unique),
|
||||
"private" => Ok(RuntimeProfileRedeemCodeMode::Private),
|
||||
_ => Err("兑换码类型无效".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_admin_response(
|
||||
record: RuntimeProfileRedeemCodeRecord,
|
||||
) -> ProfileRedeemCodeAdminResponse {
|
||||
ProfileRedeemCodeAdminResponse {
|
||||
code: record.code,
|
||||
mode: record.mode.as_str().to_string(),
|
||||
reward_points: record.reward_points,
|
||||
max_uses: record.max_uses,
|
||||
global_used_count: record.global_used_count,
|
||||
enabled: record.enabled,
|
||||
allowed_user_ids: record.allowed_user_ids,
|
||||
created_by: record.created_by,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||
@@ -417,18 +562,18 @@ mod tests {
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[test]
|
||||
fn profile_wallet_ledger_source_type_formats_asset_generation_values() {
|
||||
fn profile_wallet_ledger_source_type_formats_asset_operation_values() {
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
|
||||
),
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
||||
);
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
|
||||
),
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -486,6 +486,38 @@ impl PasswordEntryService {
|
||||
verify_stored_password_user(existing_user, &input.password).await
|
||||
}
|
||||
|
||||
pub async fn execute_with_dev_registration(
|
||||
&self,
|
||||
input: PasswordEntryInput,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
validate_password(&input.password)?;
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
|
||||
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
|
||||
if let Some(existing_user) = self
|
||||
.store
|
||||
.find_by_phone_number_for_password(&normalized_phone.e164)?
|
||||
{
|
||||
return verify_stored_password_user(existing_user, &input.password).await;
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
let user = self.store.create_dev_password_phone_user(
|
||||
normalized_phone.clone(),
|
||||
normalized_phone.masked_national_number,
|
||||
password_hash,
|
||||
)?;
|
||||
|
||||
Ok(PasswordEntryResult {
|
||||
user: AuthUser {
|
||||
login_method: AuthLoginMethod::Password,
|
||||
..user
|
||||
},
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -1336,6 +1368,53 @@ impl InMemoryAuthStore {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn create_dev_password_phone_user(
|
||||
&self,
|
||||
phone_number: PhoneNumberSnapshot,
|
||||
display_name: String,
|
||||
password_hash: String,
|
||||
) -> Result<AuthUser, PasswordEntryError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
if state.phone_to_user_id.contains_key(&phone_number.e164) {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("phone", state.next_user_id);
|
||||
let user = AuthUser {
|
||||
id: user_id.clone(),
|
||||
public_user_code,
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
phone_number_masked: Some(phone_number.masked_national_number.clone()),
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
token_version: 1,
|
||||
};
|
||||
state
|
||||
.phone_to_user_id
|
||||
.insert(phone_number.e164.clone(), user_id);
|
||||
state.users_by_username.insert(
|
||||
username,
|
||||
StoredPasswordUser {
|
||||
user: user.clone(),
|
||||
password_hash,
|
||||
password_login_enabled: true,
|
||||
phone_number: Some(phone_number.e164),
|
||||
},
|
||||
);
|
||||
self.persist_password_state(&state)?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn create_pending_wechat_user(
|
||||
&self,
|
||||
profile: WechatIdentityProfile,
|
||||
@@ -2474,6 +2553,39 @@ mod tests {
|
||||
assert_eq!(error, PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_dev_registration_creates_unknown_phone_user() {
|
||||
let service = build_password_service(build_store());
|
||||
|
||||
let created = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138009".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("dev registration should create user");
|
||||
let reused = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138009".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("same password should reuse created user");
|
||||
let wrong_password = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138009".to_string(),
|
||||
password: "secret999".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("existing user still requires the right password");
|
||||
|
||||
assert!(created.created);
|
||||
assert_eq!(created.user.login_method, AuthLoginMethod::Password);
|
||||
assert!(!reused.created);
|
||||
assert_eq!(created.user.id, reused.user.id);
|
||||
assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phone_user_can_set_password_then_login() {
|
||||
let store = build_store();
|
||||
|
||||
@@ -225,6 +225,7 @@ pub struct BigFishWorkSummarySnapshot {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -321,6 +322,15 @@ pub struct BigFishPublishInput {
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishPlayRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub played_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BigFishFieldError {
|
||||
MissingSessionId,
|
||||
@@ -659,6 +669,16 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.session_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(anchor_pack)
|
||||
}
|
||||
|
||||
@@ -259,8 +259,17 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
InviteInviterReward,
|
||||
InviteInviteeReward,
|
||||
PointsRecharge,
|
||||
AssetGenerationConsume,
|
||||
AssetGenerationRefund,
|
||||
AssetOperationConsume,
|
||||
AssetOperationRefund,
|
||||
RedeemCodeReward,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeProfileRedeemCodeMode {
|
||||
Public,
|
||||
Unique,
|
||||
Private,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput {
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemInput {
|
||||
pub user_id: String,
|
||||
pub code: String,
|
||||
pub redeemed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRewardCodeRedeemSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub allowed_public_user_codes: Vec<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRedeemCodeSnapshot {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: 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 RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRedeemCodeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeReferralInviteCenterSnapshot {
|
||||
@@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError {
|
||||
MissingLedgerId,
|
||||
InvalidWalletAmount,
|
||||
MissingInviteCode,
|
||||
MissingRedeemCode,
|
||||
InvalidRedeemCodeReward,
|
||||
InvalidRedeemCodeMaxUses,
|
||||
MissingProductId,
|
||||
MissingWorldKey,
|
||||
MissingBottomTab,
|
||||
@@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord {
|
||||
pub has_points_recharged: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemRecord {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileRedeemCodeRecord {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeReferralInviteCenterRecord {
|
||||
pub user_id: String,
|
||||
@@ -970,6 +1074,73 @@ pub fn build_runtime_referral_redeem_input(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_reward_code_redeem_input(
|
||||
user_id: String,
|
||||
code: String,
|
||||
redeemed_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
|
||||
Ok(RuntimeProfileRewardCodeRedeemInput {
|
||||
user_id,
|
||||
code,
|
||||
redeemed_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_admin_upsert_input(
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
mode: RuntimeProfileRedeemCodeMode,
|
||||
reward_points: u64,
|
||||
max_uses: u32,
|
||||
enabled: bool,
|
||||
allowed_user_ids: Vec<String>,
|
||||
allowed_public_user_codes: Vec<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
|
||||
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
||||
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
|
||||
if reward_points == 0 {
|
||||
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
|
||||
}
|
||||
if max_uses == 0 {
|
||||
return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses);
|
||||
}
|
||||
|
||||
Ok(RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
admin_user_id,
|
||||
code,
|
||||
mode,
|
||||
reward_points,
|
||||
max_uses,
|
||||
enabled,
|
||||
allowed_user_ids: allowed_user_ids
|
||||
.into_iter()
|
||||
.filter_map(|value| normalize_optional_string(Some(value)))
|
||||
.collect(),
|
||||
allowed_public_user_codes: allowed_public_user_codes
|
||||
.into_iter()
|
||||
.filter_map(|value| normalize_optional_string(Some(value)))
|
||||
.collect(),
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_admin_disable_input(
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeAdminDisableInput, RuntimeProfileFieldError> {
|
||||
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
||||
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
|
||||
Ok(RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
admin_user_id,
|
||||
code,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
||||
@@ -1323,6 +1494,35 @@ pub fn build_runtime_referral_redeem_record(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_reward_code_redeem_record(
|
||||
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
|
||||
) -> RuntimeProfileRewardCodeRedeemRecord {
|
||||
RuntimeProfileRewardCodeRedeemRecord {
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
amount_granted: snapshot.amount_granted,
|
||||
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_record(
|
||||
snapshot: RuntimeProfileRedeemCodeSnapshot,
|
||||
) -> RuntimeProfileRedeemCodeRecord {
|
||||
RuntimeProfileRedeemCodeRecord {
|
||||
code: snapshot.code,
|
||||
mode: snapshot.mode,
|
||||
reward_points: snapshot.reward_points,
|
||||
max_uses: snapshot.max_uses,
|
||||
global_used_count: snapshot.global_used_count,
|
||||
enabled: snapshot.enabled,
|
||||
allowed_user_ids: snapshot.allowed_user_ids,
|
||||
created_by: snapshot.created_by,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at: format_utc_micros(snapshot.updated_at_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_record(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> RuntimeProfilePlayedWorldRecord {
|
||||
@@ -1506,8 +1706,19 @@ impl RuntimeProfileWalletLedgerSourceType {
|
||||
Self::InviteInviterReward => "invite_inviter_reward",
|
||||
Self::InviteInviteeReward => "invite_invitee_reward",
|
||||
Self::PointsRecharge => "points_recharge",
|
||||
Self::AssetGenerationConsume => "asset_generation_consume",
|
||||
Self::AssetGenerationRefund => "asset_generation_refund",
|
||||
Self::AssetOperationConsume => "asset_operation_consume",
|
||||
Self::AssetOperationRefund => "asset_operation_refund",
|
||||
Self::RedeemCodeReward => "redeem_code_reward",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeProfileRedeemCodeMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Public => "public",
|
||||
Self::Unique => "unique",
|
||||
Self::Private => "private",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1736,6 +1947,10 @@ pub fn normalize_invite_code(value: String) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_redeem_code(value: String) -> Option<String> {
|
||||
normalize_invite_code(value)
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
||||
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
||||
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
||||
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
||||
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
|
||||
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
|
||||
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
|
||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
||||
@@ -2008,12 +2226,12 @@ mod tests {
|
||||
"points_recharge"
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(),
|
||||
"asset_generation_consume"
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(),
|
||||
"asset_operation_consume"
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(),
|
||||
"asset_generation_refund"
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
|
||||
"asset_operation_refund"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ pub struct ExecuteBigFishActionRequest {
|
||||
pub motion_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordBigFishPlayRequest {
|
||||
#[serde(default)]
|
||||
pub elapsed_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BigFishAnchorItemResponse {
|
||||
@@ -193,4 +200,14 @@ mod tests {
|
||||
assert_eq!(payload["motionKey"], json!("move_swim"));
|
||||
assert_eq!(payload["level"], json!(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_big_fish_play_request_uses_camel_case() {
|
||||
let payload = serde_json::to_value(RecordBigFishPlayRequest {
|
||||
elapsed_ms: Some(12_345),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload, json!({ "elapsedMs": 12_345 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
#[serde(default)]
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -7,10 +7,10 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str =
|
||||
"asset_generation_consume";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
|
||||
"asset_generation_refund";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str =
|
||||
"asset_operation_consume";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
|
||||
@@ -267,6 +267,60 @@ pub struct RedeemProfileReferralInviteCodeResponse {
|
||||
pub inviter_balance_after: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedeemProfileRewardCodeRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedeemProfileRewardCodeResponse {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: ProfileWalletLedgerEntryResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertProfileRedeemCodeRequest {
|
||||
pub code: String,
|
||||
pub mode: String,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_public_user_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminDisableProfileRedeemCodeRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileRedeemCodeAdminResponse {
|
||||
pub code: String,
|
||||
pub mode: String,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfilePlayedWorkSummaryResponse {
|
||||
@@ -828,7 +882,7 @@ mod tests {
|
||||
id: "ledger-5".to_string(),
|
||||
amount_delta: -1,
|
||||
balance_after: 199,
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
||||
.to_string(),
|
||||
created_at: "2026-04-22T10:04:00Z".to_string(),
|
||||
},
|
||||
@@ -836,7 +890,7 @@ mod tests {
|
||||
id: "ledger-6".to_string(),
|
||||
amount_delta: 1,
|
||||
balance_after: 200,
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
||||
.to_string(),
|
||||
created_at: "2026-04-22T10:05:00Z".to_string(),
|
||||
},
|
||||
@@ -864,11 +918,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
payload["entries"][4]["sourceType"],
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME)
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["entries"][5]["sourceType"],
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND)
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["entries"][0]["createdAt"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::mapper::*;
|
||||
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work;
|
||||
use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play;
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn create_big_fish_session(
|
||||
@@ -265,4 +266,28 @@ impl SpacetimeClient {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_big_fish_play(
|
||||
&self,
|
||||
input: BigFishPlayReportRecordInput,
|
||||
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
|
||||
let procedure_input = BigFishPlayRecordInput {
|
||||
session_id: input.session_id,
|
||||
user_id: input.user_id,
|
||||
elapsed_ms: input.elapsed_ms,
|
||||
played_at_micros: input.reported_at_micros,
|
||||
};
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.record_big_fish_play_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(|result| map_big_fish_works_procedure_result(result, None));
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ pub use mapper::{
|
||||
BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput,
|
||||
BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput,
|
||||
BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput,
|
||||
BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput,
|
||||
BigFishSessionRecord, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord,
|
||||
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
|
||||
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
||||
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord,
|
||||
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
|
||||
CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput,
|
||||
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput,
|
||||
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
|
||||
CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
|
||||
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
@@ -120,6 +121,8 @@ use module_runtime::{
|
||||
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
|
||||
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
|
||||
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
|
||||
@@ -129,8 +132,12 @@ use module_runtime::{
|
||||
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input,
|
||||
build_runtime_profile_recharge_center_record,
|
||||
build_runtime_profile_recharge_order_create_input,
|
||||
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
|
||||
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input,
|
||||
build_runtime_profile_redeem_code_admin_disable_input,
|
||||
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
|
||||
build_runtime_profile_reward_code_redeem_input,
|
||||
build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input,
|
||||
build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input,
|
||||
build_runtime_profile_wallet_adjustment_input,
|
||||
build_runtime_profile_wallet_ledger_entry_record,
|
||||
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
|
||||
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,
|
||||
|
||||
@@ -161,6 +161,48 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRewardCodeRedeemInput>
|
||||
for RuntimeProfileRewardCodeRedeemInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self {
|
||||
Self {
|
||||
user_id: input.user_id,
|
||||
code: input.code,
|
||||
redeemed_at_micros: input.redeemed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
|
||||
for RuntimeProfileRedeemCodeAdminUpsertInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self {
|
||||
Self {
|
||||
admin_user_id: input.admin_user_id,
|
||||
code: input.code,
|
||||
mode: map_runtime_profile_redeem_code_mode(input.mode),
|
||||
reward_points: input.reward_points,
|
||||
max_uses: input.max_uses,
|
||||
enabled: input.enabled,
|
||||
allowed_user_ids: input.allowed_user_ids,
|
||||
allowed_public_user_codes: input.allowed_public_user_codes,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
|
||||
for RuntimeProfileRedeemCodeAdminDisableInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self {
|
||||
Self {
|
||||
admin_user_id: input.admin_user_id,
|
||||
code: input.code,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
|
||||
for RuntimeReferralInviteCenterGetInput
|
||||
{
|
||||
@@ -802,6 +844,48 @@ pub(crate) fn map_runtime_referral_redeem_procedure_result(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
|
||||
result: RuntimeProfileRewardCodeRedeemProcedureResult,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemRecord, 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 未返回 reward redeem 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(build_runtime_profile_reward_code_redeem_record(
|
||||
map_runtime_profile_reward_code_redeem_snapshot(snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
|
||||
result: RuntimeProfileRedeemCodeAdminProcedureResult,
|
||||
) -> Result<RuntimeProfileRedeemCodeRecord, 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 未返回 redeem code 快照".to_string())
|
||||
})?;
|
||||
|
||||
Ok(build_runtime_profile_redeem_code_record(
|
||||
map_runtime_profile_redeem_code_snapshot(snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_play_stats_procedure_result(
|
||||
result: RuntimeProfilePlayStatsProcedureResult,
|
||||
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
|
||||
@@ -1673,6 +1757,33 @@ pub(crate) fn map_runtime_referral_redeem_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot(
|
||||
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
|
||||
) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
amount_granted: snapshot.amount_granted,
|
||||
ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_snapshot(
|
||||
snapshot: RuntimeProfileRedeemCodeSnapshot,
|
||||
) -> module_runtime::RuntimeProfileRedeemCodeSnapshot {
|
||||
module_runtime::RuntimeProfileRedeemCodeSnapshot {
|
||||
code: snapshot.code,
|
||||
mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode),
|
||||
reward_points: snapshot.reward_points,
|
||||
max_uses: snapshot.max_uses,
|
||||
global_used_count: snapshot.global_used_count,
|
||||
enabled: snapshot.enabled,
|
||||
allowed_user_ids: snapshot.allowed_user_ids,
|
||||
created_by: snapshot.created_by,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_played_world_snapshot(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
|
||||
@@ -3282,11 +3393,46 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_mode(
|
||||
value: module_runtime::RuntimeProfileRedeemCodeMode,
|
||||
) -> crate::module_bindings::RuntimeProfileRedeemCodeMode {
|
||||
match value {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Public => {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public
|
||||
}
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Unique => {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique
|
||||
}
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Private => {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_mode_back(
|
||||
value: crate::module_bindings::RuntimeProfileRedeemCodeMode,
|
||||
) -> module_runtime::RuntimeProfileRedeemCodeMode {
|
||||
match value {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Public
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Unique
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Private
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4222,6 +4368,14 @@ pub struct PuzzleRunNextLevelRecordInput {
|
||||
pub advanced_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BigFishPlayReportRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub reported_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleAnchorItemRecord {
|
||||
pub key: String,
|
||||
@@ -4622,6 +4776,7 @@ pub struct BigFishWorkSummaryRecord {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
|
||||
@@ -4641,6 +4796,8 @@ struct CompatibleBigFishWorkSummaryRecord {
|
||||
level_main_image_ready_count: u32,
|
||||
level_motion_ready_count: u32,
|
||||
background_ready: bool,
|
||||
#[serde(default)]
|
||||
play_count: u32,
|
||||
}
|
||||
|
||||
impl CompatibleBigFishWorkSummaryRecord {
|
||||
@@ -4665,6 +4822,7 @@ impl CompatibleBigFishWorkSummaryRecord {
|
||||
level_main_image_ready_count: self.level_main_image_ready_count,
|
||||
level_motion_ready_count: self.level_motion_ready_count,
|
||||
background_ready: self.background_ready,
|
||||
play_count: self.play_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
|
||||
use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct AdminDisableProfileRedeemCodeArgs {
|
||||
pub input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AdminDisableProfileRedeemCodeArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `admin_disable_profile_redeem_code`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait admin_disable_profile_redeem_code {
|
||||
fn admin_disable_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminDisableInput) {
|
||||
self.admin_disable_profile_redeem_code_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn admin_disable_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl admin_disable_profile_redeem_code for super::RemoteProcedures {
|
||||
fn admin_disable_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
"admin_disable_profile_redeem_code",
|
||||
AdminDisableProfileRedeemCodeArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
|
||||
use super::runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct AdminUpsertProfileRedeemCodeArgs {
|
||||
pub input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AdminUpsertProfileRedeemCodeArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `admin_upsert_profile_redeem_code`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait admin_upsert_profile_redeem_code {
|
||||
fn admin_upsert_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminUpsertInput) {
|
||||
self.admin_upsert_profile_redeem_code_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn admin_upsert_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl admin_upsert_profile_redeem_code for super::RemoteProcedures {
|
||||
fn admin_upsert_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
"admin_upsert_profile_redeem_code",
|
||||
AdminUpsertProfileRedeemCodeArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub struct BigFishCreationSession {
|
||||
pub asset_coverage_json: String,
|
||||
pub last_assistant_reply: Option<String>,
|
||||
pub publish_ready: bool,
|
||||
pub play_count: u32,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
@@ -43,6 +44,7 @@ pub struct BigFishCreationSessionCols {
|
||||
pub asset_coverage_json: __sdk::__query_builder::Col<BigFishCreationSession, String>,
|
||||
pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>,
|
||||
pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>,
|
||||
pub play_count: __sdk::__query_builder::Col<BigFishCreationSession, u32>,
|
||||
pub created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||
}
|
||||
@@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession {
|
||||
"last_assistant_reply",
|
||||
),
|
||||
publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"),
|
||||
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
}
|
||||
|
||||
@@ -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 BigFishPlayRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub played_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for BigFishPlayRecordInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
pub mod accept_quest_reducer;
|
||||
pub mod acknowledge_quest_completion_reducer;
|
||||
pub mod admin_disable_profile_redeem_code_procedure;
|
||||
pub mod admin_upsert_profile_redeem_code_procedure;
|
||||
pub mod advance_puzzle_next_level_procedure;
|
||||
pub mod ai_result_reference_input_type;
|
||||
pub mod ai_result_reference_kind_type;
|
||||
@@ -89,6 +91,7 @@ pub mod big_fish_game_draft_type;
|
||||
pub mod big_fish_level_blueprint_type;
|
||||
pub mod big_fish_message_finalize_input_type;
|
||||
pub mod big_fish_message_submit_input_type;
|
||||
pub mod big_fish_play_record_input_type;
|
||||
pub mod big_fish_publish_input_type;
|
||||
pub mod big_fish_runtime_params_type;
|
||||
pub mod big_fish_session_create_input_type;
|
||||
@@ -277,6 +280,8 @@ pub mod profile_invite_code_type;
|
||||
pub mod profile_membership_type;
|
||||
pub mod profile_played_world_type;
|
||||
pub mod profile_recharge_order_type;
|
||||
pub mod profile_redeem_code_type;
|
||||
pub mod profile_redeem_code_usage_type;
|
||||
pub mod profile_referral_relation_type;
|
||||
pub mod profile_save_archive_type;
|
||||
pub mod profile_wallet_ledger_type;
|
||||
@@ -343,7 +348,9 @@ pub mod quest_status_type;
|
||||
pub mod quest_step_snapshot_type;
|
||||
pub mod quest_treasure_inspected_signal_type;
|
||||
pub mod quest_turn_in_input_type;
|
||||
pub mod record_big_fish_play_procedure;
|
||||
pub mod redeem_profile_referral_invite_code_procedure;
|
||||
pub mod redeem_profile_reward_code_procedure;
|
||||
pub mod refresh_session_type;
|
||||
pub mod refund_profile_wallet_points_and_return_procedure;
|
||||
pub mod resolve_combat_action_and_return_procedure;
|
||||
@@ -403,6 +410,14 @@ pub mod runtime_profile_recharge_order_snapshot_type;
|
||||
pub mod runtime_profile_recharge_order_status_type;
|
||||
pub mod runtime_profile_recharge_product_kind_type;
|
||||
pub mod runtime_profile_recharge_product_snapshot_type;
|
||||
pub mod runtime_profile_redeem_code_admin_disable_input_type;
|
||||
pub mod runtime_profile_redeem_code_admin_procedure_result_type;
|
||||
pub mod runtime_profile_redeem_code_admin_upsert_input_type;
|
||||
pub mod runtime_profile_redeem_code_mode_type;
|
||||
pub mod runtime_profile_redeem_code_snapshot_type;
|
||||
pub mod runtime_profile_reward_code_redeem_input_type;
|
||||
pub mod runtime_profile_reward_code_redeem_procedure_result_type;
|
||||
pub mod runtime_profile_reward_code_redeem_snapshot_type;
|
||||
pub mod runtime_profile_save_archive_list_input_type;
|
||||
pub mod runtime_profile_save_archive_procedure_result_type;
|
||||
pub mod runtime_profile_save_archive_resume_input_type;
|
||||
@@ -477,6 +492,8 @@ pub mod user_browse_history_type;
|
||||
|
||||
pub use accept_quest_reducer::accept_quest;
|
||||
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
|
||||
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
|
||||
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
|
||||
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
|
||||
pub use ai_result_reference_input_type::AiResultReferenceInput;
|
||||
pub use ai_result_reference_kind_type::AiResultReferenceKind;
|
||||
@@ -558,6 +575,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft;
|
||||
pub use big_fish_level_blueprint_type::BigFishLevelBlueprint;
|
||||
pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput;
|
||||
pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput;
|
||||
pub use big_fish_play_record_input_type::BigFishPlayRecordInput;
|
||||
pub use big_fish_publish_input_type::BigFishPublishInput;
|
||||
pub use big_fish_runtime_params_type::BigFishRuntimeParams;
|
||||
pub use big_fish_session_create_input_type::BigFishSessionCreateInput;
|
||||
@@ -746,6 +764,8 @@ pub use profile_invite_code_type::ProfileInviteCode;
|
||||
pub use profile_membership_type::ProfileMembership;
|
||||
pub use profile_played_world_type::ProfilePlayedWorld;
|
||||
pub use profile_recharge_order_type::ProfileRechargeOrder;
|
||||
pub use profile_redeem_code_type::ProfileRedeemCode;
|
||||
pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage;
|
||||
pub use profile_referral_relation_type::ProfileReferralRelation;
|
||||
pub use profile_save_archive_type::ProfileSaveArchive;
|
||||
pub use profile_wallet_ledger_type::ProfileWalletLedger;
|
||||
@@ -812,7 +832,9 @@ pub use quest_status_type::QuestStatus;
|
||||
pub use quest_step_snapshot_type::QuestStepSnapshot;
|
||||
pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal;
|
||||
pub use quest_turn_in_input_type::QuestTurnInInput;
|
||||
pub use record_big_fish_play_procedure::record_big_fish_play;
|
||||
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
|
||||
pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code;
|
||||
pub use refresh_session_type::RefreshSession;
|
||||
pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return;
|
||||
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
|
||||
@@ -872,6 +894,14 @@ pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrde
|
||||
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
||||
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
|
||||
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
|
||||
pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
|
||||
pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
|
||||
pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
|
||||
pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
|
||||
pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput;
|
||||
pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult;
|
||||
pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot;
|
||||
pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveListInput;
|
||||
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
|
||||
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct ProfileRedeemCode {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProfileRedeemCode {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `ProfileRedeemCode`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct ProfileRedeemCodeCols {
|
||||
pub code: __sdk::__query_builder::Col<ProfileRedeemCode, String>,
|
||||
pub mode: __sdk::__query_builder::Col<ProfileRedeemCode, RuntimeProfileRedeemCodeMode>,
|
||||
pub reward_points: __sdk::__query_builder::Col<ProfileRedeemCode, u64>,
|
||||
pub max_uses: __sdk::__query_builder::Col<ProfileRedeemCode, u32>,
|
||||
pub global_used_count: __sdk::__query_builder::Col<ProfileRedeemCode, u32>,
|
||||
pub enabled: __sdk::__query_builder::Col<ProfileRedeemCode, bool>,
|
||||
pub allowed_user_ids: __sdk::__query_builder::Col<ProfileRedeemCode, Vec<String>>,
|
||||
pub created_by: __sdk::__query_builder::Col<ProfileRedeemCode, String>,
|
||||
pub created_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ProfileRedeemCode {
|
||||
type Cols = ProfileRedeemCodeCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
ProfileRedeemCodeCols {
|
||||
code: __sdk::__query_builder::Col::new(table_name, "code"),
|
||||
mode: __sdk::__query_builder::Col::new(table_name, "mode"),
|
||||
reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"),
|
||||
max_uses: __sdk::__query_builder::Col::new(table_name, "max_uses"),
|
||||
global_used_count: __sdk::__query_builder::Col::new(table_name, "global_used_count"),
|
||||
enabled: __sdk::__query_builder::Col::new(table_name, "enabled"),
|
||||
allowed_user_ids: __sdk::__query_builder::Col::new(table_name, "allowed_user_ids"),
|
||||
created_by: __sdk::__query_builder::Col::new(table_name, "created_by"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `ProfileRedeemCode`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct ProfileRedeemCodeIxCols {
|
||||
pub code: __sdk::__query_builder::IxCol<ProfileRedeemCode, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for ProfileRedeemCode {
|
||||
type IxCols = ProfileRedeemCodeIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
ProfileRedeemCodeIxCols {
|
||||
code: __sdk::__query_builder::IxCol::new(table_name, "code"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCode {}
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 ProfileRedeemCodeUsage {
|
||||
pub usage_id: String,
|
||||
pub code: String,
|
||||
pub user_id: String,
|
||||
pub amount_granted: u64,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProfileRedeemCodeUsage {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `ProfileRedeemCodeUsage`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct ProfileRedeemCodeUsageCols {
|
||||
pub usage_id: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
|
||||
pub code: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
|
||||
pub user_id: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
|
||||
pub amount_granted: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, u64>,
|
||||
pub created_at: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ProfileRedeemCodeUsage {
|
||||
type Cols = ProfileRedeemCodeUsageCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
ProfileRedeemCodeUsageCols {
|
||||
usage_id: __sdk::__query_builder::Col::new(table_name, "usage_id"),
|
||||
code: __sdk::__query_builder::Col::new(table_name, "code"),
|
||||
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
|
||||
amount_granted: __sdk::__query_builder::Col::new(table_name, "amount_granted"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `ProfileRedeemCodeUsage`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct ProfileRedeemCodeUsageIxCols {
|
||||
pub code: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
|
||||
pub usage_id: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
|
||||
pub user_id: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for ProfileRedeemCodeUsage {
|
||||
type IxCols = ProfileRedeemCodeUsageIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
ProfileRedeemCodeUsageIxCols {
|
||||
code: __sdk::__query_builder::IxCol::new(table_name, "code"),
|
||||
usage_id: __sdk::__query_builder::IxCol::new(table_name, "usage_id"),
|
||||
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCodeUsage {}
|
||||
@@ -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::big_fish_play_record_input_type::BigFishPlayRecordInput;
|
||||
use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct RecordBigFishPlayArgs {
|
||||
pub input: BigFishPlayRecordInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RecordBigFishPlayArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `record_big_fish_play`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait record_big_fish_play {
|
||||
fn record_big_fish_play(&self, input: BigFishPlayRecordInput) {
|
||||
self.record_big_fish_play_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn record_big_fish_play_then(
|
||||
&self,
|
||||
input: BigFishPlayRecordInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl record_big_fish_play for super::RemoteProcedures {
|
||||
fn record_big_fish_play_then(
|
||||
&self,
|
||||
input: BigFishPlayRecordInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>(
|
||||
"record_big_fish_play",
|
||||
RecordBigFishPlayArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput;
|
||||
use super::runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct RedeemProfileRewardCodeArgs {
|
||||
pub input: RuntimeProfileRewardCodeRedeemInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RedeemProfileRewardCodeArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `redeem_profile_reward_code`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait redeem_profile_reward_code {
|
||||
fn redeem_profile_reward_code(&self, input: RuntimeProfileRewardCodeRedeemInput) {
|
||||
self.redeem_profile_reward_code_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn redeem_profile_reward_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl redeem_profile_reward_code for super::RemoteProcedures {
|
||||
fn redeem_profile_reward_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>(
|
||||
"redeem_profile_reward_code",
|
||||
RedeemProfileRewardCodeArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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 RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
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};
|
||||
|
||||
use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRedeemCodeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub allowed_public_user_codes: Vec<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
pub enum RuntimeProfileRedeemCodeMode {
|
||||
Public,
|
||||
|
||||
Unique,
|
||||
|
||||
Private,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeMode {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRedeemCodeSnapshot {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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 RuntimeProfileRewardCodeRedeemInput {
|
||||
pub user_id: String,
|
||||
pub code: String,
|
||||
pub redeemed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemInput {
|
||||
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};
|
||||
|
||||
use super::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRewardCodeRedeemSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
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};
|
||||
|
||||
use super::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -16,9 +16,11 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
|
||||
PointsRecharge,
|
||||
|
||||
AssetGenerationConsume,
|
||||
AssetOperationConsume,
|
||||
|
||||
AssetGenerationRefund,
|
||||
AssetOperationRefund,
|
||||
|
||||
RedeemCodeReward,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
||||
|
||||
@@ -255,6 +255,97 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn redeem_profile_reward_code(
|
||||
&self,
|
||||
user_id: String,
|
||||
code: String,
|
||||
redeemed_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemRecord, SpacetimeClientError> {
|
||||
let procedure_input =
|
||||
build_runtime_profile_reward_code_redeem_input(user_id, code, redeemed_at_micros)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection.procedures().redeem_profile_reward_code_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_runtime_profile_reward_code_redeem_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_upsert_profile_redeem_code(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
mode: DomainRuntimeProfileRedeemCodeMode,
|
||||
reward_points: u64,
|
||||
max_uses: u32,
|
||||
enabled: bool,
|
||||
allowed_user_ids: Vec<String>,
|
||||
allowed_public_user_codes: Vec<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
|
||||
let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input(
|
||||
admin_user_id,
|
||||
code,
|
||||
mode,
|
||||
reward_points,
|
||||
max_uses,
|
||||
enabled,
|
||||
allowed_user_ids,
|
||||
allowed_public_user_codes,
|
||||
updated_at_micros,
|
||||
)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_runtime_profile_redeem_code_admin_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_disable_profile_redeem_code(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
|
||||
let procedure_input = build_runtime_profile_redeem_code_admin_disable_input(
|
||||
admin_user_id,
|
||||
code,
|
||||
updated_at_micros,
|
||||
)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_disable_profile_redeem_code_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_runtime_profile_redeem_code_admin_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_profile_play_stats(
|
||||
&self,
|
||||
user_id: String,
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(reply.clone()),
|
||||
publish_ready: coverage.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at,
|
||||
};
|
||||
@@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
|
||||
publish_ready: true,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: published_at,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
|
||||
};
|
||||
use crate::*;
|
||||
|
||||
const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0;
|
||||
@@ -93,6 +96,32 @@ pub fn delete_big_fish_work(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn record_big_fish_play(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: BigFishPlayRecordInput,
|
||||
) -> BigFishWorksProcedureResult {
|
||||
match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) {
|
||||
Ok(items) => match serde_json::to_string(&items) {
|
||||
Ok(items_json) => BigFishWorksProcedureResult {
|
||||
ok: true,
|
||||
items_json: Some(items_json),
|
||||
error_message: None,
|
||||
},
|
||||
Err(error) => BigFishWorksProcedureResult {
|
||||
ok: false,
|
||||
items_json: None,
|
||||
error_message: Some(error.to_string()),
|
||||
},
|
||||
},
|
||||
Err(message) => BigFishWorksProcedureResult {
|
||||
ok: false,
|
||||
items_json: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn submit_big_fish_message(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -194,6 +223,7 @@ pub(crate) fn create_big_fish_session_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(input.welcome_message_text.clone()),
|
||||
publish_ready: false,
|
||||
play_count: 0,
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
});
|
||||
@@ -383,6 +413,7 @@ pub(crate) fn submit_big_fish_message_tx(
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||
publish_ready: session.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: submitted_at,
|
||||
};
|
||||
@@ -429,6 +460,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||
publish_ready: session.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at,
|
||||
};
|
||||
@@ -483,6 +515,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: Some(assistant_reply_text),
|
||||
publish_ready: session.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at,
|
||||
};
|
||||
@@ -536,6 +569,7 @@ pub(crate) fn compile_big_fish_draft_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(reply.clone()),
|
||||
publish_ready: coverage.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: compiled_at,
|
||||
};
|
||||
@@ -550,6 +584,92 @@ pub(crate) fn compile_big_fish_draft_tx(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn record_big_fish_play_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishPlayRecordInput,
|
||||
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
|
||||
validate_play_record_input(&input).map_err(|error| error.to_string())?;
|
||||
let session = ctx
|
||||
.db
|
||||
.big_fish_creation_session()
|
||||
.session_id()
|
||||
.find(&input.session_id)
|
||||
.filter(|row| row.stage == BigFishCreationStage::Published)
|
||||
.ok_or_else(|| "big_fish 已发布作品不存在".to_string())?;
|
||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||
let draft = session
|
||||
.draft_json
|
||||
.as_deref()
|
||||
.map(deserialize_draft)
|
||||
.transpose()
|
||||
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
|
||||
let title = draft
|
||||
.as_ref()
|
||||
.map(|value| value.title.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "大鱼吃小鱼".to_string());
|
||||
let subtitle = draft
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
let subtitle = value.subtitle.trim();
|
||||
if subtitle.is_empty() {
|
||||
let core_fun = value.core_fun.trim();
|
||||
(!core_fun.is_empty()).then(|| core_fun.to_string())
|
||||
} else {
|
||||
Some(subtitle.to_string())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let world_key = format!("big-fish:{}", session.session_id);
|
||||
|
||||
upsert_profile_played_work(
|
||||
ctx,
|
||||
ProfilePlayedWorkUpsertInput {
|
||||
user_id: input.user_id.clone(),
|
||||
world_key: world_key.clone(),
|
||||
owner_user_id: Some(session.owner_user_id.clone()),
|
||||
profile_id: Some(session.session_id.clone()),
|
||||
world_type: Some("BIG_FISH".to_string()),
|
||||
world_title: title,
|
||||
world_subtitle: subtitle,
|
||||
played_at_micros: input.played_at_micros,
|
||||
},
|
||||
)?;
|
||||
add_profile_observed_play_time(
|
||||
ctx,
|
||||
&input.user_id,
|
||||
&world_key,
|
||||
input.elapsed_ms,
|
||||
input.played_at_micros,
|
||||
)?;
|
||||
let next_session = BigFishCreationSession {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
seed_text: session.seed_text.clone(),
|
||||
current_turn: session.current_turn,
|
||||
progress_percent: session.progress_percent,
|
||||
stage: session.stage,
|
||||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||||
draft_json: session.draft_json.clone(),
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||
publish_ready: session.publish_ready,
|
||||
// 中文注释:正式进入已发布作品时同时累加作品播放数,用户侧去重由 profile_played_world 保证。
|
||||
play_count: session.play_count.saturating_add(1),
|
||||
created_at: session.created_at,
|
||||
updated_at: played_at,
|
||||
};
|
||||
replace_big_fish_session(ctx, &session, next_session);
|
||||
|
||||
list_big_fish_works_tx(
|
||||
ctx,
|
||||
BigFishWorksListInput {
|
||||
owner_user_id: String::new(),
|
||||
published_only: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_big_fish_session_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
row: &BigFishCreationSession,
|
||||
@@ -663,6 +783,7 @@ pub(crate) fn build_big_fish_work_summary(
|
||||
level_main_image_ready_count: coverage.level_main_image_ready_count,
|
||||
level_motion_ready_count: coverage.level_motion_ready_count,
|
||||
background_ready: coverage.background_ready,
|
||||
play_count: row.play_count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -699,6 +820,7 @@ mod tests {
|
||||
asset_coverage_json: "{}".to_string(),
|
||||
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
|
||||
publish_ready: false,
|
||||
play_count: 0,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct BigFishCreationSession {
|
||||
pub(crate) asset_coverage_json: String,
|
||||
pub(crate) last_assistant_reply: Option<String>,
|
||||
pub(crate) publish_ready: bool,
|
||||
pub(crate) play_count: u32,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ macro_rules! migration_tables {
|
||||
user_browse_history,
|
||||
profile_dashboard_state,
|
||||
profile_wallet_ledger,
|
||||
profile_redeem_code,
|
||||
profile_redeem_code_usage,
|
||||
profile_invite_code,
|
||||
profile_referral_relation,
|
||||
profile_played_world,
|
||||
@@ -659,6 +661,19 @@ where
|
||||
Ok(wrapped.0)
|
||||
}
|
||||
|
||||
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
|
||||
let mut next_value = value.clone();
|
||||
if table_name == "big_fish_creation_session" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
||||
object
|
||||
.entry("play_count".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(0));
|
||||
}
|
||||
}
|
||||
next_value
|
||||
}
|
||||
|
||||
fn insert_migration_table_rows(
|
||||
ctx: &ReducerContext,
|
||||
table: &MigrationTable,
|
||||
@@ -672,7 +687,8 @@ fn insert_migration_table_rows(
|
||||
let mut imported = 0u64;
|
||||
let mut skipped = 0u64;
|
||||
for value in &table.rows {
|
||||
let row = row_from_json(value)
|
||||
let normalized_value = normalize_migration_row(stringify!($table), value);
|
||||
let row = row_from_json(&normalized_value)
|
||||
.map_err(|error| format!("{}: {error}", stringify!($table)))?;
|
||||
let insert_result = ctx.db
|
||||
.$table()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
|
||||
};
|
||||
use module_puzzle::{
|
||||
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
@@ -1072,6 +1075,12 @@ fn start_puzzle_run_tx(
|
||||
.map(|value| value.profile_id.clone());
|
||||
|
||||
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros);
|
||||
upsert_puzzle_profile_played_work(
|
||||
ctx,
|
||||
&input.owner_user_id,
|
||||
&entry_profile_row,
|
||||
input.started_at_micros,
|
||||
)?;
|
||||
insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?;
|
||||
Ok(run)
|
||||
}
|
||||
@@ -1179,6 +1188,12 @@ fn advance_puzzle_next_level_tx(
|
||||
.find(&next_profile.profile_id)
|
||||
{
|
||||
increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros);
|
||||
upsert_puzzle_profile_played_work(
|
||||
ctx,
|
||||
&input.owner_user_id,
|
||||
&next_profile_row,
|
||||
input.advanced_at_micros,
|
||||
)?;
|
||||
}
|
||||
replace_puzzle_runtime_run(ctx, &row, &next_run, input.advanced_at_micros);
|
||||
Ok(next_run)
|
||||
@@ -1219,6 +1234,13 @@ fn submit_puzzle_leaderboard_entry_tx(
|
||||
&input.run_id,
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
add_profile_observed_play_time(
|
||||
ctx,
|
||||
&input.owner_user_id,
|
||||
&format!("puzzle:{}", input.profile_id),
|
||||
input.elapsed_ms.max(1_000),
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
|
||||
let leaderboard_entries = list_puzzle_leaderboard_entries(
|
||||
ctx,
|
||||
@@ -1607,6 +1629,28 @@ fn increment_puzzle_profile_play_count(
|
||||
);
|
||||
}
|
||||
|
||||
fn upsert_puzzle_profile_played_work(
|
||||
ctx: &TxContext,
|
||||
user_id: &str,
|
||||
row: &PuzzleWorkProfileRow,
|
||||
played_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
// 拼图正式游玩以作品 profile_id 作为公开作品号,用户侧明细按 world_key 去重。
|
||||
upsert_profile_played_work(
|
||||
ctx,
|
||||
ProfilePlayedWorkUpsertInput {
|
||||
user_id: user_id.to_string(),
|
||||
world_key: format!("puzzle:{}", row.profile_id),
|
||||
owner_user_id: Some(row.owner_user_id.clone()),
|
||||
profile_id: Some(row.profile_id.clone()),
|
||||
world_type: Some("PUZZLE".to_string()),
|
||||
world_title: row.level_name.clone(),
|
||||
world_subtitle: row.summary.clone(),
|
||||
played_at_micros,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn replace_generated_candidate(
|
||||
draft: &mut PuzzleResultDraft,
|
||||
candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||||
|
||||
@@ -28,6 +28,39 @@ pub struct ProfileWalletLedger {
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_redeem_code)]
|
||||
pub struct ProfileRedeemCode {
|
||||
#[primary_key]
|
||||
pub(crate) code: String,
|
||||
pub(crate) mode: RuntimeProfileRedeemCodeMode,
|
||||
pub(crate) reward_points: u64,
|
||||
pub(crate) max_uses: u32,
|
||||
pub(crate) global_used_count: u32,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) allowed_user_ids: Vec<String>,
|
||||
pub(crate) created_by: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = profile_redeem_code_usage,
|
||||
index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])),
|
||||
index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])),
|
||||
index(
|
||||
accessor = by_profile_redeem_code_usage_code_user_id,
|
||||
btree(columns = [code, user_id])
|
||||
)
|
||||
)]
|
||||
pub struct ProfileRedeemCodeUsage {
|
||||
#[primary_key]
|
||||
pub(crate) usage_id: String,
|
||||
pub(crate) code: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) amount_granted: u64,
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_invite_code)]
|
||||
pub struct ProfileInviteCode {
|
||||
#[primary_key]
|
||||
@@ -83,6 +116,17 @@ pub struct ProfilePlayedWorld {
|
||||
pub(crate) last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct ProfilePlayedWorkUpsertInput {
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) world_key: String,
|
||||
pub(crate) owner_user_id: Option<String>,
|
||||
pub(crate) profile_id: Option<String>,
|
||||
pub(crate) world_type: Option<String>,
|
||||
pub(crate) world_title: String,
|
||||
pub(crate) world_subtitle: String,
|
||||
pub(crate) played_at_micros: i64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_membership)]
|
||||
pub struct ProfileMembership {
|
||||
#[primary_key]
|
||||
@@ -248,7 +292,7 @@ pub fn consume_profile_wallet_points_and_return(
|
||||
apply_profile_wallet_adjustment(
|
||||
tx,
|
||||
input.clone(),
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume,
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
|
||||
true,
|
||||
)
|
||||
}) {
|
||||
@@ -275,7 +319,7 @@ pub fn refund_profile_wallet_points_and_return(
|
||||
apply_profile_wallet_adjustment(
|
||||
tx,
|
||||
input.clone(),
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund,
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
|
||||
false,
|
||||
)
|
||||
}) {
|
||||
@@ -396,6 +440,64 @@ pub fn redeem_profile_referral_invite_code(
|
||||
}
|
||||
}
|
||||
|
||||
// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn redeem_profile_reward_code(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
) -> RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn admin_upsert_profile_redeem_code(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn admin_disable_profile_redeem_code(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_profile_save_archive_rows(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileSaveArchiveListInput,
|
||||
@@ -498,6 +600,172 @@ pub(crate) fn sync_profile_projections_from_snapshot(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn upsert_profile_played_work(
|
||||
ctx: &ReducerContext,
|
||||
input: ProfilePlayedWorkUpsertInput,
|
||||
) -> Result<(), String> {
|
||||
let user_id = input.user_id.trim();
|
||||
let world_key = input.world_key.trim();
|
||||
if user_id.is_empty() {
|
||||
return Err("profile_played_world.user_id 不能为空".to_string());
|
||||
}
|
||||
if world_key.is_empty() {
|
||||
return Err("profile_played_world.world_key 不能为空".to_string());
|
||||
}
|
||||
|
||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||
let played_world_id = format!("{user_id}:{world_key}");
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.find(&played_world_id);
|
||||
|
||||
if let Some(existing) = existing {
|
||||
ctx.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.delete(&existing.played_world_id);
|
||||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||||
played_world_id,
|
||||
user_id: user_id.to_string(),
|
||||
world_key: world_key.to_string(),
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
world_type: input.world_type,
|
||||
world_title: input.world_title,
|
||||
world_subtitle: input.world_subtitle,
|
||||
first_played_at: existing.first_played_at,
|
||||
last_played_at: played_at,
|
||||
last_observed_play_time_ms: existing.last_observed_play_time_ms,
|
||||
});
|
||||
} else {
|
||||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||||
played_world_id,
|
||||
user_id: user_id.to_string(),
|
||||
world_key: world_key.to_string(),
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
world_type: input.world_type,
|
||||
world_title: input.world_title,
|
||||
world_subtitle: input.world_subtitle,
|
||||
first_played_at: played_at,
|
||||
last_played_at: played_at,
|
||||
last_observed_play_time_ms: 0,
|
||||
});
|
||||
}
|
||||
|
||||
ensure_profile_dashboard_state(ctx, user_id, played_at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn add_profile_observed_play_time(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
world_key: &str,
|
||||
elapsed_ms: u64,
|
||||
observed_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
let user_id = user_id.trim();
|
||||
let world_key = world_key.trim();
|
||||
if user_id.is_empty() || world_key.is_empty() || elapsed_ms == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros);
|
||||
let played_world_id = format!("{user_id}:{world_key}");
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.find(&played_world_id)
|
||||
{
|
||||
ctx.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.delete(&existing.played_world_id);
|
||||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||||
played_world_id,
|
||||
user_id: existing.user_id,
|
||||
world_key: existing.world_key,
|
||||
owner_user_id: existing.owner_user_id,
|
||||
profile_id: existing.profile_id,
|
||||
world_type: existing.world_type,
|
||||
world_title: existing.world_title,
|
||||
world_subtitle: existing.world_subtitle,
|
||||
first_played_at: existing.first_played_at,
|
||||
last_played_at: observed_at,
|
||||
last_observed_play_time_ms: existing
|
||||
.last_observed_play_time_ms
|
||||
.saturating_add(elapsed_ms),
|
||||
});
|
||||
}
|
||||
|
||||
add_profile_dashboard_play_time(ctx, user_id, elapsed_ms, observed_at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
|
||||
if ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string())
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: 0,
|
||||
total_play_time_ms: 0,
|
||||
created_at: updated_at,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_profile_dashboard_play_time(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
elapsed_ms: u64,
|
||||
updated_at: Timestamp,
|
||||
) {
|
||||
let current = ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
|
||||
if let Some(existing) = current {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.delete(&existing.user_id);
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: existing.wallet_balance,
|
||||
total_play_time_ms: existing.total_play_time_ms.saturating_add(elapsed_ms),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
});
|
||||
} else {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: 0,
|
||||
total_play_time_ms: elapsed_ms,
|
||||
created_at: updated_at,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_profile_dashboard_from_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
snapshot: &RuntimeSnapshot,
|
||||
@@ -1194,6 +1462,185 @@ fn redeem_profile_referral_invite_code_record(
|
||||
})
|
||||
}
|
||||
|
||||
fn redeem_profile_reward_code_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_reward_code_redeem_input(
|
||||
input.user_id,
|
||||
input.code,
|
||||
input.redeemed_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros);
|
||||
let user_id = validated_input.user_id;
|
||||
let code = validated_input.code;
|
||||
let redeem_code = ctx
|
||||
.db
|
||||
.profile_redeem_code()
|
||||
.code()
|
||||
.find(&code)
|
||||
.ok_or_else(|| "兑换码不存在".to_string())?;
|
||||
|
||||
if !redeem_code.enabled {
|
||||
return Err("兑换码已停用".to_string());
|
||||
}
|
||||
if redeem_code.reward_points == 0 {
|
||||
return Err("兑换码奖励无效".to_string());
|
||||
}
|
||||
|
||||
let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id);
|
||||
match redeem_code.mode {
|
||||
RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => {
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Unique
|
||||
if redeem_code.global_used_count >= redeem_code.max_uses =>
|
||||
{
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Private => {
|
||||
if !redeem_code
|
||||
.allowed_user_ids
|
||||
.iter()
|
||||
.any(|item| item == &user_id)
|
||||
{
|
||||
return Err("该兑换码不适用于当前账号".to_string());
|
||||
}
|
||||
if redeem_code.global_used_count >= redeem_code.max_uses {
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let usage_id = build_profile_redeem_code_usage_id(
|
||||
ctx,
|
||||
&code,
|
||||
&user_id,
|
||||
validated_input.redeemed_at_micros,
|
||||
);
|
||||
let wallet_ledger_id = format!("{}:ledger", usage_id);
|
||||
let wallet_balance = apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&user_id,
|
||||
redeem_code.reward_points,
|
||||
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward,
|
||||
&wallet_ledger_id,
|
||||
redeemed_at,
|
||||
)?;
|
||||
|
||||
ctx.db
|
||||
.profile_redeem_code_usage()
|
||||
.insert(ProfileRedeemCodeUsage {
|
||||
usage_id,
|
||||
code: code.clone(),
|
||||
user_id,
|
||||
amount_granted: redeem_code.reward_points,
|
||||
created_at: redeemed_at,
|
||||
});
|
||||
|
||||
let next_code = ProfileRedeemCode {
|
||||
global_used_count: redeem_code.global_used_count.saturating_add(1),
|
||||
updated_at: redeemed_at,
|
||||
..redeem_code
|
||||
};
|
||||
ctx.db.profile_redeem_code().code().delete(&code);
|
||||
ctx.db.profile_redeem_code().insert(next_code);
|
||||
|
||||
let ledger_entry = ctx
|
||||
.db
|
||||
.profile_wallet_ledger()
|
||||
.wallet_ledger_id()
|
||||
.find(&wallet_ledger_id)
|
||||
.ok_or_else(|| "兑换码钱包流水写入失败".to_string())?;
|
||||
|
||||
Ok(RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
wallet_balance,
|
||||
amount_granted: ledger_entry.amount_delta.max(0) as u64,
|
||||
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_upsert_profile_redeem_code_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_redeem_code_admin_upsert_input(
|
||||
input.admin_user_id,
|
||||
input.code,
|
||||
input.mode,
|
||||
input.reward_points,
|
||||
input.max_uses,
|
||||
input.enabled,
|
||||
input.allowed_user_ids,
|
||||
input.allowed_public_user_codes,
|
||||
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 allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?;
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_redeem_code()
|
||||
.code()
|
||||
.find(&validated_input.code);
|
||||
let created_at = existing
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
.unwrap_or(updated_at);
|
||||
let global_used_count = existing
|
||||
.as_ref()
|
||||
.map(|row| row.global_used_count)
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Some(existing) = existing {
|
||||
ctx.db.profile_redeem_code().code().delete(&existing.code);
|
||||
}
|
||||
|
||||
let row = ProfileRedeemCode {
|
||||
code: validated_input.code,
|
||||
mode: validated_input.mode,
|
||||
reward_points: validated_input.reward_points,
|
||||
max_uses: validated_input.max_uses,
|
||||
global_used_count,
|
||||
enabled: validated_input.enabled,
|
||||
allowed_user_ids,
|
||||
created_by: validated_input.admin_user_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
let inserted = ctx.db.profile_redeem_code().insert(row);
|
||||
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
|
||||
}
|
||||
|
||||
fn admin_disable_profile_redeem_code_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_redeem_code_admin_disable_input(
|
||||
input.admin_user_id,
|
||||
input.code,
|
||||
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 existing = ctx
|
||||
.db
|
||||
.profile_redeem_code()
|
||||
.code()
|
||||
.find(&validated_input.code)
|
||||
.ok_or_else(|| "兑换码不存在".to_string())?;
|
||||
|
||||
ctx.db.profile_redeem_code().code().delete(&existing.code);
|
||||
let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode {
|
||||
enabled: false,
|
||||
updated_at,
|
||||
..existing
|
||||
});
|
||||
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
|
||||
}
|
||||
|
||||
fn build_profile_referral_invite_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
@@ -1579,6 +2026,74 @@ fn latest_profile_recharge_order(
|
||||
orders.into_iter().next()
|
||||
}
|
||||
|
||||
fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 {
|
||||
ctx.db
|
||||
.profile_redeem_code_usage()
|
||||
.by_profile_redeem_code_usage_code_user_id()
|
||||
.filter((code, user_id))
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_usage_id(
|
||||
ctx: &ReducerContext,
|
||||
code: &str,
|
||||
user_id: &str,
|
||||
redeemed_at_micros: i64,
|
||||
) -> String {
|
||||
let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id);
|
||||
format!(
|
||||
"redeem:{}:{}:{}:{}",
|
||||
code, user_id, redeemed_at_micros, sequence
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_profile_redeem_code_allowed_user_ids(
|
||||
ctx: &ReducerContext,
|
||||
input: &RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if input.mode != RuntimeProfileRedeemCodeMode::Private {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut allowed_user_ids = input.allowed_user_ids.clone();
|
||||
for public_user_code in &input.allowed_public_user_codes {
|
||||
if let Some(account) = ctx
|
||||
.db
|
||||
.user_account()
|
||||
.by_user_account_public_code()
|
||||
.filter(public_user_code)
|
||||
.next()
|
||||
{
|
||||
allowed_user_ids.push(account.user_id);
|
||||
}
|
||||
}
|
||||
allowed_user_ids.sort();
|
||||
allowed_user_ids.dedup();
|
||||
|
||||
if allowed_user_ids.is_empty() {
|
||||
return Err("私有兑换码必须指定可兑换用户".to_string());
|
||||
}
|
||||
|
||||
Ok(allowed_user_ids)
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_snapshot_from_row(
|
||||
row: &ProfileRedeemCode,
|
||||
) -> RuntimeProfileRedeemCodeSnapshot {
|
||||
RuntimeProfileRedeemCodeSnapshot {
|
||||
code: row.code.clone(),
|
||||
mode: row.mode,
|
||||
reward_points: row.reward_points,
|
||||
max_uses: row.max_uses,
|
||||
global_used_count: row.global_used_count,
|
||||
enabled: row.enabled,
|
||||
allowed_user_ids: row.allowed_user_ids.clone(),
|
||||
created_by: row.created_by.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_wallet_ledger_snapshot_from_row(
|
||||
row: &ProfileWalletLedger,
|
||||
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
||||
|
||||
Reference in New Issue
Block a user