This commit is contained in:
2026-05-14 13:42:12 +08:00
112 changed files with 5766 additions and 626 deletions

View File

@@ -8,6 +8,7 @@ license.workspace = true
async-stream = { workspace = true }
axum = { workspace = true, features = ["ws"] }
base64 = { workspace = true }
bytes = { workspace = true }
dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
@@ -34,8 +35,10 @@ platform-auth = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
ring = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
shared-contracts = { workspace = true, features = ["oss-contracts"] }
shared-kernel = { workspace = true }
shared-logging = { workspace = true }
@@ -56,5 +59,4 @@ base64 = { workspace = true }
hmac = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
sha2 = { workspace = true }
tower = { workspace = true, features = ["util"] }

View File

@@ -776,7 +776,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_ai_tasks".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -33,7 +33,7 @@ use crate::{
},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
auth_sessions::{auth_sessions, revoke_auth_session},
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
@@ -178,6 +178,7 @@ use crate::{
wechat_auth::{
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
},
wechat_pay::handle_wechat_pay_notify,
};
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
@@ -330,6 +331,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/auth/sessions/{session_id}/revoke",
post(revoke_auth_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/me",
axum::routing::patch(update_profile_identity).route_layer(
@@ -1401,6 +1409,10 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/wechat/notify",
post(handle_wechat_pay_notify),
)
.route(
"/api/profile/feedback",
post(submit_profile_feedback)
@@ -1919,10 +1931,12 @@ mod tests {
user: &module_auth::AuthUser,
session_id: &str,
) -> String {
let now = OffsetDateTime::now_utc();
let active_session_id = state.seed_test_refresh_session_for_user(user, session_id);
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
session_id: active_session_id,
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
@@ -1931,13 +1945,22 @@ mod tests {
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
now,
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn read_access_token(response_body: &[u8]) -> String {
let payload: Value =
serde_json::from_slice(response_body).expect("login payload should be json");
payload["token"]
.as_str()
.expect("access token should exist")
.to_string()
}
async fn password_login_request(
app: Router,
phone_number: &str,
@@ -1961,6 +1984,37 @@ mod tests {
.expect("password login request should succeed")
}
async fn password_login_request_with_client(
app: Router,
phone_number: &str,
password: &str,
client_instance_id: &str,
forwarded_for: &str,
) -> axum::response::Response {
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("x-client-instance-id", client_instance_id)
.header("x-forwarded-for", forwarded_for)
.body(Body::from(
serde_json::json!({
"phone": phone_number,
"password": password
})
.to_string(),
))
.expect("password login request should build"),
)
.await
.expect("password login request should succeed")
}
fn build_internal_creative_agent_app() -> Router {
let mut config = AppConfig::default();
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
@@ -2534,10 +2588,11 @@ mod tests {
let config = AppConfig::default();
let state = AppState::new(config.clone()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
let session_id = state.seed_test_refresh_session_for_user(&seed_user, "sess_auth_debug");
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: seed_user.id.clone(),
session_id: "sess_auth_debug".to_string(),
session_id: session_id.clone(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: seed_user.token_version,
@@ -2575,10 +2630,7 @@ mod tests {
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id));
assert_eq!(
payload["claims"]["sid"],
Value::String("sess_auth_debug".to_string())
);
assert_eq!(payload["claims"]["sid"], Value::String(session_id));
assert_eq!(
payload["claims"]["ver"],
Value::Number(serde_json::Number::from(seed_user.token_version))
@@ -4236,12 +4288,17 @@ mod tests {
session["clientType"] == Value::String("web_browser".to_string())
&& session["clientRuntime"] == Value::String("chrome".to_string())
&& session["clientPlatform"] == Value::String("windows".to_string())
&& session["sessionCount"] == Value::Number(1.into())
&& session["sessionIds"]
.as_array()
.is_some_and(|ids| ids.len() == 1)
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
&& session["isCurrent"] == Value::Bool(true)
}));
assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("mini_program".to_string())
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
&& session["sessionCount"] == Value::Number(1.into())
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
&& session["miniProgramEnv"] == Value::String("release".to_string())
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
@@ -4249,6 +4306,108 @@ mod tests {
}));
}
#[tokio::test]
async fn auth_sessions_groups_same_device_same_ip_and_marks_current_group() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
let app = build_router(state);
let login_body = serde_json::json!({
"phone": "13800138028",
"password": TEST_PASSWORD
})
.to_string();
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("x-client-instance-id", "same-device")
.header("x-forwarded-for", "203.0.113.10")
.body(Body::from(login_body.clone()))
.expect("first login request should build"),
)
.await
.expect("first login should succeed");
let first_cookie = first_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("first cookie should exist")
.to_string();
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let access_token = read_access_token(&first_body);
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("x-client-instance-id", "same-device")
.header("x-forwarded-for", "203.0.113.10")
.body(Body::from(login_body))
.expect("second login request should build"),
)
.await
.expect("second login should succeed");
let sessions_response = app
.oneshot(
Request::builder()
.uri("/api/auth/sessions")
.header("authorization", format!("Bearer {access_token}"))
.header("cookie", first_cookie)
.body(Body::empty())
.expect("sessions request should build"),
)
.await
.expect("sessions request should succeed");
assert_eq!(sessions_response.status(), StatusCode::OK);
let sessions_body = sessions_response
.into_body()
.collect()
.await
.expect("sessions body should collect")
.to_bytes();
let sessions_payload: Value =
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
let sessions = sessions_payload["sessions"]
.as_array()
.expect("sessions should be array");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["sessionCount"], Value::Number(2.into()));
assert_eq!(sessions[0]["isCurrent"], Value::Bool(true));
assert_eq!(
sessions[0]["ipMasked"],
Value::String("203.0.*.*".to_string())
);
assert_eq!(
sessions[0]["sessionIds"]
.as_array()
.expect("session ids should exist")
.len(),
2
);
}
#[tokio::test]
async fn password_entry_reuses_same_user_for_same_phone() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -4360,9 +4519,23 @@ mod tests {
#[tokio::test]
async fn password_change_allows_login_with_new_password_only() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_password_change");
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
let refresh_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let token = read_access_token(&login_body);
let change_response = app
.clone()
@@ -4384,6 +4557,40 @@ mod tests {
.await
.expect("change password request should succeed");
assert_eq!(change_response.status(), StatusCode::OK);
assert!(
change_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
let old_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("me request should build"),
)
.await
.expect("me request should succeed");
assert_eq!(old_me_response.status(), StatusCode::UNAUTHORIZED);
let old_refresh_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", refresh_cookie)
.body(Body::empty())
.expect("refresh request should build"),
)
.await
.expect("refresh request should succeed");
assert_eq!(old_refresh_response.status(), StatusCode::UNAUTHORIZED);
let old_password_response =
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
@@ -4427,23 +4634,16 @@ mod tests {
};
let state = AppState::new(config).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: seed_user.id.clone(),
session_id: "sess_me_query".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: seed_user.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(seed_user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138016", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let token = read_access_token(&login_body);
let response = app
.oneshot(
@@ -4604,6 +4804,141 @@ mod tests {
);
}
#[tokio::test]
async fn revoke_auth_session_revokes_remote_session_without_token_version_bump() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138030", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = password_login_request_with_client(
app.clone(),
"13800138030",
TEST_PASSWORD,
"revoke-current-device",
"203.0.113.30",
)
.await;
let first_cookie = first_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("first cookie should exist")
.to_string();
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let first_access_token = read_access_token(&first_body);
let second_login_response = password_login_request_with_client(
app.clone(),
"13800138030",
TEST_PASSWORD,
"revoke-remote-device",
"203.0.113.31",
)
.await;
let second_cookie = second_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("second cookie should exist")
.to_string();
let second_body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let second_access_token = read_access_token(&second_body);
let remote_sessions_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/sessions")
.header("authorization", format!("Bearer {first_access_token}"))
.header("cookie", first_cookie.clone())
.body(Body::empty())
.expect("sessions request should build"),
)
.await
.expect("sessions request should succeed");
assert_eq!(remote_sessions_response.status(), StatusCode::OK);
let remote_sessions_body = remote_sessions_response
.into_body()
.collect()
.await
.expect("sessions body should collect")
.to_bytes();
let remote_sessions_payload: Value =
serde_json::from_slice(&remote_sessions_body).expect("sessions payload should be json");
let remote_session_id = remote_sessions_payload["sessions"]
.as_array()
.expect("sessions should be array")
.iter()
.find(|session| session["isCurrent"] == Value::Bool(false))
.and_then(|session| session["sessionId"].as_str())
.expect("remote session id should exist")
.to_string();
let revoke_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/auth/sessions/{remote_session_id}/revoke"))
.header("authorization", format!("Bearer {first_access_token}"))
.header("cookie", first_cookie)
.body(Body::empty())
.expect("revoke request should build"),
)
.await
.expect("revoke request should succeed");
assert_eq!(revoke_response.status(), StatusCode::OK);
let current_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {first_access_token}"))
.body(Body::empty())
.expect("current me request should build"),
)
.await
.expect("current me request should succeed");
assert_eq!(current_me_response.status(), StatusCode::OK);
let remote_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {second_access_token}"))
.body(Body::empty())
.expect("remote me request should build"),
)
.await
.expect("remote me request should succeed");
assert_eq!(remote_me_response.status(), StatusCode::UNAUTHORIZED);
let remote_refresh_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", second_cookie)
.body(Body::empty())
.expect("remote refresh request should build"),
)
.await
.expect("remote refresh request should succeed");
assert_eq!(remote_refresh_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_clears_cookie_and_invalidates_current_access_token() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -4686,6 +5021,12 @@ mod tests {
let login_response =
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
let refresh_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let login_body = login_response
.into_body()
.collect()
@@ -4700,6 +5041,7 @@ mod tests {
.to_string();
let logout_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
@@ -4719,6 +5061,19 @@ mod tests {
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
let refresh_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", refresh_cookie)
.body(Body::empty())
.expect("refresh request should build"),
)
.await
.expect("refresh request should succeed");
assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]

View File

@@ -117,6 +117,34 @@ pub async fn require_bearer_auth(
.with_message("当前登录态已失效,请重新登录"));
}
let session_is_active = state
.refresh_session_service()
.is_session_active_for_user(
claims.user_id(),
claims.session_id(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
warn!(
%request_id,
user_id = %claims.user_id(),
session_id = %claims.session_id(),
error = %error,
"Bearer JWT refresh session 状态读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
if !session_is_active {
warn!(
%request_id,
user_id = %claims.user_id(),
session_id = %claims.session_id(),
"Bearer JWT 对应 refresh session 已失效"
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"));
}
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));

View File

@@ -1,10 +1,15 @@
use std::collections::HashMap;
use axum::{
Json,
extract::{Extension, State},
extract::{Extension, Path, State},
http::StatusCode,
};
use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput};
use platform_auth::hash_refresh_session_token;
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
use shared_contracts::auth::{
AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse,
};
use time::OffsetDateTime;
use crate::{
@@ -37,41 +42,189 @@ pub async fn auth_sessions(
.refresh_session_service()
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
.map_err(map_refresh_session_list_error)?;
let current_session_id = authenticated.claims().session_id().to_string();
let session_groups = group_sessions_by_device_and_ip(sessions.sessions);
Ok(json_success_body(
Some(&request_context),
AuthSessionsResponse {
sessions: sessions
.sessions
sessions: session_groups
.into_iter()
.map(|session| {
let is_current = current_refresh_token_hash
.as_ref()
.is_some_and(|hash| session.refresh_token_hash == *hash);
let client_label = session.client_info.device_display_name.clone();
AuthSessionSummaryPayload {
session_id: session.session_id,
client_type: session.client_info.client_type,
client_runtime: session.client_info.client_runtime,
client_platform: session.client_info.client_platform,
client_label,
device_display_name: session.client_info.device_display_name,
mini_program_app_id: session.client_info.mini_program_app_id,
mini_program_env: session.client_info.mini_program_env,
user_agent: session.client_info.user_agent,
ip_masked: mask_ip(session.client_info.ip.as_deref()),
is_current,
created_at: session.created_at,
last_seen_at: session.last_seen_at,
expires_at: session.expires_at,
}
.map(|group| {
build_session_summary(
group,
current_refresh_token_hash.as_deref(),
&current_session_id,
)
})
.collect(),
},
))
}
pub async fn revoke_auth_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(session_id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let session_id = session_id.trim().to_string();
if session_id.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少会话 ID"));
}
if session_id == authenticated.claims().session_id() {
return Err(
AppError::from_status(StatusCode::CONFLICT).with_message("当前设备请使用退出登录")
);
}
let revoke_result = state
.refresh_session_service()
.revoke_session_by_user_and_session(
RevokeRefreshSessionByUserInput {
user_id: authenticated.claims().user_id().to_string(),
session_id,
},
OffsetDateTime::now_utc(),
)
.map_err(map_refresh_session_revoke_error)?;
if !revoke_result.revoked {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("会话不存在或已失效")
);
}
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
Ok(json_success_body(
Some(&request_context),
RevokeAuthSessionResponse { ok: true },
))
}
fn group_sessions_by_device_and_ip(
sessions: Vec<RefreshSessionRecord>,
) -> Vec<Vec<RefreshSessionRecord>> {
let mut grouped = HashMap::<String, Vec<RefreshSessionRecord>>::new();
for session in sessions {
grouped
.entry(build_session_group_key(&session))
.or_default()
.push(session);
}
let mut groups = grouped.into_values().collect::<Vec<_>>();
for group in &mut groups {
group.sort_by(|left, right| {
right
.last_seen_at
.cmp(&left.last_seen_at)
.then_with(|| right.created_at.cmp(&left.created_at))
});
}
groups.sort_by(|left, right| {
group_latest_last_seen(right)
.cmp(group_latest_last_seen(left))
.then_with(|| group_earliest_created(left).cmp(group_earliest_created(right)))
});
groups
}
fn build_session_group_key(session: &RefreshSessionRecord) -> String {
let client_info = &session.client_info;
let device_key = client_info.device_fingerprint.as_deref().unwrap_or("");
if !device_key.is_empty() {
return format!("{}|{}", device_key, client_info.ip.as_deref().unwrap_or(""));
}
format!(
"{}|{}|{}|{}|{}|{}",
client_info.client_type,
client_info.client_runtime,
client_info.client_platform,
client_info.device_display_name,
client_info.user_agent.as_deref().unwrap_or(""),
client_info.ip.as_deref().unwrap_or("")
)
}
fn build_session_summary(
group: Vec<RefreshSessionRecord>,
current_refresh_token_hash: Option<&str>,
current_session_id: &str,
) -> AuthSessionSummaryPayload {
let is_current = group.iter().any(|session| {
session.session_id == current_session_id
|| current_refresh_token_hash.is_some_and(|hash| session.refresh_token_hash == hash)
});
let representative = group
.iter()
.find(|session| is_current && session.session_id == current_session_id)
.or_else(|| {
group.iter().find(|session| {
is_current
&& current_refresh_token_hash
.is_some_and(|hash| session.refresh_token_hash == hash)
})
})
.unwrap_or_else(|| group.first().expect("session group should not be empty"));
let client_label = representative.client_info.device_display_name.clone();
let session_ids = group
.iter()
.map(|session| session.session_id.clone())
.collect::<Vec<_>>();
let session_count = u32::try_from(session_ids.len()).unwrap_or(u32::MAX);
AuthSessionSummaryPayload {
session_id: representative.session_id.clone(),
session_ids,
session_count,
client_type: representative.client_info.client_type.clone(),
client_runtime: representative.client_info.client_runtime.clone(),
client_platform: representative.client_info.client_platform.clone(),
client_label,
device_display_name: representative.client_info.device_display_name.clone(),
mini_program_app_id: representative.client_info.mini_program_app_id.clone(),
mini_program_env: representative.client_info.mini_program_env.clone(),
user_agent: representative.client_info.user_agent.clone(),
ip_masked: mask_ip(representative.client_info.ip.as_deref()),
is_current,
created_at: group_earliest_created(&group).to_string(),
last_seen_at: group_latest_last_seen(&group).to_string(),
expires_at: group_latest_expires_at(&group).to_string(),
}
}
fn group_latest_last_seen(group: &[RefreshSessionRecord]) -> &str {
group
.iter()
.map(|session| session.last_seen_at.as_str())
.max()
.unwrap_or("")
}
fn group_earliest_created(group: &[RefreshSessionRecord]) -> &str {
group
.iter()
.map(|session| session.created_at.as_str())
.min()
.unwrap_or("")
}
fn group_latest_expires_at(group: &[RefreshSessionRecord]) -> &str {
group
.iter()
.map(|session| session.expires_at.as_str())
.max()
.unwrap_or("")
}
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
match error {
module_auth::RefreshSessionError::UserNotFound => {
@@ -88,3 +241,19 @@ fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> Ap
}
}
}
fn map_refresh_session_revoke_error(error: module_auth::RefreshSessionError) -> AppError {
match error {
module_auth::RefreshSessionError::MissingToken
| module_auth::RefreshSessionError::SessionNotFound => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
module_auth::RefreshSessionError::SessionExpired
| module_auth::RefreshSessionError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
module_auth::RefreshSessionError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}

View File

@@ -71,6 +71,18 @@ pub struct AppConfig {
pub wechat_mock_union_id: Option<String>,
pub wechat_mock_display_name: String,
pub wechat_mock_avatar_url: Option<String>,
pub wechat_pay_enabled: bool,
pub wechat_pay_provider: String,
pub wechat_pay_mch_id: Option<String>,
pub wechat_pay_merchant_serial_no: Option<String>,
pub wechat_pay_private_key_pem: Option<String>,
pub wechat_pay_private_key_path: Option<PathBuf>,
pub wechat_pay_platform_public_key_pem: Option<String>,
pub wechat_pay_platform_public_key_path: Option<PathBuf>,
pub wechat_pay_platform_serial_no: Option<String>,
pub wechat_pay_api_v3_key: Option<String>,
pub wechat_pay_notify_url: Option<String>,
pub wechat_pay_jsapi_endpoint: String,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
@@ -190,6 +202,19 @@ impl Default for AppConfig {
wechat_mock_union_id: Some("wx-mock-union".to_string()),
wechat_mock_display_name: "微信旅人".to_string(),
wechat_mock_avatar_url: None,
wechat_pay_enabled: false,
wechat_pay_provider: "mock".to_string(),
wechat_pay_mch_id: None,
wechat_pay_merchant_serial_no: None,
wechat_pay_private_key_pem: None,
wechat_pay_private_key_path: None,
wechat_pay_platform_public_key_pem: None,
wechat_pay_platform_public_key_path: None,
wechat_pay_platform_serial_no: None,
wechat_pay_api_v3_key: None,
wechat_pay_notify_url: None,
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
.to_string(),
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
@@ -460,6 +485,33 @@ impl AppConfig {
}
config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]);
if let Some(wechat_pay_enabled) = read_first_bool_env(&["WECHAT_PAY_ENABLED"]) {
config.wechat_pay_enabled = wechat_pay_enabled;
}
if let Some(wechat_pay_provider) = read_first_non_empty_env(&["WECHAT_PAY_PROVIDER"]) {
config.wechat_pay_provider = wechat_pay_provider;
}
config.wechat_pay_mch_id = read_first_non_empty_env(&["WECHAT_PAY_MCH_ID"]);
config.wechat_pay_merchant_serial_no =
read_first_non_empty_env(&["WECHAT_PAY_MERCHANT_SERIAL_NO"]);
config.wechat_pay_private_key_pem =
read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PEM"]);
config.wechat_pay_private_key_path =
read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PATH"]).map(PathBuf::from);
config.wechat_pay_platform_public_key_pem =
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM"]);
config.wechat_pay_platform_public_key_path =
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"]).map(PathBuf::from);
config.wechat_pay_platform_serial_no =
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_SERIAL_NO"]);
config.wechat_pay_api_v3_key = read_first_non_empty_env(&["WECHAT_PAY_API_V3_KEY"]);
config.wechat_pay_notify_url = read_first_non_empty_env(&["WECHAT_PAY_NOTIFY_URL"]);
if let Some(wechat_pay_jsapi_endpoint) =
read_first_non_empty_env(&["WECHAT_PAY_JSAPI_ENDPOINT"])
{
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
}
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
@@ -1093,6 +1145,74 @@ mod tests {
}
}
#[test]
fn from_env_reads_wechat_pay_settings() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("WECHAT_PAY_ENABLED");
std::env::remove_var("WECHAT_PAY_PROVIDER");
std::env::remove_var("WECHAT_PAY_MCH_ID");
std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH");
std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH");
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::set_var("WECHAT_PAY_ENABLED", "true");
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
std::env::set_var("WECHAT_PAY_MERCHANT_SERIAL_NO", "serial-001");
std::env::set_var("WECHAT_PAY_PRIVATE_KEY_PATH", "certs/apiclient_key.pem");
std::env::set_var(
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH",
"certs/wechatpay_platform.pem",
);
std::env::set_var("WECHAT_PAY_PLATFORM_SERIAL_NO", "platform-serial-001");
std::env::set_var("WECHAT_PAY_API_V3_KEY", "12345678901234567890123456789012");
std::env::set_var(
"WECHAT_PAY_NOTIFY_URL",
"https://api.example.com/api/profile/recharge/wechat/notify",
);
}
let config = AppConfig::from_env();
assert!(config.wechat_pay_enabled);
assert_eq!(config.wechat_pay_provider, "real");
assert_eq!(config.wechat_pay_mch_id.as_deref(), Some("1900000109"));
assert_eq!(
config.wechat_pay_private_key_path.as_deref(),
Some(std::path::Path::new("certs/apiclient_key.pem"))
);
assert_eq!(
config.wechat_pay_notify_url.as_deref(),
Some("https://api.example.com/api/profile/recharge/wechat/notify")
);
assert_eq!(
config.wechat_pay_platform_public_key_path.as_deref(),
Some(std::path::Path::new("certs/wechatpay_platform.pem"))
);
assert_eq!(
config.wechat_pay_platform_serial_no.as_deref(),
Some("platform-serial-001")
);
unsafe {
std::env::remove_var("WECHAT_PAY_ENABLED");
std::env::remove_var("WECHAT_PAY_PROVIDER");
std::env::remove_var("WECHAT_PAY_MCH_ID");
std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH");
std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH");
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");
}
}
#[test]
fn from_env_ignores_zero_spacetime_pool_size() {
let _guard = ENV_LOCK

View File

@@ -375,14 +375,15 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id,
session_id: "sess_creation_doc_input".to_string(),
user_id: user.id.clone(),
session_id: state
.seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name),
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),

View File

@@ -333,7 +333,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_llm_proxy".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_llm_proxy"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -40,6 +40,7 @@ pub async fn logout(
LogoutCurrentSessionInput {
user_id: authenticated.claims().user_id().to_string(),
refresh_token_hash,
session_id: Some(authenticated.claims().session_id().to_string()),
},
OffsetDateTime::now_utc(),
)

View File

@@ -75,6 +75,7 @@ mod vector_engine_audio_generation;
mod visual_novel;
mod volcengine_speech;
mod wechat_auth;
mod wechat_pay;
mod wechat_provider;
mod work_author;
mod work_play_tracking;

View File

@@ -15,7 +15,8 @@ use crate::{
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
@@ -30,14 +31,17 @@ pub async fn change_password(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PasswordChangeRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
) -> Result<impl IntoResponse, AppError> {
let result = state
.password_entry_service()
.change_password(ChangePasswordInput {
user_id: authenticated.claims().user_id().to_string(),
current_password: payload.current_password,
new_password: payload.new_password,
})
.change_password_and_revoke_all_sessions(
ChangePasswordInput {
user_id: authenticated.claims().user_id().to_string(),
current_password: payload.current_password,
new_password: payload.new_password,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_password_management_error)?;
state
@@ -48,11 +52,20 @@ pub async fn change_password(
.with_message(format!("同步认证快照失败:{error}"))
})?;
Ok(json_success_body(
Some(&request_context),
PasswordChangeResponse {
user: map_auth_user_payload(result.user),
},
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_clear_refresh_session_cookie_header(&state)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PasswordChangeResponse {
user: map_auth_user_payload(result.user),
},
),
))
}

View File

@@ -224,11 +224,23 @@ pub(crate) fn build_visual_novel_creation_user_prompt(
"currentDraft": params.current_draft,
"recentMessages": params.recent_messages,
"nowIso": params.now_iso,
"oneLineGenerationFlow": [
"提取一句话核心创意、故事类型、玩家身份和视觉画风",
"扩展世界观、故事前提、文学风格和默认叙事语气",
"设计 3 到 6 个角色,并为每个角色写出可生成立绘的 appearance",
"设计 3 到 8 个场景,并为 opening 场景写出可生成背景图的 description",
"组织 3 到 6 个剧情阶段,第一阶段必须能从 opening 进入",
"生成 opening.narration、可选 firstDialogue 和 2 到 4 个 initialChoices",
"图片、音乐可先为 null但文字草稿必须可进入结果页编辑、保存并试玩"
],
"draftRequirements": {
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
"scenes": "3 到 8 个,至少 1 个 opening 场景",
"storyPhases": "3 到 6 个,第一阶段可从 opening 进入",
"initialChoices": "2 到 4 个",
"initialChoices": "2 到 4 个 initialChoices",
"openingScene": "opening.sceneId 必须指向存在且 availability 为 opening 的 scene",
"firstPhase": "storyPhases[0] 必须包含 opening scene 和主要角色",
"assetFallback": "图片、音乐可先为 null但 appearance 和 scene description 必须足够后续生成资产",
"runtimeConfigDefaults": "沿用契约默认值attributePanelMode 默认为 off"
},
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
@@ -616,6 +628,29 @@ mod tests {
assert!(repair_prompt.contains("scene_change"));
}
#[test]
fn creation_prompt_guides_one_line_flow_into_playable_draft() {
let asset_ids = source_asset_ids();
let prompt = build_visual_novel_creation_user_prompt(VisualNovelCreationPromptParams {
source_mode: "idea",
seed_text: Some(
"雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。",
),
source_asset_ids: asset_ids.as_slice(),
document_summary: None,
current_draft: None,
recent_messages: &[],
now_iso: "2026-05-13T12:00:00Z",
});
assert!(prompt.contains("oneLineGenerationFlow"));
assert!(prompt.contains("提取一句话核心创意"));
assert!(prompt.contains("视觉画风"));
assert!(prompt.contains("opening.sceneId"));
assert!(prompt.contains("2 到 4 个 initialChoices"));
assert!(prompt.contains("图片、音乐可先为 null"));
}
#[test]
fn llm_requests_use_responses_template_model() {
let asset_ids = source_asset_ids();

View File

@@ -374,7 +374,10 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_browse_history".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_browse_history",
),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -174,7 +174,10 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_inventory".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_inventory",
),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -6,15 +6,16 @@ use axum::{
};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot,
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord,
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord,
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind,
};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -56,8 +57,13 @@ use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
http_error::AppError, request_context::RequestContext, state::AppState,
admin::AuthenticatedAdmin,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error},
};
pub async fn get_profile_dashboard(
@@ -186,14 +192,15 @@ pub async fn create_profile_recharge_order(
let payment_channel = payload
.payment_channel
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let payment_channel = payment_channel.trim().to_string();
let created_at_micros = current_unix_micros();
let (center, order) = state
.spacetime_client()
.create_profile_recharge_order(
user_id,
payload.product_id,
payment_channel,
created_at_micros as i64,
payment_channel.clone(),
created_at_micros,
)
.await
.map_err(|error| {
@@ -203,11 +210,36 @@ pub async fn create_profile_recharge_order(
)
})?;
let wechat_mini_program_pay_params = if payment_channel
== PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
{
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(
state
.wechat_pay_client()
.create_mini_program_order(build_wechat_payment_request(
order.order_id.clone(),
order.product_title.clone(),
order.amount_cents,
identity,
))
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
)
} else {
None
};
Ok(json_success_body(
Some(&request_context),
CreateProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
wechat_mini_program_pay_params,
},
))
}
@@ -750,6 +782,25 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context))
}
async fn resolve_wechat_identity_for_payment(
state: &AppState,
user_id: &str,
) -> Result<String, AppError> {
if let Some(identity) = state
.wechat_auth_service()
.get_identity_by_user_id(user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信身份失败:{error}"))
})?
{
return Ok(identity.provider_uid);
}
Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
}
fn build_profile_recharge_center_response(
record: RuntimeProfileRechargeCenterRecord,
) -> ProfileRechargeCenterResponse {
@@ -825,6 +876,7 @@ fn build_profile_recharge_order_response(
status: record.status.as_str().to_string(),
payment_channel: record.payment_channel,
paid_at: record.paid_at,
provider_transaction_id: record.provider_transaction_id,
created_at: record.created_at,
points_delta: record.points_delta,
membership_expires_at: record.membership_expires_at,
@@ -1568,7 +1620,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_profile".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -575,7 +575,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_save".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -350,7 +350,10 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_settings".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_settings",
),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -30,6 +30,7 @@ use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
use crate::wechat_provider::build_wechat_provider;
const ADMIN_ROLE: &str = "admin";
@@ -55,6 +56,7 @@ pub struct AppState {
wechat_auth_state_service: WechatAuthStateService,
wechat_auth_service: WechatAuthService,
wechat_provider: WechatProvider,
wechat_pay_client: WechatPayClient,
#[cfg_attr(not(test), allow(dead_code))]
ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient,
@@ -110,6 +112,7 @@ pub enum AppStateInitError {
RefreshCookie(RefreshCookieError),
AuthStore(String),
SmsProvider(SmsProviderError),
WechatPay(String),
Oss(OssError),
Llm(LlmError),
}
@@ -174,6 +177,8 @@ impl AppState {
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
let wechat_provider = build_wechat_provider(&config);
let wechat_pay_client =
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
let refresh_session_service =
RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days);
// AI 编排服务当前先挂接内存态 store后续再按 task table / procedure 接到 SpacetimeDB 真相源。
@@ -206,6 +211,7 @@ impl AppState {
wechat_auth_state_service,
wechat_auth_service,
wechat_provider,
wechat_pay_client,
ai_task_service,
spacetime_client,
llm_client,
@@ -333,23 +339,14 @@ impl AppState {
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
// 本地 auth_store 是当前认证请求的即时真相源SpacetimeDB 快照用于跨进程恢复。
// 本地 auth_store 是当前认证请求的即时真相源SpacetimeDB 正式认证表用于跨进程恢复。
// 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
.import_auth_store_snapshot_json(snapshot_json, updated_at_micros)
.await
{
warn!(
error = %error,
"认证快照写入 SpacetimeDB 失败,当前认证流程继续"
);
return Ok(());
}
// 写入快照后尝试拆入正式认证表;失败只影响远端表恢复,不阻断当前认证响应。
#[cfg(not(test))]
if let Err(error) = self.spacetime_client.import_auth_store_snapshot().await {
warn!(
error = %error,
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续"
@@ -454,6 +451,10 @@ impl AppState {
&self.wechat_provider
}
pub fn wechat_pay_client(&self) -> &WechatPayClient {
&self.wechat_pay_client
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn ai_task_service(&self) -> &AiTaskService {
&self.ai_task_service
@@ -600,6 +601,54 @@ impl AppState {
#[cfg(test)]
impl AppState {
pub(crate) fn seed_test_refresh_session_for_user(
&self,
user: &module_auth::AuthUser,
seed: &str,
) -> String {
let session = self
.refresh_session_service()
.create_session(
module_auth::CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: platform_auth::hash_refresh_session_token(&format!(
"test-refresh-token-{seed}"
)),
issued_by_provider: module_auth::AuthLoginMethod::Password,
client_info: module_auth::RefreshSessionClientInfo {
client_type: "web_browser".to_string(),
client_runtime: "test".to_string(),
client_platform: "test".to_string(),
client_instance_id: Some(seed.to_string()),
device_fingerprint: Some(format!("test-device-{seed}")),
device_display_name: "Test Browser".to_string(),
mini_program_app_id: None,
mini_program_env: None,
user_agent: Some("GenarrativeApiServerTest/1.0".to_string()),
ip: Some("127.0.0.1".to_string()),
},
},
OffsetDateTime::now_utc(),
)
.expect("test refresh session should create");
session.session.session_id
}
pub(crate) fn seed_test_refresh_session_for_user_id(
&self,
user_id: &str,
seed: &str,
) -> String {
let user = self
.auth_user_service()
.get_user_by_id(user_id)
.expect("test user lookup should succeed")
.expect("test user should exist");
self.seed_test_refresh_session_for_user(&user, seed)
}
fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) {
*self
.test_creation_entry_config
@@ -801,8 +850,8 @@ fn select_auth_store_restore_candidate(
fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 {
match source {
AuthStoreRestoreSource::SpacetimeSnapshot => 3,
AuthStoreRestoreSource::SpacetimeTables => 2,
AuthStoreRestoreSource::SpacetimeTables => 3,
AuthStoreRestoreSource::SpacetimeSnapshot => 2,
AuthStoreRestoreSource::LocalFile => 1,
}
}
@@ -812,7 +861,7 @@ impl fmt::Display for AppStateInitError {
match self {
Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"),
Self::AuthStore(error) => write!(f, "{error}"),
Self::AuthStore(error) | Self::WechatPay(error) => write!(f, "{error}"),
Self::SmsProvider(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
Self::Llm(error) => write!(f, "{error}"),

View File

@@ -959,7 +959,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_story_battles".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_story_battles"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -1132,7 +1132,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_story_sessions".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_story_sessions"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -121,6 +121,9 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrack
("GET", "/api/auth/sessions") => {
Some(route_spec("auth_sessions_view", "auth", User, "anonymous"))
}
("POST", "/api/auth/sessions/{id}/revoke") => {
Some(route_spec("auth_revoke_session", "auth", User, "anonymous"))
}
("POST", "/api/auth/refresh") => {
Some(route_spec("auth_refresh_success", "auth", Site, "site"))
}

View File

@@ -0,0 +1,780 @@
use std::{fs, path::Path, sync::Arc};
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use bytes::Bytes;
use ring::{
aead,
rand::{SecureRandom, SystemRandom},
signature,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
use shared_kernel::offset_datetime_to_unix_micros;
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::{http_error::AppError, state::AppState};
const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
const WECHAT_PAY_NOTIFY_SUCCESS: &str = "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
#[derive(Clone, Debug)]
pub enum WechatPayClient {
Disabled,
Mock,
Real(Arc<RealWechatPayClient>),
}
#[derive(Clone, Debug)]
pub struct RealWechatPayClient {
client: reqwest::Client,
app_id: String,
mch_id: String,
merchant_serial_no: String,
private_key: Arc<signature::RsaKeyPair>,
platform_public_key_der: Vec<u8>,
platform_serial_no: String,
api_v3_key: String,
notify_url: String,
jsapi_endpoint: String,
}
#[derive(Clone, Debug)]
pub struct WechatMiniProgramOrderRequest {
pub order_id: String,
pub description: String,
pub amount_cents: u64,
pub payer_openid: String,
}
#[derive(Clone, Debug)]
pub struct WechatPayNotifyOrder {
pub out_trade_no: String,
pub transaction_id: Option<String>,
pub trade_state: String,
pub success_time: Option<String>,
}
#[derive(Debug)]
pub enum WechatPayError {
Disabled,
InvalidConfig(String),
InvalidRequest(String),
RequestFailed(String),
Upstream(String),
Deserialize(String),
Crypto(String),
InvalidSignature,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WechatJsapiOrderRequest<'a> {
appid: &'a str,
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
payer: WechatJsapiPayer<'a>,
}
#[derive(Serialize)]
struct WechatJsapiAmount {
total: i64,
currency: &'static str,
}
#[derive(Serialize)]
struct WechatJsapiPayer<'a> {
openid: &'a str,
}
#[derive(Deserialize)]
struct WechatJsapiOrderResponse {
prepay_id: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatPayNotifyBody {
#[serde(default)]
resource: Option<WechatPayNotifyResource>,
}
#[derive(Deserialize)]
struct WechatPayNotifyResource {
ciphertext: String,
nonce: String,
#[serde(default)]
associated_data: Option<String>,
}
#[derive(Deserialize)]
struct WechatPayTransactionResource {
out_trade_no: String,
#[serde(default)]
transaction_id: Option<String>,
trade_state: String,
#[serde(default)]
success_time: Option<String>,
}
impl WechatPayClient {
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
if !config.wechat_pay_enabled {
return Ok(Self::Disabled);
}
if config
.wechat_pay_provider
.trim()
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK)
{
return Ok(Self::Mock);
}
if !config
.wechat_pay_provider
.trim()
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL)
{
return Err(WechatPayError::InvalidConfig(
"WECHAT_PAY_PROVIDER 仅支持 mock 或 real".to_string(),
));
}
let app_id = config
.wechat_mini_program_app_id
.as_ref()
.or(config.wechat_app_id.as_ref())
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))?
.to_string();
let mch_id = required_config(config.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
let merchant_serial_no = required_config(
config.wechat_pay_merchant_serial_no.as_deref(),
"WECHAT_PAY_MERCHANT_SERIAL_NO",
)?;
let private_key_pem = read_private_key_pem(
config.wechat_pay_private_key_pem.as_deref(),
config.wechat_pay_private_key_path.as_deref(),
)?;
let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?);
let platform_public_key_pem = read_pem(
config.wechat_pay_platform_public_key_pem.as_deref(),
config.wechat_pay_platform_public_key_path.as_deref(),
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置",
"读取微信支付平台公钥失败",
)?;
let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?;
let platform_serial_no = required_config(
config.wechat_pay_platform_serial_no.as_deref(),
"WECHAT_PAY_PLATFORM_SERIAL_NO",
)?;
let api_v3_key = required_config(
config.wechat_pay_api_v3_key.as_deref(),
"WECHAT_PAY_API_V3_KEY",
)?;
if api_v3_key.as_bytes().len() != 32 {
return Err(WechatPayError::InvalidConfig(
"WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(),
));
}
let notify_url = required_config(
config.wechat_pay_notify_url.as_deref(),
"WECHAT_PAY_NOTIFY_URL",
)?;
let jsapi_endpoint = normalize_required_url(
&config.wechat_pay_jsapi_endpoint,
"WECHAT_PAY_JSAPI_ENDPOINT",
)?;
Ok(Self::Real(Arc::new(RealWechatPayClient {
client: reqwest::Client::new(),
app_id,
mch_id,
merchant_serial_no,
private_key,
platform_public_key_der,
platform_serial_no,
api_v3_key,
notify_url,
jsapi_endpoint,
})))
}
pub async fn create_mini_program_order(
&self,
request: WechatMiniProgramOrderRequest,
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(build_mock_pay_params(&request.order_id)),
Self::Real(client) => client.create_mini_program_order(request).await,
}
}
pub fn parse_notify(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayNotifyOrder, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => parse_mock_notify(body),
Self::Real(client) => client.parse_notify(headers, body),
}
}
}
impl RealWechatPayClient {
async fn create_mini_program_order(
&self,
request: WechatMiniProgramOrderRequest,
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let body = serde_json::to_string(&WechatJsapiOrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
currency: "CNY",
},
payer: WechatJsapiPayer {
openid: &request.payer_openid,
},
})
.map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?;
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization = self.build_authorization(
"POST",
"/v3/pay/transactions/jsapi",
&timestamp,
&nonce,
&body,
)?;
let response = self
.client
.post(&self.jsapi_endpoint)
.header("Authorization", authorization)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.body(body)
.send()
.await
.map_err(|error| {
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
})?;
let payload =
serde_json::from_str::<WechatJsapiOrderResponse>(&response_text).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应解析失败:{error}"))
})?;
if !status.is_success() {
return Err(WechatPayError::Upstream(format!(
"微信支付 JSAPI 下单失败:{}",
payload
.message
.or(payload.code)
.unwrap_or_else(|| format!("HTTP {status}"))
)));
}
let prepay_id = payload
.prepay_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::Upstream("微信支付未返回 prepay_id".to_string()))?;
self.build_pay_params(&prepay_id)
}
fn build_authorization(
&self,
method: &str,
canonical_url: &str,
timestamp: &str,
nonce: &str,
body: &str,
) -> Result<String, WechatPayError> {
let message = format!("{method}\n{canonical_url}\n{timestamp}\n{nonce}\n{body}\n");
let signature = self.sign_message(&message)?;
Ok(format!(
"{WECHAT_PAY_BODY_SIGNATURE_METHOD} mchid=\"{}\",nonce_str=\"{}\",timestamp=\"{}\",serial_no=\"{}\",signature=\"{}\"",
self.mch_id, nonce, timestamp, self.merchant_serial_no, signature
))
}
fn build_pay_params(
&self,
prepay_id: &str,
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce_str = create_nonce()?;
let package = format!("prepay_id={prepay_id}");
let message = format!(
"{}\n{}\n{}\n{}\n",
self.app_id, time_stamp, nonce_str, package
);
let pay_sign = self.sign_message(&message)?;
Ok(WechatMiniProgramPayParamsResponse {
time_stamp,
nonce_str,
package,
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
pay_sign,
})
}
fn parse_notify(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayNotifyOrder, WechatPayError> {
self.verify_notify_signature(headers, body)?;
let notify = serde_json::from_slice::<WechatPayNotifyBody>(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付通知解析失败:{error}"))
})?;
let resource = notify.resource.ok_or_else(|| {
WechatPayError::InvalidRequest("微信支付通知缺少 resource".to_string())
})?;
let plain_text = decrypt_aes_256_gcm(
self.api_v3_key.as_bytes(),
resource.nonce.as_bytes(),
resource.associated_data.as_deref().unwrap_or("").as_bytes(),
resource.ciphertext.as_str(),
)?;
let transaction = serde_json::from_slice::<WechatPayTransactionResource>(&plain_text)
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付通知资源解析失败:{error}"))
})?;
Ok(WechatPayNotifyOrder {
out_trade_no: transaction.out_trade_no,
transaction_id: transaction
.transaction_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
trade_state: transaction.trade_state,
success_time: transaction.success_time,
})
}
fn verify_notify_signature(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), WechatPayError> {
let timestamp = read_required_header(headers, "Wechatpay-Timestamp")?;
let nonce = read_required_header(headers, "Wechatpay-Nonce")?;
let signature = read_required_header(headers, "Wechatpay-Signature")?;
let serial = read_required_header(headers, "Wechatpay-Serial")?;
if serial != self.platform_serial_no {
return Err(WechatPayError::InvalidSignature);
}
let message = format!(
"{}\n{}\n{}\n",
timestamp,
nonce,
String::from_utf8_lossy(body)
);
let signature_bytes = BASE64_STANDARD
.decode(signature)
.map_err(|_| WechatPayError::InvalidSignature)?;
let public_key = signature::UnparsedPublicKey::new(
&signature::RSA_PKCS1_2048_8192_SHA256,
&self.platform_public_key_der,
);
public_key
.verify(message.as_bytes(), &signature_bytes)
.map_err(|_| WechatPayError::InvalidSignature)
}
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
let rng = SystemRandom::new();
let mut signature = vec![0_u8; self.private_key.public().modulus_len()];
self.private_key
.sign(
&signature::RSA_PKCS1_SHA256,
&rng,
message.as_bytes(),
&mut signature,
)
.map_err(|_| WechatPayError::Crypto("微信支付签名失败".to_string()))?;
Ok(BASE64_STANDARD.encode(signature))
}
}
pub async fn handle_wechat_pay_notify(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<&'static str, AppError> {
let notify = state
.wechat_pay_client()
.parse_notify(&headers, &body)
.map_err(map_wechat_pay_notify_error)?;
if notify.trade_state != "SUCCESS" {
info!(
order_id = notify.out_trade_no.as_str(),
trade_state = notify.trade_state.as_str(),
"收到非成功微信支付通知"
);
return Ok(WECHAT_PAY_NOTIFY_SUCCESS);
}
let paid_at_micros = notify
.success_time
.as_deref()
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros);
state
.spacetime_client()
.mark_profile_recharge_order_paid(
notify.out_trade_no.clone(),
paid_at_micros,
notify.transaction_id.clone(),
)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("确认微信支付订单失败:{error}"))
})?;
info!(
order_id = notify.out_trade_no.as_str(),
"微信支付通知已确认订单入账"
);
Ok(WECHAT_PAY_NOTIFY_SUCCESS)
}
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
match error {
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信支付暂未启用")
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" }))
}
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("微信支付通知签名无效")
.with_details(json!({ "provider": "wechat_pay" })),
}
}
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
crate::state::AppStateInitError::WechatPay(error.to_string())
}
pub fn build_wechat_payment_request(
order_id: String,
product_title: String,
amount_cents: u64,
payer_openid: String,
) -> WechatMiniProgramOrderRequest {
WechatMiniProgramOrderRequest {
order_id,
description: format!("百梦 - {product_title}"),
amount_cents,
payer_openid,
}
}
pub fn current_unix_micros() -> i64 {
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
i64::try_from(value).unwrap_or(i64::MAX)
}
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
warn!(error = %error, "微信支付通知处理失败");
map_wechat_pay_error(error)
}
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce_str = "mock-nonce".to_string();
let package = format!("prepay_id=mock-{order_id}");
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
WechatMiniProgramPayParamsResponse {
time_stamp,
nonce_str,
package,
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
pay_sign,
}
}
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
})?;
Ok(WechatPayNotifyOrder {
out_trade_no: value
.get("outTradeNo")
.or_else(|| value.get("out_trade_no"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string())
})?
.to_string(),
transaction_id: value
.get("transactionId")
.or_else(|| value.get("transaction_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
trade_state: value
.get("tradeState")
.or_else(|| value.get("trade_state"))
.and_then(Value::as_str)
.unwrap_or("SUCCESS")
.to_string(),
success_time: value
.get("successTime")
.or_else(|| value.get("success_time"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
}
fn normalize_required_url(value: &str, key: &str) -> Result<String, WechatPayError> {
let value = value.trim();
if value.starts_with("https://") {
return Ok(value.to_string());
}
Err(WechatPayError::InvalidConfig(format!(
"{key} 必须是 https 地址"
)))
}
fn read_private_key_pem(
inline_pem: Option<&str>,
path: Option<&Path>,
) -> Result<String, WechatPayError> {
read_pem(
inline_pem,
path,
"WECHAT_PAY_PRIVATE_KEY_PEM 或 WECHAT_PAY_PRIVATE_KEY_PATH 未配置",
"读取微信支付私钥失败",
)
}
fn read_pem(
inline_pem: Option<&str>,
path: Option<&Path>,
missing_message: &str,
read_error_prefix: &str,
) -> Result<String, WechatPayError> {
if let Some(value) = inline_pem.map(str::trim).filter(|value| !value.is_empty()) {
return Ok(value.replace("\\n", "\n"));
}
let Some(path) = path else {
return Err(WechatPayError::InvalidConfig(missing_message.to_string()));
};
fs::read_to_string(path).map_err(|error| {
WechatPayError::InvalidConfig(format!("{read_error_prefix}{}{error}", path.display()))
})
}
fn parse_rsa_private_key(pem: &str) -> Result<signature::RsaKeyPair, WechatPayError> {
let (label, der) = parse_single_pem_block(pem)?;
match label.as_str() {
"PRIVATE KEY" => signature::RsaKeyPair::from_pkcs8(&der),
"RSA PRIVATE KEY" => signature::RsaKeyPair::from_der(&der),
_ => {
return Err(WechatPayError::InvalidConfig(
"微信支付私钥必须是 PRIVATE KEY 或 RSA PRIVATE KEY PEM".to_string(),
));
}
}
.map_err(|error| WechatPayError::InvalidConfig(format!("微信支付私钥解析失败:{error}")))
}
fn parse_public_key_pem(pem: &str) -> Result<Vec<u8>, WechatPayError> {
let (label, der) = parse_single_pem_block(pem)?;
if label != "PUBLIC KEY" {
return Err(WechatPayError::InvalidConfig(
"微信支付平台公钥必须是 PUBLIC KEY PEM".to_string(),
));
}
Ok(der)
}
fn parse_single_pem_block(pem: &str) -> Result<(String, Vec<u8>), WechatPayError> {
let mut label: Option<String> = None;
let mut content = String::new();
for line in pem.lines().map(str::trim).filter(|line| !line.is_empty()) {
if let Some(raw_label) = line
.strip_prefix("-----BEGIN ")
.and_then(|value| value.strip_suffix("-----"))
{
label = Some(raw_label.trim().to_string());
continue;
}
if line.starts_with("-----END ") {
break;
}
if label.is_some() {
content.push_str(line);
}
}
let label = label
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付 PEM 缺少 BEGIN 标记".to_string()))?;
let der = BASE64_STANDARD
.decode(content)
.map_err(|_| WechatPayError::InvalidConfig("微信支付 PEM base64 无效".to_string()))?;
if der.is_empty() {
return Err(WechatPayError::InvalidConfig(
"微信支付 PEM 内容为空".to_string(),
));
}
Ok((label, der))
}
fn create_nonce() -> Result<String, WechatPayError> {
let mut bytes = [0_u8; 16];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| WechatPayError::Crypto("生成微信支付 nonce 失败".to_string()))?;
Ok(hex_encode(&bytes))
}
fn decrypt_aes_256_gcm(
key: &[u8],
nonce: &[u8],
associated_data: &[u8],
ciphertext_base64: &str,
) -> Result<Vec<u8>, WechatPayError> {
let mut ciphertext = BASE64_STANDARD
.decode(ciphertext_base64)
.map_err(|_| WechatPayError::Crypto("微信支付通知密文 base64 无效".to_string()))?;
if ciphertext.len() < aead::AES_256_GCM.tag_len() {
return Err(WechatPayError::Crypto(
"微信支付通知密文长度无效".to_string(),
));
}
let nonce = aead::Nonce::try_assume_unique_for_key(nonce)
.map_err(|_| WechatPayError::Crypto("微信支付通知 nonce 长度无效".to_string()))?;
let key = aead::UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| WechatPayError::Crypto("微信支付通知解密 key 无效".to_string()))?;
let plain_text = aead::LessSafeKey::new(key)
.open_in_place(
nonce,
aead::Aad::from(associated_data),
ciphertext.as_mut_slice(),
)
.map_err(|_| WechatPayError::Crypto("微信支付通知认证或解密失败".to_string()))?;
Ok(plain_text.to_vec())
}
fn read_required_header<'a>(
headers: &'a HeaderMap,
name: &'static str,
) -> Result<&'a str, WechatPayError> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(WechatPayError::InvalidSignature)
}
fn hex_sha256(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
hex_encode(&hasher.finalize())
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
impl std::fmt::Display for WechatPayError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => formatter.write_str("微信支付暂未启用"),
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::RequestFailed(message)
| Self::Upstream(message)
| Self::Deserialize(message)
| Self::Crypto(message) => formatter.write_str(message),
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
}
}
}
impl std::error::Error for WechatPayError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_pay_params_use_request_payment_shape() {
let params = build_mock_pay_params("recharge:user:1:points_60");
assert!(!params.time_stamp.is_empty());
assert_eq!(params.sign_type, "RSA");
assert!(params.package.starts_with("prepay_id=mock-"));
assert!(!params.pay_sign.is_empty());
}
#[test]
fn parse_mock_notify_defaults_success_state() {
let notify =
parse_mock_notify(br#"{"outTradeNo":"order-1"}"#).expect("mock notify should parse");
assert_eq!(notify.out_trade_no, "order-1");
assert_eq!(notify.transaction_id, None);
assert_eq!(notify.trade_state, "SUCCESS");
}
}