Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/project-overview.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	scripts/dev.test.ts
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/wooden_fish.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/rpgEntryWorldPresentation.ts
#	src/services/miniGameDraftGenerationProgress.test.ts
#	src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
2026-06-04 11:24:14 +08:00
451 changed files with 18452 additions and 5266 deletions

View File

@@ -5,11 +5,14 @@ version.workspace = true
license.workspace = true
[dependencies]
aes = { workspace = true }
async-stream = { workspace = true }
axum = { workspace = true, features = ["ws"] }
base64 = { workspace = true }
cbc = { workspace = true }
bytes = { workspace = true }
dotenvy = { workspace = true }
hex = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
@@ -41,9 +44,11 @@ platform-image = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
hmac = { workspace = true }
ring = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
shared-contracts = { workspace = true, features = ["oss-contracts"] }
shared-kernel = { workspace = true }
@@ -67,7 +72,6 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_
[dev-dependencies]
base64 = { workspace = true }
hmac = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
tower = { workspace = true, features = ["util"] }

View File

@@ -25,8 +25,11 @@ use shared_contracts::admin::{
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
AdminWorkVisibilityListResponse,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
};
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -197,6 +200,7 @@ pub async fn admin_list_database_table_rows(
Ok(json_success_body(Some(&request_context), response))
}
/// 读取后台创作入口配置,包含模板入口和底部加号入口页公告。
pub async fn admin_get_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -209,6 +213,7 @@ pub async fn admin_get_creation_entry_config(
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
@@ -218,6 +223,7 @@ pub async fn admin_get_creation_entry_config(
))
}
/// 保存单个创作模板入口配置,并返回最新公告与入口快照。
pub async fn admin_upsert_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -232,6 +238,38 @@ pub async fn admin_upsert_creation_entry_config(
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
},
))
}
/// 保存底部加号创作入口页的多公告表单序列化配置。
pub async fn admin_upsert_creation_entry_event_banners_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertCreationEntryEventBannersRequest>,
) -> Result<Json<Value>, AppError> {
let normalized_json =
module_runtime::normalize_creation_entry_event_banners_json(&payload.event_banners_json)
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
let config = state
.upsert_creation_entry_event_banners_config(
module_runtime::CreationEntryEventBannersAdminUpsertInput {
event_banners_json: normalized_json,
},
)
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
@@ -291,6 +329,7 @@ fn map_admin_creation_entry_type_config(
category_label: entry.category_label,
category_sort_order: entry.category_sort_order,
updated_at_micros: entry.updated_at_micros,
unified_creation_spec: entry.unified_creation_spec,
}
}
@@ -305,6 +344,22 @@ fn validate_admin_creation_entry_config(
if title.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口标题不能为空"));
}
let unified_creation_spec = match payload.unified_creation_spec {
Some(spec) => {
validate_unified_creation_spec_for_play(&id, &spec).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
})?;
Some(spec)
}
None => None,
};
let unified_creation_spec_json = unified_creation_spec
.as_ref()
.map(|spec| {
encode_unified_creation_spec_response(spec)
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))
})
.transpose()?;
Ok(module_runtime::CreationEntryTypeAdminUpsertInput {
id,
title,
@@ -317,6 +372,7 @@ fn validate_admin_creation_entry_config(
category_id: payload.category_id.trim().to_string(),
category_label: payload.category_label.trim().to_string(),
category_sort_order: payload.category_sort_order,
unified_creation_spec_json,
})
}
@@ -1481,11 +1537,7 @@ mod tests {
};
use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
AdminUpsertCreationEntryTypeConfigRequest,
};
use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
#[test]
fn normalize_debug_path_rejects_absolute_url() {

View File

@@ -41,7 +41,10 @@ use crate::{
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
submit_visual_novel_message, update_visual_novel_work,
},
wechat_pay::handle_wechat_pay_notify,
wechat_pay::{
handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify,
handle_wechat_virtual_payment_notify,
},
};
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
@@ -71,6 +74,11 @@ pub fn build_router(state: AppState) -> Router {
"/api/profile/recharge/wechat/notify",
post(handle_wechat_pay_notify),
)
.route(
"/api/profile/recharge/wechat/virtual-notify",
get(handle_wechat_virtual_payment_message_push_verify)
.post(handle_wechat_virtual_payment_notify),
)
.route(
"/api/runtime/sessions/{runtime_session_id}/inventory",
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
@@ -511,6 +519,40 @@ mod tests {
.to_string()
}
/// 中文注释:后台路由测试通过真实登录流程取 token避免绕过鉴权中间件。
async fn read_admin_access_token(app: Router) -> String {
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "root",
"password": "secret123"
})
.to_string(),
))
.expect("admin login request should build"),
)
.await
.expect("admin login request should succeed");
let body = response
.into_body()
.collect()
.await
.expect("admin login body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("admin login payload should be json");
payload["token"]
.as_str()
.expect("admin token should exist")
.to_string()
}
async fn password_login_request(
app: Router,
phone_number: &str,
@@ -699,7 +741,8 @@ mod tests {
let response = app
.oneshot(
Request::builder()
.uri("/api/runtime/puzzle/works")
.method("POST")
.uri("/api/runtime/puzzle/agent/sessions")
.body(Body::empty())
.expect("request should build"),
)
@@ -715,6 +758,31 @@ mod tests {
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
}
#[tokio::test]
async fn disabled_creation_entry_does_not_block_published_runtime_routes() {
let state = AppState::new(AppConfig::default()).expect("state should build");
state.set_test_creation_entry_route_enabled("puzzle", false);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/puzzle/runs")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = read_json_response(response).await;
assert_ne!(
body["error"]["details"]["reason"],
"creation_entry_disabled"
);
}
#[tokio::test]
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -748,7 +816,7 @@ mod tests {
}
#[tokio::test]
async fn disabled_rpg_route_returns_service_unavailable() {
async fn disabled_rpg_creation_route_returns_service_unavailable() {
let state = AppState::new(AppConfig::default()).expect("state should build");
state.set_test_creation_entry_route_enabled("rpg", false);
let app = build_router(state);
@@ -3973,6 +4041,91 @@ mod tests {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
/// 中文注释:验证入口公告表单提交的 HTML 会保存进独立公告配置。
#[tokio::test]
async fn admin_creation_entry_banners_route_saves_html_form_payload() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let admin_token = read_admin_access_token(app.clone()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/creation-entry/config/banners")
.header("authorization", format!("Bearer {admin_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"eventBannersJson": serde_json::json!([
{
"title": "后台表单公告",
"htmlCode": "<section>入口公告 HTML</section>"
}
]).to_string()
})
.to_string(),
))
.expect("banners request should build"),
)
.await
.expect("banners request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("banners body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("banners payload should be json");
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");
assert_eq!(
payload["eventBanners"][0]["htmlCode"],
"<section>入口公告 HTML</section>"
);
}
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
#[tokio::test]
async fn admin_creation_entry_banners_route_rejects_script_html() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let admin_token = read_admin_access_token(app.clone()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/creation-entry/config/banners")
.header("authorization", format!("Bearer {admin_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"eventBannersJson": serde_json::json!([
{
"title": "危险公告",
"htmlCode": "<script>alert(1)</script>"
}
]).to_string()
})
.to_string(),
))
.expect("banners request should build"),
)
.await
.expect("banners request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
let mut config = AppConfig::default();

View File

@@ -94,6 +94,12 @@ pub struct AppConfig {
pub wechat_pay_api_v3_key: Option<String>,
pub wechat_pay_notify_url: Option<String>,
pub wechat_pay_jsapi_endpoint: String,
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
pub wechat_mini_program_message_token: Option<String>,
pub wechat_mini_program_message_encoding_aes_key: Option<String>,
pub wechat_mini_program_virtual_payment_env: u8,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
@@ -237,6 +243,12 @@ impl Default for AppConfig {
wechat_pay_notify_url: None,
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
.to_string(),
wechat_mini_program_virtual_payment_offer_id: None,
wechat_mini_program_virtual_payment_app_key: None,
wechat_mini_program_virtual_payment_sandbox_app_key: None,
wechat_mini_program_message_token: None,
wechat_mini_program_message_encoding_aes_key: None,
wechat_mini_program_virtual_payment_env: 0,
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
@@ -584,6 +596,21 @@ impl AppConfig {
{
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
}
config.wechat_mini_program_virtual_payment_offer_id =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"]);
config.wechat_mini_program_virtual_payment_app_key =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]);
config.wechat_mini_program_virtual_payment_sandbox_app_key =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]);
config.wechat_mini_program_message_token =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]);
config.wechat_mini_program_message_encoding_aes_key =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]);
if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
&& env <= 1
{
config.wechat_mini_program_virtual_payment_env = env;
}
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
@@ -1373,6 +1400,12 @@ mod tests {
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
std::env::set_var("WECHAT_PAY_ENABLED", "true");
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
@@ -1388,6 +1421,18 @@ mod tests {
"WECHAT_PAY_NOTIFY_URL",
"https://api.example.com/api/profile/recharge/wechat/notify",
);
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID", "offer-001");
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY", "app-key-001");
std::env::set_var(
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY",
"sandbox-app-key-001",
);
std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001");
std::env::set_var(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
);
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
}
let config = AppConfig::from_env();
@@ -1410,6 +1455,35 @@ mod tests {
config.wechat_pay_platform_serial_no.as_deref(),
Some("platform-serial-001")
);
assert_eq!(
config
.wechat_mini_program_virtual_payment_offer_id
.as_deref(),
Some("offer-001")
);
assert_eq!(
config
.wechat_mini_program_virtual_payment_app_key
.as_deref(),
Some("app-key-001")
);
assert_eq!(
config.wechat_mini_program_message_token.as_deref(),
Some("message-token-001")
);
assert_eq!(
config
.wechat_mini_program_message_encoding_aes_key
.as_deref(),
Some("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG")
);
assert_eq!(
config
.wechat_mini_program_virtual_payment_sandbox_app_key
.as_deref(),
Some("sandbox-app-key-001")
);
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
unsafe {
std::env::remove_var("WECHAT_PAY_ENABLED");
@@ -1421,6 +1495,12 @@ mod tests {
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
}
}

View File

@@ -34,7 +34,7 @@ pub async fn get_creation_entry_config_handler(
Ok(json_success_body(Some(&request_context), config))
}
/// 中文注释api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则
/// 中文注释api-server 路由熔断只拦新建创作入口,不限制已有作品读取、发布作品游玩或公开广场浏览
pub async fn require_creation_entry_route_enabled(
State(state): State<AppState>,
request: Request<Body>,
@@ -72,60 +72,59 @@ pub async fn require_creation_entry_route_enabled(
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
let normalized = path.trim_end_matches('/');
if normalized.starts_with("/api/runtime/puzzle-clear") {
return Some("puzzle-clear");
}
if normalized.starts_with("/api/creation/puzzle-clear") {
return Some("puzzle-clear");
}
if normalized.starts_with("/api/runtime/puzzle") {
if normalized == "/api/runtime/puzzle/agent/sessions"
|| normalized == "/api/runtime/puzzle/onboarding/generate"
{
return Some("puzzle");
}
if normalized.starts_with("/api/runtime/match3d") {
return Some("match3d");
if normalized.starts_with("/api/runtime/puzzle/gallery/")
&& normalized.ends_with("/remix")
{
return Some("puzzle");
}
if normalized.starts_with("/api/runtime/bark-battle") {
return Some("bark-battle");
}
if normalized.starts_with("/api/creation/bark-battle") {
return Some("bark-battle");
}
if normalized.starts_with("/api/runtime/wooden-fish") {
return Some("wooden-fish");
}
if normalized.starts_with("/api/creation/wooden-fish") {
return Some("wooden-fish");
}
if normalized.starts_with("/api/runtime/square-hole") {
return Some("square-hole");
}
if normalized.starts_with("/api/runtime/jump-hop") {
return Some("jump-hop");
}
if normalized.starts_with("/api/creation/jump-hop") {
return Some("jump-hop");
}
if normalized.starts_with("/api/runtime/big-fish") {
if normalized == "/api/runtime/big-fish/agent/sessions" {
return Some("big-fish");
}
if normalized.starts_with("/api/runtime/custom-world")
|| normalized.starts_with("/api/runtime/custom-world-library")
|| normalized.starts_with("/api/runtime/custom-world-gallery")
|| normalized.starts_with("/api/runtime/chat")
|| normalized.starts_with("/api/story")
if normalized.starts_with("/api/runtime/big-fish/gallery/")
&& normalized.ends_with("/remix")
{
return Some("big-fish");
}
if normalized == "/api/runtime/custom-world/agent/sessions"
|| normalized == "/api/runtime/custom-world/profile"
{
return Some("rpg");
}
if normalized.starts_with("/api/runtime/visual-novel") {
if normalized.starts_with("/api/runtime/custom-world-gallery/")
&& normalized.ends_with("/remix")
{
return Some("rpg");
}
if normalized == "/api/creation/match3d/sessions" {
return Some("match3d");
}
if normalized == "/api/creation/square-hole/sessions" {
return Some("square-hole");
}
if normalized == "/api/creation/bark-battle/drafts" {
return Some("bark-battle");
}
if normalized == "/api/creation/wooden-fish/sessions" {
return Some("wooden-fish");
}
if normalized == "/api/creation/jump-hop/sessions" {
return Some("jump-hop");
}
if normalized == "/api/creation/puzzle-clear/sessions" {
return Some("puzzle-clear");
}
if normalized == "/api/creation/visual-novel/sessions" {
return Some("visual-novel");
}
if normalized.starts_with("/api/creation/visual-novel") {
return Some("visual-novel");
}
if normalized.starts_with("/api/creation/edutainment/baby-object-match") {
if normalized == "/api/creation/edutainment/baby-object-match/assets" {
return Some("baby-object-match");
}
if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") {
if normalized == "/api/creation/edutainment/baby-love-drawing/magic" {
return Some("baby-love-drawing");
}
None
@@ -158,7 +157,10 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
})
@@ -174,9 +176,9 @@ mod tests {
use super::*;
#[test]
fn resolves_runtime_paths_to_creation_type_ids() {
fn resolves_new_creation_paths_to_creation_type_ids() {
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/works"),
resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"),
Some("puzzle"),
);
assert_eq!(
@@ -184,56 +186,66 @@ mod tests {
Some("puzzle-clear"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
Some("puzzle-clear"),
resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"),
resolve_creation_entry_route_id("/api/creation/match3d/sessions"),
Some("match3d"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/square-hole/runs/run-1"),
resolve_creation_entry_route_id("/api/creation/square-hole/sessions"),
Some("square-hole"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/visual-novel/works"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"),
Some("big-fish"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
resolve_creation_entry_route_id(
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
Some("rpg"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
Some("rpg"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
Some("bark-battle"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"),
Some("bark-battle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
Some("wooden-fish"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),

View File

@@ -341,6 +341,8 @@ fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {
prompt_chars = failure.prompt_chars,
reference_image_count = failure.reference_image_count,
image_model = failure.image_model,
request_id = %failure.request_id.as_deref().unwrap_or_default(),
error_source = %failure.error_source.as_deref().unwrap_or_default(),
error = %failure.error_message,
"外部 API 调用失败"
);
@@ -394,6 +396,10 @@ mod tests {
)
.with_status_code(Some(429))
.with_retryable(true)
.with_error_source(Some(
"client error (SendRequest) -> connection closed before message completed"
.to_string(),
))
.with_latency_ms(Some(1234))
.with_prompt_chars(Some(88))
.with_reference_image_count(Some(2))
@@ -414,6 +420,10 @@ mod tests {
assert_eq!(metadata["promptChars"], 88);
assert_eq!(metadata["referenceImageCount"], 2);
assert_eq!(metadata["imageModel"], "gpt-image-2-all");
assert_eq!(
metadata["errorSource"],
"client error (SendRequest) -> connection closed before message completed"
);
assert!(matches!(metadata["occurredAt"], Value::String(_)));
}

View File

@@ -163,7 +163,14 @@ pub(super) async fn compile_match3d_draft_for_session(
.clone()
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
execute_billable_match3d_draft_generation(
let compile_session_id = session_id.clone();
let compile_owner_user_id = owner_user_id.clone();
let compile_profile_id = profile_id.clone();
let compile_initial_game_name = initial_game_name.clone();
let compile_requested_summary = requested_summary.clone();
let compile_initial_tags = initial_tags.clone();
let compile_requested_cover_image_src = requested_cover_image_src.clone();
let result = execute_billable_match3d_draft_generation(
state,
request_context,
owner_user_id.as_str(),
@@ -307,7 +314,108 @@ pub(super) async fn compile_match3d_draft_for_session(
Ok((next_session, generated_item_assets))
},
)
.await;
if let Err(response) = result.as_ref()
&& response.status().is_server_error()
{
let failure_message = match3d_response_failure_message(response);
persist_failed_match3d_draft_generation(
state,
request_context,
authenticated,
compile_session_id,
compile_owner_user_id,
compile_profile_id,
compile_initial_game_name,
compile_requested_summary,
compile_initial_tags,
compile_requested_cover_image_src,
failure_message,
)
.await;
}
result
}
#[allow(clippy::too_many_arguments)]
async fn persist_failed_match3d_draft_generation(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
owner_user_id: String,
profile_id: String,
game_name: String,
summary: Option<String>,
tags: Vec<String>,
cover_image_src: Option<String>,
failure_message: String,
) {
let failure_assets_json = serialize_match3d_failed_generation_assets(failure_message.as_str());
if let Err(persist_error) = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
Some(game_name),
summary.or_else(|| Some(String::new())),
Some(serde_json::to_string(&tags).unwrap_or_default()),
cover_image_src,
None,
failure_assets_json,
)
.await
{
tracing::error!(
provider = MATCH3D_AGENT_PROVIDER,
status = ?persist_error.status(),
"抓大鹅草稿生成失败后的状态回写失败"
);
}
}
fn serialize_match3d_failed_generation_assets(message: &str) -> Option<String> {
let background_asset = Match3DGeneratedBackgroundAsset {
prompt: String::new(),
status: "failed".to_string(),
error: Some(message.trim().to_string()),
..Default::default()
};
let assets = vec![Match3DGeneratedItemAssetJson {
item_id: "match3d-generation-failure".to_string(),
item_name: "生成失败".to_string(),
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
image_src: None,
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: Some(background_asset),
status: "failed".to_string(),
error: Some(message.trim().to_string()),
}];
serde_json::to_string(&assets).ok()
}
fn match3d_response_failure_message(response: &Response) -> String {
response
.extensions()
.get::<String>()
.cloned()
.unwrap_or_else(|| format!("抓大鹅草稿生成失败HTTP {}", response.status()))
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。

View File

@@ -453,6 +453,32 @@ fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -
|| match3d_text_present(asset.container_image_object_key.as_ref())
}
fn match3d_asset_status_is_failure(status: &str) -> bool {
let normalized = status.trim().to_ascii_lowercase().replace(['-', ' '], "_");
matches!(
normalized.as_str(),
"failed" | "failure" | "error" | "partial_failed"
)
}
fn match3d_error_present(value: Option<&String>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn match3d_item_asset_has_failure(asset: &Match3DGeneratedItemAssetJson) -> bool {
match3d_asset_status_is_failure(asset.status.as_str())
|| match3d_error_present(asset.error.as_ref())
|| asset.background_asset.as_ref().is_some_and(|background| {
match3d_asset_status_is_failure(background.status.as_str())
|| match3d_error_present(background.error.as_ref())
})
}
fn match3d_background_asset_has_failure(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_asset_status_is_failure(asset.status.as_str())
|| match3d_error_present(asset.error.as_ref())
}
fn resolve_match3d_work_generation_status(
item: &Match3DWorkProfileRecord,
assets: &[Match3DGeneratedItemAssetJson],
@@ -462,6 +488,21 @@ fn resolve_match3d_work_generation_status(
return Some("ready".to_string());
}
let has_failure = assets.iter().any(match3d_item_asset_has_failure)
|| background_asset.is_some_and(match3d_background_asset_has_failure);
if has_failure {
let has_partial_result = assets.iter().any(match3d_item_asset_has_image)
|| background_asset.is_some_and(match3d_background_asset_has_image);
return Some(
if has_partial_result {
"partial_failed"
} else {
"failed"
}
.to_string(),
);
}
if assets.is_empty()
|| !assets.iter().any(match3d_item_asset_has_image)
|| !background_asset.is_some_and(match3d_background_asset_has_image)

View File

@@ -1842,3 +1842,45 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
assert_eq!(response.generation_status.as_deref(), Some("ready"));
}
#[test]
fn match3d_work_summary_marks_failed_generated_assets_failed() {
let assets = vec![Match3DGeneratedItemAsset {
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "水果厨房背景".to_string(),
status: "failed".to_string(),
error: Some("VectorEngine 请求失败".to_string()),
..Default::default()
}),
status: "failed".to_string(),
error: Some("VectorEngine 请求失败".to_string()),
..test_match3d_generated_item_asset(1, "草莓")
}];
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
work_id: "match3d-profile-1".to_string(),
profile_id: "match3d-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("match3d-session-1".to_string()),
author_display_name: "玩家".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
cover_asset_id: None,
reference_image_src: None,
clear_count: 3,
difficulty: 3,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
published_at: None,
publish_ready: false,
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
});
assert_eq!(
response.generation_status.as_deref(),
Some("partial_failed")
);
}

View File

@@ -1,11 +1,15 @@
use axum::{Router, middleware, routing::get};
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
admin::{
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
admin_login, admin_me, admin_overview, admin_update_work_visibility,
admin_upsert_creation_entry_config, require_admin_auth,
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
require_admin_auth,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
@@ -71,6 +75,12 @@ pub fn router(state: AppState) -> Router<AppState> {
require_admin_auth,
)),
)
.route(
"/admin/api/creation-entry/config/banners",
post(admin_upsert_creation_entry_event_banners_config).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/works/visibility",
get(admin_list_work_visibility)

View File

@@ -14,7 +14,8 @@ use crate::{
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
redeem_profile_reward_code, submit_profile_feedback,
redeem_profile_reward_code, stream_wechat_profile_recharge_order_events,
submit_profile_feedback,
},
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
state::AppState,
@@ -73,6 +74,12 @@ pub fn router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/profile/recharge/orders/{order_id}/wechat/events",
get(stream_wechat_profile_recharge_order_events).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/profile/feedback",
post(submit_profile_feedback)

View File

@@ -424,6 +424,7 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
details["referenceImageCount"] = json!(audit.reference_image_count);
details["imageModel"] = json!(audit.image_model);
details["rawExcerpt"] = json!(audit.raw_excerpt);
details["errorSource"] = json!(audit.error_source);
}
AppError::from_status(status).with_details(details)

View File

@@ -40,6 +40,15 @@ pub async fn password_entry(
state.password_entry_service().execute(input).await
}
.map_err(map_password_entry_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
if result.created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
@@ -48,8 +57,6 @@ pub async fn password_entry(
)
.await;
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
@@ -57,13 +64,6 @@ pub async fn password_entry(
AuthLoginMethod::Password,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -100,13 +100,6 @@ pub async fn reset_password(
&session_client,
module_auth::AuthLoginMethod::Password,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
module_auth::AuthLoginMethod::Password,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -114,6 +107,13 @@ pub async fn reset_password(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
module_auth::AuthLoginMethod::Password,
)
.await;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -151,6 +151,20 @@ pub async fn phone_login(
}
};
let created = result.created;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Phone,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
if created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
@@ -170,13 +184,6 @@ pub async fn phone_login(
} else {
None
};
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Phone,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
@@ -184,13 +191,6 @@ pub async fn phone_login(
AuthLoginMethod::Phone,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -2,8 +2,8 @@ use super::*;
pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: None,
work_description: None,
title: payload.work_title.as_deref(),
work_description: payload.work_description.as_deref(),
picture_description: payload
.picture_description
.as_deref()
@@ -32,8 +32,8 @@ pub(crate) async fn save_puzzle_form_payload_before_compile(
now: i64,
) -> Result<String, Response> {
let seed_text = build_puzzle_form_seed_text_from_parts(
None,
None,
payload.work_title.as_deref(),
payload.work_description.as_deref(),
payload
.picture_description
.as_deref()

View File

@@ -317,7 +317,16 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
);
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
let scene_generated = create_puzzle_vector_engine_image_generation(
let bundle_started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
"拼图关卡资产包生成开始"
);
let scene_started_at = Instant::now();
let scene_generated = match create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
PuzzleImageModel::GptImage2,
@@ -328,7 +337,34 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
Some(&puzzle_reference),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
.map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图生成完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡场景图生成失败"
);
return Err(error);
}
};
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
@@ -336,7 +372,8 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
}))
})?;
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
let scene_persist_future = persist_puzzle_level_asset_image(
let scene_persist_started_at = Instant::now();
let level_scene = persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
@@ -347,8 +384,18 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"level_scene",
"scene",
scene_image,
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_persist_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图持久化完成"
);
let spritesheet_future = generate_and_persist_puzzle_level_asset(
let ui_spritesheet = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
@@ -362,8 +409,9 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"puzzle_ui_spritesheet_image",
"ui_spritesheet",
"spritesheet",
);
let background_future = generate_and_persist_puzzle_level_asset(
)
.await?;
let level_background = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
@@ -377,14 +425,21 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"puzzle_level_background_image",
"level_background",
"background",
);
let (level_scene, ui_spritesheet, level_background) =
tokio::join!(scene_persist_future, spritesheet_future, background_future);
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
elapsed_ms = bundle_started_at.elapsed().as_millis() as u64,
"拼图关卡资产包生成完成"
);
Ok(GeneratedPuzzleLevelAssetBundle {
level_scene: level_scene?,
ui_spritesheet: ui_spritesheet?,
level_background: level_background?,
level_scene,
ui_spritesheet,
level_background,
})
}
@@ -403,7 +458,20 @@ async fn generate_and_persist_puzzle_level_asset(
slot: &str,
file_stem: &str,
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
let generated = create_puzzle_vector_engine_image_generation(
let started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
size,
prompt_chars = prompt.chars().count(),
reference_image_bytes = reference_image.bytes_len,
"拼图关卡资产生成请求开始"
);
let generated = match create_puzzle_vector_engine_image_generation(
http_client,
settings,
PuzzleImageModel::GptImage2,
@@ -414,7 +482,36 @@ async fn generate_and_persist_puzzle_level_asset(
Some(reference_image),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
.map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"拼图关卡资产生成请求完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡资产生成请求失败"
);
return Err(error);
}
};
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
@@ -427,7 +524,8 @@ async fn generate_and_persist_puzzle_level_asset(
image
};
persist_puzzle_level_asset_image(
let persist_started_at = Instant::now();
let persisted = persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
@@ -439,7 +537,19 @@ async fn generate_and_persist_puzzle_level_asset(
file_stem,
image,
)
.await
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
"拼图关卡资产持久化完成"
);
Ok(persisted)
}
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(

View File

@@ -725,8 +725,8 @@ pub async fn execute_puzzle_agent_action(
}
"save_puzzle_form_draft" => {
let seed_text = build_puzzle_form_seed_text_from_parts(
None,
None,
payload.work_title.as_deref(),
payload.work_description.as_deref(),
payload
.picture_description
.as_deref()

View File

@@ -384,6 +384,28 @@ fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn puzzle_form_seed_text_includes_work_metadata() {
let payload = CreatePuzzleAgentSessionRequest {
seed_text: Some("旧 seed 会被画面描述兜底覆盖。".to_string()),
work_title: Some("雨夜猫街".to_string()),
work_description: Some("123".to_string()),
picture_description: Some("一只猫在雨夜灯牌下回头。".to_string()),
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: None,
ai_redraw: Some(true),
};
let seed_text = build_puzzle_form_seed_text(&payload);
assert!(seed_text.contains("作品名称:雨夜猫街"));
assert!(seed_text.contains("作品描述123"));
assert!(seed_text.contains("画面描述:一只猫在雨夜灯牌下回头。"));
}
#[tokio::test]
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(

View File

@@ -56,13 +56,6 @@ pub async fn refresh_session(
Some(&rotated.session.issued_by_provider),
Some(&rotated.session.client_info),
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&rotated.user.id,
rotated.session.issued_by_provider.clone(),
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -70,6 +63,13 @@ pub async fn refresh_session(
AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&rotated.user.id,
rotated.session.issued_by_provider.clone(),
)
.await;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -2,12 +2,17 @@ use axum::{
Json,
extract::{Extension, Path, Query, State},
http::{HeaderMap, StatusCode},
response::Response,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use hmac::{Hmac, Mac};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord,
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
@@ -21,8 +26,9 @@ use module_runtime::{
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::Sha256;
use shared_contracts::runtime::{
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
@@ -59,10 +65,13 @@ use shared_contracts::runtime::{
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse,
WechatMiniProgramVirtualPayParamsResponse, WechatProfileRechargeOrderDoneEvent,
WechatProfileRechargeOrderErrorEvent,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use std::{convert::Infallible, time::Duration};
use time::OffsetDateTime;
use crate::{
@@ -78,6 +87,8 @@ use crate::{
},
};
type HmacSha256 = Hmac<Sha256>;
pub async fn get_profile_dashboard(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -231,7 +242,7 @@ pub async fn create_profile_recharge_order(
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(
Some(WechatMiniProgramPaymentParamsResponse::Ordinary(
state
.wechat_pay_client()
.create_mini_program_order(build_wechat_payment_request(
@@ -244,6 +255,15 @@ pub async fn create_profile_recharge_order(
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
))
} else if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(
build_wechat_virtual_pay_params(&state, &center, &order, &openid)
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
)
} else {
None
@@ -332,19 +352,19 @@ pub async fn confirm_wechat_profile_recharge_order(
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
build_wechat_profile_recharge_order_confirmation(center, order),
));
}
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
build_wechat_profile_recharge_order_confirmation(center, order),
));
}
if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
return Ok(json_success_body(
Some(&request_context),
build_wechat_profile_recharge_order_confirmation(center, order),
));
}
@@ -366,10 +386,7 @@ pub async fn confirm_wechat_profile_recharge_order(
if wechat_order.trade_state != "SUCCESS" {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
build_wechat_profile_recharge_order_confirmation(center, order),
));
}
@@ -391,13 +408,94 @@ pub async fn confirm_wechat_profile_recharge_order(
Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
build_wechat_profile_recharge_order_confirmation(center, order),
))
}
pub async fn stream_wechat_profile_recharge_order_events(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(order_id): Path<String>,
) -> Result<Response, Response> {
let user_id = authenticated.claims().user_id().to_string();
let (center, order) = load_user_wechat_profile_recharge_order(
&state,
&request_context,
user_id.clone(),
order_id.clone(),
)
.await?;
let stream_state = state.clone();
let stream_context = request_context.clone();
let stream = async_stream::stream! {
let initial_response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"order",
&initial_response,
));
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"done",
&WechatProfileRechargeOrderDoneEvent {
order_id: order.order_id.clone(),
status: build_profile_recharge_order_status(order.status),
},
));
return;
}
let mut updates = stream_state.subscribe_profile_recharge_order_updates();
let mut poll_interval = tokio::time::interval(Duration::from_millis(1200));
for _ in 0..25 {
tokio::select! {
maybe_order_id = updates.recv() => {
if !matches!(maybe_order_id, Ok(ref value) if value == &order_id) {
continue;
}
}
_ = poll_interval.tick() => {}
}
match load_user_wechat_profile_recharge_order(
&stream_state,
&stream_context,
user_id.clone(),
order_id.clone(),
).await {
Ok((center, order)) => {
let response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"order",
&response,
));
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"done",
&WechatProfileRechargeOrderDoneEvent {
order_id: order.order_id.clone(),
status: build_profile_recharge_order_status(order.status),
},
));
return;
}
}
Err(_) => {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"error",
&WechatProfileRechargeOrderErrorEvent {
message: "读取充值订单状态失败".to_string(),
},
));
return;
}
}
}
};
Ok(Sse::new(stream).into_response())
}
pub async fn submit_profile_feedback(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1014,6 +1112,81 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context))
}
async fn load_user_wechat_profile_recharge_order(
state: &AppState,
request_context: &RequestContext,
user_id: String,
order_id: String,
) -> Result<
(
RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord,
),
Response,
> {
let (center, order) = state
.spacetime_client()
.get_profile_recharge_order(order_id)
.await
.map_err(|error| {
runtime_profile_error_response(request_context, map_runtime_profile_client_error(error))
})?;
if order.user_id != user_id {
return Err(runtime_profile_error_response(
request_context,
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
));
}
if !is_wechat_recharge_payment_channel(&order.payment_channel) {
return Err(runtime_profile_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("该充值订单不是微信支付订单"),
));
}
Ok((center, order))
}
fn build_wechat_profile_recharge_order_confirmation(
center: RuntimeProfileRechargeCenterRecord,
order: RuntimeProfileRechargeOrderRecord,
) -> ConfirmWechatProfileRechargeOrderResponse {
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
}
}
fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus) -> String {
match status {
RuntimeProfileRechargeOrderStatus::Pending => "pending",
RuntimeProfileRechargeOrderStatus::Paid => "paid",
RuntimeProfileRechargeOrderStatus::Failed => "failed",
RuntimeProfileRechargeOrderStatus::Closed => "closed",
RuntimeProfileRechargeOrderStatus::Refunded => "refunded",
}
.to_string()
}
fn wechat_profile_recharge_sse_json_event<T>(event_name: &str, payload: &T) -> Event
where
T: Serialize,
{
Event::default()
.event(event_name)
.json_data(payload)
.unwrap_or_else(|_| {
Event::default()
.event("error")
.json_data(&WechatProfileRechargeOrderErrorEvent {
message: "充值订单状态事件序列化失败".to_string(),
})
.unwrap_or_else(|_| Event::default().event("error").data("{}"))
})
}
fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> {
raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
@@ -1059,6 +1232,9 @@ fn validate_recharge_device_for_payment_channel(
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
_ => false,
@@ -1106,6 +1282,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
matches!(
payment_channel,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
)
@@ -1148,6 +1325,159 @@ async fn resolve_wechat_identity_for_payment(
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
}
fn build_wechat_virtual_pay_params(
state: &AppState,
center: &RuntimeProfileRechargeCenterRecord,
order: &RuntimeProfileRechargeOrderRecord,
openid: &str,
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
let product = match order.kind {
RuntimeProfileRechargeProductKind::Points => center
.point_products
.iter()
.find(|item| item.product_id == order.product_id)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前充值商品不存在,请刷新后再试")
})?,
RuntimeProfileRechargeProductKind::Membership => center
.membership_products
.iter()
.find(|item| item.product_id == order.product_id)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前充值商品不存在,请刷新后再试")
})?,
};
let identity = state
.wechat_auth_service()
.get_identity_by_user_id(&order.user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信身份失败:{error}"))
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")
})?;
let session_key = identity.session_key.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前微信登录态缺少 session_key请重新登录后再试")
})?;
let offer_id = required_wechat_virtual_payment_config(
state
.config
.wechat_mini_program_virtual_payment_offer_id
.as_deref(),
"微信虚拟支付 OfferId 未配置",
)?;
let mode = match order.kind {
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
};
let buy_quantity = match product.kind {
RuntimeProfileRechargeProductKind::Points => product.points_amount,
RuntimeProfileRechargeProductKind::Membership => 1,
};
let mut sign_data = serde_json::json!({
"offerId": offer_id,
"buyQuantity": buy_quantity,
"env": state.config.wechat_mini_program_virtual_payment_env,
"currencyType": "CNY",
"outTradeNo": order.order_id,
"attach": serde_json::json!({
"userId": order.user_id,
"productId": order.product_id,
"paymentChannel": order.payment_channel,
"openId": openid,
}).to_string(),
});
if order.kind == RuntimeProfileRechargeProductKind::Membership {
sign_data["productId"] = json!(order.product_id);
sign_data["goodsPrice"] = json!(order.amount_cents);
}
let sign_data = sign_data.to_string();
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
let signature = calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?;
Ok(WechatMiniProgramVirtualPayParamsResponse {
mode: mode.to_string(),
sign_data,
pay_sig,
signature,
})
}
fn calc_wechat_virtual_payment_signature(
state: &AppState,
sign_data: &str,
use_sandbox_key: bool,
) -> Result<String, AppError> {
let env = state.config.wechat_mini_program_virtual_payment_env;
let app_key = if use_sandbox_key || env == 1 {
required_wechat_virtual_payment_config(
state
.config
.wechat_mini_program_virtual_payment_sandbox_app_key
.as_deref(),
"微信虚拟支付沙箱 AppKey 未配置",
)?
} else {
required_wechat_virtual_payment_config(
state
.config
.wechat_mini_program_virtual_payment_app_key
.as_deref(),
"微信虚拟支付 AppKey 未配置",
)?
};
calc_wechat_virtual_payment_pay_signature_with_key(app_key, sign_data)
}
fn required_wechat_virtual_payment_config<'a>(
value: Option<&'a str>,
message: &str,
) -> Result<&'a str, AppError> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message))
}
fn calc_wechat_virtual_payment_pay_signature_with_key(
key: &str,
sign_data: &str,
) -> Result<String, AppError> {
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(|_| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("微信虚拟支付签名密钥初始化失败")
})?;
mac.update(format!("requestVirtualPayment&{sign_data}").as_bytes());
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
}
fn calc_wechat_virtual_payment_user_signature_with_key(
session_key: &str,
sign_data: &str,
) -> Result<String, AppError> {
let mut mac = HmacSha256::new_from_slice(session_key.as_bytes()).map_err(|_| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("微信虚拟支付用户态签名密钥初始化失败")
})?;
mac.update(sign_data.as_bytes());
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
}
fn to_lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
output.push(char::from(HEX[(byte >> 4) as usize]));
output.push(char::from(HEX[(byte & 0x0f) as usize]));
}
output
}
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
order
.success_time
@@ -1619,9 +1949,21 @@ fn build_profile_redeem_code_admin_response(
#[cfg(test)]
mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType;
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus,
RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord,
RuntimeProfileWalletLedgerSourceType,
};
use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata};
use super::{
build_wechat_virtual_pay_params, calc_wechat_virtual_payment_pay_signature_with_key,
calc_wechat_virtual_payment_user_signature_with_key,
format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata,
};
use axum::{
body::Body,
@@ -2082,6 +2424,392 @@ mod tests {
);
}
#[tokio::test]
async fn wechat_virtual_pay_params_use_goods_mode_for_membership_products() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-00000001".to_string(),
provider_union_id: Some("union-user-00000001".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-1".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id;
let order = RuntimeProfileRechargeOrderRecord {
order_id: "memberorder01".to_string(),
user_id: user_id.clone(),
product_id: "member_month".to_string(),
product_title: "月卡".to_string(),
kind: RuntimeProfileRechargeProductKind::Membership,
amount_cents: 2800,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-26T10:00:00Z".to_string(),
created_at_micros: 1_779_756_000_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![],
membership_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "member_month".to_string(),
title: "月卡".to_string(),
price_cents: 2800,
kind: RuntimeProfileRechargeProductKind::Membership,
points_amount: 0,
bonus_points: 0,
duration_days: 30,
badge_label: String::new(),
description: "30天会员".to_string(),
tier: RuntimeProfileMembershipTier::Month,
}],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
};
let params =
build_wechat_virtual_pay_params(&state, &center, &order, "openid-user-00000001")
.expect("membership virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
let attach: Value = serde_json::from_str(
sign_data["attach"]
.as_str()
.expect("attach should be string json"),
)
.expect("attach should decode");
assert_eq!(params.mode, "short_series_goods");
assert_eq!(sign_data["buyQuantity"], 1);
assert_eq!(sign_data["offerId"], "offer-1");
assert_eq!(sign_data["productId"], "member_month");
assert_eq!(sign_data["goodsPrice"], 2800);
assert_eq!(sign_data["outTradeNo"], "memberorder01");
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
assert!(!params.pay_sig.is_empty());
assert!(!params.signature.is_empty());
}
#[tokio::test]
async fn wechat_virtual_pay_params_use_coin_quantity_for_points_products() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-points-60".to_string(),
provider_union_id: Some("union-user-points-60".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-points-60".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id.clone();
let order = RuntimeProfileRechargeOrderRecord {
order_id: "pointsorder60".to_string(),
user_id: user_id.clone(),
product_id: "points_60".to_string(),
product_title: "60泥点".to_string(),
kind: RuntimeProfileRechargeProductKind::Points,
amount_cents: 600,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-30T10:00:00Z".to_string(),
created_at_micros: 1_780_000_000_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "points_60".to_string(),
title: "60泥点".to_string(),
price_cents: 600,
kind: RuntimeProfileRechargeProductKind::Points,
points_amount: 60,
bonus_points: 60,
duration_days: 0,
badge_label: "首充双倍".to_string(),
description: "60+60泥点".to_string(),
tier: RuntimeProfileMembershipTier::Normal,
}],
membership_products: vec![],
benefits: vec![],
latest_order: None,
has_points_recharged: true,
};
let params =
build_wechat_virtual_pay_params(&state, &center, &order, "openid-user-points-60")
.expect("points virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
let attach: Value = serde_json::from_str(
sign_data["attach"]
.as_str()
.expect("attach should be string json"),
)
.expect("attach should decode");
assert_eq!(params.mode, "short_series_coin");
assert_eq!(sign_data["buyQuantity"], 60);
assert_eq!(sign_data["offerId"], "offer-1");
assert_eq!(sign_data["outTradeNo"], "pointsorder60");
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
assert!(!params.pay_sig.is_empty());
assert!(!params.signature.is_empty());
}
#[tokio::test]
async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-item01".to_string(),
provider_union_id: Some("union-user-item01".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-item01".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id.clone();
let order = RuntimeProfileRechargeOrderRecord {
order_id: "item01order01".to_string(),
user_id: user_id.clone(),
product_id: "item01".to_string(),
product_title: "测试道具".to_string(),
kind: RuntimeProfileRechargeProductKind::Membership,
amount_cents: 100,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-27T10:00:00Z".to_string(),
created_at_micros: 1_779_842_400_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![],
membership_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "item01".to_string(),
title: "测试道具".to_string(),
price_cents: 100,
kind: RuntimeProfileRechargeProductKind::Membership,
points_amount: 0,
bonus_points: 0,
duration_days: 30,
badge_label: String::new(),
description: "30天会员".to_string(),
tier: RuntimeProfileMembershipTier::Month,
}],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
};
let params = build_wechat_virtual_pay_params(&state, &center, &order, "openid-user-item01")
.expect("custom membership virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
assert_eq!(params.mode, "short_series_goods");
assert_eq!(sign_data["productId"], "item01");
assert_eq!(sign_data["goodsPrice"], 100);
assert_eq!(sign_data["outTradeNo"], "item01order01");
}
#[test]
fn wechat_virtual_payment_signatures_match_official_examples() {
let post_body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#;
let pay_sig = calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body)
.expect("pay signature should build");
let signature = calc_wechat_virtual_payment_user_signature_with_key(
"9hAb/NEYUlkaMBEsmFgzig==",
post_body,
)
.expect("user signature should build");
assert_eq!(
pay_sig,
"a1ab2651b927b6a766152cf864033417b85c1448fc3c6e1bedbbd7f49416e92f"
);
assert_eq!(
signature,
"089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7"
);
}
#[tokio::test]
async fn wechat_virtual_payment_sandbox_requires_sandbox_app_key() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_sandbox_app_key: None,
wechat_mini_program_virtual_payment_env: 1,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-sandbox-1".to_string(),
provider_union_id: Some("union-sandbox-1".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-sandbox-1".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id.clone();
let order = RuntimeProfileRechargeOrderRecord {
order_id: "sandboxorder01".to_string(),
user_id: user_id.clone(),
product_id: "points_60".to_string(),
product_title: "60泥点".to_string(),
kind: RuntimeProfileRechargeProductKind::Points,
amount_cents: 600,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-30T10:00:00Z".to_string(),
created_at_micros: 1_780_000_000_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "points_60".to_string(),
title: "60泥点".to_string(),
price_cents: 600,
kind: RuntimeProfileRechargeProductKind::Points,
points_amount: 60,
bonus_points: 60,
duration_days: 0,
badge_label: "首充双倍".to_string(),
description: "60+60泥点".to_string(),
tier: RuntimeProfileMembershipTier::Normal,
}],
membership_products: vec![],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
};
let error = build_wechat_virtual_pay_params(&state, &center, &order, "openid-sandbox-1")
.expect_err("sandbox pay params should reject missing sandbox app key");
assert!(
error.to_string().contains("沙箱 AppKey 未配置"),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn profile_feedback_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -27,7 +27,7 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
use tokio::sync::Semaphore;
use tokio::sync::{Semaphore, broadcast};
use tracing::{info, warn};
use crate::config::AppConfig;
@@ -257,6 +257,7 @@ pub struct AppStateInner {
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
profile_recharge_order_updates: broadcast::Sender<String>,
#[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
@@ -394,6 +395,7 @@ impl AppState {
let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
Ok(Self(Arc::new(AppStateInner {
config,
@@ -423,6 +425,7 @@ impl AppState {
creative_agent_gpt5_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
profile_recharge_order_updates,
#[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
})))
@@ -465,6 +468,45 @@ impl AppState {
}
}
/// 通过 SpacetimeDB 保存创作入口页多公告配置,并同步测试缓存。
pub async fn upsert_creation_entry_event_banners_config(
&self,
input: module_runtime::CreationEntryEventBannersAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
#[cfg(test)]
let test_event_banners_json = input.event_banners_json.clone();
match self
.spacetime_client
.upsert_creation_entry_event_banners_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => {
let mut config = self.read_test_creation_entry_config();
if let Ok(banners) = module_runtime::decode_creation_entry_event_banner_snapshots(
test_event_banners_json.as_str(),
) {
config.event_banners = banners
.into_iter()
.map(module_runtime::build_creation_entry_event_banner_response)
.collect();
if let Some(first_banner) = config.event_banners.first().cloned() {
config.event_banner = first_banner;
}
self.cache_test_creation_entry_config(config.clone());
}
Ok(config)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
@@ -555,6 +597,10 @@ impl AppState {
.to_string(),
category_sort_order: 0,
updated_at_micros: 0,
unified_creation_spec:
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(
creation_type_id,
),
},
);
}
@@ -584,7 +630,7 @@ impl AppState {
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
// 当前进程内 auth_store 是认证请求的即时工作集SpacetimeDB 正式认证表用于跨进程恢复。
// 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败
// 认证变更必须在返回客户端前写入 SpacetimeDB避免只在本进程内成功、重启后丢失账号或会话
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
@@ -593,9 +639,9 @@ impl AppState {
{
warn!(
error = %error,
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续"
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程中止"
);
return Ok(());
return Err(error);
}
#[cfg(not(test))]
Ok(())
@@ -706,6 +752,16 @@ impl AppState {
self.creative_agent_executor.clone()
}
pub fn subscribe_profile_recharge_order_updates(
&self,
) -> tokio::sync::broadcast::Receiver<String> {
self.profile_recharge_order_updates.subscribe()
}
pub fn publish_profile_recharge_order_update(&self, order_id: impl Into<String>) {
let _ = self.profile_recharge_order_updates.send(order_id.into());
}
pub fn get_creative_agent_session(
&self,
session_id: &str,

View File

@@ -145,13 +145,6 @@ pub async fn handle_wechat_callback(
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -159,6 +152,13 @@ pub async fn handle_wechat_callback(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
let mut response = Redirect::to(&build_auth_result_redirect_url(
&redirect_path,
&[
@@ -241,6 +241,20 @@ pub async fn bind_wechat_phone(
.await
.map_err(map_wechat_bind_phone_error)?
};
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
if result.activated_new_user {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
@@ -249,13 +263,6 @@ pub async fn bind_wechat_phone(
)
.await;
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
@@ -263,13 +270,6 @@ pub async fn bind_wechat_phone(
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut response_headers = HeaderMap::new();
attach_set_cookie_header(
@@ -385,6 +385,7 @@ fn map_wechat_profile_to_domain(
provider_union_id: profile.provider_union_id,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
session_key: profile.session_key,
}
}

View File

@@ -1,11 +1,18 @@
use std::{fs, path::Path, sync::Arc};
use aes::Aes256;
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
Json,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use base64::{
Engine as _, alphabet,
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use bytes::Bytes;
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
use ring::{
aead,
rand::{SecureRandom, SystemRandom},
@@ -13,11 +20,13 @@ use ring::{
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_contracts::runtime::{
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
};
use shared_kernel::offset_datetime_to_unix_micros;
use std::convert::TryInto;
use time::OffsetDateTime;
use tracing::{info, warn};
use url::Url;
@@ -43,6 +52,14 @@ const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45;
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
const WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES: usize = 43;
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES: usize = 32;
const WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES: usize = 16;
const WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES: usize = 4;
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
#[derive(Clone, Debug)]
pub enum WechatPayClient {
@@ -92,6 +109,22 @@ pub struct WechatPayNotifyOrder {
pub success_time: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct WechatVirtualPaymentNotifyOrder {
out_trade_no: String,
transaction_id: Option<String>,
paid_at_micros: Option<i64>,
event: String,
}
#[derive(Serialize)]
pub struct WechatVirtualPaymentNotifyResponse {
#[serde(rename = "ErrCode")]
err_code: i32,
#[serde(rename = "ErrMsg")]
err_msg: String,
}
#[derive(Debug)]
pub enum WechatPayError {
Disabled,
@@ -220,6 +253,45 @@ struct WechatPayQueryOrderResponse {
success_time: Option<String>,
}
#[derive(Deserialize)]
struct WechatVirtualPaymentNotifyBody {
#[serde(rename = "Event", alias = "event")]
event: String,
#[serde(rename = "OutTradeNo", alias = "out_trade_no", default)]
out_trade_no: Option<String>,
#[serde(rename = "MchOrderId", alias = "mch_order_id", default)]
mch_order_id: Option<String>,
#[serde(rename = "WeChatPayInfo", alias = "wechat_pay_info", default)]
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
}
#[derive(Deserialize)]
struct WechatVirtualPaymentNotifyPayInfo {
#[serde(rename = "MchOrderNo", alias = "mch_order_no", default)]
mch_order_no: Option<String>,
#[serde(rename = "TransactionId", alias = "transaction_id", default)]
transaction_id: Option<String>,
#[serde(rename = "PaidTime", alias = "paid_time", default)]
paid_time: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct WechatMiniProgramMessagePushQuery {
signature: Option<String>,
timestamp: Option<String>,
nonce: Option<String>,
echostr: Option<String>,
msg_signature: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatMiniProgramEncryptedMessage {
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
to_user_name: Option<String>,
#[serde(rename = "Encrypt", alias = "encrypt")]
encrypt: String,
}
impl WechatPayClient {
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
if !config.wechat_pay_enabled {
@@ -806,6 +878,154 @@ pub async fn handle_wechat_pay_notify(
Ok(StatusCode::NO_CONTENT)
}
pub async fn handle_wechat_virtual_payment_message_push_verify(
State(state): State<AppState>,
Query(query): Query<WechatMiniProgramMessagePushQuery>,
) -> Response {
let token = match read_wechat_message_push_config(
state.config.wechat_mini_program_message_token.as_deref(),
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
) {
Ok(token) => token,
Err(error) => return build_wechat_message_push_verify_error_response(error),
};
let aes_key = match read_wechat_message_push_config(
state
.config
.wechat_mini_program_message_encoding_aes_key
.as_deref(),
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
) {
Ok(value) => value,
Err(error) => return build_wechat_message_push_verify_error_response(error),
};
match resolve_wechat_message_push_verify_response(
token,
aes_key,
state
.config
.wechat_mini_program_app_id
.as_deref()
.or(state.config.wechat_app_id.as_deref()),
&query,
) {
Ok(plaintext) => (StatusCode::OK, plaintext).into_response(),
Err(error) => build_wechat_message_push_verify_error_response(error),
}
}
pub async fn handle_wechat_virtual_payment_notify(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<WechatMiniProgramMessagePushQuery>,
body: Bytes,
) -> Response {
let response_format = detect_virtual_payment_notify_response_format(&headers, &body);
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
Ok(payload) => payload,
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
};
let token = match read_wechat_message_push_config(
state.config.wechat_mini_program_message_token.as_deref(),
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
) {
Ok(token) => token,
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
};
let aes_key = match read_wechat_message_push_config(
state
.config
.wechat_mini_program_message_encoding_aes_key
.as_deref(),
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
) {
Ok(value) => value,
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
};
let signature = query
.msg_signature
.as_deref()
.or(query.signature.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() {
return build_virtual_payment_notify_error_response(
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
response_format,
);
}
if !verify_wechat_message_push_signature(
token,
timestamp,
nonce,
encrypted_payload.encrypt.as_str(),
signature,
) {
return build_virtual_payment_notify_error_response(
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
response_format,
);
}
let notify_body = match decrypt_wechat_message_push_ciphertext(
aes_key,
encrypted_payload.encrypt.as_str(),
state
.config
.wechat_mini_program_app_id
.as_deref()
.or(state.config.wechat_app_id.as_deref()),
) {
Ok(body) => body,
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
};
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
Ok(notify) => notify,
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
};
if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" {
info!(
event = notify.event.as_str(),
order_id = notify.out_trade_no.as_str(),
"收到非订单入账虚拟支付推送"
);
return build_virtual_payment_notify_success_response(response_format);
}
let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros);
if state
.spacetime_client()
.mark_profile_recharge_order_paid(
notify.out_trade_no.clone(),
paid_at_micros,
notify.transaction_id.clone(),
)
.await
.is_err()
{
warn!(
order_id = notify.out_trade_no.as_str(),
"确认微信虚拟支付订单失败"
);
return build_virtual_payment_notify_error_response(
WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()),
response_format,
);
}
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
info!(
event = notify.event.as_str(),
order_id = notify.out_trade_no.as_str(),
"微信虚拟支付推送已确认订单入账"
);
build_virtual_payment_notify_success_response(response_format)
}
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
match error {
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
@@ -875,6 +1095,362 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
map_wechat_pay_error(error)
}
fn read_wechat_message_push_config<'a>(
value: Option<&'a str>,
key: &str,
) -> Result<&'a str, WechatPayError> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
}
fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response {
let message = match error {
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
WechatPayError::InvalidConfig(message)
| WechatPayError::InvalidRequest(message)
| WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
| WechatPayError::Crypto(message)
| WechatPayError::InvalidSignature(message) => message,
};
(StatusCode::BAD_REQUEST, message).into_response()
}
fn resolve_wechat_message_push_verify_response(
token: &str,
aes_key: &str,
expected_app_id: Option<&str>,
query: &WechatMiniProgramMessagePushQuery,
) -> Result<String, WechatPayError> {
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
let echostr = query.echostr.as_deref().map(str::trim).unwrap_or("");
if timestamp.is_empty() || nonce.is_empty() || echostr.is_empty() {
return Err(WechatPayError::InvalidRequest(
"微信消息推送校验参数不完整".to_string(),
));
}
let msg_signature = query
.msg_signature
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(signature) = msg_signature {
if !verify_wechat_message_push_signature(token, timestamp, nonce, echostr, signature) {
return Err(WechatPayError::InvalidSignature(
"微信消息推送 msg_signature 无效".to_string(),
));
}
return decrypt_wechat_message_push_ciphertext(aes_key, echostr, expected_app_id);
}
let signature = query
.signature
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string()))?;
if !verify_wechat_message_push_signature(token, timestamp, nonce, "", signature) {
return Err(WechatPayError::InvalidSignature(
"微信消息推送校验签名无效".to_string(),
));
}
Ok(echostr.to_string())
}
fn parse_wechat_mini_program_message_push_payload(
body: &[u8],
) -> Result<WechatMiniProgramEncryptedMessage, WechatPayError> {
serde_json::from_slice(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信消息推送 JSON 解析失败:{error}"))
})
}
fn verify_wechat_message_push_signature(
token: &str,
timestamp: &str,
nonce: &str,
value: &str,
signature: &str,
) -> bool {
let mut parts = [token, timestamp, nonce, value];
parts.sort_unstable();
let mut hasher = Sha1::new();
hasher.update(parts.join("").as_bytes());
let expected = hex::encode(hasher.finalize());
expected.eq_ignore_ascii_case(signature)
}
fn decrypt_wechat_message_push_ciphertext(
encoding_aes_key: &str,
ciphertext: &str,
expected_app_id: Option<&str>,
) -> Result<String, WechatPayError> {
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)?;
let ciphertext = BASE64_STANDARD
.decode(ciphertext.as_bytes())
.map_err(|error| {
WechatPayError::Crypto(format!("微信消息推送密文 Base64 解码失败:{error}"))
})?;
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
let cipher = cbc::Decryptor::<Aes256>::new_from_slices(&key, iv)
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送 AES 初始化失败:{error}")))?;
let decrypted = cipher
.decrypt_padded_vec_mut::<NoPadding>(&ciphertext)
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送密文解密失败:{error}")))?;
let plaintext = remove_wechat_message_push_pkcs7_padding(&decrypted)?;
let payload = parse_wechat_message_push_plaintext(&plaintext)?;
if let Some(app_id) = expected_app_id
.map(str::trim)
.filter(|value| !value.is_empty())
&& payload.app_id != app_id
{
return Err(WechatPayError::InvalidSignature(
"微信消息推送明文 appid 校验失败".to_string(),
));
}
Ok(payload.message)
}
fn decode_wechat_message_push_encoding_aes_key(
encoding_aes_key: &str,
) -> Result<Vec<u8>, WechatPayError> {
if encoding_aes_key.chars().count() != WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES {
return Err(WechatPayError::InvalidConfig(format!(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 必须是 {WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES}"
)));
}
let padded_key = format!("{encoding_aes_key}=");
let key = WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64
.decode(padded_key.as_bytes())
.map_err(|error| {
WechatPayError::InvalidConfig(format!(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY Base64 解析失败:{error}"
))
})?;
if key.len() != WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES {
return Err(WechatPayError::InvalidConfig(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 解码后长度必须为 32 字节".to_string(),
));
}
Ok(key)
}
fn remove_wechat_message_push_pkcs7_padding(plaintext: &[u8]) -> Result<Vec<u8>, WechatPayError> {
let Some(&pad_len) = plaintext.last() else {
return Err(WechatPayError::Deserialize(
"微信消息推送明文为空".to_string(),
));
};
let pad_len = pad_len as usize;
if pad_len == 0 || pad_len > 32 || pad_len > plaintext.len() {
return Err(WechatPayError::Deserialize(
"微信消息推送 PKCS7 填充无效".to_string(),
));
}
if plaintext[plaintext.len() - pad_len..]
.iter()
.any(|byte| *byte as usize != pad_len)
{
return Err(WechatPayError::Deserialize(
"微信消息推送 PKCS7 填充校验失败".to_string(),
));
}
Ok(plaintext[..plaintext.len() - pad_len].to_vec())
}
struct WechatMessagePushPlaintext {
message: String,
app_id: String,
}
fn parse_wechat_message_push_plaintext(
plaintext: &[u8],
) -> Result<WechatMessagePushPlaintext, WechatPayError> {
if plaintext.len()
< WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES + WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES + 1
{
return Err(WechatPayError::Deserialize(
"微信消息推送明文长度不足".to_string(),
));
}
let len_offset = WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES;
let length_bytes: [u8; WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES] = plaintext
[len_offset..len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES]
.try_into()
.map_err(|_| WechatPayError::Deserialize("微信消息推送长度字段解析失败".to_string()))?;
let message_len = u32::from_be_bytes(length_bytes) as usize;
let message_start = len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES;
let message_end = message_start + message_len;
if plaintext.len() <= message_end {
return Err(WechatPayError::Deserialize(
"微信消息推送明文长度与内容不匹配".to_string(),
));
}
let app_id_start = message_end;
let message =
String::from_utf8(plaintext[message_start..message_end].to_vec()).map_err(|error| {
WechatPayError::Deserialize(format!("微信消息推送明文不是合法 UTF-8{error}"))
})?;
let app_id =
String::from_utf8(plaintext[app_id_start..plaintext.len()].to_vec()).map_err(|error| {
WechatPayError::Deserialize(format!("微信消息推送 appid 不是合法 UTF-8{error}"))
})?;
Ok(WechatMessagePushPlaintext { message, app_id })
}
fn parse_virtual_payment_notify(
body: &[u8],
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
if let Ok(notify) = serde_json::from_slice::<WechatVirtualPaymentNotifyBody>(body) {
return build_virtual_payment_notify_order(
notify.event,
notify.out_trade_no,
notify.mch_order_id,
notify.wechat_pay_info,
);
}
let text = std::str::from_utf8(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8{error}"))
})?;
let event = extract_virtual_payment_text_value(text, "Event")
.ok_or_else(|| WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string()))?;
let out_trade_no = extract_virtual_payment_text_value(text, "OutTradeNo");
let mch_order_id = extract_virtual_payment_text_value(text, "MchOrderId");
let wechat_pay_info = extract_virtual_payment_block(text, "WeChatPayInfo").map(|inner| {
WechatVirtualPaymentNotifyPayInfo {
mch_order_no: extract_virtual_payment_text_value(&inner, "MchOrderNo"),
transaction_id: extract_virtual_payment_text_value(&inner, "TransactionId"),
paid_time: extract_virtual_payment_text_value(&inner, "PaidTime")
.and_then(|value| value.parse::<i64>().ok()),
}
});
build_virtual_payment_notify_order(event, out_trade_no, mch_order_id, wechat_pay_info)
}
fn build_virtual_payment_notify_order(
event: String,
out_trade_no: Option<String>,
mch_order_id: Option<String>,
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
let event = event.trim().to_string();
if event.is_empty() {
return Err(WechatPayError::InvalidRequest(
"微信虚拟支付推送缺少 Event".to_string(),
));
}
let out_trade_no = out_trade_no
.or(mch_order_id)
.or_else(|| {
wechat_pay_info
.as_ref()
.and_then(|info| info.mch_order_no.clone())
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::InvalidRequest("微信虚拟支付推送缺少 OutTradeNo".to_string())
})?;
let transaction_id = wechat_pay_info
.as_ref()
.and_then(|info| info.transaction_id.clone())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let paid_at_micros = wechat_pay_info
.and_then(|info| info.paid_time)
.map(|paid_time| paid_time.saturating_mul(1_000_000));
Ok(WechatVirtualPaymentNotifyOrder {
out_trade_no,
transaction_id,
paid_at_micros,
event,
})
}
fn extract_virtual_payment_text_value(text: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
let raw = &text[start..end];
Some(trim_virtual_payment_text_value(raw))
}
fn extract_virtual_payment_block(text: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
Some(text[start..end].to_string())
}
fn trim_virtual_payment_text_value(value: &str) -> String {
let trimmed = value.trim();
if let Some(inner) = trimmed
.strip_prefix("<![CDATA[")
.and_then(|value| value.strip_suffix("]]>"))
{
return inner.trim().to_string();
}
trimmed.to_string()
}
fn build_virtual_payment_notify_error_response(
error: WechatPayError,
response_format: VirtualPaymentNotifyResponseFormat,
) -> Response {
warn!(error = %error, "微信虚拟支付通知处理失败");
let message = match error {
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
WechatPayError::InvalidConfig(message)
| WechatPayError::InvalidRequest(message)
| WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
| WechatPayError::Crypto(message)
| WechatPayError::InvalidSignature(message) => message,
};
build_virtual_payment_notify_response(response_format, 1, message)
}
fn build_virtual_payment_notify_success_response(
response_format: VirtualPaymentNotifyResponseFormat,
) -> Response {
build_virtual_payment_notify_response(response_format, 0, "success")
}
fn build_virtual_payment_notify_response(
response_format: VirtualPaymentNotifyResponseFormat,
err_code: i32,
err_msg: impl Into<String>,
) -> Response {
let err_msg = err_msg.into();
match response_format {
VirtualPaymentNotifyResponseFormat::Json => Json(
build_wechat_virtual_payment_notify_response(err_code, err_msg),
)
.into_response(),
VirtualPaymentNotifyResponseFormat::Xml => {
let body = format!(
"<xml><ErrCode>{err_code}</ErrCode><ErrMsg><![CDATA[{err_msg}]]></ErrMsg></xml>"
);
let mut response = (StatusCode::OK, body).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/xml; charset=utf-8"),
);
response
}
}
}
fn with_wechat_pay_json_headers(
builder: reqwest::RequestBuilder,
platform_serial_no: &str,
@@ -965,6 +1541,45 @@ fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError
})
}
fn build_wechat_virtual_payment_notify_response(
err_code: i32,
err_msg: impl Into<String>,
) -> WechatVirtualPaymentNotifyResponse {
WechatVirtualPaymentNotifyResponse {
err_code,
err_msg: err_msg.into(),
}
}
#[derive(Clone, Copy)]
enum VirtualPaymentNotifyResponseFormat {
Json,
Xml,
}
fn detect_virtual_payment_notify_response_format(
headers: &HeaderMap,
body: &[u8],
) -> VirtualPaymentNotifyResponseFormat {
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
if content_type.contains("xml") {
return VirtualPaymentNotifyResponseFormat::Xml;
}
let body_trimmed = body
.iter()
.copied()
.skip_while(|byte| byte.is_ascii_whitespace())
.next();
match body_trimmed {
Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml,
_ => VirtualPaymentNotifyResponseFormat::Json,
}
}
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
value
.map(str::trim)
@@ -1330,6 +1945,7 @@ impl std::error::Error for WechatPayError {}
#[cfg(test)]
mod tests {
use super::*;
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
#[test]
fn mock_pay_params_use_request_payment_shape() {
@@ -1551,4 +2167,178 @@ mod tests {
assert_eq!(notify.transaction_id, None);
assert_eq!(notify.trade_state, "SUCCESS");
}
#[test]
fn parse_virtual_payment_notify_supports_goods_event_json() {
let notify = parse_virtual_payment_notify(
br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"TransactionId":"wx-1","PaidTime":1710000000}}"#,
)
.expect("virtual payment notify should parse");
assert_eq!(notify.event, "xpay_goods_deliver_notify");
assert_eq!(notify.out_trade_no, "order-1");
assert_eq!(notify.transaction_id.as_deref(), Some("wx-1"));
assert_eq!(notify.paid_at_micros, Some(1_710_000_000_000_000));
}
#[test]
fn parse_virtual_payment_notify_supports_coin_event_xml() {
let notify = parse_virtual_payment_notify(
br#"<xml><Event><![CDATA[xpay_coin_pay_notify]]></Event><OutTradeNo><![CDATA[order-2]]></OutTradeNo><WeChatPayInfo><TransactionId><![CDATA[wx-2]]></TransactionId><PaidTime>1710000001</PaidTime></WeChatPayInfo></xml>"#,
)
.expect("virtual payment xml notify should parse");
assert_eq!(notify.event, "xpay_coin_pay_notify");
assert_eq!(notify.out_trade_no, "order-2");
assert_eq!(notify.transaction_id.as_deref(), Some("wx-2"));
assert_eq!(notify.paid_at_micros, Some(1_710_000_001_000_000));
}
#[test]
fn parse_virtual_payment_notify_rejects_missing_order_no() {
let error = parse_virtual_payment_notify(br#"{"Event":"xpay_goods_deliver_notify"}"#)
.expect_err("missing order id should fail");
match error {
WechatPayError::InvalidRequest(message) => {
assert!(message.contains("OutTradeNo"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn decode_wechat_message_push_encoding_aes_key_allows_trailing_bits() {
let canonical_key = BASE64_STANDARD.encode([0u8; 32]);
let mut encoding_aes_key = canonical_key.trim_end_matches('=').to_string();
encoding_aes_key.replace_range(encoding_aes_key.len() - 1.., "B");
let decoded = decode_wechat_message_push_encoding_aes_key(&encoding_aes_key)
.expect("wechat aes key with trailing bits should decode");
assert_eq!(decoded, vec![0u8; WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES]);
}
#[test]
fn wechat_message_push_signature_uses_sorted_sha1_parts() {
let token = "token-1";
let timestamp = "1710000000";
let nonce = "nonce-1";
let encrypt = "encrypted-payload";
let signature = build_wechat_message_push_test_signature(token, timestamp, nonce, encrypt);
assert!(verify_wechat_message_push_signature(
token, timestamp, nonce, encrypt, &signature
));
assert!(!verify_wechat_message_push_signature(
token,
timestamp,
nonce,
"tampered-payload",
&signature
));
}
#[test]
fn wechat_message_push_plain_get_verify_returns_echostr() {
let token = "AAAAA";
let timestamp = "1714036504";
let nonce = "1514711492";
let echostr = "4375120948345356249";
let signature = "f464b24fc39322e44b38aa78f5edd27bd1441696";
let plaintext = resolve_wechat_message_push_verify_response(
token,
"unused-aes-key",
Some("wx-test-app"),
&WechatMiniProgramMessagePushQuery {
signature: Some(signature.to_string()),
timestamp: Some(timestamp.to_string()),
nonce: Some(nonce.to_string()),
echostr: Some(echostr.to_string()),
msg_signature: None,
},
)
.expect("plain url verification should return echostr");
assert_eq!(plaintext, echostr);
}
#[test]
fn wechat_message_push_decrypts_safe_mode_ciphertext() {
let app_id = "wx-test-app";
let message = r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#;
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
let encrypted =
encrypt_wechat_message_push_test_ciphertext(&encoding_aes_key, message, app_id);
let decrypted =
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some(app_id))
.expect("encrypted message should decrypt");
assert_eq!(decrypted, message);
}
#[test]
fn wechat_message_push_rejects_mismatched_app_id() {
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
let encrypted = encrypt_wechat_message_push_test_ciphertext(
&encoding_aes_key,
r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#,
"wx-real-app",
);
let error =
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some("wx-other"))
.expect_err("mismatched app id should fail");
match error {
WechatPayError::InvalidSignature(message) => {
assert!(message.contains("appid"));
}
other => panic!("unexpected error: {other:?}"),
}
}
fn build_wechat_message_push_test_signature(
token: &str,
timestamp: &str,
nonce: &str,
value: &str,
) -> String {
let mut parts = [token, timestamp, nonce, value];
parts.sort_unstable();
let mut hasher = Sha1::new();
hasher.update(parts.join("").as_bytes());
hex::encode(hasher.finalize())
}
fn build_wechat_message_push_test_encoding_aes_key() -> String {
let raw_key = std::array::from_fn::<_, 32, _>(|index| index as u8);
BASE64_STANDARD
.encode(raw_key)
.trim_end_matches('=')
.to_string()
}
fn encrypt_wechat_message_push_test_ciphertext(
encoding_aes_key: &str,
message: &str,
app_id: &str,
) -> String {
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)
.expect("test aes key should decode");
let mut plaintext = Vec::new();
plaintext.extend_from_slice(b"0123456789abcdef");
plaintext.extend_from_slice(&(message.as_bytes().len() as u32).to_be_bytes());
plaintext.extend_from_slice(message.as_bytes());
plaintext.extend_from_slice(app_id.as_bytes());
let pad_len = 32 - (plaintext.len() % 32);
plaintext.extend(std::iter::repeat(pad_len as u8).take(pad_len));
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
let cipher = cbc::Encryptor::<Aes256>::new_from_slices(&key, iv)
.expect("test aes cipher should init");
let encrypted = cipher.encrypt_padded_vec_mut::<NoPadding>(&plaintext);
BASE64_STANDARD.encode(encrypted)
}
}

View File

@@ -147,26 +147,34 @@ pub async fn execute_wooden_fish_action(
wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let author_display_name = resolve_author_display_name(&state, &authenticated);
maybe_generate_hit_object_asset(
let result = execute_wooden_fish_action_with_generated_assets(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
&owner_user_id,
&author_display_name,
&mut payload,
)
.await?;
maybe_generate_hit_sound_asset(&mut payload);
let response = state
.spacetime_client()
.execute_wooden_fish_action(session_id, owner_user_id, author_display_name, payload)
.await
.map_err(|error| {
wooden_fish_error_response(
&request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})?;
.await;
if result
.as_ref()
.err()
.is_some_and(|response| response.status().is_server_error())
&& matches!(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
)
{
mark_wooden_fish_generation_failed(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
author_display_name.as_str(),
)
.await;
}
let response = result?;
Ok(json_success_body(Some(&request_context), response))
}
@@ -372,16 +380,24 @@ async fn build_wooden_fish_draft(
payload: &WoodenFishWorkspaceCreateRequest,
state: &AppState,
) -> Result<WoodenFishDraftResponse, Response> {
Ok(WoodenFishDraftResponse {
let work_title = resolve_wooden_fish_work_title(
state,
&payload.work_description,
&payload.hit_object_prompt,
)
.await?;
Ok(build_wooden_fish_draft_response(payload, work_title))
}
fn build_wooden_fish_draft_response(
payload: &WoodenFishWorkspaceCreateRequest,
work_title: String,
) -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: resolve_wooden_fish_work_title(
state,
&payload.work_description,
&payload.hit_object_prompt,
)
.await?,
work_title,
work_description: payload.work_description.trim().to_string(),
theme_tags: normalize_tags(payload.theme_tags.clone()),
hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT),
@@ -401,7 +417,7 @@ async fn build_wooden_fish_draft(
.or_else(|| Some(default_wooden_fish_hit_sound_asset())),
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,
})
}
}
fn validate_workspace_request(
@@ -543,6 +559,62 @@ async fn maybe_generate_hit_object_asset(
Ok(())
}
async fn execute_wooden_fish_action_with_generated_assets(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
payload: &mut WoodenFishActionRequest,
) -> Result<shared_contracts::wooden_fish::WoodenFishActionResponse, Response> {
maybe_generate_hit_object_asset(state, request_context, session_id, owner_user_id, payload)
.await?;
maybe_generate_hit_sound_asset(payload);
state
.spacetime_client()
.execute_wooden_fish_action(
session_id.to_string(),
owner_user_id.to_string(),
author_display_name.to_string(),
payload.clone(),
)
.await
.map_err(|error| {
wooden_fish_error_response(
request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})
}
async fn mark_wooden_fish_generation_failed(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
) {
if let Err(error) = state
.spacetime_client()
.mark_wooden_fish_generation_failed(
session_id.to_string(),
owner_user_id.to_string(),
author_display_name.to_string(),
)
.await
{
tracing::error!(
provider = WOODEN_FISH_CREATION_PROVIDER,
session_id,
owner_user_id,
request_id = request_context.request_id(),
error = %error,
"敲木鱼草稿生成失败后的状态回写失败"
);
}
}
fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(),