收口微信领域能力

将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。

将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。

拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。

修正微信相关测试的用户 ID 夹具并同步后端架构文档。
This commit is contained in:
kdletters
2026-06-08 21:05:37 +08:00
parent 11c5e3edf4
commit 088470a315
34 changed files with 925 additions and 837 deletions

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

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

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,

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,7 +44,7 @@ use crate::{
},
request_context::RequestContext,
state::AppState,
wechat_subscribe_message::{
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
@@ -295,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(
@@ -323,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,10 +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_subscribe_message;
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,7 +84,7 @@ use crate::{
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation,
},
wechat_subscribe_message::{
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},

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,4 +1,4 @@
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};

View File

@@ -1,4 +1,4 @@
use std::{
use std::{
collections::{BTreeMap, HashSet},
sync::{Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH},
@@ -105,7 +105,7 @@ use crate::{
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext,
state::{AppState, PuzzleApiState},
wechat_subscribe_message::{
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},

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,7 +81,7 @@ use crate::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
},
state::AppState,
wechat_subscribe_message::{
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
@@ -1119,12 +1119,15 @@ async fn compile_square_hole_draft_for_session(
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
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 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 {

View File

@@ -27,7 +27,7 @@ use platform_auth::{
};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig};
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
@@ -39,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,
};
@@ -388,8 +388,8 @@ impl AppState {
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
let wechat_provider = build_wechat_provider(&config);
let wechat_client = build_wechat_client(&config);
let wechat_pay_client =
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
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 真相源。

View File

@@ -35,7 +35,7 @@ use crate::{
prompt::visual_novel as vn_prompt,
request_context::RequestContext,
state::AppState,
wechat_subscribe_message::{
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
@@ -1748,12 +1748,15 @@ async fn compile_visual_novel_session_inner(
);
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 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 compile_result = state
.spacetime_client()
@@ -1770,9 +1773,7 @@ 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(

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,

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
use std::{
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
};
@@ -43,7 +43,7 @@ use crate::{
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
wechat_subscribe_message::{
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},