Merge branch 'master' into codex/refine-creation-progress-wooden-fish

This commit is contained in:
2026-05-27 22:45:09 +08:00
10 changed files with 450 additions and 25 deletions

View File

@@ -1087,6 +1087,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 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。

View File

@@ -1625,6 +1625,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`
## 访客推荐页上下滑不要绑定登录态
- 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。

View File

@@ -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 <exported-migration.json> --out <rebound-migration.json>
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --dry-run
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out <rebound-migration.json> --placeholder-user-id wx-openid-placeholder
```
- `--in`SpacetimeDB 导出的迁移 JSON。
- `--out`:写回后的迁移 JSON 输出路径。
- `--dry-run`:只统计回填行数,不写文件。
- `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。

View File

@@ -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 '<unknown>';
}
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;
}

View File

@@ -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');
});
});

View File

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

View File

@@ -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);

View File

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

View File

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

View File

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