Merge branch 'master' into codex/refine-creation-progress-wooden-fish
This commit is contained in:
@@ -1087,6 +1087,14 @@
|
|||||||
- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。
|
- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。
|
||||||
- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。
|
- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`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 敲木鱼发布后作品架与推荐流刷新口径
|
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
||||||
|
|
||||||
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
||||||
|
|||||||
@@ -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"`。
|
- 验证:`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`。
|
- 关联:`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`。
|
||||||
|
|
||||||
## 访客推荐页上下滑不要绑定登录态
|
## 访客推荐页上下滑不要绑定登录态
|
||||||
|
|
||||||
- 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。
|
- 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 本地开发验证与生产运维
|
# 本地开发验证与生产运维
|
||||||
|
|
||||||
更新时间:`2026-05-15`
|
更新时间:`2026-05-15`
|
||||||
|
|
||||||
@@ -413,4 +413,23 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
|
|||||||
|
|
||||||
当前 `docs/` 只保留少量融合文档。新增稳定知识时优先更新现有文档;只有现有文档无法容纳时才新增带 `【标签名】` 的 Markdown。阶段性流水账、一次性修复记录和已关闭实验不要再新增成长期文档。
|
当前 `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。
|
||||||
|
|||||||
177
scripts/rebind-orphan-work-owners.mjs
Normal file
177
scripts/rebind-orphan-work-owners.mjs
Normal 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;
|
||||||
|
}
|
||||||
42
scripts/rebind-orphan-work-owners.test.ts
Normal file
42
scripts/rebind-orphan-work-owners.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ use crate::{
|
|||||||
platform_errors::map_oss_error,
|
platform_errors::map_oss_error,
|
||||||
request_context::RequestContext,
|
request_context::RequestContext,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
work_author::resolve_work_author_by_user_id,
|
||||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
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 {
|
fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String {
|
||||||
let display_name = if owner_user_id.trim().is_empty() {
|
resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_author_display_name(display_name: Option<String>) -> String {
|
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::tracking_outbox::TrackingOutbox;
|
||||||
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||||
use crate::wechat_provider::build_wechat_provider;
|
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";
|
const ADMIN_ROLE: &str = "admin";
|
||||||
|
|
||||||
@@ -361,6 +364,14 @@ impl AppState {
|
|||||||
)?)?;
|
)?)?;
|
||||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||||
let auth_user_service = AuthUserService::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 phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
|
||||||
let wechat_auth_state_service =
|
let wechat_auth_state_service =
|
||||||
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ use module_auth::AuthUser;
|
|||||||
|
|
||||||
use crate::state::{AppState, PuzzleApiState};
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct WorkAuthorSummary {
|
pub struct WorkAuthorSummary {
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
@@ -45,21 +49,15 @@ fn resolve_work_author_by_user_id_with_service(
|
|||||||
) -> WorkAuthorSummary {
|
) -> WorkAuthorSummary {
|
||||||
let fallback_display_name =
|
let fallback_display_name =
|
||||||
normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string());
|
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 {
|
let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else {
|
||||||
return WorkAuthorSummary {
|
return orphan_work_author_summary();
|
||||||
display_name: fallback_display_name,
|
|
||||||
public_user_code: fallback_public_user_code,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match auth_user_service.get_user_by_id(&owner_user_id) {
|
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(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name),
|
||||||
Ok(None) | Err(_) => WorkAuthorSummary {
|
Ok(None) | Err(_) => orphan_work_author_summary(),
|
||||||
display_name: fallback_display_name,
|
|
||||||
public_user_code: fallback_public_user_code,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,3 +78,65 @@ fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned)
|
.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}")
|
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,仅用于展示、分享与搜索。
|
// 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
|
||||||
pub fn build_public_user_code(sequence: u64) -> String {
|
pub fn build_public_user_code(sequence: u64) -> String {
|
||||||
format!("SY-{sequence:08}")
|
format!("SY-{sequence:08}")
|
||||||
|
|||||||
@@ -800,6 +800,21 @@ impl AuthUserService {
|
|||||||
Self { store }
|
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> {
|
pub fn get_user_by_id(&self, user_id: &str) -> Result<Option<AuthUser>, LogoutError> {
|
||||||
self.store
|
self.store
|
||||||
.find_by_user_id(user_id)
|
.find_by_user_id(user_id)
|
||||||
@@ -997,6 +1012,68 @@ impl InMemoryAuthStore {
|
|||||||
.cloned())
|
.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(
|
fn find_by_public_user_code(
|
||||||
&self,
|
&self,
|
||||||
public_user_code: &str,
|
public_user_code: &str,
|
||||||
@@ -1099,7 +1176,7 @@ impl InMemoryAuthStore {
|
|||||||
PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||||
})?;
|
})?;
|
||||||
let sequence = state.next_user_id;
|
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);
|
let public_user_code = build_public_user_code(sequence);
|
||||||
state.next_user_id += 1;
|
state.next_user_id += 1;
|
||||||
let username = build_system_username("phone", state.next_user_id);
|
let username = build_system_username("phone", state.next_user_id);
|
||||||
@@ -1151,7 +1228,7 @@ impl InMemoryAuthStore {
|
|||||||
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
|
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||||
})?;
|
})?;
|
||||||
let sequence = state.next_user_id;
|
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);
|
let public_user_code = build_public_user_code(sequence);
|
||||||
state.next_user_id += 1;
|
state.next_user_id += 1;
|
||||||
let username = build_system_username("phone", state.next_user_id);
|
let username = build_system_username("phone", state.next_user_id);
|
||||||
@@ -1199,10 +1276,9 @@ impl InMemoryAuthStore {
|
|||||||
WechatAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
WechatAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||||
})?;
|
})?;
|
||||||
let sequence = state.next_user_id;
|
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);
|
let public_user_code = build_public_user_code(sequence);
|
||||||
state.next_user_id += 1;
|
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 avatar_url = normalize_optional_string(profile.avatar_url.clone());
|
||||||
let display_name = profile
|
let display_name = profile
|
||||||
.display_name
|
.display_name
|
||||||
@@ -1211,6 +1287,7 @@ impl InMemoryAuthStore {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("微信旅人")
|
.unwrap_or("微信旅人")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let username = build_wechat_username(&display_name, &profile.provider_uid);
|
||||||
let user = AuthUser {
|
let user = AuthUser {
|
||||||
id: user_id.clone(),
|
id: user_id.clone(),
|
||||||
public_user_code,
|
public_user_code,
|
||||||
@@ -2164,6 +2241,18 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
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 {
|
fn build_store() -> InMemoryAuthStore {
|
||||||
InMemoryAuthStore::default()
|
InMemoryAuthStore::default()
|
||||||
}
|
}
|
||||||
@@ -3143,6 +3232,9 @@ mod tests {
|
|||||||
first_wechat.user.binding_status,
|
first_wechat.user.binding_status,
|
||||||
AuthBindingStatus::PendingBindPhone
|
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
|
let second_wechat = wechat_service
|
||||||
.resolve_login(ResolveWechatLoginInput {
|
.resolve_login(ResolveWechatLoginInput {
|
||||||
@@ -3160,6 +3252,7 @@ mod tests {
|
|||||||
assert_eq!(second_wechat.user.id, first_wechat.user.id);
|
assert_eq!(second_wechat.user.id, first_wechat.user.id);
|
||||||
assert_ne!(second_wechat.user.id, phone_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.login_method, AuthLoginMethod::Wechat);
|
||||||
|
assert_eq!(second_wechat.user.username, first_wechat.user.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user