From 0981d6ee1bf2d5141f9162b9365b038fd8976f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 11 Apr 2026 15:43:32 +0800 Subject: [PATCH] 1 --- .env.example | 1 + .env.local | 2 + ...RING_OPTIMIZATION_PRIORITIES_2026-04-10.md | 256 +++ package.json | 3 +- packages/shared/src/contracts/auth.ts | 4 + .../dev-server/characterAssetStudioPlugins.ts | 2 +- scripts/export-story-audit-report.ts | 17 - scripts/smoke-content.ts | 18 +- scripts/validate-content.ts | 8 +- scripts/validate-overrides.ts | 6 +- server-node/src/app.test.ts | 169 +- server-node/src/auth/authService.ts | 11 + server-node/src/config.test.ts | 61 + server-node/src/config.ts | 67 +- .../src/modules/ai/chatPromptBuilders.ts | 4 +- .../src/modules/ai/customWorldOrchestrator.ts | 7 +- .../src/modules/ai/storyPromptBuilders.ts | 4 +- .../modules/assets/characterAssetRoutes.ts | 2 +- .../src/modules/quest/runtimeQuestModule.ts | 4 +- server-node/src/routes/authRoutes.ts | 17 +- .../src/services/smsVerificationService.ts | 13 + server-node/src/services/wechatAuthService.ts | 42 +- .../CustomWorldEntityEditorModal.tsx | 26 +- .../CustomWorldRoleAssetStudioModal.tsx | 10 +- src/components/ItemCatalogEditor.tsx | 908 ----------- src/components/NpcVisualEditor.tsx | 1060 ------------- src/components/PresetEditor.tsx | 110 -- src/components/SkillEffectPreview.tsx | 4 +- src/components/StateFunctionEditor.tsx | 1309 --------------- .../characterAssetWorkflowModel.ts} | 0 .../characterAssetWorkflowPersistence.ts} | 0 src/components/auth/AuthGate.tsx | 46 + src/components/auth/LoginScreen.tsx | 196 ++- .../game-canvas/GameCanvasRuntime.tsx | 6 +- .../game-shell/CharacterSelectionFlow.tsx | 4 +- .../game-shell/PreGameSelectionFlow.tsx | 6 +- .../preset-editor/CharacterAssetPanel.tsx | 1411 ----------------- .../preset-editor/CharacterAssetTab.tsx | 1 - .../preset-editor/CharacterPresetPanel.tsx | 808 ---------- .../preset-editor/CharacterPresetTab.tsx | 1 - .../preset-editor/LazyEditorFallback.tsx | 7 - .../preset-editor/MonsterPresetPanel.tsx | 362 ----- .../preset-editor/MonsterPresetTab.tsx | 1 - .../preset-editor/PresetEditorPanels.tsx | 4 - .../preset-editor/SceneNpcPresetPanel.tsx | 400 ----- .../preset-editor/SceneNpcPresetTab.tsx | 1 - .../preset-editor/ScenePresetPanel.tsx | 318 ---- .../preset-editor/ScenePresetTab.tsx | 1 - src/components/preset-editor/shared.ts | 259 --- src/data/buildDamage.test.ts | 10 +- src/data/characterPresets.ts | 49 +- src/data/customWorldLibrary.ts | 13 +- src/data/customWorldNpcMonsters.ts | 24 +- src/data/customWorldRuntime.ts | 8 +- src/data/customWorldVisuals.ts | 20 +- src/data/hostileNpcPresets.ts | 15 +- src/data/hostileNpcs.ts | 5 +- src/data/itemDesign.ts | 28 +- src/data/itemPresentation.ts | 4 +- src/data/sceneBackgrounds.test.ts | 6 +- src/data/scenePresets.ts | 12 +- src/data/worldAttributeSchemas.ts | 15 +- src/routing/appRoutes.test.ts | 23 +- src/routing/appRoutes.tsx | 64 - src/services/attributeSchemaGenerator.ts | 10 +- src/services/authService.test.ts | 18 + src/services/authService.ts | 14 +- src/services/characterChatPrompt.ts | 4 +- src/services/customWorld.ts | 7 + src/services/customWorldBuilder.ts | 5 +- src/services/customWorldOwnedSettingLayers.ts | 22 +- src/services/customWorldTheme.ts | 52 +- src/services/prompt.ts | 4 +- src/services/questPrompt.ts | 4 +- src/services/storyEngine/storyAuditReport.ts | 1175 -------------- src/tools/QwenSpriteSheetTool.tsx | 4 +- src/types/customWorld.ts | 2 + src/uiAssets.ts | 18 +- 78 files changed, 1102 insertions(+), 8510 deletions(-) create mode 100644 docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md delete mode 100644 scripts/export-story-audit-report.ts create mode 100644 server-node/src/config.test.ts delete mode 100644 src/components/ItemCatalogEditor.tsx delete mode 100644 src/components/NpcVisualEditor.tsx delete mode 100644 src/components/PresetEditor.tsx delete mode 100644 src/components/StateFunctionEditor.tsx rename src/components/{preset-editor/characterAssetStudioModel.ts => asset-studio/characterAssetWorkflowModel.ts} (100%) rename src/components/{preset-editor/characterAssetStudioPersistence.ts => asset-studio/characterAssetWorkflowPersistence.ts} (100%) delete mode 100644 src/components/preset-editor/CharacterAssetPanel.tsx delete mode 100644 src/components/preset-editor/CharacterAssetTab.tsx delete mode 100644 src/components/preset-editor/CharacterPresetPanel.tsx delete mode 100644 src/components/preset-editor/CharacterPresetTab.tsx delete mode 100644 src/components/preset-editor/LazyEditorFallback.tsx delete mode 100644 src/components/preset-editor/MonsterPresetPanel.tsx delete mode 100644 src/components/preset-editor/MonsterPresetTab.tsx delete mode 100644 src/components/preset-editor/PresetEditorPanels.tsx delete mode 100644 src/components/preset-editor/SceneNpcPresetPanel.tsx delete mode 100644 src/components/preset-editor/SceneNpcPresetTab.tsx delete mode 100644 src/components/preset-editor/ScenePresetPanel.tsx delete mode 100644 src/components/preset-editor/ScenePresetTab.tsx delete mode 100644 src/components/preset-editor/shared.ts delete mode 100644 src/services/storyEngine/storyAuditReport.ts diff --git a/.env.example b/.env.example index 73beebcc..64f49925 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ AUTH_REFRESH_COOKIE_SECURE="false" # 手机号验证码登录配置(阿里云 PNVS)。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。 +# 在 `.env.local` 或进程环境中填入 AccessKey 后会自动启用;如需强制关闭,请显式设置 `SMS_AUTH_ENABLED="false"`。 SMS_AUTH_ENABLED="false" SMS_AUTH_PROVIDER="aliyun" ALIYUN_SMS_ACCESS_KEY_ID="" diff --git a/.env.local b/.env.local index 6c1da5e8..b74994b6 100644 --- a/.env.local +++ b/.env.local @@ -6,3 +6,5 @@ DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990" EMBEDDING_MODEL="doubao-embedding-text-240715" VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI" VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ==" +WECHAT_AUTH_ENABLED="true" +WECHAT_AUTH_PROVIDER="mock" diff --git a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md new file mode 100644 index 00000000..e8291b8c --- /dev/null +++ b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md @@ -0,0 +1,256 @@ +# 当前工程优化优先级汇总(2026-04-10) + +## 结论先说 + +和 `2026-04-01` 那轮工程审查相比,当前仓库的主问题已经发生了明显迁移: + +- 运行时主链拆分已经有进展,`useStoryGeneration.ts` 不再是最高复杂度热点。 +- `typecheck`、前后端测试、内容校验、编码校验都已经回到可通过状态。 +- 当前真正卡住工程节奏的,已经变成: + - 绿色门禁不可信 + - 构建 warning 仍然会直接打断发布门禁 + - 自定义世界 / 编辑器 / 资产链路出现了新的巨型模块热点 + - 生成产物与旧工具链残留开始反向污染 lint、watch 和本地开发信号 + +一句话判断: + +**现在最该优先做的,不是继续扩功能,而是先把门禁重新拉回可信状态,再拆 editor / custom world / assets 这批新的复杂度中心。** + +--- + +## 2026-04-10 当前校验快照 + +本次汇总不是只复述旧文档,额外执行了当前仓库校验命令。 + +| 项目 | 结果 | 说明 | +| --- | --- | --- | +| `npm run check:encoding` | 通过 | 编码基线正常 | +| `npm run typecheck` | 通过 | 当前严格类型门禁可通过 | +| `npm run test` | 通过 | `92` 个测试文件、`228` 个测试通过 | +| `npm run server-node:test:baseline` | 通过 | 观测基线正常 | +| `npm run server-node:test` | 通过 | `72` 个后端测试通过 | +| `npm run check:content` | 通过 | 内容与覆盖校验正常 | +| `npm run lint:eslint` | 失败 | `330` 个 error、`4` 个 warning | +| `npm run build` | 失败 | 构建完成,但因 warning 被 `build-gate` 拦截 | + +当前状态说明: + +- 仓库不是“完全不可用”,而是已经进入“测试绿,但门禁信号不一致”的阶段。 +- 这类状态比纯红线更危险,因为团队会误以为主链已经稳定。 + +--- + +## P0:先恢复可信的绿色门禁 + +### P0-1:修复 lint 失真,重新建立可信基线 + +这是当前第一优先级。 + +#### 证据 + +- `npm run lint:eslint` 当前失败,报出 `330` 个 error、`4` 个 warning。 +- 问题既有真实源码问题,也有明显的门禁污染: + - `src/`、`server-node/`、`scripts/` 中存在 import 排序、未使用导入、少量 hook 规则问题。 + - `temp-build-goal-check/` 这类生成产物目录也被 ESLint 扫描进来,放大了噪音。 +- `.eslintrc.cjs` 当前忽略了 `dist`、`media` 等目录,但没有忽略 `temp-build-goal-check`。 +- `vite.config.ts` 的 `server.watch.ignored` 已经忽略了 `**/temp*build*/**`,说明当前 watch 口径和 lint 口径并不一致。 + +#### 影响 + +- 团队无法快速判断“现在是源码真问题,还是产物目录噪音”。 +- lint 失真会直接削弱 review、回归和集成效率。 +- 在这种状态下继续加功能,只会让真实错误被更多噪音淹没。 + +#### 当前建议 + +1. 先清理或迁出 `temp-build-goal-check/` 这类生成产物目录,至少不要再让它进入 lint 扫描范围。 +2. 统一 `watch / lint / build` 对临时目录和生成目录的忽略口径。 +3. 再集中清当前源码层 lint 问题,优先处理: + - import 排序 + - 未使用导入 + - 少量真实规则错误,例如 hook 误用和 `ban-types` + +--- + +### P0-2:修复构建 warning,恢复可发布构建 + +这是和 P0-1 同级的阻塞项。 + +#### 证据 + +- `npm run build` 当前会被 `scripts/build-gate.mjs` 拦截。 +- 当前构建输出里最关键的 warning 有两类: + - `src/services/ai.ts` 虽然尝试走动态加载,但又被 `src/components/CustomWorldEntityEditorModal.tsx` 静态引入,导致拆包失效。 + - `AuthenticatedApp-*.js` 达到 `1078.61 kB`,超过当前 `750 kB` 的 chunk warning 门槛。 +- 同轮构建里,`index-*.css` 也已经达到 `157.56 kB`,说明不仅 JS 主块重,样式也在继续膨胀。 + +#### 影响 + +- 当前不是“构建有一点 warning 可以先带着走”,而是发布门禁已经被 warning 直接打断。 +- editor / custom world / asset 工具能力正在把非主链代码重新带回主包路径。 +- 后续如果继续叠加这条链路,首屏、缓存和回归都会继续变差。 + +#### 当前建议 + +1. 先切断 `CustomWorldEntityEditorModal.tsx -> ../services/ai` 的静态依赖,让 `ai.ts` 真正留在懒加载路径。 +2. 把自定义世界编辑器、资产工作台、非首屏工具能力继续从 `AuthenticatedApp` 主块中拆出。 +3. 保持 `build warning = 失败` 的策略,不建议通过放宽阈值掩盖问题。 + +--- + +## P1:拆掉新的复杂度中心 + +### P1-1:优先拆 editor / custom world / assets 新热点 + +旧的运行时主链热点已经有所缓解,但复杂度并没有消失,而是转移到了新的模块上。 + +#### 当前大文件热点 + +前端: + +- `src/components/CustomWorldEntityEditorModal.tsx`:`2778` 行 +- `src/services/ai.ts`:`2454` 行 +- `src/services/customWorld.ts`:`2217` 行 +- `src/data/npcInteractions.ts`:`2103` 行 +- `src/data/characterPresets.ts`:`1953` 行 +- `src/services/prompt.ts`:`1725` 行 + +后端: + +- `server-node/src/modules/assets/characterAssetRoutes.ts`:`2295` 行 +- `server-node/src/app.test.ts`:`1527` 行 +- `server-node/src/auth/authService.ts`:`1243` 行 +- `server-node/src/modules/quest/runtimeQuestModule.ts`:`1137` 行 + +工具链: + +- `scripts/dev-server/localApiPlugins.ts`:`1504` 行 + +#### 影响 + +- 复杂度并没有真正被消灭,而是从运行时 story hook 转移到了自定义世界、资产编辑、提示词和数据装配链。 +- 这些文件大多同时承载了: + - 领域规则 + - API 调用 + - 文本拼装 + - UI 状态 + - 工具流程 +- 后续任何一个小改动,都容易牵动整条大链,回归成本会再次上升。 + +#### 当前建议 + +1. 前端优先拆 `CustomWorldEntityEditorModal.tsx`,按“世界锚点 / 角色 / 地点 / 资产 / 高级设置”分段。 +2. 后端优先拆 `characterAssetRoutes.ts`,把 route、job orchestration、文件发布、模板读取拆开。 +3. 把 `src/services/ai.ts` 和 `src/services/customWorld.ts` 继续按运行时 / 编辑器 / 资产工具三条职责分层。 + +--- + +### P1-2:继续收口 editor / assets 工具链边界 + +这项的重要性正在上升。 + +#### 证据 + +- `docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md` 已说明 editor/assets API 已经迁到 `server-node`,方向是对的。 +- 但当前仓库里仍保留一个 `1504` 行的 `scripts/dev-server/localApiPlugins.ts`。 +- 目录 `temp-build-goal-check/` 当前包含 `15099` 个文件,已经开始干扰 lint 和本地开发信号。 +- 相关日志里还出现了大量指向 `temp-build-goal-check` 的页面 reload 与 `ENOENT` 噪音。 + +#### 影响 + +- 旧工具链虽然“不再是主入口”,但它们还在继续占据认知空间和仓库噪音预算。 +- 新旧 editor/assets 路径长期并存,会导致维护者很难快速判断哪条链才是正式路径。 + +#### 当前建议 + +1. 明确把旧 Vite 插件链标记为迁移参考,避免继续被误用。 +2. 将临时构建目录、检查目录、导出目录统一移出主工程扫描面。 +3. 对 editor/assets 正式入口补一份“唯一推荐入口”文档或 README 更新,减少后续回流。 + +--- + +## P2:继续做架构收口,但不必抢在 P0 前面 + +### P2-1:继续压缩前端遗留 AI / 自定义世界实现 + +这一项仍然值得做,但当前不再是最前面的阻塞。 + +#### 原因 + +- `docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md` 显示正式运行时主链已经大幅回收到后端。 +- 当前更明显的遗留,已经集中到编辑器、自定义世界工作台和资产工具,而不是正式运行时 story 主链。 + +#### 当前建议 + +1. 继续让正式运行时保持“后端为真相源”。 +2. 对仍留在前端的大 AI / prompt / custom world 实现,优先做职责收缩,而不是继续在原文件上堆逻辑。 + +--- + +### P2-2:继续优化自定义世界工作台,但以“减负”和“分层”为主 + +这一项更适合作为 P0、P1 稳住后的下一轮重点。 + +#### 依据 + +- `docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md` 已经明确指出: + - 自定义世界入口、澄清、锁定、局部重生成、结果工作台仍是半收口状态。 +- 当前最大的前端热点文件也集中在这条链路上,说明它已经不仅是产品问题,也是工程复杂度问题。 + +#### 当前建议 + +1. 优先减少“大一统编辑弹窗”的职责,把高杠杆编辑和高级编辑分层。 +2. 让自定义世界生成、锁定、局部重生成规则继续向后端收口。 +3. 移动端优先,避免长表单和重弹窗继续吞掉维护成本。 + +--- + +## 推荐执行顺序 + +### 第一阶段:先把门禁拉回可信 + +1. 修 lint 口径失真 +2. 清生成产物扫描污染 +3. 修 build warning + +### 第二阶段:再拆新的复杂度中心 + +1. 拆 `CustomWorldEntityEditorModal.tsx` +2. 拆 `characterAssetRoutes.ts` +3. 收缩 `src/services/ai.ts` / `src/services/customWorld.ts` + +### 第三阶段:最后收 editor / custom world 架构尾巴 + +1. 清理旧 Vite 工具链残留 +2. 继续把自定义世界和资产工具收回正式后端边界 + +--- + +## 当前不建议优先做的事 + +- 不建议在当前 lint 与 build 仍然是红线时继续横向扩 editor / custom world 功能。 +- 不建议通过放宽 chunk warning 阈值来“修复”构建。 +- 不建议继续在 `CustomWorldEntityEditorModal.tsx`、`src/services/ai.ts`、`characterAssetRoutes.ts` 这类巨型文件中直接堆新逻辑。 + +--- + +## 本文依据 + +文档依据: + +- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` +- `docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md` +- `docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md` +- `docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md` +- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md` + +当前仓库校验依据: + +- `npm run check:encoding` +- `npm run typecheck` +- `npm run test` +- `npm run server-node:test:baseline` +- `npm run server-node:test` +- `npm run check:content` +- `npm run lint:eslint` +- `npm run build` diff --git a/package.json b/package.json index c71e3775..f7b3c678 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,7 @@ "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", "check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts", - "check:content": "npm run check:data && npm run check:overrides && npm run check:smoke", - "report:story-audit": "node scripts/run-tsx.cjs scripts/export-story-audit-report.ts" + "check:content": "npm run check:data && npm run check:overrides && npm run check:smoke" }, "dependencies": { "@tailwindcss/vite": "^4.1.14", diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index ebe0a2bc..6ee28cc6 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -50,6 +50,10 @@ export type AuthMeResponse = { availableLoginMethods: AuthLoginMethod[]; }; +export type AuthLoginOptionsResponse = { + availableLoginMethods: AuthLoginMethod[]; +}; + export type AuthWechatStartResponse = { authorizationUrl: string; }; diff --git a/scripts/dev-server/characterAssetStudioPlugins.ts b/scripts/dev-server/characterAssetStudioPlugins.ts index 91c11eb0..36711a0a 100644 --- a/scripts/dev-server/characterAssetStudioPlugins.ts +++ b/scripts/dev-server/characterAssetStudioPlugins.ts @@ -710,7 +710,7 @@ function buildNpcVisualPrompt( .filter(Boolean) .join('\n'); - return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。'); + return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。'); } function buildImageSequencePrompt( diff --git a/scripts/export-story-audit-report.ts b/scripts/export-story-audit-report.ts deleted file mode 100644 index 79eca2ae..00000000 --- a/scripts/export-story-audit-report.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; - -import { buildCurrentGameStoryAuditMarkdown } from '../src/services/storyEngine/storyAuditReport.ts'; - -const defaultOutputPath = resolve( - process.cwd(), - 'docs/audits/text/CURRENT_GAME_STORY_SOURCE_REVIEW_2026-04-07.md', -); -const outputPath = process.argv[2] - ? resolve(process.cwd(), process.argv[2]) - : defaultOutputPath; - -mkdirSync(dirname(outputPath), { recursive: true }); -writeFileSync(outputPath, buildCurrentGameStoryAuditMarkdown(), 'utf8'); - -console.log(`[story-audit] wrote ${outputPath}`); diff --git a/scripts/smoke-content.ts b/scripts/smoke-content.ts index 586eac7c..1eed50f0 100644 --- a/scripts/smoke-content.ts +++ b/scripts/smoke-content.ts @@ -1,4 +1,4 @@ -import { buildCompanionState, PRESET_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts'; +import { buildCompanionState, ROLE_TEMPLATE_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts'; import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts'; import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts'; import { @@ -37,7 +37,7 @@ function assert(condition: unknown, message: string): asserts condition { } function createBaseState(worldType: WorldType, sceneId?: string): GameState { - const playerCharacter = PRESET_CHARACTERS[0]; + const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; const currentScenePreset = sceneId ? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null : getScenePresetsByWorld(worldType)[0] ?? null; @@ -114,7 +114,7 @@ function smokeNpcStories() { context: sceneWithNpc.npcs[0].role, xMeters: 3.2, }; - const playerCharacter = PRESET_CHARACTERS[0]; + const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; const npcState = buildInitialNpcState(encounter, worldType); const story = buildNpcEncounterStoryMoment({ encounter, @@ -216,7 +216,7 @@ function smokeObserveAndCallOut() { function smokeInventoryUseLoop() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const playerCharacter = PRESET_CHARACTERS[0]; + const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; const inventory = buildInitialPlayerInventory(playerCharacter, worldType); const usableItem = inventory.find(item => isInventoryItemUsable(item)); assert(usableItem, `[inventory] missing usable starter item for ${worldType}`); @@ -231,7 +231,7 @@ function smokeInventoryUseLoop() { } function smokeEquipmentLoop() { - const playerCharacter = PRESET_CHARACTERS[0]; + const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; const starterLoadout = buildInitialEquipmentLoadout(playerCharacter); const starterBonuses = getEquipmentBonuses(starterLoadout); @@ -261,7 +261,7 @@ function smokeTradeEconomyLoop() { }; const npcState = buildInitialNpcState(encounter, worldType); const npcItem = npcState.inventory[0]; - const playerItem = buildInitialPlayerInventory(PRESET_CHARACTERS[0], worldType)[0]; + const playerItem = buildInitialPlayerInventory(ROLE_TEMPLATE_CHARACTERS[0], worldType)[0]; assert(npcItem, `[trade] missing npc item for ${worldType}`); assert(playerItem, `[trade] missing player item for ${worldType}`); @@ -326,9 +326,9 @@ function smokeEncounterTransitionLoop() { } function smokeRosterLoop() { - const playerCharacter = PRESET_CHARACTERS[0]; - const reserveCharacter = PRESET_CHARACTERS[1]; - const recruitCharacter = PRESET_CHARACTERS[2]; + const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; + const reserveCharacter = ROLE_TEMPLATE_CHARACTERS[1]; + const recruitCharacter = ROLE_TEMPLATE_CHARACTERS[2]; const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68); const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62); const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72); diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts index a79cfd6e..582f28d5 100644 --- a/scripts/validate-content.ts +++ b/scripts/validate-content.ts @@ -1,4 +1,4 @@ -import { getCharacterHomeSceneId, getCharacterNpcSceneIds, PRESET_CHARACTERS } from '../src/data/characterPresets.ts'; +import { getCharacterHomeSceneId, getCharacterNpcSceneIds, ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts'; import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts'; import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts'; import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts'; @@ -45,7 +45,7 @@ function validateScenes(errors: string[]) { } npcIds.add(npc.id); - if (npc.characterId && !PRESET_CHARACTERS.some(character => character.id === npc.characterId)) { + if (npc.characterId && !ROLE_TEMPLATE_CHARACTERS.some(character => character.id === npc.characterId)) { addError(errors, `[scene] ${scene.id} npc "${npc.id}" references unknown character "${npc.characterId}"`); } }); @@ -57,7 +57,7 @@ function validateCharacters(errors: string[]) { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneIdSet = new Set(getScenePresetsByWorld(worldType).map(scene => scene.id)); - PRESET_CHARACTERS.forEach(character => { + ROLE_TEMPLATE_CHARACTERS.forEach(character => { const homeSceneId = getCharacterHomeSceneId(worldType, character.id); if (homeSceneId && !sceneIdSet.has(homeSceneId)) { addError(errors, `[character] ${character.id} homeSceneId "${homeSceneId}" not found in ${worldType}`); @@ -110,7 +110,7 @@ function main() { const monsterCount = MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA].length + MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA].length; const functionCount = buildStateFunctionDefinitions().length; - console.log(`Content validation passed. scenes=${sceneCount} monsters=${monsterCount} characters=${PRESET_CHARACTERS.length} functions=${functionCount}`); + console.log(`Content validation passed. scenes=${sceneCount} monsters=${monsterCount} characters=${ROLE_TEMPLATE_CHARACTERS.length} functions=${functionCount}`); } main(); diff --git a/scripts/validate-overrides.ts b/scripts/validate-overrides.ts index 709b7c5a..95b4ea69 100644 --- a/scripts/validate-overrides.ts +++ b/scripts/validate-overrides.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { readdirSync } from 'node:fs'; import path from 'node:path'; -import { PRESET_CHARACTERS } from '../src/data/characterPresets.ts'; +import { ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts'; import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts'; import { buildItemCatalogId } from '../src/data/itemCatalog.ts'; import { getScenePresetsByWorld } from '../src/data/scenePresets.ts'; @@ -34,7 +34,7 @@ function validateCharacterOverrides(errors: string[]) { const overrides = readJsonFile>('src/data/characterOverrides.json'); if (!expectPlainObject(errors, 'characterOverrides', overrides)) return; - const characterIds = new Set(PRESET_CHARACTERS.map(character => character.id)); + const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id)); const sceneIds = new Set( [WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)), ); @@ -142,7 +142,7 @@ function validateSceneNpcOverrides(errors: string[]) { getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)), ), ); - const characterIds = new Set(PRESET_CHARACTERS.map(character => character.id)); + const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id)); Object.entries(overrides).forEach(([npcId, override]) => { if (!npcIds.has(npcId)) { diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index e40b796c..296dae11 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -14,12 +14,25 @@ import { requestIdMiddleware } from './middleware/requestId.ts'; import { createAppContext } from './server.ts'; import { httpRequest, type TestRequestInit } from './testHttp.ts'; -function createTestConfig(testName: string): AppConfig { +type TestConfigOverrides = Partial< + Omit +> & { + llm?: Partial; + dashScope?: Partial; + smsAuth?: Partial; + wechatAuth?: Partial; + authSession?: Partial; +}; + +function createTestConfig( + testName: string, + overrides: TestConfigOverrides = {}, +): AppConfig { const tempRoot = fs.mkdtempSync( path.join(os.tmpdir(), `genarrative-server-node-${testName}-`), ); - return { + const baseConfig: AppConfig = { nodeEnv: 'test', projectRoot: tempRoot, publicDir: path.join(tempRoot, 'public'), @@ -99,13 +112,39 @@ function createTestConfig(testName: string): AppConfig { refreshCookiePath: '/api/auth', }, }; + + return { + ...baseConfig, + ...overrides, + llm: { + ...baseConfig.llm, + ...overrides.llm, + }, + dashScope: { + ...baseConfig.dashScope, + ...overrides.dashScope, + }, + smsAuth: { + ...baseConfig.smsAuth, + ...overrides.smsAuth, + }, + wechatAuth: { + ...baseConfig.wechatAuth, + ...overrides.wechatAuth, + }, + authSession: { + ...baseConfig.authSession, + ...overrides.authSession, + }, + }; } async function withTestServer( testName: string, run: (options: { baseUrl: string }) => Promise, + overrides: TestConfigOverrides = {}, ) { - const context = await createAppContext(createTestConfig(testName)); + const context = await createAppContext(createTestConfig(testName, overrides)); const app = createApp(context); const server = await new Promise((resolve) => { const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); @@ -348,6 +387,130 @@ test('auth entry auto-registers, me works, logout invalidates old token', async }); }); +test('login options expose enabled methods without authentication', async () => { + await withTestServer('auth-login-options', async ({ baseUrl }) => { + const response = await httpRequest(`${baseUrl}/api/auth/login-options`); + const payload = (await response.json()) as { + availableLoginMethods: string[]; + }; + + assert.equal(response.status, 200); + assert.deepEqual(payload.availableLoginMethods, ['phone', 'wechat']); + }); +}); + +test('wechat start uses qrconnect for desktop browsers', async () => { + await withTestServer( + 'wechat-start-desktop', + async ({ baseUrl }) => { + const response = await httpRequest( + `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`, + { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/135.0.0.0 Safari/537.36', + }, + }, + ); + const payload = (await response.json()) as { + authorizationUrl: string; + }; + const authorizationUrl = new URL(payload.authorizationUrl); + + assert.equal(response.status, 200); + assert.equal( + `${authorizationUrl.origin}${authorizationUrl.pathname}`, + 'https://open.weixin.qq.com/connect/qrconnect', + ); + assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_login'); + assert.equal(authorizationUrl.hash, '#wechat_redirect'); + }, + { + wechatAuth: { + enabled: true, + provider: 'wechat', + appId: 'wx-test-app-id', + appSecret: 'wx-test-app-secret', + }, + }, + ); +}); + +test('wechat start uses oauth authorize inside wechat browser', async () => { + await withTestServer( + 'wechat-start-in-app', + async ({ baseUrl }) => { + const response = await httpRequest( + `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`, + { + headers: { + 'User-Agent': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.54', + }, + }, + ); + const payload = (await response.json()) as { + authorizationUrl: string; + }; + const authorizationUrl = new URL(payload.authorizationUrl); + + assert.equal(response.status, 200); + assert.equal( + `${authorizationUrl.origin}${authorizationUrl.pathname}`, + 'https://open.weixin.qq.com/connect/oauth2/authorize', + ); + assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_userinfo'); + assert.equal(authorizationUrl.hash, '#wechat_redirect'); + }, + { + wechatAuth: { + enabled: true, + provider: 'wechat', + appId: 'wx-test-app-id', + appSecret: 'wx-test-app-secret', + }, + }, + ); +}); + +test('wechat start rejects unsupported mobile browsers for real provider', async () => { + await withTestServer( + 'wechat-start-mobile-browser', + async ({ baseUrl }) => { + const response = await httpRequest( + `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`, + { + headers: { + 'User-Agent': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1', + }, + }, + ); + const payload = (await response.json()) as { + error: { + code: string; + message: string; + }; + }; + + assert.equal(response.status, 400); + assert.equal(payload.error.code, 'BAD_REQUEST'); + assert.equal( + payload.error.message, + '当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录', + ); + }, + { + wechatAuth: { + enabled: true, + provider: 'wechat', + appId: 'wx-test-app-id', + appSecret: 'wx-test-app-secret', + }, + }, + ); +}); + test('phone login sends code, creates a user and returns masked profile info', async () => { await withTestServer('phone-login', async ({ baseUrl }) => { const sendResult = await sendPhoneCode(baseUrl, '13800138000'); diff --git a/server-node/src/auth/authService.ts b/server-node/src/auth/authService.ts index ee3c4896..92d500c6 100644 --- a/server-node/src/auth/authService.ts +++ b/server-node/src/auth/authService.ts @@ -6,6 +6,7 @@ import type { AuthAuditLogsResponse, AuthBindingStatus, AuthEntryResponse, + AuthLoginOptionsResponse, AuthLiftRiskBlockResponse, AuthLoginMethod, AuthLogoutAllResponse, @@ -151,6 +152,14 @@ export async function buildAuthMeResponse( }; } +export function buildAuthLoginOptionsResponse( + context: AppContext, +): AuthLoginOptionsResponse { + return { + availableLoginMethods: resolveAvailableLoginMethods(context), + }; +} + async function signUserAuthPayload( context: AppContext, user: UserRecord, @@ -1077,12 +1086,14 @@ export async function startWechatLogin( context: AppContext, callbackUrl: string, redirectPath: string, + requestContext: RefreshSessionRequestContext | null = null, ): Promise { const stateRecord = context.wechatAuthStates.create(redirectPath); return { authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({ callbackUrl, state: stateRecord.state, + userAgent: requestContext?.userAgent ?? null, }), }; } diff --git a/server-node/src/config.test.ts b/server-node/src/config.test.ts new file mode 100644 index 00000000..48d8fdb2 --- /dev/null +++ b/server-node/src/config.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { loadConfig } from './config.ts'; + +function createTempProjectRoot(prefix: string) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +test('development config auto-enables aliyun sms auth when local credentials are provided', () => { + const projectRoot = createTempProjectRoot('genarrative-config-dev-'); + fs.writeFileSync( + path.join(projectRoot, '.env.example'), + 'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n', + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, '.env.local'), + 'ALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n', + 'utf8', + ); + + const config = loadConfig({ + projectRoot, + env: { + NODE_ENV: 'development', + }, + }); + + assert.equal(config.smsAuth.enabled, true); + assert.equal(config.smsAuth.provider, 'aliyun'); + assert.equal(config.smsAuth.accessKeyId, 'test-ak'); + assert.equal(config.smsAuth.accessKeySecret, 'test-sk'); +}); + +test('development config respects explicit local sms auth overrides', () => { + const projectRoot = createTempProjectRoot('genarrative-config-local-'); + fs.writeFileSync( + path.join(projectRoot, '.env.example'), + 'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n', + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, '.env.local'), + 'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\nALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n', + 'utf8', + ); + + const config = loadConfig({ + projectRoot, + env: { + NODE_ENV: 'development', + }, + }); + + assert.equal(config.smsAuth.enabled, false); + assert.equal(config.smsAuth.provider, 'aliyun'); +}); diff --git a/server-node/src/config.ts b/server-node/src/config.ts index 9a806f5c..d59dc300 100644 --- a/server-node/src/config.ts +++ b/server-node/src/config.ts @@ -131,14 +131,39 @@ function resolveDefaultProjectRoot() { : cwd; } -function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) { +function readMergedEnv( + exampleEnv: Record, + localEnv: Record, + processEnv: NodeJS.ProcessEnv, +) { return { - ...readEnvFile(path.join(projectRoot, '.env.example')), - ...readEnvFile(path.join(projectRoot, '.env.local')), + ...exampleEnv, + ...localEnv, ...processEnv, }; } +function hasOwnEnvKey( + env: Record, + key: string, +) { + return Object.prototype.hasOwnProperty.call(env, key); +} + +function readBooleanOverride( + env: Record, + overrideSources: Array>, + key: string, + fallback: boolean, +) { + const hasOverride = overrideSources.some((source) => hasOwnEnvKey(source, key)); + if (!hasOverride) { + return fallback; + } + + return readBoolean(env, key, fallback); +} + function readString( env: Record, key: string, @@ -199,33 +224,51 @@ function readBoolean( export function loadConfig(options: LoadConfigOptions = {}): AppConfig { const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot(); - const env = readMergedEnv(projectRoot, options.env ?? process.env); + const exampleEnv = readEnvFile(path.join(projectRoot, '.env.example')); + const localEnv = readEnvFile(path.join(projectRoot, '.env.local')); + const processEnv = options.env ?? process.env; + const env = readMergedEnv(exampleEnv, localEnv, processEnv); const logsDir = path.join(projectRoot, 'server-node', 'logs'); const dataDir = path.join(projectRoot, 'server-node', 'data'); - const defaultEditorApiEnabled = readString(env, 'NODE_ENV', 'development') !== 'production'; + const nodeEnv = readString(env, 'NODE_ENV', 'development'); + const defaultEditorApiEnabled = nodeEnv !== 'production'; const editorApiEnabled = readBoolean( env, 'EDITOR_API_ENABLED', defaultEditorApiEnabled, ); - const smsProvider = readString( + const smsProviderFromEnv = readString( env, 'SMS_AUTH_PROVIDER', - readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'aliyun', + nodeEnv === 'test' ? 'mock' : 'aliyun', ) as AppConfig['smsAuth']['provider']; const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', ''); const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', ''); + const smsProvider = smsProviderFromEnv; const defaultSmsEnabled = - smsProvider === 'mock' || Boolean(smsAccessKeyId && smsAccessKeySecret); + smsProvider === 'mock' || + Boolean(smsAccessKeyId && smsAccessKeySecret); + const smsEnabled = readBooleanOverride( + env, + [localEnv, processEnv], + 'SMS_AUTH_ENABLED', + defaultSmsEnabled, + ); const wechatProvider = readString( env, 'WECHAT_AUTH_PROVIDER', - readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'wechat', + nodeEnv === 'test' ? 'mock' : 'wechat', ) as AppConfig['wechatAuth']['provider']; const wechatAppId = readString(env, 'WECHAT_APP_ID', ''); const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', ''); const defaultWechatEnabled = wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret); + const wechatEnabled = readBooleanOverride( + env, + [localEnv, processEnv], + 'WECHAT_AUTH_ENABLED', + defaultWechatEnabled, + ); const refreshSameSite = readString( env, 'AUTH_REFRESH_COOKIE_SAME_SITE', @@ -233,7 +276,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { ); return { - nodeEnv: readString(env, 'NODE_ENV', 'development'), + nodeEnv, projectRoot, publicDir: path.join(projectRoot, 'public'), logsDir, @@ -295,7 +338,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { ), }, smsAuth: { - enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled), + enabled: smsEnabled, provider: smsProvider, endpoint: readString( env, @@ -410,7 +453,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { ), }, wechatAuth: { - enabled: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled), + enabled: wechatEnabled, provider: wechatProvider, appId: wechatAppId, appSecret: wechatAppSecret, diff --git a/server-node/src/modules/ai/chatPromptBuilders.ts b/server-node/src/modules/ai/chatPromptBuilders.ts index 04f19684..e2ec03af 100644 --- a/server-node/src/modules/ai/chatPromptBuilders.ts +++ b/server-node/src/modules/ai/chatPromptBuilders.ts @@ -73,9 +73,9 @@ function readStringArray(value: unknown) { function describeWorld(worldType: string) { switch (worldType) { case 'WUXIA': - return '武侠'; + return '边城模板'; case 'XIANXIA': - return '仙侠'; + return '灵潮模板'; case 'CUSTOM': return '自定义世界'; default: diff --git a/server-node/src/modules/ai/customWorldOrchestrator.ts b/server-node/src/modules/ai/customWorldOrchestrator.ts index fef54f5a..90ad1696 100644 --- a/server-node/src/modules/ai/customWorldOrchestrator.ts +++ b/server-node/src/modules/ai/customWorldOrchestrator.ts @@ -65,8 +65,8 @@ function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') { generatedFrom: { worldType, worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城', - settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界', - tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震', + settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界', + tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震', conflictCore: '旧秩序与新威胁正在同时逼近', }, schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴', @@ -371,9 +371,10 @@ function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) { name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`, subtitle: '前路未明', summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`, - tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震', + tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震', playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事', templateWorldType: worldType, + compatibilityTemplateWorldType: worldType, majorFactions: inferMajorFactions(seed), coreConflicts: inferCoreConflicts(setting), attributeSchema: buildAttributeSchema(worldType), diff --git a/server-node/src/modules/ai/storyPromptBuilders.ts b/server-node/src/modules/ai/storyPromptBuilders.ts index 484e3f6e..f1ffcccf 100644 --- a/server-node/src/modules/ai/storyPromptBuilders.ts +++ b/server-node/src/modules/ai/storyPromptBuilders.ts @@ -11,9 +11,9 @@ function readNumber(value: unknown, fallback = 0) { function describeWorld(worldType: string) { switch (worldType) { case 'WUXIA': - return '武侠'; + return '边城模板'; case 'XIANXIA': - return '仙侠'; + return '灵潮模板'; case 'CUSTOM': return '自定义世界'; default: diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index 76acdc8c..9fb3634d 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -709,7 +709,7 @@ function buildNpcVisualPrompt( .filter(Boolean) .join('\n'); - return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。'); + return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。'); } function buildImageSequencePrompt( diff --git a/server-node/src/modules/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts index e0c447a1..6c5c6b72 100644 --- a/server-node/src/modules/quest/runtimeQuestModule.ts +++ b/server-node/src/modules/quest/runtimeQuestModule.ts @@ -757,9 +757,9 @@ function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) { function describeWorld(worldType: QuestGenerationContext['worldType']) { switch (worldType) { case 'WUXIA': - return '武侠'; + return '边城模板'; case 'XIANXIA': - return '仙侠'; + return '灵潮模板'; case 'CUSTOM': return '自定义世界'; default: diff --git a/server-node/src/routes/authRoutes.ts b/server-node/src/routes/authRoutes.ts index 3aa6b62c..4afe0e86 100644 --- a/server-node/src/routes/authRoutes.ts +++ b/server-node/src/routes/authRoutes.ts @@ -11,6 +11,7 @@ import type { import { buildAuthRequestContext } from '../auth/authRequestContext.js'; import { bindWechatPhone, + buildAuthLoginOptionsResponse, buildAuthMeResponse, changeUserPhone, createRefreshSession, @@ -115,6 +116,14 @@ export function createAuthRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); + router.get( + '/login-options', + routeMeta({ operation: 'auth.login_options' }), + asyncHandler(async (_request, response) => { + sendApiResponse(response, buildAuthLoginOptionsResponse(context)); + }), + ); + router.post( '/entry', routeMeta({ operation: 'auth.entry' }), @@ -232,6 +241,7 @@ export function createAuthRoutes(context: AppContext) { request.query.redirectPath, context.config.wechatAuth.defaultRedirectPath, ); + const requestContext = buildAuthRequestContext(request); const callbackUrl = new URL( context.config.wechatAuth.callbackPath, resolveRequestOrigin(request), @@ -239,7 +249,12 @@ export function createAuthRoutes(context: AppContext) { sendApiResponse( response, - await startWechatLogin(context, callbackUrl, redirectPath), + await startWechatLogin( + context, + callbackUrl, + redirectPath, + requestContext, + ), ); }), ); diff --git a/server-node/src/services/smsVerificationService.ts b/server-node/src/services/smsVerificationService.ts index 6548cec3..f32d08a8 100644 --- a/server-node/src/services/smsVerificationService.ts +++ b/server-node/src/services/smsVerificationService.ts @@ -33,6 +33,18 @@ function isAliyunConfigMissing(config: AppConfig['smsAuth']) { return !config.accessKeyId || !config.accessKeySecret; } +function assertAliyunRequiredConfig(config: AppConfig['smsAuth']) { + if (!config.signName.trim()) { + throw new Error('ALIYUN_SMS_SIGN_NAME 未配置'); + } + if (!config.templateCode.trim()) { + throw new Error('ALIYUN_SMS_TEMPLATE_CODE 未配置'); + } + if (!config.templateParamKey.trim()) { + throw new Error('ALIYUN_SMS_TEMPLATE_PARAM_KEY 未配置'); + } +} + function buildProviderErrorMessage(prefix: string, message: string) { const normalizedMessage = message.trim(); return normalizedMessage ? `${prefix}:${normalizedMessage}` : prefix; @@ -48,6 +60,7 @@ class AliyunSmsVerificationService implements SmsVerificationService { if (isAliyunConfigMissing(config)) { throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置'); } + assertAliyunRequiredConfig(config); const clientConfig = new OpenApiClient.Config({ accessKeyId: config.accessKeyId, diff --git a/server-node/src/services/wechatAuthService.ts b/server-node/src/services/wechatAuthService.ts index 7501a153..8bc18db3 100644 --- a/server-node/src/services/wechatAuthService.ts +++ b/server-node/src/services/wechatAuthService.ts @@ -15,6 +15,7 @@ export type WechatAuthService = { buildAuthorizationUrl(params: { callbackUrl: string; state: string; + userAgent?: string | null; }): string; resolveCallbackProfile(params: { code?: string | null; @@ -22,12 +23,40 @@ export type WechatAuthService = { }): Promise; }; +type WechatAuthorizationScene = 'desktop' | 'wechat_in_app'; + +const WECHAT_IN_APP_AUTHORIZE_ENDPOINT = + 'https://open.weixin.qq.com/connect/oauth2/authorize'; + +function isWechatBrowser(userAgent?: string | null) { + return /MicroMessenger/iu.test(userAgent ?? ''); +} + +function isMobileBrowser(userAgent?: string | null) { + return /Android|iPhone|iPad|iPod|Mobile/iu.test(userAgent ?? ''); +} + +function resolveWechatAuthorizationScene( + userAgent?: string | null, +): WechatAuthorizationScene { + if (isWechatBrowser(userAgent)) { + return 'wechat_in_app'; + } + + if (isMobileBrowser(userAgent)) { + throw badRequest('当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录'); + } + + return 'desktop'; +} + class MockWechatAuthService implements WechatAuthService { constructor(private readonly config: AppConfig['wechatAuth']) {} buildAuthorizationUrl(params: { callbackUrl: string; state: string; + userAgent?: string | null; }) { const callbackUrl = new URL(params.callbackUrl); callbackUrl.searchParams.set('mock_code', this.config.mockUserId); @@ -64,12 +93,21 @@ class RealWechatAuthService implements WechatAuthService { buildAuthorizationUrl(params: { callbackUrl: string; state: string; + userAgent?: string | null; }) { - const url = new URL(this.config.authorizeEndpoint); + const scene = resolveWechatAuthorizationScene(params.userAgent); + const url = new URL( + scene === 'wechat_in_app' + ? WECHAT_IN_APP_AUTHORIZE_ENDPOINT + : this.config.authorizeEndpoint, + ); url.searchParams.set('appid', this.config.appId); url.searchParams.set('redirect_uri', params.callbackUrl); url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', 'snsapi_login'); + url.searchParams.set( + 'scope', + scene === 'wechat_in_app' ? 'snsapi_userinfo' : 'snsapi_login', + ); url.searchParams.set('state', params.state); return `${url.toString()}#wechat_redirect`; } diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 9d6d2fec..620d9765 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -5,7 +5,7 @@ import { createPortal } from 'react-dom'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels'; import { buildCustomWorldPlayableCharacters, - PRESET_CHARACTERS, + ROLE_TEMPLATE_CHARACTERS, } from '../data/characterPresets'; import { CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS, @@ -522,18 +522,18 @@ function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) { if (candidates.length === 1) { const firstCandidate = candidates[0]; if (!firstCandidate) { - return PRESET_CHARACTERS.slice(0, 2); + return ROLE_TEMPLATE_CHARACTERS.slice(0, 2); } const fallback = - PRESET_CHARACTERS.find( + ROLE_TEMPLATE_CHARACTERS.find( (character) => character.id !== firstCandidate.id, ) ?? - PRESET_CHARACTERS[0] ?? + ROLE_TEMPLATE_CHARACTERS[0] ?? firstCandidate; return [firstCandidate, fallback]; } - return PRESET_CHARACTERS.slice(0, 2); + return ROLE_TEMPLATE_CHARACTERS.slice(0, 2); }, [profile]); const [leftCharacter, rightCharacter] = sparringCharacters; @@ -1622,10 +1622,10 @@ function PlayableNpcEditor({ const [draft, setDraft] = useDraft(npc); const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false); const selectedTemplate = - PRESET_CHARACTERS.find( + ROLE_TEMPLATE_CHARACTERS.find( (character) => character.id === draft.templateCharacterId, ) ?? - PRESET_CHARACTERS[0] ?? + ROLE_TEMPLATE_CHARACTERS[0] ?? null; return ( @@ -1682,14 +1682,14 @@ function PlayableNpcEditor({ setDraft((current) => ({ ...current, templateCharacterId: value, })) } - options={PRESET_CHARACTERS.map((character) => ({ + options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({ value: character.id, label: `${character.name} / ${character.title}`, }))} @@ -1838,7 +1838,7 @@ function PlayableNpcEditor({ onSave({ ...draft, templateCharacterId: - draft.templateCharacterId ?? PRESET_CHARACTERS[0]?.id, + draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id, }); onClose(); }} @@ -2548,9 +2548,9 @@ function createPlayableNpc( ): CustomWorldPlayableNpc { const seed = Date.now() + profile.playableNpcs.length; const template = - PRESET_CHARACTERS[ - profile.playableNpcs.length % Math.max(1, PRESET_CHARACTERS.length) - ] ?? PRESET_CHARACTERS[0]; + ROLE_TEMPLATE_CHARACTERS[ + profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length) + ] ?? ROLE_TEMPLATE_CHARACTERS[0]; return { id: createEntryId( diff --git a/src/components/CustomWorldRoleAssetStudioModal.tsx b/src/components/CustomWorldRoleAssetStudioModal.tsx index dc4ffd11..86bed95b 100644 --- a/src/components/CustomWorldRoleAssetStudioModal.tsx +++ b/src/components/CustomWorldRoleAssetStudioModal.tsx @@ -6,7 +6,7 @@ import { } from 'lucide-react'; import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react'; -import { PRESET_CHARACTERS } from '../data/characterPresets'; +import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets'; import { AnimationState, type CustomWorldNpc, @@ -17,7 +17,7 @@ import { buildAnimationClipFromVideoSource, type DraftAnimationClip, readFileAsDataUrl, -} from './preset-editor/characterAssetStudioModel'; +} from './asset-studio/characterAssetWorkflowModel'; import { type CharacterAnimationDraftPayload, type CharacterAnimationGenerationPayload, @@ -27,7 +27,7 @@ import { generateCharacterVisualCandidates, publishCharacterAnimationAssets, publishCharacterVisualAsset, -} from './preset-editor/characterAssetStudioPersistence'; +} from './asset-studio/characterAssetWorkflowPersistence'; type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc; @@ -353,7 +353,7 @@ export function CustomWorldRoleAssetStudioModal({ const selectedTemplate = roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId - ? PRESET_CHARACTERS.find( + ? ROLE_TEMPLATE_CHARACTERS.find( (character) => character.id === role.templateCharacterId, ) ?? null : null; @@ -661,7 +661,7 @@ export function CustomWorldRoleAssetStudioModal({ value={visualPromptText} onChange={setVisualPromptText} rows={6} - placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。" + placeholder="例如:衣摆更利落、主武器辨识度更高、整体更像边境世界的年轻冒险者。" /> diff --git a/src/components/ItemCatalogEditor.tsx b/src/components/ItemCatalogEditor.tsx deleted file mode 100644 index fdab86db..00000000 --- a/src/components/ItemCatalogEditor.tsx +++ /dev/null @@ -1,908 +0,0 @@ -import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react'; - -import { PRESET_CHARACTERS } from '../data/characterPresets'; -import { getInventoryItemValue } from '../data/economy'; -import { validateItemOverrides } from '../data/editorValidation'; -import { getEquipmentSlotFromItem, getEquipmentSlotLabel } from '../data/equipmentEffects'; -import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects'; -import { - applyItemCatalogOverride, - buildItemCatalogFromAssetPaths, - createInventoryItemFromCatalogEntry, - ITEM_CATALOG_API_PATH, - ITEM_CATEGORY_OPTIONS, -} from '../data/itemCatalog'; -import { - EDITOR_JSON_RESOURCE_IDS, - fetchEditorJsonResource, - saveEditorJsonResource, -} from '../editor/shared/editorApiClient'; -import { fetchJson } from '../editor/shared/jsonClient'; -import { SectionCard as Section } from '../editor/shared/SectionCard'; -import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types'; -import { PixelIcon } from './PixelIcon'; - -const ITEM_PREVIEW_CHARACTER = PRESET_CHARACTERS[0] ?? null; -const LIST_PREVIEW_LIMIT = 240; - -type ItemCatalogAssetResponse = { - assetPaths: string[]; -}; - -const RARITY_OPTIONS: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary']; -const RARITY_LABELS: Record = { - common: '普通', - uncommon: '不普通', - rare: '稀有', - epic: '史诗', - legendary: '传奇', - }; - -function arraysEqual(left: string[], right: string[]) { - if (left.length !== right.length) return false; - return left.every((value, index) => value === right[index]); -} - -function parseTagsInput(value: string) { - return [...new Set( - value - .split(/[\n,]/u) - .map(tag => tag.trim()) - .filter(Boolean), - )]; -} - -function tagsInputValue(tags: string[]) { - return tags.join(', '); -} - -function parseBuildBuffLines( - value: string, - sourceType: TimedBuildBuff['sourceType'], - sourceId: string, -) { - return value - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - .map((line, index) => { - const [namePart, tagsPart, durationPart] = line.split('|').map(part => part.trim()); - const tags = parseTagsInput(tagsPart ?? ''); - const durationTurns = Math.max(1, Number(durationPart ?? '1') || 1); - return { - id: `${sourceId}-buff-${index + 1}`, - sourceType, - sourceId, - name: namePart || `${sourceId}-buff-${index + 1}`, - tags, - durationTurns, - } satisfies TimedBuildBuff; - }) - .filter(buff => buff.tags.length > 0); -} - -function buildBuffLinesValue(buffs: TimedBuildBuff[] | null | undefined) { - return (buffs ?? []) - .map(buff => `${buff.name}|${buff.tags.join(',')}|${buff.durationTurns}`) - .join('\n'); -} - -function Label({ children }: { children: ReactNode }) { - return
{children}
; -} - -function TextInput({ - value, - onChange, - placeholder, - disabled = false, -}: { - value: string; - onChange: (value: string) => void; - placeholder?: string; - disabled?: boolean; -}) { - return ( - onChange(event.target.value)} - placeholder={placeholder} - disabled={disabled} - className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60" - /> - ); -} - -function TextArea({ - value, - onChange, - rows = 4, -}: { - value: string; - onChange: (value: string) => void; - rows?: number; -}) { - return ( -