Merge remote-tracking branch 'origin/master' into codex/feat/jump-hop-internal-hole-detect

Current branch is outdates
This commit is contained in:
2026-06-08 21:48:15 +08:00
213 changed files with 26864 additions and 8697 deletions

22
server-rs/Cargo.lock generated
View File

@@ -129,6 +129,7 @@ dependencies = [
"platform-llm",
"platform-oss",
"platform-speech",
"platform-wechat",
"reqwest 0.12.28",
"ring",
"serde",
@@ -2508,6 +2509,27 @@ dependencies = [
"uuid",
]
[[package]]
name = "platform-wechat"
version = "0.1.0"
dependencies = [
"aes",
"base64 0.22.1",
"cbc",
"hex",
"reqwest 0.12.28",
"ring",
"serde",
"serde_json",
"sha1",
"sha2",
"shared-contracts",
"time",
"tracing",
"url",
"urlencoding",
]
[[package]]
name = "png"
version = "0.18.1"

View File

@@ -37,6 +37,7 @@ members = [
"crates/platform-hyper3d",
"crates/platform-image",
"crates/platform-llm",
"crates/platform-wechat",
"crates/platform-speech",
"crates/platform-agent",
"crates/shared-contracts",
@@ -85,6 +86,7 @@ platform-image = { path = "crates/platform-image", default-features = false }
platform-llm = { path = "crates/platform-llm", default-features = false }
platform-oss = { path = "crates/platform-oss", default-features = false }
platform-speech = { path = "crates/platform-speech", default-features = false }
platform-wechat = { path = "crates/platform-wechat", default-features = false }
shared-contracts = { path = "crates/shared-contracts", default-features = false }
shared-kernel = { path = "crates/shared-kernel", default-features = false }
shared-logging = { path = "crates/shared-logging", default-features = false }

View File

@@ -44,6 +44,7 @@ platform-image = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
platform-wechat = { workspace = true }
hmac = { workspace = true }
ring = { workspace = true }
serde = { workspace = true }

View File

@@ -41,7 +41,7 @@ use crate::{
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
submit_visual_novel_message, update_visual_novel_work,
},
wechat_pay::{
wechat::pay::{
handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify,
handle_wechat_virtual_payment_notify,
},
@@ -1507,8 +1507,7 @@ mod tests {
#[tokio::test]
async fn wooden_fish_session_creation_accepts_legacy_audio_body_above_default_limit() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user =
seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
let seed_user = seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_audio_body");
let app = build_router(state);
let request_body = format!(
@@ -1548,8 +1547,7 @@ mod tests {
#[tokio::test]
async fn wooden_fish_actions_accept_legacy_audio_body_above_default_limit() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user =
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_action_body");
let app = build_router(state);
let request_body = format!(

View File

@@ -71,6 +71,10 @@ async fn consume_asset_operation_points(
asset_id: &str,
points_cost: u64,
) -> Result<bool, AppError> {
if points_cost == 0 {
return Ok(false);
}
let ledger_id = format!(
"asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id

View File

@@ -1,4 +1,4 @@
use axum::{
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,

View File

@@ -36,7 +36,7 @@ use time::{Duration as TimeDuration, OffsetDateTime};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -62,6 +62,8 @@ const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-";
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE: &str = "initial_draft_generation";
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT: u64 = 3;
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024";
const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792";
@@ -303,11 +305,13 @@ pub async fn generate_bark_battle_image_asset(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let result = execute_billable_asset_operation(
let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await;
let result = execute_billable_asset_operation_with_cost(
&state,
&owner_user_id,
bark_battle_slot_asset_kind(&slot),
asset_id.as_str(),
points_cost,
async {
generate_and_persist_bark_battle_image_asset(
&state,
@@ -328,6 +332,40 @@ pub async fn generate_bark_battle_image_asset(
Ok(json_success_body(Some(&request_context), result))
}
async fn resolve_bark_battle_image_asset_points_cost(
state: &AppState,
payload: &BarkBattleImageAssetGenerateRequest,
) -> u64 {
if payload.billing_purpose.as_deref()
!= Some(BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE)
{
return crate::asset_billing::ASSET_OPERATION_POINTS_COST;
}
let total_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
BARK_BATTLE_PLAY_TYPE_ID,
BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT
* crate::asset_billing::ASSET_OPERATION_POINTS_COST,
)
.await;
resolve_bark_battle_initial_generation_slot_points_cost(&payload.slot, total_cost)
}
fn resolve_bark_battle_initial_generation_slot_points_cost(
slot: &BarkBattleAssetSlot,
total_cost: u64,
) -> u64 {
let base_cost = total_cost / BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
let remainder = total_cost % BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
let slot_index = match slot {
BarkBattleAssetSlot::PlayerCharacter => 0,
BarkBattleAssetSlot::OpponentCharacter => 1,
BarkBattleAssetSlot::UiBackground => 2,
};
base_cost + u64::from(slot_index < remainder)
}
pub async fn publish_bark_battle_work(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1661,6 +1699,94 @@ mod tests {
);
}
#[test]
fn initial_generation_slot_cost_splits_creation_entry_total_cost() {
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
1,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
1,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
1,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
2,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
2,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
2,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
8,
),
3,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
8,
),
3,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
8,
),
2,
);
}
#[test]
fn draft_config_mapping_includes_stable_work_identity() {
let request_context = RequestContext::new(

View File

@@ -1,4 +1,4 @@
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -100,6 +100,10 @@ pub struct AppConfig {
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_subscribe_message_enabled: bool,
pub wechat_mini_program_generation_result_template_id: Option<String>,
pub wechat_mini_program_subscribe_message_endpoint: String,
pub wechat_mini_program_subscribe_message_state: String,
pub wechat_mini_program_virtual_payment_env: u8,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
@@ -250,6 +254,13 @@ impl Default for AppConfig {
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_subscribe_message_enabled: true,
wechat_mini_program_generation_result_template_id: Some(
"m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU".to_string(),
),
wechat_mini_program_subscribe_message_endpoint:
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send".to_string(),
wechat_mini_program_subscribe_message_state: "formal".to_string(),
wechat_mini_program_virtual_payment_env: 0,
oss_bucket: None,
oss_endpoint: None,
@@ -613,6 +624,26 @@ impl AppConfig {
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(enabled) =
read_first_bool_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"])
{
config.wechat_mini_program_subscribe_message_enabled = enabled;
}
if let Some(template_id) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"])
{
config.wechat_mini_program_generation_result_template_id = Some(template_id);
}
if let Some(endpoint) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENDPOINT"])
{
config.wechat_mini_program_subscribe_message_endpoint = endpoint;
}
if let Some(state) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"])
{
config.wechat_mini_program_subscribe_message_state = state;
}
if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
&& env <= 1
{
@@ -1419,6 +1450,9 @@ mod tests {
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_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
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");
@@ -1446,6 +1480,12 @@ mod tests {
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
);
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED", "true");
std::env::set_var(
"WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID",
"tmpl-generation-result",
);
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE", "trial");
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
}
@@ -1497,6 +1537,14 @@ mod tests {
.as_deref(),
Some("sandbox-app-key-001")
);
assert!(config.wechat_mini_program_subscribe_message_enabled);
assert_eq!(
config
.wechat_mini_program_generation_result_template_id
.as_deref(),
Some("tmpl-generation-result")
);
assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial");
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
unsafe {
@@ -1514,6 +1562,9 @@ mod tests {
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_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
}
}

View File

@@ -126,6 +126,44 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
None
}
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
config: &CreationEntryConfigResponse,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
config
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.and_then(|item| item.unified_creation_spec.as_ref())
.map(|spec| u64::from(spec.mud_point_cost))
.filter(|cost| *cost > 0)
.unwrap_or(fallback_cost)
}
pub(crate) async fn resolve_creation_entry_mud_point_cost(
state: &AppState,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
match state.get_creation_entry_config().await {
Ok(config) => resolve_creation_entry_mud_point_cost_from_config(
&config,
creation_type_id,
fallback_cost,
),
Err(error) => {
tracing::warn!(
creation_type_id,
fallback_cost,
error = %error,
"读取创作入口泥点成本失败,回退到代码默认值"
);
fallback_cost
}
}
}
fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -170,6 +208,7 @@ pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigRespon
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
#[test]
fn resolves_new_creation_paths_to_creation_type_ids() {
@@ -258,6 +297,50 @@ mod tests {
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn resolves_mud_point_cost_from_unified_creation_spec() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
let spec = puzzle
.unified_creation_spec
.as_mut()
.expect("puzzle unified spec should exist");
spec.mud_point_cost = 8;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
8,
);
}
#[test]
fn resolves_mud_point_cost_with_fallback_for_legacy_config() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
puzzle.unified_creation_spec = None;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
2,
);
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(
&config,
"missing-play",
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
),
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
);
}
#[test]
fn test_creation_entry_config_response_opens_bark_battle() {
let config = test_creation_entry_config_response();

View File

@@ -37,11 +37,11 @@ use spacetime_client::{
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldLibraryEntryRecord,
CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput,
CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput,
CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord,
CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError,
CustomWorldLibraryEntryRecord, CustomWorldProfileLikeReportRecordInput,
CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError,
};
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};

View File

@@ -10,9 +10,9 @@ use axum::{
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType};
#[cfg(test)]
use image::ImageFormat;
use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,

View File

@@ -9,8 +9,8 @@ use crate::{
#[allow(unused_imports)]
pub(crate) use generated_asset_sheets_impl::{
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetSliceImage,
GeneratedAssetSheetUpload,
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options,

View File

@@ -14,10 +14,9 @@ use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
JumpHopRunResponse,
JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse,
JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
@@ -45,6 +44,10 @@ use crate::{
},
request_context::RequestContext,
state::AppState,
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -150,27 +153,86 @@ pub async fn execute_jump_hop_action(
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let mut payload = payload;
maybe_generate_jump_hop_assets(
&state,
&request_context,
session_id.as_str(),
owner_user_id.as_str(),
&mut payload,
)
.await?;
let response = state
.spacetime_client()
.execute_jump_hop_action(session_id, owner_user_id, payload)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft);
let generation_points_cost = if is_compile_draft {
resolve_jump_hop_generation_points_cost(&state).await
} else {
0
};
let result = async {
maybe_generate_jump_hop_assets(
&state,
&request_context,
session_id.as_str(),
owner_user_id.as_str(),
&mut payload,
)
.await?;
state
.spacetime_client()
.execute_jump_hop_action(session_id, owner_user_id.clone(), payload)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})
}
.await;
Ok(json_success_body(Some(&request_context), response))
match result {
Ok(response) => {
if is_compile_draft && response.session.status == JumpHopGenerationStatus::Ready {
send_generation_result_subscribe_message_after_completion(
&state,
GenerationResultSubscribeMessage {
owner_user_id,
task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()),
work_name: response
.session
.draft
.as_ref()
.map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: generation_points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Ok(json_success_body(Some(&request_context), response))
}
Err(response) => {
if is_compile_draft && response.status().is_server_error() {
send_generation_result_subscribe_message_after_completion(
&state,
GenerationResultSubscribeMessage {
owner_user_id,
task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()),
work_name: None,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Err(response)
}
}
}
async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
JUMP_HOP_TEMPLATE_ID,
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
)
.await
}
pub async fn publish_jump_hop_work(
@@ -232,10 +294,7 @@ pub async fn get_jump_hop_work_detail(
ensure_non_empty(&request_context, &profile_id, "profileId")?;
let work = state
.spacetime_client()
.get_jump_hop_work_profile(
profile_id,
authenticated.claims().user_id().to_string(),
)
.get_jump_hop_work_profile(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
jump_hop_error_response(
@@ -260,10 +319,7 @@ pub async fn delete_jump_hop_work(
ensure_non_empty(&request_context, &profile_id, "profileId")?;
let works = state
.spacetime_client()
.delete_jump_hop_work(
profile_id,
authenticated.claims().user_id().to_string(),
)
.delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
jump_hop_error_response(

View File

@@ -1,4 +1,4 @@
use axum::{
use axum::{
Json,
extract::{Extension, State},
};

View File

@@ -89,9 +89,7 @@ mod tracking_outbox;
mod vector_engine_audio_generation;
mod visual_novel;
mod volcengine_speech;
mod wechat_auth;
mod wechat_pay;
mod wechat_provider;
mod wechat;
mod wooden_fish;
mod work_author;
mod work_play_tracking;

View File

@@ -1,4 +1,4 @@
use std::{
use std::{
collections::BTreeMap,
convert::Infallible,
future::Future,
@@ -84,6 +84,10 @@ use crate::{
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation,
},
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent";

View File

@@ -163,6 +163,12 @@ 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());
let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
"match3d",
MATCH3D_DRAFT_GENERATION_POINTS_COST,
)
.await;
let compile_session_id = session_id.clone();
let compile_owner_user_id = owner_user_id.clone();
let compile_profile_id = profile_id.clone();
@@ -175,6 +181,7 @@ pub(super) async fn compile_match3d_draft_for_session(
request_context,
owner_user_id.as_str(),
billing_asset_id.as_str(),
points_cost,
async {
let mut session = upsert_match3d_draft_snapshot(
state,
@@ -316,27 +323,56 @@ pub(super) async fn compile_match3d_draft_for_session(
)
.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;
match result {
Ok((session, generated_item_assets)) => {
send_generation_result_subscribe_message_after_completion(
state,
GenerationResultSubscribeMessage {
owner_user_id: compile_owner_user_id.clone(),
task_name: Some("抓大鹅".to_string()),
work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
Ok((session, generated_item_assets))
}
Err(response) if 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.clone(),
compile_profile_id,
compile_initial_game_name.clone(),
compile_requested_summary,
compile_initial_tags,
compile_requested_cover_image_src,
failure_message,
)
.await;
send_generation_result_subscribe_message_after_completion(
state,
GenerationResultSubscribeMessage {
owner_user_id: compile_owner_user_id,
task_name: Some("抓大鹅".to_string()),
work_name: Some(compile_initial_game_name),
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
Err(response)
}
Err(response) => Err(response),
}
result
}
#[allow(clippy::too_many_arguments)]
@@ -418,12 +454,13 @@ fn match3d_response_failure_message(response: &Response) -> String {
.unwrap_or_else(|| format!("抓大鹅草稿生成失败HTTP {}", response.status()))
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按后台入口配置的泥点成本幂等预扣
async fn execute_billable_match3d_draft_generation<T, Fut>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
operation: Fut,
) -> Result<T, Response>
where
@@ -434,6 +471,7 @@ where
request_context,
owner_user_id,
billing_asset_id,
points_cost,
)
.await?;
@@ -441,8 +479,13 @@ where
Ok(value) => Ok(value),
Err(response) => {
if points_consumed {
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
.await;
refund_match3d_draft_generation_points(
state,
owner_user_id,
billing_asset_id,
points_cost,
)
.await;
}
Err(response)
}
@@ -454,6 +497,7 @@ async fn consume_match3d_draft_generation_points(
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
) -> Result<bool, Response> {
let ledger_id = format!(
"asset_operation_consume:{}:match3d_draft_generation:{}",
@@ -463,7 +507,7 @@ async fn consume_match3d_draft_generation_points(
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)
@@ -491,6 +535,7 @@ async fn refund_match3d_draft_generation_points(
state: &AppState,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
) {
let ledger_id = format!(
"asset_operation_refund:{}:match3d_draft_generation:{}",
@@ -500,7 +545,7 @@ async fn refund_match3d_draft_generation_points(
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)

View File

@@ -1,4 +1,4 @@
use axum::{
use axum::{
Router, middleware,
routing::{get, post},
};
@@ -16,7 +16,7 @@ use crate::{
phone_auth::{phone_login, send_phone_code},
refresh_session::refresh_session,
state::AppState,
wechat_auth::{
wechat::auth::{
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
},
};

View File

@@ -1,7 +1,6 @@
use axum::{
middleware,
Router, middleware,
routing::{get, post},
Router,
};
use crate::{
@@ -9,9 +8,8 @@ use crate::{
jump_hop::{
create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump,
list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run,
start_jump_hop_run,
get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, list_jump_hop_gallery,
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
},
state::AppState,
};

View File

@@ -24,9 +24,7 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/creation/wooden-fish/sessions",
post(create_wooden_fish_session)
// 中文注释:兼容旧小程序把参考图或录音 Data URL 放进创作 JSON 的请求;新前端音频会先直传 OSS。
.layer(DefaultBodyLimit::max(
WOODEN_FISH_CREATION_BODY_LIMIT_BYTES,
))
.layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
@@ -43,9 +41,7 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/creation/wooden-fish/sessions/{session_id}/actions",
post(execute_wooden_fish_action)
// 中文注释compile/regenerate 会携带参考图旧兼容输入,避免 Axum 默认 2MB 先于 handler 拦截。
.layer(DefaultBodyLimit::max(
WOODEN_FISH_CREATION_BODY_LIMIT_BYTES,
))
.layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
@@ -98,4 +94,4 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/wooden-fish/gallery/{public_work_code}",
get(get_wooden_fish_gallery_detail),
)
}
}

View File

@@ -1,7 +1,8 @@
use axum::http::{HeaderValue, StatusCode};
use axum::http::{HeaderValue, StatusCode};
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
use platform_llm::{LlmError, LlmErrorKind};
use platform_oss::{OssError, OssErrorKind};
use platform_wechat::{WechatError, WechatErrorKind};
use serde_json::json;
use crate::http_error::AppError;
@@ -68,6 +69,17 @@ pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
AppError::from_status(status).with_message(error.to_string())
}
pub fn map_wechat_error(error: WechatError) -> AppError {
let status = match error.kind() {
WechatErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
WechatErrorKind::RequestFailed
| WechatErrorKind::DeserializeFailed
| WechatErrorKind::Upstream => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_message(error.to_string())
}
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => error.with_header("retry-after", value),

View File

@@ -1,4 +1,4 @@
use std::{
use std::{
collections::{BTreeMap, HashSet},
sync::{Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH},
@@ -58,16 +58,15 @@ use spacetime_client::{
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
@@ -106,6 +105,10 @@ use crate::{
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext,
state::{AppState, PuzzleApiState},
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_author::resolve_puzzle_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
};

View File

@@ -307,13 +307,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload(
levels,
form_draft: None,
};
let stage = if is_puzzle_session_snapshot_publish_ready(&draft) {
"ready_to_publish"
} else {
"image_refining"
};
Ok(PuzzleAgentSessionRecord {
session_id: session_id.to_string(),
seed_text: String::new(),
current_turn: 0,
progress_percent: 94,
stage: "ready_to_publish".to_string(),
stage: stage.to_string(),
anchor_pack,
draft: Some(draft),
messages: Vec::new(),
@@ -1764,7 +1769,11 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
sync_puzzle_primary_draft_fields_from_level(draft);
}
session.progress_percent = session.progress_percent.max(94);
session.stage = "ready_to_publish".to_string();
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
"ready_to_publish".to_string()
} else {
"image_refining".to_string()
};
session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string());
session.updated_at = format_timestamp_micros(updated_at_micros);
session

View File

@@ -589,6 +589,7 @@ pub async fn execute_puzzle_agent_action(
let now = current_utc_micros();
let action = payload.action.trim().to_string();
let billing_asset_id = format!("{session_id}:{now}");
let mut operation_consumed_points = 0;
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
@@ -617,13 +618,14 @@ pub async fn execute_puzzle_agent_action(
let log_session_id = session_id.clone();
let log_owner_user_id = owner_user_id.clone();
async move {
let failed_at_micros = current_utc_micros();
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id,
owner_user_id,
owner_user_id: owner_user_id.clone(),
error_message,
failed_at_micros: current_utc_micros(),
failed_at_micros,
})
.await;
if let Err(error) = result {
@@ -634,6 +636,20 @@ pub async fn execute_puzzle_agent_action(
message = %error,
"拼图草稿失败态回写失败,继续返回原始错误"
);
} else {
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id,
task_name: Some("拼图".to_string()),
work_name: None,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: failed_at_micros,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
}
};
@@ -641,6 +657,17 @@ pub async fn execute_puzzle_agent_action(
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let puzzle_draft_generation_points_cost = if ai_redraw {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state.root_state(),
"puzzle",
PUZZLE_IMAGE_GENERATION_POINTS_COST,
)
.await
} else {
0
};
operation_consumed_points = puzzle_draft_generation_points_cost;
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
@@ -677,10 +704,7 @@ pub async fn execute_puzzle_agent_action(
);
state
.spacetime_client()
.get_puzzle_agent_session(
compile_session_id.clone(),
owner_user_id.clone(),
)
.get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
.await
.map(mark_puzzle_initial_generation_started_snapshot)
.map_err(map_puzzle_client_error)
@@ -696,10 +720,9 @@ pub async fn execute_puzzle_agent_action(
.map_err(map_puzzle_compile_error);
match compiled_session {
Ok(compiled_session) => {
let response_session =
mark_puzzle_initial_generation_started_snapshot(
compiled_session.clone(),
);
let response_session = mark_puzzle_initial_generation_started_snapshot(
compiled_session.clone(),
);
let background_state = state.clone();
let background_request_context = request_context.clone();
let background_session_id = compile_session_id.clone();
@@ -708,20 +731,23 @@ pub async fn execute_puzzle_agent_action(
let background_reference_image_src =
primary_reference_image_src.map(str::to_string);
let background_image_model = payload.image_model.clone();
let background_points_cost = puzzle_draft_generation_points_cost;
let background_work_name = compiled_session
.draft
.as_ref()
.map(|draft| draft.work_title.clone());
let background_billing_asset_id =
format!("{background_session_id}:compile_puzzle_draft");
tokio::spawn(async move {
let operation_owner_user_id =
background_owner_user_id.clone();
let background_root_state =
background_state.root_state().clone();
let operation_owner_user_id = background_owner_user_id.clone();
let background_root_state = background_state.root_state().clone();
let operation_state = background_state.clone();
let result = execute_billable_asset_operation_with_cost(
&background_root_state,
&background_owner_user_id,
"puzzle_initial_image",
&background_billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
background_points_cost,
async move {
generate_puzzle_initial_cover_from_compiled_session(
&operation_state,
@@ -739,6 +765,23 @@ pub async fn execute_puzzle_agent_action(
.await;
match result {
Ok(session) => {
send_generation_result_subscribe_message_after_completion(
&background_root_state,
GenerationResultSubscribeMessage {
owner_user_id: background_owner_user_id.clone(),
task_name: Some("拼图".to_string()),
work_name: session
.draft
.as_ref()
.map(|draft| draft.work_title.clone()),
status:
GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: background_points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
@@ -748,15 +791,15 @@ pub async fn execute_puzzle_agent_action(
}
Err(error) => {
let error_message = error.body_text();
let failed_at_micros = current_utc_micros();
let failure_result = background_state
.spacetime_client()
.mark_puzzle_draft_generation_failed(
PuzzleDraftCompileFailureRecordInput {
session_id: background_session_id.clone(),
owner_user_id: background_owner_user_id
.clone(),
owner_user_id: background_owner_user_id.clone(),
error_message: error_message.clone(),
failed_at_micros: current_utc_micros(),
failed_at_micros,
},
)
.await;
@@ -768,6 +811,21 @@ pub async fn execute_puzzle_agent_action(
message = %mark_error,
"拼图首图后台生成失败态回写失败"
);
} else {
send_generation_result_subscribe_message_after_completion(
&background_root_state,
GenerationResultSubscribeMessage {
owner_user_id: background_owner_user_id.clone(),
task_name: Some("拼图".to_string()),
work_name: background_work_name.clone(),
status:
GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: failed_at_micros,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -778,9 +836,7 @@ pub async fn execute_puzzle_agent_action(
);
}
}
unregister_puzzle_background_compile_task(
&background_session_id,
);
unregister_puzzle_background_compile_task(&background_session_id);
});
Ok(response_session)
}
@@ -1428,6 +1484,26 @@ pub async fn execute_puzzle_agent_action(
};
let session = session?;
if operation_type == "compile_puzzle_draft"
&& session
.draft
.as_ref()
.is_some_and(|draft| draft.generation_status == "ready")
{
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id: owner_user_id.clone(),
task_name: Some("拼图".to_string()),
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: operation_consumed_points,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Ok(json_success_body(
Some(&request_context),

View File

@@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
session
}
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> bool {
image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
!draft.work_title.trim().is_empty()
&& !draft.work_description.trim().is_empty()
@@ -261,6 +272,18 @@ pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraft
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
&& has_required_puzzle_asset_ref(
&level.level_scene_image_src,
&level.level_scene_image_object_key,
)
&& has_required_puzzle_asset_ref(
&level.ui_spritesheet_image_src,
&level.ui_spritesheet_image_object_key,
)
&& has_required_puzzle_asset_ref(
&level.level_background_image_src,
&level.level_background_image_object_key,
)
})
}

View File

@@ -469,7 +469,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
.expect("fallback session");
let draft = session.draft.expect("draft");
assert_eq!(session.stage, "ready_to_publish");
assert_eq!(session.stage, "image_refining");
assert_eq!(draft.work_title, "暖灯猫街作品");
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
@@ -479,6 +479,62 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
);
}
#[test]
fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() {
let levels_json = serde_json::to_string(&vec![json!({
"level_id": "puzzle-level-1",
"level_name": "雨夜猫街",
"picture_description": "一只猫在雨夜灯牌下回头。",
"candidates": [],
"selected_candidate_id": null,
"cover_image_src": "/generated/puzzle/cover.png",
"cover_asset_id": "asset-cover",
"level_scene_image_src": "/generated/puzzle/level-scene.png",
"level_scene_image_object_key": "generated/puzzle/level-scene.png",
"ui_spritesheet_image_src": "/generated/puzzle/ui-spritesheet.png",
"ui_spritesheet_image_object_key": "generated/puzzle/ui-spritesheet.png",
"level_background_image_src": "/generated/puzzle/level-background.png",
"level_background_image_object_key": "generated/puzzle/level-background.png",
"generation_status": "ready",
})])
.expect("levels json");
let payload = ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
should_auto_name_level: None,
candidate_id: None,
level_id: Some("puzzle-level-1".to_string()),
work_title: Some("暖灯猫街作品".to_string()),
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
picture_description: None,
level_name: None,
summary: Some("当前关卡画面。".to_string()),
theme_tags: Some(vec![
"猫咪".to_string(),
"雨夜".to_string(),
"灯牌".to_string(),
]),
levels_json: Some(levels_json.clone()),
};
let session = build_puzzle_session_snapshot_from_action_payload(
"puzzle-session-1",
&payload,
Some(levels_json.as_str()),
1_713_686_401_234_567,
)
.expect("fallback session");
assert_eq!(session.stage, "ready_to_publish");
}
#[test]
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
assert_eq!(

View File

@@ -9,12 +9,12 @@ use shared_contracts::{
puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse},
puzzle_works::PuzzleWorkSummaryResponse,
};
#[cfg(test)]
use tokio::sync::OwnedMutexGuard;
use tokio::{
sync::{Mutex, MutexGuard, RwLock},
time,
};
#[cfg(test)]
use tokio::sync::OwnedMutexGuard;
use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext};

View File

@@ -26,6 +26,7 @@ use module_runtime::{
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind,
};
use platform_wechat::pay::WechatPayNotifyOrder;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::Sha256;
@@ -81,9 +82,9 @@ use crate::{
http_error::AppError,
request_context::RequestContext,
state::AppState,
wechat_pay::{
WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request,
current_unix_micros, map_wechat_pay_error,
wechat::pay::{
build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros,
map_wechat_pay_error,
},
};
@@ -3056,11 +3057,12 @@ mod tests {
}
fn issue_access_token(state: &AppState) -> String {
let user_id = test_authenticated_user_id(state);
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
user_id: user_id.clone(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
.seed_test_refresh_session_for_user_id(&user_id, "sess_runtime_profile"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
@@ -3081,11 +3083,11 @@ mod tests {
client_platform: &str,
session_id: &str,
) -> String {
let user_id = test_authenticated_user_id(state);
let claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", session_id),
user_id: user_id.clone(),
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
provider: AuthProvider::Wechat,
roles: vec!["user".to_string()],
token_version: 2,
@@ -3105,4 +3107,13 @@ mod tests {
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn test_authenticated_user_id(state: &AppState) -> String {
state
.auth_user_service()
.get_user_by_public_user_code("SY-00000001")
.expect("test user lookup should succeed")
.expect("seeded test user should exist")
.id
}
}

View File

@@ -81,12 +81,18 @@ use crate::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
},
state::AppState,
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent";
const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works";
const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime";
const SQUARE_HOLE_TEMPLATE_ID: &str = "square-hole";
const SQUARE_HOLE_TEMPLATE_NAME: &str = "方洞";
const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱";
const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能";
const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12;
@@ -1112,14 +1118,24 @@ async fn compile_square_hole_draft_for_session(
.as_ref()
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
state
let resolved_game_name = game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text)));
let generation_points_cost =
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
SQUARE_HOLE_TEMPLATE_ID,
u64::from(
shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
),
)
.await;
let result = state
.spacetime_client()
.compile_square_hole_draft(SquareHoleCompileDraftRecordInput {
session_id,
owner_user_id,
owner_user_id: owner_user_id.clone(),
profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX),
author_display_name: resolve_author_display_name(state, authenticated),
game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))),
game_name: resolved_game_name.clone(),
summary_text: summary,
tags_json,
cover_image_src,
@@ -1132,7 +1148,43 @@ async fn compile_square_hole_draft_for_session(
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})
});
match result {
Ok(session) => {
send_generation_result_subscribe_message_after_completion(
state,
GenerationResultSubscribeMessage {
owner_user_id,
task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()),
work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: generation_points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
Ok(session)
}
Err(response) => {
if response.status().is_server_error() {
send_generation_result_subscribe_message_after_completion(
state,
GenerationResultSubscribeMessage {
owner_user_id,
task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()),
work_name: resolved_game_name,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Err(response)
}
}
}
mod visual_assets;

View File

@@ -10,12 +10,12 @@ use std::{
use axum::extract::FromRef;
use module_ai::{AiTaskService, InMemoryAiTaskStore};
#[cfg(not(test))]
use module_auth::RefreshAuthStoreSnapshotResult;
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
#[cfg(not(test))]
use module_auth::RefreshAuthStoreSnapshotResult;
use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
@@ -27,6 +27,7 @@ use platform_auth::{
};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
@@ -38,8 +39,8 @@ use tracing::{info, warn};
use crate::config::AppConfig;
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
use crate::tracking_outbox::TrackingOutbox;
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
use crate::wechat_provider::build_wechat_provider;
use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error};
use crate::wechat::provider::build_wechat_provider;
use crate::work_author::{
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
};
@@ -251,6 +252,7 @@ pub struct AppStateInner {
wechat_auth_state_service: WechatAuthStateService,
wechat_auth_service: WechatAuthService,
wechat_provider: WechatProvider,
wechat_client: WechatClient,
wechat_pay_client: WechatPayClient,
#[cfg_attr(not(test), allow(dead_code))]
ai_task_service: AiTaskService,
@@ -385,8 +387,9 @@ impl AppState {
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
let wechat_provider = build_wechat_provider(&config);
let wechat_pay_client =
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
let wechat_client = build_wechat_client(&config);
let wechat_pay_client = WechatPayClient::from_config(&build_wechat_pay_config(&config))
.map_err(map_wechat_pay_init_error)?;
let refresh_session_service =
RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days);
// AI 编排服务当前先挂接内存态 store后续再按 task table / procedure 接到 SpacetimeDB 真相源。
@@ -424,6 +427,7 @@ impl AppState {
wechat_auth_state_service,
wechat_auth_service,
wechat_provider,
wechat_client,
wechat_pay_client,
ai_task_service,
spacetime_client,
@@ -776,6 +780,10 @@ impl AppState {
&self.wechat_provider
}
pub fn wechat_client(&self) -> &WechatClient {
&self.wechat_client
}
pub fn wechat_pay_client(&self) -> &WechatPayClient {
&self.wechat_pay_client
}
@@ -1333,6 +1341,17 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
Ok(Some(OssClient::new(oss_config)))
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
app_secret: config.wechat_mini_program_app_secret.clone(),
stable_access_token_endpoint: config.wechat_stable_access_token_endpoint.clone(),
subscribe_message_endpoint: config
.wechat_mini_program_subscribe_message_endpoint
.clone(),
})
}
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> {
let Some(api_key) = config
.llm_api_key

View File

@@ -35,6 +35,10 @@ use crate::{
prompt::visual_novel as vn_prompt,
request_context::RequestContext,
state::AppState,
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -1743,8 +1747,18 @@ async fn compile_visual_novel_session_inner(
current_utc_iso().as_str(),
);
let projection = project_draft_for_work(&draft, &profile_id)?;
let notification_work_name = projection.work_title.clone();
let generation_points_cost =
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
VISUAL_NOVEL_RUNTIME_KIND,
u64::from(
shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
),
)
.await;
let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None);
let compiled_session = state
let compile_result = state
.spacetime_client()
.compile_visual_novel_work_profile(VisualNovelWorkCompileRecordInput {
session_id: session_id.clone(),
@@ -1759,9 +1773,43 @@ async fn compile_visual_novel_session_inner(
compiled_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
visual_novel_error_response(request_context, map_spacetime_error(error))
})?;
.map_err(|error| visual_novel_error_response(request_context, map_spacetime_error(error)));
let compiled_session = match compile_result {
Ok(session) => {
send_generation_result_subscribe_message_after_completion(
state,
GenerationResultSubscribeMessage {
owner_user_id: owner_user_id.clone(),
task_name: Some("视觉小说".to_string()),
work_name: Some(notification_work_name.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: generation_points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
session
}
Err(response) => {
if response.status().is_server_error() {
send_generation_result_subscribe_message_after_completion(
state,
GenerationResultSubscribeMessage {
owner_user_id,
task_name: Some("视觉小说".to_string()),
work_name: Some(notification_work_name),
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
return Err(response);
}
};
let work = state
.spacetime_client()
.get_visual_novel_work_detail(profile_id, owner_user_id)

View File

@@ -0,0 +1,4 @@
pub(crate) mod auth;
pub(crate) mod pay;
pub(crate) mod provider;
pub(crate) mod subscribe_message;

View File

@@ -1,4 +1,4 @@
use axum::{
use axum::{
Json,
extract::{Extension, Query, State},
http::{HeaderMap, StatusCode},

View File

@@ -0,0 +1,423 @@
use axum::{
Json,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use bytes::Bytes;
use platform_wechat::pay::{
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig,
WechatPayError, WechatWebOrderRequest, decrypt_wechat_message_push_ciphertext,
parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload,
resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature,
};
use serde::Serialize;
use serde_json::json;
use shared_kernel::offset_datetime_to_unix_micros;
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::{config::AppConfig, http_error::AppError, state::AppState};
#[derive(Clone, Copy)]
enum VirtualPaymentNotifyResponseFormat {
Json,
Xml,
}
#[derive(Serialize)]
struct ApiWechatVirtualPaymentNotifyResponse {
#[serde(rename = "ErrCode")]
err_code: i32,
#[serde(rename = "ErrMsg")]
err_msg: String,
}
pub async fn handle_wechat_pay_notify(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, AppError> {
let notify = state
.wechat_pay_client()
.parse_notify(&headers, &body)
.map_err(map_wechat_pay_notify_error)?;
if notify.trade_state != "SUCCESS" {
info!(
order_id = notify.out_trade_no.as_str(),
trade_state = notify.trade_state.as_str(),
"收到非成功微信支付通知"
);
return Ok(StatusCode::NO_CONTENT);
}
let paid_at_micros = notify
.success_time
.as_deref()
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros);
state
.spacetime_client()
.mark_profile_recharge_order_paid(
notify.out_trade_no.clone(),
paid_at_micros,
notify.transaction_id.clone(),
)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("确认微信支付订单失败:{error}"))
})?;
info!(
order_id = notify.out_trade_no.as_str(),
"微信支付通知已确认订单入账"
);
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 build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig {
WechatPayConfig {
enabled: config.wechat_pay_enabled,
provider: config.wechat_pay_provider.clone(),
app_id: config
.wechat_mini_program_app_id
.clone()
.or_else(|| config.wechat_app_id.clone()),
mch_id: config.wechat_pay_mch_id.clone(),
merchant_serial_no: config.wechat_pay_merchant_serial_no.clone(),
private_key_pem: config.wechat_pay_private_key_pem.clone(),
private_key_path: config.wechat_pay_private_key_path.clone(),
platform_public_key_pem: config.wechat_pay_platform_public_key_pem.clone(),
platform_public_key_path: config.wechat_pay_platform_public_key_path.clone(),
platform_serial_no: config.wechat_pay_platform_serial_no.clone(),
api_v3_key: config.wechat_pay_api_v3_key.clone(),
notify_url: config.wechat_pay_notify_url.clone(),
jsapi_endpoint: config.wechat_pay_jsapi_endpoint.clone(),
}
}
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
match error {
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信支付暂未启用")
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" }))
}
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidSignature(message) => {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("微信支付通知签名无效")
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
}
}
}
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
crate::state::AppStateInitError::WechatPay(error.to_string())
}
pub fn build_wechat_payment_request(
order_id: String,
product_title: String,
amount_cents: u64,
payer_openid: String,
) -> WechatMiniProgramOrderRequest {
WechatMiniProgramOrderRequest {
order_id,
description: format!("陶泥儿 - {product_title}"),
amount_cents,
payer_openid,
}
}
pub fn build_wechat_web_payment_request(
order_id: String,
product_title: String,
amount_cents: u64,
payer_client_ip: String,
) -> WechatWebOrderRequest {
WechatWebOrderRequest {
order_id,
description: format!("陶泥儿 - {product_title}"),
amount_cents,
payer_client_ip,
}
}
pub fn current_unix_micros() -> i64 {
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
i64::try_from(value).unwrap_or(i64::MAX)
}
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
warn!(error = %error, "微信支付通知处理失败");
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 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 build_wechat_virtual_payment_notify_response(
err_code: i32,
err_msg: impl Into<String>,
) -> ApiWechatVirtualPaymentNotifyResponse {
ApiWechatVirtualPaymentNotifyResponse {
err_code,
err_msg: err_msg.into(),
}
}
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,
}
}

View File

@@ -1,4 +1,4 @@
use platform_auth::{
use platform_auth::{
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT,
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT,

View File

@@ -0,0 +1,246 @@
use std::collections::BTreeMap;
use axum::http::StatusCode;
use platform_wechat::WechatSubscribeMessageRequest;
use time::{OffsetDateTime, UtcOffset};
use tracing::{info, warn};
use crate::{http_error::AppError, platform_errors::map_wechat_error, state::AppState};
const GENERATION_RESULT_TASK_NAME: &str = "AI创作生成";
const DEFAULT_WORK_NAME: &str = "AI创作作品";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GenerationResultSubscribeMessageStatus {
Succeeded,
Failed,
}
#[derive(Clone, Debug)]
pub struct GenerationResultSubscribeMessage {
pub owner_user_id: String,
pub task_name: Option<String>,
pub work_name: Option<String>,
pub status: GenerationResultSubscribeMessageStatus,
pub consumed_points: u64,
pub completed_at_micros: i64,
pub page: Option<String>,
}
pub async fn send_generation_result_subscribe_message_after_completion(
state: &AppState,
message: GenerationResultSubscribeMessage,
) {
if let Err(error) = send_generation_result_subscribe_message(state, message).await {
warn!(
error = %error,
"微信小程序生成结果订阅消息发送失败,已忽略"
);
}
}
async fn send_generation_result_subscribe_message(
state: &AppState,
message: GenerationResultSubscribeMessage,
) -> Result<(), AppError> {
if !state.config.wechat_mini_program_subscribe_message_enabled {
return Ok(());
}
let template_id = state
.config
.wechat_mini_program_generation_result_template_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("微信订阅消息模板 ID 未配置")
})?;
let user = state
.auth_user_service()
.get_user_by_id(&message.owner_user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信订阅消息用户失败:{error}"))
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("微信订阅消息用户不存在")
})?;
let openid = user
.wechat_account
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("用户未绑定微信小程序 openid")
})?;
state
.wechat_client()
.send_subscribe_message(WechatSubscribeMessageRequest {
touser: openid.to_string(),
template_id: template_id.to_string(),
page: message
.page
.clone()
.or_else(|| Some("/pages/web-view/index".to_string())),
miniprogram_state: Some(
normalize_miniprogram_state(
&state.config.wechat_mini_program_subscribe_message_state,
)
.to_string(),
),
lang: Some("zh_CN".to_string()),
data: build_generation_result_template_data(&message),
})
.await
.map_err(map_wechat_error)?;
info!(
owner_user_id = %message.owner_user_id,
template_id,
"微信小程序生成结果订阅消息已发送"
);
Ok(())
}
fn build_generation_result_template_data(
message: &GenerationResultSubscribeMessage,
) -> BTreeMap<String, String> {
BTreeMap::from([
(
"thing1".to_string(),
truncate_template_value(
message
.task_name
.as_deref()
.unwrap_or(GENERATION_RESULT_TASK_NAME),
20,
),
),
(
"phrase2".to_string(),
truncate_template_value(message.status.template_status_label(), 5),
),
(
"time4".to_string(),
truncate_template_value(
&format_generation_completed_time(message.completed_at_micros),
20,
),
),
(
"thing5".to_string(),
truncate_template_value(
message.work_name.as_deref().unwrap_or(DEFAULT_WORK_NAME),
20,
),
),
(
"number6".to_string(),
truncate_template_value(&message.consumed_points.to_string(), 32),
),
])
}
impl GenerationResultSubscribeMessageStatus {
fn template_status_label(self) -> &'static str {
match self {
Self::Succeeded => "已完成",
Self::Failed => "生成失败",
}
}
}
fn truncate_template_value(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
let mut result = String::new();
for character in trimmed.chars().take(max_chars) {
result.push(character);
}
if result.is_empty() {
DEFAULT_WORK_NAME.to_string()
} else {
result
}
}
fn format_generation_completed_time(completed_at_micros: i64) -> String {
let seconds = completed_at_micros.div_euclid(1_000_000);
let Ok(utc_time) = OffsetDateTime::from_unix_timestamp(seconds) else {
return "1970-01-01 08:00".to_string();
};
let beijing_offset = UtcOffset::from_hms(8, 0, 0).unwrap_or(UtcOffset::UTC);
let local_time = utc_time.to_offset(beijing_offset);
format!(
"{:04}-{:02}-{:02} {:02}:{:02}",
local_time.year(),
u8::from(local_time.month()),
local_time.day(),
local_time.hour(),
local_time.minute()
)
}
fn normalize_miniprogram_state(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"developer" | "develop" | "dev" => "developer",
"trial" => "trial",
_ => "formal",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_generation_result_template_uses_failed_status_and_zero_points() {
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
owner_user_id: "user-1".to_string(),
task_name: Some("拼图".to_string()),
work_name: Some("首关拼图".to_string()),
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: 1_762_000_000_000_000,
page: None,
});
assert_eq!(data.get("phrase2").map(String::as_str), Some("生成失败"));
assert_eq!(data.get("number6").map(String::as_str), Some("0"));
}
#[test]
fn generation_result_template_time_uses_wechat_time_format() {
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
owner_user_id: "user-1".to_string(),
task_name: Some("拼图".to_string()),
work_name: Some("首关拼图".to_string()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: 15,
completed_at_micros: 0,
page: None,
});
assert_eq!(
data.get("time4").map(String::as_str),
Some("1970-01-01 08:00")
);
}
#[test]
fn generation_result_template_uses_task_template_name() {
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
owner_user_id: "user-1".to_string(),
task_name: Some("敲木鱼".to_string()),
work_name: Some("功德木鱼".to_string()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: 10,
completed_at_micros: 0,
page: None,
});
assert_eq!(data.get("thing1").map(String::as_str), Some("敲木鱼"));
}
}

View File

@@ -1,4 +1,4 @@
use std::{
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
};
@@ -43,6 +43,10 @@ use crate::{
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
};
const WOODEN_FISH_PROVIDER: &str = "wooden-fish";
@@ -147,6 +151,15 @@ 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);
let is_compile_draft = matches!(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
);
let generation_points_cost = if is_compile_draft {
resolve_wooden_fish_generation_points_cost(&state).await
} else {
0
};
let result = execute_wooden_fish_action_with_generated_assets(
&state,
&request_context,
@@ -160,21 +173,55 @@ pub async fn execute_wooden_fish_action(
.as_ref()
.err()
.is_some_and(|response| response.status().is_server_error())
&& matches!(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
)
&& is_compile_draft
{
mark_wooden_fish_generation_failed(
let failed_at_micros = current_utc_micros();
let work_name =
resolve_wooden_fish_notification_work_name(&state, &session_id, &owner_user_id).await;
if mark_wooden_fish_generation_failed(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
author_display_name.as_str(),
)
.await;
.await
{
send_generation_result_subscribe_message_after_completion(
&state,
GenerationResultSubscribeMessage {
owner_user_id: owner_user_id.clone(),
task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()),
work_name,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: failed_at_micros,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
}
let response = result?;
if is_compile_draft && response.session.status == WoodenFishGenerationStatus::Ready {
send_generation_result_subscribe_message_after_completion(
&state,
GenerationResultSubscribeMessage {
owner_user_id,
task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()),
work_name: response
.session
.draft
.as_ref()
.map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: generation_points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Ok(json_success_body(Some(&request_context), response))
}
@@ -588,13 +635,37 @@ async fn execute_wooden_fish_action_with_generated_assets(
})
}
async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
WOODEN_FISH_TEMPLATE_ID,
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
)
.await
}
async fn resolve_wooden_fish_notification_work_name(
state: &AppState,
session_id: &str,
owner_user_id: &str,
) -> Option<String> {
state
.spacetime_client()
.get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.ok()
.and_then(|session| session.draft)
.map(|draft| draft.work_title)
.filter(|value| !value.trim().is_empty())
}
async fn mark_wooden_fish_generation_failed(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
) {
) -> bool {
if let Err(error) = state
.spacetime_client()
.mark_wooden_fish_generation_failed(
@@ -612,7 +683,9 @@ async fn mark_wooden_fish_generation_failed(
error = %error,
"敲木鱼草稿生成失败后的状态回写失败"
);
return false;
}
true
}
fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {

View File

@@ -541,6 +541,17 @@ pub fn build_result_preview(
}
}
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> bool {
image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
pub fn validate_publish_requirements(
draft: &PuzzleResultDraft,
author_display_name: Option<&str>,
@@ -582,6 +593,36 @@ pub fn validate_publish_requirements(
message: "正式拼图图片尚未确定".to_string(),
});
}
if !has_required_puzzle_asset_ref(
&level.level_scene_image_src,
&level.level_scene_image_object_key,
) {
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-level-scene-image-{}", level.level_id),
code: "MISSING_LEVEL_SCENE_IMAGE".to_string(),
message: "正式关卡画面尚未生成".to_string(),
});
}
if !has_required_puzzle_asset_ref(
&level.ui_spritesheet_image_src,
&level.ui_spritesheet_image_object_key,
) {
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-ui-spritesheet-image-{}", level.level_id),
code: "MISSING_UI_SPRITESHEET_IMAGE".to_string(),
message: "UI spritesheet 尚未生成".to_string(),
});
}
if !has_required_puzzle_asset_ref(
&level.level_background_image_src,
&level.level_background_image_object_key,
) {
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-level-background-image-{}", level.level_id),
code: "MISSING_LEVEL_BACKGROUND_IMAGE".to_string(),
message: "关卡背景图尚未生成".to_string(),
});
}
}
if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|| draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT
@@ -4011,4 +4052,37 @@ mod tests {
.any(|blocker| blocker.code == "MISSING_LEVEL_NAME")
);
}
#[test]
fn validate_publish_requirements_requires_generated_level_asset_pack() {
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
let mut draft = compile_result_draft(&anchor_pack, &[]);
draft.levels[0].cover_image_src = Some("/cover.png".to_string());
let blockers = validate_publish_requirements(&draft, Some("玩家"));
let blocker_codes = blockers
.iter()
.map(|blocker| blocker.code.as_str())
.collect::<Vec<_>>();
assert!(blocker_codes.contains(&"MISSING_LEVEL_SCENE_IMAGE"));
assert!(blocker_codes.contains(&"MISSING_UI_SPRITESHEET_IMAGE"));
assert!(blocker_codes.contains(&"MISSING_LEVEL_BACKGROUND_IMAGE"));
draft.levels[0].level_scene_image_object_key =
Some("generated/puzzle/level-scene.png".to_string());
draft.levels[0].ui_spritesheet_image_object_key =
Some("generated/puzzle/ui-spritesheet.png".to_string());
draft.levels[0].level_background_image_object_key =
Some("generated/puzzle/level-background.png".to_string());
let blockers = validate_publish_requirements(&draft, Some("玩家"));
assert!(!blockers.iter().any(|blocker| {
matches!(
blocker.code.as_str(),
"MISSING_LEVEL_SCENE_IMAGE"
| "MISSING_UI_SPRITESHEET_IMAGE"
| "MISSING_LEVEL_BACKGROUND_IMAGE"
)
}));
}
}

View File

@@ -0,0 +1,22 @@
[package]
name = "platform-wechat"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
aes = { workspace = true }
base64 = { workspace = true }
cbc = { workspace = true }
hex = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
ring = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
shared-contracts = { workspace = true }
time = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }

View File

@@ -0,0 +1,11 @@
pub mod pay;
pub mod subscribe_message;
pub use pay::{
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayClient,
WechatPayConfig, WechatPayError, WechatPayNotifyOrder, WechatWebOrderRequest,
};
pub use subscribe_message::{
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT,
WechatClient, WechatConfig, WechatError, WechatErrorKind, WechatSubscribeMessageRequest,
};

View File

@@ -1,38 +1,33 @@
use std::{fs, path::Path, sync::Arc};
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
};
use aes::Aes256;
use axum::{
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 bytes::Bytes;
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
use reqwest::header::HeaderMap;
use ring::{
aead,
rand::{SecureRandom, SystemRandom},
signature,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::Value;
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 tracing::warn;
use url::Url;
use crate::{http_error::AppError, state::AppState};
const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
@@ -61,6 +56,23 @@ const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
#[derive(Clone, Debug)]
pub struct WechatPayConfig {
pub enabled: bool,
pub provider: String,
pub app_id: Option<String>,
pub mch_id: Option<String>,
pub merchant_serial_no: Option<String>,
pub private_key_pem: Option<String>,
pub private_key_path: Option<PathBuf>,
pub platform_public_key_pem: Option<String>,
pub platform_public_key_path: Option<PathBuf>,
pub platform_serial_no: Option<String>,
pub api_v3_key: Option<String>,
pub notify_url: Option<String>,
pub jsapi_endpoint: String,
}
#[derive(Clone, Debug)]
pub enum WechatPayClient {
Disabled,
@@ -110,19 +122,11 @@ pub struct WechatPayNotifyOrder {
}
#[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,
pub struct WechatVirtualPaymentNotifyOrder {
pub out_trade_no: String,
pub transaction_id: Option<String>,
pub paid_at_micros: Option<i64>,
pub event: String,
}
#[derive(Debug)]
@@ -276,30 +280,30 @@ struct WechatVirtualPaymentNotifyPayInfo {
}
#[derive(Debug, Deserialize)]
pub(crate) struct WechatMiniProgramMessagePushQuery {
signature: Option<String>,
timestamp: Option<String>,
nonce: Option<String>,
echostr: Option<String>,
msg_signature: Option<String>,
pub struct WechatMiniProgramMessagePushQuery {
pub signature: Option<String>,
pub timestamp: Option<String>,
pub nonce: Option<String>,
pub echostr: Option<String>,
pub msg_signature: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatMiniProgramEncryptedMessage {
pub struct WechatMiniProgramEncryptedMessage {
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
_to_user_name: Option<String>,
#[serde(rename = "Encrypt", alias = "encrypt")]
encrypt: String,
pub encrypt: String,
}
impl WechatPayClient {
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
if !config.wechat_pay_enabled {
pub fn from_config(config: &WechatPayConfig) -> Result<Self, WechatPayError> {
if !config.enabled {
return Ok(Self::Disabled);
}
if config
.wechat_pay_provider
.provider
.trim()
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK)
{
@@ -307,7 +311,7 @@ impl WechatPayClient {
}
if !config
.wechat_pay_provider
.provider
.trim()
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL)
{
@@ -317,52 +321,43 @@ impl WechatPayClient {
}
let app_id = config
.wechat_mini_program_app_id
.app_id
.as_ref()
.or(config.wechat_app_id.as_ref())
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))?
.to_string();
let mch_id = required_config(config.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
let mch_id = required_config(config.mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
let merchant_serial_no = required_config(
config.wechat_pay_merchant_serial_no.as_deref(),
config.merchant_serial_no.as_deref(),
"WECHAT_PAY_MERCHANT_SERIAL_NO",
)?;
let private_key_pem = read_private_key_pem(
config.wechat_pay_private_key_pem.as_deref(),
config.wechat_pay_private_key_path.as_deref(),
config.private_key_pem.as_deref(),
config.private_key_path.as_deref(),
)?;
let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?);
let platform_public_key_pem = read_pem(
config.wechat_pay_platform_public_key_pem.as_deref(),
config.wechat_pay_platform_public_key_path.as_deref(),
config.platform_public_key_pem.as_deref(),
config.platform_public_key_path.as_deref(),
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置",
"读取微信支付平台公钥失败",
)?;
let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?;
let platform_serial_no = required_config(
config.wechat_pay_platform_serial_no.as_deref(),
config.platform_serial_no.as_deref(),
"WECHAT_PAY_PLATFORM_SERIAL_NO",
)?;
let api_v3_key = required_config(
config.wechat_pay_api_v3_key.as_deref(),
"WECHAT_PAY_API_V3_KEY",
)?;
let api_v3_key = required_config(config.api_v3_key.as_deref(), "WECHAT_PAY_API_V3_KEY")?;
if api_v3_key.as_bytes().len() != 32 {
return Err(WechatPayError::InvalidConfig(
"WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(),
));
}
let notify_url = required_config(
config.wechat_pay_notify_url.as_deref(),
"WECHAT_PAY_NOTIFY_URL",
)?;
let notify_url = required_config(config.notify_url.as_deref(), "WECHAT_PAY_NOTIFY_URL")?;
validate_notify_url(&notify_url, "WECHAT_PAY_NOTIFY_URL")?;
let jsapi_endpoint = normalize_required_url(
&config.wechat_pay_jsapi_endpoint,
"WECHAT_PAY_JSAPI_ENDPOINT",
)?;
let jsapi_endpoint =
normalize_required_url(&config.jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT")?;
let h5_endpoint =
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?;
let native_endpoint =
@@ -833,293 +828,97 @@ impl RealWechatPayClient {
}
}
pub async fn handle_wechat_pay_notify(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, AppError> {
let notify = state
.wechat_pay_client()
.parse_notify(&headers, &body)
.map_err(map_wechat_pay_notify_error)?;
if notify.trade_state != "SUCCESS" {
info!(
order_id = notify.out_trade_no.as_str(),
trade_state = notify.trade_state.as_str(),
"收到非成功微信支付通知"
);
return Ok(StatusCode::NO_CONTENT);
}
let paid_at_micros = notify
.success_time
.as_deref()
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros);
state
.spacetime_client()
.mark_profile_recharge_order_paid(
notify.out_trade_no.clone(),
paid_at_micros,
notify.transaction_id.clone(),
fn with_wechat_pay_json_headers(
builder: reqwest::RequestBuilder,
platform_serial_no: &str,
) -> reqwest::RequestBuilder {
builder
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
.header(
reqwest::header::CONTENT_TYPE,
WECHAT_PAY_CONTENT_TYPE_HEADER,
)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("确认微信支付订单失败:{error}"))
})?;
info!(
order_id = notify.out_trade_no.as_str(),
"微信支付通知已确认订单入账"
);
Ok(StatusCode::NO_CONTENT)
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
}
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),
fn with_wechat_pay_jsapi_headers(
builder: reqwest::RequestBuilder,
platform_serial_no: &str,
) -> reqwest::RequestBuilder {
with_wechat_pay_json_headers(builder, platform_serial_no)
}
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce_str = "mock-nonce".to_string();
let package = format!("prepay_id=mock-{order_id}");
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
WechatMiniProgramPayParamsResponse {
time_stamp,
nonce_str,
package,
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
pay_sign,
}
}
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)
.with_message("微信支付暂未启用")
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" }))
}
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidSignature(message) => {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("微信支付通知签名无效")
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
}
fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
WechatH5PaymentResponse {
h5_url: format!(
"https://mock.wechat-pay.local/h5?out_trade_no={}",
urlencoding::encode(order_id)
),
}
}
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
crate::state::AppStateInitError::WechatPay(error.to_string())
}
pub fn build_wechat_payment_request(
order_id: String,
product_title: String,
amount_cents: u64,
payer_openid: String,
) -> WechatMiniProgramOrderRequest {
WechatMiniProgramOrderRequest {
order_id,
description: format!("陶泥儿 - {product_title}"),
amount_cents,
payer_openid,
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
WechatNativePaymentResponse {
code_url: format!(
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
hex_sha256(order_id.as_bytes())
),
}
}
pub fn build_wechat_web_payment_request(
order_id: String,
product_title: String,
amount_cents: u64,
payer_client_ip: String,
) -> WechatWebOrderRequest {
WechatWebOrderRequest {
order_id,
description: format!("陶泥儿 - {product_title}"),
amount_cents,
payer_client_ip,
}
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
})?;
Ok(WechatPayNotifyOrder {
out_trade_no: value
.get("outTradeNo")
.or_else(|| value.get("out_trade_no"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string())
})?
.to_string(),
transaction_id: value
.get("transactionId")
.or_else(|| value.get("transaction_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
trade_state: value
.get("tradeState")
.or_else(|| value.get("trade_state"))
.and_then(Value::as_str)
.unwrap_or("SUCCESS")
.to_string(),
success_time: value
.get("successTime")
.or_else(|| value.get("success_time"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
pub fn current_unix_micros() -> i64 {
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
i64::try_from(value).unwrap_or(i64::MAX)
}
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
warn!(error = %error, "微信支付通知处理失败");
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(
pub fn resolve_wechat_message_push_verify_response(
token: &str,
aes_key: &str,
expected_app_id: Option<&str>,
@@ -1161,7 +960,7 @@ fn resolve_wechat_message_push_verify_response(
Ok(echostr.to_string())
}
fn parse_wechat_mini_program_message_push_payload(
pub fn parse_wechat_mini_program_message_push_payload(
body: &[u8],
) -> Result<WechatMiniProgramEncryptedMessage, WechatPayError> {
serde_json::from_slice(body).map_err(|error| {
@@ -1169,7 +968,7 @@ fn parse_wechat_mini_program_message_push_payload(
})
}
fn verify_wechat_message_push_signature(
pub fn verify_wechat_message_push_signature(
token: &str,
timestamp: &str,
nonce: &str,
@@ -1184,7 +983,7 @@ fn verify_wechat_message_push_signature(
expected.eq_ignore_ascii_case(signature)
}
fn decrypt_wechat_message_push_ciphertext(
pub fn decrypt_wechat_message_push_ciphertext(
encoding_aes_key: &str,
ciphertext: &str,
expected_app_id: Option<&str>,
@@ -1302,7 +1101,7 @@ fn parse_wechat_message_push_plaintext(
Ok(WechatMessagePushPlaintext { message, app_id })
}
fn parse_virtual_payment_notify(
pub fn parse_virtual_payment_notify(
body: &[u8],
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
if let Ok(notify) = serde_json::from_slice::<WechatVirtualPaymentNotifyBody>(body) {
@@ -1402,184 +1201,6 @@ fn trim_virtual_payment_text_value(value: &str) -> 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,
) -> reqwest::RequestBuilder {
builder
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
.header(
reqwest::header::CONTENT_TYPE,
WECHAT_PAY_CONTENT_TYPE_HEADER,
)
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
}
fn with_wechat_pay_jsapi_headers(
builder: reqwest::RequestBuilder,
platform_serial_no: &str,
) -> reqwest::RequestBuilder {
with_wechat_pay_json_headers(builder, platform_serial_no)
}
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce_str = "mock-nonce".to_string();
let package = format!("prepay_id=mock-{order_id}");
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
WechatMiniProgramPayParamsResponse {
time_stamp,
nonce_str,
package,
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
pay_sign,
}
}
fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
WechatH5PaymentResponse {
h5_url: format!(
"https://mock.wechat-pay.local/h5?out_trade_no={}",
urlencoding::encode(order_id)
),
}
}
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
WechatNativePaymentResponse {
code_url: format!(
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
hex_sha256(order_id.as_bytes())
),
}
}
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
})?;
Ok(WechatPayNotifyOrder {
out_trade_no: value
.get("outTradeNo")
.or_else(|| value.get("out_trade_no"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string())
})?
.to_string(),
transaction_id: value
.get("transactionId")
.or_else(|| value.get("transaction_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
trade_state: value
.get("tradeState")
.or_else(|| value.get("trade_state"))
.and_then(Value::as_str)
.unwrap_or("SUCCESS")
.to_string(),
success_time: value
.get("successTime")
.or_else(|| value.get("success_time"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
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)
@@ -1946,6 +1567,7 @@ impl std::error::Error for WechatPayError {}
mod tests {
use super::*;
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
use serde_json::json;
#[test]
fn mock_pay_params_use_request_payment_shape() {

View File

@@ -0,0 +1,234 @@
use std::{collections::BTreeMap, error::Error, fmt};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::warn;
use url::Url;
pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str =
"https://api.weixin.qq.com/cgi-bin/stable_token";
pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str =
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatConfig {
pub app_id: Option<String>,
pub app_secret: Option<String>,
pub stable_access_token_endpoint: String,
pub subscribe_message_endpoint: String,
}
#[derive(Clone, Debug)]
pub struct WechatClient {
client: Client,
config: WechatConfig,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatSubscribeMessageRequest {
pub touser: String,
pub template_id: String,
pub page: Option<String>,
pub miniprogram_state: Option<String>,
pub lang: Option<String>,
pub data: BTreeMap<String, String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum WechatError {
InvalidConfig(String),
RequestFailed(String),
DeserializeFailed(String),
Upstream(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WechatErrorKind {
InvalidConfig,
RequestFailed,
DeserializeFailed,
Upstream,
}
#[derive(Debug, Deserialize)]
struct WechatStableAccessTokenResponse {
access_token: Option<String>,
errcode: Option<i64>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatSubscribeMessageResponse {
errcode: i64,
errmsg: Option<String>,
}
#[derive(Debug, Serialize)]
struct WechatTemplateDataValue {
value: String,
}
impl WechatClient {
pub fn new(config: WechatConfig) -> Self {
Self {
client: Client::new(),
config,
}
}
pub async fn send_subscribe_message(
&self,
request: WechatSubscribeMessageRequest,
) -> Result<(), WechatError> {
let app_id = self
.config
.app_id
.as_deref()
.and_then(non_empty)
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?;
let app_secret = self
.config
.app_secret
.as_deref()
.and_then(non_empty)
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?;
let access_token = self.request_access_token(app_id, app_secret).await?;
let mut send_url =
Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| {
WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}"))
})?;
send_url
.query_pairs_mut()
.append_pair("access_token", &access_token);
let data = request
.data
.into_iter()
.map(|(key, value)| (key, WechatTemplateDataValue { value }))
.collect::<BTreeMap<_, _>>();
let payload = json!({
"touser": request.touser,
"template_id": request.template_id,
"page": request.page,
"miniprogram_state": request.miniprogram_state,
"lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()),
"data": data,
});
let response = self
.client
.post(send_url.as_str())
.json(&payload)
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信订阅消息请求失败");
WechatError::RequestFailed("微信订阅消息请求失败".to_string())
})?
.json::<WechatSubscribeMessageResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信订阅消息响应解析失败");
WechatError::DeserializeFailed("微信订阅消息响应非法".to_string())
})?;
if response.errcode != 0 {
return Err(WechatError::Upstream(format!(
"微信订阅消息发送失败:{}",
response.errmsg.unwrap_or_else(|| format!(
"subscribeMessage.send 返回错误 {}",
response.errcode
))
)));
}
Ok(())
}
async fn request_access_token(
&self,
app_id: &str,
app_secret: &str,
) -> Result<String, WechatError> {
let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| {
WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}"))
})?;
let payload = self
.client
.post(url.as_str())
.json(&json!({
"grant_type": "client_credential",
"appid": app_id,
"secret": app_secret,
"force_refresh": false,
}))
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信 stable_token 请求失败");
WechatError::RequestFailed("微信 stable_token 请求失败".to_string())
})?
.json::<WechatStableAccessTokenResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信 stable_token 响应解析失败");
WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string())
})?;
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
return Err(WechatError::Upstream(format!(
"微信 stable_token 返回错误:{}",
payload
.errmsg
.unwrap_or_else(|| format!("errcode={errcode}"))
)));
}
payload
.access_token
.and_then(|value| non_empty_owned(value))
.ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string()))
}
}
impl WechatError {
pub fn kind(&self) -> WechatErrorKind {
match self {
Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig,
Self::RequestFailed(_) => WechatErrorKind::RequestFailed,
Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed,
Self::Upstream(_) => WechatErrorKind::Upstream,
}
}
}
impl fmt::Display for WechatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message)
| Self::RequestFailed(message)
| Self::DeserializeFailed(message)
| Self::Upstream(message) => f.write_str(message),
}
}
}
impl Error for WechatError {}
fn non_empty(value: &str) -> Option<&str> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn non_empty_owned(value: String) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}

View File

@@ -169,6 +169,8 @@ pub struct BarkBattleImageAssetGenerateRequest {
pub slot: BarkBattleAssetSlot,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing_purpose: Option<String>,
pub config: BarkBattleConfigEditorPayload,
}
@@ -823,6 +825,7 @@ mod tests {
let request = BarkBattleImageAssetGenerateRequest {
slot: BarkBattleAssetSlot::OpponentCharacter,
draft_id: Some("bark-battle-draft-1".to_string()),
billing_purpose: None,
config: BarkBattleConfigEditorPayload {
title: "汪汪冠军杯".to_string(),
description: Some(String::new()),