Merge branch 'master' of https://git.genarrative.world/GenarrativeAI/Genarrative
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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(),
|
||||
¤t_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
780
server-rs/crates/api-server/src/wechat_pay.rs
Normal file
780
server-rs/crates/api-server/src/wechat_pay.rs
Normal 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",
|
||||
×tamp,
|
||||
&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");
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,12 @@ pub struct ListActiveRefreshSessionsResult {
|
||||
pub sessions: Vec<RefreshSessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RevokeRefreshSessionResult {
|
||||
pub session_id: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionResult {
|
||||
pub user: AuthUser,
|
||||
|
||||
@@ -87,10 +87,17 @@ pub struct RotateRefreshSessionInput {
|
||||
pub next_refresh_token_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RevokeRefreshSessionByUserInput {
|
||||
pub user_id: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionInput {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -118,6 +118,14 @@ pub struct WechatIdentityProfile {
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 已绑定微信身份快照。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WechatIdentityRecord {
|
||||
pub user_id: String,
|
||||
pub provider_uid: String,
|
||||
pub provider_union_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 微信授权 state 快照。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WechatAuthStateRecord {
|
||||
|
||||
@@ -230,6 +230,22 @@ impl PasswordEntryService {
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
input: ChangePasswordInput,
|
||||
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||
self.change_password_internal(input, None).await
|
||||
}
|
||||
|
||||
pub async fn change_password_and_revoke_all_sessions(
|
||||
&self,
|
||||
input: ChangePasswordInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||
self.change_password_internal(input, Some(now)).await
|
||||
}
|
||||
|
||||
async fn change_password_internal(
|
||||
&self,
|
||||
input: ChangePasswordInput,
|
||||
revoke_all_sessions_at: Option<OffsetDateTime>,
|
||||
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||
validate_password(&input.new_password)?;
|
||||
let stored_user = self
|
||||
@@ -257,7 +273,7 @@ impl PasswordEntryService {
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
let user = self
|
||||
.store
|
||||
.set_user_password_hash(&input.user_id, password_hash)?
|
||||
.set_user_password_hash(&input.user_id, password_hash, revoke_all_sessions_at)?
|
||||
.ok_or(PasswordEntryError::UserNotFound)?;
|
||||
|
||||
Ok(ChangePasswordResult { user })
|
||||
@@ -375,6 +391,39 @@ impl RefreshSessionService {
|
||||
let sessions = self.store.list_active_sessions_by_user(user_id, now)?;
|
||||
Ok(ListActiveRefreshSessionsResult { sessions })
|
||||
}
|
||||
|
||||
pub fn revoke_session_by_user_and_session(
|
||||
&self,
|
||||
input: RevokeRefreshSessionByUserInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<RevokeRefreshSessionResult, RefreshSessionError> {
|
||||
self.store
|
||||
.find_by_user_id(&input.user_id)
|
||||
.map_err(map_password_store_error)?
|
||||
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||
|
||||
let Some(session_id) = normalize_required_string(&input.session_id) else {
|
||||
return Err(RefreshSessionError::SessionNotFound);
|
||||
};
|
||||
let revoked =
|
||||
self.store
|
||||
.revoke_session_by_user_and_session_id(&input.user_id, &session_id, now)?;
|
||||
|
||||
Ok(RevokeRefreshSessionResult {
|
||||
session_id,
|
||||
revoked,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_session_active_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, RefreshSessionError> {
|
||||
self.store
|
||||
.is_session_active_for_user(user_id, session_id.trim(), now)
|
||||
}
|
||||
}
|
||||
|
||||
impl PhoneAuthService {
|
||||
@@ -748,6 +797,13 @@ impl WechatAuthService {
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_identity_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<WechatIdentityRecord>, WechatAuthError> {
|
||||
self.store.get_wechat_identity_by_user_id(user_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUserService {
|
||||
@@ -779,7 +835,7 @@ impl AuthUserService {
|
||||
input: LogoutCurrentSessionInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<LogoutCurrentSessionResult, LogoutError> {
|
||||
if let Some(refresh_token_hash) = input
|
||||
let revoked_by_hash = if let Some(refresh_token_hash) = input
|
||||
.refresh_token_hash
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
@@ -788,6 +844,21 @@ impl AuthUserService {
|
||||
self.store
|
||||
.revoke_session_by_refresh_token_hash(refresh_token_hash, now)
|
||||
.map_err(map_refresh_error_to_logout_error)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !revoked_by_hash
|
||||
&& let Some(session_id) = input
|
||||
.session_id
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
self.store
|
||||
.revoke_session_by_user_and_session_id(&input.user_id, session_id, now)
|
||||
.map_err(map_refresh_error_to_logout_error)?;
|
||||
}
|
||||
|
||||
let user = self
|
||||
@@ -1278,6 +1349,29 @@ impl InMemoryAuthStore {
|
||||
.map(|stored| stored.user.clone()))
|
||||
}
|
||||
|
||||
fn get_wechat_identity_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<WechatIdentityRecord>, WechatAuthError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
let Some(identity) = state
|
||||
.wechat_identity_by_provider_uid
|
||||
.values()
|
||||
.find(|identity| identity.user_id == user_id.trim())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(WechatIdentityRecord {
|
||||
user_id: identity.user_id.clone(),
|
||||
provider_uid: identity.provider_uid.clone(),
|
||||
provider_union_id: identity.provider_union_id.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn refresh_wechat_identity_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -1685,6 +1779,36 @@ impl InMemoryAuthStore {
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn is_session_active_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, RefreshSessionError> {
|
||||
if session_id.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(stored) = state.sessions_by_id.get(session_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if stored.session.user_id != user_id || stored.session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let expires_at = OffsetDateTime::parse(
|
||||
&stored.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("会话过期时间解析失败:{error}")))?;
|
||||
|
||||
Ok(expires_at > now)
|
||||
}
|
||||
|
||||
fn rotate_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -1774,6 +1898,37 @@ impl InMemoryAuthStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revoke_session_by_user_and_session_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(stored) = state.sessions_by_id.get_mut(session_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if stored.session.user_id != user_id {
|
||||
return Ok(false);
|
||||
}
|
||||
if stored.session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?;
|
||||
stored.session.revoked_at = Some(now_iso.clone());
|
||||
stored.session.updated_at = now_iso;
|
||||
self.persist_refresh_state(&state)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn revoke_all_sessions_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -1832,11 +1987,21 @@ impl InMemoryAuthStore {
|
||||
&self,
|
||||
user_id: &str,
|
||||
password_hash: String,
|
||||
revoke_all_sessions_at: Option<OffsetDateTime>,
|
||||
) -> Result<Option<AuthUser>, PasswordEntryError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
let revoke_all_sessions_at = match revoke_all_sessions_at {
|
||||
Some(now) => Some(
|
||||
now.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
PasswordEntryError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
for stored_user in state.users_by_username.values_mut() {
|
||||
if stored_user.user.id != user_id {
|
||||
@@ -1847,6 +2012,18 @@ impl InMemoryAuthStore {
|
||||
stored_user.password_login_enabled = true;
|
||||
stored_user.user.token_version += 1;
|
||||
let next_user = stored_user.user.clone();
|
||||
if let Some(now_iso) = revoke_all_sessions_at.as_ref() {
|
||||
for stored_session in state.sessions_by_id.values_mut() {
|
||||
if stored_session.session.user_id != user_id
|
||||
|| stored_session.session.revoked_at.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stored_session.session.revoked_at = Some(now_iso.clone());
|
||||
stored_session.session.updated_at = now_iso.clone();
|
||||
}
|
||||
}
|
||||
self.persist_password_state(&state)?;
|
||||
return Ok(Some(next_user));
|
||||
}
|
||||
@@ -2177,6 +2354,118 @@ mod tests {
|
||||
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_and_revoke_all_sessions_revokes_every_refresh_session() {
|
||||
let store = build_store();
|
||||
let user = create_phone_login_user(store.clone(), "13800138030").await;
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
let first_password_user = password_service
|
||||
.change_password(ChangePasswordInput {
|
||||
user_id: user.id.clone(),
|
||||
current_password: None,
|
||||
new_password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first password should set")
|
||||
.user;
|
||||
let first_token_hash = hash_refresh_session_token("change-password-token-01");
|
||||
let second_token_hash = hash_refresh_session_token("change-password-token-02");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("first session should create");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: second_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "safari".to_string(),
|
||||
device_display_name: "iOS / Safari".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now + Duration::seconds(1),
|
||||
)
|
||||
.expect("second session should create");
|
||||
|
||||
let changed_user = password_service
|
||||
.change_password_and_revoke_all_sessions(
|
||||
ChangePasswordInput {
|
||||
user_id: user.id.clone(),
|
||||
current_password: Some("secret123".to_string()),
|
||||
new_password: "secret456".to_string(),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.await
|
||||
.expect("password change should revoke all sessions")
|
||||
.user;
|
||||
|
||||
assert_eq!(
|
||||
changed_user.token_version,
|
||||
first_password_user.token_version + 1
|
||||
);
|
||||
assert!(
|
||||
refresh_service
|
||||
.list_active_sessions_by_user(&user.id, now + Duration::minutes(2))
|
||||
.expect("active sessions should list")
|
||||
.sessions
|
||||
.is_empty()
|
||||
);
|
||||
for (token_hash, next_hash) in [
|
||||
(
|
||||
first_token_hash,
|
||||
hash_refresh_session_token("change-password-token-01-next"),
|
||||
),
|
||||
(
|
||||
second_token_hash,
|
||||
hash_refresh_session_token("change-password-token-02-next"),
|
||||
),
|
||||
] {
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: token_hash,
|
||||
next_refresh_token_hash: next_hash,
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("revoked session should not rotate");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
password_service
|
||||
.execute(PasswordEntryInput {
|
||||
phone_number: "13800138030".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("old password should fail"),
|
||||
PasswordEntryError::InvalidCredentials
|
||||
);
|
||||
let login = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
phone_number: "13800138030".to_string(),
|
||||
password: "secret456".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("new password should login");
|
||||
assert_eq!(login.user.id, user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_wrong_password_after_set() {
|
||||
let store = build_store();
|
||||
@@ -2524,6 +2813,7 @@ mod tests {
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: Some(refresh_token_hash.clone()),
|
||||
session_id: None,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
@@ -2543,6 +2833,148 @@ mod tests {
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_session_by_user_and_session_revokes_only_target_without_token_bump() {
|
||||
let store = build_store();
|
||||
let user = create_phone_login_user(store.clone(), "13800138028").await;
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let first_token_hash = hash_refresh_session_token("revoke-target-token");
|
||||
let second_token_hash = hash_refresh_session_token("revoke-current-token");
|
||||
|
||||
let target = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("target session should create");
|
||||
let current = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: second_token_hash,
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "firefox".to_string(),
|
||||
device_display_name: "Windows / Firefox".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now + Duration::seconds(1),
|
||||
)
|
||||
.expect("current session should create");
|
||||
|
||||
let revoke = refresh_service
|
||||
.revoke_session_by_user_and_session(
|
||||
RevokeRefreshSessionByUserInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: target.session.session_id.clone(),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.expect("target session should revoke");
|
||||
|
||||
assert!(revoke.revoked);
|
||||
assert_eq!(revoke.session_id, target.session.session_id);
|
||||
assert!(
|
||||
!refresh_service
|
||||
.is_session_active_for_user(
|
||||
&user.id,
|
||||
&target.session.session_id,
|
||||
now + Duration::minutes(2)
|
||||
)
|
||||
.expect("target active check should succeed")
|
||||
);
|
||||
assert!(
|
||||
refresh_service
|
||||
.is_session_active_for_user(
|
||||
&user.id,
|
||||
¤t.session.session_id,
|
||||
now + Duration::minutes(2)
|
||||
)
|
||||
.expect("current active check should succeed")
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.find_by_user_id(&user.id)
|
||||
.expect("user lookup should succeed")
|
||||
.expect("user should exist")
|
||||
.user
|
||||
.token_version,
|
||||
user.token_version
|
||||
);
|
||||
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: first_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("revoke-target-next"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("revoked target should not rotate");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_current_session_uses_session_id_when_refresh_cookie_missing() {
|
||||
let store = build_store();
|
||||
let user = create_phone_login_user(store.clone(), "13800138029").await;
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let user_service = build_user_service(store);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let refresh_token_hash = hash_refresh_session_token("logout-sid-token");
|
||||
let session = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("session should create");
|
||||
|
||||
let result = user_service
|
||||
.logout_current_session(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: None,
|
||||
session_id: Some(session.session.session_id.clone()),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.expect("logout should succeed");
|
||||
|
||||
assert_eq!(result.user.token_version, user.token_version + 1);
|
||||
assert!(
|
||||
!refresh_service
|
||||
.is_session_active_for_user(
|
||||
&user.id,
|
||||
&session.session.session_id,
|
||||
now + Duration::minutes(2)
|
||||
)
|
||||
.expect("session active check should succeed")
|
||||
);
|
||||
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-sid-next"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("sid-revoked session should fail");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
|
||||
let store = build_store();
|
||||
|
||||
@@ -190,8 +190,9 @@ pub fn build_runtime_profile_recharge_order_record(
|
||||
amount_cents: snapshot.amount_cents,
|
||||
status: snapshot.status,
|
||||
payment_channel: snapshot.payment_channel,
|
||||
paid_at: format_utc_micros(snapshot.paid_at_micros),
|
||||
paid_at: snapshot.paid_at_micros.map(format_utc_micros),
|
||||
paid_at_micros: snapshot.paid_at_micros,
|
||||
provider_transaction_id: snapshot.provider_transaction_id,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
points_delta: snapshot.points_delta,
|
||||
|
||||
@@ -265,6 +265,20 @@ pub fn build_runtime_profile_recharge_order_create_input(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_order_paid_input(
|
||||
order_id: String,
|
||||
paid_at_micros: i64,
|
||||
provider_transaction_id: Option<String>,
|
||||
) -> Result<RuntimeProfileRechargeOrderPaidInput, RuntimeProfileFieldError> {
|
||||
let order_id =
|
||||
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
||||
Ok(RuntimeProfileRechargeOrderPaidInput {
|
||||
order_id,
|
||||
paid_at_micros,
|
||||
provider_transaction_id: provider_transaction_id.and_then(normalize_required_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_feedback_submission_input(
|
||||
user_id: String,
|
||||
description: String,
|
||||
|
||||
@@ -33,6 +33,7 @@ pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1;
|
||||
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
|
||||
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
|
||||
pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200;
|
||||
pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40;
|
||||
@@ -951,13 +952,21 @@ impl RuntimeProfileMembershipTier {
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeProfileRechargeOrderStatus {
|
||||
Pending,
|
||||
Paid,
|
||||
Failed,
|
||||
Closed,
|
||||
Refunded,
|
||||
}
|
||||
|
||||
impl RuntimeProfileRechargeOrderStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Paid => "paid",
|
||||
Self::Failed => "failed",
|
||||
Self::Closed => "closed",
|
||||
Self::Refunded => "refunded",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1009,7 +1018,8 @@ pub struct RuntimeProfileRechargeOrderSnapshot {
|
||||
pub amount_cents: u64,
|
||||
pub status: RuntimeProfileRechargeOrderStatus,
|
||||
pub payment_channel: String,
|
||||
pub paid_at_micros: i64,
|
||||
pub paid_at_micros: Option<i64>,
|
||||
pub provider_transaction_id: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub points_delta: i64,
|
||||
pub membership_expires_at_micros: Option<i64>,
|
||||
@@ -1059,6 +1069,14 @@ pub struct RuntimeProfileRechargeOrderCreateInput {
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRechargeOrderPaidInput {
|
||||
pub order_id: String,
|
||||
pub paid_at_micros: i64,
|
||||
pub provider_transaction_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletLedgerEntrySnapshot {
|
||||
@@ -1471,8 +1489,9 @@ pub struct RuntimeProfileRechargeOrderRecord {
|
||||
pub amount_cents: u64,
|
||||
pub status: RuntimeProfileRechargeOrderStatus,
|
||||
pub payment_channel: String,
|
||||
pub paid_at: String,
|
||||
pub paid_at_micros: i64,
|
||||
pub paid_at: Option<String>,
|
||||
pub paid_at_micros: Option<i64>,
|
||||
pub provider_transaction_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub created_at_micros: i64,
|
||||
pub points_delta: i64,
|
||||
|
||||
@@ -72,6 +72,7 @@ pub enum RuntimeProfileFieldError {
|
||||
TaskDisabled,
|
||||
TaskNotClaimable,
|
||||
TaskAlreadyClaimed,
|
||||
MissingOrderId,
|
||||
MissingProductId,
|
||||
MissingWorldKey,
|
||||
MissingBottomTab,
|
||||
@@ -133,6 +134,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::TaskDisabled => f.write_str("任务已停用"),
|
||||
Self::TaskNotClaimable => f.write_str("任务尚未达成"),
|
||||
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
|
||||
Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"),
|
||||
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
|
||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
||||
|
||||
@@ -358,10 +358,13 @@ struct WechatPhoneNumberResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatPhoneNumberInfo {
|
||||
#[serde(default)]
|
||||
#[serde(alias = "phoneNumber")]
|
||||
phone_number: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "purePhoneNumber")]
|
||||
pure_phone_number: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "countryCode")]
|
||||
country_code: Option<String>,
|
||||
}
|
||||
|
||||
@@ -2109,6 +2112,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_phone_number_response_accepts_wechat_camel_case_fields() {
|
||||
let payload = serde_json::from_str::<WechatPhoneNumberResponse>(
|
||||
r#"{
|
||||
"errcode": 0,
|
||||
"errmsg": "ok",
|
||||
"phone_info": {
|
||||
"phoneNumber": "+8613800138000",
|
||||
"purePhoneNumber": "13800138000",
|
||||
"countryCode": "86"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("wechat phone number response should parse");
|
||||
let phone_info = payload.phone_info.expect("phone info should exist");
|
||||
|
||||
assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000"));
|
||||
assert_eq!(
|
||||
phone_info.pure_phone_number.as_deref(),
|
||||
Some("13800138000")
|
||||
);
|
||||
assert_eq!(phone_info.country_code.as_deref(), Some("86"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_wechat_provider_builds_callback_authorization_url() {
|
||||
let provider = WechatProvider::new(WechatAuthConfig::new(
|
||||
|
||||
@@ -114,6 +114,8 @@ pub struct AuthSessionsResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionSummaryPayload {
|
||||
pub session_id: String,
|
||||
pub session_ids: Vec<String>,
|
||||
pub session_count: u32,
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
@@ -144,6 +146,11 @@ pub struct LogoutAllResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RevokeAuthSessionResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeRequest {
|
||||
|
||||
@@ -222,12 +222,23 @@ pub struct ProfileRechargeOrderResponse {
|
||||
pub amount_cents: u64,
|
||||
pub status: String,
|
||||
pub payment_channel: String,
|
||||
pub paid_at: String,
|
||||
pub paid_at: Option<String>,
|
||||
pub provider_transaction_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub points_delta: i64,
|
||||
pub membership_expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatMiniProgramPayParamsResponse {
|
||||
pub time_stamp: String,
|
||||
pub nonce_str: String,
|
||||
pub package: String,
|
||||
pub sign_type: String,
|
||||
pub pay_sign: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileRechargeCenterResponse {
|
||||
@@ -253,6 +264,8 @@ pub struct CreateProfileRechargeOrderRequest {
|
||||
pub struct CreateProfileRechargeOrderResponse {
|
||||
pub order: ProfileRechargeOrderResponse,
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
#[serde(default)]
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -57,6 +57,29 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_auth_store_snapshot_json(
|
||||
&self,
|
||||
snapshot_json: String,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<AuthStoreSnapshotImportRecord, SpacetimeClientError> {
|
||||
let procedure_input = AuthStoreSnapshotUpsertInput {
|
||||
snapshot_json,
|
||||
updated_at_micros,
|
||||
};
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.import_auth_store_snapshot_json_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_import_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_auth_store_snapshot(
|
||||
&self,
|
||||
) -> Result<AuthStoreSnapshotImportRecord, SpacetimeClientError> {
|
||||
|
||||
@@ -176,6 +176,18 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRechargeOrderPaidInput>
|
||||
for RuntimeProfileRechargeOrderPaidInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRechargeOrderPaidInput) -> Self {
|
||||
Self {
|
||||
order_id: input.order_id,
|
||||
paid_at_micros: input.paid_at_micros,
|
||||
provider_transaction_id: input.provider_transaction_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileFeedbackSubmissionInput>
|
||||
for RuntimeProfileFeedbackSubmissionInput
|
||||
{
|
||||
@@ -2217,6 +2229,7 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot(
|
||||
status: map_runtime_profile_recharge_order_status_back(snapshot.status),
|
||||
payment_channel: snapshot.payment_channel,
|
||||
paid_at_micros: snapshot.paid_at_micros,
|
||||
provider_transaction_id: snapshot.provider_transaction_id,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
points_delta: snapshot.points_delta,
|
||||
membership_expires_at_micros: snapshot.membership_expires_at_micros,
|
||||
@@ -5026,9 +5039,21 @@ pub(crate) fn map_runtime_profile_recharge_order_status_back(
|
||||
value: crate::module_bindings::RuntimeProfileRechargeOrderStatus,
|
||||
) -> module_runtime::RuntimeProfileRechargeOrderStatus {
|
||||
match value {
|
||||
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Pending => {
|
||||
module_runtime::RuntimeProfileRechargeOrderStatus::Pending
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid => {
|
||||
module_runtime::RuntimeProfileRechargeOrderStatus::Paid
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Failed => {
|
||||
module_runtime::RuntimeProfileRechargeOrderStatus::Failed
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Closed => {
|
||||
module_runtime::RuntimeProfileRechargeOrderStatus::Closed
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Refunded => {
|
||||
module_runtime::RuntimeProfileRechargeOrderStatus::Refunded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use super::auth_store_projection_meta_type::AuthStoreProjectionMeta;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `auth_store_projection_meta`.
|
||||
///
|
||||
/// Obtain a handle from the [`AuthStoreProjectionMetaTableAccess::auth_store_projection_meta`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.auth_store_projection_meta()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.auth_store_projection_meta().on_insert(...)`.
|
||||
pub struct AuthStoreProjectionMetaTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<AuthStoreProjectionMeta>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `auth_store_projection_meta`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait AuthStoreProjectionMetaTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`AuthStoreProjectionMetaTableHandle`], which mediates access to the table `auth_store_projection_meta`.
|
||||
fn auth_store_projection_meta(&self) -> AuthStoreProjectionMetaTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl AuthStoreProjectionMetaTableAccess for super::RemoteTables {
|
||||
fn auth_store_projection_meta(&self) -> AuthStoreProjectionMetaTableHandle<'_> {
|
||||
AuthStoreProjectionMetaTableHandle {
|
||||
imp: self
|
||||
.imp
|
||||
.get_table::<AuthStoreProjectionMeta>("auth_store_projection_meta"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthStoreProjectionMetaInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct AuthStoreProjectionMetaDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for AuthStoreProjectionMetaTableHandle<'ctx> {
|
||||
type Row = AuthStoreProjectionMeta;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = AuthStoreProjectionMeta> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = AuthStoreProjectionMetaInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> AuthStoreProjectionMetaInsertCallbackId {
|
||||
AuthStoreProjectionMetaInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: AuthStoreProjectionMetaInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = AuthStoreProjectionMetaDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> AuthStoreProjectionMetaDeleteCallbackId {
|
||||
AuthStoreProjectionMetaDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: AuthStoreProjectionMetaDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthStoreProjectionMetaUpdateCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::TableWithPrimaryKey for AuthStoreProjectionMetaTableHandle<'ctx> {
|
||||
type UpdateCallbackId = AuthStoreProjectionMetaUpdateCallbackId;
|
||||
|
||||
fn on_update(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
|
||||
) -> AuthStoreProjectionMetaUpdateCallbackId {
|
||||
AuthStoreProjectionMetaUpdateCallbackId(self.imp.on_update(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_update(&self, callback: AuthStoreProjectionMetaUpdateCallbackId) {
|
||||
self.imp.remove_on_update(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Access to the `meta_id` unique index on the table `auth_store_projection_meta`,
|
||||
/// which allows point queries on the field of the same name
|
||||
/// via the [`AuthStoreProjectionMetaMetaIdUnique::find`] method.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.auth_store_projection_meta().meta_id().find(...)`.
|
||||
pub struct AuthStoreProjectionMetaMetaIdUnique<'ctx> {
|
||||
imp: __sdk::UniqueConstraintHandle<AuthStoreProjectionMeta, String>,
|
||||
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
impl<'ctx> AuthStoreProjectionMetaTableHandle<'ctx> {
|
||||
/// Get a handle on the `meta_id` unique index on the table `auth_store_projection_meta`.
|
||||
pub fn meta_id(&self) -> AuthStoreProjectionMetaMetaIdUnique<'ctx> {
|
||||
AuthStoreProjectionMetaMetaIdUnique {
|
||||
imp: self.imp.get_unique_constraint::<String>("meta_id"),
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> AuthStoreProjectionMetaMetaIdUnique<'ctx> {
|
||||
/// Find the subscribed row whose `meta_id` column value is equal to `col_val`,
|
||||
/// if such a row is present in the client cache.
|
||||
pub fn find(&self, col_val: &String) -> Option<AuthStoreProjectionMeta> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table =
|
||||
client_cache.get_or_make_table::<AuthStoreProjectionMeta>("auth_store_projection_meta");
|
||||
_table.add_unique_constraint::<String>("meta_id", |row| &row.meta_id);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<AuthStoreProjectionMeta>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<AuthStoreProjectionMeta>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `AuthStoreProjectionMeta`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait auth_store_projection_metaQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `AuthStoreProjectionMeta`.
|
||||
fn auth_store_projection_meta(&self) -> __sdk::__query_builder::Table<AuthStoreProjectionMeta>;
|
||||
}
|
||||
|
||||
impl auth_store_projection_metaQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn auth_store_projection_meta(&self) -> __sdk::__query_builder::Table<AuthStoreProjectionMeta> {
|
||||
__sdk::__query_builder::Table::new("auth_store_projection_meta")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AuthStoreProjectionMeta {
|
||||
pub meta_id: String,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AuthStoreProjectionMeta {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `AuthStoreProjectionMeta`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct AuthStoreProjectionMetaCols {
|
||||
pub meta_id: __sdk::__query_builder::Col<AuthStoreProjectionMeta, String>,
|
||||
pub updated_at: __sdk::__query_builder::Col<AuthStoreProjectionMeta, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for AuthStoreProjectionMeta {
|
||||
type Cols = AuthStoreProjectionMetaCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
AuthStoreProjectionMetaCols {
|
||||
meta_id: __sdk::__query_builder::Col::new(table_name, "meta_id"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `AuthStoreProjectionMeta`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct AuthStoreProjectionMetaIxCols {
|
||||
pub meta_id: __sdk::__query_builder::IxCol<AuthStoreProjectionMeta, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for AuthStoreProjectionMeta {
|
||||
type IxCols = AuthStoreProjectionMetaIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
AuthStoreProjectionMetaIxCols {
|
||||
meta_id: __sdk::__query_builder::IxCol::new(table_name, "meta_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for AuthStoreProjectionMeta {}
|
||||
@@ -0,0 +1,59 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::auth_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult;
|
||||
use super::auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct ImportAuthStoreSnapshotJsonArgs {
|
||||
pub input: AuthStoreSnapshotUpsertInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ImportAuthStoreSnapshotJsonArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `import_auth_store_snapshot_json`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait import_auth_store_snapshot_json {
|
||||
fn import_auth_store_snapshot_json(&self, input: AuthStoreSnapshotUpsertInput) {
|
||||
self.import_auth_store_snapshot_json_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn import_auth_store_snapshot_json_then(
|
||||
&self,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotImportProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl import_auth_store_snapshot_json for super::RemoteProcedures {
|
||||
fn import_auth_store_snapshot_json_then(
|
||||
&self,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotImportProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AuthStoreSnapshotImportProcedureResult>(
|
||||
"import_auth_store_snapshot_json",
|
||||
ImportAuthStoreSnapshotJsonArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
||||
use super::runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct MarkProfileRechargeOrderPaidAndReturnArgs {
|
||||
pub input: RuntimeProfileRechargeOrderPaidInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for MarkProfileRechargeOrderPaidAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `mark_profile_recharge_order_paid_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait mark_profile_recharge_order_paid_and_return {
|
||||
fn mark_profile_recharge_order_paid_and_return(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeOrderPaidInput,
|
||||
) {
|
||||
self.mark_profile_recharge_order_paid_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn mark_profile_recharge_order_paid_and_return_then(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeOrderPaidInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl mark_profile_recharge_order_paid_and_return for super::RemoteProcedures {
|
||||
fn mark_profile_recharge_order_paid_and_return_then(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeOrderPaidInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>(
|
||||
"mark_profile_recharge_order_paid_and_return",
|
||||
MarkProfileRechargeOrderPaidAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
@@ -82,6 +82,8 @@ pub mod asset_object_upsert_snapshot_type;
|
||||
pub mod attach_ai_result_reference_and_return_procedure;
|
||||
pub mod auth_identity_table;
|
||||
pub mod auth_identity_type;
|
||||
pub mod auth_store_projection_meta_table;
|
||||
pub mod auth_store_projection_meta_type;
|
||||
pub mod auth_store_snapshot_import_procedure_result_type;
|
||||
pub mod auth_store_snapshot_import_record_type;
|
||||
pub mod auth_store_snapshot_procedure_result_type;
|
||||
@@ -338,6 +340,7 @@ pub mod grant_inventory_item_input_type;
|
||||
pub mod grant_new_user_registration_wallet_reward_procedure;
|
||||
pub mod grant_player_progression_experience_and_return_procedure;
|
||||
pub mod grant_player_progression_experience_reducer;
|
||||
pub mod import_auth_store_snapshot_json_procedure;
|
||||
pub mod import_auth_store_snapshot_procedure;
|
||||
pub mod import_database_migration_from_chunks_procedure;
|
||||
pub mod import_database_migration_from_file_procedure;
|
||||
@@ -367,6 +370,7 @@ pub mod list_puzzle_works_procedure;
|
||||
pub mod list_square_hole_works_procedure;
|
||||
pub mod list_visual_novel_runtime_history_procedure;
|
||||
pub mod list_visual_novel_works_procedure;
|
||||
pub mod mark_profile_recharge_order_paid_and_return_procedure;
|
||||
pub mod match_3_d_agent_message_finalize_input_type;
|
||||
pub mod match_3_d_agent_message_row_type;
|
||||
pub mod match_3_d_agent_message_submit_input_type;
|
||||
@@ -616,6 +620,7 @@ pub mod runtime_profile_recharge_center_get_input_type;
|
||||
pub mod runtime_profile_recharge_center_procedure_result_type;
|
||||
pub mod runtime_profile_recharge_center_snapshot_type;
|
||||
pub mod runtime_profile_recharge_order_create_input_type;
|
||||
pub mod runtime_profile_recharge_order_paid_input_type;
|
||||
pub mod runtime_profile_recharge_order_snapshot_type;
|
||||
pub mod runtime_profile_recharge_order_status_type;
|
||||
pub mod runtime_profile_recharge_product_kind_type;
|
||||
@@ -892,6 +897,8 @@ pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot;
|
||||
pub use attach_ai_result_reference_and_return_procedure::attach_ai_result_reference_and_return;
|
||||
pub use auth_identity_table::*;
|
||||
pub use auth_identity_type::AuthIdentity;
|
||||
pub use auth_store_projection_meta_table::*;
|
||||
pub use auth_store_projection_meta_type::AuthStoreProjectionMeta;
|
||||
pub use auth_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult;
|
||||
pub use auth_store_snapshot_import_record_type::AuthStoreSnapshotImportRecord;
|
||||
pub use auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult;
|
||||
@@ -1148,6 +1155,7 @@ pub use grant_inventory_item_input_type::GrantInventoryItemInput;
|
||||
pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_registration_wallet_reward;
|
||||
pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return;
|
||||
pub use grant_player_progression_experience_reducer::grant_player_progression_experience;
|
||||
pub use import_auth_store_snapshot_json_procedure::import_auth_store_snapshot_json;
|
||||
pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot;
|
||||
pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks;
|
||||
pub use import_database_migration_from_file_procedure::import_database_migration_from_file;
|
||||
@@ -1177,6 +1185,7 @@ pub use list_puzzle_works_procedure::list_puzzle_works;
|
||||
pub use list_square_hole_works_procedure::list_square_hole_works;
|
||||
pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history;
|
||||
pub use list_visual_novel_works_procedure::list_visual_novel_works;
|
||||
pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return;
|
||||
pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput;
|
||||
pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow;
|
||||
pub use match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput;
|
||||
@@ -1426,6 +1435,7 @@ pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCe
|
||||
pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
||||
pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
|
||||
pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput;
|
||||
pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput;
|
||||
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
|
||||
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
||||
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
|
||||
@@ -1908,6 +1918,7 @@ pub struct DbUpdate {
|
||||
asset_event: __sdk::TableUpdate<AssetEvent>,
|
||||
asset_object: __sdk::TableUpdate<AssetObject>,
|
||||
auth_identity: __sdk::TableUpdate<AuthIdentity>,
|
||||
auth_store_projection_meta: __sdk::TableUpdate<AuthStoreProjectionMeta>,
|
||||
auth_store_snapshot: __sdk::TableUpdate<AuthStoreSnapshot>,
|
||||
battle_state: __sdk::TableUpdate<BattleState>,
|
||||
big_fish_agent_message: __sdk::TableUpdate<BigFishAgentMessage>,
|
||||
@@ -2016,6 +2027,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
"auth_identity" => db_update
|
||||
.auth_identity
|
||||
.append(auth_identity_table::parse_table_update(table_update)?),
|
||||
"auth_store_projection_meta" => db_update.auth_store_projection_meta.append(
|
||||
auth_store_projection_meta_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"auth_store_snapshot" => db_update
|
||||
.auth_store_snapshot
|
||||
.append(auth_store_snapshot_table::parse_table_update(table_update)?),
|
||||
@@ -2291,6 +2305,12 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
diff.auth_identity = cache
|
||||
.apply_diff_to_table::<AuthIdentity>("auth_identity", &self.auth_identity)
|
||||
.with_updates_by_pk(|row| &row.identity_id);
|
||||
diff.auth_store_projection_meta = cache
|
||||
.apply_diff_to_table::<AuthStoreProjectionMeta>(
|
||||
"auth_store_projection_meta",
|
||||
&self.auth_store_projection_meta,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.meta_id);
|
||||
diff.auth_store_snapshot = cache
|
||||
.apply_diff_to_table::<AuthStoreSnapshot>(
|
||||
"auth_store_snapshot",
|
||||
@@ -2691,6 +2711,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"auth_identity" => db_update
|
||||
.auth_identity
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"auth_store_projection_meta" => db_update
|
||||
.auth_store_projection_meta
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"auth_store_snapshot" => db_update
|
||||
.auth_store_snapshot
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
@@ -2944,6 +2967,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"auth_identity" => db_update
|
||||
.auth_identity
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"auth_store_projection_meta" => db_update
|
||||
.auth_store_projection_meta
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"auth_store_snapshot" => db_update
|
||||
.auth_store_snapshot
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
@@ -3179,6 +3205,7 @@ pub struct AppliedDiff<'r> {
|
||||
asset_event: __sdk::TableAppliedDiff<'r, AssetEvent>,
|
||||
asset_object: __sdk::TableAppliedDiff<'r, AssetObject>,
|
||||
auth_identity: __sdk::TableAppliedDiff<'r, AuthIdentity>,
|
||||
auth_store_projection_meta: __sdk::TableAppliedDiff<'r, AuthStoreProjectionMeta>,
|
||||
auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>,
|
||||
battle_state: __sdk::TableAppliedDiff<'r, BattleState>,
|
||||
big_fish_agent_message: __sdk::TableAppliedDiff<'r, BigFishAgentMessage>,
|
||||
@@ -3305,6 +3332,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
&self.auth_identity,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<AuthStoreProjectionMeta>(
|
||||
"auth_store_projection_meta",
|
||||
&self.auth_store_projection_meta,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<AuthStoreSnapshot>(
|
||||
"auth_store_snapshot",
|
||||
&self.auth_store_snapshot,
|
||||
@@ -4313,6 +4345,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
asset_event_table::register_table(client_cache);
|
||||
asset_object_table::register_table(client_cache);
|
||||
auth_identity_table::register_table(client_cache);
|
||||
auth_store_projection_meta_table::register_table(client_cache);
|
||||
auth_store_snapshot_table::register_table(client_cache);
|
||||
battle_state_table::register_table(client_cache);
|
||||
big_fish_agent_message_table::register_table(client_cache);
|
||||
@@ -4395,6 +4428,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
"asset_event",
|
||||
"asset_object",
|
||||
"auth_identity",
|
||||
"auth_store_projection_meta",
|
||||
"auth_store_snapshot",
|
||||
"battle_state",
|
||||
"big_fish_agent_message",
|
||||
|
||||
@@ -18,7 +18,8 @@ pub struct ProfileRechargeOrder {
|
||||
pub amount_cents: u64,
|
||||
pub status: RuntimeProfileRechargeOrderStatus,
|
||||
pub payment_channel: String,
|
||||
pub paid_at: __sdk::Timestamp,
|
||||
pub paid_at: Option<__sdk::Timestamp>,
|
||||
pub provider_transaction_id: Option<String>,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub points_delta: i64,
|
||||
pub membership_expires_at: Option<__sdk::Timestamp>,
|
||||
@@ -41,7 +42,8 @@ pub struct ProfileRechargeOrderCols {
|
||||
pub status:
|
||||
__sdk::__query_builder::Col<ProfileRechargeOrder, RuntimeProfileRechargeOrderStatus>,
|
||||
pub payment_channel: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
|
||||
pub paid_at: __sdk::__query_builder::Col<ProfileRechargeOrder, __sdk::Timestamp>,
|
||||
pub paid_at: __sdk::__query_builder::Col<ProfileRechargeOrder, Option<__sdk::Timestamp>>,
|
||||
pub provider_transaction_id: __sdk::__query_builder::Col<ProfileRechargeOrder, Option<String>>,
|
||||
pub created_at: __sdk::__query_builder::Col<ProfileRechargeOrder, __sdk::Timestamp>,
|
||||
pub points_delta: __sdk::__query_builder::Col<ProfileRechargeOrder, i64>,
|
||||
pub membership_expires_at:
|
||||
@@ -61,6 +63,10 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeOrder {
|
||||
status: __sdk::__query_builder::Col::new(table_name, "status"),
|
||||
payment_channel: __sdk::__query_builder::Col::new(table_name, "payment_channel"),
|
||||
paid_at: __sdk::__query_builder::Col::new(table_name, "paid_at"),
|
||||
provider_transaction_id: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"provider_transaction_id",
|
||||
),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
points_delta: __sdk::__query_builder::Col::new(table_name, "points_delta"),
|
||||
membership_expires_at: __sdk::__query_builder::Col::new(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRechargeOrderPaidInput {
|
||||
pub order_id: String,
|
||||
pub paid_at_micros: i64,
|
||||
pub provider_transaction_id: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRechargeOrderPaidInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -18,7 +18,8 @@ pub struct RuntimeProfileRechargeOrderSnapshot {
|
||||
pub amount_cents: u64,
|
||||
pub status: RuntimeProfileRechargeOrderStatus,
|
||||
pub payment_channel: String,
|
||||
pub paid_at_micros: i64,
|
||||
pub paid_at_micros: Option<i64>,
|
||||
pub provider_transaction_id: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub points_delta: i64,
|
||||
pub membership_expires_at_micros: Option<i64>,
|
||||
|
||||
@@ -8,7 +8,15 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
#[sats(crate = __lib)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
pub enum RuntimeProfileRechargeOrderStatus {
|
||||
Pending,
|
||||
|
||||
Paid,
|
||||
|
||||
Failed,
|
||||
|
||||
Closed,
|
||||
|
||||
Refunded,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRechargeOrderStatus {
|
||||
|
||||
@@ -268,6 +268,42 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_profile_recharge_order_paid(
|
||||
&self,
|
||||
order_id: String,
|
||||
paid_at_micros: i64,
|
||||
provider_transaction_id: Option<String>,
|
||||
) -> Result<
|
||||
(
|
||||
RuntimeProfileRechargeCenterRecord,
|
||||
RuntimeProfileRechargeOrderRecord,
|
||||
),
|
||||
SpacetimeClientError,
|
||||
> {
|
||||
let procedure_input = module_runtime::build_runtime_profile_recharge_order_paid_input(
|
||||
order_id,
|
||||
paid_at_micros,
|
||||
provider_transaction_id,
|
||||
)
|
||||
.map_err(SpacetimeClientError::validation_failed)?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.mark_profile_recharge_order_paid_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_runtime_profile_recharge_order_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn submit_profile_feedback(
|
||||
&self,
|
||||
user_id: String,
|
||||
|
||||
@@ -7,12 +7,14 @@ use super::{
|
||||
sanitize_identity_component,
|
||||
},
|
||||
tables::{
|
||||
AuthIdentity, AuthStoreSnapshot, RefreshSession, UserAccount, auth_identity,
|
||||
auth_store_snapshot, refresh_session, user_account,
|
||||
AuthIdentity, AuthStoreProjectionMeta, AuthStoreSnapshot, RefreshSession, UserAccount,
|
||||
auth_identity, auth_store_projection_meta, auth_store_snapshot, refresh_session,
|
||||
user_account,
|
||||
},
|
||||
};
|
||||
|
||||
const AUTH_STORE_SNAPSHOT_ID: &str = "default";
|
||||
const AUTH_STORE_PROJECTION_META_ID: &str = "default";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct AuthStoreSnapshotRecord {
|
||||
@@ -70,7 +72,7 @@ pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotP
|
||||
}
|
||||
}
|
||||
|
||||
// Axum 每次鉴权仓储变更后覆盖写入整份快照,后续拆表阶段再替换为细粒度 reducer。
|
||||
// 历史迁移入口:覆盖写入整份快照,供旧库从 `auth_store_snapshot/default` 导入正式表。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn upsert_auth_store_snapshot(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -90,6 +92,26 @@ pub fn upsert_auth_store_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
// Axum 运行期认证变更直接导入正式认证表,不再继续刷新 `auth_store_snapshot/default`。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn import_auth_store_snapshot_json(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
) -> AuthStoreSnapshotImportProcedureResult {
|
||||
match ctx.try_with_tx(|tx| import_auth_store_snapshot_json_tx(tx, input.clone())) {
|
||||
Ok(record) => AuthStoreSnapshotImportProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AuthStoreSnapshotImportProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn import_auth_store_snapshot(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -191,10 +213,35 @@ fn import_auth_store_snapshot_tx(
|
||||
.snapshot_id()
|
||||
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
|
||||
.ok_or_else(|| "认证快照不存在,无法导入正式表".to_string())?;
|
||||
let parsed = serde_json::from_str::<PersistentAuthStoreSnapshot>(&snapshot.snapshot_json)
|
||||
|
||||
import_auth_store_snapshot_json_value_tx(
|
||||
ctx,
|
||||
&snapshot.snapshot_json,
|
||||
snapshot.updated_at.to_micros_since_unix_epoch(),
|
||||
)
|
||||
}
|
||||
|
||||
fn import_auth_store_snapshot_json_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
) -> Result<AuthStoreSnapshotImportRecord, String> {
|
||||
import_auth_store_snapshot_json_value_tx(ctx, &input.snapshot_json, input.updated_at_micros)
|
||||
}
|
||||
|
||||
fn import_auth_store_snapshot_json_value_tx(
|
||||
ctx: &ReducerContext,
|
||||
snapshot_json: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<AuthStoreSnapshotImportRecord, String> {
|
||||
let snapshot_json = snapshot_json.trim();
|
||||
if snapshot_json.is_empty() {
|
||||
return Err("认证快照 JSON 不能为空".to_string());
|
||||
}
|
||||
let parsed = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
|
||||
.map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?;
|
||||
|
||||
clear_auth_target_tables(ctx);
|
||||
upsert_auth_projection_meta(ctx, updated_at_micros);
|
||||
|
||||
let mut imported_user_count = 0_u32;
|
||||
let mut imported_identity_count = 0_u32;
|
||||
@@ -293,6 +340,12 @@ fn export_auth_store_snapshot_from_tables_tx(
|
||||
updated_at_micros: None,
|
||||
});
|
||||
}
|
||||
let updated_at_micros = ctx
|
||||
.db
|
||||
.auth_store_projection_meta()
|
||||
.meta_id()
|
||||
.find(&AUTH_STORE_PROJECTION_META_ID.to_string())
|
||||
.map(|row| row.updated_at.to_micros_since_unix_epoch());
|
||||
|
||||
let mut phone_identity_by_user_id = std::collections::HashMap::new();
|
||||
let mut phone_to_user_id = std::collections::HashMap::new();
|
||||
@@ -407,7 +460,7 @@ fn export_auth_store_snapshot_from_tables_tx(
|
||||
|
||||
Ok(AuthStoreSnapshotRecord {
|
||||
snapshot_json: Some(snapshot_json),
|
||||
updated_at_micros: None,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -428,3 +481,25 @@ fn clear_auth_target_tables(ctx: &ReducerContext) {
|
||||
ctx.db.user_account().user_id().delete(&row.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) {
|
||||
let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string();
|
||||
if ctx
|
||||
.db
|
||||
.auth_store_projection_meta()
|
||||
.meta_id()
|
||||
.find(&meta_id)
|
||||
.is_some()
|
||||
{
|
||||
ctx.db
|
||||
.auth_store_projection_meta()
|
||||
.meta_id()
|
||||
.delete(&meta_id);
|
||||
}
|
||||
ctx.db
|
||||
.auth_store_projection_meta()
|
||||
.insert(AuthStoreProjectionMeta {
|
||||
meta_id,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ pub struct AuthStoreSnapshot {
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = auth_store_projection_meta)]
|
||||
pub struct AuthStoreProjectionMeta {
|
||||
#[primary_key]
|
||||
pub(crate) meta_id: String,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = user_account,
|
||||
index(accessor = by_user_account_username, btree(columns = [username])),
|
||||
|
||||
@@ -158,6 +158,7 @@ macro_rules! migration_tables {
|
||||
$macro_name! {
|
||||
$($arg,)*
|
||||
auth_store_snapshot,
|
||||
auth_store_projection_meta,
|
||||
user_account,
|
||||
auth_identity,
|
||||
refresh_session,
|
||||
@@ -1151,6 +1152,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
|
||||
}
|
||||
}
|
||||
if table_name == "profile_recharge_order" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:真实微信支付接入后才有平台交易号,旧迁移包按未回填处理。
|
||||
object
|
||||
.entry("provider_transaction_id".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
if table_name == "big_fish_creation_session" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
||||
|
||||
@@ -336,6 +336,7 @@ pub struct ProfileMembership {
|
||||
btree(columns = [user_id, created_at])
|
||||
)
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
pub struct ProfileRechargeOrder {
|
||||
#[primary_key]
|
||||
pub(crate) order_id: String,
|
||||
@@ -346,7 +347,10 @@ pub struct ProfileRechargeOrder {
|
||||
pub(crate) amount_cents: u64,
|
||||
pub(crate) status: RuntimeProfileRechargeOrderStatus,
|
||||
pub(crate) payment_channel: String,
|
||||
pub(crate) paid_at: Timestamp,
|
||||
#[default(None::<Timestamp>)]
|
||||
pub(crate) paid_at: Option<Timestamp>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) provider_transaction_id: Option<String>,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) points_delta: i64,
|
||||
pub(crate) membership_expires_at: Option<Timestamp>,
|
||||
@@ -767,7 +771,6 @@ pub fn get_profile_recharge_center(
|
||||
}
|
||||
}
|
||||
|
||||
// 当前阶段没有真实支付网关,下单后在服务端模拟支付成功并立即写入权益。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_profile_recharge_order_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -789,6 +792,27 @@ pub fn create_profile_recharge_order_and_return(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn mark_profile_recharge_order_paid_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeOrderPaidInput,
|
||||
) -> RuntimeProfileRechargeCenterProcedureResult {
|
||||
match ctx.try_with_tx(|tx| mark_profile_recharge_order_paid_record(tx, input.clone())) {
|
||||
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
order: Some(order),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
order: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn submit_profile_feedback_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -1409,6 +1433,12 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() {
|
||||
assert!(should_skip_existing_tracking_event_id(true));
|
||||
assert!(!should_skip_existing_tracking_event_id(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_public_work_play_counts_group_requested_profiles_in_window() {
|
||||
let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10;
|
||||
@@ -2043,36 +2073,24 @@ fn create_profile_recharge_order_record(
|
||||
let product = runtime_profile_recharge_product_by_id(&validated_input.product_id)
|
||||
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
|
||||
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
|
||||
|
||||
let (points_delta, membership_expires_at) = match product.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => {
|
||||
let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id);
|
||||
let points_delta =
|
||||
resolve_runtime_profile_points_recharge_delta(&product, has_recharged);
|
||||
apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
points_delta,
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
|
||||
&build_runtime_profile_recharge_wallet_ledger_id(
|
||||
&validated_input.user_id,
|
||||
validated_input.created_at_micros,
|
||||
&product.product_id,
|
||||
),
|
||||
created_at,
|
||||
)?;
|
||||
(points_delta as i64, None)
|
||||
}
|
||||
RuntimeProfileRechargeProductKind::Membership => {
|
||||
let expires_at = apply_profile_membership_purchase(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
product.tier,
|
||||
product.duration_days,
|
||||
created_at,
|
||||
);
|
||||
(0, Some(expires_at))
|
||||
}
|
||||
let should_settle_immediately =
|
||||
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
|
||||
let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately {
|
||||
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
&product,
|
||||
validated_input.created_at_micros,
|
||||
created_at,
|
||||
)?;
|
||||
(
|
||||
RuntimeProfileRechargeOrderStatus::Paid,
|
||||
Some(created_at),
|
||||
points_delta,
|
||||
membership_expires_at,
|
||||
)
|
||||
} else {
|
||||
(RuntimeProfileRechargeOrderStatus::Pending, None, 0, None)
|
||||
};
|
||||
|
||||
let order = ProfileRechargeOrder {
|
||||
@@ -2086,9 +2104,10 @@ fn create_profile_recharge_order_record(
|
||||
product_title: product.title.clone(),
|
||||
kind: product.kind,
|
||||
amount_cents: product.price_cents,
|
||||
status: RuntimeProfileRechargeOrderStatus::Paid,
|
||||
status,
|
||||
payment_channel: validated_input.payment_channel,
|
||||
paid_at: created_at,
|
||||
paid_at,
|
||||
provider_transaction_id: None,
|
||||
created_at,
|
||||
points_delta,
|
||||
membership_expires_at,
|
||||
@@ -2103,6 +2122,106 @@ fn create_profile_recharge_order_record(
|
||||
))
|
||||
}
|
||||
|
||||
fn mark_profile_recharge_order_paid_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeOrderPaidInput,
|
||||
) -> Result<
|
||||
(
|
||||
RuntimeProfileRechargeCenterSnapshot,
|
||||
RuntimeProfileRechargeOrderSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let validated_input = build_runtime_profile_recharge_order_paid_input(
|
||||
input.order_id,
|
||||
input.paid_at_micros,
|
||||
input.provider_transaction_id,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut order = ctx
|
||||
.db
|
||||
.profile_recharge_order()
|
||||
.order_id()
|
||||
.find(&validated_input.order_id)
|
||||
.ok_or_else(|| "profile_recharge_order 不存在".to_string())?;
|
||||
|
||||
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
||||
return Ok((
|
||||
build_profile_recharge_center_snapshot(ctx, &order.user_id),
|
||||
build_profile_recharge_order_snapshot_from_row(&order),
|
||||
));
|
||||
}
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
return Err("profile_recharge_order 当前状态不能确认支付".to_string());
|
||||
}
|
||||
|
||||
let product = runtime_profile_recharge_product_by_id(&order.product_id)
|
||||
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
|
||||
let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros);
|
||||
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
|
||||
ctx,
|
||||
&order.user_id,
|
||||
&product,
|
||||
order.created_at.to_micros_since_unix_epoch(),
|
||||
paid_at,
|
||||
)?;
|
||||
|
||||
ctx.db
|
||||
.profile_recharge_order()
|
||||
.order_id()
|
||||
.delete(&order.order_id);
|
||||
order.status = RuntimeProfileRechargeOrderStatus::Paid;
|
||||
order.paid_at = Some(paid_at);
|
||||
order.provider_transaction_id = validated_input.provider_transaction_id;
|
||||
order.points_delta = points_delta;
|
||||
order.membership_expires_at = membership_expires_at;
|
||||
ctx.db.profile_recharge_order().insert(order.clone());
|
||||
|
||||
Ok((
|
||||
build_profile_recharge_center_snapshot(ctx, &order.user_id),
|
||||
build_profile_recharge_order_snapshot_from_row(&order),
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_profile_recharge_purchase(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
product: &RuntimeProfileRechargeProductSnapshot,
|
||||
order_created_at_micros: i64,
|
||||
paid_at: Timestamp,
|
||||
) -> Result<(i64, Option<Timestamp>), String> {
|
||||
match product.kind {
|
||||
RuntimeProfileRechargeProductKind::Points => {
|
||||
let has_recharged = has_profile_points_recharged(ctx, user_id);
|
||||
let points_delta =
|
||||
resolve_runtime_profile_points_recharge_delta(product, has_recharged);
|
||||
apply_profile_wallet_delta(
|
||||
ctx,
|
||||
user_id,
|
||||
points_delta,
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
|
||||
&build_runtime_profile_recharge_wallet_ledger_id(
|
||||
user_id,
|
||||
order_created_at_micros,
|
||||
&product.product_id,
|
||||
),
|
||||
paid_at,
|
||||
)?;
|
||||
Ok((points_delta as i64, None))
|
||||
}
|
||||
RuntimeProfileRechargeProductKind::Membership => {
|
||||
let expires_at = apply_profile_membership_purchase(
|
||||
ctx,
|
||||
user_id,
|
||||
product.tier,
|
||||
product.duration_days,
|
||||
paid_at,
|
||||
);
|
||||
Ok((0, Some(expires_at)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_profile_feedback_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileFeedbackSubmissionInput,
|
||||
@@ -3223,6 +3342,10 @@ fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Res
|
||||
)
|
||||
}
|
||||
|
||||
fn should_skip_existing_tracking_event_id(event_exists: bool) -> bool {
|
||||
event_exists
|
||||
}
|
||||
|
||||
fn record_tracking_event(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeTrackingEventInput,
|
||||
@@ -3242,6 +3365,15 @@ fn record_tracking_event(
|
||||
.map_err(|error| error.to_string())?;
|
||||
let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros);
|
||||
let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros);
|
||||
if should_skip_existing_tracking_event_id(
|
||||
ctx.db
|
||||
.tracking_event()
|
||||
.event_id()
|
||||
.find(&validated_input.event_id)
|
||||
.is_some(),
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
// 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。
|
||||
ensure_analytics_date_dimension_row(ctx, day_key)?;
|
||||
ctx.db.tracking_event().insert(TrackingEvent {
|
||||
@@ -3726,7 +3858,8 @@ fn build_profile_recharge_order_snapshot_from_row(
|
||||
amount_cents: row.amount_cents,
|
||||
status: row.status,
|
||||
payment_channel: row.payment_channel.clone(),
|
||||
paid_at_micros: row.paid_at.to_micros_since_unix_epoch(),
|
||||
paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()),
|
||||
provider_transaction_id: row.provider_transaction_id.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
points_delta: row.points_delta,
|
||||
membership_expires_at_micros: row
|
||||
|
||||
Reference in New Issue
Block a user