迁移后端认证与拆分 Spacetime 客户端

This commit is contained in:
2026-04-24 14:10:11 +08:00
parent ef53028be5
commit 4f369617c7
55 changed files with 9206 additions and 343 deletions

View File

@@ -29,10 +29,12 @@
7. 基础 `TraceLayer` 挂载
8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化
9. 接入 `POST /api/auth/entry` 首版密码登录链路
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
11. 接入 `GET /api/auth/me` 当前用户查询链路
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
13. 接入 `POST /api/auth/logout` 当前设备退出链路
10. 接入 `POST /api/auth/password/change` 登录后修改密码链路
11. 接入 `POST /api/auth/password/reset` 手机验证码重置密码链路
12. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
13. 接入 `GET /api/auth/me` 当前用户查询链路
14. 接入 `POST /api/auth/refresh` refresh token 轮换链路
15. 接入 `POST /api/auth/logout` 当前设备退出链路
14. 接入 `POST /api/assets/objects/confirm` 上传完成确认链路
15. 接入 `GET /api/auth/login-options` 登录方式探测链路
16. 接入 `POST /api/auth/phone/send-code` 手机验证码发送链路

View File

@@ -73,6 +73,7 @@ use crate::{
logout::logout,
logout_all::logout_all,
password_entry::password_entry,
password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code},
puzzle::{
advance_puzzle_next_level, create_puzzle_agent_session, drag_puzzle_piece_or_group,
@@ -887,6 +888,14 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route("/api/auth/entry", post(password_entry))
.route(
"/api/auth/password/change",
post(change_password).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/password/reset", post(reset_password))
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。

View File

@@ -66,7 +66,8 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidUsername
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidCredentials => {
| module_auth::PasswordEntryError::InvalidCredentials
| module_auth::PasswordEntryError::UserNotFound => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}

View File

@@ -7,6 +7,7 @@ use platform_llm::{
const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715";
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
@@ -27,6 +28,7 @@ pub struct AppConfig {
pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
pub auth_store_path: PathBuf,
pub sms_auth_enabled: bool,
pub sms_auth_provider: String,
pub sms_endpoint: String,
@@ -109,6 +111,7 @@ impl Default for AppConfig {
refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(),
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
@@ -255,6 +258,10 @@ impl AppConfig {
config.refresh_session_ttl_days = refresh_session_ttl_days;
}
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
config.auth_store_path = PathBuf::from(auth_store_path);
}
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
config.sms_auth_enabled = sms_auth_enabled;
}

View File

@@ -988,10 +988,10 @@ pub async fn execute_custom_world_agent_action(
let result = state
.spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id,
owner_user_id,
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
operation_id: build_prefixed_uuid_id("operation-"),
action,
action: action.clone(),
payload_json: Some(payload_json),
submitted_at_micros,
})
@@ -1179,7 +1179,7 @@ fn log_custom_world_publish_gate_diagnostics(
blocker_codes = %blocker_codes,
has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false),
has_result_preview = session.result_preview.is_some(),
preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(Value::as_str).unwrap_or(""),
preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(serde_json::Value::as_str).unwrap_or(""),
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText"]),
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"),

View File

@@ -1,6 +1,6 @@
use axum::{
extract::{Extension, State},
http::HeaderMap,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::LogoutCurrentSessionInput;
@@ -44,6 +44,13 @@ pub async fn logout(
OffsetDateTime::now_utc(),
)
.map_err(map_logout_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -1,6 +1,6 @@
use axum::{
extract::{Extension, State},
http::HeaderMap,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::LogoutAllSessionsInput;
@@ -32,6 +32,13 @@ pub async fn logout_all(
OffsetDateTime::now_utc(),
)
.map_err(map_logout_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -29,6 +29,7 @@ mod login_options;
mod logout;
mod logout_all;
mod password_entry;
mod password_management;
mod phone_auth;
mod puzzle;
mod puzzle_agent_turn;
@@ -67,7 +68,8 @@ async fn main() -> Result<(), std::io::Error> {
let bind_address = config.bind_socket_addr();
let listener = TcpListener::bind(bind_address).await?;
let state = AppState::new(config)
let state = AppState::try_restore_auth_store_from_spacetime(config)
.await
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
let router = build_router(state);

View File

@@ -36,6 +36,13 @@ pub async fn password_entry(
.map_err(map_password_entry_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
@@ -75,6 +82,9 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
}
PasswordEntryError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
}
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}

View File

@@ -0,0 +1,117 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{ChangePasswordInput, PasswordEntryError, ResetPasswordInput};
use shared_contracts::auth::{
PasswordChangeRequest, PasswordChangeResponse, PasswordResetRequest, PasswordResetResponse,
};
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
phone_auth::map_phone_auth_error,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
pub async fn change_password(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PasswordChangeRequest>,
) -> Result<Json<serde_json::Value>, 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,
})
.await
.map_err(map_password_management_error)?;
Ok(json_success_body(
Some(&request_context),
PasswordChangeResponse {
user: map_auth_user_payload(result.user),
},
))
}
pub async fn reset_password(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Json(payload): Json<PasswordResetRequest>,
) -> Result<impl IntoResponse, AppError> {
if !state.config.sms_auth_enabled {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let result = state
.phone_auth_service()
.reset_password(
ResetPasswordInput {
phone_number: payload.phone,
verify_code: payload.code,
new_password: payload.new_password,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_phone_auth_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
module_auth::AuthLoginMethod::Password,
)?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PasswordResetResponse {
token: signed_session.access_token,
user: map_auth_user_payload(result.user),
},
),
))
}
fn map_password_management_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("密码长度需要在 6 到 128 位之间"),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("当前密码错误")
}
PasswordEntryError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"),
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -153,6 +153,13 @@ pub async fn phone_login(
&session_client,
AuthLoginMethod::Phone,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
@@ -177,6 +184,7 @@ fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppEr
"login" => Ok(PhoneAuthScene::Login),
"bind_phone" => Ok(PhoneAuthScene::BindPhone),
"change_phone" => Ok(PhoneAuthScene::ChangePhone),
"reset_password" => Ok(PhoneAuthScene::ResetPassword),
_ => Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("短信验证码场景不合法")
.with_details(json!({ "field": "scene" }))),
@@ -214,7 +222,7 @@ fn mask_phone_digits(value: &str) -> String {
masked
}
fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
match error {
PhoneAuthError::InvalidPhoneNumber
| PhoneAuthError::InvalidVerifyCode

View File

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

View File

@@ -21,6 +21,7 @@ use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
@@ -37,6 +38,7 @@ pub struct AppState {
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
auth_store: InMemoryAuthStore,
password_entry_service: PasswordEntryService,
refresh_session_service: RefreshSessionService,
auth_user_service: AuthUserService,
@@ -86,6 +88,7 @@ pub struct AdminSession {
pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
AuthStore(String),
SmsProvider(SmsProviderError),
Oss(OssError),
Llm(LlmError),
@@ -93,6 +96,15 @@ pub enum AppStateInitError {
impl AppState {
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Self::new_with_auth_store(config, auth_store)
}
fn new_with_auth_store(
config: AppConfig,
auth_store: InMemoryAuthStore,
) -> Result<Self, AppStateInitError> {
let auth_jwt_config = JwtConfig::new(
config.jwt_issuer.clone(),
config.jwt_secret.clone(),
@@ -111,7 +123,6 @@ impl AppState {
config.refresh_session_ttl_days,
)?;
let oss_client = build_oss_client(&config)?;
let auth_store = InMemoryAuthStore::default();
let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new(
SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| {
SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string())
@@ -141,7 +152,7 @@ impl AppState {
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
let wechat_provider = build_wechat_provider(&config);
let refresh_session_service =
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days);
// AI 编排服务当前先挂接内存态 store后续再按 task table / procedure 接到 SpacetimeDB 真相源。
let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default());
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
@@ -158,6 +169,7 @@ impl AppState {
admin_runtime,
refresh_cookie_config,
oss_client,
auth_store,
password_entry_service,
refresh_session_service,
auth_user_service,
@@ -193,6 +205,49 @@ impl AppState {
&self.password_entry_service
}
pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
let snapshot_json = self
.auth_store
.export_snapshot_json()
.map_err(SpacetimeClientError::Runtime)?;
let updated_at_micros = i64::try_from(
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
self.spacetime_client
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
.await?;
Ok(())
}
pub async fn try_restore_auth_store_from_spacetime(
config: AppConfig,
) -> Result<Self, AppStateInitError> {
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
server_url: config.spacetime_server_url.clone(),
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
pool_size: config.spacetime_pool_size,
});
match spacetime_client.get_auth_store_snapshot().await {
Ok(snapshot) => {
if let Some(snapshot_json) = snapshot.snapshot_json {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("已从 SpacetimeDB 恢复认证快照");
return Self::new_with_auth_store(config, auth_store);
}
}
}
Err(error) => {
warn!(error = %error, "从 SpacetimeDB 恢复认证快照失败,回退到本地快照");
}
}
Self::new(config)
}
pub fn refresh_session_service(&self) -> &RefreshSessionService {
&self.refresh_session_service
}
@@ -392,6 +447,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::SmsProvider(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
Self::Llm(error) => write!(f, "{error}"),

View File

@@ -135,6 +135,13 @@ pub async fn handle_wechat_callback(
&session_client,
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut response = Redirect::to(&build_auth_result_redirect_url(
&redirect_path,
&[
@@ -187,6 +194,13 @@ pub async fn bind_wechat_phone(
&session_client,
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut response_headers = HeaderMap::new();
attach_set_cookie_header(