diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 6468f9d3..d6c9d221 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -198,7 +198,8 @@ ## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化 - 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 -- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 +- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 +- 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f0dc0a4f..c62d83a2 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -458,8 +458,8 @@ - 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。 - 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。 -- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。 -- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,且 shell 变量仍然最高优先级。 +- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。本地认证开关例外:`SMS_AUTH_ENABLED`、`SMS_AUTH_PROVIDER` 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 `.env.local`。 +- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,真实密钥 shell 变量仍然最高优先级;`mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" })` 在 `.env.local` 写 `SMS_AUTH_ENABLED=true` 时应返回 true。 - 关联:`scripts/dev-utils.mjs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。 ## OSS 密钥键名不要把字母 O 写成数字 0 @@ -488,23 +488,23 @@ ## 本地短信登录页签突然消失 - 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。 -- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类: +- 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。 - 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。 - Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。 - 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。 - 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。 - 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。 -- 处理:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local` 的 `SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启。 -- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。 -- 关联:`scripts/dev-utils.mjs`、`scripts/dev.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。 +- 处理:当前口径是登录弹窗永远展示 `短信登录` 与 `密码登录` 两个核心入口;`login-options` 只补充微信等环境相关入口,不能隐藏短信或密码页签。如果“获取验证码”点击后失败,再按短信 provider / API 代理问题排查:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,确认 `.env.local` 覆盖 `.env`、`RUST_SERVER_TARGET` 没有指向旧端口,并分别请求 3000 域名和 Rust API 目标。 +- 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录`、`密码登录`、`验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。 +- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/dev-utils.mjs`、`scripts/dev.mjs`。 ## 本地短信收不到验证码先查 provider - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 -- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。 -- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。`api-server` 重启会清掉未校验的本地验证码。 -- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效;需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET` 和 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`。 -- 关联:`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。 +- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程,Rust bool 解析失败后保持默认 false。 +- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_ENABLED=true`、`SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`,cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。 +- 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]`;`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET` 和 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`。 +- 关联:`server-rs/crates/api-server/src/config.rs`、`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。 ## 手机验证码登录 500 先查短信 provider 语义 @@ -951,3 +951,19 @@ - 处理:把 `jenkins/Jenkinsfile.production-stdb-module-build` 的 `Checkout` 和 `Build Stdb Module` 两处 `powershell` step 收口成 `runWindowsPowerShell(...)` helper,先用 `writeFile` 写出临时 `.ps1`,再用显式 `powershell.exe` 把脚本重写成 UTF-8 with BOM,最后通过 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` 执行。这个 helper 写在 Groovy GString 里时,PowerShell 的 `$path` / `$text` / `$true` 必须写成 `\$path` / `\$text` / `\$true`,否则 Jenkinsfile 会在 Groovy 编译阶段报 `unexpected token: true`。Checkout 阶段优先复用 Jenkins GitSCM 已完成的工作区结果;`COMMIT_HASH` 为空或已经等于当前 `HEAD` 时不再重复 `git fetch` / `git checkout` / `git clean`,只有确实要切到另一个指定 commit 时才补 fetch、归属校验和 checkout。 - 验证:检查 Jenkins build log 中是否出现 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,以及 `[stdb-checkout] current HEAD:`。上游 Full Build 传下来的 `COMMIT_HASH` 若已等于当前 GitSCM checkout,日志应显示 `requested commit already matches Jenkins GitSCM checkout` 并继续进入构建阶段;同时确认 `builds//log` 不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或 Checkout 内部 exit code 5。 - 关联:`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底 + +- 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。 +- 原因:公开作品卡封面内部图片是绝对铺满,容器原本主要依赖 Tailwind `aspect-video` / CSS `aspect-ratio` 撑高;旧内核不支持或实现异常时封面容器高度会坍缩为 0。若封面还是 `/generated-*` 私有资源,换签失败后没有玩法参考图兜底时会进一步表现成黑卡。 +- 处理:`.platform-public-work-card__cover::before` 使用 `padding-top: 56.25%` 保留 16:9 高度,沉浸式卡片单独覆盖比例;公开作品卡通过 `resolvePlatformWorldFallbackCoverImage(...)` 给 `ResolvedAssetImage` 传入玩法参考图兜底,签名失败或图片加载失败时仍有可见封面。 +- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 +- 关联:`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 生成中草稿刷新后不要只恢复作品架遮罩 + +- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。 +- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。 +- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。 +- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 244f4173..d65b4209 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -220,7 +220,7 @@ ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486 ALIYUN_SMS_TEMPLATE_PARAM_KEY=code ``` -阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH`、`ALIYUN_SMS_CODE_TYPE`、`ALIYUN_SMS_RETURN_VERIFY_CODE`、`ALIYUN_SMS_CASE_AUTH_POLICY`、`ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun`、`SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`。 +阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH`、`ALIYUN_SMS_CODE_TYPE`、`ALIYUN_SMS_RETURN_VERIFY_CODE`、`ALIYUN_SMS_CASE_AUTH_POLICY`、`ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun`、`SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`。修改 `.env.local` 后必须重启 `api-server`,再用 `/api/auth/login-options` 确认返回包含 `phone`;如果通过 shell 临时覆盖,PowerShell 使用 `$env:SMS_AUTH_ENABLED="true"`,cmd 使用 `set SMS_AUTH_ENABLED=true`,不要把引号作为环境变量值的一部分传给进程。 如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3bdad79f..c82e2a6b 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -17,7 +17,8 @@ 3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 -6. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。 +7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 ## 拼图 @@ -31,8 +32,8 @@ - 图像输入复用 `CreativeImageInputPanel`。 - 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。 -- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;当前不自动生成背景音乐。 -- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。 - 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 - 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。 - 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。 @@ -162,3 +163,4 @@ 3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。 4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。 5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。 +6. 发现页 / 推荐流公开作品卡封面必须兼容旧移动浏览器内核:封面容器不能只依赖 CSS `aspect-ratio` 撑高,必须保留 16:9 或对应沉浸卡比例的可见高度兜底;generated 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index d04c7773..7b539e81 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -39,6 +39,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。 +## 账户与登录 + +1. 主站登录弹窗必须稳定展示 `短信登录` 与 `密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。 +2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。 +3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 + ## 账户与充值 1. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。 diff --git a/scripts/check-api-server-env.mjs b/scripts/check-api-server-env.mjs index 9a9932ef..212523cc 100644 --- a/scripts/check-api-server-env.mjs +++ b/scripts/check-api-server-env.mjs @@ -27,6 +27,10 @@ function printStatus(key, present) { const env = mergeApiServerEnv(process.cwd(), process.env); const missing = []; +console.log('[api-server-env] 认证短信配置检查'); +printStatus('SMS_AUTH_ENABLED', env.SMS_AUTH_ENABLED === 'true'); +printStatus('SMS_AUTH_PROVIDER', hasValue(env.SMS_AUTH_PROVIDER)); + console.log('[api-server-env] 拼图真实生成配置检查'); for (const key of REQUIRED_FOR_PUZZLE_GENERATION) { const present = hasValue(env[key]); diff --git a/scripts/dev-utils.mjs b/scripts/dev-utils.mjs index 39d12fcf..e3e24402 100644 --- a/scripts/dev-utils.mjs +++ b/scripts/dev-utils.mjs @@ -2,6 +2,13 @@ import {existsSync, mkdirSync, readFileSync} from 'node:fs'; import {dirname, isAbsolute, resolve} from 'node:path'; export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local']; +const LOCAL_ENV_OVERRIDE_KEYS = new Set([ + 'SMS_AUTH_ENABLED', + 'SMS_AUTH_PROVIDER', + 'SMS_AUTH_MOCK_VERIFY_CODE', + 'WECHAT_AUTH_ENABLED', + 'WECHAT_AUTH_PROVIDER', +]); export function buildProtectedEnvKeys(baseEnv) { return new Set( @@ -29,7 +36,7 @@ export function loadEnvFile(path, target, protectedKeys) { } const [, key, rawValue] = match; - if (protectedKeys.has(key)) { + if (protectedKeys.has(key) && !LOCAL_ENV_OVERRIDE_KEYS.has(key)) { continue; } diff --git a/scripts/dev-utils.test.ts b/scripts/dev-utils.test.ts index a9f3b902..aeabfcee 100644 --- a/scripts/dev-utils.test.ts +++ b/scripts/dev-utils.test.ts @@ -68,6 +68,26 @@ describe('dev utils env merge', () => { ); }); + test('本地认证开关覆盖外层 shell 旧值', () => { + withTempEnvFiles( + { + '.env.local': [ + 'SMS_AUTH_ENABLED=true', + 'SMS_AUTH_PROVIDER=aliyun', + ].join('\n'), + }, + (_env, tempDir) => { + const env = mergeApiServerEnv(tempDir, { + SMS_AUTH_ENABLED: 'false', + SMS_AUTH_PROVIDER: 'mock', + }); + + expect(env.SMS_AUTH_ENABLED).toBe('true'); + expect(env.SMS_AUTH_PROVIDER).toBe('aliyun'); + }, + ); + }); + test('空外层 shell 变量不会遮蔽本地私密配置', () => { withTempEnvFiles( { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 13a62372..306d557c 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -978,6 +978,16 @@ fn parse_duration_seconds(raw: &str) -> Option { } fn parse_bool(raw: &str) -> Option { + let raw = raw.trim(); + let raw = raw + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| { + raw.strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + }) + .unwrap_or(raw); + match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), @@ -1053,7 +1063,9 @@ fn parse_positive_u16(raw: &str) -> Option { #[cfg(test)] mod tests { - use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider}; + use super::{ + AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool, + }; use std::sync::{Mutex, OnceLock}; static ENV_LOCK: OnceLock> = OnceLock::new(); @@ -1086,6 +1098,34 @@ mod tests { ); } + #[test] + fn parse_bool_accepts_wrapped_quotes_from_shell_env() { + assert_eq!(parse_bool("\"true\""), Some(true)); + assert_eq!(parse_bool("'true'"), Some(true)); + assert_eq!(parse_bool("\"false\""), Some(false)); + assert_eq!(parse_bool("'off'"), Some(false)); + } + + #[test] + fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("SMS_AUTH_ENABLED"); + std::env::set_var("SMS_AUTH_ENABLED", "\"true\""); + } + + let config = AppConfig::from_env(); + assert!(config.sms_auth_enabled); + + unsafe { + std::env::remove_var("SMS_AUTH_ENABLED"); + } + } + #[test] fn from_env_reads_non_public_models_and_urls() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 018c02c7..6aef164c 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -134,15 +134,3916 @@ const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2"; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素"; +<<<<<<< Updated upstream mod handlers; pub(crate) use self::handlers::*; +======= +pub async fn create_puzzle_agent_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + let seed_text = build_puzzle_form_seed_text(&payload); + let session = state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: build_prefixed_uuid_id("puzzle-session-"), + owner_user_id: authenticated.claims().user_id().to_string(), + seed_text: seed_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&seed_text), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn generate_puzzle_onboarding_work( + State(state): State, + Extension(request_context): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + let prompt_text = payload.prompt_text.trim().to_string(); + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &prompt_text, + "promptText", + )?; + + let now = current_utc_micros(); + let session_id = build_prefixed_uuid_id("puzzle-onboarding-"); + let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; + let tags = + generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; + let candidates = generate_puzzle_image_candidates( + &state, + "onboarding-guest", + session_id.as_str(), + naming.level_name.as_str(), + prompt_text.as_str(), + None, + false, + Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2), + 1, + 0, + ) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_generation_endpoint_error(error), + ) + })? + .into_records(); + let selected = candidates.first().cloned().ok_or_else(|| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "新手引导拼图图片生成结果为空", + })), + ) + })?; + let level = PuzzleDraftLevelRecord { + level_id: "onboarding-level-1".to_string(), + level_name: naming.level_name.clone(), + picture_description: prompt_text.clone(), + picture_reference: None, + ui_background_prompt: naming.ui_background_prompt.clone(), + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + candidates, + selected_candidate_id: Some(selected.candidate_id.clone()), + cover_image_src: Some(selected.image_src.clone()), + cover_asset_id: Some(selected.asset_id.clone()), + generation_status: "ready".to_string(), + }; + let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack( + naming.level_name.as_str(), + level.picture_description.as_str(), + )); + let item = PuzzleWorkProfileRecord { + work_id: format!("onboarding-work-{now}"), + profile_id: format!("onboarding-profile-{now}"), + owner_user_id: "onboarding-guest".to_string(), + source_session_id: None, + author_display_name: "陶泥儿主".to_string(), + work_title: naming.level_name.clone(), + work_description: prompt_text.clone(), + level_name: naming.level_name, + summary: prompt_text, + theme_tags: tags, + cover_image_src: level.cover_image_src.clone(), + cover_asset_id: level.cover_asset_id.clone(), + publication_status: "draft".to_string(), + updated_at: format_timestamp_micros(now), + published_at: None, + play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, + anchor_pack, + publish_ready: true, + levels: vec![level.clone()], + }; + + Ok(json_success_body( + Some(&request_context), + PuzzleOnboardingGenerateResponse { + item: map_puzzle_work_profile_response(&state, item.clone()).summary, + level: map_puzzle_draft_level_response(level), + }, + )) +} + +pub async fn save_puzzle_onboarding_work( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + let prompt_text = payload.prompt_text.trim().to_string(); + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &prompt_text, + "promptText", + )?; + + let first_level = payload.item.levels.first().cloned().ok_or_else(|| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": "新手引导拼图缺少可保存关卡", + })), + ) + })?; + let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?; + let work_title = payload.item.work_title.trim(); + let work_title = if work_title.is_empty() { + first_level.level_name.clone() + } else { + work_title.to_string() + }; + let work_description = payload.item.work_description.trim(); + let work_description = if work_description.is_empty() { + prompt_text.clone() + } else { + work_description.to_string() + }; + let summary = payload.item.summary.trim(); + let summary = if summary.is_empty() { + first_level.picture_description.clone() + } else { + summary.to_string() + }; + let now = current_utc_micros(); + let owner_user_id = authenticated.claims().user_id().to_string(); + let session_id = build_prefixed_uuid_id("puzzle-session-"); + state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + seed_text: prompt_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&prompt_text), + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str()); + let item = state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id, + work_title, + work_description, + level_name: first_level.level_name, + summary, + theme_tags: payload.item.theme_tags, + cover_image_src: first_level.cover_image_src, + cover_asset_id: first_level.cover_asset_id, + levels_json: Some(levels_json), + updated_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn get_puzzle_agent_session( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn submit_puzzle_agent_message( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let client_message_id = payload.client_message_id.trim().to_string(); + let message_text = payload.text.trim().to_string(); + if client_message_id.is_empty() || message_text.is_empty() { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "clientMessageId and text are required", + )); + } + + let owner_user_id = authenticated.claims().user_id().to_string(); + let submitted_session = state + .spacetime_client() + .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + user_message_id: client_message_id, + user_message_text: message_text, + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let turn_result = run_puzzle_agent_turn( + PuzzleAgentTurnRequest { + llm_client: state.llm_client(), + session: &submitted_session, + quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), + enable_web_search: state.config.creation_agent_llm_web_search_enabled, + }, + |_| {}, + ) + .await; + let finalize_input = match turn_result { + Ok(turn_result) => build_finalize_record_input( + session_id.clone(), + owner_user_id.clone(), + format!("assistant-{session_id}-{}", current_utc_micros()), + turn_result, + current_utc_micros(), + ), + Err(error) => build_failed_finalize_record_input( + session_id.clone(), + owner_user_id.clone(), + &submitted_session, + error.to_string(), + current_utc_micros(), + ), + }; + let session = state + .spacetime_client() + .finalize_puzzle_agent_message(finalize_input) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn stream_puzzle_agent_message( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false); + let session = state + .spacetime_client() + .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + user_message_id: payload.client_message_id.trim().to_string(), + user_message_text: payload.text.trim().to_string(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let state = state.clone(); + let session_id_for_stream = session_id.clone(); + let owner_user_id_for_stream = owner_user_id.clone(); + let stream = async_stream::stream! { + let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( + "puzzle", + owner_user_id_for_stream.as_str(), + session_id_for_stream.as_str(), + payload.client_message_id.as_str(), + "拼图模板生成草稿", + )); + if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { + tracing::warn!(error = %error, "拼图模板生成草稿任务启动失败,主生成流程继续执行"); + } + let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::(); + let turn_result = { + let run_turn = run_puzzle_agent_turn( + PuzzleAgentTurnRequest { + llm_client: state.llm_client(), + session: &session, + quick_fill_requested, + enable_web_search: state.config.creation_agent_llm_web_search_enabled, + }, + move |text| { + let _ = reply_tx.send(text.to_string()); + }, + ); + tokio::pin!(run_turn); + + loop { + tokio::select! { + result = &mut run_turn => break result, + maybe_text = reply_rx.recv() => { + if let Some(text) = maybe_text { + draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; + yield Ok::(puzzle_sse_json_event_or_error( + "reply_delta", + json!({ "text": text }), + )); + } + } + } + } + }; + + while let Some(text) = reply_rx.recv().await { + draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; + yield Ok::(puzzle_sse_json_event_or_error( + "reply_delta", + json!({ "text": text }), + )); + } + + let finalize_input = match turn_result { + Ok(turn_result) => build_finalize_record_input( + session_id_for_stream.clone(), + owner_user_id_for_stream.clone(), + format!("assistant-{session_id_for_stream}-{}", current_utc_micros()), + turn_result, + current_utc_micros(), + ), + Err(error) => build_failed_finalize_record_input( + session_id_for_stream.clone(), + owner_user_id_for_stream.clone(), + &session, + error.to_string(), + current_utc_micros(), + ), + }; + let finalize_result = state + .spacetime_client() + .finalize_puzzle_agent_message(finalize_input) + .await; + let _final_session = match finalize_result { + Ok(session) => session, + Err(error) => { + yield Ok::(puzzle_sse_json_event_or_error( + "error", + json!({ "message": error.to_string() }), + )); + return; + } + }; + let final_session = match state + .spacetime_client() + .get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream) + .await + { + Ok(session) => session, + Err(error) => { + yield Ok::(puzzle_sse_json_event_or_error( + "error", + json!({ "message": error.to_string() }), + )); + return; + } + }; + let session_response = map_puzzle_agent_session_response(final_session); + yield Ok::(puzzle_sse_json_event_or_error( + "session", + json!({ "session": session_response }), + )); + yield Ok::(puzzle_sse_json_event_or_error( + "done", + json!({ "ok": true }), + )); + }; + Ok(Sse::new(stream).into_response()) +} + +pub async fn execute_puzzle_agent_action( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let now = current_utc_micros(); + let action = payload.action.trim().to_string(); + let billing_asset_id = format!("{session_id}:{now}"); + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + action = %action, + image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), + prompt_chars = payload + .prompt_text + .as_deref() + .map(|value| value.chars().count()) + .unwrap_or(0), + has_reference_image = has_puzzle_reference_images( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ), + "拼图 Agent action 开始执行" + ); + let (operation_type, phase_label, phase_detail, session) = match action.as_str() { + "compile_puzzle_draft" => { + let ai_redraw = payload.ai_redraw.unwrap_or(true); + let reference_image_sources = collect_puzzle_reference_image_sources( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ); + let primary_reference_image_src = reference_image_sources.first().map(String::as_str); + let prompt_text = payload + .picture_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| payload.prompt_text.as_deref()); + let compile_session_id = match save_puzzle_form_payload_before_compile( + &state, + &request_context, + &session_id, + &owner_user_id, + &payload, + now, + ) + .await + { + Ok(next_session_id) => next_session_id, + Err(response) => return Err(response), + }; + let session = if ai_redraw { + execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "puzzle_initial_image", + &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + compile_puzzle_draft_with_initial_cover( + &state, + compile_session_id.clone(), + owner_user_id.clone(), + prompt_text, + primary_reference_image_src, + payload.image_model.as_deref(), + now, + ) + .await + }, + ) + .await + } else { + compile_puzzle_draft_with_uploaded_cover( + &state, + compile_session_id.clone(), + owner_user_id.clone(), + prompt_text, + payload.reference_image_src.as_deref(), + now, + ) + .await + } + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "compile_puzzle_draft", + "首关拼图草稿", + if ai_redraw { + "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。" + } else { + "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" + }, + session, + ) + } + "save_puzzle_form_draft" => { + let seed_text = build_puzzle_form_seed_text_from_parts( + None, + None, + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + seed_text, + saved_at_micros: now, + }) + .await; + let session = match save_result { + Ok(session) => Ok(session), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + // 中文注释:旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图表单自动保存 procedure 缺失,降级返回当前会话" + ); + state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|fallback_error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(fallback_error), + ) + }) + } + Err(error) => Err(puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + }; + ( + "save_puzzle_form_draft", + "表单草稿保存", + "拼图表单草稿已保存。", + session, + ) + } + "generate_puzzle_images" => { + let target_level_id = payload.level_id.clone(); + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + }); + let session = execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "puzzle_generated_image", + &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + let levels_json = levels_json?; + let session = get_puzzle_session_for_image_generation( + &state, + session_id.clone(), + owner_user_id.clone(), + &payload, + levels_json.as_deref(), + now, + ) + .await?; + let mut draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let mut target_level = + select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let fallback_level_name = target_level.level_name.clone(); + let prompt = resolve_puzzle_level_image_prompt( + payload.prompt_text.as_deref(), + &target_level.picture_description, + ); + let reference_image_sources = collect_puzzle_reference_image_sources( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ); + let primary_reference_image_src = + reference_image_sources.first().map(String::as_str); + // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 + let candidate_count = 1; + let candidate_start_index = target_level.candidates.len(); + let candidates = generate_puzzle_image_candidates( + &state, + owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + &prompt, + primary_reference_image_src, + payload.ai_redraw.unwrap_or(true), + payload.image_model.as_deref(), + candidate_count, + candidate_start_index, + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + if candidates.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( + json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + }), + )); + } + if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + &state, + target_level.picture_description.as_str(), + &candidates[0].downloaded_image, + ) + .await + { + target_level.level_name = refined_naming.level_name; + if refined_naming.ui_background_prompt.is_some() { + target_level.ui_background_prompt = refined_naming.ui_background_prompt; + } + } + let generated_level_name = target_level.level_name.clone(); + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module( + &build_puzzle_levels_with_primary_update( + &draft, + &target_level, + primary_reference_image_src, + ), + )?); + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + let save_result = state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: levels_json_with_generated_name, + candidates_json, + saved_at_micros: now, + }) + .await; + match save_result { + Ok(session) => Ok(session), + Err(error) + if should_skip_asset_operation_billing_for_connectivity(&error) => + { + // 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let fallback_session = + replace_puzzle_session_draft_snapshot(session, draft, now); + Ok(apply_generated_puzzle_candidates_to_session_snapshot( + apply_generated_puzzle_first_level_name_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + generated_level_name.as_str(), + fallback_level_name.as_str(), + now, + ), + target_level.level_id.as_str(), + candidates.into_records(), + primary_reference_image_src, + now, + )) + } + Err(error) => Err(map_puzzle_client_error(error)), + } + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "generate_puzzle_images", + "拼图图片生成", + "已生成并替换当前拼图图片。", + session, + ) + } + "generate_puzzle_ui_background" => { + let target_level_id = payload.level_id.clone(); + let raw_prompt = payload + .prompt_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default() + .to_string(); + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + }); + let session = execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "puzzle_ui_background_image", + &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + let levels_json = levels_json?; + let session = get_puzzle_session_for_image_generation( + &state, + session_id.clone(), + owner_user_id.clone(), + &payload, + levels_json.as_deref(), + now, + ) + .await?; + let mut draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let target_level = + select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let resolved_prompt = normalize_puzzle_ui_background_prompt( + raw_prompt.as_str(), + &draft, + &target_level, + ); + let generated = generate_puzzle_ui_background_image( + &state, + owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + resolved_prompt.as_str(), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + let save_result = state + .spacetime_client() + .save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput { + session_id: session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json, + prompt: resolved_prompt.clone(), + image_src: generated.image_src.clone(), + image_object_key: Some(generated.object_key.clone()), + saved_at_micros: now, + }) + .await; + match save_result { + Ok(session) => Ok(session), + Err(error) + if should_skip_asset_operation_billing_for_connectivity(&error) => + { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let fallback_session = + replace_puzzle_session_draft_snapshot(session, draft, now); + Ok(apply_generated_puzzle_ui_background_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + resolved_prompt, + generated.image_src, + Some(generated.object_key), + now, + )) + } + Err(error) => Err(map_puzzle_client_error(error)), + } + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "generate_puzzle_ui_background", + "UI 背景图生成", + "已生成拼图 UI 背景图。", + session, + ) + } + "generate_puzzle_tags" => { + let work_title = payload + .work_title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "作品名称不能为空", + ) + })?; + let work_description = payload + .work_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "作品描述不能为空", + ) + })?; + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })), + ) + })?; + let generated_tags = + generate_puzzle_work_tags(&state, work_title, work_description).await; + let session = save_generated_puzzle_tags_to_session( + &state, + &session_id, + &owner_user_id, + &payload, + generated_tags, + levels_json, + now, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "generate_puzzle_tags", + "作品标签生成", + "已生成 6 个作品标签。", + session, + ) + } + "select_puzzle_image" => { + let candidate_id = payload + .candidate_id + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "candidateId is required", + ) + })?; + let session = state + .spacetime_client() + .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: payload.level_id.clone(), + candidate_id, + selected_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + }); + ( + "select_puzzle_image", + "正式图确认", + "已应用正式拼图图片。", + session, + ) + } + "publish_puzzle_work" => { + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error, + })), + ) + })?; + let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); + let author_display_name = resolve_author_display_name(&state, &authenticated); + let profile = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_publish_work", + &work_id, + async { + state + .spacetime_client() + .publish_puzzle_work(PuzzlePublishRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 + work_id: work_id.clone(), + profile_id, + author_display_name, + work_title: payload.work_title.clone(), + work_description: payload.work_description.clone(), + level_name: payload.level_name.clone(), + summary: payload.summary.clone(), + theme_tags: payload.theme_tags.clone(), + levels_json, + published_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + })?; + + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: profile.profile_id.clone(), + operation_type: "publish_puzzle_work".to_string(), + status: "completed".to_string(), + phase_label: "作品发布".to_string(), + phase_detail: "拼图作品已发布到广场。".to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } + other => { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + format!("action `{other}` is not supported").as_str(), + )); + } + }; + + let session = session?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: session.session_id.clone(), + operation_type: operation_type.to_string(), + status: "completed".to_string(), + phase_label: phase_label.to_string(), + phase_detail: phase_detail.to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn get_puzzle_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_puzzle_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorksResponse { + items: items + .into_iter() + .map(|item| map_puzzle_work_summary_response(&state, item)) + .collect(), + }, + )) +} + +pub async fn get_puzzle_work_detail( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .get_puzzle_work_detail(profile_id) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkDetailResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn put_puzzle_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + work_title: payload.work_title, + work_description: payload.work_description, + level_name: payload.level_name, + summary: payload.summary, + theme_tags: payload.theme_tags, + cover_image_src: payload.cover_image_src, + cover_asset_id: payload.cover_asset_id, + levels_json: Some(serialize_puzzle_levels_response( + &request_context, + &payload.levels, + )?), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn delete_puzzle_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let items = state + .spacetime_client() + .delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorksResponse { + items: items + .into_iter() + .map(|item| map_puzzle_work_summary_response(&state, item)) + .collect(), + }, + )) +} + +pub async fn claim_puzzle_work_point_incentive( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + claimed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn list_puzzle_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result { + if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { + crate::telemetry::record_puzzle_gallery_cache_hit(); + return Ok(puzzle_gallery_cached_json(&request_context, response)); + } + crate::telemetry::record_puzzle_gallery_cache_miss(); + let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await; + if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { + crate::telemetry::record_puzzle_gallery_cache_hit(); + return Ok(puzzle_gallery_cached_json(&request_context, response)); + } + + let rebuild_started_at = std::time::Instant::now(); + let items = state + .spacetime_client() + .list_puzzle_gallery() + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + let response = build_puzzle_gallery_window_response( + items + .into_iter() + .map(|item| map_puzzle_gallery_card_response(&state, item)) + .collect(), + ); + let cached_response = state + .puzzle_gallery_cache() + .store_response(response) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": PUZZLE_GALLERY_PROVIDER, + "message": format!("拼图广场缓存序列化失败:{error}"), + })), + ) + })?; + crate::telemetry::record_puzzle_gallery_cache_rebuild( + rebuild_started_at.elapsed(), + cached_response.data_json_len(), + ); + + Ok(puzzle_gallery_cached_json(&request_context, cached_response)) +} + +pub async fn get_puzzle_gallery_detail( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .get_puzzle_gallery_detail(profile_id) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleGalleryDetailResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn record_puzzle_gallery_like( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { + profile_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleGalleryDetailResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn remix_puzzle_gallery_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .remix_puzzle_work(PuzzleWorkRemixRecordInput { + source_profile_id: profile_id, + target_owner_user_id: owner_user_id, + target_session_id: build_prefixed_uuid_id("puzzle-session-"), + target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), + target_work_id: build_prefixed_uuid_id("puzzle-work-"), + author_display_name: resolve_author_display_name(&state, &authenticated), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn start_puzzle_run( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.profile_id, + "profileId", + )?; + + let run = state + .spacetime_client() + .start_puzzle_run(PuzzleRunStartRecordInput { + run_id: build_prefixed_uuid_id("puzzle-run-"), + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id: payload.profile_id.clone(), + level_id: payload.level_id.clone(), + started_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "puzzle", + payload.profile_id.clone(), + &authenticated, + "/api/runtime/puzzle/...", + ) + .profile_id(payload.profile_id.clone()) + .extra(json!({ + "levelId": payload.level_id, + "runId": run.run_id, + })), + ) + .await; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn get_puzzle_run( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .get_puzzle_run(run_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn swap_puzzle_pieces( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.first_piece_id, + "firstPieceId", + )?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.second_piece_id, + "secondPieceId", + )?; + + let run = state + .spacetime_client() + .swap_puzzle_pieces(PuzzleRunSwapRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + first_piece_id: payload.first_piece_id, + second_piece_id: payload.second_piece_id, + swapped_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn drag_puzzle_piece_or_group( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.piece_id, + "pieceId", + )?; + + let run = state + .spacetime_client() + .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + piece_id: payload.piece_id, + target_row: payload.target_row, + target_col: payload.target_col, + dragged_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn advance_puzzle_next_level( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + let payload = match payload { + Ok(Json(payload)) => payload, + Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => { + AdvancePuzzleNextLevelRequest { + target_profile_id: None, + } + } + Err(error) => { + return Err(puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + )); + } + }; + + let run = state + .spacetime_client() + .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + target_profile_id: payload.target_profile_id, + advanced_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn update_puzzle_run_pause( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .update_puzzle_run_pause(PuzzleRunPauseRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + paused: payload.paused, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn use_puzzle_runtime_prop( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.prop_kind, + "propKind", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let prop_kind = payload.prop_kind.trim().to_string(); + let billing_asset_kind = match prop_kind.as_str() { + "hint" => "puzzle_prop_hint", + "reference" => "puzzle_prop_preview", + "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", + "extendTime" | "extend_time" => "puzzle_prop_extend_time", + _ => { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + "unknown puzzle prop kind", + )); + } + }; + let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); + let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); + let reducer_owner_user_id = owner_user_id.clone(); + let reducer_run_id = run_id.clone(); + let fallback_run_id = run_id.clone(); + let fallback_owner_user_id = owner_user_id.clone(); + let run_result = execute_billable_asset_operation( + &state, + &owner_user_id, + billing_asset_kind, + billing_asset_id.as_str(), + async { + state + .spacetime_client() + .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { + run_id: reducer_run_id, + owner_user_id: reducer_owner_user_id, + prop_kind, + used_at_micros: current_utc_micros(), + spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await; + + let run = match run_result { + Ok(run) => run, + Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => { + // 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。 + // 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。 + state + .spacetime_client() + .get_puzzle_run(fallback_run_id, fallback_owner_user_id) + .await + .map_err(map_puzzle_client_error) + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error) + })? + } + Err(error) => { + return Err(puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + error, + )); + } + }; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn submit_puzzle_leaderboard( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id: payload.profile_id, + grid_size: payload.grid_size, + elapsed_ms: payload.elapsed_ms.max(1_000), + nickname: payload.nickname.trim().to_string(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} +>>>>>>> Stashed changes mod mappers; use self::mappers::*; +<<<<<<< Updated upstream mod draft; use self::draft::*; +======= +fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title: None, + work_description: None, + picture_description: payload + .picture_description + .as_deref() + .or(payload.seed_text.as_deref()), + }) +} + +fn build_puzzle_form_seed_text_from_parts( + title: Option<&str>, + work_description: Option<&str>, + picture_description: Option<&str>, +) -> String { + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title, + work_description, + picture_description, + }) +} + +async fn save_puzzle_form_payload_before_compile( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + now: i64, +) -> Result { + let seed_text = build_puzzle_form_seed_text_from_parts( + None, + None, + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + if seed_text.trim().is_empty() { + return Ok(session_id.to_string()); + } + + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + saved_at_micros: now, + }) + .await + .map(|_| ()); + match save_result { + Ok(()) => Ok(session_id.to_string()), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + create_seeded_puzzle_session_when_form_save_missing( + state, + request_context, + session_id, + owner_user_id, + seed_text, + now, + &error, + ) + .await + } + Err(error) => Err(puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + } +} + +async fn create_seeded_puzzle_session_when_form_save_missing( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + seed_text: String, + now: i64, + original_error: &SpacetimeClientError, +) -> Result { + let current_session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + if !current_session.seed_text.trim().is_empty() { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译" + ); + return Ok(session_id.to_string()); + } + + // 中文注释:旧 wasm 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 + let replacement_session_id = build_prefixed_uuid_id("puzzle-session-"); + let replacement = state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: replacement_session_id.clone(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&seed_text), + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + old_session_id = %session_id, + new_session_id = %replacement.session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session" + ); + Ok(replacement.session_id) +} + +fn select_puzzle_level_for_api( + draft: &PuzzleResultDraftRecord, + level_id: Option<&str>, +) -> Result { + let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty()); + if let Some(target_id) = normalized_level_id { + return draft + .levels + .iter() + .find(|level| level.level_id == target_id) + .cloned() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡不存在:{target_id}"), + })) + }); + } + let level = draft.levels.first().cloned(); + level.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + }) +} + +fn parse_puzzle_level_records_from_module_json( + value: &str, +) -> Result, AppError> { + let levels: Vec = + serde_json::from_str(value).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡列表 JSON 非法:{error}"), + })) + })?; + Ok(levels + .into_iter() + .map(|level| PuzzleDraftLevelRecord { + level_id: level.level_id, + level_name: level.level_name, + picture_description: level.picture_description, + picture_reference: level.picture_reference, + ui_background_prompt: level.ui_background_prompt, + ui_background_image_src: level.ui_background_image_src, + ui_background_image_object_key: level.ui_background_image_object_key, + background_music: level + .background_music + .map(map_puzzle_audio_asset_domain_record), + candidates: level + .candidates + .into_iter() + .map(|candidate| PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate.candidate_id, + image_src: candidate.image_src, + asset_id: candidate.asset_id, + prompt: candidate.prompt, + actual_prompt: candidate.actual_prompt, + source_type: candidate.source_type, + selected: candidate.selected, + }) + .collect(), + selected_candidate_id: level.selected_candidate_id, + cover_image_src: level.cover_image_src, + cover_asset_id: level.cover_asset_id, + generation_status: level.generation_status, + }) + .collect()) +} + +async fn get_puzzle_session_for_image_generation( + state: &AppState, + session_id: String, + owner_user_id: String, + payload: &ExecutePuzzleAgentActionRequest, + normalized_levels_json: Option<&str>, + now: i64, +) -> Result { + match state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + { + Ok(session) => Ok(session), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + // 中文注释:结果页已经带有当前草稿快照;Maincloud 读取 session 短暂 503 时不应阻断外部生图。 + let fallback_session = build_puzzle_session_snapshot_from_action_payload( + session_id.as_str(), + payload, + normalized_levels_json, + now, + )?; + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图图片生成读取 session 因 SpacetimeDB 连接不可用而降级使用前端草稿快照" + ); + Ok(fallback_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +fn build_puzzle_session_snapshot_from_action_payload( + session_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + normalized_levels_json: Option<&str>, + now: i64, +) -> Result { + let levels_json = normalized_levels_json.ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "spacetimedb", + "message": "SpacetimeDB 暂不可用,且请求缺少拼图关卡快照,无法继续生成图片", + })) + })?; + let levels = parse_puzzle_level_records_from_module_json(levels_json)?; + let first_level = levels.first().cloned().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + })?; + let work_title = payload + .work_title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(first_level.level_name.as_str()) + .to_string(); + let work_description = payload + .work_description + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + let summary = payload + .summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(first_level.picture_description.as_str()) + .to_string(); + let theme_tags = payload.theme_tags.clone().unwrap_or_default(); + let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::empty_anchor_pack()); + let draft = PuzzleResultDraftRecord { + work_title, + work_description, + level_name: first_level.level_name.clone(), + summary, + theme_tags, + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: anchor_pack.clone(), + candidates: first_level.candidates.clone(), + selected_candidate_id: first_level.selected_candidate_id.clone(), + cover_image_src: first_level.cover_image_src.clone(), + cover_asset_id: first_level.cover_asset_id.clone(), + generation_status: first_level.generation_status.clone(), + levels, + form_draft: None, + }; + + Ok(PuzzleAgentSessionRecord { + session_id: session_id.to_string(), + seed_text: String::new(), + current_turn: 0, + progress_percent: 94, + stage: "ready_to_publish".to_string(), + anchor_pack, + draft: Some(draft), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: format_timestamp_micros(now), + }) +} + +fn map_puzzle_domain_anchor_pack( + anchor_pack: module_puzzle::PuzzleAnchorPack, +) -> PuzzleAnchorPackRecord { + PuzzleAnchorPackRecord { + theme_promise: map_puzzle_domain_anchor_item(anchor_pack.theme_promise), + visual_subject: map_puzzle_domain_anchor_item(anchor_pack.visual_subject), + visual_mood: map_puzzle_domain_anchor_item(anchor_pack.visual_mood), + composition_hooks: map_puzzle_domain_anchor_item(anchor_pack.composition_hooks), + tags_and_forbidden: map_puzzle_domain_anchor_item(anchor_pack.tags_and_forbidden), + } +} + +fn map_puzzle_domain_anchor_item( + anchor: module_puzzle::PuzzleAnchorItem, +) -> PuzzleAnchorItemRecord { + PuzzleAnchorItemRecord { + key: anchor.key, + label: anchor.label, + value: anchor.value, + status: anchor.status.as_str().to_string(), + } +} + +fn serialize_puzzle_levels_response( + request_context: &RequestContext, + levels: &[PuzzleDraftLevelResponse], +) -> Result { + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "picture_reference": level.picture_reference, + "ui_background_prompt": level.ui_background_prompt, + "ui_background_image_src": level.ui_background_image_src, + "ui_background_image_object_key": level.ui_background_image_object_key, + "background_music": puzzle_audio_asset_response_module_json(&level.background_music), + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload).map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": format!("拼图关卡列表序列化失败:{error}"), + })), + ) + }) +} + +fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result, String> { + let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(None); + }; + let levels: Vec = + serde_json::from_str(raw).map_err(|error| format!("拼图关卡列表 JSON 非法:{error}"))?; + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "picture_reference": level.picture_reference, + "ui_background_prompt": level.ui_background_prompt, + "ui_background_image_src": level.ui_background_image_src, + "ui_background_image_object_key": level.ui_background_image_object_key, + "background_music": puzzle_audio_asset_response_module_json(&level.background_music), + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload) + .map(Some) + .map_err(|error| format!("拼图关卡列表序列化失败:{error}")) +} + +fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { + let stable_suffix = session_id + .strip_prefix("puzzle-session-") + .unwrap_or(session_id); + ( + format!("puzzle-work-{stable_suffix}"), + format!("puzzle-profile-{stable_suffix}"), + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PuzzleLevelNaming { + level_name: String, + work_description: Option, + work_tags: Vec, + ui_background_prompt: Option, +} + +impl PuzzleLevelNaming { + fn fallback(picture_description: &str) -> Self { + Self { + level_name: build_fallback_puzzle_first_level_name(picture_description), + work_description: None, + work_tags: Vec::new(), + ui_background_prompt: None, + } + } +} + +async fn generate_puzzle_first_level_name( + state: &AppState, + picture_description: &str, +) -> PuzzleLevelNaming { + if let Some(llm_client) = state.llm_client() { + let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) + .await; + match response { + Ok(response) => { + if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str()) + { + return naming; + } + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + "拼图首关名模型返回非法,降级使用关键词名" + ); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + error = %error, + "拼图首关名生成失败,降级使用关键词名" + ); + } + } + } + + PuzzleLevelNaming::fallback(picture_description) +} + +async fn generate_puzzle_first_level_name_from_image( + state: &AppState, + picture_description: &str, + image: &PuzzleDownloadedImage, +) -> Option { + let Some(llm_client) = state.creative_agent_gpt5_client() else { + return None; + }; + let Some(image_data_url) = build_puzzle_level_name_image_data_url(image) else { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + "拼图首关名图片输入压缩失败,保留文本关卡名" + ); + return None; + }; + let user_text = build_puzzle_first_level_name_vision_user_text(picture_description); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), + LlmMessage::user_multimodal(vec![ + LlmMessageContentPart::InputText { text: user_text }, + LlmMessageContentPart::InputImage { + image_url: image_data_url, + }, + ]), + ]) + .with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL) + .with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS), + ) + .await; + + match response { + Ok(response) => { + parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, + picture_chars = picture_description.chars().count(), + "拼图首关名视觉模型返回非法,保留文本关卡名" + ); + None + }) + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, + picture_chars = picture_description.chars().count(), + error = %error, + "拼图首关名视觉生成失败,保留文本关卡名" + ); + None + } + } +} + +fn build_puzzle_level_name_image_data_url(image: &PuzzleDownloadedImage) -> Option { + let bytes = resize_puzzle_level_name_image_bytes(image.bytes.as_slice()) + .unwrap_or_else(|| image.bytes.clone()); + let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + "image/png" + } else { + image.mime_type.as_str() + }; + Some(format!( + "data:{};base64,{}", + normalize_puzzle_downloaded_image_mime_type(mime_type), + BASE64_STANDARD.encode(bytes) + )) +} + +fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option> { + let image = image::load_from_memory(bytes).ok()?; + let resized = image.resize( + PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, + PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, + image::imageops::FilterType::Triangle, + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + resized.write_to(&mut cursor, ImageFormat::Png).ok()?; + Some(cursor.into_inner()) +} + +fn parse_puzzle_level_naming_from_text(text: &str) -> Option { + let trimmed = text.trim(); + let json_text = if let Some(start) = trimmed.find('{') + && let Some(end) = trimmed.rfind('}') + && end > start + { + &trimmed[start..=end] + } else { + trimmed + }; + let parsed = serde_json::from_str::(json_text).ok(); + if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) { + return None; + } + let raw_name = parsed + .as_ref() + .and_then(|value| value.get("levelName").and_then(Value::as_str)) + .or_else(|| { + parsed + .as_ref() + .and_then(|value| value.get("level_name").and_then(Value::as_str)) + }) + .unwrap_or(trimmed); + let level_name = normalize_puzzle_first_level_name(raw_name)?; + let work_description = parsed + .as_ref() + .and_then(parse_puzzle_generated_work_description_field); + let work_tags = parsed + .as_ref() + .and_then(parse_puzzle_generated_work_tags_field) + .unwrap_or_default(); + let ui_background_prompt = parsed + .as_ref() + .and_then(parse_puzzle_ui_background_prompt_field); + + Some(PuzzleLevelNaming { + level_name, + work_description, + work_tags, + ui_background_prompt, + }) +} + +#[cfg(test)] +fn parse_puzzle_first_level_name_from_text(text: &str) -> Option { + parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name) +} + +fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option { + value + .get("uiBackgroundPrompt") + .and_then(Value::as_str) + .or_else(|| value.get("ui_background_prompt").and_then(Value::as_str)) + .and_then(normalize_puzzle_generated_ui_background_prompt) +} + +fn parse_puzzle_generated_work_description_field(value: &Value) -> Option { + value + .get("workDescription") + .and_then(Value::as_str) + .or_else(|| value.get("work_description").and_then(Value::as_str)) + .and_then(normalize_puzzle_generated_work_description) +} + +fn normalize_puzzle_generated_work_description(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .split_whitespace() + .collect::>() + .join(""); + let description = normalized.chars().take(80).collect::(); + (description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description)) + .then_some(description) +} + +fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option> { + let tags_value = value + .get("workTags") + .or_else(|| value.get("work_tags")) + .or_else(|| value.get("themeTags")) + .or_else(|| value.get("theme_tags")) + .or_else(|| value.get("tags"))?; + let raw_tags = match tags_value { + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>(), + Value::String(text) => text + .split([',', ',', '、', '\n', '|', '/']) + .map(ToString::to_string) + .collect::>(), + _ => Vec::new(), + }; + let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags); + (tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags) +} + +fn normalize_puzzle_generated_work_tag_candidates( + candidates: impl IntoIterator, +) -> Vec +where + S: AsRef, +{ + let mut tags = Vec::new(); + for candidate in candidates { + let normalized = normalize_puzzle_tag(candidate.as_ref()); + if normalized.is_empty() + || looks_like_puzzle_json_field_name(&normalized) + || tags.iter().any(|tag| tag == &normalized) + { + continue; + } + tags.push(normalized); + if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT { + break; + } + } + tags +} + +fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .split_whitespace() + .collect::>() + .join(""); + let filtered = normalized + .replace("拼图槽", "") + .replace("棋盘", "") + .replace("HUD", "") + .replace("按钮", "") + .replace("文字", "") + .replace("水印", "") + .replace("数字", "") + .replace("拼图碎片", "") + .replace("完整拼图图像", "") + .replace("教程浮层", ""); + let prompt = filtered + .chars() + .take(160) + .collect::() + .trim() + .trim_matches(|ch: char| matches!(ch, ',' | '。' | '、' | ';' | ':')) + .to_string(); + if prompt.chars().count() >= 12 { + Some(prompt) + } else { + None + } +} + +fn normalize_puzzle_first_level_name(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')')) + .chars() + .filter(|ch| { + !matches!( + ch, + '#' | '"' + | '\'' + | '`' + | ' ' + | '\t' + | '\r' + | '\n' + | ',' + | '。' + | '、' + | ';' + | ':' + | '!' + | '?' + | '“' + | '”' + | '《' + | '》' + ) + }) + .take(12) + .collect::(); + let normalized = strip_puzzle_level_name_generic_words(normalized); + if normalized.chars().count() >= 2 + && !matches!( + normalized.as_str(), + "第一关" | "画面" | "拼图" | "作品" | "关卡" + ) + && !looks_like_puzzle_json_field_name(&normalized) + { + Some(normalized) + } else { + None + } +} + +fn looks_like_puzzle_json_field_name(value: &str) -> bool { + let normalized = value.trim().trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }); + let compact = normalized.to_ascii_lowercase().replace('_', ""); + matches!(compact.as_str(), "levelnam" | "levelname") + || [ + "levelname", + "workdescription", + "worktags", + "themetags", + "uibackgroundprompt", + ] + .iter() + .any(|field| { + compact == *field + || (compact.len() >= 6 && field.starts_with(compact.as_str())) + || compact.starts_with(field) + }) +} + +fn looks_like_puzzle_json_fragment(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + return true; + } + let lower = trimmed.to_ascii_lowercase(); + [ + "\"levelnam", + "\"levelname\"", + "\"level_name\"", + "\"workdescription\"", + "\"work_description\"", + "\"worktags\"", + "\"work_tags\"", + "\"uibackgroundprompt\"", + "\"ui_background_prompt\"", + ] + .iter() + .any(|field| lower.contains(field)) +} + +fn strip_puzzle_level_name_generic_words(mut value: String) -> String { + for prefix in ["第一关", "关卡名", "关卡"] { + value = value.trim_start_matches(prefix).to_string(); + } + for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] { + value = value.trim_end_matches(suffix).to_string(); + } + value.chars().take(8).collect() +} + +fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String { + let source = picture_description.trim(); + if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) { + return "雨夜猫街".to_string(); + } + if source.contains("猫") && source.contains('灯') { + return "暖灯猫街".to_string(); + } + for (keyword, level_name) in [ + ("雨夜", "雨夜灯街"), + ("猫", "暖灯猫街"), + ("狗", "花园小狗"), + ("神庙", "神庙遗光"), + ("遗迹", "遗迹谜光"), + ("森林", "森林秘境"), + ("城市", "霓虹城市"), + ("机械", "机械迷城"), + ("蒸汽", "蒸汽街区"), + ("海", "海岸微光"), + ("花", "花园晨光"), + ("雪", "雪境小径"), + ("龙", "龙影高塔"), + ("灯", "暖灯街角"), + ("塔", "塔顶星光"), + ] { + if source.contains(keyword) { + return level_name.to_string(); + } + } + "奇境初见".to_string() +} + +fn build_puzzle_levels_with_primary_update( + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, + picture_reference: Option<&str>, +) -> Vec { + let mut levels = draft.levels.clone(); + if let Some(index) = levels + .iter() + .position(|level| level.level_id == target_level.level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + { + levels[index].level_name = target_level.level_name.clone(); + levels[index].ui_background_prompt = target_level.ui_background_prompt.clone(); + levels[index].ui_background_image_src = target_level.ui_background_image_src.clone(); + levels[index].ui_background_image_object_key = + target_level.ui_background_image_object_key.clone(); + if let Some(picture_reference) = picture_reference + .map(str::trim) + .filter(|value| !value.is_empty()) + { + levels[index].picture_reference = Some(picture_reference.to_string()); + } + } + levels +} + +fn attach_selected_puzzle_candidate_to_levels( + levels: &mut [PuzzleDraftLevelRecord], + target_level_id: &str, + candidate: &PuzzleGeneratedImageCandidateRecord, +) { + if let Some(index) = levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + { + let level = &mut levels[index]; + level.candidates.clear(); + let mut candidate = candidate.clone(); + candidate.selected = true; + level.selected_candidate_id = Some(candidate.candidate_id.clone()); + level.cover_image_src = Some(candidate.image_src.clone()); + level.cover_asset_id = Some(candidate.asset_id.clone()); + level.candidates.push(candidate); + level.generation_status = "ready".to_string(); + } +} + +fn resolve_puzzle_initial_ui_background_prompt( + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> String { + target_level + .ui_background_prompt + .as_deref() + .and_then(normalize_puzzle_generated_ui_background_prompt) + .unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level)) +} + +fn normalize_puzzle_ui_background_prompt( + raw_prompt: &str, + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> String { + let prompt = raw_prompt.trim(); + if !prompt.is_empty() { + return prompt.chars().take(420).collect(); + } + + let title = draft.work_title.trim(); + let title = if title.is_empty() { + target_level.level_name.trim() + } else { + title + }; + let tags = draft + .theme_tags + .iter() + .map(|tag| tag.trim()) + .filter(|tag| !tag.is_empty()) + .collect::>() + .join(","); + [ + title, + draft.work_description.trim(), + target_level.picture_description.trim(), + tags.as_str(), + PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER, + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join("。") + .chars() + .take(420) + .collect() +} + +fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str) -> String { + let level_name = level_name.trim(); + let title_clause = if level_name.is_empty() { + String::new() + } else { + format!("当前拼图关卡名称:{level_name}。") + }; + format!( + "{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图,只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。" + ) +} + +fn attach_puzzle_level_ui_background( + levels: &mut [PuzzleDraftLevelRecord], + level_id: &str, + prompt: String, + generated: GeneratedPuzzleUiBackgroundResponse, +) { + let Some(index) = levels + .iter() + .position(|level| level.level_id == level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + else { + return; + }; + levels[index].ui_background_prompt = Some(prompt); + levels[index].ui_background_image_src = Some(generated.image_src); + levels[index].ui_background_image_object_key = Some(generated.object_key); +} + +async fn generate_puzzle_background_music_required( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + title: &str, +) -> Result { + let normalized_title = title.trim(); + if normalized_title.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", + })), + ); + } + generate_background_music_asset_for_creation( + state, + owner_user_id, + String::new(), + normalized_title.to_string(), + Some("轻快, 拼图, 循环, instrumental".to_string()), + None, + GeneratedCreationAudioTarget { + entity_kind: PUZZLE_ENTITY_KIND.to_string(), + entity_id: profile_id.to_string(), + slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(), + asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(), + profile_id: Some(profile_id.to_string()), + storage_prefix: LegacyAssetPrefix::PuzzleAssets, + }, + ) + .await +} + +async fn generate_puzzle_initial_ui_background_required( + state: &AppState, + owner_user_id: &str, + session_id: &str, + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> { + let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level); + let generated = generate_puzzle_ui_background_image( + state, + owner_user_id, + session_id, + target_level.level_name.as_str(), + prompt.as_str(), + ) + .await?; + Ok((prompt, generated)) +} + +fn ensure_puzzle_initial_level_assets_ready( + level: &PuzzleDraftLevelRecord, +) -> Result<(), AppError> { + let has_ui_background = level + .ui_background_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || level + .ui_background_image_object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if has_ui_background { + return Ok(()); + } + + let mut missing = Vec::new(); + if !has_ui_background { + missing.push("UI背景图"); + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")), + "missingAssets": missing, + })), + ) +} + +fn find_puzzle_level_for_initial_asset_check<'a>( + levels: &'a [PuzzleDraftLevelRecord], + level_id: &str, +) -> Option<&'a PuzzleDraftLevelRecord> { + levels + .iter() + .find(|level| level.level_id == level_id) + .or_else(|| levels.first()) +} + +async fn compile_puzzle_draft_with_initial_cover( + state: &AppState, + session_id: String, + owner_user_id: String, + prompt_text: Option<&str>, + reference_image_src: Option<&str>, + image_model: Option<&str>, + now: i64, +) -> Result { + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) + .await + .map_err(map_puzzle_compile_error)?; + let draft = compiled_session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let mut target_level = select_puzzle_level_for_api(&draft, None)?; + let fallback_level_name = target_level.level_name.clone(); + let image_prompt = resolve_puzzle_draft_cover_prompt( + prompt_text, + &target_level.picture_description, + &draft.summary, + ); + let generated_naming = + generate_puzzle_first_level_name(state, &target_level.picture_description).await; + target_level.level_name = generated_naming.level_name.clone(); + target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); + let mut generated_metadata = generated_naming; + // 点击生成草稿时一次性完成首图生成、UI 背景生成与正式图选定,前端只展示进度,不再承担业务编排。 + let candidates_future = generate_puzzle_image_candidates( + state, + owner_user_id.as_str(), + &compiled_session.session_id, + &target_level.level_name, + &image_prompt, + reference_image_src, + true, + image_model, + 1, + target_level.candidates.len(), + ); + let ui_background_future = generate_puzzle_initial_ui_background_required( + state, + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &draft, + &target_level, + ); + // 中文注释:命名稳定后并行发起首关图与 UI 背景,避免两次外部生图串行等待。 + let (candidates_result, ui_background_result) = + tokio::join!(candidates_future, ui_background_future); + let mut candidates = candidates_result?; + if let Some(first_candidate) = candidates.first() + && let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + state, + target_level.picture_description.as_str(), + &first_candidate.downloaded_image, + ) + .await + { + target_level.level_name = refined_naming.level_name; + if refined_naming.work_description.is_some() { + generated_metadata.work_description = refined_naming.work_description; + } + if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { + generated_metadata.work_tags = refined_naming.work_tags; + } + generated_metadata.level_name = target_level.level_name.clone(); + generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); + } + let generated_level_name = target_level.level_name.clone(); + let mut updated_levels = + build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); + for candidate in &mut candidates { + candidate.record.prompt = image_prompt.clone(); + } + let selected_candidate_id = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + .map(|candidate| candidate.record.candidate_id.clone()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + })) + })?; + // 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。 + let (ui_prompt, ui_background) = ui_background_result?; + attach_puzzle_level_ui_background( + &mut updated_levels, + target_level.level_id.as_str(), + ui_prompt, + ui_background, + ); + if let Some(selected_candidate) = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + { + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &selected_candidate.record, + ); + } + let ready_level = + find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿资源生成完成后未找到目标关卡", + })) + })?; + ensure_puzzle_initial_level_assets_ready(ready_level)?; + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); + let work_title = if draft.work_title.trim().is_empty() + || draft.work_title.trim() == fallback_level_name.trim() + { + generated_level_name.clone() + } else { + draft.work_title.clone() + }; + let work_description = if draft.work_description.trim().is_empty() { + generated_metadata + .work_description + .clone() + .unwrap_or_else(|| draft.work_description.clone()) + } else { + draft.work_description.clone() + }; + let theme_tags = if draft.theme_tags.is_empty() + && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + generated_metadata.work_tags.clone() + } else { + draft.theme_tags.clone() + }; + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + let (saved_session, save_used_fallback) = state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: compiled_session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: levels_json_with_generated_name.clone(), + candidates_json, + saved_at_micros: current_utc_micros(), + }) + .await + .map_err(map_puzzle_client_error) + .map(|session| (session, false)) + .or_else(|error| { + if is_spacetimedb_connectivity_app_error(&error) { + // 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let session = apply_generated_puzzle_candidates_to_session_snapshot( + apply_generated_puzzle_levels_to_session_snapshot( + apply_generated_puzzle_first_level_name_to_session_snapshot( + compiled_session.clone(), + target_level.level_id.as_str(), + generated_level_name.as_str(), + fallback_level_name.as_str(), + now, + ), + updated_levels.clone(), + now, + ), + target_level.level_id.as_str(), + candidates.into_records(), + reference_image_src, + now, + ); + Ok((session, true)) + } else { + Err(error) + } + })?; + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); + match state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: owner_user_id.clone(), + work_title, + work_description: work_description.clone(), + level_name: generated_level_name.clone(), + summary: work_description, + theme_tags, + cover_image_src: ready_level.cover_image_src.clone(), + cover_asset_id: ready_level.cover_asset_id.clone(), + levels_json: levels_json_with_generated_name.clone(), + updated_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + { + Ok(_) => {} + Err(error) if is_spacetimedb_connectivity_app_error(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照" + ); + } + Err(error) => return Err(error), + } + let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + saved_session, + &generated_metadata, + fallback_level_name.as_str(), + now, + ); + if save_used_fallback { + return Ok(saved_session); + } + match state + .spacetime_client() + .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { + session_id, + owner_user_id, + level_id: Some(target_level.level_id), + candidate_id: selected_candidate_id, + selected_at_micros: current_utc_micros(), + }) + .await + { + Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &generated_metadata, + fallback_level_name.as_str(), + now, + )), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %saved_session.session_id, + error = %error, + "拼图首图选定回写因 SpacetimeDB 连接不可用而降级使用已生成快照" + ); + Ok(saved_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +async fn compile_puzzle_draft_with_uploaded_cover( + state: &AppState, + session_id: String, + owner_user_id: String, + prompt_text: Option<&str>, + reference_image_src: Option<&str>, + now: i64, +) -> Result { + let uploaded_image_src = reference_image_src + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "field": "referenceImageSrc", + "message": "关闭 AI 重绘时必须上传拼图图片。", + })) + })?; + let uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "field": "referenceImageSrc", + "message": "关闭 AI 重绘时上传图必须是图片 Data URL。", + })) + })?; + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) + .await + .map_err(map_puzzle_compile_error)?; + let draft = compiled_session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let mut target_level = select_puzzle_level_for_api(&draft, None)?; + let fallback_level_name = target_level.level_name.clone(); + let image_prompt = resolve_puzzle_draft_cover_prompt( + prompt_text, + &target_level.picture_description, + &draft.summary, + ); + // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 + let candidate_id = format!( + "{}-candidate-{}", + compiled_session.session_id, + target_level.candidates.len() + 1 + ); + let uploaded_downloaded_image = PuzzleDownloadedImage { + extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(), + mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()), + bytes: uploaded_image.bytes, + }; + let level_name_future = + generate_puzzle_first_level_name(state, &target_level.picture_description); + let image_level_name_future = generate_puzzle_first_level_name_from_image( + state, + target_level.picture_description.as_str(), + &uploaded_downloaded_image, + ); + let (mut generated_naming, refined_naming) = + tokio::join!(level_name_future, image_level_name_future); + if let Some(refined_naming) = refined_naming { + generated_naming.level_name = refined_naming.level_name; + if refined_naming.ui_background_prompt.is_some() { + generated_naming.ui_background_prompt = refined_naming.ui_background_prompt; + } + if refined_naming.work_description.is_some() { + generated_naming.work_description = refined_naming.work_description; + } + if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { + generated_naming.work_tags = refined_naming.work_tags; + } + } + target_level.level_name = generated_naming.level_name.clone(); + target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); + let mut generated_metadata = generated_naming; + generated_metadata.level_name = target_level.level_name.clone(); + generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); + let generated_level_name = target_level.level_name.clone(); + let mut updated_levels = + build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); + let persist_upload_future = persist_puzzle_generated_asset( + state, + owner_user_id.as_str(), + &compiled_session.session_id, + target_level.level_name.as_str(), + candidate_id.as_str(), + "uploaded-direct", + uploaded_downloaded_image.clone(), + current_utc_micros(), + ); + let ui_background_future = generate_puzzle_initial_ui_background_required( + state, + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &draft, + &target_level, + ); + // 中文注释:直用上传图时并行完成上传图持久化与 UI 背景生成;音频生成入口临时关闭。 + let (persisted_upload_result, ui_background_result) = + tokio::join!(persist_upload_future, ui_background_future); + let persisted_upload = persisted_upload_result?; + let (ui_prompt, ui_background) = ui_background_result?; + attach_puzzle_level_ui_background( + &mut updated_levels, + target_level.level_id.as_str(), + ui_prompt, + ui_background, + ); + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate_id.clone(), + image_src: persisted_upload.image_src.clone(), + asset_id: persisted_upload.asset_id.clone(), + prompt: image_prompt.clone(), + actual_prompt: None, + source_type: "uploaded".to_string(), + selected: true, + }, + ); + let ready_level = + find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿资源生成完成后未找到目标关卡", + })) + })?; + ensure_puzzle_initial_level_assets_ready(ready_level)?; + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); + let work_title = if draft.work_title.trim().is_empty() + || draft.work_title.trim() == fallback_level_name.trim() + { + generated_level_name.clone() + } else { + draft.work_title.clone() + }; + let work_description = if draft.work_description.trim().is_empty() { + generated_metadata + .work_description + .clone() + .unwrap_or_else(|| draft.work_description.clone()) + } else { + draft.work_description.clone() + }; + let theme_tags = if draft.theme_tags.is_empty() + && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + generated_metadata.work_tags.clone() + } else { + draft.theme_tags.clone() + }; + let candidate = PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate_id.clone(), + image_src: persisted_upload.image_src, + asset_id: persisted_upload.asset_id, + prompt: image_prompt, + actual_prompt: None, + source_type: "uploaded".to_string(), + selected: true, + }; + let candidates_json = serde_json::to_string(&vec![to_puzzle_generated_image_candidate( + &candidate, + )]) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图上传图候选序列化失败:{error}"), + })) + })?; + let (saved_session, save_used_fallback) = state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: compiled_session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: levels_json_with_generated_name.clone(), + candidates_json, + saved_at_micros: current_utc_micros(), + }) + .await + .map_err(map_puzzle_client_error) + .map(|session| (session, false)) + .or_else(|error| { + if is_spacetimedb_connectivity_app_error(&error) { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图上传图草稿回写不可用,降级返回本地快照" + ); + let session = apply_generated_puzzle_candidates_to_session_snapshot( + apply_generated_puzzle_levels_to_session_snapshot( + apply_generated_puzzle_first_level_name_to_session_snapshot( + compiled_session.clone(), + target_level.level_id.as_str(), + generated_level_name.as_str(), + fallback_level_name.as_str(), + now, + ), + updated_levels.clone(), + now, + ), + target_level.level_id.as_str(), + vec![candidate.clone()], + reference_image_src, + now, + ); + Ok((session, true)) + } else { + Err(error) + } + })?; + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); + match state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: owner_user_id.clone(), + work_title, + work_description: work_description.clone(), + level_name: generated_level_name.clone(), + summary: work_description, + theme_tags, + cover_image_src: ready_level.cover_image_src.clone(), + cover_asset_id: ready_level.cover_asset_id.clone(), + levels_json: levels_json_with_generated_name.clone(), + updated_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + { + Ok(_) => {} + Err(error) if is_spacetimedb_connectivity_app_error(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照" + ); + } + Err(error) => return Err(error), + } + let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + saved_session, + &generated_metadata, + fallback_level_name.as_str(), + now, + ); + if save_used_fallback { + return Ok(saved_session); + } + match state + .spacetime_client() + .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { + session_id, + owner_user_id, + level_id: Some(target_level.level_id), + candidate_id, + selected_at_micros: current_utc_micros(), + }) + .await + { + Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &generated_metadata, + fallback_level_name.as_str(), + now, + )), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %saved_session.session_id, + error = %error, + "拼图上传图选定回写因 SpacetimeDB 连接不可用而降级使用已保存快照" + ); + Ok(saved_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +fn apply_generated_puzzle_candidates_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + candidates: Vec, + picture_reference: Option<&str>, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + let mut candidates = candidates + .into_iter() + .take(1) + .map(|mut candidate| { + candidate.selected = true; + candidate + }) + .collect::>(); + let Some(selected) = candidates.first().cloned() else { + return session; + }; + let level = &mut draft.levels[target_index]; + level.candidates.clear(); + level.candidates.append(&mut candidates); + level.selected_candidate_id = Some(selected.candidate_id.clone()); + level.cover_image_src = Some(selected.image_src.clone()); + level.cover_asset_id = Some(selected.asset_id.clone()); + if let Some(picture_reference) = picture_reference + .map(str::trim) + .filter(|value| !value.is_empty()) + { + level.picture_reference = Some(picture_reference.to_string()); + } + level.generation_status = "ready".to_string(); + if target_index == 0 { + sync_puzzle_primary_draft_fields_from_level(draft); + } + session.progress_percent = session.progress_percent.max(94); + session.stage = "ready_to_publish".to_string(); + session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn apply_generated_puzzle_levels_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + levels: Vec, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + if levels.is_empty() { + return session; + } + draft.levels = levels; + sync_puzzle_primary_draft_fields_from_level(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn apply_generated_puzzle_first_level_name_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + level_name: &str, + previous_level_name: &str, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let normalized_name = level_name.trim(); + if normalized_name.is_empty() { + return session; + } + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + draft.levels[target_index].level_name = normalized_name.to_string(); + let should_default_work_title = + draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); + if target_index == 0 && should_default_work_title { + draft.work_title = normalized_name.to_string(); + } + sync_puzzle_primary_draft_fields_from_level(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn apply_generated_puzzle_initial_metadata_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + metadata: &PuzzleLevelNaming, + previous_level_name: &str, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + apply_generated_puzzle_initial_metadata_to_draft( + draft, + metadata, + previous_level_name, + updated_at_micros, + ); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn apply_generated_puzzle_initial_metadata_to_draft( + draft: &mut PuzzleResultDraftRecord, + metadata: &PuzzleLevelNaming, + previous_level_name: &str, + _updated_at_micros: i64, +) { + let should_default_work_title = + draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); + if should_default_work_title { + draft.work_title = metadata.level_name.clone(); + } + + if draft.work_description.trim().is_empty() + && let Some(description) = metadata.work_description.as_ref() + { + draft.work_description = description.clone(); + draft.summary = description.clone(); + } + + if draft.theme_tags.is_empty() + && metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + draft.theme_tags = metadata.work_tags.clone(); + } + + sync_puzzle_primary_draft_fields_from_level(draft); +} + +fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) { + let Some(primary_level) = draft.levels.first() else { + return; + }; + draft.level_name = primary_level.level_name.clone(); + draft.candidates = primary_level.candidates.clone(); + draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); + draft.cover_image_src = primary_level.cover_image_src.clone(); + draft.cover_asset_id = primary_level.cover_asset_id.clone(); + draft.generation_status = primary_level.generation_status.clone(); + draft.summary = draft.work_description.clone(); + if draft.form_draft.is_some() { + draft.form_draft = Some(PuzzleFormDraftRecord { + work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()), + work_description: (!draft.work_description.trim().is_empty()) + .then_some(draft.work_description.clone()), + picture_description: (!primary_level.picture_description.trim().is_empty()) + .then_some(primary_level.picture_description.clone()), + }); + } +} + +fn replace_puzzle_session_draft_snapshot( + mut session: PuzzleAgentSessionRecord, + draft: PuzzleResultDraftRecord, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + session.draft = Some(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn apply_generated_puzzle_ui_background_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + prompt: String, + image_src: String, + image_object_key: Option, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + let level = &mut draft.levels[target_index]; + level.ui_background_prompt = Some(prompt); + level.ui_background_image_src = Some(image_src); + level.ui_background_image_object_key = image_object_key; + if target_index == 0 { + sync_puzzle_primary_draft_fields_from_level(draft); + } + session.progress_percent = session.progress_percent.max(96); + session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string()); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} +>>>>>>> Stashed changes mod tags; diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 21db5870..3120f30d 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -316,7 +316,7 @@ test('auth gate does not auto-create a guest account when dev guest switch is no expect(await screen.findByText('应用内容')).toBeTruthy(); }); -test('auth gate keeps password entry available when login options are empty', async () => { +test('auth gate keeps sms and password entries available when login options are empty', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ @@ -336,12 +336,19 @@ test('auth gate keeps password entry available when login options are empty', as await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); + expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy(); + expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy(); + expect(within(dialog).getByLabelText('验证码')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: '获取验证码' }), + ).toBeTruthy(); + await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); expect(within(dialog).getByLabelText('密码')).toBeTruthy(); expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull(); expect(within(dialog).queryByText('读取登录方式失败')).toBeNull(); }); -test('auth gate falls back to password entry when login options request fails', async () => { +test('auth gate keeps sms and password entries available when login options request fails', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockRejectedValue( @@ -357,6 +364,13 @@ test('auth gate falls back to password entry when login options request fails', await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); + expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy(); + expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy(); + expect(within(dialog).getByLabelText('验证码')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: '获取验证码' }), + ).toBeTruthy(); + await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); expect(within(dialog).getByLabelText('密码')).toBeTruthy(); expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull(); }); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index e4e12a61..e2dc89a6 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -61,7 +61,7 @@ type AuthStatus = | 'ready' | 'error'; -const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password']; +const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password']; function readInviteCodeFromLocation(): string { const params = new URLSearchParams(window.location.search || ''); @@ -76,11 +76,13 @@ function normalizeAvailableLoginMethods( ): AuthLoginMethod[] { const normalizedMethods = Array.from(new Set(methods ?? [])); - // 密码登录由 Rust auth entry 固定承载,不依赖短信或微信环境开关。 - // 当 login-options 联调失败或配置返回空数组时,仍要保留账号入口,避免登录弹窗失去可操作方式。 - return normalizedMethods.length > 0 - ? normalizedMethods - : FALLBACK_LOGIN_METHODS; + // 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。 + return Array.from( + new Set([ + ...REQUIRED_LOGIN_METHODS, + ...normalizedMethods, + ]), + ); } type AuthHydrateSessionResult = @@ -367,9 +369,9 @@ export function AuthGate({ children }: AuthGateProps) { return; } - setAvailableLoginMethods(FALLBACK_LOGIN_METHODS); + setAvailableLoginMethods(REQUIRED_LOGIN_METHODS); setUser(null); - // 中文注释:登录方式接口失败时按产品约定保留密码登录入口; + // 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口; // 这里不展示接口读取错误,避免用户误以为登录本身不可用。 setError(callbackResult?.error ?? ''); setStatus('unauthenticated'); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 1e19ce9f..fc41169e 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -80,8 +80,8 @@ export function LoginScreen({ const [legalConsentChecked, setLegalConsentChecked] = useState(false); const [activeLegalDocumentId, setActiveLegalDocumentId] = useState(null); - const passwordLoginEnabled = availableLoginMethods.includes('password'); - const phoneLoginEnabled = availableLoginMethods.includes('phone'); + const passwordLoginEnabled = true; + const phoneLoginEnabled = true; const wechatLoginEnabled = availableLoginMethods.includes('wechat'); const [activeLoginTab, setActiveLoginTab] = useState('phone'); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b97a525a..8d8d9dc1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1718,6 +1718,10 @@ function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) { return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed'); } +function isPersistedDraftGenerating(value: string | null | undefined) { + return value?.trim() === 'generating'; +} + function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { @@ -8750,7 +8754,7 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId, buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), - ]); + ]) || isPersistedDraftGenerating(item.generationStatus); setPuzzleOperation(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); @@ -8897,7 +8901,7 @@ export function PlatformEntryFlowShellImpl({ item.workId, item.profileId, item.sourceSessionId, - ]); + ]) || isPersistedDraftGenerating(item.generationStatus); const backgroundTask = getMatch3DBackgroundCompileTask( item.sourceSessionId, @@ -8972,7 +8976,10 @@ export function PlatformEntryFlowShellImpl({ setMatch3DFormDraftPayload(null); setMatch3DProfile(null); setMatch3DGenerationState( - createMiniGameDraftGenerationState('match3d'), + createMiniGameDraftGenerationStateFromStartedAt( + 'match3d', + parseDraftGenerationStartedAtMs(item.updatedAt), + ), ); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 9e9ea966..732dc6f6 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -3432,6 +3432,73 @@ test('running match3d persisted draft reopens progress instead of unfinished res ); }); +test('persisted generating match3d draft opens generation progress after refresh', async () => { + const user = userEvent.setup(); + const persistedGeneratingWork: Match3DWorkSummary = { + workId: 'match3d-work-generating', + profileId: 'match3d-profile-generating', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-generating', + gameName: '生成中抓鹅', + themeText: '霓虹水果摊', + summary: '刷新后仍应回到抓大鹅生成面板。', + tags: ['水果', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-18T12:05:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + generatedItemAssets: [], + }; + + vi.mocked(listMatch3DWorks).mockResolvedValue({ + items: [persistedGeneratingWork], + }); + vi.mocked(match3dCreationClient.getSession).mockResolvedValueOnce({ + session: buildMockMatch3DAgentSession({ + sessionId: 'match3d-session-generating', + draft: { + profileId: 'match3d-profile-generating', + gameName: '生成中抓鹅', + themeText: '霓虹水果摊', + summary: '刷新后仍应回到抓大鹅生成面板。', + tags: ['水果', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + generatedItemAssets: [], + }, + stage: 'draft_ready', + lastAssistantReply: '正在生成抓大鹅素材。', + updatedAt: '2026-05-18T12:05:00.000Z', + }), + }); + + render(); + + await openDraftHub(user); + await user.click( + await screen.findByRole('button', { name: /继续创作《生成中抓鹅》/u }), + ); + + await waitFor(() => { + expect(match3dCreationClient.getSession).toHaveBeenCalledWith( + 'match3d-session-generating', + ); + }); + expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect(screen.queryByText('抓大鹅结果页')).toBeNull(); + expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( + 'match3d-profile-generating', + ); +}); + test('running match3d form generation keeps other creation templates available', async () => { const user = userEvent.setup(); const runningSession = buildMockMatch3DAgentSession({ @@ -6410,6 +6477,59 @@ test('puzzle draft result back button returns to creation hub', async () => { expect(screen.queryByText('拼图结果页')).toBeNull(); }); +test('persisted generating puzzle draft opens generation progress after refresh', async () => { + const user = userEvent.setup(); + + vi.mocked(listPuzzleWorks).mockResolvedValue({ + items: [ + { + workId: 'puzzle-work-session-generating', + profileId: 'puzzle-profile-session-generating', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-generating', + authorDisplayName: '测试玩家', + workTitle: '生成中拼图', + workDescription: '刷新后仍应回到生成面板。', + levelName: '生成中拼图', + summary: '刷新后仍应回到生成面板。', + themeTags: ['雨夜'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-05-18T12:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: 'generating', + }, + ], + }); + vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({ + session: buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-generating', + stage: 'collecting_anchors', + progressPercent: 42, + lastAssistantReply: '正在生成拼图草稿。', + updatedAt: '2026-05-18T12:00:00.000Z', + }), + }); + + render(); + + await openDraftHub(user); + await user.click(await screen.findByRole('button', { name: /继续创作/u })); + + await waitFor(() => { + expect(getPuzzleAgentSession).toHaveBeenCalledWith( + 'puzzle-session-generating', + ); + }); + expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + expect(screen.queryByText('拼图结果页')).toBeNull(); +}); + test('published puzzle work card restores its source session for editing', async () => { const user = userEvent.setup(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index e3acdd25..6cfa161e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -387,16 +387,24 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: ({ src, + fallbackSrc, alt, className, ...rest }: { src?: string | null; + fallbackSrc?: string | null; alt?: string; className?: string; }) => src ? ( - {alt + {alt ) : null, })); @@ -2901,6 +2909,36 @@ test('mobile discover recommend feed only rotates the card closest to screen cen ); }); +test('mobile discover recommend feed renders cover fallback for legacy browsers', async () => { + renderStatefulLoggedOutHomeView({ + latestEntries: [ + { + ...puzzlePublicEntry, + coverImageSrc: + '/generated-puzzle-assets/puzzle-session-1/cover/image.png', + }, + ], + }); + + const discoverPanel = document.getElementById('platform-tab-panel-category'); + if (!discoverPanel) { + throw new Error('缺少发现面板'); + } + + const card = within(discoverPanel).getByRole('button', { name: /奇幻拼图/u }); + const cover = card.querySelector('.platform-public-work-card__cover'); + const image = within(card).getByRole('img'); + + expect(cover).toBeTruthy(); + expect(cover?.className).toContain('platform-public-work-card__cover'); + expect(image.getAttribute('src')).toBe( + '/generated-puzzle-assets/puzzle-session-1/cover/image.png', + ); + expect(image.getAttribute('data-fallback-src')).toBe( + '/creation-type-references/puzzle.webp', + ); +}); + test('mobile today channel only shows newly published works from today', async () => { const user = userEvent.setup(); const now = new Date(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 9129d608..fd557adc 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -31,6 +31,7 @@ import { UserRound, XCircle, } from 'lucide-react'; +import QRCode from 'qrcode'; import { type ComponentType, type CSSProperties, @@ -42,7 +43,6 @@ import { useRef, useState, } from 'react'; -import QRCode from 'qrcode'; import communityQqQrImage from '../../../media/social-media-group/qq.png'; import communityWechatQrImage from '../../../media/social-media-group/wechat.png'; @@ -58,13 +58,13 @@ import type { ProfileRechargeCenterResponse, ProfileRechargeProduct, ProfileReferralInviteCenterResponse, - WechatNativePayment, ProfileSaveArchiveSummary, ProfileTaskCenterResponse, ProfileTaskItem, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, WechatMiniProgramPayParams, + WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; @@ -137,6 +137,7 @@ import { resolvePlatformPublicWorkCode, resolvePlatformWorldCoverImage, resolvePlatformWorldCoverSlides, + resolvePlatformWorldFallbackCoverImage, resolvePlatformWorldLeadPortrait, } from './rpgEntryWorldPresentation'; @@ -392,11 +393,13 @@ function usePlatformDesktopLayout() { function ResolvedAssetBackdrop({ src, + fallbackSrc, alt, className, ariaHidden = false, }: { src?: string | null; + fallbackSrc?: string | null; alt: string; className: string; ariaHidden?: boolean; @@ -404,6 +407,7 @@ function ResolvedAssetBackdrop({ return ( { if (!enableCoverCarousel) { return fallbackCoverImage @@ -606,6 +611,7 @@ function WorldCard({ {coverImage ? ( @@ -692,6 +698,7 @@ function RecommendCoverOnlyCard({ onClick: () => void; }) { const coverImage = resolvePlatformWorldCoverImage(entry); + const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry); const displayName = formatPlatformWorkDisplayName(entry.worldName); const typeLabel = describePublicGalleryCardKind(entry); const authorName = entry.authorDisplayName.trim() || '玩家'; @@ -708,6 +715,7 @@ function RecommendCoverOnlyCard({ {coverImage ? ( diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index 92921414..8abc1fe3 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -13,7 +13,9 @@ import { mapBabyObjectMatchDraftToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, type PlatformEdutainmentGalleryCard, + type PlatformPuzzleGalleryCard, resolvePlatformPublicWorkCode, + resolvePlatformWorldFallbackCoverImage, } from './rpgEntryWorldPresentation'; test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => { @@ -46,6 +48,32 @@ test('platform work display text limits names and tags by character count', () = ).toEqual(['超长机关', '星桥']); }); +test('platform public cards use play type reference images as cover fallback', () => { + const puzzleCard: PlatformPuzzleGalleryCard = { + sourceType: 'puzzle', + workId: 'puzzle-work-1', + profileId: 'puzzle-profile-1', + publicWorkCode: 'PZ-PUZZLE1', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: '机关拼图', + subtitle: '拼图关卡', + summaryText: '公开作品', + coverImageSrc: '/generated-puzzle-assets/session/cover/image.png', + themeTags: ['拼图'], + playCount: 1, + remixCount: 0, + likeCount: 0, + visibility: 'published', + publishedAt: '2026-05-18T00:00:00.000Z', + updatedAt: '2026-05-18T00:00:00.000Z', + }; + + expect(resolvePlatformWorldFallbackCoverImage(puzzleCard)).toBe( + '/creation-type-references/puzzle.webp', + ); +}); + test('buildPuzzleWorkCoverSlides prefers each level formal image', () => { const slides = buildPuzzleWorkCoverSlides({ workId: 'work-1', diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 60018dba..331c9348 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -446,6 +446,36 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) { return ''; } +export function resolvePlatformWorldFallbackCoverImage( + entry: PlatformWorldCardLike, +) { + if (isPuzzleGalleryEntry(entry)) { + return '/creation-type-references/puzzle.webp'; + } + + if (isMatch3DGalleryEntry(entry)) { + return '/creation-type-references/match3d.webp'; + } + + if (isSquareHoleGalleryEntry(entry)) { + return '/creation-type-references/square-hole.webp'; + } + + if (isVisualNovelGalleryEntry(entry)) { + return '/creation-type-references/visual-novel.webp'; + } + + if (isBigFishGalleryEntry(entry)) { + return '/creation-type-references/big-fish.webp'; + } + + if (isEdutainmentGalleryEntry(entry)) { + return '/creation-type-references/creative-agent.webp'; + } + + return '/creation-type-references/rpg.webp'; +} + export function resolvePlatformWorldCoverSlides( entry: PlatformWorldCardLike, ): PlatformPuzzleCoverSlide[] { diff --git a/src/index.css b/src/index.css index 10776058..a1691f57 100644 --- a/src/index.css +++ b/src/index.css @@ -1226,6 +1226,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { background: color-mix(in srgb, var(--platform-panel-fill-soft) 86%, #000 14%); } +.platform-public-work-card__cover::before { + content: ''; + display: block; + padding-top: 56.25%; +} + .platform-public-work-card__body { background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%); } @@ -4902,6 +4908,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { min-height: min(58vh, 28rem); } + .platform-public-work-card--immersive .platform-public-work-card__cover::before { + padding-top: 122%; + } + .platform-public-work-card--immersive .platform-public-work-card__body { min-height: 8rem; padding: 0.9rem 0.95rem 1rem; diff --git a/src/services/miniGameDraftGenerationProgress.test.ts b/src/services/miniGameDraftGenerationProgress.test.ts index 7a5ddaf4..127af3e5 100644 --- a/src/services/miniGameDraftGenerationProgress.test.ts +++ b/src/services/miniGameDraftGenerationProgress.test.ts @@ -25,15 +25,15 @@ describe('miniGameDraftGenerationProgress', () => { expect(progress?.steps.map((step) => step.label)).toEqual([ '编译首关草稿', '生成关卡名称', - '生成首关画面', - '生成UI背景', + '并行生成素材', + '校验背景资源', '写入正式草稿', ]); expect(progress?.phaseLabel).toBe('编译首关草稿'); expect(progress?.steps[0]?.detail).toBe( '读取画面描述,建立可编辑草稿与首关结构。', ); - expect(progress?.estimatedRemainingMs).toBe(130_500); + expect(progress?.estimatedRemainingMs).toBe(298_500); expect(progress?.overallProgress).toBeGreaterThan(0); expect(progress?.steps[0]?.completed).toBeGreaterThan(0); }); @@ -49,17 +49,20 @@ describe('miniGameDraftGenerationProgress', () => { }; const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000); - const uiProgress = buildMiniGameDraftGenerationProgress(state, 96_000); - const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 126_000); + const uiProgress = buildMiniGameDraftGenerationProgress(state, 282_000); + const writeBackProgress = buildMiniGameDraftGenerationProgress( + state, + 296_000, + ); expect(imageProgress?.phaseId).toBe('puzzle-images'); - expect(imageProgress?.estimatedRemainingMs).toBe(107_000); + expect(imageProgress?.estimatedRemainingMs).toBe(275_000); expect(imageProgress?.steps[1]?.status).toBe('completed'); expect(imageProgress?.steps[2]?.status).toBe('active'); expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0); expect(uiProgress?.phaseId).toBe('puzzle-ui-background'); expect(writeBackProgress?.phaseId).toBe('puzzle-select-image'); - expect(writeBackProgress?.estimatedRemainingMs).toBe(7_000); + expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000); expect(writeBackProgress?.steps[3]?.status).toBe('completed'); expect(writeBackProgress?.steps[4]?.status).toBe('active'); }); @@ -74,7 +77,7 @@ describe('miniGameDraftGenerationProgress', () => { error: null, }; - const progress = buildMiniGameDraftGenerationProgress(state, 200_000); + const progress = buildMiniGameDraftGenerationProgress(state, 360_000); expect(progress?.phaseId).toBe('puzzle-select-image'); expect(progress?.overallProgress).toBe(98); diff --git a/src/services/miniGameDraftGenerationProgress.ts b/src/services/miniGameDraftGenerationProgress.ts index 6c47a4f8..d4052a04 100644 --- a/src/services/miniGameDraftGenerationProgress.ts +++ b/src/services/miniGameDraftGenerationProgress.ts @@ -93,15 +93,15 @@ const PUZZLE_STEPS = [ }, { id: 'puzzle-images', - label: '生成首关画面', - detail: '调用图片模型生成适合切块的正方形首图。', - weight: 42, + label: '并行生成素材', + detail: '同时生成首关画面与 9:16 纯背景。', + weight: 74, }, { id: 'puzzle-ui-background', - label: '生成UI背景', - detail: '生成不含槽位和控件的 9:16 纯背景。', - weight: 32, + label: '校验背景资源', + detail: '确认首关图和 UI 背景都已写入资产库。', + weight: 0, }, { id: 'puzzle-select-image', @@ -111,7 +111,7 @@ const PUZZLE_STEPS = [ }, ] as const satisfies ReadonlyArray; -const PUZZLE_ESTIMATED_WAIT_MS = 132_000; +const PUZZLE_ESTIMATED_WAIT_MS = 5 * 60_000; const PUZZLE_NON_READY_MAX_PROGRESS = 98; const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000; const PUZZLE_PHASE_TIMELINE: Array<{ @@ -127,8 +127,8 @@ const PUZZLE_PHASE_TIMELINE: Array<{ }> = [ { phase: 'compile', durationMs: 12_000 }, { phase: 'puzzle-level-name', durationMs: 8_000 }, - { phase: 'puzzle-images', durationMs: 70_000 }, - { phase: 'puzzle-ui-background', durationMs: 32_000 }, + { phase: 'puzzle-images', durationMs: 260_000 }, + { phase: 'puzzle-ui-background', durationMs: 10_000 }, { phase: 'puzzle-select-image', durationMs: 10_000 }, ];