Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts: # .hermes/shared-memory/decision-log.md # .hermes/shared-memory/project-overview.md # docs/【开发运维】本地开发验证与生产运维-2026-05-15.md # scripts/dev.test.ts # server-rs/crates/api-server/src/creation_entry_config.rs # server-rs/crates/api-server/src/wooden_fish.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/spacetime-client/src/wooden_fish.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # src/components/custom-world-home/creationWorkShelf.ts # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/rpgEntryWorldPresentation.ts # src/services/miniGameDraftGenerationProgress.test.ts # src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
@@ -5,11 +5,14 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aes = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
axum = { workspace = true, features = ["ws"] }
|
||||
base64 = { workspace = true }
|
||||
cbc = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||
http-body-util = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
@@ -41,9 +44,11 @@ platform-image = { workspace = true }
|
||||
platform-llm = { workspace = true }
|
||||
platform-oss = { workspace = true }
|
||||
platform-speech = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
ring = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
||||
shared-kernel = { workspace = true }
|
||||
@@ -67,7 +72,6 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
http-body-util = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
|
||||
@@ -25,8 +25,11 @@ use shared_contracts::admin::{
|
||||
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
@@ -197,6 +200,7 @@ pub async fn admin_list_database_table_rows(
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
|
||||
/// 读取后台创作入口配置,包含模板入口和底部加号入口页公告。
|
||||
pub async fn admin_get_creation_entry_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -209,6 +213,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -218,6 +223,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存单个创作模板入口配置,并返回最新公告与入口快照。
|
||||
pub async fn admin_upsert_creation_entry_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -232,6 +238,38 @@ pub async fn admin_upsert_creation_entry_config(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存底部加号创作入口页的多公告表单序列化配置。
|
||||
pub async fn admin_upsert_creation_entry_event_banners_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertCreationEntryEventBannersRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let normalized_json =
|
||||
module_runtime::normalize_creation_entry_event_banners_json(&payload.event_banners_json)
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
||||
let config = state
|
||||
.upsert_creation_entry_event_banners_config(
|
||||
module_runtime::CreationEntryEventBannersAdminUpsertInput {
|
||||
event_banners_json: normalized_json,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -291,6 +329,7 @@ fn map_admin_creation_entry_type_config(
|
||||
category_label: entry.category_label,
|
||||
category_sort_order: entry.category_sort_order,
|
||||
updated_at_micros: entry.updated_at_micros,
|
||||
unified_creation_spec: entry.unified_creation_spec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +344,22 @@ fn validate_admin_creation_entry_config(
|
||||
if title.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口标题不能为空"));
|
||||
}
|
||||
let unified_creation_spec = match payload.unified_creation_spec {
|
||||
Some(spec) => {
|
||||
validate_unified_creation_spec_for_play(&id, &spec).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
|
||||
})?;
|
||||
Some(spec)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let unified_creation_spec_json = unified_creation_spec
|
||||
.as_ref()
|
||||
.map(|spec| {
|
||||
encode_unified_creation_spec_response(spec)
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(module_runtime::CreationEntryTypeAdminUpsertInput {
|
||||
id,
|
||||
title,
|
||||
@@ -317,6 +372,7 @@ fn validate_admin_creation_entry_config(
|
||||
category_id: payload.category_id.trim().to_string(),
|
||||
category_label: payload.category_label.trim().to_string(),
|
||||
category_sort_order: payload.category_sort_order,
|
||||
unified_creation_spec_json,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1481,11 +1537,7 @@ mod tests {
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
use shared_contracts::admin::{
|
||||
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
|
||||
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
};
|
||||
use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_absolute_url() {
|
||||
|
||||
@@ -41,7 +41,10 @@ use crate::{
|
||||
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
|
||||
submit_visual_novel_message, update_visual_novel_work,
|
||||
},
|
||||
wechat_pay::handle_wechat_pay_notify,
|
||||
wechat_pay::{
|
||||
handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify,
|
||||
handle_wechat_virtual_payment_notify,
|
||||
},
|
||||
};
|
||||
|
||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||
@@ -71,6 +74,11 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/profile/recharge/wechat/notify",
|
||||
post(handle_wechat_pay_notify),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/wechat/virtual-notify",
|
||||
get(handle_wechat_virtual_payment_message_push_verify)
|
||||
.post(handle_wechat_virtual_payment_notify),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/sessions/{runtime_session_id}/inventory",
|
||||
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
|
||||
@@ -511,6 +519,40 @@ mod tests {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 中文注释:后台路由测试通过真实登录流程取 token,避免绕过鉴权中间件。
|
||||
async fn read_admin_access_token(app: Router) -> String {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "root",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("admin login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("admin login request should succeed");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("admin login body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("admin login payload should be json");
|
||||
|
||||
payload["token"]
|
||||
.as_str()
|
||||
.expect("admin token should exist")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn password_login_request(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
@@ -699,7 +741,8 @@ mod tests {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/runtime/puzzle/works")
|
||||
.method("POST")
|
||||
.uri("/api/runtime/puzzle/agent/sessions")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
@@ -715,6 +758,31 @@ mod tests {
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_creation_entry_does_not_block_published_runtime_routes() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_creation_entry_route_enabled("puzzle", false);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/puzzle/runs")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_ne!(
|
||||
body["error"]["details"]["reason"],
|
||||
"creation_entry_disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -748,7 +816,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_rpg_route_returns_service_unavailable() {
|
||||
async fn disabled_rpg_creation_route_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_creation_entry_route_enabled("rpg", false);
|
||||
let app = build_router(state);
|
||||
@@ -3973,6 +4041,91 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// 中文注释:验证入口公告表单提交的 HTML 会保存进独立公告配置。
|
||||
#[tokio::test]
|
||||
async fn admin_creation_entry_banners_route_saves_html_form_payload() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/banners")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"eventBannersJson": serde_json::json!([
|
||||
{
|
||||
"title": "后台表单公告",
|
||||
"htmlCode": "<section>入口公告 HTML</section>"
|
||||
}
|
||||
]).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("banners request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("banners request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("banners body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("banners payload should be json");
|
||||
|
||||
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
|
||||
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");
|
||||
assert_eq!(
|
||||
payload["eventBanners"][0]["htmlCode"],
|
||||
"<section>入口公告 HTML</section>"
|
||||
);
|
||||
}
|
||||
|
||||
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
|
||||
#[tokio::test]
|
||||
async fn admin_creation_entry_banners_route_rejects_script_html() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/banners")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"eventBannersJson": serde_json::json!([
|
||||
{
|
||||
"title": "危险公告",
|
||||
"htmlCode": "<script>alert(1)</script>"
|
||||
}
|
||||
]).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("banners request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("banners request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -94,6 +94,12 @@ pub struct AppConfig {
|
||||
pub wechat_pay_api_v3_key: Option<String>,
|
||||
pub wechat_pay_notify_url: Option<String>,
|
||||
pub wechat_pay_jsapi_endpoint: String,
|
||||
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
|
||||
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
|
||||
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
|
||||
pub wechat_mini_program_message_token: Option<String>,
|
||||
pub wechat_mini_program_message_encoding_aes_key: Option<String>,
|
||||
pub wechat_mini_program_virtual_payment_env: u8,
|
||||
pub oss_bucket: Option<String>,
|
||||
pub oss_endpoint: Option<String>,
|
||||
pub oss_access_key_id: Option<String>,
|
||||
@@ -237,6 +243,12 @@ impl Default for AppConfig {
|
||||
wechat_pay_notify_url: None,
|
||||
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
|
||||
.to_string(),
|
||||
wechat_mini_program_virtual_payment_offer_id: None,
|
||||
wechat_mini_program_virtual_payment_app_key: None,
|
||||
wechat_mini_program_virtual_payment_sandbox_app_key: None,
|
||||
wechat_mini_program_message_token: None,
|
||||
wechat_mini_program_message_encoding_aes_key: None,
|
||||
wechat_mini_program_virtual_payment_env: 0,
|
||||
oss_bucket: None,
|
||||
oss_endpoint: None,
|
||||
oss_access_key_id: None,
|
||||
@@ -584,6 +596,21 @@ impl AppConfig {
|
||||
{
|
||||
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
|
||||
}
|
||||
config.wechat_mini_program_virtual_payment_offer_id =
|
||||
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"]);
|
||||
config.wechat_mini_program_virtual_payment_app_key =
|
||||
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]);
|
||||
config.wechat_mini_program_virtual_payment_sandbox_app_key =
|
||||
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]);
|
||||
config.wechat_mini_program_message_token =
|
||||
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]);
|
||||
config.wechat_mini_program_message_encoding_aes_key =
|
||||
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]);
|
||||
if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
|
||||
&& env <= 1
|
||||
{
|
||||
config.wechat_mini_program_virtual_payment_env = env;
|
||||
}
|
||||
|
||||
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
||||
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
||||
@@ -1373,6 +1400,12 @@ mod tests {
|
||||
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
||||
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
||||
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||
std::env::set_var("WECHAT_PAY_ENABLED", "true");
|
||||
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
|
||||
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
|
||||
@@ -1388,6 +1421,18 @@ mod tests {
|
||||
"WECHAT_PAY_NOTIFY_URL",
|
||||
"https://api.example.com/api/profile/recharge/wechat/notify",
|
||||
);
|
||||
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID", "offer-001");
|
||||
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY", "app-key-001");
|
||||
std::env::set_var(
|
||||
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY",
|
||||
"sandbox-app-key-001",
|
||||
);
|
||||
std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001");
|
||||
std::env::set_var(
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
||||
);
|
||||
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
@@ -1410,6 +1455,35 @@ mod tests {
|
||||
config.wechat_pay_platform_serial_no.as_deref(),
|
||||
Some("platform-serial-001")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.wechat_mini_program_virtual_payment_offer_id
|
||||
.as_deref(),
|
||||
Some("offer-001")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.wechat_mini_program_virtual_payment_app_key
|
||||
.as_deref(),
|
||||
Some("app-key-001")
|
||||
);
|
||||
assert_eq!(
|
||||
config.wechat_mini_program_message_token.as_deref(),
|
||||
Some("message-token-001")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
Some("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.wechat_mini_program_virtual_payment_sandbox_app_key
|
||||
.as_deref(),
|
||||
Some("sandbox-app-key-001")
|
||||
);
|
||||
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("WECHAT_PAY_ENABLED");
|
||||
@@ -1421,6 +1495,12 @@ mod tests {
|
||||
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
||||
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
||||
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
|
||||
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
|
||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ pub async fn get_creation_entry_config_handler(
|
||||
Ok(json_success_body(Some(&request_context), config))
|
||||
}
|
||||
|
||||
/// 中文注释:api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。
|
||||
/// 中文注释:api-server 路由熔断只拦新建创作入口,不限制已有作品读取、发布作品游玩或公开广场浏览。
|
||||
pub async fn require_creation_entry_route_enabled(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
@@ -72,60 +72,59 @@ pub async fn require_creation_entry_route_enabled(
|
||||
|
||||
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
let normalized = path.trim_end_matches('/');
|
||||
if normalized.starts_with("/api/runtime/puzzle-clear") {
|
||||
return Some("puzzle-clear");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/puzzle-clear") {
|
||||
return Some("puzzle-clear");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/puzzle") {
|
||||
if normalized == "/api/runtime/puzzle/agent/sessions"
|
||||
|| normalized == "/api/runtime/puzzle/onboarding/generate"
|
||||
{
|
||||
return Some("puzzle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/match3d") {
|
||||
return Some("match3d");
|
||||
if normalized.starts_with("/api/runtime/puzzle/gallery/")
|
||||
&& normalized.ends_with("/remix")
|
||||
{
|
||||
return Some("puzzle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/bark-battle") {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/bark-battle") {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/wooden-fish") {
|
||||
return Some("wooden-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/wooden-fish") {
|
||||
return Some("wooden-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/square-hole") {
|
||||
return Some("square-hole");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/jump-hop") {
|
||||
return Some("jump-hop");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/jump-hop") {
|
||||
return Some("jump-hop");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/big-fish") {
|
||||
if normalized == "/api/runtime/big-fish/agent/sessions" {
|
||||
return Some("big-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/custom-world")
|
||||
|| normalized.starts_with("/api/runtime/custom-world-library")
|
||||
|| normalized.starts_with("/api/runtime/custom-world-gallery")
|
||||
|| normalized.starts_with("/api/runtime/chat")
|
||||
|| normalized.starts_with("/api/story")
|
||||
if normalized.starts_with("/api/runtime/big-fish/gallery/")
|
||||
&& normalized.ends_with("/remix")
|
||||
{
|
||||
return Some("big-fish");
|
||||
}
|
||||
if normalized == "/api/runtime/custom-world/agent/sessions"
|
||||
|| normalized == "/api/runtime/custom-world/profile"
|
||||
{
|
||||
return Some("rpg");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/visual-novel") {
|
||||
if normalized.starts_with("/api/runtime/custom-world-gallery/")
|
||||
&& normalized.ends_with("/remix")
|
||||
{
|
||||
return Some("rpg");
|
||||
}
|
||||
if normalized == "/api/creation/match3d/sessions" {
|
||||
return Some("match3d");
|
||||
}
|
||||
if normalized == "/api/creation/square-hole/sessions" {
|
||||
return Some("square-hole");
|
||||
}
|
||||
if normalized == "/api/creation/bark-battle/drafts" {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized == "/api/creation/wooden-fish/sessions" {
|
||||
return Some("wooden-fish");
|
||||
}
|
||||
if normalized == "/api/creation/jump-hop/sessions" {
|
||||
return Some("jump-hop");
|
||||
}
|
||||
if normalized == "/api/creation/puzzle-clear/sessions" {
|
||||
return Some("puzzle-clear");
|
||||
}
|
||||
if normalized == "/api/creation/visual-novel/sessions" {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/visual-novel") {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/edutainment/baby-object-match") {
|
||||
if normalized == "/api/creation/edutainment/baby-object-match/assets" {
|
||||
return Some("baby-object-match");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") {
|
||||
if normalized == "/api/creation/edutainment/baby-love-drawing/magic" {
|
||||
return Some("baby-love-drawing");
|
||||
}
|
||||
None
|
||||
@@ -158,7 +157,10 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
|
||||
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
|
||||
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
|
||||
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
|
||||
updated_at_micros: 0,
|
||||
})
|
||||
@@ -174,9 +176,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_runtime_paths_to_creation_type_ids() {
|
||||
fn resolves_new_creation_paths_to_creation_type_ids() {
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/puzzle/works"),
|
||||
resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"),
|
||||
Some("puzzle"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -184,56 +186,66 @@ mod tests {
|
||||
Some("puzzle-clear"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
|
||||
Some("puzzle-clear"),
|
||||
resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"),
|
||||
Some("puzzle"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"),
|
||||
resolve_creation_entry_route_id("/api/creation/match3d/sessions"),
|
||||
Some("match3d"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/square-hole/runs/run-1"),
|
||||
resolve_creation_entry_route_id("/api/creation/square-hole/sessions"),
|
||||
Some("square-hole"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/visual-novel/works"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"),
|
||||
Some("big-fish"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
|
||||
resolve_creation_entry_route_id(
|
||||
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
|
||||
),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
|
||||
Some("rpg"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
|
||||
Some("rpg"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||
Some("bark-battle"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"),
|
||||
Some("bark-battle"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
|
||||
Some("wooden-fish"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
|
||||
|
||||
@@ -341,6 +341,8 @@ fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {
|
||||
prompt_chars = failure.prompt_chars,
|
||||
reference_image_count = failure.reference_image_count,
|
||||
image_model = failure.image_model,
|
||||
request_id = %failure.request_id.as_deref().unwrap_or_default(),
|
||||
error_source = %failure.error_source.as_deref().unwrap_or_default(),
|
||||
error = %failure.error_message,
|
||||
"外部 API 调用失败"
|
||||
);
|
||||
@@ -394,6 +396,10 @@ mod tests {
|
||||
)
|
||||
.with_status_code(Some(429))
|
||||
.with_retryable(true)
|
||||
.with_error_source(Some(
|
||||
"client error (SendRequest) -> connection closed before message completed"
|
||||
.to_string(),
|
||||
))
|
||||
.with_latency_ms(Some(1234))
|
||||
.with_prompt_chars(Some(88))
|
||||
.with_reference_image_count(Some(2))
|
||||
@@ -414,6 +420,10 @@ mod tests {
|
||||
assert_eq!(metadata["promptChars"], 88);
|
||||
assert_eq!(metadata["referenceImageCount"], 2);
|
||||
assert_eq!(metadata["imageModel"], "gpt-image-2-all");
|
||||
assert_eq!(
|
||||
metadata["errorSource"],
|
||||
"client error (SendRequest) -> connection closed before message completed"
|
||||
);
|
||||
assert!(matches!(metadata["occurredAt"], Value::String(_)));
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,14 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||
execute_billable_match3d_draft_generation(
|
||||
let compile_session_id = session_id.clone();
|
||||
let compile_owner_user_id = owner_user_id.clone();
|
||||
let compile_profile_id = profile_id.clone();
|
||||
let compile_initial_game_name = initial_game_name.clone();
|
||||
let compile_requested_summary = requested_summary.clone();
|
||||
let compile_initial_tags = initial_tags.clone();
|
||||
let compile_requested_cover_image_src = requested_cover_image_src.clone();
|
||||
let result = execute_billable_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
@@ -307,7 +314,108 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
Ok((next_session, generated_item_assets))
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(response) = result.as_ref()
|
||||
&& response.status().is_server_error()
|
||||
{
|
||||
let failure_message = match3d_response_failure_message(response);
|
||||
persist_failed_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
compile_session_id,
|
||||
compile_owner_user_id,
|
||||
compile_profile_id,
|
||||
compile_initial_game_name,
|
||||
compile_requested_summary,
|
||||
compile_initial_tags,
|
||||
compile_requested_cover_image_src,
|
||||
failure_message,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn persist_failed_match3d_draft_generation(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
game_name: String,
|
||||
summary: Option<String>,
|
||||
tags: Vec<String>,
|
||||
cover_image_src: Option<String>,
|
||||
failure_message: String,
|
||||
) {
|
||||
let failure_assets_json = serialize_match3d_failed_generation_assets(failure_message.as_str());
|
||||
if let Err(persist_error) = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
Some(game_name),
|
||||
summary.or_else(|| Some(String::new())),
|
||||
Some(serde_json::to_string(&tags).unwrap_or_default()),
|
||||
cover_image_src,
|
||||
None,
|
||||
failure_assets_json,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
provider = MATCH3D_AGENT_PROVIDER,
|
||||
status = ?persist_error.status(),
|
||||
"抓大鹅草稿生成失败后的状态回写失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_match3d_failed_generation_assets(message: &str) -> Option<String> {
|
||||
let background_asset = Match3DGeneratedBackgroundAsset {
|
||||
prompt: String::new(),
|
||||
status: "failed".to_string(),
|
||||
error: Some(message.trim().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let assets = vec![Match3DGeneratedItemAssetJson {
|
||||
item_id: "match3d-generation-failure".to_string(),
|
||||
item_name: "生成失败".to_string(),
|
||||
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
|
||||
image_src: None,
|
||||
image_object_key: None,
|
||||
image_views: Vec::new(),
|
||||
model_src: None,
|
||||
model_object_key: None,
|
||||
model_file_name: None,
|
||||
task_uuid: None,
|
||||
subscription_key: None,
|
||||
sound_prompt: None,
|
||||
background_music_title: None,
|
||||
background_music_style: None,
|
||||
background_music_prompt: None,
|
||||
background_music: None,
|
||||
click_sound: None,
|
||||
background_asset: Some(background_asset),
|
||||
status: "failed".to_string(),
|
||||
error: Some(message.trim().to_string()),
|
||||
}];
|
||||
serde_json::to_string(&assets).ok()
|
||||
}
|
||||
|
||||
fn match3d_response_failure_message(response: &Response) -> String {
|
||||
response
|
||||
.extensions()
|
||||
.get::<String>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("抓大鹅草稿生成失败,HTTP {}", response.status()))
|
||||
}
|
||||
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||
|
||||
@@ -453,6 +453,32 @@ fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -
|
||||
|| match3d_text_present(asset.container_image_object_key.as_ref())
|
||||
}
|
||||
|
||||
fn match3d_asset_status_is_failure(status: &str) -> bool {
|
||||
let normalized = status.trim().to_ascii_lowercase().replace(['-', ' '], "_");
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"failed" | "failure" | "error" | "partial_failed"
|
||||
)
|
||||
}
|
||||
|
||||
fn match3d_error_present(value: Option<&String>) -> bool {
|
||||
value.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn match3d_item_asset_has_failure(asset: &Match3DGeneratedItemAssetJson) -> bool {
|
||||
match3d_asset_status_is_failure(asset.status.as_str())
|
||||
|| match3d_error_present(asset.error.as_ref())
|
||||
|| asset.background_asset.as_ref().is_some_and(|background| {
|
||||
match3d_asset_status_is_failure(background.status.as_str())
|
||||
|| match3d_error_present(background.error.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_background_asset_has_failure(asset: &Match3DGeneratedBackgroundAsset) -> bool {
|
||||
match3d_asset_status_is_failure(asset.status.as_str())
|
||||
|| match3d_error_present(asset.error.as_ref())
|
||||
}
|
||||
|
||||
fn resolve_match3d_work_generation_status(
|
||||
item: &Match3DWorkProfileRecord,
|
||||
assets: &[Match3DGeneratedItemAssetJson],
|
||||
@@ -462,6 +488,21 @@ fn resolve_match3d_work_generation_status(
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
let has_failure = assets.iter().any(match3d_item_asset_has_failure)
|
||||
|| background_asset.is_some_and(match3d_background_asset_has_failure);
|
||||
if has_failure {
|
||||
let has_partial_result = assets.iter().any(match3d_item_asset_has_image)
|
||||
|| background_asset.is_some_and(match3d_background_asset_has_image);
|
||||
return Some(
|
||||
if has_partial_result {
|
||||
"partial_failed"
|
||||
} else {
|
||||
"failed"
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if assets.is_empty()
|
||||
|| !assets.iter().any(match3d_item_asset_has_image)
|
||||
|| !background_asset.is_some_and(match3d_background_asset_has_image)
|
||||
|
||||
@@ -1842,3 +1842,45 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
|
||||
|
||||
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_summary_marks_failed_generated_assets_failed() {
|
||||
let assets = vec![Match3DGeneratedItemAsset {
|
||||
background_asset: Some(Match3DGeneratedBackgroundAsset {
|
||||
prompt: "水果厨房背景".to_string(),
|
||||
status: "failed".to_string(),
|
||||
error: Some("VectorEngine 请求失败".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
status: "failed".to_string(),
|
||||
error: Some("VectorEngine 请求失败".to_string()),
|
||||
..test_match3d_generated_item_asset(1, "草莓")
|
||||
}];
|
||||
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
|
||||
work_id: "match3d-profile-1".to_string(),
|
||||
profile_id: "match3d-profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: Some("match3d-session-1".to_string()),
|
||||
author_display_name: "玩家".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary: "水果主题".to_string(),
|
||||
tags: vec!["水果".to_string()],
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
reference_image_src: None,
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
publication_status: "draft".to_string(),
|
||||
play_count: 0,
|
||||
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
|
||||
published_at: None,
|
||||
publish_ready: false,
|
||||
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
response.generation_status.as_deref(),
|
||||
Some("partial_failed")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use axum::{Router, middleware, routing::get};
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
|
||||
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
|
||||
admin_login, admin_me, admin_overview, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, require_admin_auth,
|
||||
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
|
||||
require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -71,6 +75,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/creation-entry/config/banners",
|
||||
post(admin_upsert_creation_entry_event_banners_config).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/works/visibility",
|
||||
get(admin_list_work_visibility)
|
||||
|
||||
@@ -14,7 +14,8 @@ use crate::{
|
||||
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
|
||||
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
|
||||
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
|
||||
redeem_profile_reward_code, submit_profile_feedback,
|
||||
redeem_profile_reward_code, stream_wechat_profile_recharge_order_events,
|
||||
submit_profile_feedback,
|
||||
},
|
||||
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
|
||||
state::AppState,
|
||||
@@ -73,6 +74,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/orders/{order_id}/wechat/events",
|
||||
get(stream_wechat_profile_recharge_order_events).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/feedback",
|
||||
post(submit_profile_feedback)
|
||||
|
||||
@@ -424,6 +424,7 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
details["referenceImageCount"] = json!(audit.reference_image_count);
|
||||
details["imageModel"] = json!(audit.image_model);
|
||||
details["rawExcerpt"] = json!(audit.raw_excerpt);
|
||||
details["errorSource"] = json!(audit.error_source);
|
||||
}
|
||||
|
||||
AppError::from_status(status).with_details(details)
|
||||
|
||||
@@ -40,6 +40,15 @@ pub async fn password_entry(
|
||||
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
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
if result.created {
|
||||
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
||||
&state,
|
||||
@@ -48,8 +57,6 @@ pub async fn password_entry(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
@@ -57,13 +64,6 @@ pub async fn password_entry(
|
||||
AuthLoginMethod::Password,
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
|
||||
@@ -100,13 +100,6 @@ pub async fn reset_password(
|
||||
&session_client,
|
||||
module_auth::AuthLoginMethod::Password,
|
||||
)?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
&result.user.id,
|
||||
module_auth::AuthLoginMethod::Password,
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
@@ -114,6 +107,13 @@ pub async fn reset_password(
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
&result.user.id,
|
||||
module_auth::AuthLoginMethod::Password,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
|
||||
@@ -151,6 +151,20 @@ pub async fn phone_login(
|
||||
}
|
||||
};
|
||||
let created = result.created;
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
&result.user,
|
||||
&session_client,
|
||||
AuthLoginMethod::Phone,
|
||||
)?;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
if created {
|
||||
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
||||
&state,
|
||||
@@ -170,13 +184,6 @@ pub async fn phone_login(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
&result.user,
|
||||
&session_client,
|
||||
AuthLoginMethod::Phone,
|
||||
)?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
@@ -184,13 +191,6 @@ pub async fn phone_login(
|
||||
AuthLoginMethod::Phone,
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::*;
|
||||
|
||||
pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: None,
|
||||
work_description: None,
|
||||
title: payload.work_title.as_deref(),
|
||||
work_description: payload.work_description.as_deref(),
|
||||
picture_description: payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -32,8 +32,8 @@ pub(crate) async fn save_puzzle_form_payload_before_compile(
|
||||
now: i64,
|
||||
) -> Result<String, Response> {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
None,
|
||||
None,
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
|
||||
@@ -317,7 +317,16 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
);
|
||||
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
||||
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
||||
let scene_generated = create_puzzle_vector_engine_image_generation(
|
||||
let bundle_started_at = Instant::now();
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
"拼图关卡资产包生成开始"
|
||||
);
|
||||
let scene_started_at = Instant::now();
|
||||
let scene_generated = match create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
PuzzleImageModel::GptImage2,
|
||||
@@ -328,7 +337,34 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
Some(&puzzle_reference),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
.map_err(map_puzzle_generation_endpoint_error)
|
||||
{
|
||||
Ok(generated) => {
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
slot = "level_scene",
|
||||
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
|
||||
"拼图关卡场景图生成完成"
|
||||
);
|
||||
generated
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
slot = "level_scene",
|
||||
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
|
||||
error = %error,
|
||||
"拼图关卡场景图生成失败"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
@@ -336,7 +372,8 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
}))
|
||||
})?;
|
||||
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
|
||||
let scene_persist_future = persist_puzzle_level_asset_image(
|
||||
let scene_persist_started_at = Instant::now();
|
||||
let level_scene = persist_puzzle_level_asset_image(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
@@ -347,8 +384,18 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
"level_scene",
|
||||
"scene",
|
||||
scene_image,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
slot = "level_scene",
|
||||
elapsed_ms = scene_persist_started_at.elapsed().as_millis() as u64,
|
||||
"拼图关卡场景图持久化完成"
|
||||
);
|
||||
let spritesheet_future = generate_and_persist_puzzle_level_asset(
|
||||
let ui_spritesheet = generate_and_persist_puzzle_level_asset(
|
||||
state,
|
||||
&http_client,
|
||||
&settings,
|
||||
@@ -362,8 +409,9 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
"puzzle_ui_spritesheet_image",
|
||||
"ui_spritesheet",
|
||||
"spritesheet",
|
||||
);
|
||||
let background_future = generate_and_persist_puzzle_level_asset(
|
||||
)
|
||||
.await?;
|
||||
let level_background = generate_and_persist_puzzle_level_asset(
|
||||
state,
|
||||
&http_client,
|
||||
&settings,
|
||||
@@ -377,14 +425,21 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
"puzzle_level_background_image",
|
||||
"level_background",
|
||||
"background",
|
||||
);
|
||||
let (level_scene, ui_spritesheet, level_background) =
|
||||
tokio::join!(scene_persist_future, spritesheet_future, background_future);
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
elapsed_ms = bundle_started_at.elapsed().as_millis() as u64,
|
||||
"拼图关卡资产包生成完成"
|
||||
);
|
||||
Ok(GeneratedPuzzleLevelAssetBundle {
|
||||
level_scene: level_scene?,
|
||||
ui_spritesheet: ui_spritesheet?,
|
||||
level_background: level_background?,
|
||||
level_scene,
|
||||
ui_spritesheet,
|
||||
level_background,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +458,20 @@ async fn generate_and_persist_puzzle_level_asset(
|
||||
slot: &str,
|
||||
file_stem: &str,
|
||||
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
|
||||
let generated = create_puzzle_vector_engine_image_generation(
|
||||
let started_at = Instant::now();
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
slot,
|
||||
asset_kind,
|
||||
size,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
reference_image_bytes = reference_image.bytes_len,
|
||||
"拼图关卡资产生成请求开始"
|
||||
);
|
||||
let generated = match create_puzzle_vector_engine_image_generation(
|
||||
http_client,
|
||||
settings,
|
||||
PuzzleImageModel::GptImage2,
|
||||
@@ -414,7 +482,36 @@ async fn generate_and_persist_puzzle_level_asset(
|
||||
Some(reference_image),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
.map_err(map_puzzle_generation_endpoint_error)
|
||||
{
|
||||
Ok(generated) => {
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
slot,
|
||||
asset_kind,
|
||||
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||
"拼图关卡资产生成请求完成"
|
||||
);
|
||||
generated
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
slot,
|
||||
asset_kind,
|
||||
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||
error = %error,
|
||||
"拼图关卡资产生成请求失败"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
@@ -427,7 +524,8 @@ async fn generate_and_persist_puzzle_level_asset(
|
||||
image
|
||||
};
|
||||
|
||||
persist_puzzle_level_asset_image(
|
||||
let persist_started_at = Instant::now();
|
||||
let persisted = persist_puzzle_level_asset_image(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
@@ -439,7 +537,19 @@ async fn generate_and_persist_puzzle_level_asset(
|
||||
file_stem,
|
||||
image,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
slot,
|
||||
asset_kind,
|
||||
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||||
"拼图关卡资产持久化完成"
|
||||
);
|
||||
|
||||
Ok(persisted)
|
||||
}
|
||||
|
||||
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(
|
||||
|
||||
@@ -725,8 +725,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
"save_puzzle_form_draft" => {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
None,
|
||||
None,
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
|
||||
@@ -384,6 +384,28 @@ fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_form_seed_text_includes_work_metadata() {
|
||||
let payload = CreatePuzzleAgentSessionRequest {
|
||||
seed_text: Some("旧 seed 会被画面描述兜底覆盖。".to_string()),
|
||||
work_title: Some("雨夜猫街".to_string()),
|
||||
work_description: Some("123".to_string()),
|
||||
picture_description: Some("一只猫在雨夜灯牌下回头。".to_string()),
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: None,
|
||||
ai_redraw: Some(true),
|
||||
};
|
||||
|
||||
let seed_text = build_puzzle_form_seed_text(&payload);
|
||||
|
||||
assert!(seed_text.contains("作品名称:雨夜猫街"));
|
||||
assert!(seed_text.contains("作品描述:123"));
|
||||
assert!(seed_text.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
|
||||
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||
|
||||
@@ -56,13 +56,6 @@ pub async fn refresh_session(
|
||||
Some(&rotated.session.issued_by_provider),
|
||||
Some(&rotated.session.client_info),
|
||||
)?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
&rotated.user.id,
|
||||
rotated.session.issued_by_provider.clone(),
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
@@ -70,6 +63,13 @@ pub async fn refresh_session(
|
||||
AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
&rotated.user.id,
|
||||
rotated.session.issued_by_provider.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
|
||||
@@ -2,12 +2,17 @@ use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use module_runtime::{
|
||||
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord,
|
||||
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
||||
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
||||
@@ -21,8 +26,9 @@ use module_runtime::{
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeTrackingScopeKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Sha256;
|
||||
use shared_contracts::runtime::{
|
||||
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
|
||||
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
|
||||
@@ -59,10 +65,13 @@ use shared_contracts::runtime::{
|
||||
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
|
||||
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
|
||||
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
|
||||
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse,
|
||||
WechatMiniProgramVirtualPayParamsResponse, WechatProfileRechargeOrderDoneEvent,
|
||||
WechatProfileRechargeOrderErrorEvent,
|
||||
};
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use std::{convert::Infallible, time::Duration};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -78,6 +87,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -231,7 +242,7 @@ pub async fn create_profile_recharge_order(
|
||||
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
||||
.await
|
||||
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
||||
Some(
|
||||
Some(WechatMiniProgramPaymentParamsResponse::Ordinary(
|
||||
state
|
||||
.wechat_pay_client()
|
||||
.create_mini_program_order(build_wechat_payment_request(
|
||||
@@ -244,6 +255,15 @@ pub async fn create_profile_recharge_order(
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
||||
})?,
|
||||
))
|
||||
} else if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
|
||||
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
||||
.await
|
||||
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
||||
Some(
|
||||
build_wechat_virtual_pay_params(&state, ¢er, &order, &openid)
|
||||
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
|
||||
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
@@ -332,19 +352,19 @@ pub async fn confirm_wechat_profile_recharge_order(
|
||||
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
build_wechat_profile_recharge_order_confirmation(center, order),
|
||||
));
|
||||
}
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
build_wechat_profile_recharge_order_confirmation(center, order),
|
||||
));
|
||||
}
|
||||
if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_wechat_profile_recharge_order_confirmation(center, order),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -366,10 +386,7 @@ pub async fn confirm_wechat_profile_recharge_order(
|
||||
if wechat_order.trade_state != "SUCCESS" {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
build_wechat_profile_recharge_order_confirmation(center, order),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -391,13 +408,94 @@ pub async fn confirm_wechat_profile_recharge_order(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
build_wechat_profile_recharge_order_confirmation(center, order),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn stream_wechat_profile_recharge_order_events(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(order_id): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let (center, order) = load_user_wechat_profile_recharge_order(
|
||||
&state,
|
||||
&request_context,
|
||||
user_id.clone(),
|
||||
order_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
let stream_state = state.clone();
|
||||
let stream_context = request_context.clone();
|
||||
let stream = async_stream::stream! {
|
||||
let initial_response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
|
||||
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
||||
"order",
|
||||
&initial_response,
|
||||
));
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
||||
"done",
|
||||
&WechatProfileRechargeOrderDoneEvent {
|
||||
order_id: order.order_id.clone(),
|
||||
status: build_profile_recharge_order_status(order.status),
|
||||
},
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut updates = stream_state.subscribe_profile_recharge_order_updates();
|
||||
let mut poll_interval = tokio::time::interval(Duration::from_millis(1200));
|
||||
for _ in 0..25 {
|
||||
tokio::select! {
|
||||
maybe_order_id = updates.recv() => {
|
||||
if !matches!(maybe_order_id, Ok(ref value) if value == &order_id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ = poll_interval.tick() => {}
|
||||
}
|
||||
|
||||
match load_user_wechat_profile_recharge_order(
|
||||
&stream_state,
|
||||
&stream_context,
|
||||
user_id.clone(),
|
||||
order_id.clone(),
|
||||
).await {
|
||||
Ok((center, order)) => {
|
||||
let response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
|
||||
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
||||
"order",
|
||||
&response,
|
||||
));
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
||||
"done",
|
||||
&WechatProfileRechargeOrderDoneEvent {
|
||||
order_id: order.order_id.clone(),
|
||||
status: build_profile_recharge_order_status(order.status),
|
||||
},
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
||||
"error",
|
||||
&WechatProfileRechargeOrderErrorEvent {
|
||||
message: "读取充值订单状态失败".to_string(),
|
||||
},
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream).into_response())
|
||||
}
|
||||
|
||||
pub async fn submit_profile_feedback(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1014,6 +1112,81 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
async fn load_user_wechat_profile_recharge_order(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
order_id: String,
|
||||
) -> Result<
|
||||
(
|
||||
RuntimeProfileRechargeCenterRecord,
|
||||
RuntimeProfileRechargeOrderRecord,
|
||||
),
|
||||
Response,
|
||||
> {
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
.get_profile_recharge_order(order_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(request_context, map_runtime_profile_client_error(error))
|
||||
})?;
|
||||
|
||||
if order.user_id != user_id {
|
||||
return Err(runtime_profile_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
|
||||
));
|
||||
}
|
||||
if !is_wechat_recharge_payment_channel(&order.payment_channel) {
|
||||
return Err(runtime_profile_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("该充值订单不是微信支付订单"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((center, order))
|
||||
}
|
||||
|
||||
fn build_wechat_profile_recharge_order_confirmation(
|
||||
center: RuntimeProfileRechargeCenterRecord,
|
||||
order: RuntimeProfileRechargeOrderRecord,
|
||||
) -> ConfirmWechatProfileRechargeOrderResponse {
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus) -> String {
|
||||
match status {
|
||||
RuntimeProfileRechargeOrderStatus::Pending => "pending",
|
||||
RuntimeProfileRechargeOrderStatus::Paid => "paid",
|
||||
RuntimeProfileRechargeOrderStatus::Failed => "failed",
|
||||
RuntimeProfileRechargeOrderStatus::Closed => "closed",
|
||||
RuntimeProfileRechargeOrderStatus::Refunded => "refunded",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn wechat_profile_recharge_sse_json_event<T>(event_name: &str, payload: &T) -> Event
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
Event::default()
|
||||
.event(event_name)
|
||||
.json_data(payload)
|
||||
.unwrap_or_else(|_| {
|
||||
Event::default()
|
||||
.event("error")
|
||||
.json_data(&WechatProfileRechargeOrderErrorEvent {
|
||||
message: "充值订单状态事件序列化失败".to_string(),
|
||||
})
|
||||
.unwrap_or_else(|_| Event::default().event("error").data("{}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> {
|
||||
raw.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -1059,6 +1232,9 @@ fn validate_recharge_device_for_payment_channel(
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
|
||||
claims.is_wechat_mini_program_device()
|
||||
}
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL => {
|
||||
claims.is_wechat_mini_program_device()
|
||||
}
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
|
||||
_ => false,
|
||||
@@ -1106,6 +1282,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
|
||||
matches!(
|
||||
payment_channel,
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|
||||
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
||||
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
|
||||
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
|
||||
)
|
||||
@@ -1148,6 +1325,159 @@ async fn resolve_wechat_identity_for_payment(
|
||||
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
||||
}
|
||||
|
||||
fn build_wechat_virtual_pay_params(
|
||||
state: &AppState,
|
||||
center: &RuntimeProfileRechargeCenterRecord,
|
||||
order: &RuntimeProfileRechargeOrderRecord,
|
||||
openid: &str,
|
||||
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
|
||||
let product = match order.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => center
|
||||
.point_products
|
||||
.iter()
|
||||
.find(|item| item.product_id == order.product_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("当前充值商品不存在,请刷新后再试")
|
||||
})?,
|
||||
RuntimeProfileRechargeProductKind::Membership => center
|
||||
.membership_products
|
||||
.iter()
|
||||
.find(|item| item.product_id == order.product_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("当前充值商品不存在,请刷新后再试")
|
||||
})?,
|
||||
};
|
||||
let identity = state
|
||||
.wechat_auth_service()
|
||||
.get_identity_by_user_id(&order.user_id)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("读取微信身份失败:{error}"))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")
|
||||
})?;
|
||||
let session_key = identity.session_key.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("当前微信登录态缺少 session_key,请重新登录后再试")
|
||||
})?;
|
||||
let offer_id = required_wechat_virtual_payment_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_virtual_payment_offer_id
|
||||
.as_deref(),
|
||||
"微信虚拟支付 OfferId 未配置",
|
||||
)?;
|
||||
let mode = match order.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
|
||||
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
|
||||
};
|
||||
let buy_quantity = match product.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => product.points_amount,
|
||||
RuntimeProfileRechargeProductKind::Membership => 1,
|
||||
};
|
||||
let mut sign_data = serde_json::json!({
|
||||
"offerId": offer_id,
|
||||
"buyQuantity": buy_quantity,
|
||||
"env": state.config.wechat_mini_program_virtual_payment_env,
|
||||
"currencyType": "CNY",
|
||||
"outTradeNo": order.order_id,
|
||||
"attach": serde_json::json!({
|
||||
"userId": order.user_id,
|
||||
"productId": order.product_id,
|
||||
"paymentChannel": order.payment_channel,
|
||||
"openId": openid,
|
||||
}).to_string(),
|
||||
});
|
||||
if order.kind == RuntimeProfileRechargeProductKind::Membership {
|
||||
sign_data["productId"] = json!(order.product_id);
|
||||
sign_data["goodsPrice"] = json!(order.amount_cents);
|
||||
}
|
||||
let sign_data = sign_data.to_string();
|
||||
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
|
||||
let signature = calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?;
|
||||
|
||||
Ok(WechatMiniProgramVirtualPayParamsResponse {
|
||||
mode: mode.to_string(),
|
||||
sign_data,
|
||||
pay_sig,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
|
||||
fn calc_wechat_virtual_payment_signature(
|
||||
state: &AppState,
|
||||
sign_data: &str,
|
||||
use_sandbox_key: bool,
|
||||
) -> Result<String, AppError> {
|
||||
let env = state.config.wechat_mini_program_virtual_payment_env;
|
||||
let app_key = if use_sandbox_key || env == 1 {
|
||||
required_wechat_virtual_payment_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_virtual_payment_sandbox_app_key
|
||||
.as_deref(),
|
||||
"微信虚拟支付沙箱 AppKey 未配置",
|
||||
)?
|
||||
} else {
|
||||
required_wechat_virtual_payment_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_virtual_payment_app_key
|
||||
.as_deref(),
|
||||
"微信虚拟支付 AppKey 未配置",
|
||||
)?
|
||||
};
|
||||
calc_wechat_virtual_payment_pay_signature_with_key(app_key, sign_data)
|
||||
}
|
||||
|
||||
fn required_wechat_virtual_payment_config<'a>(
|
||||
value: Option<&'a str>,
|
||||
message: &str,
|
||||
) -> Result<&'a str, AppError> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message))
|
||||
}
|
||||
|
||||
fn calc_wechat_virtual_payment_pay_signature_with_key(
|
||||
key: &str,
|
||||
sign_data: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(|_| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message("微信虚拟支付签名密钥初始化失败")
|
||||
})?;
|
||||
mac.update(format!("requestVirtualPayment&{sign_data}").as_bytes());
|
||||
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
|
||||
}
|
||||
|
||||
fn calc_wechat_virtual_payment_user_signature_with_key(
|
||||
session_key: &str,
|
||||
sign_data: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut mac = HmacSha256::new_from_slice(session_key.as_bytes()).map_err(|_| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message("微信虚拟支付用户态签名密钥初始化失败")
|
||||
})?;
|
||||
mac.update(sign_data.as_bytes());
|
||||
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
|
||||
}
|
||||
|
||||
fn to_lower_hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for &byte in bytes {
|
||||
output.push(char::from(HEX[(byte >> 4) as usize]));
|
||||
output.push(char::from(HEX[(byte & 0x0f) as usize]));
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
|
||||
order
|
||||
.success_time
|
||||
@@ -1619,9 +1949,21 @@ fn build_profile_redeem_code_admin_response(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
|
||||
use module_runtime::{
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
||||
RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus,
|
||||
RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
|
||||
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
|
||||
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord,
|
||||
RuntimeProfileWalletLedgerSourceType,
|
||||
};
|
||||
|
||||
use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata};
|
||||
use super::{
|
||||
build_wechat_virtual_pay_params, calc_wechat_virtual_payment_pay_signature_with_key,
|
||||
calc_wechat_virtual_payment_user_signature_with_key,
|
||||
format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -2082,6 +2424,392 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_virtual_pay_params_use_goods_mode_for_membership_products() {
|
||||
let state = seed_authenticated_state_with_config(AppConfig {
|
||||
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_env: 0,
|
||||
..fast_spacetime_timeout_config()
|
||||
})
|
||||
.await;
|
||||
let wechat_login = state
|
||||
.wechat_auth_service()
|
||||
.resolve_login(ResolveWechatLoginInput {
|
||||
profile: WechatIdentityProfile {
|
||||
provider_uid: "openid-user-00000001".to_string(),
|
||||
provider_union_id: Some("union-user-00000001".to_string()),
|
||||
display_name: Some("资料页用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: Some("session-key-1".to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("wechat identity should seed");
|
||||
let user_id = wechat_login.user.id;
|
||||
let order = RuntimeProfileRechargeOrderRecord {
|
||||
order_id: "memberorder01".to_string(),
|
||||
user_id: user_id.clone(),
|
||||
product_id: "member_month".to_string(),
|
||||
product_title: "月卡".to_string(),
|
||||
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||
amount_cents: 2800,
|
||||
status: RuntimeProfileRechargeOrderStatus::Pending,
|
||||
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
||||
.to_string(),
|
||||
paid_at: None,
|
||||
paid_at_micros: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-26T10:00:00Z".to_string(),
|
||||
created_at_micros: 1_779_756_000_000_000,
|
||||
points_delta: 0,
|
||||
membership_expires_at: None,
|
||||
membership_expires_at_micros: None,
|
||||
};
|
||||
|
||||
let center = RuntimeProfileRechargeCenterRecord {
|
||||
user_id: user_id.clone(),
|
||||
wallet_balance: 0,
|
||||
membership: RuntimeProfileMembershipRecord {
|
||||
user_id: user_id.clone(),
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at: None,
|
||||
started_at_micros: None,
|
||||
expires_at: None,
|
||||
expires_at_micros: None,
|
||||
updated_at: None,
|
||||
updated_at_micros: None,
|
||||
},
|
||||
point_products: vec![],
|
||||
membership_products: vec![RuntimeProfileRechargeProductRecord {
|
||||
product_id: "member_month".to_string(),
|
||||
title: "月卡".to_string(),
|
||||
price_cents: 2800,
|
||||
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||
points_amount: 0,
|
||||
bonus_points: 0,
|
||||
duration_days: 30,
|
||||
badge_label: String::new(),
|
||||
description: "30天会员".to_string(),
|
||||
tier: RuntimeProfileMembershipTier::Month,
|
||||
}],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: false,
|
||||
};
|
||||
|
||||
let params =
|
||||
build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001")
|
||||
.expect("membership virtual pay params should build");
|
||||
let sign_data: Value =
|
||||
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||
let attach: Value = serde_json::from_str(
|
||||
sign_data["attach"]
|
||||
.as_str()
|
||||
.expect("attach should be string json"),
|
||||
)
|
||||
.expect("attach should decode");
|
||||
|
||||
assert_eq!(params.mode, "short_series_goods");
|
||||
assert_eq!(sign_data["buyQuantity"], 1);
|
||||
assert_eq!(sign_data["offerId"], "offer-1");
|
||||
assert_eq!(sign_data["productId"], "member_month");
|
||||
assert_eq!(sign_data["goodsPrice"], 2800);
|
||||
assert_eq!(sign_data["outTradeNo"], "memberorder01");
|
||||
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
|
||||
assert!(!params.pay_sig.is_empty());
|
||||
assert!(!params.signature.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_virtual_pay_params_use_coin_quantity_for_points_products() {
|
||||
let state = seed_authenticated_state_with_config(AppConfig {
|
||||
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_env: 0,
|
||||
..fast_spacetime_timeout_config()
|
||||
})
|
||||
.await;
|
||||
let wechat_login = state
|
||||
.wechat_auth_service()
|
||||
.resolve_login(ResolveWechatLoginInput {
|
||||
profile: WechatIdentityProfile {
|
||||
provider_uid: "openid-user-points-60".to_string(),
|
||||
provider_union_id: Some("union-user-points-60".to_string()),
|
||||
display_name: Some("资料页用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: Some("session-key-points-60".to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("wechat identity should seed");
|
||||
let user_id = wechat_login.user.id.clone();
|
||||
let order = RuntimeProfileRechargeOrderRecord {
|
||||
order_id: "pointsorder60".to_string(),
|
||||
user_id: user_id.clone(),
|
||||
product_id: "points_60".to_string(),
|
||||
product_title: "60泥点".to_string(),
|
||||
kind: RuntimeProfileRechargeProductKind::Points,
|
||||
amount_cents: 600,
|
||||
status: RuntimeProfileRechargeOrderStatus::Pending,
|
||||
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
||||
.to_string(),
|
||||
paid_at: None,
|
||||
paid_at_micros: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-30T10:00:00Z".to_string(),
|
||||
created_at_micros: 1_780_000_000_000_000,
|
||||
points_delta: 0,
|
||||
membership_expires_at: None,
|
||||
membership_expires_at_micros: None,
|
||||
};
|
||||
|
||||
let center = RuntimeProfileRechargeCenterRecord {
|
||||
user_id: user_id.clone(),
|
||||
wallet_balance: 0,
|
||||
membership: RuntimeProfileMembershipRecord {
|
||||
user_id: user_id.clone(),
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at: None,
|
||||
started_at_micros: None,
|
||||
expires_at: None,
|
||||
expires_at_micros: None,
|
||||
updated_at: None,
|
||||
updated_at_micros: None,
|
||||
},
|
||||
point_products: vec![RuntimeProfileRechargeProductRecord {
|
||||
product_id: "points_60".to_string(),
|
||||
title: "60泥点".to_string(),
|
||||
price_cents: 600,
|
||||
kind: RuntimeProfileRechargeProductKind::Points,
|
||||
points_amount: 60,
|
||||
bonus_points: 60,
|
||||
duration_days: 0,
|
||||
badge_label: "首充双倍".to_string(),
|
||||
description: "60+60泥点".to_string(),
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
}],
|
||||
membership_products: vec![],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: true,
|
||||
};
|
||||
|
||||
let params =
|
||||
build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-points-60")
|
||||
.expect("points virtual pay params should build");
|
||||
let sign_data: Value =
|
||||
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||
let attach: Value = serde_json::from_str(
|
||||
sign_data["attach"]
|
||||
.as_str()
|
||||
.expect("attach should be string json"),
|
||||
)
|
||||
.expect("attach should decode");
|
||||
|
||||
assert_eq!(params.mode, "short_series_coin");
|
||||
assert_eq!(sign_data["buyQuantity"], 60);
|
||||
assert_eq!(sign_data["offerId"], "offer-1");
|
||||
assert_eq!(sign_data["outTradeNo"], "pointsorder60");
|
||||
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
|
||||
assert!(!params.pay_sig.is_empty());
|
||||
assert!(!params.signature.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() {
|
||||
let state = seed_authenticated_state_with_config(AppConfig {
|
||||
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_env: 0,
|
||||
..fast_spacetime_timeout_config()
|
||||
})
|
||||
.await;
|
||||
let wechat_login = state
|
||||
.wechat_auth_service()
|
||||
.resolve_login(ResolveWechatLoginInput {
|
||||
profile: WechatIdentityProfile {
|
||||
provider_uid: "openid-user-item01".to_string(),
|
||||
provider_union_id: Some("union-user-item01".to_string()),
|
||||
display_name: Some("资料页用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: Some("session-key-item01".to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("wechat identity should seed");
|
||||
let user_id = wechat_login.user.id.clone();
|
||||
let order = RuntimeProfileRechargeOrderRecord {
|
||||
order_id: "item01order01".to_string(),
|
||||
user_id: user_id.clone(),
|
||||
product_id: "item01".to_string(),
|
||||
product_title: "测试道具".to_string(),
|
||||
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||
amount_cents: 100,
|
||||
status: RuntimeProfileRechargeOrderStatus::Pending,
|
||||
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
||||
.to_string(),
|
||||
paid_at: None,
|
||||
paid_at_micros: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-27T10:00:00Z".to_string(),
|
||||
created_at_micros: 1_779_842_400_000_000,
|
||||
points_delta: 0,
|
||||
membership_expires_at: None,
|
||||
membership_expires_at_micros: None,
|
||||
};
|
||||
|
||||
let center = RuntimeProfileRechargeCenterRecord {
|
||||
user_id: user_id.clone(),
|
||||
wallet_balance: 0,
|
||||
membership: RuntimeProfileMembershipRecord {
|
||||
user_id: user_id.clone(),
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at: None,
|
||||
started_at_micros: None,
|
||||
expires_at: None,
|
||||
expires_at_micros: None,
|
||||
updated_at: None,
|
||||
updated_at_micros: None,
|
||||
},
|
||||
point_products: vec![],
|
||||
membership_products: vec![RuntimeProfileRechargeProductRecord {
|
||||
product_id: "item01".to_string(),
|
||||
title: "测试道具".to_string(),
|
||||
price_cents: 100,
|
||||
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||
points_amount: 0,
|
||||
bonus_points: 0,
|
||||
duration_days: 30,
|
||||
badge_label: String::new(),
|
||||
description: "30天会员".to_string(),
|
||||
tier: RuntimeProfileMembershipTier::Month,
|
||||
}],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: false,
|
||||
};
|
||||
|
||||
let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-item01")
|
||||
.expect("custom membership virtual pay params should build");
|
||||
let sign_data: Value =
|
||||
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||
|
||||
assert_eq!(params.mode, "short_series_goods");
|
||||
assert_eq!(sign_data["productId"], "item01");
|
||||
assert_eq!(sign_data["goodsPrice"], 100);
|
||||
assert_eq!(sign_data["outTradeNo"], "item01order01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_virtual_payment_signatures_match_official_examples() {
|
||||
let post_body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#;
|
||||
|
||||
let pay_sig = calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body)
|
||||
.expect("pay signature should build");
|
||||
let signature = calc_wechat_virtual_payment_user_signature_with_key(
|
||||
"9hAb/NEYUlkaMBEsmFgzig==",
|
||||
post_body,
|
||||
)
|
||||
.expect("user signature should build");
|
||||
|
||||
assert_eq!(
|
||||
pay_sig,
|
||||
"a1ab2651b927b6a766152cf864033417b85c1448fc3c6e1bedbbd7f49416e92f"
|
||||
);
|
||||
assert_eq!(
|
||||
signature,
|
||||
"089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_virtual_payment_sandbox_requires_sandbox_app_key() {
|
||||
let state = seed_authenticated_state_with_config(AppConfig {
|
||||
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
||||
wechat_mini_program_virtual_payment_sandbox_app_key: None,
|
||||
wechat_mini_program_virtual_payment_env: 1,
|
||||
..fast_spacetime_timeout_config()
|
||||
})
|
||||
.await;
|
||||
let wechat_login = state
|
||||
.wechat_auth_service()
|
||||
.resolve_login(ResolveWechatLoginInput {
|
||||
profile: WechatIdentityProfile {
|
||||
provider_uid: "openid-sandbox-1".to_string(),
|
||||
provider_union_id: Some("union-sandbox-1".to_string()),
|
||||
display_name: Some("资料页用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: Some("session-key-sandbox-1".to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("wechat identity should seed");
|
||||
let user_id = wechat_login.user.id.clone();
|
||||
let order = RuntimeProfileRechargeOrderRecord {
|
||||
order_id: "sandboxorder01".to_string(),
|
||||
user_id: user_id.clone(),
|
||||
product_id: "points_60".to_string(),
|
||||
product_title: "60泥点".to_string(),
|
||||
kind: RuntimeProfileRechargeProductKind::Points,
|
||||
amount_cents: 600,
|
||||
status: RuntimeProfileRechargeOrderStatus::Pending,
|
||||
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
||||
.to_string(),
|
||||
paid_at: None,
|
||||
paid_at_micros: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-30T10:00:00Z".to_string(),
|
||||
created_at_micros: 1_780_000_000_000_000,
|
||||
points_delta: 0,
|
||||
membership_expires_at: None,
|
||||
membership_expires_at_micros: None,
|
||||
};
|
||||
|
||||
let center = RuntimeProfileRechargeCenterRecord {
|
||||
user_id: user_id.clone(),
|
||||
wallet_balance: 0,
|
||||
membership: RuntimeProfileMembershipRecord {
|
||||
user_id: user_id.clone(),
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at: None,
|
||||
started_at_micros: None,
|
||||
expires_at: None,
|
||||
expires_at_micros: None,
|
||||
updated_at: None,
|
||||
updated_at_micros: None,
|
||||
},
|
||||
point_products: vec![RuntimeProfileRechargeProductRecord {
|
||||
product_id: "points_60".to_string(),
|
||||
title: "60泥点".to_string(),
|
||||
price_cents: 600,
|
||||
kind: RuntimeProfileRechargeProductKind::Points,
|
||||
points_amount: 60,
|
||||
bonus_points: 60,
|
||||
duration_days: 0,
|
||||
badge_label: "首充双倍".to_string(),
|
||||
description: "60+60泥点".to_string(),
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
}],
|
||||
membership_products: vec![],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: false,
|
||||
};
|
||||
|
||||
let error = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-sandbox-1")
|
||||
.expect_err("sandbox pay params should reject missing sandbox app key");
|
||||
assert!(
|
||||
error.to_string().contains("沙箱 AppKey 未配置"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_feedback_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -27,7 +27,7 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::{Semaphore, broadcast};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -257,6 +257,7 @@ pub struct AppStateInner {
|
||||
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
|
||||
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
|
||||
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
|
||||
profile_recharge_order_updates: broadcast::Sender<String>,
|
||||
#[cfg(test)]
|
||||
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
|
||||
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
|
||||
@@ -394,6 +395,7 @@ impl AppState {
|
||||
let llm_client = build_llm_client(&config)?;
|
||||
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
|
||||
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
|
||||
let (profile_recharge_order_updates, _) = broadcast::channel(128);
|
||||
|
||||
Ok(Self(Arc::new(AppStateInner {
|
||||
config,
|
||||
@@ -423,6 +425,7 @@ impl AppState {
|
||||
creative_agent_gpt5_client,
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
profile_recharge_order_updates,
|
||||
#[cfg(test)]
|
||||
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
|
||||
})))
|
||||
@@ -465,6 +468,45 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 SpacetimeDB 保存创作入口页多公告配置,并同步测试缓存。
|
||||
pub async fn upsert_creation_entry_event_banners_config(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryEventBannersAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
let test_event_banners_json = input.event_banners_json.clone();
|
||||
match self
|
||||
.spacetime_client
|
||||
.upsert_creation_entry_event_banners_config(input)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => {
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Ok(banners) = module_runtime::decode_creation_entry_event_banner_snapshots(
|
||||
test_event_banners_json.as_str(),
|
||||
) {
|
||||
config.event_banners = banners
|
||||
.into_iter()
|
||||
.map(module_runtime::build_creation_entry_event_banner_response)
|
||||
.collect();
|
||||
if let Some(first_banner) = config.event_banners.first().cloned() {
|
||||
config.event_banner = first_banner;
|
||||
}
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_creation_entry_config(
|
||||
&self,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
@@ -555,6 +597,10 @@ impl AppState {
|
||||
.to_string(),
|
||||
category_sort_order: 0,
|
||||
updated_at_micros: 0,
|
||||
unified_creation_spec:
|
||||
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(
|
||||
creation_type_id,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -584,7 +630,7 @@ impl AppState {
|
||||
)
|
||||
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
|
||||
// 当前进程内 auth_store 是认证请求的即时工作集;SpacetimeDB 正式认证表用于跨进程恢复。
|
||||
// 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
|
||||
// 认证变更必须在返回客户端前写入 SpacetimeDB,避免只在本进程内成功、重启后丢失账号或会话。
|
||||
#[cfg(not(test))]
|
||||
if let Err(error) = self
|
||||
.spacetime_client
|
||||
@@ -593,9 +639,9 @@ impl AppState {
|
||||
{
|
||||
warn!(
|
||||
error = %error,
|
||||
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续"
|
||||
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程中止"
|
||||
);
|
||||
return Ok(());
|
||||
return Err(error);
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Ok(())
|
||||
@@ -706,6 +752,16 @@ impl AppState {
|
||||
self.creative_agent_executor.clone()
|
||||
}
|
||||
|
||||
pub fn subscribe_profile_recharge_order_updates(
|
||||
&self,
|
||||
) -> tokio::sync::broadcast::Receiver<String> {
|
||||
self.profile_recharge_order_updates.subscribe()
|
||||
}
|
||||
|
||||
pub fn publish_profile_recharge_order_update(&self, order_id: impl Into<String>) {
|
||||
let _ = self.profile_recharge_order_updates.send(order_id.into());
|
||||
}
|
||||
|
||||
pub fn get_creative_agent_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
|
||||
@@ -145,13 +145,6 @@ pub async fn handle_wechat_callback(
|
||||
&session_client,
|
||||
AuthLoginMethod::Wechat,
|
||||
)?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
&result.user.id,
|
||||
AuthLoginMethod::Wechat,
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
@@ -159,6 +152,13 @@ pub async fn handle_wechat_callback(
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
&result.user.id,
|
||||
AuthLoginMethod::Wechat,
|
||||
)
|
||||
.await;
|
||||
let mut response = Redirect::to(&build_auth_result_redirect_url(
|
||||
&redirect_path,
|
||||
&[
|
||||
@@ -241,6 +241,20 @@ pub async fn bind_wechat_phone(
|
||||
.await
|
||||
.map_err(map_wechat_bind_phone_error)?
|
||||
};
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
&result.user,
|
||||
&session_client,
|
||||
AuthLoginMethod::Wechat,
|
||||
)?;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
if result.activated_new_user {
|
||||
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
||||
&state,
|
||||
@@ -249,13 +263,6 @@ pub async fn bind_wechat_phone(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
&result.user,
|
||||
&session_client,
|
||||
AuthLoginMethod::Wechat,
|
||||
)?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
&request_context,
|
||||
@@ -263,13 +270,6 @@ pub async fn bind_wechat_phone(
|
||||
AuthLoginMethod::Wechat,
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
let mut response_headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
@@ -385,6 +385,7 @@ fn map_wechat_profile_to_domain(
|
||||
provider_union_id: profile.provider_union_id,
|
||||
display_name: profile.display_name,
|
||||
avatar_url: profile.avatar_url,
|
||||
session_key: profile.session_key,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
use std::{fs, path::Path, sync::Arc};
|
||||
|
||||
use aes::Aes256;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{
|
||||
Engine as _, alphabet,
|
||||
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use bytes::Bytes;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
|
||||
use ring::{
|
||||
aead,
|
||||
rand::{SecureRandom, SystemRandom},
|
||||
@@ -13,11 +20,13 @@ use ring::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::runtime::{
|
||||
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
|
||||
};
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use std::convert::TryInto;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
use url::Url;
|
||||
@@ -43,6 +52,14 @@ const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45;
|
||||
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
|
||||
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
|
||||
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
|
||||
const WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES: usize = 43;
|
||||
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES: usize = 32;
|
||||
const WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES: usize = 16;
|
||||
const WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES: usize = 4;
|
||||
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||
&alphabet::STANDARD,
|
||||
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WechatPayClient {
|
||||
@@ -92,6 +109,22 @@ pub struct WechatPayNotifyOrder {
|
||||
pub success_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct WechatVirtualPaymentNotifyOrder {
|
||||
out_trade_no: String,
|
||||
transaction_id: Option<String>,
|
||||
paid_at_micros: Option<i64>,
|
||||
event: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WechatVirtualPaymentNotifyResponse {
|
||||
#[serde(rename = "ErrCode")]
|
||||
err_code: i32,
|
||||
#[serde(rename = "ErrMsg")]
|
||||
err_msg: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WechatPayError {
|
||||
Disabled,
|
||||
@@ -220,6 +253,45 @@ struct WechatPayQueryOrderResponse {
|
||||
success_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WechatVirtualPaymentNotifyBody {
|
||||
#[serde(rename = "Event", alias = "event")]
|
||||
event: String,
|
||||
#[serde(rename = "OutTradeNo", alias = "out_trade_no", default)]
|
||||
out_trade_no: Option<String>,
|
||||
#[serde(rename = "MchOrderId", alias = "mch_order_id", default)]
|
||||
mch_order_id: Option<String>,
|
||||
#[serde(rename = "WeChatPayInfo", alias = "wechat_pay_info", default)]
|
||||
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WechatVirtualPaymentNotifyPayInfo {
|
||||
#[serde(rename = "MchOrderNo", alias = "mch_order_no", default)]
|
||||
mch_order_no: Option<String>,
|
||||
#[serde(rename = "TransactionId", alias = "transaction_id", default)]
|
||||
transaction_id: Option<String>,
|
||||
#[serde(rename = "PaidTime", alias = "paid_time", default)]
|
||||
paid_time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct WechatMiniProgramMessagePushQuery {
|
||||
signature: Option<String>,
|
||||
timestamp: Option<String>,
|
||||
nonce: Option<String>,
|
||||
echostr: Option<String>,
|
||||
msg_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatMiniProgramEncryptedMessage {
|
||||
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
|
||||
to_user_name: Option<String>,
|
||||
#[serde(rename = "Encrypt", alias = "encrypt")]
|
||||
encrypt: String,
|
||||
}
|
||||
|
||||
impl WechatPayClient {
|
||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||
if !config.wechat_pay_enabled {
|
||||
@@ -806,6 +878,154 @@ pub async fn handle_wechat_pay_notify(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_message_push_verify(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
) -> Response {
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
match resolve_wechat_message_push_verify_response(
|
||||
token,
|
||||
aes_key,
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
&query,
|
||||
) {
|
||||
Ok(plaintext) => (StatusCode::OK, plaintext).into_response(),
|
||||
Err(error) => build_wechat_message_push_verify_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let response_format = detect_virtual_payment_notify_response_format(&headers, &body);
|
||||
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let signature = query
|
||||
.msg_signature
|
||||
.as_deref()
|
||||
.or(query.signature.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("");
|
||||
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||||
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||||
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
if !verify_wechat_message_push_signature(
|
||||
token,
|
||||
timestamp,
|
||||
nonce,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
signature,
|
||||
) {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
let notify_body = match decrypt_wechat_message_push_ciphertext(
|
||||
aes_key,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
) {
|
||||
Ok(body) => body,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
|
||||
Ok(notify) => notify,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" {
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"收到非订单入账虚拟支付推送"
|
||||
);
|
||||
return build_virtual_payment_notify_success_response(response_format);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros);
|
||||
if state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
notify.out_trade_no.clone(),
|
||||
paid_at_micros,
|
||||
notify.transaction_id.clone(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"确认微信虚拟支付订单失败"
|
||||
);
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
|
||||
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
|
||||
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"微信虚拟支付推送已确认订单入账"
|
||||
);
|
||||
|
||||
build_virtual_payment_notify_success_response(response_format)
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
match error {
|
||||
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
@@ -875,6 +1095,362 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn read_wechat_message_push_config<'a>(
|
||||
value: Option<&'a str>,
|
||||
key: &str,
|
||||
) -> Result<&'a str, WechatPayError> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
|
||||
}
|
||||
|
||||
fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response {
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
(StatusCode::BAD_REQUEST, message).into_response()
|
||||
}
|
||||
|
||||
fn resolve_wechat_message_push_verify_response(
|
||||
token: &str,
|
||||
aes_key: &str,
|
||||
expected_app_id: Option<&str>,
|
||||
query: &WechatMiniProgramMessagePushQuery,
|
||||
) -> Result<String, WechatPayError> {
|
||||
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||||
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||||
let echostr = query.echostr.as_deref().map(str::trim).unwrap_or("");
|
||||
if timestamp.is_empty() || nonce.is_empty() || echostr.is_empty() {
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信消息推送校验参数不完整".to_string(),
|
||||
));
|
||||
}
|
||||
let msg_signature = query
|
||||
.msg_signature
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if let Some(signature) = msg_signature {
|
||||
if !verify_wechat_message_push_signature(token, timestamp, nonce, echostr, signature) {
|
||||
return Err(WechatPayError::InvalidSignature(
|
||||
"微信消息推送 msg_signature 无效".to_string(),
|
||||
));
|
||||
}
|
||||
return decrypt_wechat_message_push_ciphertext(aes_key, echostr, expected_app_id);
|
||||
}
|
||||
|
||||
let signature = query
|
||||
.signature
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string()))?;
|
||||
if !verify_wechat_message_push_signature(token, timestamp, nonce, "", signature) {
|
||||
return Err(WechatPayError::InvalidSignature(
|
||||
"微信消息推送校验签名无效".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(echostr.to_string())
|
||||
}
|
||||
|
||||
fn parse_wechat_mini_program_message_push_payload(
|
||||
body: &[u8],
|
||||
) -> Result<WechatMiniProgramEncryptedMessage, WechatPayError> {
|
||||
serde_json::from_slice(body).map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信消息推送 JSON 解析失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_wechat_message_push_signature(
|
||||
token: &str,
|
||||
timestamp: &str,
|
||||
nonce: &str,
|
||||
value: &str,
|
||||
signature: &str,
|
||||
) -> bool {
|
||||
let mut parts = [token, timestamp, nonce, value];
|
||||
parts.sort_unstable();
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(parts.join("").as_bytes());
|
||||
let expected = hex::encode(hasher.finalize());
|
||||
expected.eq_ignore_ascii_case(signature)
|
||||
}
|
||||
|
||||
fn decrypt_wechat_message_push_ciphertext(
|
||||
encoding_aes_key: &str,
|
||||
ciphertext: &str,
|
||||
expected_app_id: Option<&str>,
|
||||
) -> Result<String, WechatPayError> {
|
||||
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)?;
|
||||
let ciphertext = BASE64_STANDARD
|
||||
.decode(ciphertext.as_bytes())
|
||||
.map_err(|error| {
|
||||
WechatPayError::Crypto(format!("微信消息推送密文 Base64 解码失败:{error}"))
|
||||
})?;
|
||||
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
|
||||
let cipher = cbc::Decryptor::<Aes256>::new_from_slices(&key, iv)
|
||||
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送 AES 初始化失败:{error}")))?;
|
||||
let decrypted = cipher
|
||||
.decrypt_padded_vec_mut::<NoPadding>(&ciphertext)
|
||||
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送密文解密失败:{error}")))?;
|
||||
let plaintext = remove_wechat_message_push_pkcs7_padding(&decrypted)?;
|
||||
let payload = parse_wechat_message_push_plaintext(&plaintext)?;
|
||||
if let Some(app_id) = expected_app_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
&& payload.app_id != app_id
|
||||
{
|
||||
return Err(WechatPayError::InvalidSignature(
|
||||
"微信消息推送明文 appid 校验失败".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(payload.message)
|
||||
}
|
||||
|
||||
fn decode_wechat_message_push_encoding_aes_key(
|
||||
encoding_aes_key: &str,
|
||||
) -> Result<Vec<u8>, WechatPayError> {
|
||||
if encoding_aes_key.chars().count() != WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES {
|
||||
return Err(WechatPayError::InvalidConfig(format!(
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 必须是 {WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES} 位"
|
||||
)));
|
||||
}
|
||||
let padded_key = format!("{encoding_aes_key}=");
|
||||
let key = WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64
|
||||
.decode(padded_key.as_bytes())
|
||||
.map_err(|error| {
|
||||
WechatPayError::InvalidConfig(format!(
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY Base64 解析失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if key.len() != WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES {
|
||||
return Err(WechatPayError::InvalidConfig(
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 解码后长度必须为 32 字节".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn remove_wechat_message_push_pkcs7_padding(plaintext: &[u8]) -> Result<Vec<u8>, WechatPayError> {
|
||||
let Some(&pad_len) = plaintext.last() else {
|
||||
return Err(WechatPayError::Deserialize(
|
||||
"微信消息推送明文为空".to_string(),
|
||||
));
|
||||
};
|
||||
let pad_len = pad_len as usize;
|
||||
if pad_len == 0 || pad_len > 32 || pad_len > plaintext.len() {
|
||||
return Err(WechatPayError::Deserialize(
|
||||
"微信消息推送 PKCS7 填充无效".to_string(),
|
||||
));
|
||||
}
|
||||
if plaintext[plaintext.len() - pad_len..]
|
||||
.iter()
|
||||
.any(|byte| *byte as usize != pad_len)
|
||||
{
|
||||
return Err(WechatPayError::Deserialize(
|
||||
"微信消息推送 PKCS7 填充校验失败".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(plaintext[..plaintext.len() - pad_len].to_vec())
|
||||
}
|
||||
|
||||
struct WechatMessagePushPlaintext {
|
||||
message: String,
|
||||
app_id: String,
|
||||
}
|
||||
|
||||
fn parse_wechat_message_push_plaintext(
|
||||
plaintext: &[u8],
|
||||
) -> Result<WechatMessagePushPlaintext, WechatPayError> {
|
||||
if plaintext.len()
|
||||
< WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES + WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES + 1
|
||||
{
|
||||
return Err(WechatPayError::Deserialize(
|
||||
"微信消息推送明文长度不足".to_string(),
|
||||
));
|
||||
}
|
||||
let len_offset = WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES;
|
||||
let length_bytes: [u8; WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES] = plaintext
|
||||
[len_offset..len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES]
|
||||
.try_into()
|
||||
.map_err(|_| WechatPayError::Deserialize("微信消息推送长度字段解析失败".to_string()))?;
|
||||
let message_len = u32::from_be_bytes(length_bytes) as usize;
|
||||
let message_start = len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES;
|
||||
let message_end = message_start + message_len;
|
||||
if plaintext.len() <= message_end {
|
||||
return Err(WechatPayError::Deserialize(
|
||||
"微信消息推送明文长度与内容不匹配".to_string(),
|
||||
));
|
||||
}
|
||||
let app_id_start = message_end;
|
||||
let message =
|
||||
String::from_utf8(plaintext[message_start..message_end].to_vec()).map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信消息推送明文不是合法 UTF-8:{error}"))
|
||||
})?;
|
||||
let app_id =
|
||||
String::from_utf8(plaintext[app_id_start..plaintext.len()].to_vec()).map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信消息推送 appid 不是合法 UTF-8:{error}"))
|
||||
})?;
|
||||
Ok(WechatMessagePushPlaintext { message, app_id })
|
||||
}
|
||||
|
||||
fn parse_virtual_payment_notify(
|
||||
body: &[u8],
|
||||
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
|
||||
if let Ok(notify) = serde_json::from_slice::<WechatVirtualPaymentNotifyBody>(body) {
|
||||
return build_virtual_payment_notify_order(
|
||||
notify.event,
|
||||
notify.out_trade_no,
|
||||
notify.mch_order_id,
|
||||
notify.wechat_pay_info,
|
||||
);
|
||||
}
|
||||
|
||||
let text = std::str::from_utf8(body).map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}"))
|
||||
})?;
|
||||
let event = extract_virtual_payment_text_value(text, "Event")
|
||||
.ok_or_else(|| WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string()))?;
|
||||
let out_trade_no = extract_virtual_payment_text_value(text, "OutTradeNo");
|
||||
let mch_order_id = extract_virtual_payment_text_value(text, "MchOrderId");
|
||||
let wechat_pay_info = extract_virtual_payment_block(text, "WeChatPayInfo").map(|inner| {
|
||||
WechatVirtualPaymentNotifyPayInfo {
|
||||
mch_order_no: extract_virtual_payment_text_value(&inner, "MchOrderNo"),
|
||||
transaction_id: extract_virtual_payment_text_value(&inner, "TransactionId"),
|
||||
paid_time: extract_virtual_payment_text_value(&inner, "PaidTime")
|
||||
.and_then(|value| value.parse::<i64>().ok()),
|
||||
}
|
||||
});
|
||||
|
||||
build_virtual_payment_notify_order(event, out_trade_no, mch_order_id, wechat_pay_info)
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_order(
|
||||
event: String,
|
||||
out_trade_no: Option<String>,
|
||||
mch_order_id: Option<String>,
|
||||
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
|
||||
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
|
||||
let event = event.trim().to_string();
|
||||
if event.is_empty() {
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信虚拟支付推送缺少 Event".to_string(),
|
||||
));
|
||||
}
|
||||
let out_trade_no = out_trade_no
|
||||
.or(mch_order_id)
|
||||
.or_else(|| {
|
||||
wechat_pay_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.mch_order_no.clone())
|
||||
})
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WechatPayError::InvalidRequest("微信虚拟支付推送缺少 OutTradeNo".to_string())
|
||||
})?;
|
||||
let transaction_id = wechat_pay_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.transaction_id.clone())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let paid_at_micros = wechat_pay_info
|
||||
.and_then(|info| info.paid_time)
|
||||
.map(|paid_time| paid_time.saturating_mul(1_000_000));
|
||||
|
||||
Ok(WechatVirtualPaymentNotifyOrder {
|
||||
out_trade_no,
|
||||
transaction_id,
|
||||
paid_at_micros,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_virtual_payment_text_value(text: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = text.find(&open)? + open.len();
|
||||
let end = text[start..].find(&close)? + start;
|
||||
let raw = &text[start..end];
|
||||
Some(trim_virtual_payment_text_value(raw))
|
||||
}
|
||||
|
||||
fn extract_virtual_payment_block(text: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = text.find(&open)? + open.len();
|
||||
let end = text[start..].find(&close)? + start;
|
||||
Some(text[start..end].to_string())
|
||||
}
|
||||
|
||||
fn trim_virtual_payment_text_value(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if let Some(inner) = trimmed
|
||||
.strip_prefix("<![CDATA[")
|
||||
.and_then(|value| value.strip_suffix("]]>"))
|
||||
{
|
||||
return inner.trim().to_string();
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_error_response(
|
||||
error: WechatPayError,
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
warn!(error = %error, "微信虚拟支付通知处理失败");
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
build_virtual_payment_notify_response(response_format, 1, message)
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_success_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
build_virtual_payment_notify_response(response_format, 0, "success")
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> Response {
|
||||
let err_msg = err_msg.into();
|
||||
match response_format {
|
||||
VirtualPaymentNotifyResponseFormat::Json => Json(
|
||||
build_wechat_virtual_payment_notify_response(err_code, err_msg),
|
||||
)
|
||||
.into_response(),
|
||||
VirtualPaymentNotifyResponseFormat::Xml => {
|
||||
let body = format!(
|
||||
"<xml><ErrCode>{err_code}</ErrCode><ErrMsg><![CDATA[{err_msg}]]></ErrMsg></xml>"
|
||||
);
|
||||
let mut response = (StatusCode::OK, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/xml; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_wechat_pay_json_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
@@ -965,6 +1541,45 @@ fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError
|
||||
})
|
||||
}
|
||||
|
||||
fn build_wechat_virtual_payment_notify_response(
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> WechatVirtualPaymentNotifyResponse {
|
||||
WechatVirtualPaymentNotifyResponse {
|
||||
err_code,
|
||||
err_msg: err_msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum VirtualPaymentNotifyResponseFormat {
|
||||
Json,
|
||||
Xml,
|
||||
}
|
||||
|
||||
fn detect_virtual_payment_notify_response_format(
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> VirtualPaymentNotifyResponseFormat {
|
||||
let content_type = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if content_type.contains("xml") {
|
||||
return VirtualPaymentNotifyResponseFormat::Xml;
|
||||
}
|
||||
let body_trimmed = body
|
||||
.iter()
|
||||
.copied()
|
||||
.skip_while(|byte| byte.is_ascii_whitespace())
|
||||
.next();
|
||||
match body_trimmed {
|
||||
Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml,
|
||||
_ => VirtualPaymentNotifyResponseFormat::Json,
|
||||
}
|
||||
}
|
||||
|
||||
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
|
||||
value
|
||||
.map(str::trim)
|
||||
@@ -1330,6 +1945,7 @@ impl std::error::Error for WechatPayError {}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
|
||||
|
||||
#[test]
|
||||
fn mock_pay_params_use_request_payment_shape() {
|
||||
@@ -1551,4 +2167,178 @@ mod tests {
|
||||
assert_eq!(notify.transaction_id, None);
|
||||
assert_eq!(notify.trade_state, "SUCCESS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_virtual_payment_notify_supports_goods_event_json() {
|
||||
let notify = parse_virtual_payment_notify(
|
||||
br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"TransactionId":"wx-1","PaidTime":1710000000}}"#,
|
||||
)
|
||||
.expect("virtual payment notify should parse");
|
||||
|
||||
assert_eq!(notify.event, "xpay_goods_deliver_notify");
|
||||
assert_eq!(notify.out_trade_no, "order-1");
|
||||
assert_eq!(notify.transaction_id.as_deref(), Some("wx-1"));
|
||||
assert_eq!(notify.paid_at_micros, Some(1_710_000_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_virtual_payment_notify_supports_coin_event_xml() {
|
||||
let notify = parse_virtual_payment_notify(
|
||||
br#"<xml><Event><![CDATA[xpay_coin_pay_notify]]></Event><OutTradeNo><![CDATA[order-2]]></OutTradeNo><WeChatPayInfo><TransactionId><![CDATA[wx-2]]></TransactionId><PaidTime>1710000001</PaidTime></WeChatPayInfo></xml>"#,
|
||||
)
|
||||
.expect("virtual payment xml notify should parse");
|
||||
|
||||
assert_eq!(notify.event, "xpay_coin_pay_notify");
|
||||
assert_eq!(notify.out_trade_no, "order-2");
|
||||
assert_eq!(notify.transaction_id.as_deref(), Some("wx-2"));
|
||||
assert_eq!(notify.paid_at_micros, Some(1_710_000_001_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_virtual_payment_notify_rejects_missing_order_no() {
|
||||
let error = parse_virtual_payment_notify(br#"{"Event":"xpay_goods_deliver_notify"}"#)
|
||||
.expect_err("missing order id should fail");
|
||||
|
||||
match error {
|
||||
WechatPayError::InvalidRequest(message) => {
|
||||
assert!(message.contains("OutTradeNo"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_wechat_message_push_encoding_aes_key_allows_trailing_bits() {
|
||||
let canonical_key = BASE64_STANDARD.encode([0u8; 32]);
|
||||
let mut encoding_aes_key = canonical_key.trim_end_matches('=').to_string();
|
||||
encoding_aes_key.replace_range(encoding_aes_key.len() - 1.., "B");
|
||||
|
||||
let decoded = decode_wechat_message_push_encoding_aes_key(&encoding_aes_key)
|
||||
.expect("wechat aes key with trailing bits should decode");
|
||||
|
||||
assert_eq!(decoded, vec![0u8; WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_message_push_signature_uses_sorted_sha1_parts() {
|
||||
let token = "token-1";
|
||||
let timestamp = "1710000000";
|
||||
let nonce = "nonce-1";
|
||||
let encrypt = "encrypted-payload";
|
||||
let signature = build_wechat_message_push_test_signature(token, timestamp, nonce, encrypt);
|
||||
|
||||
assert!(verify_wechat_message_push_signature(
|
||||
token, timestamp, nonce, encrypt, &signature
|
||||
));
|
||||
assert!(!verify_wechat_message_push_signature(
|
||||
token,
|
||||
timestamp,
|
||||
nonce,
|
||||
"tampered-payload",
|
||||
&signature
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_message_push_plain_get_verify_returns_echostr() {
|
||||
let token = "AAAAA";
|
||||
let timestamp = "1714036504";
|
||||
let nonce = "1514711492";
|
||||
let echostr = "4375120948345356249";
|
||||
let signature = "f464b24fc39322e44b38aa78f5edd27bd1441696";
|
||||
|
||||
let plaintext = resolve_wechat_message_push_verify_response(
|
||||
token,
|
||||
"unused-aes-key",
|
||||
Some("wx-test-app"),
|
||||
&WechatMiniProgramMessagePushQuery {
|
||||
signature: Some(signature.to_string()),
|
||||
timestamp: Some(timestamp.to_string()),
|
||||
nonce: Some(nonce.to_string()),
|
||||
echostr: Some(echostr.to_string()),
|
||||
msg_signature: None,
|
||||
},
|
||||
)
|
||||
.expect("plain url verification should return echostr");
|
||||
|
||||
assert_eq!(plaintext, echostr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_message_push_decrypts_safe_mode_ciphertext() {
|
||||
let app_id = "wx-test-app";
|
||||
let message = r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#;
|
||||
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
|
||||
let encrypted =
|
||||
encrypt_wechat_message_push_test_ciphertext(&encoding_aes_key, message, app_id);
|
||||
|
||||
let decrypted =
|
||||
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some(app_id))
|
||||
.expect("encrypted message should decrypt");
|
||||
|
||||
assert_eq!(decrypted, message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_message_push_rejects_mismatched_app_id() {
|
||||
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
|
||||
let encrypted = encrypt_wechat_message_push_test_ciphertext(
|
||||
&encoding_aes_key,
|
||||
r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#,
|
||||
"wx-real-app",
|
||||
);
|
||||
|
||||
let error =
|
||||
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some("wx-other"))
|
||||
.expect_err("mismatched app id should fail");
|
||||
|
||||
match error {
|
||||
WechatPayError::InvalidSignature(message) => {
|
||||
assert!(message.contains("appid"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wechat_message_push_test_signature(
|
||||
token: &str,
|
||||
timestamp: &str,
|
||||
nonce: &str,
|
||||
value: &str,
|
||||
) -> String {
|
||||
let mut parts = [token, timestamp, nonce, value];
|
||||
parts.sort_unstable();
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(parts.join("").as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn build_wechat_message_push_test_encoding_aes_key() -> String {
|
||||
let raw_key = std::array::from_fn::<_, 32, _>(|index| index as u8);
|
||||
BASE64_STANDARD
|
||||
.encode(raw_key)
|
||||
.trim_end_matches('=')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn encrypt_wechat_message_push_test_ciphertext(
|
||||
encoding_aes_key: &str,
|
||||
message: &str,
|
||||
app_id: &str,
|
||||
) -> String {
|
||||
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)
|
||||
.expect("test aes key should decode");
|
||||
let mut plaintext = Vec::new();
|
||||
plaintext.extend_from_slice(b"0123456789abcdef");
|
||||
plaintext.extend_from_slice(&(message.as_bytes().len() as u32).to_be_bytes());
|
||||
plaintext.extend_from_slice(message.as_bytes());
|
||||
plaintext.extend_from_slice(app_id.as_bytes());
|
||||
let pad_len = 32 - (plaintext.len() % 32);
|
||||
plaintext.extend(std::iter::repeat(pad_len as u8).take(pad_len));
|
||||
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
|
||||
let cipher = cbc::Encryptor::<Aes256>::new_from_slices(&key, iv)
|
||||
.expect("test aes cipher should init");
|
||||
let encrypted = cipher.encrypt_padded_vec_mut::<NoPadding>(&plaintext);
|
||||
BASE64_STANDARD.encode(encrypted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,26 +147,34 @@ pub async fn execute_wooden_fish_action(
|
||||
wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
maybe_generate_hit_object_asset(
|
||||
let result = execute_wooden_fish_action_with_generated_assets(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
owner_user_id.as_str(),
|
||||
&owner_user_id,
|
||||
&author_display_name,
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
maybe_generate_hit_sound_asset(&mut payload);
|
||||
let response = state
|
||||
.spacetime_client()
|
||||
.execute_wooden_fish_action(session_id, owner_user_id, author_display_name, payload)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
&request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})?;
|
||||
.await;
|
||||
if result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|response| response.status().is_server_error())
|
||||
&& matches!(
|
||||
payload.action_type,
|
||||
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
|
||||
)
|
||||
{
|
||||
mark_wooden_fish_generation_failed(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
owner_user_id.as_str(),
|
||||
author_display_name.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let response = result?;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
@@ -372,16 +380,24 @@ async fn build_wooden_fish_draft(
|
||||
payload: &WoodenFishWorkspaceCreateRequest,
|
||||
state: &AppState,
|
||||
) -> Result<WoodenFishDraftResponse, Response> {
|
||||
Ok(WoodenFishDraftResponse {
|
||||
let work_title = resolve_wooden_fish_work_title(
|
||||
state,
|
||||
&payload.work_description,
|
||||
&payload.hit_object_prompt,
|
||||
)
|
||||
.await?;
|
||||
Ok(build_wooden_fish_draft_response(payload, work_title))
|
||||
}
|
||||
|
||||
fn build_wooden_fish_draft_response(
|
||||
payload: &WoodenFishWorkspaceCreateRequest,
|
||||
work_title: String,
|
||||
) -> WoodenFishDraftResponse {
|
||||
WoodenFishDraftResponse {
|
||||
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
|
||||
template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(),
|
||||
profile_id: None,
|
||||
work_title: resolve_wooden_fish_work_title(
|
||||
state,
|
||||
&payload.work_description,
|
||||
&payload.hit_object_prompt,
|
||||
)
|
||||
.await?,
|
||||
work_title,
|
||||
work_description: payload.work_description.trim().to_string(),
|
||||
theme_tags: normalize_tags(payload.theme_tags.clone()),
|
||||
hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT),
|
||||
@@ -401,7 +417,7 @@ async fn build_wooden_fish_draft(
|
||||
.or_else(|| Some(default_wooden_fish_hit_sound_asset())),
|
||||
cover_image_src: None,
|
||||
generation_status: WoodenFishGenerationStatus::Draft,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_workspace_request(
|
||||
@@ -543,6 +559,62 @@ async fn maybe_generate_hit_object_asset(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_wooden_fish_action_with_generated_assets(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
author_display_name: &str,
|
||||
payload: &mut WoodenFishActionRequest,
|
||||
) -> Result<shared_contracts::wooden_fish::WoodenFishActionResponse, Response> {
|
||||
maybe_generate_hit_object_asset(state, request_context, session_id, owner_user_id, payload)
|
||||
.await?;
|
||||
maybe_generate_hit_sound_asset(payload);
|
||||
state
|
||||
.spacetime_client()
|
||||
.execute_wooden_fish_action(
|
||||
session_id.to_string(),
|
||||
owner_user_id.to_string(),
|
||||
author_display_name.to_string(),
|
||||
payload.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn mark_wooden_fish_generation_failed(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
author_display_name: &str,
|
||||
) {
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.mark_wooden_fish_generation_failed(
|
||||
session_id.to_string(),
|
||||
owner_user_id.to_string(),
|
||||
author_display_name.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
provider = WOODEN_FISH_CREATION_PROVIDER,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
request_id = request_context.request_id(),
|
||||
error = %error,
|
||||
"敲木鱼草稿生成失败后的状态回写失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {
|
||||
WoodenFishImageAsset {
|
||||
asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(),
|
||||
|
||||
@@ -116,6 +116,7 @@ pub struct WechatIdentityProfile {
|
||||
pub provider_union_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub session_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 已绑定微信身份快照。
|
||||
@@ -124,6 +125,7 @@ pub struct WechatIdentityRecord {
|
||||
pub user_id: String,
|
||||
pub provider_uid: String,
|
||||
pub provider_union_id: Option<String>,
|
||||
pub session_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 微信授权 state 快照。
|
||||
|
||||
@@ -11,7 +11,7 @@ pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,7 @@ struct StoredWechatIdentity {
|
||||
provider_union_id: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
session_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -917,16 +918,47 @@ impl Default for InMemoryAuthStoreState {
|
||||
|
||||
impl InMemoryAuthStoreState {
|
||||
fn from_persistent_snapshot(snapshot: PersistentAuthStoreSnapshot) -> Self {
|
||||
let existing_user_ids = snapshot
|
||||
.users_by_username
|
||||
.values()
|
||||
.map(|stored| stored.user.id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let phone_to_user_id = snapshot
|
||||
.phone_to_user_id
|
||||
.into_iter()
|
||||
.filter(|(_, user_id)| existing_user_ids.contains(user_id))
|
||||
.collect();
|
||||
let sessions_by_id = snapshot
|
||||
.sessions_by_id
|
||||
.into_iter()
|
||||
.filter(|(_, stored)| existing_user_ids.contains(&stored.session.user_id))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let session_id_by_refresh_token_hash = snapshot
|
||||
.session_id_by_refresh_token_hash
|
||||
.into_iter()
|
||||
.filter(|(_, session_id)| sessions_by_id.contains_key(session_id))
|
||||
.collect();
|
||||
let wechat_identity_by_provider_uid = snapshot
|
||||
.wechat_identity_by_provider_uid
|
||||
.into_iter()
|
||||
.filter(|(_, identity)| existing_user_ids.contains(&identity.user_id))
|
||||
.collect();
|
||||
let user_id_by_provider_union_id = snapshot
|
||||
.user_id_by_provider_union_id
|
||||
.into_iter()
|
||||
.filter(|(_, user_id)| existing_user_ids.contains(user_id))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
next_user_id: snapshot.next_user_id,
|
||||
users_by_username: snapshot.users_by_username,
|
||||
phone_to_user_id: snapshot.phone_to_user_id,
|
||||
sessions_by_id: snapshot.sessions_by_id,
|
||||
session_id_by_refresh_token_hash: snapshot.session_id_by_refresh_token_hash,
|
||||
phone_to_user_id,
|
||||
sessions_by_id,
|
||||
session_id_by_refresh_token_hash,
|
||||
phone_codes_by_key: HashMap::new(),
|
||||
wechat_states_by_token: HashMap::new(),
|
||||
wechat_identity_by_provider_uid: snapshot.wechat_identity_by_provider_uid,
|
||||
user_id_by_provider_union_id: snapshot.user_id_by_provider_union_id,
|
||||
wechat_identity_by_provider_uid,
|
||||
user_id_by_provider_union_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,6 +1356,7 @@ impl InMemoryAuthStore {
|
||||
provider_union_id: normalize_optional_string(profile.provider_union_id),
|
||||
display_name: normalize_optional_string(profile.display_name),
|
||||
avatar_url,
|
||||
session_key: normalize_optional_string(profile.session_key),
|
||||
};
|
||||
if let Some(provider_union_id) = identity.provider_union_id.clone() {
|
||||
state
|
||||
@@ -1393,6 +1426,7 @@ impl InMemoryAuthStore {
|
||||
user_id: identity.user_id.clone(),
|
||||
provider_uid: identity.provider_uid.clone(),
|
||||
provider_union_id: identity.provider_union_id.clone(),
|
||||
session_key: identity.session_key.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1409,6 +1443,7 @@ impl InMemoryAuthStore {
|
||||
let next_display_name = normalize_optional_string(profile.display_name);
|
||||
let next_avatar_url = normalize_optional_string(profile.avatar_url);
|
||||
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
|
||||
let next_session_key = normalize_optional_string(profile.session_key);
|
||||
let next_provider_uid =
|
||||
normalize_required_string(&profile.provider_uid).unwrap_or_default();
|
||||
{
|
||||
@@ -1430,6 +1465,9 @@ impl InMemoryAuthStore {
|
||||
identity.display_name = next_display_name.clone();
|
||||
identity.avatar_url = next_avatar_url;
|
||||
identity.provider_union_id = next_provider_union_id.clone();
|
||||
if next_session_key.is_some() {
|
||||
identity.session_key = next_session_key.clone();
|
||||
}
|
||||
state
|
||||
.wechat_identity_by_provider_uid
|
||||
.insert(next_provider_uid.clone(), identity);
|
||||
@@ -2722,6 +2760,54 @@ mod tests {
|
||||
assert_eq!(rotated.user.id, user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_json_drops_orphan_phone_index_before_phone_login() {
|
||||
let snapshot = PersistentAuthStoreSnapshot {
|
||||
next_user_id: 9,
|
||||
users_by_username: HashMap::new(),
|
||||
phone_to_user_id: HashMap::from([(
|
||||
"+8613800138032".to_string(),
|
||||
"user_missing_phone_owner".to_string(),
|
||||
)]),
|
||||
sessions_by_id: HashMap::new(),
|
||||
session_id_by_refresh_token_hash: HashMap::new(),
|
||||
wechat_identity_by_provider_uid: HashMap::new(),
|
||||
user_id_by_provider_union_id: HashMap::new(),
|
||||
};
|
||||
let snapshot_json = serde_json::to_string(&snapshot).expect("snapshot should serialize");
|
||||
let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.expect("snapshot json should restore");
|
||||
let phone_service = build_phone_service(restored_store);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
phone_service
|
||||
.send_code(
|
||||
SendPhoneCodeInput {
|
||||
phone_number: "13800138032".to_string(),
|
||||
scene: PhoneAuthScene::Login,
|
||||
},
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.expect("phone code should send");
|
||||
let result = phone_service
|
||||
.login(
|
||||
PhoneLoginInput {
|
||||
phone_number: "13800138032".to_string(),
|
||||
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||
},
|
||||
now + Duration::seconds(1),
|
||||
)
|
||||
.await
|
||||
.expect("orphan phone index should not block phone login");
|
||||
|
||||
assert!(result.created);
|
||||
assert_eq!(
|
||||
result.user.phone_number_masked.as_deref(),
|
||||
Some("138****8032")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_email_or_username_identifier() {
|
||||
let service = build_password_service(build_store());
|
||||
@@ -3314,6 +3400,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-shared".to_string()),
|
||||
display_name: Some("微信旅人甲".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -3335,6 +3422,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-shared".to_string()),
|
||||
display_name: Some("微信旅人乙".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -3383,6 +3471,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-bind".to_string()),
|
||||
display_name: Some("待绑定微信用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -3428,6 +3517,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-bind".to_string()),
|
||||
display_name: Some("已归并微信用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -12,11 +12,22 @@ use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
|
||||
};
|
||||
|
||||
/// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。
|
||||
pub fn build_creation_entry_config_response(
|
||||
snapshot: CreationEntryConfigSnapshot,
|
||||
) -> CreationEntryConfigResponse {
|
||||
let event_banners = resolve_creation_entry_event_banner_responses(
|
||||
snapshot.event_banners_json.as_deref(),
|
||||
&snapshot.event_banner,
|
||||
);
|
||||
let event_banner = event_banners
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| build_creation_entry_event_banner_response(snapshot.event_banner));
|
||||
|
||||
CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
title: snapshot.start_card.title,
|
||||
@@ -28,35 +39,303 @@ pub fn build_creation_entry_config_response(
|
||||
title: snapshot.type_modal.title,
|
||||
description: snapshot.type_modal.description,
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: snapshot.event_banner.title,
|
||||
description: snapshot.event_banner.description,
|
||||
cover_image_src: snapshot.event_banner.cover_image_src,
|
||||
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
|
||||
starts_at_text: snapshot.event_banner.starts_at_text,
|
||||
ends_at_text: snapshot.event_banner.ends_at_text,
|
||||
},
|
||||
event_banner,
|
||||
event_banners,
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(|item| CreationEntryTypeResponse {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
.map(|item| {
|
||||
let unified_creation_spec = resolve_unified_creation_spec_response(
|
||||
item.id.as_str(),
|
||||
item.unified_creation_spec_json.as_deref(),
|
||||
);
|
||||
CreationEntryTypeResponse {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
unified_creation_spec,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
|
||||
pub fn default_creation_entry_event_banner_snapshots() -> Vec<CreationEntryEventBannerSnapshot> {
|
||||
vec![CreationEntryEventBannerSnapshot {
|
||||
title: "创作公告".to_string(),
|
||||
description: String::new(),
|
||||
cover_image_src: String::new(),
|
||||
prize_pool_mud_points: 0,
|
||||
starts_at_text: String::new(),
|
||||
ends_at_text: String::new(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some(
|
||||
r#"<section style="box-sizing:border-box;width:100%;min-height:180px;padding:28px 30px;border-radius:24px;background:#fff7ed;color:#6f2f21;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"><h1 style="margin:0 0 10px;font-size:28px;">创作公告</h1><p style="margin:0;font-size:16px;line-height:1.7;">这里可以在后台替换成你的公告 HTML。</p></section>"#
|
||||
.to_string(),
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
/// 生成默认公告 JSON,供 SpacetimeDB 表字段持久化。
|
||||
pub fn default_creation_entry_event_banners_json() -> String {
|
||||
encode_creation_entry_event_banner_snapshots(&default_creation_entry_event_banner_snapshots())
|
||||
.unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
/// 校验并归一后台公告表单序列化后的持久化 JSON。
|
||||
pub fn normalize_creation_entry_event_banners_json(input: &str) -> Result<String, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(default_creation_entry_event_banners_json());
|
||||
}
|
||||
|
||||
let banners = decode_creation_entry_event_banner_snapshots(trimmed)?;
|
||||
encode_creation_entry_event_banner_snapshots(&banners)
|
||||
}
|
||||
|
||||
/// 解析后台公告持久化 JSON,输出已归一化的领域快照。
|
||||
pub fn decode_creation_entry_event_banner_snapshots(
|
||||
input: &str,
|
||||
) -> Result<Vec<CreationEntryEventBannerSnapshot>, String> {
|
||||
let raw_value =
|
||||
serde_json::from_str::<Value>(input).map_err(|error| format!("公告 JSON 非法:{error}"))?;
|
||||
let banners = raw_value
|
||||
.as_array()
|
||||
.ok_or_else(|| "公告 JSON 必须是数组".to_string())?;
|
||||
if banners.is_empty() {
|
||||
return Err("公告至少需要配置一条".to_string());
|
||||
}
|
||||
if banners.len() > CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT {
|
||||
return Err(format!(
|
||||
"公告最多配置 {} 条",
|
||||
CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT
|
||||
));
|
||||
}
|
||||
|
||||
banners
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, banner)| normalize_creation_entry_announcement_banner_value(index, banner))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 归一后台公告配置:新格式支持 HTML 字符串 / `{title, htmlCode}`,旧结构化 banner 保持兼容。
|
||||
fn normalize_creation_entry_announcement_banner_value(
|
||||
index: usize,
|
||||
value: &Value,
|
||||
) -> Result<CreationEntryEventBannerSnapshot, String> {
|
||||
if let Some(html_code) = value.as_str() {
|
||||
return build_creation_entry_html_announcement_snapshot(index, None, html_code.to_string());
|
||||
}
|
||||
|
||||
let Some(object) = value.as_object() else {
|
||||
return Err(format!("第 {} 条公告必须是 HTML 字符串或对象", index + 1));
|
||||
};
|
||||
let explicit_render_mode = value
|
||||
.get("renderMode")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !explicit_render_mode.is_empty() && !explicit_render_mode.eq_ignore_ascii_case("html") {
|
||||
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
|
||||
object.clone(),
|
||||
))
|
||||
.map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?;
|
||||
return normalize_creation_entry_event_banner_response(index, banner);
|
||||
}
|
||||
if let Some(html_code) = read_announcement_html_code(value) {
|
||||
return build_creation_entry_html_announcement_snapshot(
|
||||
index,
|
||||
read_announcement_title(value),
|
||||
html_code,
|
||||
);
|
||||
}
|
||||
|
||||
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
|
||||
object.clone(),
|
||||
))
|
||||
.map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?;
|
||||
normalize_creation_entry_event_banner_response(index, banner)
|
||||
}
|
||||
|
||||
/// 将后台公告 HTML 代码包装成前台沙箱 iframe 可渲染的 banner 快照。
|
||||
fn build_creation_entry_html_announcement_snapshot(
|
||||
index: usize,
|
||||
title: Option<String>,
|
||||
html_code: String,
|
||||
) -> Result<CreationEntryEventBannerSnapshot, String> {
|
||||
Ok(CreationEntryEventBannerSnapshot {
|
||||
title: title
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| format!("公告 {}", index + 1)),
|
||||
description: String::new(),
|
||||
cover_image_src: String::new(),
|
||||
prize_pool_mud_points: 0,
|
||||
starts_at_text: String::new(),
|
||||
ends_at_text: String::new(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: normalize_banner_html_code(index, "html", Some(html_code))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读取公告对象标题,兼容 title/name 两种后台填写习惯。
|
||||
fn read_announcement_title(value: &Value) -> Option<String> {
|
||||
read_string_field(value, &["title", "name"])
|
||||
}
|
||||
|
||||
/// 读取公告 HTML 代码,兼容 htmlCode/html/code 三种后台填写习惯。
|
||||
fn read_announcement_html_code(value: &Value) -> Option<String> {
|
||||
read_string_field(value, &["htmlCode", "html", "code"])
|
||||
}
|
||||
|
||||
/// 从 JSON 对象读取第一个非空字符串字段。
|
||||
fn read_string_field(value: &Value, field_names: &[&str]) -> Option<String> {
|
||||
field_names.iter().find_map(|field_name| {
|
||||
value
|
||||
.get(*field_name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
/// 把公告领域快照编码为稳定 JSON。
|
||||
pub fn encode_creation_entry_event_banner_snapshots(
|
||||
banners: &[CreationEntryEventBannerSnapshot],
|
||||
) -> Result<String, String> {
|
||||
if banners.is_empty() {
|
||||
return Err("公告至少需要配置一条".to_string());
|
||||
}
|
||||
|
||||
let responses = banners
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string_pretty(&responses)
|
||||
.map_err(|error| format!("公告 JSON 序列化失败:{error}"))
|
||||
}
|
||||
|
||||
/// 根据持久化 JSON 或旧单条字段得到前台可渲染公告列表。
|
||||
pub fn resolve_creation_entry_event_banner_responses(
|
||||
event_banners_json: Option<&str>,
|
||||
fallback_banner: &CreationEntryEventBannerSnapshot,
|
||||
) -> Vec<CreationEntryEventBannerResponse> {
|
||||
event_banners_json
|
||||
.and_then(|raw| decode_creation_entry_event_banner_snapshots(raw).ok())
|
||||
.filter(|banners| !banners.is_empty())
|
||||
.unwrap_or_else(|| vec![fallback_banner.clone()])
|
||||
.into_iter()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 把领域公告快照转换为 HTTP 响应字段。
|
||||
pub fn build_creation_entry_event_banner_response(
|
||||
banner: CreationEntryEventBannerSnapshot,
|
||||
) -> CreationEntryEventBannerResponse {
|
||||
CreationEntryEventBannerResponse {
|
||||
title: banner.title,
|
||||
description: banner.description,
|
||||
cover_image_src: banner.cover_image_src,
|
||||
prize_pool_mud_points: banner.prize_pool_mud_points,
|
||||
starts_at_text: banner.starts_at_text,
|
||||
ends_at_text: banner.ends_at_text,
|
||||
render_mode: normalize_banner_render_mode(&banner.render_mode),
|
||||
html_code: banner.html_code,
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验旧结构化 banner 响应并转换为领域公告快照。
|
||||
fn normalize_creation_entry_event_banner_response(
|
||||
index: usize,
|
||||
banner: CreationEntryEventBannerResponse,
|
||||
) -> Result<CreationEntryEventBannerSnapshot, String> {
|
||||
let render_mode = normalize_banner_render_mode(&banner.render_mode);
|
||||
let html_code = normalize_banner_html_code(index, render_mode.as_str(), banner.html_code)?;
|
||||
let default_banner = default_creation_entry_event_banner_snapshots()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("default banner should exist");
|
||||
|
||||
Ok(CreationEntryEventBannerSnapshot {
|
||||
title: normalize_banner_text(banner.title, default_banner.title),
|
||||
description: normalize_banner_text(banner.description, default_banner.description),
|
||||
cover_image_src: normalize_banner_text(
|
||||
banner.cover_image_src,
|
||||
default_banner.cover_image_src,
|
||||
),
|
||||
prize_pool_mud_points: banner.prize_pool_mud_points,
|
||||
starts_at_text: normalize_banner_text(banner.starts_at_text, default_banner.starts_at_text),
|
||||
ends_at_text: normalize_banner_text(banner.ends_at_text, default_banner.ends_at_text),
|
||||
render_mode,
|
||||
html_code,
|
||||
})
|
||||
}
|
||||
|
||||
/// 归一化公告渲染模式,未知值统一回到结构化兼容 UI。
|
||||
fn normalize_banner_render_mode(value: &str) -> String {
|
||||
if value.trim().eq_ignore_ascii_case("html") {
|
||||
"html".to_string()
|
||||
} else {
|
||||
"structured".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理旧结构化 banner 文案字段,空值沿用平台默认文案。
|
||||
fn normalize_banner_text(value: String, fallback: String) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
fallback
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 HTML 公告片段,只允许交给前端沙箱 iframe 展示。
|
||||
fn normalize_banner_html_code(
|
||||
index: usize,
|
||||
render_mode: &str,
|
||||
value: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
if render_mode != "html" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let html_code = value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("第 {} 条 HTML 公告缺少 htmlCode", index + 1))?;
|
||||
if html_code.len() > CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"第 {} 条 HTML 公告超过 {} 字节",
|
||||
index + 1,
|
||||
CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES
|
||||
));
|
||||
}
|
||||
let lower_html_code = html_code.to_ascii_lowercase();
|
||||
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
|
||||
return Err(format!(
|
||||
"第 {} 条 HTML 公告含有不允许的脚本代码",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(html_code))
|
||||
}
|
||||
|
||||
pub fn default_creation_entry_type_snapshots(
|
||||
updated_at_micros: i64,
|
||||
) -> Vec<CreationEntryTypeSnapshot> {
|
||||
@@ -70,9 +349,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
10,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -98,9 +377,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
30,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -112,9 +391,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
40,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -274,9 +553,15 @@ fn build_default_creation_entry_type_snapshot(
|
||||
category_label: category_label.to_string(),
|
||||
category_sort_order,
|
||||
updated_at_micros,
|
||||
unified_creation_spec_json: default_unified_creation_spec_json(id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_unified_creation_spec_json(play_id: &str) -> Option<String> {
|
||||
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(play_id)
|
||||
.and_then(|spec| encode_unified_creation_spec_response(&spec).ok())
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
|
||||
RuntimeSettingsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
|
||||
@@ -34,6 +34,7 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL: &str = "wechat_mp_virtual";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native";
|
||||
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
|
||||
@@ -50,8 +51,9 @@ pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab";
|
||||
pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启";
|
||||
pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型";
|
||||
pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recent";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "最近创作";
|
||||
/// 创作模板分类缺省回退到推荐,不再把真实作品维度的“最近创作”种成模板页签。
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recommended";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "热门推荐";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
|
||||
@@ -59,6 +61,10 @@ pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000;
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
|
||||
/// 后台创作入口公告最多允许配置的轮播条数。
|
||||
pub const CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT: usize = 8;
|
||||
/// 单条 HTML 公告的代码大小上限,避免后台误贴超大片段拖慢入口页。
|
||||
pub const CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES: usize = 12_000;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -76,6 +82,7 @@ pub struct CreationEntryTypeModalSnapshot {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 创作入口公告快照,支持 HTML 公告渲染和旧结构化 banner 兼容。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryEventBannerSnapshot {
|
||||
@@ -85,6 +92,8 @@ pub struct CreationEntryEventBannerSnapshot {
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
pub render_mode: String,
|
||||
pub html_code: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -102,8 +111,10 @@ pub struct CreationEntryTypeSnapshot {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 创作入口全局配置快照,供前台入口页和后台配置页共同读取。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryConfigSnapshot {
|
||||
@@ -111,6 +122,8 @@ pub struct CreationEntryConfigSnapshot {
|
||||
pub start_card: CreationEntryStartCardSnapshot,
|
||||
pub type_modal: CreationEntryTypeModalSnapshot,
|
||||
pub event_banner: CreationEntryEventBannerSnapshot,
|
||||
/// 底部加号创作入口页的多公告 JSON 配置;旧库为空时由应用层兜底。
|
||||
pub event_banners_json: Option<String>,
|
||||
pub creation_types: Vec<CreationEntryTypeSnapshot>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
@@ -129,6 +142,15 @@ pub struct CreationEntryTypeAdminUpsertInput {
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口多公告表单序列化结果的领域输入。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryEventBannersAdminUpsertInput {
|
||||
/// 持久化字段沿用 JSON 字符串,内容由后台表单生成。
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
@@ -253,11 +253,135 @@ mod tests {
|
||||
assert!(rpg.open);
|
||||
assert_eq!(rpg.badge, "可创建");
|
||||
assert_eq!(rpg.sort_order, 10);
|
||||
assert_eq!(rpg.category_id, "recent");
|
||||
assert_eq!(rpg.category_label, "最近创作");
|
||||
assert_eq!(rpg.category_id, "recommended");
|
||||
assert_eq!(rpg.category_label, "热门推荐");
|
||||
assert_eq!(rpg.category_sort_order, 20);
|
||||
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_do_not_seed_recent_as_template_category() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
|
||||
assert!(configs.iter().all(|item| item.category_id != "recent"));
|
||||
assert!(configs.iter().all(|item| item.category_label != "最近创作"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_normalizes_multiple_banners() {
|
||||
let normalized = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
{
|
||||
"title": " 周末拼图赛 ",
|
||||
"description": " 拼一个新主题 ",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 1200,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30",
|
||||
"renderMode": "structured",
|
||||
"htmlCode": "<div>ignored</div>"
|
||||
},
|
||||
{
|
||||
"title": "HTML 横幅",
|
||||
"description": "沙箱片段",
|
||||
"coverImageSrc": "/creation-type-references/match3d.webp",
|
||||
"prizePoolMudPoints": 900,
|
||||
"startsAtText": "2026-07-01",
|
||||
"endsAtText": "2026-07-31",
|
||||
"renderMode": "html",
|
||||
"htmlCode": " <section>安全片段</section> "
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.expect("valid banner json should normalize");
|
||||
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
||||
.expect("normalized banner json should decode");
|
||||
|
||||
assert_eq!(banners.len(), 2);
|
||||
assert_eq!(banners[0].title, "周末拼图赛");
|
||||
assert_eq!(banners[0].description, "拼一个新主题");
|
||||
assert_eq!(banners[0].render_mode, "structured");
|
||||
assert!(banners[0].html_code.is_none());
|
||||
assert_eq!(banners[1].render_mode, "html");
|
||||
assert_eq!(
|
||||
banners[1].html_code.as_deref(),
|
||||
Some("<section>安全片段</section>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_empty_input_returns_defaults() {
|
||||
let normalized = normalize_creation_entry_event_banners_json(" ")
|
||||
.expect("blank banner json should use defaults");
|
||||
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
||||
.expect("default banner json should decode");
|
||||
|
||||
assert_eq!(banners, default_creation_entry_event_banner_snapshots());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_accepts_announcement_html_code() {
|
||||
let normalized = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
"<section>纯 HTML 公告</section>",
|
||||
{"title": "后台公告", "htmlCode": "<article>自定义公告</article>"}
|
||||
]"#,
|
||||
)
|
||||
.expect("announcement html json should normalize");
|
||||
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
||||
.expect("normalized announcement json should decode");
|
||||
|
||||
assert_eq!(banners.len(), 2);
|
||||
assert_eq!(banners[0].title, "公告 1");
|
||||
assert_eq!(banners[0].render_mode, "html");
|
||||
assert_eq!(
|
||||
banners[0].html_code.as_deref(),
|
||||
Some("<section>纯 HTML 公告</section>")
|
||||
);
|
||||
assert_eq!(banners[1].title, "后台公告");
|
||||
assert_eq!(
|
||||
banners[1].html_code.as_deref(),
|
||||
Some("<article>自定义公告</article>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_rejects_script_like_html() {
|
||||
let script_error = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
{
|
||||
"title": "脚本横幅",
|
||||
"description": "不允许脚本",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 100,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30",
|
||||
"renderMode": "html",
|
||||
"htmlCode": "<script>alert(1)</script>"
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.expect_err("script tag should be rejected");
|
||||
let javascript_url_error = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
{
|
||||
"title": "链接横幅",
|
||||
"description": "不允许 javascript URL",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 100,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30",
|
||||
"renderMode": "html",
|
||||
"htmlCode": "<a href=\"javascript:alert(1)\">bad</a>"
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.expect_err("javascript url should be rejected");
|
||||
|
||||
assert!(script_error.contains("脚本代码"));
|
||||
assert!(javascript_url_error.contains("脚本代码"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_include_bark_battle() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
|
||||
@@ -225,6 +225,7 @@ pub struct WechatIdentityProfile {
|
||||
pub provider_union_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub session_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -359,6 +360,7 @@ struct WechatUserInfoResponse {
|
||||
struct WechatJsCodeSessionResponse {
|
||||
openid: Option<String>,
|
||||
unionid: Option<String>,
|
||||
session_key: Option<String>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
@@ -834,6 +836,7 @@ impl MockWechatProvider {
|
||||
provider_union_id: self.mock_union_id.clone(),
|
||||
display_name: Some(self.mock_display_name.clone()),
|
||||
avatar_url: self.mock_avatar_url.clone(),
|
||||
session_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -975,6 +978,7 @@ impl RealWechatProvider {
|
||||
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
|
||||
display_name: user_info_payload.nickname,
|
||||
avatar_url: user_info_payload.headimgurl,
|
||||
session_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1053,6 +1057,7 @@ impl RealWechatProvider {
|
||||
provider_union_id: payload.unionid,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
session_key: payload.session_key,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ pub fn build_vector_engine_image_http_client(
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
|
||||
.http1_only()
|
||||
.pool_max_idle_per_host(0)
|
||||
.build()
|
||||
.map_err(|error| PlatformImageError::InvalidConfig {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
@@ -29,7 +30,14 @@ pub(super) fn map_reqwest_error(
|
||||
) -> PlatformImageError {
|
||||
let is_timeout = error.is_timeout();
|
||||
let is_connect = error.is_connect();
|
||||
let source = error.source().map(ToString::to_string);
|
||||
let source_chain_parts = collect_error_source_chain(&error);
|
||||
let source = source_chain_parts.first().cloned();
|
||||
let source_chain_depth = source_chain_parts.len();
|
||||
let source_chain = if source_chain_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(source_chain_parts.join(" -> "))
|
||||
};
|
||||
let message = format!("{context}:{error}");
|
||||
let audit = build_failure_audit(
|
||||
request_url,
|
||||
@@ -40,7 +48,7 @@ pub(super) fn map_reqwest_error(
|
||||
is_timeout,
|
||||
is_connect,
|
||||
message.as_str(),
|
||||
source.clone(),
|
||||
source_chain.clone().or_else(|| source.clone()),
|
||||
None,
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
@@ -56,6 +64,8 @@ pub(super) fn map_reqwest_error(
|
||||
body = error.is_body(),
|
||||
status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
|
||||
source = %source.clone().unwrap_or_default(),
|
||||
source_chain = %source_chain.clone().unwrap_or_default(),
|
||||
source_chain_depth,
|
||||
message = %message,
|
||||
elapsed_ms = latency_ms,
|
||||
prompt_chars,
|
||||
@@ -72,7 +82,62 @@ pub(super) fn map_reqwest_error(
|
||||
request: error.is_request(),
|
||||
body: error.is_body(),
|
||||
status_code: error.status().map(|status| status.as_u16()),
|
||||
source,
|
||||
source: source_chain.or(source),
|
||||
audit: Some(audit),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_error_source_chain(error: &(dyn Error + 'static)) -> Vec<String> {
|
||||
let mut chain = Vec::new();
|
||||
let mut next = error.source();
|
||||
while let Some(source) = next {
|
||||
chain.push(source.to_string());
|
||||
next = source.source();
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestError {
|
||||
message: &'static str,
|
||||
source: Option<Box<TestError>>,
|
||||
}
|
||||
|
||||
impl fmt::Display for TestError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for TestError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
self.source
|
||||
.as_deref()
|
||||
.map(|source| source as &(dyn Error + 'static))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_error_source_chain_keeps_nested_causes() {
|
||||
let error = TestError {
|
||||
message: "top",
|
||||
source: Some(Box::new(TestError {
|
||||
message: "middle",
|
||||
source: Some(Box::new(TestError {
|
||||
message: "bottom",
|
||||
source: None,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
collect_error_source_chain(&error),
|
||||
vec!["middle".to_string(), "bottom".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
|
||||
|
||||
// 管理后台协议统一收口在 shared-contracts,避免页面脚本和 Rust handler 各自手拼字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -16,6 +18,8 @@ pub struct AdminLoginRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminCreationEntryConfigResponse {
|
||||
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
|
||||
/// 底部加号创作入口页的后台公告列表。
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
}
|
||||
|
||||
/// 后台单个创作入口开关配置。
|
||||
@@ -34,6 +38,8 @@ pub struct AdminCreationEntryTypeConfigPayload {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口开关配置请求。
|
||||
@@ -51,6 +57,16 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口公告表单序列化结果请求。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertCreationEntryEventBannersRequest {
|
||||
/// 传输字段沿用既有契约,内容由后台标题 / HTML 表单生成。
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 前台创作入口配置响应。
|
||||
///
|
||||
/// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取
|
||||
/// `event_banners`,由后台表单配置多条公告并支持轮播。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryConfigResponse {
|
||||
pub start_card: CreationEntryStartCardResponse,
|
||||
pub type_modal: CreationEntryTypeModalResponse,
|
||||
pub event_banner: CreationEntryEventBannerResponse,
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
pub creation_types: Vec<CreationEntryTypeResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryStartCardResponse {
|
||||
pub title: String,
|
||||
@@ -18,14 +25,19 @@ pub struct CreationEntryStartCardResponse {
|
||||
pub busy_badge: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作类型选择弹层的基础文案契约。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeModalResponse {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口单条公告。
|
||||
///
|
||||
/// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示;
|
||||
/// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryEventBannerResponse {
|
||||
pub title: String,
|
||||
@@ -34,9 +46,19 @@ pub struct CreationEntryEventBannerResponse {
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
#[serde(default = "default_creation_entry_event_banner_render_mode")]
|
||||
pub render_mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub html_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 默认渲染模式使用受控结构化 UI,用于旧数据兼容。
|
||||
pub fn default_creation_entry_event_banner_render_mode() -> String {
|
||||
"structured".to_string()
|
||||
}
|
||||
|
||||
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeResponse {
|
||||
pub id: String,
|
||||
@@ -51,4 +73,383 @@ pub struct CreationEntryTypeResponse {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationSpecResponse {
|
||||
pub play_id: String,
|
||||
pub title: String,
|
||||
pub workspace_stage: String,
|
||||
pub generation_stage: String,
|
||||
pub result_stage: String,
|
||||
pub fields: Vec<UnifiedCreationFieldResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationFieldResponse {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
|
||||
|
||||
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
|
||||
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
|
||||
"rpg" => (
|
||||
"agent-workspace",
|
||||
"custom-world-generating",
|
||||
"custom-world-result",
|
||||
vec![unified_creation_field("message", "text", "创作想法", true)],
|
||||
),
|
||||
"big-fish" => (
|
||||
"big-fish-agent-workspace",
|
||||
"big-fish-generating",
|
||||
"big-fish-result",
|
||||
vec![unified_creation_field("message", "text", "玩法想法", true)],
|
||||
),
|
||||
"puzzle" => (
|
||||
"puzzle-agent-workspace",
|
||||
"puzzle-generating",
|
||||
"puzzle-result",
|
||||
vec![
|
||||
unified_creation_field("pictureDescription", "text", "画面描述", true),
|
||||
unified_creation_field("referenceImage", "image", "拼图画面", false),
|
||||
unified_creation_field("promptReferenceImages", "image", "参考图", false),
|
||||
],
|
||||
),
|
||||
"match3d" => (
|
||||
"match3d-agent-workspace",
|
||||
"match3d-generating",
|
||||
"match3d-result",
|
||||
vec![
|
||||
unified_creation_field("themeText", "text", "题材", true),
|
||||
unified_creation_field("difficulty", "select", "难度", true),
|
||||
],
|
||||
),
|
||||
"jump-hop" => (
|
||||
"jump-hop-workspace",
|
||||
"jump-hop-generating",
|
||||
"jump-hop-result",
|
||||
vec![
|
||||
unified_creation_field("workTitle", "text", "作品标题", true),
|
||||
unified_creation_field("workDescription", "text", "作品简介", true),
|
||||
unified_creation_field("themeTags", "text", "主题标签", true),
|
||||
unified_creation_field("difficulty", "select", "难度", true),
|
||||
unified_creation_field("stylePreset", "select", "风格", true),
|
||||
unified_creation_field("characterPrompt", "text", "角色提示词", true),
|
||||
unified_creation_field("tilePrompt", "text", "地块提示词", true),
|
||||
unified_creation_field("endMoodPrompt", "text", "终点氛围", false),
|
||||
],
|
||||
),
|
||||
"wooden-fish" => (
|
||||
"wooden-fish-workspace",
|
||||
"wooden-fish-generating",
|
||||
"wooden-fish-result",
|
||||
vec![
|
||||
unified_creation_field("hitObjectPrompt", "text", "敲什么", false),
|
||||
unified_creation_field("hitObjectReferenceImage", "image", "参考图", false),
|
||||
unified_creation_field("hitSoundAsset", "audio", "敲击音效", false),
|
||||
unified_creation_field("floatingWords", "text", "功德有什么", true),
|
||||
],
|
||||
),
|
||||
"square-hole" => (
|
||||
"square-hole-agent-workspace",
|
||||
"square-hole-generating",
|
||||
"square-hole-result",
|
||||
vec![unified_creation_field("message", "text", "玩法想法", true)],
|
||||
),
|
||||
"bark-battle" => (
|
||||
"bark-battle-workspace",
|
||||
"bark-battle-generating",
|
||||
"bark-battle-result",
|
||||
vec![
|
||||
unified_creation_field("title", "text", "作品标题", true),
|
||||
unified_creation_field("themeDescription", "text", "主题/场景描述", true),
|
||||
unified_creation_field(
|
||||
"playerImageDescription",
|
||||
"text",
|
||||
"玩家形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field(
|
||||
"opponentImageDescription",
|
||||
"text",
|
||||
"对手形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field("onomatopoeia", "text", "拟声词", false),
|
||||
unified_creation_field("difficultyPreset", "select", "难度", true),
|
||||
],
|
||||
),
|
||||
"visual-novel" => (
|
||||
"visual-novel-agent-workspace",
|
||||
"visual-novel-generating",
|
||||
"visual-novel-result",
|
||||
vec![
|
||||
unified_creation_field("ideaText", "text", "一句话创作", true),
|
||||
unified_creation_field("visualStyleId", "select", "视觉画风", true),
|
||||
],
|
||||
),
|
||||
"baby-object-match" => (
|
||||
"baby-object-match-workspace",
|
||||
"baby-object-match-generating",
|
||||
"baby-object-match-result",
|
||||
vec![
|
||||
unified_creation_field("itemAName", "text", "物品 A", true),
|
||||
unified_creation_field("itemBName", "text", "物品 B", true),
|
||||
],
|
||||
),
|
||||
"creative-agent" => (
|
||||
"creative-agent-workspace",
|
||||
"puzzle-generating",
|
||||
"puzzle-result",
|
||||
vec![
|
||||
unified_creation_field("message", "text", "创作想法", true),
|
||||
unified_creation_field("referenceImage", "image", "参考图", false),
|
||||
],
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(UnifiedCreationSpecResponse {
|
||||
play_id: play_id.to_string(),
|
||||
title: "想做个什么玩法?".to_string(),
|
||||
workspace_stage: workspace_stage.to_string(),
|
||||
generation_stage: generation_stage.to_string(),
|
||||
result_stage: result_stage.to_string(),
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
if spec.play_id.trim().is_empty() {
|
||||
return Err("统一创作契约 playId 不能为空".to_string());
|
||||
}
|
||||
if spec.title.trim().is_empty() {
|
||||
return Err("统一创作契约标题不能为空".to_string());
|
||||
}
|
||||
|
||||
let workspace_stage = spec.workspace_stage.trim();
|
||||
let generation_stage = spec.generation_stage.trim();
|
||||
let result_stage = spec.result_stage.trim();
|
||||
if workspace_stage.is_empty() || generation_stage.is_empty() || result_stage.is_empty() {
|
||||
return Err("统一创作契约阶段不能为空".to_string());
|
||||
}
|
||||
if workspace_stage == generation_stage
|
||||
|| workspace_stage == result_stage
|
||||
|| generation_stage == result_stage
|
||||
{
|
||||
return Err("统一创作契约阶段不能重复".to_string());
|
||||
}
|
||||
if spec.fields.is_empty() {
|
||||
return Err("统一创作契约 fields 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut field_ids = BTreeSet::new();
|
||||
for field in &spec.fields {
|
||||
let field_id = field.id.trim();
|
||||
if field_id.is_empty() {
|
||||
return Err("统一创作契约字段 id 不能为空".to_string());
|
||||
}
|
||||
if !field_ids.insert(field_id.to_string()) {
|
||||
return Err(format!("统一创作契约字段 id 重复:{field_id}"));
|
||||
}
|
||||
if field.label.trim().is_empty() {
|
||||
return Err(format!("统一创作契约字段 {field_id} 标签不能为空"));
|
||||
}
|
||||
if !UNIFIED_CREATION_FIELD_KINDS.contains(&field.kind.trim()) {
|
||||
return Err(format!(
|
||||
"统一创作契约字段 {field_id} kind 非法:{}",
|
||||
field.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_for_play(
|
||||
play_id: &str,
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
if spec.play_id.trim() != play_id.trim() {
|
||||
return Err(format!(
|
||||
"统一创作契约 playId 必须与入口 ID 一致:{}",
|
||||
play_id.trim()
|
||||
));
|
||||
}
|
||||
|
||||
validate_unified_creation_spec_response(spec)
|
||||
}
|
||||
|
||||
pub fn encode_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<String, String> {
|
||||
validate_unified_creation_spec_response(spec)?;
|
||||
serde_json::to_string(spec).map_err(|error| format!("统一创作契约序列化失败:{error}"))
|
||||
}
|
||||
|
||||
pub fn decode_unified_creation_spec_response(
|
||||
value: &str,
|
||||
) -> Result<UnifiedCreationSpecResponse, String> {
|
||||
let spec = serde_json::from_str::<UnifiedCreationSpecResponse>(value)
|
||||
.map_err(|error| format!("统一创作契约 JSON 非法:{error}"))?;
|
||||
validate_unified_creation_spec_response(&spec)?;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
pub fn resolve_unified_creation_spec_response(
|
||||
play_id: &str,
|
||||
value: Option<&str>,
|
||||
) -> Option<UnifiedCreationSpecResponse> {
|
||||
match value {
|
||||
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
|
||||
None => build_phase1_unified_creation_spec(play_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn unified_creation_field(
|
||||
id: &str,
|
||||
kind: &str,
|
||||
label: &str,
|
||||
required: bool,
|
||||
) -> UnifiedCreationFieldResponse {
|
||||
UnifiedCreationFieldResponse {
|
||||
id: id.to_string(),
|
||||
kind: kind.to_string(),
|
||||
label: label.to_string(),
|
||||
required,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn phase1_unified_creation_specs_cover_existing_templates() {
|
||||
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
|
||||
assert_eq!(puzzle.fields[0].id, "pictureDescription");
|
||||
assert_eq!(puzzle.fields[1].kind, "image");
|
||||
|
||||
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
|
||||
assert_eq!(
|
||||
match3d
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|field| field.kind == "select")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "stylePreset")
|
||||
);
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "endMoodPrompt")
|
||||
);
|
||||
|
||||
let wooden_fish =
|
||||
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
|
||||
assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio"));
|
||||
|
||||
let visual_novel =
|
||||
build_phase1_unified_creation_spec("visual-novel").expect("visual-novel spec");
|
||||
assert_eq!(visual_novel.workspace_stage, "visual-novel-agent-workspace");
|
||||
|
||||
let bark_battle =
|
||||
build_phase1_unified_creation_spec("bark-battle").expect("bark-battle spec");
|
||||
assert_eq!(bark_battle.generation_stage, "bark-battle-generating");
|
||||
|
||||
let baby_object_match = build_phase1_unified_creation_spec("baby-object-match")
|
||||
.expect("baby-object-match spec");
|
||||
assert_eq!(
|
||||
baby_object_match
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|field| field.kind == "text")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
|
||||
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
|
||||
r#"{
|
||||
"title": "旧版横幅",
|
||||
"description": "兼容旧字段",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 1000,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30"
|
||||
}"#,
|
||||
)
|
||||
.expect("legacy banner json should decode");
|
||||
|
||||
assert_eq!(banner.render_mode, "structured");
|
||||
assert!(banner.html_code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_config_serializes_event_banners_contract() {
|
||||
let response = CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
title: "新建作品".to_string(),
|
||||
description: "选择模板".to_string(),
|
||||
idle_badge: "模板".to_string(),
|
||||
busy_badge: "开启中".to_string(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalResponse {
|
||||
title: "选择创作类型".to_string(),
|
||||
description: "先选玩法".to_string(),
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: "第一条".to_string(),
|
||||
description: "兼容单条".to_string(),
|
||||
cover_image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
prize_pool_mud_points: 1000,
|
||||
starts_at_text: "2026-06-01".to_string(),
|
||||
ends_at_text: "2026-06-30".to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners: vec![CreationEntryEventBannerResponse {
|
||||
title: "HTML 条".to_string(),
|
||||
description: "沙箱".to_string(),
|
||||
cover_image_src: "/creation-type-references/match3d.webp".to_string(),
|
||||
prize_pool_mud_points: 800,
|
||||
starts_at_text: "2026-07-01".to_string(),
|
||||
ends_at_text: "2026-07-31".to_string(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some("<section>ok</section>".to_string()),
|
||||
}],
|
||||
creation_types: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(response).expect("response should serialize");
|
||||
|
||||
assert_eq!(value["eventBanners"][0]["renderMode"], "html");
|
||||
assert_eq!(
|
||||
value["eventBanners"][0]["htmlCode"],
|
||||
"<section>ok</section>"
|
||||
);
|
||||
assert!(value.get("event_banner").is_none());
|
||||
assert!(value.get("eventBanner").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,22 @@ pub struct WechatMiniProgramPayParamsResponse {
|
||||
pub pay_sign: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatMiniProgramVirtualPayParamsResponse {
|
||||
pub mode: String,
|
||||
pub sign_data: String,
|
||||
pub pay_sig: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum WechatMiniProgramPaymentParamsResponse {
|
||||
Ordinary(WechatMiniProgramPayParamsResponse),
|
||||
Virtual(WechatMiniProgramVirtualPayParamsResponse),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatH5PaymentResponse {
|
||||
@@ -283,7 +299,7 @@ pub struct CreateProfileRechargeOrderResponse {
|
||||
pub order: ProfileRechargeOrderResponse,
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
#[serde(default)]
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPaymentParamsResponse>,
|
||||
#[serde(default)]
|
||||
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
|
||||
#[serde(default)]
|
||||
@@ -297,6 +313,19 @@ pub struct ConfirmWechatProfileRechargeOrderResponse {
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatProfileRechargeOrderDoneEvent {
|
||||
pub order_id: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatProfileRechargeOrderErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileFeedbackEvidenceItemRequest {
|
||||
@@ -1451,6 +1480,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_recharge_order_response_serializes_virtual_wechat_payloads() {
|
||||
let order = ProfileRechargeOrderResponse {
|
||||
order_id: "rcgtest002".to_string(),
|
||||
product_id: "member_month".to_string(),
|
||||
product_title: "月卡".to_string(),
|
||||
kind: "membership".to_string(),
|
||||
amount_cents: 2800,
|
||||
status: "pending".to_string(),
|
||||
payment_channel: "wechat_mp_virtual".to_string(),
|
||||
paid_at: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-15T10:00:00Z".to_string(),
|
||||
points_delta: 0,
|
||||
membership_expires_at: Some("2026-06-15T10:00:00Z".to_string()),
|
||||
};
|
||||
let center = ProfileRechargeCenterResponse {
|
||||
wallet_balance: 0,
|
||||
membership: ProfileMembershipResponse {
|
||||
status: "normal".to_string(),
|
||||
tier: "normal".to_string(),
|
||||
started_at: None,
|
||||
expires_at: None,
|
||||
updated_at: None,
|
||||
},
|
||||
point_products: vec![],
|
||||
membership_products: vec![],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: false,
|
||||
};
|
||||
let payload = serde_json::to_value(CreateProfileRechargeOrderResponse {
|
||||
order,
|
||||
center,
|
||||
wechat_mini_program_pay_params: Some(WechatMiniProgramPaymentParamsResponse::Virtual(
|
||||
WechatMiniProgramVirtualPayParamsResponse {
|
||||
mode: "short_series_goods".to_string(),
|
||||
sign_data:
|
||||
"{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}"
|
||||
.to_string(),
|
||||
pay_sig: "pay-sig".to_string(),
|
||||
signature: "user-sig".to_string(),
|
||||
},
|
||||
)),
|
||||
wechat_h5_payment: None,
|
||||
wechat_native_payment: None,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["mode"],
|
||||
json!("short_series_goods")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["signData"],
|
||||
json!("{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["paySig"],
|
||||
json!("pay-sig")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["signature"],
|
||||
json!("user-sig")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_feedback_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
|
||||
|
||||
@@ -1154,6 +1154,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构造不携带资产覆盖的 JumpHop action,单测按需再覆盖字段。
|
||||
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
|
||||
JumpHopActionRequest {
|
||||
action_type,
|
||||
|
||||
@@ -14,6 +14,18 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
|
||||
category_id: input.category_id,
|
||||
category_label: input.category_label,
|
||||
category_sort_order: input.category_sort_order,
|
||||
unified_creation_spec_json: input.unified_creation_spec_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将业务层 banner JSON 保存输入转换为 SpacetimeDB 生成绑定类型。
|
||||
impl From<module_runtime::CreationEntryEventBannersAdminUpsertInput>
|
||||
for CreationEntryEventBannersAdminUpsertInput
|
||||
{
|
||||
fn from(input: module_runtime::CreationEntryEventBannersAdminUpsertInput) -> Self {
|
||||
Self {
|
||||
event_banners_json: input.event_banners_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,6 +244,7 @@ fn map_admin_work_visibility_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
/// 从本地订阅表行组装创作入口配置响应,兼容旧单条 banner 字段。
|
||||
pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
header: CreationEntryConfig,
|
||||
mut creation_types: Vec<CreationEntryTypeConfig>,
|
||||
@@ -277,7 +290,10 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
header.event_ends_at_text,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT,
|
||||
),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners_json: header.event_banners_json,
|
||||
creation_types: creation_types
|
||||
.into_iter()
|
||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
||||
@@ -299,6 +315,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
),
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
@@ -306,6 +323,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
)
|
||||
}
|
||||
|
||||
/// 将 SpacetimeDB procedure 快照映射为 module-runtime 领域快照。
|
||||
fn map_creation_entry_config_snapshot(
|
||||
snapshot: CreationEntryConfigSnapshot,
|
||||
) -> module_runtime::CreationEntryConfigSnapshot {
|
||||
@@ -328,7 +346,10 @@ fn map_creation_entry_config_snapshot(
|
||||
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
|
||||
starts_at_text: snapshot.event_banner.starts_at_text,
|
||||
ends_at_text: snapshot.event_banner.ends_at_text,
|
||||
render_mode: snapshot.event_banner.render_mode,
|
||||
html_code: snapshot.event_banner.html_code,
|
||||
},
|
||||
event_banners_json: snapshot.event_banners_json,
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -345,6 +366,7 @@ fn map_creation_entry_config_snapshot(
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
|
||||
@@ -100,6 +100,7 @@ fn map_wooden_fish_session_snapshot(
|
||||
fn map_wooden_fish_work_snapshot(
|
||||
snapshot: WoodenFishWorkSnapshot,
|
||||
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
|
||||
let generation_status = parse_generation_status(&snapshot.generation_status);
|
||||
let draft = WoodenFishDraftResponse {
|
||||
template_id: "wooden-fish".to_string(),
|
||||
template_name: "敲木鱼".to_string(),
|
||||
@@ -116,15 +117,23 @@ fn map_wooden_fish_work_snapshot(
|
||||
back_button_asset: snapshot.back_button_asset.clone().map(map_image_asset),
|
||||
hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset),
|
||||
cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()),
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
generation_status: generation_status.clone(),
|
||||
};
|
||||
let hit_object_asset = draft
|
||||
.hit_object_asset
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
matches!(generation_status, WoodenFishGenerationStatus::Failed)
|
||||
.then(default_failed_hit_object_asset)
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?;
|
||||
let hit_sound_asset = draft
|
||||
.hit_sound_asset
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
matches!(generation_status, WoodenFishGenerationStatus::Failed)
|
||||
.then(default_failed_hit_sound_asset)
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit sound asset"))?;
|
||||
Ok(WoodenFishWorkProfileResponse {
|
||||
summary: WoodenFishWorkSummaryResponse {
|
||||
@@ -143,7 +152,7 @@ fn map_wooden_fish_work_snapshot(
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
|
||||
publish_ready: snapshot.publish_ready,
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
generation_status,
|
||||
},
|
||||
draft,
|
||||
hit_object_asset,
|
||||
@@ -154,6 +163,31 @@ fn map_wooden_fish_work_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn default_failed_hit_object_asset() -> WoodenFishImageAsset {
|
||||
WoodenFishImageAsset {
|
||||
asset_id: "wooden-fish-failed-hit-object".to_string(),
|
||||
image_src: "/wooden-fish/default-hit-object.png".to_string(),
|
||||
image_object_key: "public/wooden-fish/default-hit-object.png".to_string(),
|
||||
asset_object_id: "wooden-fish-failed-hit-object".to_string(),
|
||||
generation_provider: "failed-fallback".to_string(),
|
||||
prompt: "生成失败占位图".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_failed_hit_sound_asset() -> WoodenFishAudioAsset {
|
||||
WoodenFishAudioAsset {
|
||||
asset_id: "wooden-fish-failed-hit-sound".to_string(),
|
||||
audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(),
|
||||
audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(),
|
||||
asset_object_id: "wooden-fish-failed-hit-sound".to_string(),
|
||||
source: "failed-fallback".to_string(),
|
||||
prompt: Some("生成失败占位音效".to_string()),
|
||||
duration_ms: Some(3_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFishDraftResponse {
|
||||
WoodenFishDraftResponse {
|
||||
template_id: snapshot.template_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6).
|
||||
// This was generated using spacetimedb cli version 2.3.0 (commit aa73d1c35b4b346b98eeba10a3d756b4ae72162f).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
@@ -245,6 +245,7 @@ pub mod creation_entry_config_snapshot_type;
|
||||
pub mod creation_entry_config_table;
|
||||
pub mod creation_entry_config_type;
|
||||
pub mod creation_entry_event_banner_snapshot_type;
|
||||
pub mod creation_entry_event_banners_admin_upsert_input_type;
|
||||
pub mod creation_entry_start_card_snapshot_type;
|
||||
pub mod creation_entry_type_admin_upsert_input_type;
|
||||
pub mod creation_entry_type_config_table;
|
||||
@@ -1000,6 +1001,7 @@ pub mod update_visual_novel_work_procedure;
|
||||
pub mod update_wooden_fish_work_procedure;
|
||||
pub mod upsert_chapter_progression_and_return_procedure;
|
||||
pub mod upsert_chapter_progression_reducer;
|
||||
pub mod upsert_creation_entry_event_banners_config_procedure;
|
||||
pub mod upsert_creation_entry_type_config_procedure;
|
||||
pub mod upsert_custom_world_agent_operation_progress_procedure;
|
||||
pub mod upsert_custom_world_profile_and_return_procedure;
|
||||
@@ -1335,6 +1337,7 @@ pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot;
|
||||
pub use creation_entry_config_table::*;
|
||||
pub use creation_entry_config_type::CreationEntryConfig;
|
||||
pub use creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot;
|
||||
pub use creation_entry_event_banners_admin_upsert_input_type::CreationEntryEventBannersAdminUpsertInput;
|
||||
pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
|
||||
pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
|
||||
pub use creation_entry_type_config_table::*;
|
||||
@@ -2090,6 +2093,7 @@ pub use update_visual_novel_work_procedure::update_visual_novel_work;
|
||||
pub use update_wooden_fish_work_procedure::update_wooden_fish_work;
|
||||
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
|
||||
pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
|
||||
pub use upsert_creation_entry_event_banners_config_procedure::upsert_creation_entry_event_banners_config;
|
||||
pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config;
|
||||
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
|
||||
pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return;
|
||||
@@ -5228,19 +5232,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle {
|
||||
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
|
||||
pub trait RemoteDbContext:
|
||||
__sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
{
|
||||
}
|
||||
impl<
|
||||
Ctx: __sdk::DbContext<
|
||||
Ctx: __sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>,
|
||||
> RemoteDbContext for Ctx
|
||||
> RemoteDbContext for Ctx
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait accept_quest {
|
||||
&self,
|
||||
input: QuestRecordInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl accept_quest for super::RemoteReducers {
|
||||
&self,
|
||||
input: QuestRecordInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AcceptQuestArgs { input }, callback)
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait acknowledge_quest_completion {
|
||||
&self,
|
||||
input: QuestCompletionAckInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl acknowledge_quest_completion for super::RemoteReducers {
|
||||
&self,
|
||||
input: QuestCompletionAckInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code {
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures {
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config {
|
||||
input: RuntimeProfileTaskConfigAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskConfigAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_profile_invite_codes {
|
||||
input: RuntimeProfileInviteCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_profile_invite_codes for super::RemoteProcedures {
|
||||
input: RuntimeProfileInviteCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminListProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait admin_list_profile_recharge_products {
|
||||
input: RuntimeProfileRechargeProductAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl admin_list_profile_recharge_products for super::RemoteProcedures {
|
||||
input: RuntimeProfileRechargeProductAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminListProcedureResult>(
|
||||
"admin_list_profile_recharge_products",
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_profile_redeem_codes {
|
||||
input: RuntimeProfileRedeemCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_profile_redeem_codes for super::RemoteProcedures {
|
||||
input: RuntimeProfileRedeemCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminListProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_profile_task_configs {
|
||||
input: RuntimeProfileTaskConfigAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_profile_task_configs for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskConfigAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminListProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_work_visibility {
|
||||
input: AdminWorkVisibilityListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_work_visibility for super::RemoteProcedures {
|
||||
input: AdminWorkVisibilityListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AdminWorkVisibilityListProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_update_work_visibility {
|
||||
input: AdminWorkVisibilityUpdateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_update_work_visibility for super::RemoteProcedures {
|
||||
input: AdminWorkVisibilityUpdateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AdminWorkVisibilityProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_invite_code {
|
||||
input: RuntimeProfileInviteCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_upsert_profile_invite_code for super::RemoteProcedures {
|
||||
input: RuntimeProfileInviteCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait admin_upsert_profile_recharge_product {
|
||||
input: RuntimeProfileRechargeProductAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl admin_upsert_profile_recharge_product for super::RemoteProcedures {
|
||||
input: RuntimeProfileRechargeProductAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_redeem_code {
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_upsert_profile_redeem_code for super::RemoteProcedures {
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_task_config {
|
||||
input: RuntimeProfileTaskConfigAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_upsert_profile_task_config for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskConfigAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait advance_puzzle_next_level {
|
||||
input: PuzzleRunNextLevelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl advance_puzzle_next_level for super::RemoteProcedures {
|
||||
input: PuzzleRunNextLevelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait append_ai_text_chunk_and_return {
|
||||
input: AiTextChunkAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl append_ai_text_chunk_and_return for super::RemoteProcedures {
|
||||
input: AiTextChunkAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait append_visual_novel_runtime_history_entry {
|
||||
input: VisualNovelRuntimeHistoryAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl append_visual_novel_runtime_history_entry for super::RemoteProcedures {
|
||||
input: VisualNovelRuntimeHistoryAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, VisualNovelHistoryProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait apply_chapter_progression_ledger_entry_and_return {
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl apply_chapter_progression_ledger_entry_and_return for super::RemoteProcedur
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ChapterProgressionProcedureResult>(
|
||||
|
||||
@@ -50,11 +50,9 @@ pub trait apply_chapter_progression_ledger_entry {
|
||||
&self,
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -63,11 +61,9 @@ impl apply_chapter_progression_ledger_entry for super::RemoteReducers {
|
||||
&self,
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp.invoke_reducer_with_callback(
|
||||
ApplyChapterProgressionLedgerEntryArgs { input },
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait apply_inventory_mutation {
|
||||
&self,
|
||||
input: InventoryMutationInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl apply_inventory_mutation for super::RemoteReducers {
|
||||
&self,
|
||||
input: InventoryMutationInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(ApplyInventoryMutationArgs { input }, callback)
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait apply_quest_signal {
|
||||
&self,
|
||||
input: QuestSignalApplyInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl apply_quest_signal for super::RemoteReducers {
|
||||
&self,
|
||||
input: QuestSignalApplyInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(ApplyQuestSignalArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait attach_ai_result_reference_and_return {
|
||||
input: AiResultReferenceInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl attach_ai_result_reference_and_return for super::RemoteProcedures {
|
||||
input: AiResultReferenceInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait authorize_database_migration_operator {
|
||||
input: DatabaseMigrationAuthorizeOperatorInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl authorize_database_migration_operator for super::RemoteProcedures {
|
||||
input: DatabaseMigrationAuthorizeOperatorInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, DatabaseMigrationOperatorProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait begin_story_session_and_return {
|
||||
input: StorySessionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl begin_story_session_and_return for super::RemoteProcedures {
|
||||
input: StorySessionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, StorySessionProcedureResult>(
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait begin_story_session {
|
||||
&self,
|
||||
input: StorySessionInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl begin_story_session for super::RemoteReducers {
|
||||
&self,
|
||||
input: StorySessionInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(BeginStorySessionArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait bind_asset_object_to_entity_and_return {
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl bind_asset_object_to_entity_and_return for super::RemoteProcedures {
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AssetEntityBindingProcedureResult>(
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait bind_asset_object_to_entity {
|
||||
&self,
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl bind_asset_object_to_entity for super::RemoteReducers {
|
||||
&self,
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(BindAssetObjectToEntityArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait cancel_ai_task_and_return {
|
||||
input: AiTaskCancelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl cancel_ai_task_and_return for super::RemoteProcedures {
|
||||
input: AiTaskCancelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait checkpoint_wooden_fish_run {
|
||||
input: WoodenFishRunCheckpointInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl checkpoint_wooden_fish_run for super::RemoteProcedures {
|
||||
input: WoodenFishRunCheckpointInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, WoodenFishRunProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait claim_profile_task_reward_and_return {
|
||||
input: RuntimeProfileTaskClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl claim_profile_task_reward_and_return for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskClaimProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait claim_puzzle_work_point_incentive {
|
||||
input: PuzzleWorkPointIncentiveClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl claim_puzzle_work_point_incentive for super::RemoteProcedures {
|
||||
input: PuzzleWorkPointIncentiveClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleWorkProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait clear_database_migration_import_chunks {
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl clear_database_migration_import_chunks for super::RemoteProcedures {
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait clear_platform_browse_history_and_return {
|
||||
input: RuntimeBrowseHistoryClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl clear_platform_browse_history_and_return for super::RemoteProcedures {
|
||||
input: RuntimeBrowseHistoryClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeBrowseHistoryProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait click_match_3_d_item {
|
||||
input: Match3DRunClickInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl click_match_3_d_item for super::RemoteProcedures {
|
||||
input: Match3DRunClickInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, Match3DClickItemProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_big_fish_draft {
|
||||
input: BigFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_big_fish_draft for super::RemoteProcedures {
|
||||
input: BigFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait compile_custom_world_published_profile {
|
||||
input: CustomWorldPublishedProfileCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl compile_custom_world_published_profile for super::RemoteProcedures {
|
||||
input: CustomWorldPublishedProfileCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, CustomWorldPublishedProfileCompileResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_jump_hop_draft {
|
||||
input: JumpHopDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_jump_hop_draft for super::RemoteProcedures {
|
||||
input: JumpHopDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, JumpHopAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_match_3_d_draft {
|
||||
input: Match3DDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_match_3_d_draft for super::RemoteProcedures {
|
||||
input: Match3DDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_puzzle_agent_draft {
|
||||
input: PuzzleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_puzzle_agent_draft for super::RemoteProcedures {
|
||||
input: PuzzleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_square_hole_draft {
|
||||
input: SquareHoleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_square_hole_draft for super::RemoteProcedures {
|
||||
input: SquareHoleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, SquareHoleAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_visual_novel_work_profile {
|
||||
input: VisualNovelWorkCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_visual_novel_work_profile for super::RemoteProcedures {
|
||||
input: VisualNovelWorkCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, VisualNovelAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_wooden_fish_draft {
|
||||
input: WoodenFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_wooden_fish_draft for super::RemoteProcedures {
|
||||
input: WoodenFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, WoodenFishAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait complete_ai_stage_and_return {
|
||||
input: AiStageCompletionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl complete_ai_stage_and_return for super::RemoteProcedures {
|
||||
input: AiStageCompletionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait complete_ai_task_and_return {
|
||||
input: AiTaskFinishInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl complete_ai_task_and_return for super::RemoteProcedures {
|
||||
input: AiTaskFinishInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait confirm_asset_object_and_return {
|
||||
input: AssetObjectUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl confirm_asset_object_and_return for super::RemoteProcedures {
|
||||
input: AssetObjectUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AssetObjectProcedureResult>(
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait confirm_asset_object {
|
||||
&self,
|
||||
input: AssetObjectUpsertInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl confirm_asset_object for super::RemoteReducers {
|
||||
&self,
|
||||
input: AssetObjectUpsertInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(ConfirmAssetObjectArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait consume_profile_wallet_points_and_return {
|
||||
input: RuntimeProfileWalletAdjustmentInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl consume_profile_wallet_points_and_return for super::RemoteProcedures {
|
||||
input: RuntimeProfileWalletAdjustmentInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletAdjustmentProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait continue_story_and_return {
|
||||
input: StoryContinueInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl continue_story_and_return for super::RemoteProcedures {
|
||||
input: StoryContinueInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, StorySessionProcedureResult>(
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait continue_story {
|
||||
&self,
|
||||
input: StoryContinueInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl continue_story for super::RemoteReducers {
|
||||
&self,
|
||||
input: StoryContinueInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(ContinueStoryArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_ai_task_and_return {
|
||||
input: AiTaskCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_ai_task_and_return for super::RemoteProcedures {
|
||||
input: AiTaskCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait create_ai_task {
|
||||
&self,
|
||||
input: AiTaskCreateInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl create_ai_task for super::RemoteReducers {
|
||||
&self,
|
||||
input: AiTaskCreateInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(CreateAiTaskArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_bark_battle_draft {
|
||||
input: BarkBattleDraftCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BarkBattleProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BarkBattleProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_bark_battle_draft for super::RemoteProcedures {
|
||||
input: BarkBattleDraftCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BarkBattleProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BarkBattleProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BarkBattleProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_battle_state_and_return {
|
||||
input: BattleStateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BattleStateProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BattleStateProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_battle_state_and_return for super::RemoteProcedures {
|
||||
input: BattleStateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BattleStateProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BattleStateProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BattleStateProcedureResult>(
|
||||
|
||||
@@ -47,11 +47,9 @@ pub trait create_battle_state {
|
||||
&self,
|
||||
input: BattleStateInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -60,11 +58,9 @@ impl create_battle_state for super::RemoteReducers {
|
||||
&self,
|
||||
input: BattleStateInput,
|
||||
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(CreateBattleStateArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_big_fish_session {
|
||||
input: BigFishSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_big_fish_session for super::RemoteProcedures {
|
||||
input: BigFishSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_custom_world_agent_session {
|
||||
input: CustomWorldAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_custom_world_agent_session for super::RemoteProcedures {
|
||||
input: CustomWorldAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, CustomWorldAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_jump_hop_agent_session {
|
||||
input: JumpHopAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_jump_hop_agent_session for super::RemoteProcedures {
|
||||
input: JumpHopAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, JumpHopAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_match_3_d_agent_session {
|
||||
input: Match3DAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_match_3_d_agent_session for super::RemoteProcedures {
|
||||
input: Match3DAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait create_profile_recharge_order_and_return {
|
||||
input: RuntimeProfileRechargeOrderCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl create_profile_recharge_order_and_return for super::RemoteProcedures {
|
||||
input: RuntimeProfileRechargeOrderCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_puzzle_agent_session {
|
||||
input: PuzzleAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_puzzle_agent_session for super::RemoteProcedures {
|
||||
input: PuzzleAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait create_square_hole_agent_session {
|
||||
input: SquareHoleAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl create_square_hole_agent_session for super::RemoteProcedures {
|
||||
input: SquareHoleAgentSessionCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, SquareHoleAgentSessionProcedureResult>(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user