1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-28 20:52:08 +08:00
82 changed files with 3844 additions and 4125 deletions

View File

@@ -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");

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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
);
}