From 48dd96d5cd21b8cad1d46e29063c5586435f97ee Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 27 May 2026 22:44:01 +0800 Subject: [PATCH] fix: prevent reused account ownership for orphan works --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 21 ++- scripts/rebind-orphan-work-owners.mjs | 177 ++++++++++++++++++ scripts/rebind-orphan-work-owners.test.ts | 42 +++++ .../crates/api-server/src/bark_battle.rs | 13 +- server-rs/crates/api-server/src/state.rs | 11 ++ .../crates/api-server/src/work_author.rs | 78 +++++++- server-rs/crates/module-auth/src/domain.rs | 16 ++ server-rs/crates/module-auth/src/lib.rs | 101 +++++++++- 10 files changed, 450 insertions(+), 25 deletions(-) create mode 100644 scripts/rebind-orphan-work-owners.mjs create mode 100644 scripts/rebind-orphan-work-owners.test.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1495b21d..730465ac 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1071,6 +1071,14 @@ - 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。 - 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-27 微信新用户用户名与孤儿作品作者回退收口 + +- 背景:用户数据清空后,旧作品的 `owner_user_id` 可能落到空洞或顺序号账号上,新注册用户会错误顶替历史作品;同时微信新用户默认用户名过于固定,不便于区分 openid。 +- 决策:微信新用户的用户名统一改为 `名字_openid`,内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格;作品作者找不到真实账号时统一回退到占位作者 `wx-openid-placeholder`,显示名固定为 `失效作者`,公开陶泥号固定为 `SY-00000000`。 +- 影响范围:`module-auth`、`api-server` 作品作者解析、`AppState` 启动初始化、历史孤儿作品离线回填脚本与相关文档。 +- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。 +- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。 ## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径 - 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 4529c30c..c83d63db 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1615,6 +1615,14 @@ - 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。 - 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 微信历史孤儿作品不要让新注册账号顶替 + +- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。 +- 原因:作品作者解析曾经把缺失作者简单回退到普通登录用户,且微信新用户用户名 / 内部 ID 都太容易被误认或复用。 +- 处理:作品作者找不到真实账号时统一回退到占位作者 `wx-openid-placeholder`,展示名固定为 `失效作者`;微信新用户用户名改为 `名字_openid`,内部 `user_id` 改成不可复用的 UUID 风格;离线回填时先识别真实有效用户,再把孤儿作品表写回占位账号。 +- 验证:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。 +- 关联:`server-rs/crates/api-server/src/work_author.rs`、`server-rs/crates/module-auth/src/domain.rs`、`scripts/rebind-orphan-work-owners.mjs`。 + ## 访客推荐页上下滑不要绑定登录态 - 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 29769429..5a5b07db 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,4 +1,4 @@ -# 本地开发验证与生产运维 +# 本地开发验证与生产运维 更新时间:`2026-05-15` @@ -413,4 +413,23 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC; 当前 `docs/` 只保留少量融合文档。新增稳定知识时优先更新现有文档;只有现有文档无法容纳时才新增带 `【标签名】` 的 Markdown。阶段性流水账、一次性修复记录和已关闭实验不要再新增成长期文档。 +## 微信登录与孤儿作品归属处理 +- 微信新用户的 `username` 统一拼为 `名字_openid`;若昵称或 openid 为空,则分别回退到 `微信旅人` 和 `openid`。 +- 微信新用户的内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格,避免清库后旧作品被后来的顺序号账号顶替。 +- 当作品作者的 `owner_user_id` 找不到真实账号时,作品统一显示为占位作者:`失效作者`,公开陶泥号固定为 `SY-00000000`,占位账号 ID 为 `wx-openid-placeholder`。 +- 该占位账号只用于作品作者域,不扩展到全站其它身份域。 +- 如需把历史孤儿作品批量回填到占位作者,使用 `scripts/rebind-orphan-work-owners.mjs` 先基于当前 auth 快照识别有效用户,再把缺失作者对应的作品表写回为占位 ID;脚本输入输出都基于 SpacetimeDB 迁移 JSON。 + +### 回填脚本用法 + +```bash +node scripts/rebind-orphan-work-owners.mjs --in --out +node scripts/rebind-orphan-work-owners.mjs --in --dry-run +node scripts/rebind-orphan-work-owners.mjs --in --out --placeholder-user-id wx-openid-placeholder +``` + +- `--in`:SpacetimeDB 导出的迁移 JSON。 +- `--out`:写回后的迁移 JSON 输出路径。 +- `--dry-run`:只统计回填行数,不写文件。 +- `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。 diff --git a/scripts/rebind-orphan-work-owners.mjs b/scripts/rebind-orphan-work-owners.mjs new file mode 100644 index 00000000..c3d6d356 --- /dev/null +++ b/scripts/rebind-orphan-work-owners.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export const DEFAULT_ORPHAN_WORK_OWNER_USER_ID = 'wx-openid-placeholder'; + +export const WORK_OWNER_TABLES = [ + 'custom_world_profile', + 'custom_world_gallery_entry', + 'custom_world_session', + 'custom_world_agent_session', + 'custom_world_draft_card', + 'puzzle_agent_session', + 'puzzle_work_profile', + 'bark_battle_draft_config', + 'bark_battle_published_config', + 'match3d_agent_session', + 'match3d_work_profile', + 'jump_hop_agent_session', + 'jump_hop_work_profile', + 'wooden_fish_agent_session', + 'wooden_fish_work_profile', + 'square_hole_agent_session', + 'square_hole_work_profile', + 'visual_novel_agent_session', + 'visual_novel_work_profile', + 'big_fish_creation_session', +]; + +const ROW_KEY_FIELDS = ['profile_id', 'work_id', 'session_id', 'draft_id', 'gallery_entry_id', 'id']; + +if (isCliEntry()) { + runCli(process.argv.slice(2)).catch((error) => { + console.error( + `[rebind-orphan-work-owners] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + }); +} + +export function rebindOrphanWorkOwnersInMigration( + migration, + { placeholderUserId = DEFAULT_ORPHAN_WORK_OWNER_USER_ID, validUserIds = [] } = {}, +) { + if (!migration || !Array.isArray(migration.tables)) { + throw new Error('迁移 JSON 必须包含 tables 数组。'); + } + + const normalizedPlaceholderUserId = placeholderUserId.trim(); + const validUserIdSet = new Set( + (Array.isArray(validUserIds) ? validUserIds : []) + .map((value) => String(value).trim()) + .filter(Boolean), + ); + validUserIdSet.add(normalizedPlaceholderUserId); + + const reboundRows = []; + for (const table of migration.tables) { + if (!table || !WORK_OWNER_TABLES.includes(table.name) || !Array.isArray(table.rows)) { + continue; + } + + for (const row of table.rows) { + if (!row || typeof row !== 'object') { + continue; + } + const currentOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id.trim() : ''; + if (currentOwner === normalizedPlaceholderUserId || validUserIdSet.has(currentOwner)) { + continue; + } + + const originalOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id : ''; + row.owner_user_id = normalizedPlaceholderUserId; + reboundRows.push({ + table: table.name, + rowKey: resolveRowKey(row), + from: originalOwner, + to: normalizedPlaceholderUserId, + }); + } + } + + return { reboundRows, validUserCount: validUserIdSet.size }; +} + +function resolveRowKey(row) { + for (const field of ROW_KEY_FIELDS) { + const value = row[field]; + if (typeof value === 'string' && value.trim()) { + return value; + } + } + return ''; +} + +async function runCli(argv) { + const options = parseCliArgs(argv); + const inputPath = path.resolve(options.in); + const outputPath = path.resolve(options.out); + const migration = JSON.parse(await readFile(inputPath, 'utf8')); + const result = rebindOrphanWorkOwnersInMigration(migration, { + placeholderUserId: options.placeholderUserId, + validUserIds: collectValidUserIds(migration), + }); + + if (!options.dryRun) { + await writeFile(outputPath, `${JSON.stringify(migration, null, 2)}\n`, 'utf8'); + } + + console.log( + `[rebind-orphan-work-owners] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},回填 ${result.reboundRows.length} 行`, + ); +} + +function parseCliArgs(argv) { + const options = { + in: '', + out: '', + placeholderUserId: DEFAULT_ORPHAN_WORK_OWNER_USER_ID, + dryRun: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = (name) => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${name} 缺少参数值。`); + } + index += 1; + return value; + }; + + if (arg === '--in') { + options.in = readValue(arg); + } else if (arg === '--out') { + options.out = readValue(arg); + } else if (arg === '--placeholder-user-id') { + options.placeholderUserId = readValue(arg); + } else if (arg === '--dry-run') { + options.dryRun = true; + } else { + throw new Error(`未知参数: ${arg}`); + } + } + + if (!options.in) { + throw new Error('必须传入 --in。'); + } + if (!options.out && !options.dryRun) { + throw new Error('非 dry-run 必须传入 --out。'); + } + return options; +} + +function collectValidUserIds(migration) { + const result = new Set(); + for (const table of migration.tables ?? []) { + if (!table || !Array.isArray(table.rows)) { + continue; + } + if (table.name === 'user_account') { + for (const row of table.rows) { + if (typeof row?.user_id === 'string' && row.user_id.trim()) { + result.add(row.user_id.trim()); + } + } + } + } + return result; +} + +function isCliEntry() { + const entry = process.argv[1]; + return entry ? import.meta.url === `file://${entry.replace(/\\/gu, '/')}` : false; +} diff --git a/scripts/rebind-orphan-work-owners.test.ts b/scripts/rebind-orphan-work-owners.test.ts new file mode 100644 index 00000000..b5733586 --- /dev/null +++ b/scripts/rebind-orphan-work-owners.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs'; + +const placeholderUserId = 'wx-openid-placeholder'; + +function table(name, rows) { + return { name, rows }; +} + +describe('rebindOrphanWorkOwnersInMigration', () => { + it('把作品表里认证表不存在的 owner_user_id 回填到占位用户', () => { + const migration = { + schema_version: 1, + exported_at_micros: 1, + tables: [ + table('user_account', [{ user_id: 'user_alive' }, { user_id: placeholderUserId }]), + table('puzzle_work_profile', [ + { profile_id: 'p1', owner_user_id: 'user_missing' }, + { profile_id: 'p2', owner_user_id: 'user_alive' }, + { profile_id: 'p3', owner_user_id: placeholderUserId }, + ]), + table('puzzle_agent_session', [{ session_id: 'draft-1', owner_user_id: '' }]), + table('tracking_event', [{ event_id: 't1', owner_user_id: 'user_missing' }]), + ], + }; + + const result = rebindOrphanWorkOwnersInMigration(migration, { + placeholderUserId, + validUserIds: ['user_alive'], + }); + + expect(result.reboundRows).toEqual([ + { table: 'puzzle_work_profile', rowKey: 'p1', from: 'user_missing', to: placeholderUserId }, + { table: 'puzzle_agent_session', rowKey: 'draft-1', from: '', to: placeholderUserId }, + ]); + expect(migration.tables[1].rows[0].owner_user_id).toBe(placeholderUserId); + expect(migration.tables[1].rows[1].owner_user_id).toBe('user_alive'); + expect(migration.tables[1].rows[2].owner_user_id).toBe(placeholderUserId); + expect(migration.tables[2].rows[0].owner_user_id).toBe(placeholderUserId); + expect(migration.tables[3].rows[0].owner_user_id).toBe('user_missing'); + }); +}); diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 4610df88..beb9d940 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -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 { diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 37a25b0a..6dcf8b9c 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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); diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs index 38b4bea6..2afc2447 100644 --- a/server-rs/crates/api-server/src/work_author.rs +++ b/server-rs/crates/api-server/src/work_author.rs @@ -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 { .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)); + } +} diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index d8057f81..07077e80 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -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}") diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index c88e9274..46cbd15b 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -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 { + 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, 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 { + 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]