Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment
# Conflicts: # .hermes/shared-memory/decision-log.md
This commit is contained in:
@@ -237,6 +237,22 @@ pub fn build_system_username(prefix: &str, sequence: u64) -> String {
|
||||
format!("{prefix}_{sequence:08}")
|
||||
}
|
||||
|
||||
pub fn build_wechat_username(display_name: &str, provider_uid: &str) -> String {
|
||||
let normalized_display_name = display_name.trim();
|
||||
let normalized_provider_uid = provider_uid.trim();
|
||||
let fallback_display_name = if normalized_display_name.is_empty() {
|
||||
"微信旅人"
|
||||
} else {
|
||||
normalized_display_name
|
||||
};
|
||||
let fallback_provider_uid = if normalized_provider_uid.is_empty() {
|
||||
"openid"
|
||||
} else {
|
||||
normalized_provider_uid
|
||||
};
|
||||
format!("{fallback_display_name}_{fallback_provider_uid}")
|
||||
}
|
||||
|
||||
// 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
|
||||
pub fn build_public_user_code(sequence: u64) -> String {
|
||||
format!("SY-{sequence:08}")
|
||||
|
||||
@@ -12,8 +12,6 @@ pub use events::*;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
@@ -33,7 +31,6 @@ use tracing::{info, warn};
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryAuthStore {
|
||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||
persistence_path: Option<Arc<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -804,6 +801,21 @@ impl AuthUserService {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub fn ensure_orphan_work_owner_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
username: &str,
|
||||
display_name: &str,
|
||||
public_user_code: &str,
|
||||
) -> Result<AuthUser, PasswordEntryError> {
|
||||
self.store.ensure_orphan_work_owner_user(
|
||||
user_id,
|
||||
username,
|
||||
display_name,
|
||||
public_user_code,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(&self, user_id: &str) -> Result<Option<AuthUser>, LogoutError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
@@ -888,7 +900,6 @@ impl Default for InMemoryAuthStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())),
|
||||
persistence_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -937,14 +948,6 @@ impl InMemoryAuthStoreState {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_temp_persistence_path(path: &Path) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("auth-store.json");
|
||||
path.with_file_name(format!("{file_name}.tmp"))
|
||||
}
|
||||
|
||||
impl InMemoryAuthStore {
|
||||
pub fn from_snapshot_json(snapshot_json: &str) -> Result<Self, String> {
|
||||
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
|
||||
@@ -953,25 +956,6 @@ impl InMemoryAuthStore {
|
||||
inner: Arc::new(Mutex::new(
|
||||
InMemoryAuthStoreState::from_persistent_snapshot(snapshot),
|
||||
)),
|
||||
persistence_path: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_persistence_path(path: impl Into<PathBuf>) -> Result<Self, String> {
|
||||
let path = path.into();
|
||||
let state = if path.is_file() {
|
||||
let raw_text =
|
||||
fs::read_to_string(&path).map_err(|error| format!("读取认证快照失败:{error}"))?;
|
||||
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(&raw_text)
|
||||
.map_err(|error| format!("解析认证快照失败:{error}"))?;
|
||||
InMemoryAuthStoreState::from_persistent_snapshot(snapshot)
|
||||
} else {
|
||||
InMemoryAuthStoreState::default()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(state)),
|
||||
persistence_path: Some(Arc::new(path)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -986,30 +970,8 @@ impl InMemoryAuthStore {
|
||||
}
|
||||
|
||||
fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> {
|
||||
let Some(path) = self.persistence_path.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(parent_dir) = path.parent() {
|
||||
fs::create_dir_all(parent_dir).map_err(|error| {
|
||||
format!(
|
||||
"创建认证快照目录失败:{},路径:{}",
|
||||
error,
|
||||
parent_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let snapshot = state.to_persistent_snapshot();
|
||||
let raw_text = serde_json::to_string_pretty(&snapshot)
|
||||
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
|
||||
let temp_path = build_temp_persistence_path(path);
|
||||
fs::write(&temp_path, raw_text)
|
||||
.map_err(|error| format!("写入认证快照临时文件失败:{error}"))?;
|
||||
fs::rename(&temp_path, path).map_err(|error| {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
format!("替换认证快照文件失败:{error}")
|
||||
})
|
||||
let _ = state;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_password_state(
|
||||
@@ -1051,6 +1013,68 @@ impl InMemoryAuthStore {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn ensure_orphan_work_owner_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
username: &str,
|
||||
display_name: &str,
|
||||
public_user_code: &str,
|
||||
) -> Result<AuthUser, PasswordEntryError> {
|
||||
let user_id = normalize_required_string(user_id).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string())
|
||||
})?;
|
||||
let username = normalize_required_string(username).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string())
|
||||
})?;
|
||||
let display_name = normalize_required_string(display_name).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string())
|
||||
})?;
|
||||
let public_user_code = normalize_required_string(public_user_code).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string())
|
||||
})?;
|
||||
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
if let Some(stored) = state
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == user_id)
|
||||
{
|
||||
return Ok(stored.user.clone());
|
||||
}
|
||||
|
||||
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
|
||||
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let user = AuthUser {
|
||||
id: user_id,
|
||||
public_user_code,
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
phone_number_masked: None,
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
token_version: 1,
|
||||
created_at,
|
||||
};
|
||||
state.users_by_username.insert(
|
||||
username,
|
||||
StoredPasswordUser {
|
||||
user: user.clone(),
|
||||
password_hash: String::new(),
|
||||
password_login_enabled: false,
|
||||
phone_number: None,
|
||||
},
|
||||
);
|
||||
self.persist_password_state(&state)?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn find_by_public_user_code(
|
||||
&self,
|
||||
public_user_code: &str,
|
||||
@@ -1153,7 +1177,7 @@ impl InMemoryAuthStore {
|
||||
PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let user_id = build_prefixed_uuid_id("user_");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("phone", state.next_user_id);
|
||||
@@ -1205,7 +1229,7 @@ impl InMemoryAuthStore {
|
||||
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let user_id = build_prefixed_uuid_id("user_");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("phone", state.next_user_id);
|
||||
@@ -1253,10 +1277,9 @@ impl InMemoryAuthStore {
|
||||
WechatAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let user_id = build_prefixed_uuid_id("user_");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("wechat", state.next_user_id);
|
||||
let avatar_url = normalize_optional_string(profile.avatar_url.clone());
|
||||
let display_name = profile
|
||||
.display_name
|
||||
@@ -1265,6 +1288,7 @@ impl InMemoryAuthStore {
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("微信旅人")
|
||||
.to_string();
|
||||
let username = build_wechat_username(&display_name, &profile.provider_uid);
|
||||
let user = AuthUser {
|
||||
id: user_id.clone(),
|
||||
public_user_code,
|
||||
@@ -2224,6 +2248,18 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_wechat_username_uses_display_name_and_provider_uid() {
|
||||
assert_eq!(
|
||||
build_wechat_username("小明", "wx-openid-123"),
|
||||
"小明_wx-openid-123"
|
||||
);
|
||||
assert_eq!(
|
||||
build_wechat_username(" ", "wx-openid-123"),
|
||||
"微信旅人_wx-openid-123"
|
||||
);
|
||||
}
|
||||
|
||||
fn build_store() -> InMemoryAuthStore {
|
||||
InMemoryAuthStore::default()
|
||||
}
|
||||
@@ -2552,15 +2588,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_store_restores_user_and_refresh_session_after_restart() {
|
||||
let store_path = std::env::temp_dir().join(format!(
|
||||
"genarrative-auth-store-{}.json",
|
||||
new_uuid_simple_string()
|
||||
));
|
||||
let _ = std::fs::remove_file(&store_path);
|
||||
|
||||
let store = InMemoryAuthStore::from_persistence_path(store_path.clone())
|
||||
.expect("persistent store should initialize");
|
||||
async fn snapshot_json_restores_user_and_refresh_session_after_roundtrip() {
|
||||
let store = InMemoryAuthStore::default();
|
||||
let user = create_phone_login_user(store.clone(), "13800138003").await;
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
@@ -2583,10 +2612,12 @@ mod tests {
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("refresh session should be persisted");
|
||||
drop(store);
|
||||
|
||||
let restored_store = InMemoryAuthStore::from_persistence_path(store_path.clone())
|
||||
.expect("persistent store should restore");
|
||||
let snapshot_json = store
|
||||
.export_snapshot_json()
|
||||
.expect("snapshot export should succeed");
|
||||
let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.expect("snapshot json should restore");
|
||||
let restored_user = build_password_service(restored_store.clone())
|
||||
.get_user_by_id(&user.id)
|
||||
.expect("restored user query should succeed")
|
||||
@@ -2604,8 +2635,6 @@ mod tests {
|
||||
)
|
||||
.expect("restored refresh session should rotate");
|
||||
assert_eq!(rotated.user.id, user.id);
|
||||
|
||||
let _ = std::fs::remove_file(&store_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3211,6 +3240,9 @@ mod tests {
|
||||
first_wechat.user.binding_status,
|
||||
AuthBindingStatus::PendingBindPhone
|
||||
);
|
||||
assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first");
|
||||
assert!(first_wechat.user.id.starts_with("user_"));
|
||||
assert!(!first_wechat.user.id.ends_with("00000001"));
|
||||
|
||||
let second_wechat = wechat_service
|
||||
.resolve_login(ResolveWechatLoginInput {
|
||||
@@ -3229,6 +3261,7 @@ mod tests {
|
||||
assert_eq!(second_wechat.user.id, first_wechat.user.id);
|
||||
assert_ne!(second_wechat.user.id, phone_user.id);
|
||||
assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat);
|
||||
assert_eq!(second_wechat.user.username, first_wechat.user.username);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user