1
This commit is contained in:
@@ -642,13 +642,9 @@ mod tests {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "ai_tasks_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138100", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -659,7 +655,7 @@ mod tests {
|
||||
session_id: "sess_ai_tasks".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("AI 任务用户".to_string()),
|
||||
|
||||
@@ -1060,6 +1060,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
@@ -1077,6 +1078,40 @@ mod tests {
|
||||
|
||||
use super::build_router;
|
||||
|
||||
const TEST_PASSWORD: &str = "secret123";
|
||||
async fn seed_phone_user_with_password(
|
||||
state: &AppState,
|
||||
phone_number: &str,
|
||||
password: &str,
|
||||
) -> module_auth::AuthUser {
|
||||
state
|
||||
.seed_test_phone_user_with_password(phone_number, password)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn password_login_request(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
password: &str,
|
||||
) -> axum::response::Response {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.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")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_legacy_compatible_payload_and_headers() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -1191,24 +1226,17 @@ mod tests {
|
||||
async fn internal_auth_claims_returns_verified_claims() {
|
||||
let config = AppConfig::default();
|
||||
let state = AppState::new(config.clone()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "guest_auth_debug".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
user_id: seed_user.id.clone(),
|
||||
session_id: "sess_auth_debug".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: seed_user.token_version,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
display_name: Some(seed_user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
@@ -1239,17 +1267,14 @@ mod tests {
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["claims"]["sub"],
|
||||
Value::String("user_00000001".to_string())
|
||||
);
|
||||
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"]["ver"],
|
||||
Value::Number(serde_json::Number::from(1))
|
||||
Value::Number(serde_json::Number::from(seed_user.token_version))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1322,26 +1347,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_creates_user_and_sets_refresh_cookie() {
|
||||
async fn password_entry_rejects_unknown_phone_without_registration() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let response = password_login_request(app, "13800138011", TEST_PASSWORD).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138012", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = password_login_request(app, "13800138012", TEST_PASSWORD).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
@@ -1361,9 +1381,10 @@ mod tests {
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
|
||||
assert_eq!(
|
||||
payload["user"]["username"],
|
||||
Value::String("guest_001".to_string())
|
||||
payload["user"]["loginMethod"],
|
||||
Value::String("password".to_string())
|
||||
);
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
}
|
||||
@@ -1400,7 +1421,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
payload["availableLoginMethods"],
|
||||
serde_json::json!(["phone", "wechat"])
|
||||
serde_json::json!(["phone", "password", "wechat"])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2261,7 +2282,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_sessions_returns_multi_device_session_fields() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let first_login_response = app
|
||||
.clone()
|
||||
@@ -2277,8 +2300,8 @@ mod tests {
|
||||
.header("x-client-instance-id", "chrome-instance-001")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_sessions_api",
|
||||
"password": "secret123"
|
||||
"phone": "13800138013",
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -2321,8 +2344,8 @@ mod tests {
|
||||
.header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_sessions_api",
|
||||
"password": "secret123"
|
||||
"phone": "13800138013",
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -2375,27 +2398,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_reuses_same_user_for_same_credentials() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
async fn password_entry_reuses_same_user_for_same_phone() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138014", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let first_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first request should succeed");
|
||||
let first_response =
|
||||
password_login_request(app.clone(), "13800138014", TEST_PASSWORD).await;
|
||||
let first_body = first_response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -2405,23 +2414,7 @@ mod tests {
|
||||
let first_payload: Value =
|
||||
serde_json::from_slice(&first_body).expect("first payload should be json");
|
||||
|
||||
let second_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second request should succeed");
|
||||
let second_response = password_login_request(app, "13800138014", TEST_PASSWORD).await;
|
||||
let second_body = second_response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -2431,54 +2424,23 @@ mod tests {
|
||||
let second_payload: Value =
|
||||
serde_json::from_slice(&second_body).expect("second payload should be json");
|
||||
|
||||
assert_eq!(first_payload["user"]["id"], Value::String(seed_user.id));
|
||||
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_wrong_password() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138015", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("seed request should succeed");
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret999"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let response = password_login_request(app, "13800138015", "secret999").await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_invalid_username() {
|
||||
async fn password_entry_rejects_email_or_username_identifier() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
@@ -2489,8 +2451,8 @@ mod tests {
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "无效用户",
|
||||
"password": "secret123"
|
||||
"phone": "user@example.com",
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -2510,24 +2472,17 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
user_id: seed_user.id.clone(),
|
||||
session_id: "sess_me_query".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: seed_user.token_version,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("guest_001".to_string()),
|
||||
display_name: Some(seed_user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
@@ -2558,13 +2513,10 @@ mod tests {
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["user"]["id"],
|
||||
Value::String("user_00000001".to_string())
|
||||
);
|
||||
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
|
||||
assert_eq!(
|
||||
payload["availableLoginMethods"],
|
||||
serde_json::json!(["phone", "wechat"])
|
||||
serde_json::json!(["phone", "password", "wechat"])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2606,26 +2558,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_session_rotates_cookie_and_returns_new_access_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138017", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_refresh",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138017", TEST_PASSWORD).await;
|
||||
let first_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
@@ -2714,26 +2652,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_clears_cookie_and_invalidates_current_access_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138018", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138018", TEST_PASSWORD).await;
|
||||
let refresh_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
@@ -2802,26 +2726,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138019", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_no_cookie",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -2859,7 +2769,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_all_clears_cookie_and_invalidates_all_sessions() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138020", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let first_login_response = app
|
||||
.clone()
|
||||
@@ -2874,8 +2786,8 @@ mod tests {
|
||||
)
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_all_api",
|
||||
"password": "secret123"
|
||||
"phone": "13800138020",
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -2913,8 +2825,8 @@ mod tests {
|
||||
.header("x-client-instance-id", "logout-all-instance-002")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_all_api",
|
||||
"password": "secret123"
|
||||
"phone": "13800138020",
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -3005,26 +2917,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_all_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138021", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_all_nc",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138021", TEST_PASSWORD).await;
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -3108,26 +3006,11 @@ mod tests {
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138022", TEST_PASSWORD).await;
|
||||
let app = build_router(state.clone());
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_admin_forbidden",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login should succeed");
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138022", TEST_PASSWORD).await;
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
|
||||
@@ -34,6 +34,7 @@ pub async fn auth_me(
|
||||
user: map_auth_user_payload(user.user),
|
||||
available_login_methods: build_available_login_methods(
|
||||
state.config.sms_auth_enabled,
|
||||
true,
|
||||
state.config.wechat_auth_enabled,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr
|
||||
}
|
||||
module_auth::PasswordEntryError::Store(_)
|
||||
| module_auth::PasswordEntryError::PasswordHash(_)
|
||||
| module_auth::PasswordEntryError::InvalidUsername
|
||||
| module_auth::PasswordEntryError::InvalidPhoneNumber
|
||||
| module_auth::PasswordEntryError::InvalidPasswordLength
|
||||
| module_auth::PasswordEntryError::InvalidCredentials
|
||||
| module_auth::PasswordEntryError::UserNotFound => {
|
||||
|
||||
@@ -2537,6 +2537,7 @@ fn map_custom_world_agent_operation_response(
|
||||
phase_detail: operation.phase_detail,
|
||||
progress: operation.progress,
|
||||
error: operation.error_message,
|
||||
started_at: Some(timestamp_micros_to_rfc3339(operation.started_at_micros)),
|
||||
updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,13 +257,9 @@ mod tests {
|
||||
async fn seed_authenticated_state(config: AppConfig) -> AppState {
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "llm_proxy_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138101", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -274,7 +270,7 @@ mod tests {
|
||||
session_id: "sess_llm_proxy".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("LLM 代理用户".to_string()),
|
||||
|
||||
@@ -15,6 +15,7 @@ pub async fn auth_login_options(
|
||||
AuthLoginOptionsResponse {
|
||||
available_login_methods: build_available_login_methods(
|
||||
state.config.sms_auth_enabled,
|
||||
true,
|
||||
state.config.wechat_auth_enabled,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ pub async fn password_entry(
|
||||
let result = state
|
||||
.password_entry_service()
|
||||
.execute(PasswordEntryInput {
|
||||
username: payload.username,
|
||||
phone_number: payload.phone,
|
||||
password: payload.password,
|
||||
})
|
||||
.await
|
||||
@@ -64,10 +64,10 @@ pub async fn password_entry(
|
||||
|
||||
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
PasswordEntryError::InvalidPhoneNumber => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("手机号格式不正确")
|
||||
.with_details(json!({
|
||||
"field": "username",
|
||||
"field": "phone",
|
||||
})),
|
||||
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("密码长度需要在 6 到 128 位之间")
|
||||
@@ -77,7 +77,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("叙世号格式不正确")
|
||||
.with_details(json!({
|
||||
"field": "username",
|
||||
"field": "phone",
|
||||
})),
|
||||
PasswordEntryError::InvalidCredentials => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
|
||||
|
||||
@@ -100,7 +100,7 @@ pub async fn reset_password(
|
||||
|
||||
fn map_password_management_error(error: PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPublicUserCode => {
|
||||
PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
|
||||
}
|
||||
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
|
||||
@@ -422,13 +422,9 @@ mod tests {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "browse_history_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138102", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -439,7 +435,7 @@ mod tests {
|
||||
session_id: "sess_runtime_browse_history".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("浏览历史用户".to_string()),
|
||||
|
||||
@@ -59,7 +59,7 @@ pub async fn stream_runtime_npc_chat_turn(
|
||||
.or_else(|| read_string_field(&payload.encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
let player_message = payload.player_message.trim();
|
||||
if player_message.is_empty() {
|
||||
if player_message.is_empty() && !payload.npc_initiates_conversation {
|
||||
return Err(runtime_chat_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -112,8 +112,11 @@ pub async fn stream_runtime_npc_chat_turn(
|
||||
};
|
||||
|
||||
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
|
||||
let affinity_delta =
|
||||
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count);
|
||||
let affinity_delta = if payload.npc_initiates_conversation {
|
||||
0
|
||||
} else {
|
||||
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count)
|
||||
};
|
||||
let complete_payload = json!({
|
||||
"npcReply": npc_reply,
|
||||
"affinityDelta": affinity_delta,
|
||||
@@ -655,6 +658,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_initiated_opening_keeps_neutral_affinity_delta() {
|
||||
// 首遇主动开场不是玩家发言结算,不能因为空 playerMessage 或占位文本触发好感变化。
|
||||
let npc_initiates_conversation = true;
|
||||
let player_message = "";
|
||||
let npc_reply = "你来了。先别急着走,我正有话想和你说。";
|
||||
let affinity_delta = if npc_initiates_conversation {
|
||||
0
|
||||
} else {
|
||||
compute_npc_chat_affinity_delta(player_message, npc_reply, 0.0)
|
||||
};
|
||||
|
||||
assert_eq!(affinity_delta, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_chat_suggestion_parser_strips_list_markers() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -262,7 +262,11 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
)),
|
||||
combat_context_block,
|
||||
function_options_block,
|
||||
Some(format!("玩家刚刚说:{}", payload.player_message)),
|
||||
if payload.npc_initiates_conversation {
|
||||
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
|
||||
} else {
|
||||
Some(format!("玩家刚刚说:{}", payload.player_message))
|
||||
},
|
||||
Some(format!("NPC 刚刚回复:{npc_reply}")),
|
||||
if is_hostile_model_chat {
|
||||
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())
|
||||
|
||||
@@ -164,13 +164,9 @@ mod tests {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_inventory_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138103", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -181,7 +177,7 @@ mod tests {
|
||||
session_id: "sess_runtime_inventory".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("背包查询用户".to_string()),
|
||||
|
||||
@@ -463,7 +463,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/api/profile/recharge/orders")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"productId":"points_10"}"#))
|
||||
.body(Body::from(r#"{"productId":"points_60"}"#))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
@@ -597,7 +597,12 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
AppState::new(AppConfig::default()).expect("state should build")
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.seed_test_phone_user_with_password("13800138104", "secret123")
|
||||
.await
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
@@ -607,7 +612,7 @@ mod tests {
|
||||
session_id: "sess_runtime_profile".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("资料页用户".to_string()),
|
||||
|
||||
@@ -380,13 +380,9 @@ mod tests {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_save_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138105", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -397,7 +393,7 @@ mod tests {
|
||||
session_id: "sess_runtime_save".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("存档用户".to_string()),
|
||||
|
||||
@@ -340,13 +340,9 @@ mod tests {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_settings_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138106", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -357,7 +353,7 @@ mod tests {
|
||||
session_id: "sess_runtime_settings".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("设置用户".to_string()),
|
||||
|
||||
@@ -1986,13 +1986,9 @@ fn runtime_story_dialogue_current_story_keeps_continue_and_deferred_options() {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_story_state_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138109", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -2003,7 +1999,7 @@ fn issue_access_token(state: &AppState) -> String {
|
||||
session_id: "sess_runtime_story_state".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("运行时剧情状态用户".to_string()),
|
||||
|
||||
@@ -38,6 +38,7 @@ pub struct AppState {
|
||||
admin_runtime: Option<AdminRuntime>,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
oss_client: Option<OssClient>,
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
auth_store: InMemoryAuthStore,
|
||||
password_entry_service: PasswordEntryService,
|
||||
refresh_session_service: RefreshSessionService,
|
||||
@@ -96,6 +97,9 @@ pub enum AppStateInitError {
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
|
||||
#[cfg(test)]
|
||||
let auth_store = InMemoryAuthStore::default();
|
||||
#[cfg(not(test))]
|
||||
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
Self::new_with_auth_store(config, auth_store)
|
||||
@@ -206,19 +210,27 @@ impl AppState {
|
||||
}
|
||||
|
||||
pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
return Ok(());
|
||||
|
||||
#[cfg(not(test))]
|
||||
let snapshot_json = self
|
||||
.auth_store
|
||||
.export_snapshot_json()
|
||||
.map_err(SpacetimeClientError::Runtime)?;
|
||||
#[cfg(not(test))]
|
||||
let updated_at_micros = i64::try_from(
|
||||
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
|
||||
)
|
||||
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
|
||||
#[cfg(not(test))]
|
||||
self.spacetime_client
|
||||
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
|
||||
.await?;
|
||||
// ?????????????????????????????????
|
||||
#[cfg(not(test))]
|
||||
self.spacetime_client.import_auth_store_snapshot().await?;
|
||||
#[cfg(not(test))]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -401,6 +413,47 @@ impl AppState {
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppState {
|
||||
pub(crate) async fn seed_test_phone_user_with_password(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
password: &str,
|
||||
) -> module_auth::AuthUser {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
self.phone_auth_service()
|
||||
.send_code(
|
||||
module_auth::SendPhoneCodeInput {
|
||||
phone_number: phone_number.to_string(),
|
||||
scene: module_auth::PhoneAuthScene::Login,
|
||||
},
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.expect("test phone code should send");
|
||||
let user = self
|
||||
.phone_auth_service()
|
||||
.login(
|
||||
module_auth::PhoneLoginInput {
|
||||
phone_number: phone_number.to_string(),
|
||||
verify_code: "123456".to_string(),
|
||||
},
|
||||
now + time::Duration::seconds(1),
|
||||
)
|
||||
.await
|
||||
.expect("test phone login should create user")
|
||||
.user;
|
||||
let changed = self
|
||||
.password_entry_service()
|
||||
.change_password(module_auth::ChangePasswordInput {
|
||||
user_id: user.id.clone(),
|
||||
current_password: None,
|
||||
new_password: password.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("test password should set");
|
||||
|
||||
changed.user
|
||||
}
|
||||
|
||||
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
|
||||
self.test_runtime_snapshot_store
|
||||
.lock()
|
||||
|
||||
@@ -797,13 +797,9 @@ mod tests {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "story_battles_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138107", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -814,7 +810,7 @@ mod tests {
|
||||
session_id: "sess_story_battles".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("战斗接口用户".to_string()),
|
||||
|
||||
@@ -384,13 +384,9 @@ mod tests {
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "story_sessions_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.seed_test_phone_user_with_password("13800138108", "secret123")
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
.id;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -401,7 +397,7 @@ mod tests {
|
||||
session_id: "sess_story_sessions".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("故事会话用户".to_string()),
|
||||
|
||||
@@ -18,8 +18,6 @@ use shared_kernel::{
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const USERNAME_MIN_LENGTH: usize = 3;
|
||||
const USERNAME_MAX_LENGTH: usize = 24;
|
||||
const PASSWORD_MIN_LENGTH: usize = 6;
|
||||
const PASSWORD_MAX_LENGTH: usize = 128;
|
||||
const SMS_CODE_LENGTH: usize = 6;
|
||||
@@ -65,7 +63,7 @@ pub struct PublicUserSearchResult {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PasswordEntryInput {
|
||||
pub username: String,
|
||||
pub phone_number: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
@@ -315,7 +313,7 @@ pub struct AuthStoreSnapshotProcedureResult {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordEntryError {
|
||||
InvalidUsername,
|
||||
InvalidPhoneNumber,
|
||||
InvalidPasswordLength,
|
||||
InvalidPublicUserCode,
|
||||
InvalidCredentials,
|
||||
@@ -476,27 +474,16 @@ impl PasswordEntryService {
|
||||
input: PasswordEntryInput,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
validate_password(&input.password)?;
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
|
||||
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
|
||||
let Some(existing_user) = self
|
||||
.store
|
||||
.find_by_phone_number_for_password(&normalized_phone.e164)?
|
||||
else {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
};
|
||||
|
||||
// 登录面板现在固定使用手机号作为密码登录标识;先走手机号索引,
|
||||
// 再保留历史用户名路径给开发游客和旧测试数据使用。
|
||||
if let Ok(normalized_phone) = normalize_mainland_china_phone_number(&input.username) {
|
||||
let Some(existing_user) = self
|
||||
.store
|
||||
.find_by_phone_number_for_password(&normalized_phone.e164)?
|
||||
else {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
};
|
||||
|
||||
return verify_stored_password_user(existing_user, &input.password).await;
|
||||
}
|
||||
|
||||
let username = normalize_username(&input.username)?;
|
||||
|
||||
if let Some(existing_user) = self.store.find_by_username(&username)? {
|
||||
return verify_stored_password_user(existing_user, &input.password).await;
|
||||
}
|
||||
|
||||
Err(PasswordEntryError::InvalidCredentials)
|
||||
verify_stored_password_user(existing_user, &input.password).await
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(
|
||||
@@ -1232,17 +1219,6 @@ impl InMemoryAuthStore {
|
||||
.map_err(RefreshSessionError::Store)
|
||||
}
|
||||
|
||||
fn find_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
Ok(state.users_by_username.get(username).cloned())
|
||||
}
|
||||
|
||||
fn find_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -2087,10 +2063,10 @@ impl AuthBindingStatus {
|
||||
impl fmt::Display for PasswordEntryError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
|
||||
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
|
||||
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
|
||||
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
|
||||
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
|
||||
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
|
||||
Self::UserNotFound => f.write_str("用户不存在"),
|
||||
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
|
||||
}
|
||||
@@ -2161,7 +2137,7 @@ impl Error for LogoutError {}
|
||||
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
|
||||
PasswordEntryError::InvalidUsername
|
||||
PasswordEntryError::InvalidPhoneNumber
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidPublicUserCode
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
@@ -2176,7 +2152,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
|
||||
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
|
||||
PasswordEntryError::InvalidUsername
|
||||
PasswordEntryError::InvalidPhoneNumber
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidPublicUserCode
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
@@ -2187,7 +2163,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
|
||||
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => LogoutError::Store(message),
|
||||
PasswordEntryError::InvalidUsername
|
||||
PasswordEntryError::InvalidPhoneNumber
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidPublicUserCode
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
@@ -2215,21 +2191,6 @@ fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||
let username = normalize_required_string(raw_username).unwrap_or_default();
|
||||
let valid_length =
|
||||
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
|
||||
let valid_chars = username
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '_');
|
||||
|
||||
if !valid_length || !valid_chars {
|
||||
return Err(PasswordEntryError::InvalidUsername);
|
||||
}
|
||||
|
||||
Ok(username)
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
||||
let length = password.chars().count();
|
||||
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
|
||||
@@ -2255,7 +2216,10 @@ async fn verify_stored_password_user(
|
||||
}
|
||||
|
||||
Ok(PasswordEntryResult {
|
||||
user: existing_user.user,
|
||||
user: AuthUser {
|
||||
login_method: AuthLoginMethod::Password,
|
||||
..existing_user.user
|
||||
},
|
||||
created: false,
|
||||
})
|
||||
}
|
||||
@@ -2501,7 +2465,7 @@ mod tests {
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
phone_number: "13800138000".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
@@ -2516,7 +2480,7 @@ mod tests {
|
||||
let user = create_phone_login_user(store.clone(), "13800138000").await;
|
||||
let service = build_password_service(store);
|
||||
|
||||
let changed = service
|
||||
service
|
||||
.change_password(ChangePasswordInput {
|
||||
user_id: user.id.clone(),
|
||||
current_password: None,
|
||||
@@ -2526,7 +2490,7 @@ mod tests {
|
||||
.expect("phone user should set first password");
|
||||
let result = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: changed.user.username.clone(),
|
||||
phone_number: "13800138000".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
@@ -2534,7 +2498,7 @@ mod tests {
|
||||
|
||||
assert!(!result.created);
|
||||
assert_eq!(result.user.id, user.id);
|
||||
assert_eq!(result.user.login_method, AuthLoginMethod::Phone);
|
||||
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2553,7 +2517,7 @@ mod tests {
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: user.username,
|
||||
phone_number: "13800138001".to_string(),
|
||||
password: "secret999".to_string(),
|
||||
})
|
||||
.await
|
||||
@@ -2651,18 +2615,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_username_returns_bad_request_error() {
|
||||
async fn password_entry_rejects_email_or_username_identifier() {
|
||||
let service = build_password_service(build_store());
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "坏用户名".to_string(),
|
||||
phone_number: "user@example.com".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("invalid username should fail");
|
||||
.expect_err("email should fail");
|
||||
|
||||
assert_eq!(error, PasswordEntryError::InvalidUsername);
|
||||
assert_eq!(error, PasswordEntryError::InvalidPhoneNumber);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1508,59 +1508,59 @@ impl RuntimeProfileRechargeOrderStatus {
|
||||
|
||||
pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargeProductSnapshot> {
|
||||
vec![
|
||||
build_points_recharge_product(
|
||||
"points_10",
|
||||
"10积分",
|
||||
100,
|
||||
10,
|
||||
19,
|
||||
"首充送积分",
|
||||
"首充送19积分",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_60",
|
||||
"60积分",
|
||||
"60叙世币",
|
||||
600,
|
||||
60,
|
||||
0,
|
||||
"无首充赠礼",
|
||||
"无首充赠送",
|
||||
60,
|
||||
"首充双倍",
|
||||
"首充送60叙世币",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_240",
|
||||
"240积分",
|
||||
2400,
|
||||
240,
|
||||
240,
|
||||
"points_180",
|
||||
"180叙世币",
|
||||
1800,
|
||||
180,
|
||||
180,
|
||||
"首充双倍",
|
||||
"首充送240积分",
|
||||
"首充送180叙世币",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_450",
|
||||
"450积分",
|
||||
4500,
|
||||
450,
|
||||
450,
|
||||
"points_300",
|
||||
"300叙世币",
|
||||
3000,
|
||||
300,
|
||||
300,
|
||||
"首充双倍",
|
||||
"首充送450积分",
|
||||
"首充送300叙世币",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_950",
|
||||
"950积分",
|
||||
9500,
|
||||
950,
|
||||
950,
|
||||
"points_680",
|
||||
"680叙世币",
|
||||
6800,
|
||||
680,
|
||||
680,
|
||||
"首充双倍",
|
||||
"首充送950积分",
|
||||
"首充送680叙世币",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_1980",
|
||||
"1980积分",
|
||||
19800,
|
||||
1980,
|
||||
1980,
|
||||
"points_1280",
|
||||
"1280叙世币",
|
||||
12800,
|
||||
1280,
|
||||
1280,
|
||||
"首充双倍",
|
||||
"首充送1980积分",
|
||||
"首充送1280叙世币",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_3280",
|
||||
"3280叙世币",
|
||||
32800,
|
||||
3280,
|
||||
3280,
|
||||
"首充双倍",
|
||||
"首充送3280叙世币",
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1609,7 +1609,7 @@ pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBene
|
||||
year_value: "¥248".to_string(),
|
||||
},
|
||||
RuntimeProfileMembershipBenefitSnapshot {
|
||||
benefit_name: "免积分回合数".to_string(),
|
||||
benefit_name: "免叙世币回合数".to_string(),
|
||||
normal_value: "30".to_string(),
|
||||
month_value: "100".to_string(),
|
||||
season_value: "100".to_string(),
|
||||
@@ -1970,10 +1970,12 @@ mod tests {
|
||||
let membership_products = runtime_profile_recharge_membership_products();
|
||||
|
||||
assert_eq!(point_products.len(), 6);
|
||||
assert_eq!(point_products[0].product_id, "points_10");
|
||||
assert_eq!(point_products[0].price_cents, 100);
|
||||
assert_eq!(point_products[0].bonus_points, 19);
|
||||
assert_eq!(point_products[5].points_amount, 1980);
|
||||
assert_eq!(point_products[0].product_id, "points_60");
|
||||
assert_eq!(point_products[0].price_cents, 600);
|
||||
assert_eq!(point_products[0].bonus_points, 60);
|
||||
assert_eq!(point_products[5].product_id, "points_3280");
|
||||
assert_eq!(point_products[5].price_cents, 32800);
|
||||
assert_eq!(point_products[5].bonus_points, 3280);
|
||||
assert_eq!(membership_products.len(), 3);
|
||||
assert_eq!(membership_products[0].title, "月卡");
|
||||
assert_eq!(membership_products[0].price_cents, 2800);
|
||||
|
||||
@@ -42,7 +42,7 @@ pub struct PublicUserSearchResponse {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
pub username: String,
|
||||
pub phone: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
@@ -193,12 +193,16 @@ pub struct WechatBindPhoneResponse {
|
||||
|
||||
pub fn build_available_login_methods(
|
||||
sms_auth_enabled: bool,
|
||||
password_auth_enabled: bool,
|
||||
wechat_auth_enabled: bool,
|
||||
) -> Vec<String> {
|
||||
let mut methods = vec![AUTH_LOGIN_METHOD_PASSWORD.to_string()];
|
||||
let mut methods = Vec::new();
|
||||
if sms_auth_enabled {
|
||||
methods.push(AUTH_LOGIN_METHOD_PHONE.to_string());
|
||||
}
|
||||
if password_auth_enabled {
|
||||
methods.push(AUTH_LOGIN_METHOD_PASSWORD.to_string());
|
||||
}
|
||||
if wechat_auth_enabled {
|
||||
methods.push(AUTH_LOGIN_METHOD_WECHAT.to_string());
|
||||
}
|
||||
@@ -212,13 +216,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn available_login_methods_keep_phone_then_wechat_order() {
|
||||
let methods = build_available_login_methods(true, true);
|
||||
let methods = build_available_login_methods(true, true, true);
|
||||
|
||||
assert_eq!(
|
||||
methods,
|
||||
vec![
|
||||
AUTH_LOGIN_METHOD_PASSWORD.to_string(),
|
||||
AUTH_LOGIN_METHOD_PHONE.to_string(),
|
||||
AUTH_LOGIN_METHOD_PASSWORD.to_string(),
|
||||
AUTH_LOGIN_METHOD_WECHAT.to_string()
|
||||
]
|
||||
);
|
||||
@@ -227,7 +231,7 @@ mod tests {
|
||||
#[test]
|
||||
fn password_entry_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(PasswordEntryRequest {
|
||||
username: "guest_001".to_string(),
|
||||
phone: "13800138000".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
@@ -235,7 +239,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"username": "guest_001",
|
||||
"phone": "13800138000",
|
||||
"password": "secret123"
|
||||
})
|
||||
);
|
||||
|
||||
@@ -486,6 +486,7 @@ pub struct CustomWorldAgentOperationResponse {
|
||||
pub phase_detail: String,
|
||||
pub progress: u32,
|
||||
pub error: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -782,15 +783,15 @@ mod tests {
|
||||
updated_at: Some("2026-04-25T10:00:00Z".to_string()),
|
||||
},
|
||||
point_products: vec![ProfileRechargeProductResponse {
|
||||
product_id: "points_10".to_string(),
|
||||
title: "10积分".to_string(),
|
||||
price_cents: 100,
|
||||
product_id: "points_60".to_string(),
|
||||
title: "60叙世币".to_string(),
|
||||
price_cents: 600,
|
||||
kind: "points".to_string(),
|
||||
points_amount: 10,
|
||||
bonus_points: 19,
|
||||
points_amount: 60,
|
||||
bonus_points: 60,
|
||||
duration_days: 0,
|
||||
badge_label: "首充送积分".to_string(),
|
||||
description: "首充送19积分".to_string(),
|
||||
badge_label: "首充双倍".to_string(),
|
||||
description: "首充送60叙世币".to_string(),
|
||||
tier: "normal".to_string(),
|
||||
}],
|
||||
membership_products: vec![],
|
||||
@@ -805,8 +806,8 @@ mod tests {
|
||||
payload["membership"]["expiresAt"],
|
||||
json!("2026-05-25T10:00:00Z")
|
||||
);
|
||||
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_10"));
|
||||
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(100));
|
||||
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60"));
|
||||
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600));
|
||||
assert_eq!(payload["hasPointsRecharged"], json!(false));
|
||||
}
|
||||
|
||||
|
||||
@@ -1932,6 +1932,7 @@ pub(crate) fn map_custom_world_agent_operation_snapshot(
|
||||
phase_detail: snapshot.phase_detail,
|
||||
progress: snapshot.progress,
|
||||
error_message: snapshot.error_message,
|
||||
started_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
@@ -3816,6 +3817,7 @@ pub struct CustomWorldAgentOperationRecord {
|
||||
pub phase_detail: String,
|
||||
pub progress: u32,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -1384,7 +1384,9 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
|
||||
cover_image_src: profile.cover_image_src,
|
||||
cover_asset_id: profile.cover_asset_id,
|
||||
publication_status: profile.publication_status,
|
||||
play_count: profile.play_count,
|
||||
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
|
||||
// 广场消费数据,不能因为重新发布被清零。
|
||||
play_count: existing.play_count.max(profile.play_count),
|
||||
anchor_pack_json: serialize_json(&profile.anchor_pack),
|
||||
publish_ready: profile.publish_ready,
|
||||
created_at: existing.created_at,
|
||||
|
||||
@@ -322,7 +322,7 @@ pub fn get_profile_referral_invite_center(
|
||||
}
|
||||
}
|
||||
|
||||
// 填码绑定、每日邀请者奖励上限和双方积分发放都在同一事务内完成。
|
||||
// 填码绑定、每日邀请者奖励上限和双方叙世币发放都在同一事务内完成。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn redeem_profile_referral_invite_code(
|
||||
ctx: &mut ProcedureContext,
|
||||
|
||||
Reference in New Issue
Block a user