fix: prevent reused account ownership for orphan works
This commit is contained in:
@@ -51,6 +51,7 @@ use crate::{
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
work_author::resolve_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
|
||||
@@ -1015,17 +1016,7 @@ fn resolve_bark_battle_author_display_name_for_record(state: &AppState, value: &
|
||||
}
|
||||
|
||||
fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String {
|
||||
let display_name = if owner_user_id.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(owner_user_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|user| user.display_name)
|
||||
};
|
||||
normalize_author_display_name(display_name)
|
||||
resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
|
||||
}
|
||||
|
||||
fn normalize_author_display_name(display_name: Option<String>) -> String {
|
||||
|
||||
@@ -35,6 +35,9 @@ use crate::puzzle_gallery_cache::PuzzleGalleryCache;
|
||||
use crate::tracking_outbox::TrackingOutbox;
|
||||
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||
use crate::wechat_provider::build_wechat_provider;
|
||||
use crate::work_author::{
|
||||
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
|
||||
};
|
||||
|
||||
const ADMIN_ROLE: &str = "admin";
|
||||
|
||||
@@ -361,6 +364,14 @@ impl AppState {
|
||||
)?)?;
|
||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||
let auth_user_service = AuthUserService::new(auth_store.clone());
|
||||
auth_user_service
|
||||
.ensure_orphan_work_owner_user(
|
||||
ORPHAN_WORK_OWNER_USER_ID,
|
||||
ORPHAN_WORK_OWNER_USER_ID,
|
||||
ORPHAN_WORK_AUTHOR_DISPLAY_NAME,
|
||||
ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE,
|
||||
)
|
||||
.map_err(|error| AppStateInitError::AuthStore(error.to_string()))?;
|
||||
let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
|
||||
let wechat_auth_state_service =
|
||||
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
||||
|
||||
@@ -2,6 +2,10 @@ use module_auth::AuthUser;
|
||||
|
||||
use crate::state::{AppState, PuzzleApiState};
|
||||
|
||||
pub const ORPHAN_WORK_OWNER_USER_ID: &str = "wx-openid-placeholder";
|
||||
pub const ORPHAN_WORK_AUTHOR_DISPLAY_NAME: &str = "失效作者";
|
||||
pub const ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE: &str = "SY-00000000";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WorkAuthorSummary {
|
||||
pub display_name: String,
|
||||
@@ -45,21 +49,15 @@ fn resolve_work_author_by_user_id_with_service(
|
||||
) -> WorkAuthorSummary {
|
||||
let fallback_display_name =
|
||||
normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string());
|
||||
let fallback_public_user_code = normalize_optional_text(fallback_public_user_code);
|
||||
let _fallback_public_user_code = normalize_optional_text(fallback_public_user_code);
|
||||
|
||||
let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else {
|
||||
return WorkAuthorSummary {
|
||||
display_name: fallback_display_name,
|
||||
public_user_code: fallback_public_user_code,
|
||||
};
|
||||
return orphan_work_author_summary();
|
||||
};
|
||||
|
||||
match auth_user_service.get_user_by_id(&owner_user_id) {
|
||||
Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name),
|
||||
Ok(None) | Err(_) => WorkAuthorSummary {
|
||||
display_name: fallback_display_name,
|
||||
public_user_code: fallback_public_user_code,
|
||||
},
|
||||
Ok(None) | Err(_) => orphan_work_author_summary(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,3 +78,65 @@ fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn orphan_work_author_summary() -> WorkAuthorSummary {
|
||||
WorkAuthorSummary {
|
||||
display_name: ORPHAN_WORK_AUTHOR_DISPLAY_NAME.to_string(),
|
||||
public_user_code: Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文注释:运维回填只处理空作者或认证仓储不可再解析的历史 owner_user_id,避免把有效作品误转给占位账号。
|
||||
pub fn should_rebind_orphan_work_owner(
|
||||
auth_user_service: &module_auth::AuthUserService,
|
||||
owner_user_id: &str,
|
||||
) -> bool {
|
||||
let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else {
|
||||
return true;
|
||||
};
|
||||
if owner_user_id == ORPHAN_WORK_OWNER_USER_ID {
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(auth_user_service.get_user_by_id(&owner_user_id), Ok(Some(_)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_auth::{AuthUserService, InMemoryAuthStore};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn orphan_work_author_summary_uses_placeholder_account() {
|
||||
assert_eq!(
|
||||
orphan_work_author_summary(),
|
||||
WorkAuthorSummary {
|
||||
display_name: "失效作者".to_string(),
|
||||
public_user_code: Some("SY-00000000".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_author_resolves_to_placeholder_account() {
|
||||
let service = AuthUserService::new(InMemoryAuthStore::default());
|
||||
|
||||
let author = resolve_work_author_by_user_id_with_service(
|
||||
&service,
|
||||
"user_missing",
|
||||
Some("历史昵称"),
|
||||
Some("SY-00000001"),
|
||||
);
|
||||
|
||||
assert_eq!(author, orphan_work_author_summary());
|
||||
}
|
||||
#[test]
|
||||
fn should_rebind_orphan_work_owner_detects_missing_and_empty_author() {
|
||||
let service = AuthUserService::new(InMemoryAuthStore::default());
|
||||
|
||||
assert!(should_rebind_orphan_work_owner(&service, ""));
|
||||
assert!(should_rebind_orphan_work_owner(&service, "user_missing"));
|
||||
assert!(!should_rebind_orphan_work_owner(&service, ORPHAN_WORK_OWNER_USER_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,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}")
|
||||
|
||||
@@ -800,6 +800,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)
|
||||
@@ -997,6 +1012,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,
|
||||
@@ -1099,7 +1176,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);
|
||||
@@ -1151,7 +1228,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);
|
||||
@@ -1199,10 +1276,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
|
||||
@@ -1211,6 +1287,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,
|
||||
@@ -2164,6 +2241,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()
|
||||
}
|
||||
@@ -3143,6 +3232,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 {
|
||||
@@ -3160,6 +3252,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