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:
kdletters
2026-05-28 00:43:00 +08:00
57 changed files with 2533 additions and 890 deletions

View File

@@ -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}")

View File

@@ -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]