29 Commits

Author SHA1 Message Date
e8d832c1ea Merge pull request 'fix: 修复微信支付生产构建依赖' (#20) from master into release
Reviewed-on: #20
2026-05-14 03:38:41 +08:00
e8648e45fc fix: 修复微信支付生产构建依赖 2026-05-14 02:40:34 +08:00
b08f878841 Merge pull request 'master' (#19) from master into release
Reviewed-on: #19
2026-05-14 01:25:03 +08:00
ae58a443a3 feat: 接入微信小程序支付 2026-05-14 00:16:17 +08:00
bf4423e53b Add release web artifact rsync fallback 2026-05-13 21:22:26 +08:00
57de9a8df6 Merge pull request 'hermes/visual-novel-genarrative' (#18) from hermes/visual-novel-genarrative into master
Reviewed-on: #18
2026-05-13 21:19:38 +08:00
c1131e6f55 feat: add visual novel AI image entry points
Some checks are pending
CI / verify (pull_request) Waiting to run
2026-05-13 21:14:13 +08:00
2a75a19ece fix: handle visual novel typed SSE events 2026-05-13 20:44:22 +08:00
5b96265c50 fix wechat mini program phone parsing 2026-05-13 20:39:01 +08:00
ad1b91498d Merge pull request 'master' (#17) from master into release
Reviewed-on: #17
2026-05-13 20:26:33 +08:00
2277b37888 Limit Jenkins fallback git checkouts 2026-05-13 20:24:58 +08:00
be53a90f77 remove github ci 2026-05-13 19:40:33 +08:00
71c7dd2558 Merge pull request 'Use domain fallback for Jenkins git checkout' (#16) from master into release
Reviewed-on: #16
2026-05-13 19:38:54 +08:00
bcd7617fb7 Use domain fallback for Jenkins git checkout
Some checks are pending
CI / verify (push) Waiting to run
CI / verify (pull_request) Waiting to run
2026-05-13 19:35:50 +08:00
a6de4d8a32 Merge branch 'master' into release 2026-05-13 17:34:46 +08:00
49468441bc fix(jenkins): use git domain for scm remotes
Some checks failed
CI / verify (push) Has been cancelled
2026-05-13 17:17:55 +08:00
8ddaf72eb7 Merge pull request 'master' (#15) from master into release
Reviewed-on: #15
2026-05-13 16:21:57 +08:00
a92dc2b7b0 fix(jenkins): add git fallback and nginx aliases
Some checks failed
CI / verify (pull_request) Waiting to run
CI / verify (push) Has been cancelled
2026-05-13 16:07:54 +08:00
4fecf9c975 fix(auth): tighten refresh session revocation 2026-05-13 15:13:43 +08:00
ad970797e9 Merge pull request 'master' (#14) from master into release
Reviewed-on: http://82.157.175.59:3000/GenarrativeAI/Genarrative/pulls/14
2026-05-13 13:23:06 +08:00
c3fbf7a30b feat: tighten visual novel one-line generation flow 2026-05-13 12:26:39 +08:00
b13870f71b 1
Some checks failed
CI / verify (pull_request) Waiting to run
CI / verify (push) Has been cancelled
2026-05-13 03:11:00 +08:00
e4a8bd42bb Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative 2026-05-13 03:10:55 +08:00
01c5ab985a 1 2026-05-13 00:28:07 +08:00
ac12f1ed5e Merge branch 'codex/wechat'
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 22:30:50 +08:00
36e134e323 merge codex/wechat into master
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 18:58:21 +08:00
9b72dbb3ea ci: load nginx dynamic modules for brotli probe
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 16:59:01 +08:00
188c6704db ci: detect nginx brotli via config test
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 16:53:53 +08:00
d641840098 ci: enable nginx compression in server provision
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 16:30:35 +08:00
198 changed files with 16390 additions and 2865 deletions

View File

@@ -1,44 +0,0 @@
name: CI
on:
pull_request:
push:
branches:
- main
- master
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.19.0
cache: npm
- name: Install dependencies
run: npm ci
- name: Check encoding
run: npm run check:encoding
- name: Lint
run: npm run lint:eslint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test
- name: Build
run: npm run build
- name: Validate content
run: npm run check:content

View File

@@ -0,0 +1,343 @@
# Genarrative 视觉小说“一句话生成”最小闭环落地计划
生成时间2026-05-13 11:22
工作区:`C:/proj/Genarrative/.worktrees/hermes-visual-novel`
参考文档:`C:/Users/DSK/Documents/Interactive-fiction/一句话生成视觉小说整体流程总结.md`
## 1. 目标
把 Interactive-fiction 总结文档中的“一句话生成视觉小说”流程,映射并落地到 Genarrative 现有视觉小说能力中,优先做成一个可端到端验证的最小闭环:
1. 用户在视觉小说入口输入一句话并选择画风。
2. 前端进入生成过程页,展示分阶段进度。
3. 后端创建视觉小说创作会话,并基于 seedText 生成 `VisualNovelResultDraft`
4. 生成完成后进入草稿结果页,可看到世界观、角色、场景、剧情阶段、开场选择。
5. 草稿可编译/保存为作品 profile并进入视觉小说运行态测试/正式游玩。
本计划只覆盖 Genarrative 内部最小闭环,不引入 Interactive-fiction 原项目的独立 TXT 播放记录、分享播放包、外部活动运营、独立账号/交易/资产系统。
## 2. 当前上下文与已发现实现
### 2.1 Interactive-fiction 总结文档提炼
参考文档将整体流程分为:
- 输入侧:一句话创意、主题/风格、可选文档或素材。
- 生成侧:理解意图、扩展世界观、角色、场景、剧情阶段、开场与选择。
- 编辑侧:草稿页可查看和调整生成结果。
- 运行侧:从草稿进入视觉小说游玩,支持剧情推进、玩家选择、历史与状态。
- 资产侧:角色立绘、背景、音乐/音效可作为后续增强,最小闭环可先使用文字描述与空资产占位。
### 2.2 Genarrative 已有实现基础
已确认项目中视觉小说相关能力并非从零开始:
- 前端入口表单:
- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx`
- 已有“一句话创作” textarea、6 个视觉画风选项、提交按钮“生成视觉小说草稿”。
- 前端入口 payload/progress
- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts`
- 已有 `VisualNovelEntryFormPayload`、锚点展示、一句话/画风生成进度步骤。
- 前端平台主流程:
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- 已接入 `createVisualNovelDraftFromForm`,会创建 session、stream message、进入 `visual-novel-generating`,完成后进入 `visual-novel-result`
- 前端 API client
- `src/services/visual-novel-creation/visualNovelCreationClient.ts`
- 已封装 session/message/action/compile 接口。
- 共享契约:
- `packages/shared/src/contracts/visualNovel.ts`
- 已定义 `VisualNovelResultDraft`、world/characters/scenes/storyPhases/opening/runtimeConfig/work/run/history 等结构。
- 后端 API
- `server-rs/crates/api-server/src/visual_novel.rs`
- 已有创建 session、发消息、流式消息、执行 action、compile、work、runtime run 等接口。
- 后端 prompt
- `server-rs/crates/api-server/src/prompt/visual_novel.rs`
- 已有 `VISUAL_NOVEL_CREATION_SYSTEM_PROMPT`、结构化输出契约、runtime GM prompt、repair prompt。
- SpacetimeDB 模块:
- `server-rs/crates/spacetime-module/src/visual_novel.rs`
- 已有 session/message/work/run/history/event 表与 procedure。
- 文档参考:
- `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`
- `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`
- `docs/technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md`
### 2.3 关键实现判断
当前项目已经实现了视觉小说的主要骨架,本次不应大规模重写。更合理的落地方式是补齐“一句话生成”闭环中最容易断裂的点:
- 入口输入与画风信息是否被稳定传给后端 prompt。
- 后端生成 draft 后是否自动保存/关联可编辑 work profile。
- 生成过程页是否能清晰展示 Interactive-fiction 文档中提到的阶段。
- 结果页是否有足够的字段展示与继续游玩入口。
- 运行态是否能基于 opening/choices 正常启动,而不依赖尚未生成的图片/音乐资产。
## 3. 拟采用方案
### 3.1 最小闭环范围
本次优先实现:
1. “一句话 + 视觉画风”作为 `sourceMode: 'idea'` 的 seedText。
2. 后端生成完整 `VisualNovelResultDraft`,包括:
- world
- 3-6 个角色
- 3-8 个场景
- 3-6 个剧情阶段
- opening narration/firstDialogue/2-4 个 choices
- runtimeConfig
3. 若 LLM 输出失败,使用 repair 或确定性 fallback保证可回到草稿页并显示错误/警告。
4. 结果页支持保存/编译为 work profile。
5. work profile 支持启动 runtime runopening 能展示初始场景、旁白、对话和选择。
暂不做或仅预留:
- 真实图片/音乐生成队列。
- 多文档解析导入的完整链路。
- 复杂分镜/节点图编辑器。
- 外部 Interactive-fiction 项目的播放器、TXT 记录包、分享活动、独立账号系统。
### 3.2 与 Genarrative 架构的映射
| Interactive-fiction 概念 | Genarrative 落点 |
| --- | --- |
| 一句话创意 | `VisualNovelEntryFormPayload.ideaText` / `seedText` |
| 画风/主题 | `seedText` 中的“视觉画风/画风要求”,后续可结构化为 metadata |
| 世界观设定 | `VisualNovelResultDraft.world` |
| 角色设定 | `VisualNovelResultDraft.characters` |
| 场景设定 | `VisualNovelResultDraft.scenes` |
| 剧情阶段/章节 | `VisualNovelResultDraft.storyPhases` |
| 开场文本与选项 | `VisualNovelResultDraft.opening` |
| 运行时剧情推进 | `VisualNovelRuntimeStep[]` + run snapshot/history |
| 发布/作品库 | `VisualNovelWorkProfileRecord` / works API |
## 4. 分步计划
### Step 1补齐入口 payload 与生成过程语义
涉及文件:
- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx`
- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts`
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
任务:
1. 保持现有 6 个画风选项,但确认每个 option 的 prompt 会进入 `seedText`
2. 将生成过程阶段从当前 3 步细化为更贴合参考文档的 4-5 步,例如:
- 理解一句话创意
- 扩展世界观与玩家身份
- 设计角色/场景/剧情阶段
- 生成开场与选择
- 准备可编辑草稿
3. 生成过程页的 anchor 保留“一句话”和“视觉画风”,必要时增加“生成目标:视觉小说草稿”。
4. 确认 `createVisualNovelDraftFromForm` 对失败状态会保留返回入口/重试能力。
验收点:提交一句话后能进入 `visual-novel-generating`,看到阶段进度;完成后进入 `visual-novel-result`
### Step 2增强后端 creation prompt 与 fallback 约束
涉及文件:
- `server-rs/crates/api-server/src/prompt/visual_novel.rs`
- `server-rs/crates/api-server/src/visual_novel.rs`
- 如已有 domain crate`server-rs/crates/module-visual-novel/**` 或相关 normalize/validate 文件
任务:
1. 在 creation prompt 中显式吸收 Interactive-fiction 的“一句话生成”目标:
- 从 seedText 提取核心创意、视觉风格、故事类型。
- 生成可直接运行的 opening 和 choices。
- 图片/音乐资产先置 null但必须有可生成图像的描述。
2. 强化输出约束:
- `opening.sceneId` 必须指向存在且 availability 为 `opening` 的 scene。
- `opening.initialChoices` 必须 2-4 个。
- `storyPhases[0]` 必须包含 opening scene 和主要角色。
- `publishReady` 的判定与 validationIssues 一致。
3. 检查 `submit_visual_novel_message_turn` / `resolve_action_draft` / compile 相关代码:
- 如果 LLM 失败,是否已有 fallback没有则补确定性 fallback draft。
- 如果 draft 不完整,是否会 normalize/repair 并写入 session。
4. 保留现有“不要输出旧 TXT 播放记录、分享播放包、外部商业字段”的约束,避免把参考项目的外部概念误并入 Genarrative。
验收点:后端给定 seedText 时,返回 session.draft 不为空且满足共享契约。
### Step 3确认草稿结果页、保存/编译与作品库链路
涉及文件:
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- `src/components/visual-novel-creation/**`
- `src/services/visual-novel-works*` 或相关 visual novel works client
- `server-rs/crates/api-server/src/visual_novel.rs`
- `packages/shared/src/contracts/visualNovel.ts`
任务:
1. 查找并确认 `visual-novel-result` 页面组件:
- 是否显示 workTitle/workDescription/world/characters/scenes/storyPhases/opening。
- 是否有保存/发布/开始试玩按钮。
2. 确认 `compileVisualNovelWorkProfile``executeVisualNovelAction({kind:'compile_work_profile'})` 会生成/更新 work profile。
3. 确认作品架上使用 `profileId` 而不是 sessionId 作为稳定作品 ID。
4. 如果结果页缺少“一句话来源/画风”的可视化提示,可在结果页或 summary 中补轻量展示,避免用户以为画风丢失。
验收点:生成完成后能保存为作品;作品出现在“我的作品/创作架”;再次打开能读取同一 draft。
### Step 4确认运行态 opening 闭环
涉及文件:
- `src/components/visual-novel-runtime/**`
- `src/services/visual-novel-runtime*`
- `server-rs/crates/api-server/src/visual_novel.rs`
- `server-rs/crates/api-server/src/prompt/visual_novel.rs`
- `packages/shared/src/contracts/visualNovel.ts`
任务:
1. 启动 visual novel work run 时,优先使用 `draft.opening` 生成第一轮 runtime snapshot/history。
2. 如果没有图片/音乐,前端 runtime shell 必须可用文字 fallback不应白屏或阻断游玩。
3. 玩家选择 `choice` 后,后端 runtime GM prompt 生成下一轮 `VisualNovelRuntimeStep[]`
4. 确认正式游玩入口调用 `work_play_start`,并满足已有埋点约定:
- `scope_kind=work`
- `scope_id=稳定作品 ID`
- metadata 包含 `playType/workId/sourceRoute/userId` 等。
验收点:从生成出的作品进入运行态,能看到 opening 并点击至少一个选择推进一轮。
### Step 5补测试与文档
涉及文件:
- 前端测试:按仓库现有测试布局查找 `*.test.ts` / `*.test.tsx`
- Rust 测试:`server-rs/crates/api-server/src/**` 或 domain crate tests
- 文档:可追加到 `docs/technical/``.hermes/shared-memory/decision-log.md`(如团队约定需要)
建议测试:
1. TypeScript 单元测试:
- `buildVisualNovelEntryGenerationProgress` 阶段输出。
- `buildVisualNovelEntryGenerationAnchorEntries` 能展示一句话和画风。
2. Rust 单元测试:
- creation prompt 包含 seedText、sourceMode、输出契约。
- draft normalize/fallback 能生成合法 opening/choices。
- runtime opening 或 first-step 构造不依赖图片/音乐。
3. 集成/手工测试文档:
- 访问平台视觉小说入口。
- 输入一句话。
- 选择画风。
- 点击生成。
- 查看结果页。
- 保存作品。
- 启动试玩并点击选择。
## 5. 可能改动文件清单
高概率改动:
- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx`
- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts`
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- `server-rs/crates/api-server/src/prompt/visual_novel.rs`
- `server-rs/crates/api-server/src/visual_novel.rs`
- `packages/shared/src/contracts/visualNovel.ts`
中概率改动:
- `src/components/visual-novel-runtime/**`
- `src/services/visual-novel-creation/**`
- `src/services/visual-novel-runtime/**`
- `src/services/visual-novel-works/**`
- `server-rs/crates/spacetime-module/src/visual_novel.rs`
- `server-rs/crates/spacetime-client/**` 生成/绑定文件,若 SpacetimeDB contract 需要更新
低概率/仅文档:
- `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`
- `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`
- `.hermes/shared-memory/decision-log.md`
## 6. 验证计划
### 6.1 静态检查
在 worktree 根目录执行:
```bash
npm run typecheck
```
如仓库无统一 typecheck则按 package scripts 选择最接近的前端类型检查命令。
### 6.2 前端定向测试
优先运行与 visual novel / platform entry 相关测试,如存在:
```bash
npm test -- visual-novel
npm test -- platform-entry
```
若仓库使用 vitest
```bash
npm run test -- visual-novel
```
### 6.3 Rust 定向测试
`server-rs` 下运行 visual novel 相关测试:
```bash
cargo test -p api-server visual_novel
cargo test -p shared-contracts visual_novel
```
如改动 SpacetimeDB module
```bash
cargo test -p spacetime-module visual_novel
```
### 6.4 人工验收步骤
1. 启动本地 dev 栈。
2. 访问 Genarrative 主站。
3. 进入创作/视觉小说入口。
4. 输入:`一个雨夜,失忆的高中生在旧图书馆发现一本会回应她心声的日记。`
5. 选择任一画风,例如“映画动画”。
6. 点击“生成视觉小说草稿”。
7. 预期:进入生成过程页,能看到分阶段进度。
8. 预期:完成后进入草稿结果页,包含标题、简介、世界观、角色、场景、剧情阶段和 opening choices。
9. 点击保存/编译作品。
10. 从作品入口进入试玩。
11. 预期opening 文本出现,至少 2 个选择可点击;点击后剧情继续推进一轮。
## 7. 风险、权衡与开放问题
### 7.1 风险
- 现有视觉小说代码已较完整,贸然新增一套 parallel pipeline 会制造重复逻辑;应复用当前 `VisualNovelResultDraft` 与 creation agent flow。
- LLM 输出不稳定可能导致草稿结构不完整;需要 normalize/repair/fallback 确保最小闭环。
- 视觉/音乐资产生成未接入时UI 必须接受 null asset否则运行态可能白屏。
- `PlatformEntryFlowShellImpl.tsx` 文件很大,改动需局部、谨慎,避免影响其他玩法入口。
- 若改动 SpacetimeDB 表结构,可能牵涉 publish、client binding、清库/迁移;最小闭环阶段应尽量避免 schema 变更。
### 7.2 权衡
- 先让文字版视觉小说完整跑通,再补角色立绘/背景图生成。
- 先用 `seedText` 承载画风,再考虑把 `visualStyleId/Label/Prompt` 结构化进 draft metadata。
- 先用现有 result/work/runtime 页面闭环,不引入新编辑器。
### 7.3 开放问题
1. 用户是否要求把 Interactive-fiction 原项目中的具体 UI 样式/页面布局迁移到 Genarrative当前计划只迁移流程语义不迁移独立 UI。
2. 画风是否需要成为作品可编辑字段?当前以 seedText/prompt 影响生成内容,后续可在 draft 中增加 metadata。
3. 文档导入模式是否本期要做当前计划聚焦一句话模式document 模式只保留契约能力。
4. 是否需要真实图片/音乐生成?当前计划作为后续增强,不纳入最小闭环。
## 8. 建议实施顺序
1. 先做只改 prompt/progress/少量前端展示的轻量闭环修补。
2. 运行前后端定向测试,确认现有能力是否已足够。
3. 如果后端没有 fallback 或 normalize再补 Rust 层确定性兜底。
4. 手工跑通“一句话 -> 生成 -> 结果页 -> 保存 -> 试玩”。
5. 最后再考虑是否需要资产生成、文档导入、结构化画风 metadata。

View File

@@ -16,6 +16,69 @@
---
## 2026-05-13 微信小程序支付以后端通知为唯一入账事实
- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。
- 决策:`paymentChannel = "mock"` 继续创建即 paid 订单并立即入账;`paymentChannel = "wechat_mp"` 先在 `profile_recharge_order` 写入 `pending` 订单,再由 `api-server` 调微信支付 JSAPI 下单并返回小程序 `wx.requestPayment` 参数。小程序或 H5 的支付成功回调只触发刷新,不直接发放光点或会员;最终入账只由 `/api/profile/recharge/wechat/notify` 验签、解密并确认 `trade_state = SUCCESS` 后完成。`provider_transaction_id` 保存微信支付平台交易号,用于对账、查单、退款和客服排障。
- 影响范围:`profile_recharge_order` 表、SpacetimeDB 充值 procedure、`api-server` 微信支付客户端、小程序 native 支付页、H5 充值弹窗与共享 contract。
- 验证方式:执行 `npm run typecheck``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml``cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml`,后端联调仍用 `npm run api-server``/healthz`
- 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 2026-05-13 修改密码后全设备强制下线
- 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token不符合“改密后全设备强制下线”的账号安全预期。
- 决策:`POST /api/auth/password/change` 成功后必须在同一认证真相更新中撤销该用户全部 active `refresh_session`,继续递增 `token_version`,响应清除当前 refresh cookie前端 `changePassword` 成功后清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。
- 影响范围:`module-auth` 修改密码用例、`api-server` password management route、`AuthGate``authService`、密码登录/重置技术文档。
- 验证方式:执行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml password_change_allows_login_with_new_password_only -- --nocapture``npm run test -- AuthGate.test.tsx authService.test.ts``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md``docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`
## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线
- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session前端“踢下线”只做本地状态变化未真正让远端设备失效。
- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session并继续递增 `token_version`
- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate``AccountModal`、认证会话技术文档和路由/埋点索引。
- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session``cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture``cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture``cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture``npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts``npm run check:encoding``git diff --check`,并用 `npm run api-server` 检查 `/healthz`
- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md``docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md``docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格
- 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。
- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。
- 影响范围:`Match3DAgentWorkspace`、抓大鹅入口交互测试、Match3D PRD、素材生成流水线技术文档、F1 入口文档和 `public/match3d-style-references/` 静态资产。
- 验证方式:执行 `npm run test -- src\components\match3d-creation\Match3DAgentWorkspace.interaction.test.tsx``cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml``npm run typecheck``npm run check:encoding`,并人工抽查 `.tmp/match3d-style-preview.png`
- 关联文档:`docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md``docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md`
## 2026-05-12 拼图与抓大鹅草稿背景音乐按纯音乐自动生成
- 背景:拼图和抓大鹅需要在草稿生成阶段直接产出可试听、可重生成、可进入运行态循环播放的背景音乐。
- 决策:复用通用 VectorEngine Suno 创作音频链路,不新增 SpacetimeDB 表;拼图音乐保存到首关 `PuzzleDraftLevel.backgroundMusic`,运行态通过 `PuzzleRuntimeLevelSnapshot.backgroundMusic` 下发;抓大鹅音乐保存到首个 `generatedItemAssets[].backgroundMusic`。两者草稿生成都使用 `title` 驱动、`prompt = ""``make_instrumental = true`,失败只降级记录 warning结果页允许重新生成。
- 影响范围:`api-server` 音频生成、拼图草稿编译、抓大鹅草稿编译、Puzzle/Match3D 结果页和运行态音频播放。
- 验证方式:检查草稿 response / work detail 中的 `backgroundMusic.audioSrc`,运行态开局后隐藏 audio 循环播放;执行音频相关后端 check、前端 typecheck 和编码检查。
- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``compile_puzzle_draft` 草稿编译阶段在首图和背景音乐后自动生成首关 UI 背景,失败只记录 warning 并允许结果页重试;结果页新增 `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。
- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 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`
## 2026-05-12 抓大鹅结果页素材编辑统一走作品级资产面板
- 背景:抓大鹅结果页需要支持碰面图上传 / AI 重绘、物品素材独立预览、单项删除和批量新增,且不能把素材编辑继续做成列表内联展开或前端临时状态。
- 决策:结果页 `作品信息` 的碰面图点击打开独立面板,参考图可来自本地上传、物品素材和 UI 素材AI 重绘统一调用 `POST /api/creation/match3d/works/{profileId}/cover-image` 并转存到 `generated-match3d-assets``素材配置 > 物品` 列表项点击打开独立预览面板,不再提供单项重新生成按钮;单项删除和批量新增都写回同一份 `generated_item_assets_json`。批量新增调用 `POST /api/creation/match3d/works/{profileId}/item-assets`,复用草稿生成的 2D 素材图、5x5 切图、OSS 上传和可选点击音效链路,仅作用于新增物品,不新增 SpacetimeDB 表。
- 影响范围Match3D 结果页、Match3D works shared contracts、`api-server` Match3D 作品路由、生成资产历史类型和草稿恢复路径。
- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run typecheck``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 2026-05-12 平台法律文档入口与登录协议确认
- 背景:生产发布需要在个人页展示用户协议、隐私政策、免责声明和备案号;登录页首次登录需要显式确认法律协议。
- 决策:法律文档内容读取 `media/files/*.md`,统一通过 `LegalDocumentModal` 独立弹窗展示;“我的”页常用功能区固定 3 列,设置入口下方展示法律信息和 `京ICP备2026025677号` 外链。登录弹窗用 `genarrative.auth.legal-consent.v1` 记录本机确认,首次未勾选时短信 / 密码登录按钮禁用,法律链接不自动勾选。
- 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。
- 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`
- 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`
## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权
- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
@@ -28,6 +91,7 @@
- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。
- 决策:新增 `/api/creation/audio/*` 通用创作音频路由,后端统一负责 VectorEngine 音频任务、OSS 转存、`asset_object``asset_entity_binding` 写入;视觉小说旧路由保留并复用同一持久化逻辑。拼图背景音乐暂存到首关 `levels_json[0].backgroundMusic/background_music`;抓大鹅背景音乐暂存到 `generated_item_assets_json[0].backgroundMusic/background_music`,单物体点击音效存到对应 item 的 `clickSound/click_sound`。本轮不新增 SpacetimeDB 表和字段。
- 2026-05-12 补充:抓大鹅入口页新增 `generateClickSound` 开关,默认关闭;开启时 `match3d_compile_draft` 在生成首批 2D 物品素材后并行生成各物品点击音效,并继续复用通用创作音频路由的 OSS、资产绑定和扣费口径。
- 影响范围:拼图结果页、抓大鹅结果页、抓大鹅运行态音频播放、通用创作音频 shared contracts、`api-server` 音频路由和资产绑定。
- 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck``cargo test -p api-server vector_engine_audio_generation``cargo test -p shared-contracts creation_audio``cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。
- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
@@ -269,10 +333,18 @@
## 2026-05-10 抓大鹅草稿元信息由 gpt-4o 生成
- 背景:抓大鹅草稿生成需要基于入口题材设定生成作品名称,结果页作品信息要对齐拼图草稿,不再把封面和作品名称拆成两个模块。
- 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会产出 3 个物品图片并立即调用 Rodin 生成 GLB图片和模型一起写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].modelSrc` / `modelObjectKey`,默认积木只做兜底。
- 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息``3D素材` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。
- 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会按难度产出多视角 2D 物品图片并写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].imageViews[]`,默认积木只做兜底。
- 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息``素材配置` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。
- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/services/miniGameDraftGenerationProgress.test.ts``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run check:encoding`,并用 `npm run api-server` 检查 `/healthz`
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md``docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md`
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md``docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md` 仅作历史参考
## 2026-05-12 抓大鹅物品种类从消除次数中拆出并改为 2D 五视角素材
- 背景:结果页草稿素材已经能生成和预览,但标准 / 硬核难度仍可能按 `clearCount` 误判需要 12 / 20 种素材,且继续生产 GLB 会拉长草稿生成耗时。
- 决策:难度配置统一使用 `物品种类`:轻松 3、标准 9、进阶 15、硬核 21历史硬核 `clearCount=20` 在运行态升为 21 组三消。新草稿和批量新增不再调用 Rodin、不再生成 GLB。每个物品生成 5 个不同 2D 视角,单张 1K 素材图固定按 5x5 切割,最多承载 5 个物品;超过 5 个物品时由 `api-server` 自动分批并行生图。发布必须校验已生成 `image_ready` 且有 `imageViews[]` 或首图引用的素材数量满足当前难度;试玩通过 `itemTypeCountOverride` 自动降到可用 2D 素材数量。历史模型字段只作为旧数据兼容,不再进入新生产链路。
- 影响范围Match3D 结果页、运行态启动契约、`module-match3d` 初始 run 生成、SpacetimeDB start input / restart、发布校验和 Match3D 技术文档。
- 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`、相关后端 check / tests。
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 2026-05-07 移动端整页缩放由入口统一锁定

View File

@@ -22,6 +22,14 @@
- 验证:运行 `cd server-rs && cargo test -p platform-oss -- --nocapture`,并用 bucket=`xushi-dev`、object_key=`generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png` 覆盖签名生成。
- 关联:`server-rs/crates/platform-oss/src/lib.rs``server-rs/crates/platform-oss/README.md`
## generated 音频路径进运行态前要先换签
- 现象:草稿页 audio 控件能播放背景音乐但拼图或抓大鹅运行态开局后背景音乐不响Network 可能出现裸 `/generated-*-assets/...mp3` 私有路径 403。
- 原因:生成音乐转存到 OSS 私有对象后,`audioSrc` 是 generated legacy path浏览器 `<audio>` 不能像公开静态资源一样直接请求裸路径。
- 处理:运行态隐藏 `<audio>` 设置 `src` 前,先通过 `useResolvedAssetReadUrl``resolveAssetReadUrl` 换签;播放失败只静默兜底,不阻断局内交互。拼图读取 `currentLevel.backgroundMusic.audioSrc`,抓大鹅读取 `generatedItemAssets[].backgroundMusic.audioSrc`
- 验证:运行态开局后 `<audio loop>``src` 为签名 URL 或公开 URL`npm run typecheck` 不报契约字段缺失,后端 run response 带 `backgroundMusic`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 中文乱码与编码风险
- 现象:中文文案、注释、剧情或文档显示为乱码,或被改写成英文。
@@ -430,6 +438,22 @@
- 验证:发布链路使用当前 `deploy/systemd``deploy/nginx``scripts/deploy``jenkins/Jenkinsfile.production-*`
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Release Web 产物通过内网 rsync 拉取
- 现象:`Genarrative-Web-Deploy` 发布到 `release` 目标时release agent 本地没有 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/web.tar.gz`,但 Jenkins controller 又只归档轻量元数据,导致发布阶段找不到 Web 大包。
- 原因Web 大包为了避免从 Linux 构建机拉回 Jenkins controller默认留在构建机稳定缓存目录development 目标与构建机同机可直接读取release 目标是独立机器,需要内网同步。
- 处理release 服务器的 Jenkins 运行用户配置 SSH Host `genarrative-build-internal` 指向构建机内网地址,`Genarrative-Web-Deploy``DEPLOY_TARGET=release` 且本地缺少大包时默认执行 `rsync` 拉取同一路径内容;真实内网 IP、用户和私钥路径只放在服务器本机 SSH config不写入 Jenkinsfile。
- 验证:在 release 服务器上先手工跑通 `rsync -av --progress "genarrative-build-internal:${SRC}/" "${DST}/"`,再运行 Web Deploy流水线会继续执行 `web.tar.gz.sha256` 校验。
- 关联:`jenkins/Jenkinsfile.production-web-deploy``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Jenkins 生产流水线拉 Git 先本机再域名备用
- 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败;若 fallback 到公网 Git 时没有限制 refspec、浅克隆和 tags还可能在约 10 分钟后出现 `git-remote-https died of signal 15``early EOF``invalid index-pack output`
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。
- 处理Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback首次 checkout 必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile确认存在 `GIT_REMOTE_FALLBACK_URL``EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`
- 关联:`jenkins/Jenkinsfile.production-web-deploy``jenkins/Jenkinsfile.production-api-deploy``jenkins/Jenkinsfile.production-stdb-module-publish``jenkins/Jenkinsfile.production-server-provision``jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import``scripts/jenkins-checkout-source.sh``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Jenkins 可选参数在 set -u 下不能裸读
- 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。
@@ -470,46 +494,77 @@
- 验证:`cargo test -p api-server accepts_opaque_subscription_key_without_length_cap --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`
## 抓大鹅草稿生成恢复 Rodin 后要并行生成模型、同步长超时和 GLB 私有读取
## 抓大鹅草稿不要再接回 Rodin GLB 生成
- 现象:抓大鹅草稿生成重新接回 Rodin 后,前端可能在模型轮询和 GLB 转存完成前超时;或 Hyper3D 控制台显示 3 个任务已完成,但草稿进度页仍停留在 `生成3D模型`;或结果页把 `/generated-match3d-assets/.../model.glb` 直接当浏览器 URL 加载导致私有 OSS/CORS 读取失败
- 原因:`match3d_compile_draft` 会完成作品元信息、素材图、切图上传、3 件 Rodin 图生模型、GLB 下载和 OSS 转存,耗时远长于普通 Agent action如果 3 件 Rodin 模型逐个提交和轮询,等待时间会线性叠加;同时 generated 私有资产不能被 Three.js 直接 `fetch`
- 处理:切图和图片入库后,所有 Rodin 图生模型任务必须并行提交、并行轮询、并行下载转存Match3D creation client 的 `executeAction` 必须给长超时,当前为 20 分钟;生成进度页要包含 `生成3D模型` 阶段,但它不是 Hyper3D task 订阅页,而是在长 action 执行期间旁路轮询 session / work detail并用 profile 的 `generatedItemAssets` 更新完成数量;控制台看到 Rodin `Done` 后仍需等待下载列表、GLB 下载、OSS 转存和草稿 JSON 写回。结果页模型预览、场内运行态和备选栏预览都必须通过 `/api/assets/read-bytes` 读取 GLB 字节后交给 Three.js GLTFLoader不要直接请求裸 generated 路径。排查时按同一个 session/profile 查看 api-server 日志:`抓大鹅 Rodin 状态轮询返回``抓大鹅 Rodin 下载列表轮询返回``抓大鹅 Rodin GLB 下载完成``抓大鹅 Rodin GLB 转存 OSS 完成`;同时检查前端 work detail 响应里的 `generatedItemAssets[].status/modelObjectKey/error`
- 验证:`npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx``npm run typecheck`;真实联调需配置 `VECTOR_ENGINE_API_KEY``HYPER3D_API_KEY` 和 OSS 变量
- 关联:`src/services/match3d-creation/match3dCreationClient.ts``src/services/creation-agent/creationAgentClientFactory.ts``src/components/match3d-result/Match3DModelPreview.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``server-rs/crates/api-server/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## Rodin 完成态后下载列表可能延迟或字段漂移
- 现象:抓大鹅草稿生成时 Rodin 状态已完成,但 `/api/creation/match3d/sessions/{sessionId}/actions` 返回 502提示 `{物品名} 3D 模型已完成但未返回可下载模型文件:{taskUuid}`
- 原因Hyper3D Rodin 官方 `check-status_reset_v` 示例要求看 `jobs` 列表,只有所有 job 都 `Done` 才能进入下载;`download-results_reset_v` 还明确要求用生成响应顶层 `uuid` 作为 `task_uuid`,不要用 `jobs.uuids` 子任务 uuid。旧聚合若只看 root status 或第一个 job可能在 preview job 完成但模型 job 仍在生成时提前下载。另外任务完成和下载列表文件发布不是强同步的;上游下载结果还可能使用 `fileUrl``signedUrl``presignedUrl``fileName` 等字段别名,旧解析器只识别 `url/downloadUrl/name/file_name/filename` 时会得到空列表。
- 处理:`query_task_status` 聚合状态必须以 `jobs` 为准:任一 failed 即 failed全部 done 才 done`match3d_compile_draft` 在状态完成后对 `query_downloads` 继续轮询;下载解析兼容常见 URL 和文件名字段别名;模型选择优先 `.glb`,可兜底到非图片下载文件,但只有 preview/png/jpg/webp 这类预览图时必须继续失败,不能伪装成 GLB。
- 验证:`cargo test -p api-server match3d_model_download --manifest-path server-rs/Cargo.toml``cargo test -p api-server extracts_download_files --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/match3d.rs``server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
- 现象:修改抓大鹅素材时容易沿用旧 Rodin/GLB 方案,导致新草稿生成耗时变长、进度停在模型阶段,或运行态等待不存在的 GLB
- 原因:仓库里保留了 Hyper3D 通用代理和历史模型字段,旧文档也曾要求草稿阶段同步生成 GLB。当前产品口径已经改为 2D 多视角素材
- 处理:`match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个视角,单张 1K 素材图固定 5x5 切割,一行对应一个物品,超过 5 个物品自动分批并行生图。`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]` 或首图引用。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路
- 验证:`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅切图路径不能只用中文物品名
- 现象:草稿页 `3D素材` Tab 中多个素材名称不同,但预览图片完全一样;点击图生模型生成时还可能提示 `参考图必须是 data URL`
- 原因:中文物品名经过 OSS 路径段清洗后都可能退化成 `item`,多张切割图片写到同一个 `items/item/image.png` object key后写入覆盖先写入结果页手动 Rodin 图生模型还曾把 `/generated-match3d-assets/...` 私有路径直接作为 `imageDataUrls` 提交
- 处理:切割图上传路径必须带稳定唯一 `itemId` 前缀,例如 `items/match3d-item-1-item/image.png`;结果页提交图生模型前,generated 私有路径先经同源 `/api/assets/read-bytes` 由后端换签并读取字节,前端再转成 `data:image/...;base64,...`,不要在浏览器里直接 `fetch` OSS 签名 URL否则会被 bucket CORS 拦截
- 验证:后端单测覆盖中文名路径唯一前端单测覆盖 generated 参考图会换签、fetch 并以 Data URL 调用 `submitHyper3dImageToModel`
- 现象:草稿页 `素材配置 > 物品` 中多个素材名称不同,但预览图片完全一样。
- 原因:中文物品名经过 OSS 路径段清洗后都可能退化成 `item`,多张切割图片写到同一个 object key后写入覆盖先写入
- 处理:切割图上传路径必须带稳定唯一 `itemId` 前缀,例如 `items/match3d-item-1-item/views/view-01.png`;运行态读取 generated 私有图片时通过同源 `/api/assets/read-url` 换签,不直接请求裸 OSS 路径
- 验证:后端单测覆盖中文名路径唯一前端运行态测试覆盖 generated 图片源解析
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅生成素材不能只挂在 compile response
- 现象:抓大鹅草稿生成完成后停留在结果页能看到切割好的 `3D素材` 图片;退出后从草稿 Tab 重新进入同一草稿,素材列表变回默认占位或为空,已生成的物品名称和图片丢失。
- 现象:抓大鹅草稿生成完成后停留在结果页能看到切割好的物品图片;退出后从草稿 Tab 重新进入同一草稿,素材列表变回默认占位或为空,已生成的物品名称和图片丢失。
- 原因:`generatedItemAssets` 如果只附加在 `match3d_compile_draft` 的 HTTP response draft 上,刷新或重进时 `getMatch3DWorkDetail` 只能读取 SpacetimeDB 中的 `match3d_work_profile`;旧 mapper 返回空数组,自然无法恢复素材。拼图链路已经通过 `save_puzzle_generated_images` 把候选图和 levels 写回 work profile抓大鹅也必须同样写持久字段。
- 处理compile 成功时把独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json``update_match3d_work` / `publish_match3d_work` 保留该字段API work summary/detail 映射反序列化为 `generatedItemAssets`。前端保持“本次 draft 优先,重进 profile 兜底”的读取顺序。
- 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`
- 关联:`server-rs/crates/spacetime-module/src/match3d/*``server-rs/crates/spacetime-client/src/mapper.rs``server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅试玩和正式运行态不要只读草稿页本地模型预览
## 抓大鹅试玩和正式运行态不要只读草稿页本地素材预览
- 现象:历史草稿页 `3D素材` Tab 能看到水果模型,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。
- 原因:结果页手动 `重新生成` 曾只更新本地 `assetDrafts.downloads`,没有把新的 GLB 写回 `generatedItemAssets`;历史数据还可能只有 `modelObjectKey` 而没有 `modelSrc`;推荐流内嵌运行态若只读卡片摘要,卡片缺素材时会把已持久化 profile 模型丢掉;本次生成 response 的 draft 也可能比 profile 旧,只带图片而不带模型
- 处理:结果页模型预览和运行态都按 `modelSrc || modelObjectKey` 读取;手动重新生成成功后把素材草稿重新序列化并写回作品 profile`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有模型补齐旧 draft;推荐流内嵌运行态启动前若卡片摘要没有生成素材,补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell`
- 验证:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`,并检查历史草稿和公开 M3 作品的 Network 响应里 `generatedItemAssets[].modelSrc/modelObjectKey`
- 现象:结果页能看到生成的物品图片,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。
- 原因:结果页本地 `assetDrafts` 和作品 profile 的 `generatedItemAssets` 可能不同步;推荐流内嵌运行态若只读卡片摘要,卡片缺素材时会把已持久化 profile 素材丢掉;点击试玩时 React state 异步更新也可能让运行态第一帧读取旧 `match3dProfile`
- 处理:删除、批量新增、音效生成或封面引用物品素材后,都把当前 `generatedItemAssets` 写回作品 profile`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有 `imageViews[]` 或首图引用补齐旧 draft点击试玩前把试玩可用物品种类通过 `itemTypeCountOverride` 降到已生成 2D 素材数量;推荐流内嵌运行态启动前若卡片摘要没有生成素材,补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell``PlatformEntryFlowShellImpl` 需要维护 `match3dRuntimeProfile`,在 `startMatch3DRunFromProfile` 创建 run 后立即锁定本次完整 profileruntime 渲染时优先按 `run.profileId` 使用这份 profile而不是等待普通 `match3dProfile` state 下一轮刷新。
- 验证:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`,并检查历史草稿和公开 M3 作品的 Network 响应里 `generatedItemAssets[].imageViews/imageSrc/imageObjectKey`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 法律文档弹窗通过 portal 挂载时要显式带平台主题
- 现象:登录弹窗内点击协议链接打开法律文档时,弹窗可能继承不到 `platform-theme--light/dark` 变量,或者层级低于登录遮罩导致不可见。
- 原因:`UnifiedModal` 默认通过 portal 挂到 `document.body`,不再处于原页面的主题容器内;登录弹窗自身又使用较高 z-index。
- 处理:法律文档弹窗组件应支持传入 `platformTheme`overlay 上显式挂 `platform-theme platform-theme--*`,并使用高于登录遮罩的层级。法律内容必须作为独立面板打开,不要在当前个人页或登录面板下方内联展开。
- 验证:登录页协议链接、个人页法律入口均能打开可滚动 `LegalDocumentModal`,亮色 / 暗色主题文本和按钮可读。
## 生成页完成回调不能只依赖异步 React state
- 现象:抓大鹅或拼图点击生成后,进度页已经显示 100% / 生成完成,但没有自动进入试玩或结果页。
- 原因:完成回调用 `selectionStageRef.current` 判断用户是否仍在生成页;如果执行 compile 前只调用 `setSelectionStage('*-generating')`action 很快返回时 ref 仍可能是旧 stage。
- 处理:进入各玩法生成页时同步写 `selectionStageRef.current = '*-generating'`,再调用 `setSelectionStage('*-generating')`。这不是为渲染服务,而是给同一异步链路里的完成回调提供即时事实。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 覆盖抓大鹅和拼图生成后自动试玩 / 返回结果页。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 微信支付回调验签不要用商户私钥
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。
- 处理api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*``WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id``profile_recharge_order.provider_transaction_id`
- 验证mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs``docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
- 原因:历史结果页手动 `重新生成` 会把 Hyper3D/Rodin 的外部 CDN 下载链接直接保存到 `generatedItemAssets[].modelSrc`,同时 `modelObjectKey` 为空。外部链接可能过期、跨域、返回 HTML 错误页或非 GLB 内容;前端预览和运行态不能把它当作稳定私有资产。
- 处理:该问题只适用于旧数据。结果页发现 `status = model_ready``modelSrc = https://...` 且无 `modelObjectKey` 时,可调用 `POST /api/creation/match3d/works/{profileId}/generated-models` 做一次性转存;新草稿和批量新增不得继续生成或依赖 GLB。若历史半修复数据同时保留外部 `modelSrc` 和平台 `modelObjectKey`,旧模型预览读取层优先用 `modelObjectKey`
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``npm run test -- src\components\match3d-runtime\Match3DRuntimeShell.test.tsx``npm run test -- src\components\rpg-entry\RpgEntryFlowShell.agent.interaction.test.tsx``cargo test -p api-server match3d_model_download --manifest-path server-rs\Cargo.toml`,并检查修复后响应中的 `generatedItemAssets[].modelObjectKey` 不为空。
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``src/components/match3d-result/Match3DModelPreview.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅难度配置的物品种类和消除次数必须分离
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;素材不足时发布或试玩行为不一致。
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
- 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]``imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/services/match3d-runtime/match3dRuntimeClient.ts``server-rs/crates/module-match3d/src/application.rs``server-rs/crates/spacetime-module/src/match3d/mod.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
- 现象AI 或兜底生成的 `3D素材` 标签在后端规范化后变成 `D素材`

34
deploy/nginx/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Genarrative Nginx compression policy
本配置片段由 `scripts/jenkins-server-provision.sh` 在安装 Nginx 站点配置时展开。
## gzip
- `deploy/nginx/genarrative.conf``deploy/nginx/genarrative-dev-http.conf` 默认开启 gzip。
- 覆盖 `application/json`,用于降低 `/api/runtime/*/gallery` 这类 JSON 列表接口的公网带宽占用。
- 当前推荐等级为 `gzip_comp_level 5`,兼顾 2C/2G 服务器 CPU 与压缩收益。
## Brotli
- Brotli 只在目标服务器 Nginx 接受 brotli 指令时开启。
- Provision 脚本通过临时配置执行 `nginx -t` 做能力探测;探测配置会先 `include /etc/nginx/modules-enabled/*.conf`,避免 Ubuntu 动态模块已安装但测试配置未加载模块导致误判。可用时把模板中的 `# __GENARRATIVE_BROTLI_DIRECTIVES__` 替换为 brotli 指令,不可用时保留注释说明。
- 不要直接在静态模板里无条件写 `brotli on;`,否则没有 brotli 模块的服务器会 `nginx -t` 失败并回滚。
- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用Ubuntu apt 安装的 brotli 是动态模块,可能只出现在 `nginx -T``load_module` 配置里。
## 验证
```bash
curl -sSI -H 'Accept-Encoding: gzip' \
http://<host>/api/runtime/puzzle/gallery \
| grep -iE 'content-encoding|vary|content-type|content-length'
curl -sSI -H 'Accept-Encoding: br' \
http://<host>/api/runtime/puzzle/gallery \
| grep -iE 'content-encoding|vary|content-type|content-length'
```
预期:
- gzip 可用时返回 `Content-Encoding: gzip`
- br 可用时返回 `Content-Encoding: br`
- 响应头应包含 `Vary: Accept-Encoding`

View File

@@ -5,6 +5,23 @@ server {
listen 80;
server_name genarrative.example.com;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
# __GENARRATIVE_BROTLI_DIRECTIVES__
root /srv/genarrative/web;
index index.html;

View File

@@ -16,6 +16,23 @@ server {
listen 443 ssl http2;
server_name genarrative.example.com;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
# __GENARRATIVE_BROTLI_DIRECTIVES__
ssl_certificate /etc/letsencrypt/live/genarrative.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/genarrative.example.com/privkey.pem;

View File

@@ -2,7 +2,7 @@
Issues and PRDs for this repo live as issues in the self-hosted Gitea remote:
- Remote: `http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`
- Remote: `https://git.genarrative.world/GenarrativeAI/Genarrative.git`
- Tracker type: Gitea Issues
## Conventions

View File

@@ -11,7 +11,7 @@
3. 原“排行”页内容并入“发现”页的子 Tab 中,不再作为一级主 Tab 独立展示。
4. 创作页只保留新建创作入口;原创作页作品列表拆到一级“草稿” Tab替换原“存档” Tab。
5. 原“存档”列表结构并入“我的”页面的“玩过”列表弹层,作为每个已玩作品的可继续存档入口。
6. 移动端推荐页与底部 Tab 栏参考用户给定样式,使用大画面推荐流、顶部品牌/通知、悬浮胶囊底部导航;保留当前平台已有明暗两套主题色 token。
6. 移动端推荐页与底部 Tab 栏参考用户给定样式,使用大画面推荐流、顶部品牌悬浮胶囊底部导航;右上角不保留通知按钮,账号相关入口统一进入“我的”和账号面板;保留当前平台已有明暗两套主题色 token。
## 2. 状态映射

View File

@@ -122,6 +122,7 @@
## 9. 2026-05-08 创作首页通知入口下线
- `CreativeAgentHome` 顶栏右上角不再展示“通知与账户”按钮,避免创作首页把通知入口放在首屏高频区域。
- 2026-05-12 平台入口页同步移除移动端和桌面端右上角通知铃铛;移动端顶栏只保留品牌,未登录时保留登录按钮,桌面端只保留账号入口。
- 账号入口仍保留在侧边栏底部,创作首页顶栏维持左侧菜单、居中品牌的轻量结构。
- 当前账号相关入口统一保留在平台首页受保护动作、个人页、存档页与账号弹窗,不再占用全局悬浮层。
@@ -220,4 +221,12 @@
---
## 19. 2026-05-12 登录协议与个人页法律入口
- 登录弹窗的法律协议确认应挂在短信 / 密码登录提交按钮上方,法律链接只打开独立 `LegalDocumentModal`,不能顺手勾选同意。
- 法律弹窗通过 portal 挂到 `body` 时必须显式带 `platform-theme--*` 和高于登录遮罩的层级,否则容易丢主题变量或被登录弹窗遮住。
- “我的”页常用功能区固定为 3 列,法律信息区放在设置入口下方;备案号作为外链进入工信部备案站,入口保持轻量,不在页面内展开长文。
---
_文档目的交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。_

View File

@@ -96,7 +96,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
2. 创建流程采用入口表单收集关键配置。
3. 表单必须在进入结果页前确认:
- 题材主题
- 3D 素材风格
- 2D 素材风格
- 难度选项
4. `需要消除次数` 与难度 `1~10` 数值不再作为独立输入框展示,由难度选项派生。
5. 生成抓大鹅草稿消耗 `20` 光点,生成按钮必须显式展示。
@@ -110,7 +110,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
13. 清空圆形空间中全部物品即胜利。
14. 倒计时结束或备选栏满即失败。
15. 胜利 / 失败后展示结算界面。
16. 入口页的 3D 素材风格选择会进入素材图提示词,并作为结果页手动 Rodin 3D 模型生成的默认提示词依据。
16. 入口页的 2D 素材风格选择会进入素材图提示词,并作为后续物品素材新增和重绘的默认提示词依据。
17. 点击、入槽、消除、失败、胜利的即时反馈效果由前端先行呈现,后端负责权威确认、状态落库和成绩可信性。
---
@@ -124,7 +124,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
3. 不做排行榜正式展示。
4. 不做道具,但需要预留功能口。
5. 不做洗牌、重置、旋转、放大等局内操作。
6. 不做多批次真实 3D 模型生成;当前草稿生成只固定产出 `3` 个 GLB 模型并写入 OSS。
6. 不做首屏真实 3D 模型生成;当前草稿生成以多视角 2D 物品素材为主,并写入 OSS。
7. 不做真实 3D 物理遮挡。
8. 不做真实物理碰撞结算。
9. 不做必须试玩通关才能发布的门槛。
@@ -143,7 +143,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
表单的职责是帮助用户确认可以直接编译 demo 的最小配置:
1. 题材主题。
2. 3D 素材风格。
2. 2D 素材风格。
3. 游戏难度选项。
`需要消除次数` 与游戏难度数值仍属于后端会话配置,但不再要求用户手填。当前入口页固定采用以下映射:
@@ -152,7 +152,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
轻松 -> 需要消除 8 次,难度 2
标准 -> 需要消除 12 次,难度 4
进阶 -> 需要消除 16 次,难度 6
硬核 -> 需要消除 20 次,难度 8
硬核 -> 需要消除 21 次,难度 8
```
## 6.2 必填配置
@@ -161,7 +161,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
当前抓大鹅草稿生成会固定生成 `3` 个题材物品:素材图切割出的独立图片会作为 Rodin 图生 3D 参考图,生成出的 GLB 模型必须转存 OSS并随作品 profile 的 `generatedItemAssets` 持久化。运行态优先使用这些生成模型;只有模型缺失、加载失败或未进入 3D 渲染模式时,才回退到 25 个默认积木件视觉键。默认素材不使用透明气泡,也不在图案上放文字标识。
当前抓大鹅草稿会按难度生成题材物品:素材图切割出的多视角 2D 图片必须转存 OSS并随作品 profile 的 `generatedItemAssets` 持久化。运行态优先使用这些生成图片;只有图片缺失、读取失败或未进入生成素材模式时,才回退到默认积木件视觉键。默认素材不使用透明气泡,也不在图案上放文字标识。
可消除物尺寸使用五档相对体积规则XL 型相对体积为 `1.60~2.30`L 型为 `1.25~1.60`M 型为 `1.00`XS 型为 `0.65~0.85`S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。
@@ -183,19 +183,25 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
首版 demo 中,用户只需凭感觉选择难度;具体难度规则由系统内部解释。后续优化阶段再细化难度曲线、生成算法和遮挡策略。
### 3D 素材风格
### 2D 素材风格
入口页在题材主题与难度之间展示 `3D素材风格` 横向滑动选择。首批固定选项为:
入口页在题材主题与难度之间展示 `2D素材风格` 横向滑动选择。首批固定选项为:
```text
黏土手作 / 低多边形 / 玩具塑料 / 木质雕刻 / 体素积木 / 金属机甲 / 自定义
扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义
```
每个内置选项使用 VectorEngine `gpt-image-2-all` 生成的画风参考图展示;参考图保存在 `public/match3d-style-references/`,只作为入口选择的视觉提示,不作为用户上传参考图。选择内置风格时,前端提交 `assetStyleId``assetStyleLabel` 与对应 `assetStylePrompt`。选择 `自定义` 时必须弹出独立面板,用户填写描述后才允许应用;自定义描述作为 `assetStylePrompt` 进入后端生成链路。
## 6.3 参考图片
抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;草稿生成阶段会直接以切割图片作为 Rodin 图生模型参考图生成首批 GLB。结果页 `3D素材` Tab 仍可对单个素材触发重新生成
抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;草稿生成阶段会生成多视角 2D 物品素材并写入作品 profile。结果页 `素材配置 > 物品` 继续承接物品素材预览、删除、批量新增和音效配置
## 6.4 生成音效开关
抓大鹅入口页在生成按钮前提供 `生成音效` Toggle默认关闭。关闭时草稿生成只保存 `generatedItemAssets[].soundPrompt`,不调用 Vidu 生成点击音效。
用户打开 Toggle 后,前端在创建会话和执行 `match3d_compile_draft` 时提交 `generateClickSound=true`。后端完成物品名称与 `soundPrompt` 生成后,在图片素材生成阶段为每个生成物品调用 Vidu 生成点击音效,并把结果写入对应 `generatedItemAssets[].clickSound`。音效生成复用通用创作音频接口和资产落点;结果页仍保留单个物品音效的手动补生成入口。
---
@@ -222,9 +228,9 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
## 7.3 素材生成边界
抓大鹅草稿生成链路会生成首批 `3`题材物品素材文本模型生成物品名VectorEngine 生成 `2*2` 素材图并切割独立图片,再以每张独立图片调用 Rodin 图生 3D下载 `.glb`转存 OSS。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。
抓大鹅草稿生成链路会根据难度生成题材物品素材文本模型生成物品名VectorEngine 分批生成 `1:1` 素材图并切割为每个物品 `5` 张不同视角图片,再转存 OSS。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页批量新增物品时继续以该风格作为默认提示词起点。
生成出的独立图片与 GLB 模型都必须作为草稿`3D素材` Tab 的预览资产返回。模型生成成功时 `generatedItemAssets[].status = model_ready`,并携带 `modelSrc``modelObjectKey``modelFileName``taskUuid``subscriptionKey`;正式平台资产绑定和更完整的二次编辑流程以后续技术方案为准。
生成出的独立图片必须作为结果`素材配置 > 物品` 的预览资产返回。图片素材生成成功时 `generatedItemAssets[].status = image_ready`,并携带 `imageViews[]`,兼容字段 `imageSrc` / `imageObjectKey` 指向首张视角图;正式平台资产绑定和更完整的二次编辑流程以后续技术方案为准。
## 7.4 发布前试玩
@@ -277,13 +283,16 @@ totalItemCount = clearCount * 3
每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。
生成的消除物类型数由用户填写的需要消除次数决定:
生成的消除物类型数由难度档位决定:
```text
itemTypeCount = clearCount <= 25 ? clearCount : 25
轻松 = 3
标准 = 9
进阶 = 15
硬核 = 21
```
`clearCount <= 25` 时,本局生成的 `itemTypeId` 数量等于 `clearCount`,每种类型默认生成 `3` 件;当 `clearCount > 25` 时,本局最多生成 `25` `itemTypeId`,后续消除组按这 `25`类型轮转补齐,且每种类型最终数量仍必须保持 `3` 的倍数
前四档难度分别生成 `3 / 9 / 15 / 21` `itemTypeId`。历史草稿若仍保留 `clearCount = 20` 的硬核配置,运行时和素材生成都必须兼容映射为 `21`物品,不得回退成 `20` 种。
同一局内这些类型必须分别使用不同的形状和颜色组合,不能出现两个组看起来像同一种物体的情况。
@@ -297,13 +306,13 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25
## 8.5 物品资产
当前 demo 使用生成 GLB 优先、默认积木兜底的物品资产策略。
当前 demo 使用生成 2D 图片优先、默认积木兜底的物品资产策略。
1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合默认素材,支撑 `clearCount > 25` 时的类型上限和 GLB 缺失兜底。
2.`generatedItemAssets[].modelSrc``modelObjectKey` 时,运行态与备选栏必须优先读取该 GLB;默认积木件只作为加载失败、模型缺失或 2D 回退时的兜底素材池。
3. 前端读取生成模型必须通过 `/api/assets/read-bytes` 获取私有 OSS 字节,再交给 Three.js `GLTFLoader` 解析;不得直接把 `/generated-match3d-assets/...` 当裸 URL 请求。
4. 当前固定 `clearCount = 3` 的生成草稿中,运行态 `match3d-type-01/02/03` 按类型编号顺序映射到生成出的 `3` 个模型;后续恢复更大生成数量时,模型列表顺序必须继续与类型编号稳定对应。
5. 默认积木视觉键仍需映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。
1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合默认素材,支撑 `clearCount > 25` 时的类型上限和图片缺失兜底。
2.`generatedItemAssets[].imageViews``imageSrc``imageObjectKey` 时,运行态与备选栏必须优先读取该 2D 图片素材;默认积木件只作为加载失败或图片缺失时的兜底素材池。
3. 前端读取 generated legacy 图片必须通过 `/api/assets/read-url` 换签后加载;不得直接把 `/generated-match3d-assets/...` 当裸 URL 请求。
4. 运行态 `match3d-type-01/02/03` 等类型按类型编号顺序映射到生成出的图片素材列表;后续更大生成数量时,素材列表顺序必须继续与类型编号稳定对应。
5. 默认积木视觉键仍需映射为无文字的纯色 2D 图标,不能显示为透明气泡或文字标记。
6. 用户题材主题后续会映射为符合常识预期的物品集合。
示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。
@@ -334,7 +343,7 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25
飞行动画过程中,物品不再与其他物品产生碰撞。
当前 3D 实验模式下,物品进入备选栏后必须从圆形空间的物理世界移除;备选栏展示该物品同款 3D 模型的独立预览,固定为斜 `45` 度便于识别,不再参与场内碰撞、重力或堆叠。
物品进入备选栏后必须从圆形空间的可点击列表移除;备选栏展示该物品同款 2D 素材图,不再参与场内点击、遮挡或堆叠。
前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。
@@ -344,7 +353,7 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
2. 备选栏中每出现 `3` 个相同物品 id前端立即播放自动消除效果并腾出格子。
3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer并按 `7` 格容器实际宽高把模型居中摆放到对应格子不能因多个预览上下文导致中心场地模型不可见WebGL 回退或 `2D` 模式下才使用保留的 2D 图标。
3. 备选栏格子展示从场内取出的同款 2D 素材图,不使用另一套不一致的 UI 图标;图片缺失或读取失败时才使用保留的默认图标。
4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。
5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
@@ -671,11 +680,12 @@ GET /api/runtime/match3d/runs/:runId
## 14.2 入口表单
入口表单只展示个输入块:
入口表单只展示个输入块:
1. `想做一个什么题材的抓大鹅?` 大文本输入框。
2. `3D素材风格` 横向滑动风格卡,最后一个为 `自定义`
2. `2D素材风格` 横向滑动风格卡,最后一个为 `自定义`
3. `难度` 选项按钮。
4. `生成音效` Toggle默认关闭。
入口页不展示参考图、`需要消除次数` 数值输入、`难度数值` 滑杆,也不展示 `题材 / 物品 / 难度` 三个摘要框。`需要消除次数``difficulty` 由难度选项派生后提交给后端。
@@ -704,24 +714,25 @@ GET /api/runtime/match3d/runs/:runId
首版 PRD 对应 demo 验收标准:
1. 用户可从平台创作入口进入“抓大鹅”模板。
2. 入口表单能确认题材主题、3D 素材风格和难度选项,并提交派生后的消除次数与难度数值。
2. 入口表单能确认题材主题、2D 素材风格和难度选项,并提交派生后的消除次数与难度数值。
3. 入口页不展示参考图上传。
4. 内置风格显示画风参考图,自定义风格通过独立面板填写并进入提交 payload。
5. 移动端入口页所有内容一屏展示,不产生纵向滚动。
6. 系统可生成待发布结果页,并在草稿中返回首批切割图片与 OSS GLB 模型素材预览
7. 用户可编辑游戏名称、标签、封面图等基础信息
8. 用户可发布前试玩,且试玩失败不阻断发布
9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏;存在 `generatedItemAssets` 模型时必须优先展示生成 GLB而不是默认积木素材
10. 物品可重叠、遮挡、堆叠
11. 被完全遮挡物品不可点击,露出可点击区域的物品可点击
12. 点击通过后物品飞入备选栏
13. 备选栏`3` 个相同物品 id 自动消除
14. 清空空间中全部物品后胜利
15. 倒计时结束或备选栏满后失败
16. 胜利结算展示使用时间
17. 失败结算展示完成进度和重新开始按钮
18. 局内即时反馈由前端先行呈现,关键状态以后端确认快照校正
19. 相关中文文档通过编码检查
6. `生成音效` 关闭时草稿生成不产生 `clickSound`;打开时首批生成物品随图片素材生成并持久化点击音效
7. 系统可生成待发布结果页,并在草稿中返回首批多视角 2D 切割图片素材预览
8. 用户可编辑游戏名称、标签、封面图等基础信息
9. 用户可发布前试玩,且试玩失败不阻断发布
10. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏;存在 `generatedItemAssets` 图片素材时必须优先展示生成 2D 素材,而不是默认积木素材
11. 物品可重叠、遮挡、堆叠
12. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。
13. 点击通过后物品飞入备选栏。
14. 备选栏中 `3` 个相同物品 id 自动消除
15. 清空空间中全部物品后胜利
16. 倒计时结束或备选栏满后失败
17. 胜利结算展示使用时间
18. 失败结算展示完成进度和重新开始按钮
19. 局内即时反馈由前端先行呈现,关键状态以后端确认快照校正
20. 相关中文文档通过编码检查。
---

View File

@@ -0,0 +1,90 @@
# “我的”页签法律信息与登录协议确认 PRD
## 1. 目标
在平台“我的”页签底部补齐法律信息入口和备案信息;同时在登录弹窗中增加协议确认,用户首次登录必须手动勾选同意后才能继续登录。
## 2. 入口与布局
### 2.1 “我的”页签常用功能
- 已登录用户在“我的”页签看到常用功能区。
- 常用功能区移动端和网页端都使用 3 列网格。
- 每个功能入口保持图标、主标题、短副标题结构。
- 不新增独立“我的”页面,只扩展现有个人页签。
### 2.2 法律信息区
法律信息区放在“我的”页签底部、设置入口之后。
区块内容:
- 区块标题:`法律信息`
- 三个列表入口:
- `用户协议`
- `隐私政策`
- `免责声明`
- 每个入口点击后打开独立模态面板,不在当前页签下方展开内容。
- 备案信息固定显示在法律入口下方:
- 文案:`京ICP备2026025677号`
- 点击跳转到 `https://beian.miit.gov.cn/`
- 外链在新窗口打开,并使用 `rel="noreferrer"`
## 3. 法律内容面板
### 3.1 内容来源
三份法律内容读取仓库现有 Markdown 文件:
- `media/files/user_agreement.md`
- `media/files/privacy_policy.md`
- `media/files/disclaimer.md`
### 3.2 展示规则
- 使用平台主题变量渲染,暗色和亮色主题都必须可读。
- 面板最大高度不超过视口,正文区域内部滚动。
- 标题固定在面板顶部,底部保留确认按钮 `我知道了`
- Markdown 首版只需要支持标题、段落、列表和加粗文本。
- 不把 Markdown 原文作为纯文本整段堆叠,必须保留基本阅读层级。
## 4. 登录协议确认
### 4.1 展示位置
登录弹窗的短信登录和密码登录表单都在提交按钮上方展示协议确认行:
`我已阅读并同意《用户协议》《隐私政策》和《免责声明》`
其中三段蓝色链接分别打开对应法律内容面板。
### 4.2 勾选规则
- 使用本地存储 key `genarrative.auth.legal-consent.v1` 记录是否已经确认。
- 首次打开登录弹窗时,如果没有本地确认记录,勾选框默认为未选中。
- 后续打开登录弹窗时,如果本地已有确认记录,勾选框默认为选中。
- 用户未勾选时:
- 登录按钮禁用。
- 点击法律链接只打开内容面板,不自动勾选。
- 用户勾选后:
- 立即写入本地确认记录。
- 短信登录和密码登录都可继续使用。
## 5. 验收
- 已登录“我的”页签常用功能区为 3 列。
- “我的”页签底部出现 `法律信息`、三个入口和 `京ICP备2026025677号`
- 三个法律入口都能打开独立可滚动面板。
- 备案号点击打开 `https://beian.miit.gov.cn/`
- 首次登录弹窗协议勾选为空,登录按钮禁用。
- 勾选协议后登录按钮恢复可用,并持久化本地确认状态。
- 再次打开登录弹窗时协议勾选默认选中。
## 6. 2026-05-12 落地记录
- 法律文档解析与弹窗复用 `src/components/common/legalDocuments.ts``src/components/common/LegalDocumentModal.tsx`
- “我的”页签在 `src/components/rpg-entry/RpgEntryHomeView.tsx` 中接入 3 列常用功能、法律入口和备案链接。
- 登录协议确认在 `src/components/auth/LoginScreen.tsx` 中接入,短信登录和密码登录共用同一确认行。
- 定向验证:
- `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- `npx eslint src/components/auth/LoginScreen.tsx src/components/auth/AuthGate.test.tsx src/components/common/LegalDocumentModal.tsx src/components/common/legalDocuments.ts src/components/rpg-entry/RpgEntryHomeView.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx --max-warnings 0`

View File

@@ -13,6 +13,7 @@
- [AI 原生拼图玩法创作工具与玩法系统 PRD](./AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md):拼图玩法创作、结果页、发布、广场和运行时主链路。
- [AI 原生方洞挑战玩法创作工具与玩法系统 PRD](./AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md):方洞挑战创作、发布与试玩闭环。
- [后台管理独立前端工程 PRD](./ADMIN_WEB_CONSOLE_PRD_2026-04-30.md):后台管理端产品边界。
- [“我的”页签法律信息与登录协议确认 PRD](./PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md):定义个人页法律入口、备案链接、法律内容弹窗和首次登录协议勾选规则。
## 使用规则

View File

@@ -10,6 +10,7 @@
2. 当前设备识别方式与 `isCurrent` 语义
3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO
4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界
5. `2026-05-13` 会话组合并展示与远端踢下线闭环修复口径
## 2. 当前基线
@@ -46,11 +47,16 @@
3. 登录创建 session 时落库结构化客户端身份字段
4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel`
本阶段明确不包含
`2026-05-13` 起,本接口同时承担账号安全页的会话组读模型
1. `/api/auth/sessions/:sessionId/revoke`
2. 前端完整消费全部新增字段
3. SpacetimeDB reducer / view 正式读表
1. 后端按“同设备 + 同 IP”聚合活跃 `refresh_session`
2. 前端只消费后端聚合结果,不自行推断合并
3. `POST /api/auth/sessions/{sessionId}/revoke` 已纳入 Rust 实现,用于踢下线非当前会话
本阶段仍明确不包含:
1. SpacetimeDB reducer / view 正式读表
2. 登录方式、refresh token 轮换策略或账号安全页整体重设计
## 5. 请求与响应 contract
@@ -70,6 +76,8 @@
"sessions": [
{
"sessionId": "usess_xxx",
"sessionIds": ["usess_xxx", "usess_yyy"],
"sessionCount": 2,
"clientType": "web_browser",
"clientRuntime": "chrome",
"clientPlatform": "windows",
@@ -90,9 +98,12 @@
字段说明:
1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
2. `clientRuntime``clientPlatform``deviceDisplayName` 是多端识别首版最小新增字段
3. 小程序来源额外暴露 `miniProgramAppId``miniProgramEnv`
1. `sessionId` 是聚合组代表会话 ID若组内包含当前 `sid`,代表 ID 必须使用当前会话 ID
2. `sessionIds` 是该聚合组内全部活跃 session ID前端批量踢下线时逐个调用 revoke
3. `sessionCount` 是聚合组内 session 数量
4. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
5. `clientRuntime``clientPlatform``deviceDisplayName` 是多端识别首版最小新增字段
6. 小程序来源额外暴露 `miniProgramAppId``miniProgramEnv`
### 5.3 失败响应
@@ -110,12 +121,25 @@
1. 从 refresh cookie 读取当前原始 refresh token
2. 在 Axum 侧计算 `sha256(refresh_token)`
3. 与会话列表中的 `refresh_token_hash` 比较
4. 命中则 `isCurrent = true`
4. 同时读取 Bearer access token claims 中的 `sid`
5. 聚合组内任意 session 命中当前 refresh hash 或当前 `sid`,则整组 `isCurrent = true`
说明:
1. 如果请求没有携带 refresh cookie本接口仍可返回会话列表
2. 此时全部会话的 `isCurrent` 都为 `false`
2. 此时仍可通过 Bearer `sid` 标记当前组
3. 当前组不允许在前端显示“踢下线”,当前设备退出必须走 `/api/auth/logout`
## 6.1 会话组合并规则
同设备同 IP 的 active refresh sessions 在后端合并为一条 DTO
1. 优先使用 `device_fingerprint + ip` 作为聚合 key
2.`device_fingerprint` 时退化为 `client_type + client_runtime + client_platform + device_display_name + user_agent + ip`
3. `createdAt` 取组内最早 `created_at`
4. `lastSeenAt` 取组内最新 `last_seen_at`
5. `expiresAt` 取组内最新 `expires_at`
6. `ipMasked` 仍只返回脱敏 IP
## 7. 多端标识派生规则
@@ -161,8 +185,21 @@
负责:
1. 读取 Bearer JWT 与 refresh cookie
2. 把活跃会话映射成旧接口兼容 DTO
3. 派生 `ipMasked``isCurrent`
2. 按同设备同 IP 聚合活跃会话
3. 把活跃会话组映射成旧接口兼容 DTO
4. 派生 `ipMasked``isCurrent`
5. 暴露 `POST /api/auth/sessions/{sessionId}/revoke`
## 8.3 指定会话吊销接口
`POST /api/auth/sessions/{sessionId}/revoke` 固定规则:
1. Bearer JWT 必填
2. 仅允许吊销当前用户自己的非当前会话
3. 当前会话自吊销返回业务错误,提示使用退出登录
4. 只撤销目标 `refresh_session`,不递增 `token_version`
5. 撤销后同步 auth store 到 SpacetimeDB
6. 认证中间件会校验 access token `sid` 对应 active `refresh_session`,因此被踢设备已有 access token 会立即失效
## 9. 测试策略
@@ -172,6 +209,9 @@
2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser`
3. 显式小程序头优先于 `User-Agent` 判断
4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true`
5. 同设备同 IP 会话会合并,并返回 `sessionIds/sessionCount`
6. 合并组包含当前 `sid` 或当前 refresh hash 时,整组 `isCurrent = true`
7. 指定远端会话吊销后,被踢设备 access token 立即无法通过认证
## 10. 完成定义
@@ -181,4 +221,6 @@
2. 会话列表可区分普通浏览器、微信内 H5、小程序来源
3. 同设备不同浏览器可在会话列表中清晰区分
4. `clientLabel` 与新增多端字段都已稳定返回
5. 文档、任务清单与测试已同步更新
5. 同设备同 IP 的重复 active refresh sessions 已合并展示
6. 非当前会话可通过真实 revoke 接口踢下线
7. 文档、任务清单与测试已同步更新

View File

@@ -94,6 +94,7 @@ API Server 新增统一 helper
| `auth_phone_login_success` | `POST /api/auth/phone/login` |
| `auth_me_view` | `GET /api/auth/me` |
| `auth_sessions_view` | `GET /api/auth/sessions` |
| `auth_revoke_session` | `POST /api/auth/sessions/{session_id}/revoke` |
| `auth_refresh_success` | `POST /api/auth/refresh` |
| `auth_logout` | `POST /api/auth/logout` |
| `auth_logout_all` | `POST /api/auth/logout-all` |

View File

@@ -2,9 +2,9 @@
## 1. 范围
本方案用于改造 `生成抓大鹅草稿` 的首版生成链路:点击按钮后先进入独立生成过程页,生成结束后自动进入抓大鹅草稿页,并在草稿`3D素材` Tab 预览本次生成的 3D 模型
本方案用于改造 `生成抓大鹅草稿` 的首版生成链路:点击按钮后先进入独立生成过程页,生成结束后自动进入抓大鹅结果页,并在结果`素材配置 > 物品` 预览本次生成的 2D 多视角物品素材
本次只把任意难度都收敛为 `3` 件物品。后续难度曲线恢复时,再把物品数、网格数和手动 3D 任务数量从配置中放开
草稿生成不再调用 Hyper3D Rodin也不再生成 GLB 模型。物品素材继续沿用原来的“生成图片 -> 网格拆分 -> 上传 OSS -> 写回草稿”机制,但每个物品必须生成 `5` 个不同视角的 2D 视图。试玩和正式运行态的消除次数、总物品数和物品种类数以结果页 `难度配置` 保存的难度为准。难度对应物品种类固定为:轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21` 种。历史硬核草稿若仍保存 `clearCount = 20`,运行态按新硬核升为 `21` 次消除、`63` 件总物品。正式发布前如果已生成 `image_ready` 且具备至少 `5` 张有效 `imageViews[]` 的物品种类不足当前难度要求,必须阻断发布;试玩不阻断,但启动时把物品种类自动降到当前可用 2D 素材数量
## 2. 前端流程
@@ -20,34 +20,36 @@
生成页步骤固定为:
```text
生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 生成3D模型 -> 写入草稿页
生成游戏名称 -> 生成物品名称与背景音乐名称 -> 生成背景提示词 -> 分批生成1K素材图 -> 切割五视角图片 -> 上传图片资产 -> 生成背景音乐 -> 生成背景图 -> 写入草稿页
```
生成页只展示题材和物品数量,不展示玩法规则说明。
当前 `match3d-generating` 进度页不是后端 task 状态订阅页,而是一个覆盖 `match3d_compile_draft` 长 action 的本地时间进度页:前端每 500ms 以本地时间刷新阶段展示,真正的生成完成仍以 action 返回为准。为避免长 action 未返回时页面完全无感,生成页在 `match3d_compile_draft` 执行期间每 3 秒旁路读取一次 session 和 work detail并用 profile 中已写回的 `generatedItemAssets` 更新 `生成3D模型` 的完成数量。Hyper3D 控制台中看到 3 个 Rodin 任务已经 `Done` 后,页面仍可能继续停留在 `生成3D模型`,此时通常表示后端还在等待下载列表、下载 GLB、转存 OSS 或写回 `generated_item_assets_json``generatedItemAssets` 已出现 `model_ready`,前端应逐步显示完成数量。排查时应看 api-server 日志中的 `抓大鹅 Rodin 状态轮询返回``抓大鹅 Rodin 下载列表轮询返回``抓大鹅 Rodin GLB 下载完成``抓大鹅 Rodin GLB 转存 OSS 完成`
当前 `match3d-generating` 进度页不是后端 task 状态订阅页,而是一个覆盖 `match3d_compile_draft` 长 action 的本地时间进度页:前端每 500ms 以本地时间刷新阶段展示,真正的生成完成仍以 action 返回为准。为避免长 action 未返回时页面完全无感,生成页在 `match3d_compile_draft` 执行期间每 3 秒旁路读取一次 session 和 work detail并用 profile 中已写回的 `generatedItemAssets` 更新图片素材完成数量。`generatedItemAssets` 已出现 `image_ready` 且带 `imageViews`,前端应逐步显示完成数量。
## 3. 后端编排边界
外部模型和 OSS 上传全部由 `api-server` 编排,不进入 SpacetimeDB reducer。SpacetimeDB 继续只负责 Match3D 会话、草稿和作品 profile 的确定性写入。
外部生图、音频生成和 OSS 上传全部由 `api-server` 编排,不进入 SpacetimeDB reducer。SpacetimeDB 继续只负责 Match3D 会话、草稿和作品 profile 的确定性写入。
`match3d_compile_draft` action 的后端顺序为:
1. 读取 session config。
2. 将本次 MVP 的 `clearCount` 固定为 `3`,并同步用于草稿编译
3. 先调用 SpacetimeDB compile procedure 写入草稿。首次执行使用新 `profileId`;重试时复用 session draft / work profile 中已有 `profileId`。这一步不能等待 LLM、图片、OSS 或 Rodin 成功后才执行。
4. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。文本模型不可用时保留第 3 步的本地兜底,不阻断草稿。
5. 调用文本模型生成 `3` 个题材下的短物品名称
6. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine不重新接入 APIMart 图片网关。
7. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 物品使用 `2*2` 网格,取前 `3`
8. 将素材图和每张独立图片上传到 OSS其中独立图片作为草稿页素材预览和 Rodin 图生模型参考图;每次获得可恢复的图片资产后,都要回写 `match3d_work_profile.generated_item_assets_json`
9. 使用每张独立图片作为参考图,并行调用 Hyper3D Rodin 图生模型;所有 3D 模型任务必须在同一阶段同时提交、同时轮询状态、同时下载并转存 OSS禁止逐个物品串行等待模型完成。每个任务按官方 `check-status_reset_v` / `download-results_reset_v` 文档轮询状态和下载:状态查询使用 `subscription_key`,整体完成态以 `jobs[]` 聚合为准;下载查询使用生成响应顶层 `uuid` 作为 `task_uuid`,不能使用 `jobs.uuids` 子任务 uuid。只有 `jobs` 全部进入 `Done` 才能视为任务完成,任一 job `Failed` 则失败。完成后选择 `.glb` 下载文件,并把 GLB 转存到 OSS。Rodin 的 `subscriptionKey` 是上游 opaque token不做 256 字符这类短文本长度限制。Rodin 任务状态进入完成态后,下载列表仍可能延迟发布;后端必须对下载列表继续轮询,并兼容 `url``downloadUrl``fileUrl``signedUrl` 等下载字段别名,只有预览图而没有模型文件时不能伪装成 GLB 成功
10. Rodin 每批完成后继续回写 `generated_item_assets_json`。成功素材状态为 `model_ready`;失败素材保留图片引用并记录 `error`,下次 `match3d_compile_draft` 只继续缺失模型的素材,不重复生成已完成的 GLB
11. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材
2. 草稿编译先创建可恢复 profile素材生成数量由入口页难度派生的物品种类决定轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21`
3. 先调用 SpacetimeDB compile procedure 写入草稿。首次执行使用新 `profileId`;重试时复用 session draft / work profile 中已有 `profileId`。这一步不能等待 LLM、图片、音频或 OSS 成功后才执行。
4. 基于入口页题材设定文本调用文本模型生成作品生成计划。模型固定请求 `gpt-4o`,只返回 JSON其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。生成计划还必须包含 `backgroundMusic.title``backgroundMusic.style``backgroundMusic.prompt``backgroundPrompt`,以及 `items[]` 中每个物品的 `name``soundPrompt``backgroundMusic.title` 是背景音乐名称,`backgroundMusic.prompt` 固定为空字符串,用于后续 Suno 纯音乐生成;`backgroundPrompt` 用于生成局内竖屏背景图,必须描述绿色纵向背景与居中浅锅/圆盘状竞技区融合为一张完整背景图,且不包含 UI、文字、按钮、倒计时或物品。文本模型不可用时保留第 3 步的本地兜底,不阻断草稿。
5. 后端从同一份作品生成计划读取当前难度所需数量的短物品名称和音效提示词;不得再只生成物品名称而丢失后续音效生成上下文
6. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成 `1:1``1024x1024` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine不重新接入 APIMart 图片网关。
7. 每个物品固定需要 `5` 个不同视角。单张素材图最多切成 `5*5 = 25` 格;因此单张图最多承载 `5` 物品。若草稿物品数超过 `5`,后端按每批最多 `5` 个物品自动分批,多张素材图并行生成
8.每张素材图`n*n` 网格切割成独立图片,并按物品顺序连续分配 `5` 张视角图。每个物品 JSON 写入 `imageViews[]`,同时把第一个视角兼容写入 `imageSrc/imageObjectKey`
9. 将素材图和每张独立视角图片上传到 OSS。每次获得可恢复的图片资产后都要回写 `match3d_work_profile.generated_item_assets_json`。成功素材状态为 `image_ready`;失败素材保留已成功图片引用并记录 `error`。每个素材 JSON 同步保存 `soundPrompt`,首个素材 JSON 同步保存 `backgroundMusicTitle``backgroundMusicStyle``backgroundMusicPrompt` 保存为空字符串作为兼容字段
10. 后端在图片素材生成后使用 `backgroundMusic.title` 提交 Suno 背景音乐任务,`prompt` 为空,`tags` 来自 `backgroundMusic.style`,并固定走纯音乐生成。轮询完成后通过通用创作音频资产链路转存 OSS、确认 `asset_object`、绑定到 `match3d_work/background_music`,再写回首个素材的 `backgroundMusic`。音乐生成失败只记录 warning不阻断草稿页进入用户可在结果页 `素材配置 > 背景音乐` 重试
11. 若入口页 `generateClickSound=true`,后端在图片素材生成后继续为缺少 `clickSound` 的已生成物品并行提交 Vidu 点击音效任务,轮询完成后通过通用创作音频资产链路转存 OSS、确认 `asset_object`、绑定实体并写回对应素材的 `clickSound`;若开关关闭则只保存 `soundPrompt`,不调用音频生成
12. 背景图生成同样由 `api-server` 调用 VectorEngine `gpt-image-2-all`,尺寸固定为 `9:16`,并固定传入 `public/match3d-background-references/pot-fused-reference.png` 作为参考图。参考图只表达抓大鹅绿色页面背景和锅状圆形竞技区的融合构图,不包含 HUD、物品、文字或按钮。生成后的背景图上传到 `generated-match3d-assets/{sessionId}/{profileId}/background/{taskId}/background.png`,并作为 `backgroundAsset` 挂在首个 `generatedItemAssets[]` JSON 上HTTP DTO 同时顶层输出 `backgroundPrompt``backgroundImageSrc``backgroundImageObjectKey``generatedBackgroundAsset`
13. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息、背景音乐资产信息和背景资产信息;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材、音乐与背景。
若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。
草稿生成阶段调用 Hyper3D Rodin 并等待 GLB 下载完成;前端 `match3d_compile_draft` action 请求超时必须覆盖该长耗时链路,当前 Match3D client 使用 20 分钟超时。Rodin 单模型状态轮询预算为 10 分钟,下载列表发布轮询预算为 5 分钟GLB 下载和 OSS PutObject 各自设置 3 分钟 HTTP 超时,避免上游下载或转存连接长期悬挂。由于 3 个模型并行生成,总耗时按最慢模型计算,不能按模型数量线性叠加。结果页 `3D素材` Tab 直接加载已生成模型;用户点击 `重新生成` 时再复用 Rodin 安全代理,首版重新生成只更新当前页面内预览状态,后续正式资产绑定以独立技术方案为准
草稿生成阶段不再调用 Hyper3D Rodin,不生成 GLB也不等待任何模型轮询。前端 `match3d_compile_draft` action 的长耗时主要来自文本生成、分批 1K 生图、切图、OSS 上传、背景图和可选音频生成。批量新增物品由 `POST /api/creation/match3d/works/{profileId}/item-assets` 复用同一套 2D 素材图生成、5x5 切图、OSS 上传和可选点击音效链路,只补齐本次新增物品并把 `imageViews[]` 写回 `generatedItemAssets`
## 4. 图片提示词
@@ -55,27 +57,35 @@
```text
生成一张1:1图片
生成2*2网格素材图
生成不超过5*5网格素材图
整体画风遵循:...
只绘制这些物品:...
不要出现文字、水印、UI、边框
```
`包含若干个物品名称` 在落地中解释为“按生成出的物品名称绘制对应主体”,不要求图片上写出物品名称。这样可以避免文字渲染污染切图和后续手动 3D 模型参考
`包含若干个物品名称` 在落地中解释为“按生成出的物品名称绘制对应主体”,不要求图片上写出物品名称。这样可以避免文字渲染污染切图和局内 2D 素材表现
入口页内置风格参考图通过同一 VectorEngine `gpt-image-2-all` 能力生成,保存路径固定为:
入口页内置 2D 风格参考图通过同一 VectorEngine `gpt-image-2-all` 能力生成,执行命令为 `npm run assets:match3d-style-references -- --live`保存路径固定为:
```text
public/match3d-style-references/clay-toy.png
public/match3d-style-references/low-poly.png
public/match3d-style-references/toy-plastic.png
public/match3d-style-references/wood-carved.png
public/match3d-style-references/voxel-block.png
public/match3d-style-references/metal-mecha.png
public/match3d-style-references/flat-icon.png
public/match3d-style-references/cel-cartoon.png
public/match3d-style-references/pixel-retro.png
public/match3d-style-references/watercolor.png
public/match3d-style-references/sticker-outline.png
public/match3d-style-references/painterly-icon.png
```
这些图片只作为入口页风格选择的视觉参考,不进入用户草稿资产,不替代生成时的物品素材图。
局内背景生成固定参考图路径为:
```text
public/match3d-background-references/pot-fused-reference.png
```
这张图作为 VectorEngine `image` 参考输入使用,用来锁定“绿色竖屏背景 + 居中锅状竞技区”的构图。每次草稿生成仍会根据 `backgroundPrompt` 生成新的题材化背景图;参考图本身不作为运行态最终背景。
## 5. OSS 路径
新增 generated legacy prefix
@@ -88,47 +98,56 @@ generated-match3d-assets
```text
generated-match3d-assets/{sessionId}/{profileId}/material-sheet/{taskId}/sheet.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image/image.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/model/{taskUuid}/model.glb
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/views/view-01.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/views/view-02.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/views/view-03.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/views/view-04.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/views/view-05.png
generated-match3d-assets/{sessionId}/{profileId}/background/{taskId}/background.png
```
`itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key导致草稿页预览图全部一致。
HTTP DTO 同时返回 `imageSrc``imageObjectKey``modelSrc``modelObjectKey``modelFileName``taskUuid``subscriptionKey``status`模型生成成功后 `status = model_ready`若后续允许部分模型失败降级,失败素材必须带 `error`,且不能伪装成可预览模型。前端模型预览必须通过 `/api/assets/read-bytes` 读取私有 GLB 字节并转成 Blob URL 后交给 Three.js不直接请求裸 `/generated-match3d-assets/...` 路径
HTTP DTO 同时返回兼容字段 `imageSrc``imageObjectKey`,以及正式 2D 字段 `imageViews[]``backgroundAsset``status`图片素材生成成功后 `status = image_ready`背景生成成功后首个素材的 `backgroundAsset.status = image_ready`。前端通过 `/api/assets/read-url` 将 generated legacy path 换签后加载私有图片,不直接请求裸 `/generated-match3d-assets/...` 路径。运行态背景图同样通过 `/api/assets/read-url` 换签后作为全屏 `object-cover` 背景加载
## 5.1 运行态模型消费
## 5.1 运行态 2D 素材消费
生成模型不仅用于结果页预览,也必须进入游戏运行态。运行态入口的传递链路为:
生成的 2D 五视角素材不仅用于结果页预览,也必须进入游戏运行态。运行态入口的传递链路为:
```text
Match3DWorkProfile / PlatformMatch3DGalleryCard
-> Match3DRuntimeShell(generatedItemAssets)
-> Match3DRuntimeShell(generatedItemAssets, backgroundImageSrc)
-> Match3DPhysicsBoard / Match3DTrayPreviewBoard
```
`Match3DPhysicsBoard``Match3DTrayPreviewBoard` 按运行快照中的 `itemTypeId` 稳定排序后,把生成出的模型顺序映射到对应类型。当前 MVP 固定 `clearCount = 3`,因此 `match3d-type-01/02/03` 分别对应生成列表的第 `1/2/3` 个模型;后续恢复更多物品生成时,后端必须继续保证 `generatedItemAssets` 顺序与类型编号一致
运行态按运行快照中的 `itemTypeId` 稳定排序后,把 `generatedItemAssets` 顺序映射到对应类型。加载某个物品实例时,从该类型素材的 `imageViews[]` 中按实例 id 稳定随机选择一个视角;若历史数据没有 `imageViews[]`,则回退到 `imageSrc/imageObjectKey`。没有生成图片或图片加载失败时,继续使用默认积木图标兜底
运行态背景优先读取 `backgroundImageSrc` / `generatedBackgroundAsset.imageSrc`,为空时从 `generatedItemAssets[].backgroundAsset.imageSrc/imageObjectKey` 兜底。`Match3DRuntimeShell` 只保留顶部返回、倒计时、重开三个控件;进度、组数、版本等状态信息不得再作为顶部常驻 UI 出现,避免遮挡生成背景和锅状竞技区。
前端加载规则:
1. 优先读取 `modelSrc`为空时使用 `modelObjectKey`
2. 通过 `readAssetBytes` 调用 `/api/assets/read-bytes`,由同源后端读取 OSS 私有对象字节
3. 使用 Three.js `GLTFLoader.parseAsync` 解析 GLB 字节,并按物品类型缓存模板
4. 场内每个物品和备选栏预览都从模板 clone 独立对象,点击命中继续写入 `itemInstanceId`
5. 物理碰撞和边界仍沿用现有 `visualKey` 的程序化几何,生成 GLB 只替换视觉模型,不承接规则真相
6. 模型缺失、读取失败或 WebGL 回退时,继续使用默认积木素材,不能阻断开局、点击、入槽或结算;调试模式下需要输出加载失败的 `itemTypeId`、模型来源和错误信息便于区分“资产没有传入”和“GLB 字节读取或解析失败”。
1. 优先读取 `imageViews[]` 中的 `imageSrc/imageObjectKey`为空时使用兼容字段 `imageSrc/imageObjectKey`
2. 对 generated legacy path 通过同源 `/api/assets/read-url` 换签后交给浏览器图片加载
3. 场内物品、点击命中和备选栏继续使用后端快照中的 `itemInstanceId/itemTypeId/x/y/radius/layer`;生成 2D 图片只替换视觉表现,不承接规则真相
4. 同一物品类型的多个实例可以展示不同视角,但同一实例在本局中应稳定使用同一个视角,避免移动或入槽时闪图
5. 图片缺失、读取失败或解码失败时,继续使用默认积木素材,不能阻断开局、点击、入槽或结算
结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到运行态 profile并在启动试玩前调用生成素材保存接口把当前可见的 `generatedItemAssets` 写回作品 profile不能只在内存里把素材补到 `onStartTestRun(profile)`。发布同理必须先落库当前素材,再调用 `publish_match3d_work`,否则公开推荐流和正式运行态只能读到旧 profile 快照,历史草稿尤其容易表现为结果页有 3D 模型、正式游戏仍是默认积木。若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `modelSrc` / `modelObjectKey` 补齐 draft不能让旧 draft 把模型状态覆盖回 `image_ready``PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成模型写入 `match3dProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 3D 模型覆盖成空列表。
结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到运行态 profile并在启动试玩前调用生成素材保存接口把当前可见的 `generatedItemAssets` 写回作品 profile不能只在内存里把素材补到 `onStartTestRun(profile)`。发布同理必须先落库当前素材,再调用 `publish_match3d_work`,否则公开推荐流和正式运行态只能读到旧 profile 快照。若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `imageViews[]``imageSrc` `imageObjectKey` 补齐 draft不能让旧 draft 把素材覆盖成空列表`PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成图片素材写入 `match3dProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 2D 素材覆盖成空列表。
历史草稿若仍保存 `status = model_ready``modelSrc``modelObjectKey`,仅作为旧版本兼容读取,不再参与新素材生产。历史外部模型链接转存接口只用于清理旧数据,不能被新草稿生成、批量新增或结果页普通编辑入口调用。
生成完成后自动进入试玩依赖 `selectionStageRef.current === 'match3d-generating'` 的同步判断。执行 `match3d_compile_draft` 前切到生成页时,必须同时写 `selectionStageRef.current = 'match3d-generating'``setSelectionStage('match3d-generating')`;只调用 React state 会让 action 很快返回时读到旧 stage表现为生成页已经 100% 但不进入试玩或结果页。拼图、大鱼吃小鱼、方洞挑战等同类生成页也遵循同一规则。
## 6. 自动保存与草稿恢复
点击 `生成抓大鹅草稿` 后,草稿存档创建与素材生成解耦:
1. 首次 compile 必须先写 `match3d_work_profile` 草稿行即使后续卡在文本模型、图片生成、OSS 上传、Rodin 生成或下载转存任意阶段。
1. 首次 compile 必须先写 `match3d_work_profile` 草稿行,即使后续卡在文本模型、图片生成、音频生成或 OSS 上传任意阶段。
2. 失败态前端要重新读取 session / work detail并刷新草稿作品架保证用户离开生成页后仍能在草稿 Tab 找到这份作品。
3. 重新生成时优先使用当前 session 的 `draft.profileId``publishedProfileId`,不得重新创建 session后端读取同一 profile 的 `generated_item_assets_json` 后,只补齐缺失图片或缺失模型的阶段。
4. 已有 `status = model_ready` 且带 `modelSrc` / `modelObjectKey` 的素材视为完成,不再重复调用 Rodin
3. 重新生成时优先使用当前 session 的 `draft.profileId``publishedProfileId`,不得重新创建 session后端读取同一 profile 的 `generated_item_assets_json` 后,只补齐缺失图片或缺失音频的阶段。
4. 已有 `status = image_ready` 且带 `imageViews[]` `imageSrc/imageObjectKey` 的素材视为完成,不再重复生成图片
抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。结果页 `3D素材` Tab 手动点击 `重新生成` 并拿到 GLB 下载文件后,必须把当前素材草稿重新序列化成 `generatedItemAssets` 并写回作品 profile否则页面内预览会显示新模型,但试玩、发布和重进草稿会读取旧的空模型快照。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。
抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。结果页 `素材配置 > 物品` 只在独立面板中预览和编辑当前素材,不再提供单项重新生成入口;删除单项或批量新增成功后,必须把当前素材列表重新序列化成 `generatedItemAssets` 并写回作品 profile否则试玩、发布和重进草稿会读取旧素材快照。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。
草稿架重进路径为:
@@ -136,22 +155,56 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
草稿 Tab -> getMatch3DWorkDetail(profileId) -> Match3DResultView(profile.generatedItemAssets)
```
因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets`。前端 `Match3DResultView` 的读取顺序为:有 `draft.generatedItemAssets` 时先用 draft 保留本次生成顺序和图片;同 `itemId``profile.generatedItemAssets` 中已有模型字段时,用 profile 模型字段补齐 draft从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。
因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets` 与顶层背景字段。前端 `Match3DResultView` 的读取顺序为:有 `draft.generatedItemAssets` 时先用 draft 保留本次生成顺序和图片;同 `itemId``profile.generatedItemAssets` 中已有 `imageViews[]``imageSrc/imageObjectKey` 时,用 profile 图片字段补齐 draft背景资产同样必须从 profile 或 draft 的首个 `backgroundAsset` 保留到保存 payload从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认素材占位。
结果页 `作品信息` Tab 字段命名对齐拼图草稿:
1. `作品名称` 对应 Match3D `gameName`
2. `作品描述` 对应 Match3D `summary`,草稿生成默认空。
3. `作品标签` 对应 Match3D `tags`,可由 AI 首次生成并允许用户继续编辑。
4. 封面图与作品名称不再拆成左右两个大模块;封面只作为同一 Tab 内的可选上传入口,避免和作品基础信息割裂。
4. 封面图与作品名称不再拆成左右两个大模块;封面只作为同一 Tab 内的可选入口,避免和作品基础信息割裂。点击封面图必须弹出独立编辑面板,不允许在当前作品信息面板下方展开。封面面板布局参考拼图创作页上传卡:移动端优先、左侧/上方为方形预览,右侧/下方为提示词与操作区。面板支持三类输入:本地上传图片、上传后开启 AI 重绘、直接引用 `物品素材``UI素材` 中已有图片作为封面或 AI 重绘参考图。AI 重绘通过 `api-server` 的 Match3D 作品封面生成接口调用 VectorEngine `gpt-image-2-all`,生成结果转存到 `generated-match3d-assets/{sessionId}/{profileId}/cover/{taskId}/cover.png` 后再写回 `coverImageSrc`;关闭 AI 重绘时只把选中的 Data URL 或 generated legacy path 写入封面字段。
`3D素材` 详情页只保留
结果页 `难度配置` Tab 取代旧 `玩法配置`,不再展示旧的分散输入项。该 Tab 必须与创作入口页使用同一组难度选项,并统一把原“类型素材图片 / 局内类型”等口径归一为 `物品种类`
1. 模型预览区:优先加载 `modelSrc` 对应 GLB缺失时加载 `modelObjectKey`,支持拖动旋转;没有模型时展示空预览。
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
| ---- | ---------: | ---------: | -------: | -------: |
| 轻松 | 8 | 2 | 24 | 3 |
| 标准 | 12 | 4 | 36 | 9 |
| 进阶 | 16 | 6 | 48 | 15 |
| 硬核 | 21 | 8 | 63 | 21 |
预览区展示 `需要消除``总物品数``物品种类``已生成物品种类`。历史草稿如果保存的是旧 `clearCount/difficulty`,前端按 `clearCount` 精确命中优先、否则按 `difficulty` 就近归一到上述选项,并把归一后的数值保存回 profile。发布校验以 `generatedItemAssets[]``image_ready` 且至少有 `5` 张有效 `imageViews[]` 的素材数量为准;试玩启动时用同一数量计算 `itemTypeCountOverride`,不足时自动降低,不修改草稿难度配置本身。历史单图 `imageSrc/imageObjectKey` 只作为运行态和预览兜底,不计入新发布素材完成数。
结果页 `素材配置` Tab 取代旧一级素材入口,并包含三个子 Tab
1. `物品`:显示 2D 物品素材列表、五视角预览、素材名称、点击音效提示词和点击音效生成入口。
2. `UI`:预览生成的竖屏游戏背景图,读取顺序为 draft 顶层背景、draft `generatedBackgroundAsset`、profile 顶层背景、profile `generatedBackgroundAsset``generatedItemAssets[].backgroundAsset`、本地参考图兜底。该页必须展示默认画面描述提示词,默认值来自草稿生成计划的 `backgroundPrompt` 或持久化 `backgroundAsset.prompt`;用户修改后点击重新生成,后端继续固定使用 `public/match3d-background-references/pot-fused-reference.png` 作为 VectorEngine `image` 参考图,并把新的 `backgroundAsset` 写回同一份 `generated_item_assets_json`。UI 子 Tab 还必须提供独立的运行态 UI 预览面板,直接用当前背景图模拟抓大鹅竖屏页面的顶部返回、倒计时、重开控件、锅状竞技区和底部托盘,不在 Tab 下方内联展开。
3. `背景音乐`:承载原一级音乐 Tab 的背景音乐曲名、风格、生成进度和试听控件;背景音乐始终按纯音乐生成,前端不提供提示词输入。
旧一级 `音乐` Tab 删除;抓大鹅背景音乐入口只保留在 `素材配置 > 背景音乐`
`素材配置 > 物品` 详情页只保留:
1. 五视角预览区:优先展示 `imageViews[]`,缺失时展示兼容字段 `imageSrc/imageObjectKey`
2. 素材名称输入。
3. `重新生成` 按钮
3. 可编辑的点击音效提示词输入
4. 点击音效生成入口。
详情页不再展示参考图、用途、提示词、文生/图生切换、状态查询、下载列表、taskUuid 或 subscriptionKey。
详情页不再展示参考图、用途、模型提示词、文生/图生切换、状态查询、下载列表、taskUuid 或 subscriptionKey。
`物品素材` 列表项点击必须弹出独立预览面板,不允许在列表右侧或列表下方内联展示。预览面板只承担查看五视角图片、编辑素材名称、编辑点击音效提示词和生成点击音效;不再展示 `重新生成` 按钮。列表项自身支持单项删除,删除后立即把剩余 `generatedItemAssets` 写回作品 profile。批量新增通过列表顶部按钮打开独立面板面板内每个输入框只输入一个物品名称`新增物品名称` 按钮追加一个输入框;提交后按输入框顺序清洗、去重并调用 Match3D 作品批量生图接口。生成进度同时显示在批量新增面板和 `素材配置 > 物品` 列表顶部面板可关闭后台生成继续推进不阻塞封面、音频等其他生成操作。后端复用草稿生成的素材图、切图、OSS 上传和可选点击音效流程,但仅作用于本次新增名称,不重新生成已有物品,不新增 SpacetimeDB 表,最终仍写回同一份 `generated_item_assets_json`
## 6.1 音频生成与扣费
抓大鹅结果页音频生成复用通用创作音频路由:
1. `素材配置 > 背景音乐` 默认读取首个 `generatedItemAssets[0].backgroundMusicTitle/backgroundMusicStyle`,用户可继续编辑曲名和风格;`backgroundMusicPrompt` 保留为空字符串兼容旧 JSON生成请求固定传空 `prompt`
2. 物品点击音效默认读取对应 `generatedItemAssets[].soundPrompt`,用户可在 `素材配置 > 物品` 详情面板内编辑。
3. 背景音乐与物品音效生成过程必须显示进度条;提交任务、等待生成、转存资产和完成分别推进到不同进度,不再只展示旋转图标。
4. 音频生成完成后立即展示浏览器原生 audio 控件,支持试听。
5. `POST /api/creation/audio/background-music/{task_id}/asset``POST /api/creation/audio/sound-effect/{task_id}/asset` 在真正拿到音频并转存资产前,由后端按 `taskId + 资产槽位` 幂等预扣 `10` 光点任务仍在处理中时不扣费。资产下载、OSS 转存或资产绑定失败时后端自动退款。前端只展示生成按钮和进度,不自行计算或写入钱包。
入口页 `生成音效` Toggle 复用同一扣费与资产绑定规则。默认关闭,关闭时草稿生成阶段不产生音频任务也不扣除音频光点;开启时每个首批物品的点击音效按单独任务和单独 `match3d_click_sound` 资产槽位扣费。音效生成失败不阻断草稿结果页进入,失败素材保留 `soundPrompt`,用户可在结果页物品详情面板手动重试。
## 7. 验收
@@ -173,4 +226,4 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml
```
真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY``HYPER3D_API_KEY` 和 OSS 访问变量。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`
真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY` 和 OSS 访问变量;开启音频生成还需要对应音频上游配置。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`

View File

@@ -4,7 +4,7 @@
>
> 2026-05-10 更新:抓大鹅入口页对齐拼图入口页,直接嵌入创作页模板 Tab。入口表单不再展示参考图、消除次数输入、难度数值滑杆和题材/物品/难度摘要框,仅保留题材主题大输入框和难度选项。难度选项负责派生 `clearCount` 与 `difficulty`,生成按钮必须展示 `消耗20光点`。
>
> 2026-05-10 补充:入口页新增 `3D素材风格` 横向滑动选择,首批风格参考图通过 VectorEngine `gpt-image-2-all` 生成并保存到 `public/match3d-style-references/`。最后一个选项为 `自定义`,点击后弹出独立面板填写画风描述。
> 2026-05-12 补充:入口页风格选择收敛为 `2D素材风格`,首批常见 2D 素材风格参考图通过 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成并保存到 `public/match3d-style-references/`。最后一个选项为 `自定义`,点击后弹出独立面板填写画风描述。
## 1. 阶段边界
@@ -40,11 +40,12 @@ badge: 可创建
创作页 `选择模板` Tab 中切换到 `抓大鹅` 时,直接渲染该表单,不创建会话,也不跳到独立工作台。点击生成后才创建 Match3D 会话并执行 `match3d_compile_draft`
表单只展示个输入块:
表单只展示个输入块:
1. `想做一个什么题材的抓大鹅?`:大文本输入框,收集 `themeText`
2. `3D素材风格`:横向滑动风格卡,选择会写入 `assetStyleId``assetStyleLabel``assetStylePrompt`
2. `2D素材风格`:横向滑动风格卡,选择会写入 `assetStyleId``assetStyleLabel``assetStylePrompt`
3. `难度`:四个选项按钮,选项内部派生消除次数和难度数值。
4. `生成音效`Toggle默认关闭开启后提交 `generateClickSound=true`
当前难度映射固定为:
@@ -52,7 +53,7 @@ badge: 可创建
轻松 -> clearCount 8, difficulty 2
标准 -> clearCount 12, difficulty 4
进阶 -> clearCount 16, difficulty 6
硬核 -> clearCount 20, difficulty 8
硬核 -> clearCount 21, difficulty 8
```
入口页不再上传参考图,提交 payload 中 `referenceImageSrc` 固定为 `null`。如果从旧会话或旧草稿恢复,前端只根据已有 `difficulty` 选择最接近的难度选项,并按当前选项重新派生 `clearCount``difficulty`
@@ -60,7 +61,7 @@ badge: 可创建
内置风格选项为:
```text
黏土手作 / 低多边形 / 玩具塑料 / 木质雕刻 / 体素积木 / 金属机甲 / 自定义
扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义
```
自定义风格必须在弹出面板中填写描述后才能应用。入口表单必须在移动端创作页可视区内完成题材、风格、难度和生成按钮的展示,页面自身不产生纵向滚动;风格卡只允许横向滑动。

View File

@@ -275,6 +275,12 @@ type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
F2 不实现运行态本身;只冻结结果页如何发起试玩。
### 12.1 生成完成自动试玩补充2026-05-12
抓大鹅表单生成草稿完成后,如果用户仍停留在 `match3d-generating` 进度页,前端应立即用刚生成的 `Match3DWorkProfile` 启动试玩,并把运行态返回目标设置为 `match3d-result`。试玩过程中点击左上角返回时,进入同一份抓大鹅草稿结果页查看与编辑。
该自动试玩只响应当前等待中的生成页;如果用户已经返回草稿 Tab 或切到其它页面,后台生成完成只更新草稿可见状态,不主动切屏。自动启动失败时,仍保留结果页草稿作为兜底入口。
---
## 13. 发布接口

View File

@@ -9,30 +9,30 @@
1. `光点充值`
2. `会员卡充值`
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。普通 H5 / 本地联调继续使用 `mock` 渠道:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。微信小程序 web-view 使用 `wechat_mp` 渠道:创建订单时只写入 `pending` 订单并返回小程序 `wx.requestPayment` 参数,真实到账以后端微信支付通知为准
## 2. 产品规则
### 2.1 光点充值套餐
| productId | 光点 | 金额分 | 徽标 | 说明 |
| --- | ---: | ---: | --- | --- |
| `points_60` | 60 | 600 | 首充双倍 | 首充送60光点 |
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180光点 |
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300光点 |
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680光点 |
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280光点 |
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280光点 |
| productId | 光点 | 金额分 | 徽标 | 说明 |
| ------------- | ---: | -----: | -------- | -------------- |
| `points_60` | 60 | 600 | 首充双倍 | 首充送60光点 |
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180光点 |
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300光点 |
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680光点 |
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280光点 |
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280光点 |
光点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账光点为基础光点与等额赠送光点之和;已有充值流水后只到账基础光点。实际到账光点写入交易流水,余额以 SpacetimeDB projection 为准。
### 2.2 会员卡套餐
| productId | 类型 | 天数 | 金额分 | 权益 |
| --- | --- | ---: | ---: | --- |
| `member_month` | 月卡 | 30 | 2800 | 免光点回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免光点回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免光点回合数100每日签到加成210% |
| productId | 类型 | 天数 | 金额分 | 权益 |
| --------------- | ---- | ---: | -----: | --------------------------------- |
| `member_month` | 月卡 | 30 | 2800 | 免光点回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免光点回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免光点回合数100每日签到加成210% |
购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。
@@ -63,19 +63,58 @@
行为:
1. 校验 `productId`
2. 后端创建已支付订单
3. 光点套餐写入钱包余额与流水
4. 会员套餐写入会员状态
5. 返回最新账户中心快照与订单摘要
2. `paymentChannel = "mock"`后端创建已支付订单
3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数
4. mock 光点套餐立即写入钱包余额与流水mock 会员套餐立即写入会员状态
5. wechat_mp 订单不提前发光点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams`
兼容路径:`POST /api/runtime/profile/recharge/orders`
响应里的 `wechatMiniProgramPayParams` 只在微信小程序支付渠道返回,字段直接对应 `wx.requestPayment`
```json
{
"wechatMiniProgramPayParams": {
"timeStamp": "1777110165",
"nonceStr": "nonce",
"package": "prepay_id=wx201410272009395522657a690389285100",
"signType": "RSA",
"paySign": "..."
}
}
```
### 3.3 `POST /api/profile/recharge/wechat/notify`
微信支付通知地址,无需 Bearer JWT。行为
1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签。
2. 使用 `WECHAT_PAY_API_V3_KEY` 解密通知 `resource`
3. 仅当 `trade_state = "SUCCESS"` 时确认订单支付。
4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`
5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。
6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。
关键环境变量:
| 变量 | 说明 |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `WECHAT_PAY_ENABLED` | 是否启用微信支付客户端 |
| `WECHAT_PAY_PROVIDER` | `mock``real` |
| `WECHAT_PAY_MCH_ID` | 微信支付商户号 |
| `WECHAT_PAY_MERCHANT_SERIAL_NO` | 商户 API 证书序列号,用于请求微信支付签名头 |
| `WECHAT_PAY_PRIVATE_KEY_PEM` / `WECHAT_PAY_PRIVATE_KEY_PATH` | 商户 API 私钥 |
| `WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM` / `WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH` | 微信支付平台公钥或平台证书公钥,用于回调验签 |
| `WECHAT_PAY_PLATFORM_SERIAL_NO` | 微信支付通知头里的平台证书/公钥序列号 |
| `WECHAT_PAY_API_V3_KEY` | 32 字节 API v3 密钥,用于解密通知资源 |
| `WECHAT_PAY_NOTIFY_URL` | 公网 HTTPS 通知地址,通常为 `/api/profile/recharge/wechat/notify` |
## 4. 前端交互
1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
3. 默认打开 `光点充值`,可切换到 `会员卡充值`
4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`
4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`
5. 弹窗内不写大段说明文案,只保留必要金额、光点、会员权益和状态反馈。
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。

View File

@@ -32,7 +32,8 @@
2. 请求字段:`currentPassword``newPassword`
3. 若账号未设置过密码,允许 `currentPassword` 为空并设置首个密码。
4. 若账号已有密码,必须校验 `currentPassword` 后才能写入 `newPassword`
5. 修改成功后递增用户 `token_version`,使旧 access token 失效;前端沿用当前 refresh 会话刷新登录态
5. 修改成功后递增用户 `token_version`,使旧 access token 失效。
6. `2026-05-13` 起,修改密码成功后必须撤销该用户全部 active `refresh_session`,并在响应中清除当前 refresh cookie前端清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。
### 2.3 重置密码
@@ -79,7 +80,7 @@
## 5. 2026-05-12 快照同步修复
重置密码和修改密码都会改变认证真相:`password_hash``password_login_enabled``token_version`,重置密码还会立即创建新的 refresh session。因此 API 层在 `POST /api/auth/password/change``POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`
重置密码和修改密码都会改变认证真相:`password_hash``password_login_enabled``token_version`,重置密码还会立即创建新的 refresh session修改密码还会撤销全部旧 refresh session。因此 API 层在 `POST /api/auth/password/change``POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`
若只更新本地 `InMemoryAuthStore` 而不同步 SpacetimeDB 认证快照,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态,表现为用户已通过忘记密码重设成功,但再次密码登录仍返回“手机号或密码错误”。启动恢复时应从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照;当本地文件更新且远端表无更新时间戳时,优先使用本地文件并尝试回写 SpacetimeDB避免旧远端状态覆盖刚重设的密码。

View File

@@ -0,0 +1,21 @@
# 平台移动端推荐页卡片与滑动热区布局 2026-05-12
## 背景
移动端推荐页承载嵌入式作品运行态,顶部品牌栏和底部导航上方留白会压缩首屏可玩区域。推荐页同时需要纵向切换作品,但不能让切换手势覆盖作品运行态内部的点击、拖拽、滑动热区。
## 落地口径
1. 仅推荐页隐藏移动端顶部品牌栏,发现、创作、草稿、我的页继续保留原顶部结构。
2. 推荐页外层使用独立 shell class把原顶部区域和底部导航上方额外留白让给推荐卡片。
3. 推荐页运行态画面保持独立可交互区域,不挂平台切换作品的 pointer 手势。
4. 切换作品的纵向手势只绑定在卡片底部作品信息区;底部信息区可以扩大触控高度,但不得绝对定位覆盖运行态画面。
5. 点赞、分享、改造按钮继续阻止 pointer 事件冒泡,避免按钮点击误触发切换作品。
## 验收
1. 手机竖屏进入推荐页时,首屏不显示顶部品牌标题。
2. 推荐卡片上沿贴近可视区域顶部,下沿贴近底部导航上方。
3. 在作品运行态画面内点击、拖拽或滑动,只触发作品自身交互。
4. 在底部作品信息区上下滑动,可以切换推荐作品。
5. 点赞、分享、改造按钮可正常点击,不触发作品切换。

View File

@@ -132,8 +132,8 @@ SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露
Nginx 配置文件分为两类:
- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem``privkey.pem` 已存在。
- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`
- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem``privkey.pem` 已存在。`SERVER_NAME` 只填证书主目录名对应的单个域名;`www` 等额外域名通过 `SERVER_ALIASES` 写入 Nginx `server_name`,不参与证书目录拼接。
- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名;如有多个入口,额外域名或 IP 填 `SERVER_ALIASES`。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`
## 维护模式
@@ -272,13 +272,14 @@ journalctl -u 'jenkins-agent@*.service' -f
Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置:
- Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行SCM URL 使用 controller 可访问的公网地址`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`
- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`
- Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行SCM URL 使用 controller 可访问的公网域名`https://git.genarrative.world/GenarrativeAI/Genarrative.git`
- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 优先使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`
-`127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git` 重新 checkout。该首次 checkout 只拉 `SOURCE_BRANCH` 单分支、`depth=1` 且不拉 tags避免 release agent 通过公网备用地址拉取全仓库历史时被 Jenkins Git checkout timeout 杀掉;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、域名备用地址顺序重试,并在日志中输出最终使用的远端。
- 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。
因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], ...])`后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为 `GIT_REMOTE_URL`,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。
因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/<SOURCE_BRANCH>:refs/remotes/origin/<SOURCE_BRANCH>"]], ...])`。首次 checkout 先尝试 `GIT_REMOTE_URL`,失败后尝试 `GIT_REMOTE_FALLBACK_URL`,两次都必须保持单分支浅克隆和 `noTags=true`后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为实际可用远端,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。
`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,必须把对应 Jenkinsfile 的 `GIT_REMOTE_URL` 改成 release agent 可访问的内网地址,不能让 release 发布阶段回退到 controller 公网拉取
`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,`GIT_REMOTE_FALLBACK_URL` 统一使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不要再配置内网 IP 备用地址
### SSH PEM 凭证
@@ -323,7 +324,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆
构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build``release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。
发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。Web 大包例外:`Genarrative-Web-Build` 只把轻量元数据归档到 Jenkins controller`web.tar.gz` 保留在 Linux 构建机稳定目录 `/var/cache/genarrative-build/web-artifacts/``Genarrative-Web-Deploy` 在部署目标机器上按构建 Job、构建号和版本号读取该目录。development 目标天然共享当前 Linux 开发/构建/开发部署机release 目标若不是同一台机器,必须先把该目录通过共享存储、rsync 或其它内网同步方式提供给 release 部署 agent。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` stepJenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。
发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。Web 大包例外:`Genarrative-Web-Build` 只把轻量元数据归档到 Jenkins controller`web.tar.gz` 保留在 Linux 构建机稳定目录 `/var/cache/genarrative-build/web-artifacts/``Genarrative-Web-Deploy` 在部署目标机器上按构建 Job、构建号和版本号读取该目录。development 目标天然共享当前 Linux 开发/构建/开发部署机release 目标若不是同一台机器,发布流水线默认在本地缓存缺少 `web.tar.gz` 时通过 `rsync` 从 SSH Host `genarrative-build-internal` 拉取同一路径内容。该 Host 必须配置在 release 服务器上 Jenkins 运行用户的 SSH config 中,真实内网 IP、用户和私钥路径只保存在服务器本机如需改名或指定 config可通过 `WEB_ARTIFACT_SYNC_HOST` / `WEB_ARTIFACT_SYNC_SSH_CONFIG` 参数覆盖。也可以提前通过共享存储或其它内网同步方式提供该目录。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` stepJenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。
邮件通知的持久收件人不写入 Git由 Jenkins `Secret text` 凭据 `genarrative-notification-emails` 保存,凭据内容为逗号分隔邮箱。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把持久收件人凭据与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。
@@ -424,8 +425,8 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
执行规则:
- 流水线先按 Jenkins SCM 配置 checkout 仓库,再执行 `git fetch --tags --prune origin "+refs/heads/<SOURCE_BRANCH>:refs/remotes/origin/<SOURCE_BRANCH>"`
- 如果工作区是浅克隆,流水线必须尝试 `git fetch --unshallow --tags`,确保能验证目标 commit 与分支关系
- 流水线先按 Jenkins SCM 配置 checkout 仓库,再执行单分支 `git fetch --no-tags --prune origin "+refs/heads/<SOURCE_BRANCH>:refs/remotes/origin/<SOURCE_BRANCH>"``COMMIT_HASH` 为空时追加 `--depth=1`
- 如果工作区是浅克隆,只有在 `COMMIT_HASH` 非空、需要验证指定提交属于目标分支时,流水线尝试 `git fetch --unshallow --no-tags``COMMIT_HASH` 为空时只需要目标分支 HEAD必须保持 `--depth=1 --no-tags`,避免普通发布或服务器配置任务拉取全仓库历史
- `COMMIT_HASH` 为空时detached checkout 到 `refs/remotes/origin/<SOURCE_BRANCH>` 当前最新 commit。
- `COMMIT_HASH` 非空时,先解析到完整 commit再用 `git merge-base --is-ancestor <commit> refs/remotes/origin/<SOURCE_BRANCH>` 校验该提交属于指定分支,校验通过后 detached checkout。
- 流水线日志必须输出最终 `SOURCE_BRANCH` 与实际 `SOURCE_COMMIT`
@@ -462,7 +463,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
该流水线属于高风险操作,默认要求人工确认后执行。
已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`只打印将执行的初始化动作真正写入系统用户、目录、systemd、环境文件并启动服务时必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。
首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem``/etc/letsencrypt/live/<SERVER_NAME>/privkey.pem` 后,再把 `SERVER_NAME` 改成真实域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。
首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem``/etc/letsencrypt/live/<SERVER_NAME>/privkey.pem` 后,再把 `SERVER_NAME` 改成证书主域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。如果同一张证书同时覆盖根域名和 `www` 域名,`SERVER_NAME` 仍只填证书目录名,例如 `genarrative.world``SERVER_ALIASES``www.genarrative.world`流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。
若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem``privkey.pem`。新版初始化在 `NGINX_CONFIG_MODE=none` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`
@@ -482,6 +483,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 部署脚本源码,默认使用 `origin/master` 最新 commit上游构建触发时使用上游传入的实际构建 commit。
- 通过 Jenkins 归档获取 `web.tar.gz.sha256``release-manifest.json``web-artifact-pointer.txt`,再从 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/` 读取 `web.tar.gz`;先校验 checksum再解压到 `/opt/genarrative/releases/<version>/web`
-`DEPLOY_TARGET=release` 且 release 服务器本地缓存缺少 `web.tar.gz` 时,默认先执行 `rsync -av --progress <WEB_ARTIFACT_SYNC_HOST>:/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/ /var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/`,再继续校验 checksum默认 Host 为 `genarrative-build-internal`,由 release 服务器本机 SSH config 解析。
- 更新 `/opt/genarrative/current``/srv/genarrative/web` 指向。
- 执行 Nginx 配置测试和静态页面 smoke test。
- 不进入维护模式。

View File

@@ -6,6 +6,12 @@
2026-05-03 后入口进一步收口为画面描述直创:入口表单只保留画面描述、参考图和图片模型选择;作品名称、作品描述、作品标签全部进入结果页补全。若本文件早期段落仍提到入口必填作品名称或作品描述,以 `PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md` 为准。
## 首访新手引导隐藏
2026-05-12 起,平台首访不再自动进入 `puzzle-onboarding` 新手引导步骤。前端应直接停留在平台入口;旧新手引导面板、生成接口和保存接口暂时保留为休眠代码,后续只有在产品明确恢复时才重新打开分流开关。
首访隐藏不写入 `genarrative.puzzle-onboarding.first-visit.v1`,避免把“引导未展示”误记录成玩家已主动完成或跳过。
## 入口表单
### 2026-05-03 画面描述直创补充
@@ -87,10 +93,28 @@
## 结果页
拼图草稿结果页分为个 Tab
拼图草稿结果页分为个 Tab
1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。
2. 作品信息:展示并编辑作品名称、作品描述、作品标签。
3. UI展示并编辑拼图运行态 UI 背景提示词。`compile_puzzle_draft` 草稿编译完成首图和背景音乐后,`api-server` 会基于作品名称、作品描述、标签和首关信息自动生成首关 9:16 UI 背景图;结果页继续支持用户修改提示词并通过 `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` 图片生成链路。生成结果写入首关 `levels_json``uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。
4. 音乐:编辑并生成背景音乐,音乐资产暂存到首关 `levels_json[0].backgroundMusic`
### 2026-05-12 UI 背景生成补充
1. UI 背景图只生成拼图棋盘以外的运行态背景与 UI 容器层次,提示词必须要求中央正方形拼图区和外部 UI 背景之间有明确描边、容器或留白边界。
2. UI 背景图不得生成文字、水印、按钮文字、数字、拼图碎片、完整拼图图像或教程浮层,避免与真实拼图图块和运行态 HUD 混淆。
3. 结果页 UI Tab 支持直接修改提示词并重新生成;点击生成前会把本地首关 `uiBackgroundPrompt` 同步进 `levelsJson`,使自动保存尚未完成时后端仍能拿到最新提示词。
4. 草稿编译阶段自动生成 UI 背景失败时只记录 warning并保留草稿进入结果页用户可在 UI Tab 重新生成,不因背景图上游波动阻断首图草稿主流程。
5. `api-server` 负责读取参考图、拼接生成 prompt、调用 VectorEngine、下载并转存 OSSSpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。
6. 拼图运行态读取 `currentLevel.uiBackgroundImageSrc` 渲染为全屏背景;无 UI 背景图时继续使用原封面模糊背景兜底。棋盘本身仍由正式拼图图生成,不能把 UI 背景当作拼图切块来源。
### 2026-05-12 草稿生成完成自动试玩补充
1. 玩家停留在拼图草稿生成进度页等待 `compile_puzzle_draft` 完成时,前端必须先把最新 session / profile 记为草稿结果页状态,再自动启动本地拼图试玩。
2. 自动试玩的返回目标固定为 `puzzle-result`。玩家在试玩过程中点击左上角返回后,应进入同一份拼图草稿结果页继续查看和编辑。
3. 自动试玩只在当前仍处于 `puzzle-generating` 时触发;若玩家已返回草稿 Tab 或切到其它页面,后台生成完成只标记草稿已生成,不得强行抢屏进入试玩。
4. 若自动启动试玩失败,前端保留草稿结果页作为兜底查看入口,并展示已有错误态,不应丢失已生成草稿。
### 2026-04-30 关卡列表卡片交互补充
@@ -107,7 +131,7 @@
6. api-server 处理 `generate_puzzle_images` 时,若 action 带有 `levelsJson`,必须用这份关卡快照覆盖本次生成的草稿关卡视图后再定位 `levelId`。若请求明确传入 `levelId` 但关卡列表中不存在该关卡,必须返回错误,不得静默回退第一关。
7. 历史拼图素材入口和本地上传参考图入口统一收口到 `画面图` 图卡右下角,避免 `画面描述` 输入区同时承载文本编辑和素材入口;无正式图时也展示空图态图卡。
8. 历史拼图素材列表必须由服务端按当前登录账号过滤,只返回 `asset_kind = puzzle_cover_image``owner_user_id = 当前账号` 的资产;不得依赖前端过滤,也不得展示其他账号素材。
9. `画面图` 图卡本身就是上传热区,详情页不再保留右下角独立“上传参考图”按钮;历史入口统一使用带 `History` 图标和 `历史` 小字的按钮。入口页空图态的“点击上传拼图图片”只作为图卡内轻量提示,不使用胶囊按钮、边框或背景样式
9. `画面图` 图卡本身就是上传热区,详情页不再保留右下角独立“上传参考图”按钮;历史入口统一使用带 `History` 图标和 `历史` 小字的按钮。入口页历史按钮固定在图片上传区域右上角;空图态只展示“上传图片/填写画面描述”轻量提示,不再展示额外规则说明文案
### 2026-05-10 关卡生图交互补充
@@ -126,8 +150,9 @@
## 验收
1. 从拼图创作入口只能看到作品名称、作品描述、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。
2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。
2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择、背景音乐生成和首关 UI 背景图生成
3. 首图生成请求使用玩家画面描述作为 prompt上传参考图时走图生图作品详情页展示玩家作品描述。
4. 结果页包含“拼图关卡”“作品信息”个 Tab关卡列表默认至少一关支持新增、删除和进入关卡详情。
4. 结果页包含“拼图关卡”“作品信息”“UI”“音乐”四个 Tab关卡列表默认至少一关支持新增、删除和进入关卡详情。
5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。
6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。
7. 草稿初次生成后首关默认带 `uiBackgroundImageSrc`UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc`,且拼图棋盘区域和 UI 背景区域有明确边界。

View File

@@ -1,12 +1,14 @@
# 拼图与抓大鹅结果页音乐 Tab 2026-05-11
# 拼图与抓大鹅结果页音乐入口 2026-05-11
## 1. 范围
本方案把 VectorEngine 音频生成能力从视觉小说结果页扩展到拼图与抓大鹅结果页:
1. 拼图结果页新增 `音乐` Tab支持通过 Suno 生成作品背景音乐。
2. 抓大鹅结果页新增 `音乐` Tab支持通过 Suno 生成作品背景音乐。
3. 抓大鹅 `3D素材` Tab 支持为每个生成物体通过 Vidu 生成点击音效。
2. 抓大鹅结果页 `素材配置 > 背景音乐` 支持通过 Suno 生成作品背景音乐;旧一级 `音乐` Tab 已删除
3. 抓大鹅 `素材配置 > 物品` 支持为每个生成物体通过 Vidu 生成点击音效。
4. 拼图运行态与抓大鹅运行态内置默认关卡音频配置:通用点击音效 `/audio/ui-click-soft.wav`、过关音效 `/audio/ui-level-clear.wav`、倒计时临界音效 `/audio/ui-countdown-warning.wav`
5. 拼图和抓大鹅草稿生成阶段会自动生成背景音乐并转存 OSS结果页继续支持试听和重新生成。
本轮不新增 SpacetimeDB 表,不修改表字段,不把供应商密钥下发到前端。
@@ -28,8 +30,9 @@
3. 下载音频字节。
4. 写入 OSS 私有对象。
5. 确认 `asset_object` 并绑定 `asset_entity_binding`
6. 音频真正可下载并准备转存时,按 `taskId + assetKind + entityId + slot` 幂等扣除 `10` 光点;任务仍在处理中不扣费,转存或资产绑定失败自动退款。
视觉小说原路由保持兼容,内部继续复用同一套提交、轮询、转存逻辑。
通用背景音乐提交允许 `prompt = ""`。拼图和抓大鹅草稿生成都按纯音乐处理:后端提交 Suno 时固定带 `make_instrumental = true`,只用 `title``tags` 约束作品气质,不把歌词或规则描述写入 prompt。视觉小说原路由保持兼容,内部继续复用同一套提交、轮询、转存逻辑。
## 3. 数据落点
@@ -50,7 +53,7 @@
}
```
运行态后续可从当前关卡快照或作品详情读取该字段作为背景音乐源;若字段为空,继续使用现有程序化背景音乐兜底。
草稿生成阶段在生成首关作品题目后,使用作品题目作为 Suno `title``prompt` 为空,`tags` 使用轻快、拼图、循环、instrumental。生成失败只记录 warning不阻断草稿进入结果页。运行态从 `PuzzleRuntimeLevelSnapshot.backgroundMusic.audioSrc` 读取该字段作为背景音乐源,游戏开始后自动循环播放;若字段为空,保持静默背景音乐兜底。
### 3.2 抓大鹅
@@ -59,7 +62,7 @@
1. 作品背景音乐暂存到第一个 `Match3DGeneratedItemAsset.backgroundMusic`,表示当前 work profile 的作品级背景音乐。
2. 单个物体点击音效保存到对应 `Match3DGeneratedItemAsset.clickSound`
这是一个兼容性折中:当前 Match3D work profile 没有 work-level metadata 字段,而 `generated_item_assets_json` 已经随作品详情、草稿架、运行态入口稳定传递。后续若新增正式作品 metadata 表达,应迁移 `backgroundMusic` 到作品级字段。
这是一个兼容性折中:当前 Match3D work profile 没有 work-level metadata 字段,而 `generated_item_assets_json` 已经随作品详情、草稿架、运行态入口稳定传递。草稿生成阶段的文本计划在生成物品名称时同步生成 `backgroundMusic.title` 作为背景音乐名称,`backgroundMusic.prompt` 固定为空字符串,后端用该名称作为 Suno `title` 并生成纯音乐。后续若新增正式作品 metadata 表达,应迁移 `backgroundMusic` 到作品级字段。
## 4. 前端交互
@@ -68,6 +71,19 @@
1. `音乐` Tab 只展示必要输入、生成按钮、状态与音频预览,不展示供应商规则说明。
2. 生成完成后立即写回本地草稿状态,并触发既有保存链路或专用保存接口。
3. 抓大鹅每个物体音效生成入口放在对应素材详情面板内,不在列表下方展开大段配置。
4. 抓大鹅物体音效提示词允许在素材详情面板内编辑;背景音乐只允许在 `素材配置 > 背景音乐` 编辑曲名和风格,生成请求固定使用空 `prompt`
5. 背景音乐和物体音效生成期间都显示进度条,生成完成后展示 audio 控件试听。
6. 背景音乐重新生成只要求曲名非空;重新生成继续按纯音乐提交,`prompt = ""`
### 4.1 运行态默认点击音效
1. `src/services/runtimeAudioFeedback.ts` 提供通用关卡音频配置 `DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG`,内部缓存 `HTMLAudioElement`,失败时静默兜底,不阻塞玩法交互。
2. 拼图点击、按压或拖拽拼块时播放默认通用点击音效,并继续保留既有触觉反馈。
3. 抓大鹅点击物体时优先播放该物体绑定的 `clickSound.audioSrc`;若作品没有生成物体点击音效,则回退播放 `/audio/ui-click-soft.wav`
4. 拼图关卡 `currentLevel.status` 首次进入 `cleared` 时播放默认过关音效;抓大鹅 run `status` 首次进入 `won` 时播放默认过关音效。
5. 拼图使用 `displayRemainingMs`,抓大鹅使用 `timeLeftMs`。当剩余时间进入默认阈值 `5_000ms` 后,每个自然秒桶最多播放一次倒计时音效,归零后停止。
6. 默认关卡音效跟随现有 `musicVolume` 设置,不新增独立音量 UI不在运行态界面增加说明文案。
7. 拼图和抓大鹅运行态背景音乐同样跟随 `musicVolume`,读取 generated legacy path 时先换签,再交给隐藏 `<audio loop preload="auto">` 自动播放;浏览器拒绝自动播放时静默失败,不阻断游戏交互。
## 5. 验收

View File

@@ -33,11 +33,11 @@
- 亮色主题下上传卡片必须使用白色或暖浅色卡面,不得显示整块黑色底。
- 上传卡片固定为 1:1 正方形,避免拼图主画面在首屏出现非正方形预期。
- 移动端表单主体不可依赖纵向拖动查看核心控件;玩法卡带、描述输入框和底部生成按钮占位固定后,上传卡片必须按剩余高度等比例缩放,仍保持 1:1。
- 上传卡片底部不再叠加文件名 bar`点击上传拼图图片` 入口必须显示在拼图画面卡片内部。
- 上传卡片底部不再叠加文件名 bar`上传图片/填写画面描述` 入口必须显示在拼图画面卡片内部。
- 上传卡片上方固定展示 `拼图画面` 标题。
- 无图状态下,上传卡片内部`点击上传拼图图片` 按钮上方展示 11px 级辅助提示 `若没有合适的图片可以通过填写画面描述生成画面`,提示用户可不上传图片、直接填写画面描述生成画面
- 上传成功后,`AI重绘` 开关显示在卡片左下角,右上角显示移除拼图图片图标按钮;移除必须先弹出二次确认。
- 叠在上传卡片上的 `AI重绘`、移除图标和上传入口必须和卡面保持足够对比,避免浅色主题重映射后不可读。
- 无图状态下,上传卡片内部只保留 `上传图片/填写画面描述` 轻量提示,不再展示额外规则说明文案
- 历史素材按钮固定在上传卡片右上角;上传成功后,`AI重绘` 开关显示在卡片左下角,移除拼图图片图标按钮显示在卡片左上角;移除必须先弹出二次确认。
- 叠在上传卡片上的历史按钮、`AI重绘`、移除图标和上传入口必须和卡面保持足够对比,避免浅色主题重映射后不可读。
3. 画面描述输入框高度固定,移动端保持约 `6rem`,不随剩余屏幕高度变大或变小,避免把上传参考图和提交区挤出首屏。
4. 创作 Tab 顶部玩法卡带的选中态只使用卡内暗色蒙版、细描边或内描边,不使用粉色外发光、外扩阴影或会从卡片边缘突出的高饱和边。
5. 输入区保留:
@@ -93,7 +93,7 @@ size = 1024x1024
拼图入口上传区左下角展示 `AI重绘` 开关,默认打开;未上传拼图图片前不显示开关,上传成功后才显示。上传成功后右上角展示移除图标按钮,点击后必须二次确认。
1. `AI重绘=true`
- 上传区文案为 `点击上传拼图图片`,上传图作为生图参考图。
- 上传区文案为 `上传图片/填写画面描述`,上传图作为生图参考图。
- 未上传图片时,输入框标题为 `画面描述`
- 已上传图片时,输入框标题为 `画面AI重绘要求提示词`
- 展示图片模型切换。

View File

@@ -8,6 +8,7 @@
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
- [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。
- [PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md](./PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md):冻结移动端推荐页隐藏顶部品牌栏、扩大推荐卡片可用高度,以及只在底部作品信息区承接切换作品手势的布局口径。
- [BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md](./BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md):冻结寓教于乐 `宝贝识物` 模板创作发布线程的前端入口、契约、service、结果页、发布标签和后端 image-2 接口预留边界。
- [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。
- [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md)记录运行态输入设备抽象层明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。
@@ -20,8 +21,8 @@
- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品、公开拼图作品完整运行态、平台 bootstrap 私有投影刷新和展示层图片换签的局部请求 `401` 不应扩散成全局登出的修复,覆盖 `authImpact: local` 请求策略、推荐页 embedded 运行态启动、拼图开局/排行榜/下一关和回归测试。
- [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。
- [HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md](./HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md):记录 Hyper3D Rodin Gen-2 文生 3D 模型、图生 3D 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。
- [MATCH3D_RODIN_ASSET_TAB_2026-05-10.md](./MATCH3D_RODIN_ASSET_TAB_2026-05-10.md):记录抓大鹅结果页多 Tab 改造与 Rodin 3D 素材列表/详情页的前端接入边界,明确首版只复用 Hyper3D 后端代理,不新增表或正式资产写入
- [MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md](./MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md):冻结抓大鹅草稿生成过程页、3 件物品名称生成、VectorEngine 1:1 素材图、n*n 切图、并行 Rodin 图生 3D 与 OSS 回填草稿页的端到端边界。
- [MATCH3D_RODIN_ASSET_TAB_2026-05-10.md](./MATCH3D_RODIN_ASSET_TAB_2026-05-10.md)历史记录抓大鹅 Rodin 3D 素材列表/详情页的早期接入边界;当前新草稿不再调用 Rodin 或生成 GLB
- [MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md](./MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md):冻结抓大鹅草稿生成过程页、按题材生成 UI 背景提示词、VectorEngine 2D 五视角素材、UI 背景图、背景音乐和 OSS 回填草稿页的端到端边界。
- [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。
- [VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md](./VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md):记录视觉小说结果页接入 VectorEngine Suno 文生背景音乐与 Vidu 文生音效的接口、环境变量、后端路由、OSS 资产回写和前端弹层交互边界。
- [PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md](./PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md):冻结“我的”页签帮助与反馈入口的后端接入方案,覆盖 `POST /api/profile/feedback``profile_feedback_submission`、凭证图片 Data URL 校验和前端预览/提交边界。
@@ -174,7 +175,7 @@
- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md)`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract以及用户不存在时的 `401` 语义。
- [AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、同设备同 IP 会话组合并、`clientLabel` 兼容策略与 Rust 接口边界。
- [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。
- [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429``Retry-After` contract。
- [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md):冻结 Rust `api-server + module-auth + platform-auth` 接入真实阿里云短信 provider 的 crate 边界、发送与校验职责、配置项和错误语义。
@@ -206,7 +207,7 @@
- [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)`M2` 第六张短信鉴权统计表 `sms_auth_event` 的事件范围、统计口径、索引与和风控/审计表的协作边界。
- [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md)`M2` 第五张风控状态表 `auth_risk_block` 的作用域、活跃态、刷新/解除规则与读取派生约束。
- [SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md)`M2` 第四张鉴权审计表 `auth_audit_log` 的事件范围、追加写规则、索引与对外 DTO 派生约束。
- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换吊销语义、索引与迁移规则。
- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换、logout fallback、指定会话吊销语义、索引与迁移规则。
- [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。
- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于旧 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。

View File

@@ -1,6 +1,6 @@
# Rust API Server 路由索引2026-04-23
更新时间:`2026-05-01`
更新时间:`2026-05-13`
> 2026-04-29 补充本文件保留为迁移期路由快照。DDD G1 后续并行工作的契约冻结口径以 [`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md) 为准,尤其是新增的 Big Fish、Puzzle、profile、runtime chat、story facade 和兼容路由删除计划。
>
@@ -20,7 +20,7 @@
2. 内部鉴权调试接口:`2` 条。
3. AI task 接口:`9` 条。
4. assets / OSS 接口:`15` 条。
5. auth 接口:`12` 条。
5. auth 接口:`13` 条。
6. custom world / agent 接口:`23` 条。
7. match3d creation / runtime 接口:`14` 条。
8. llm proxy 接口:`1` 条。
@@ -84,13 +84,14 @@
3. `POST /api/auth/logout`
4. `POST /api/auth/logout-all`
5. `GET /api/auth/sessions`
6. `POST /api/auth/refresh`
7. `POST /api/auth/phone/send-code`
8. `POST /api/auth/phone/login`
9. `GET /api/auth/wechat/start`
10. `GET /api/auth/wechat/callback`
11. `POST /api/auth/wechat/bind-phone`
12. `POST /api/auth/entry`
6. `POST /api/auth/sessions/{session_id}/revoke`
7. `POST /api/auth/refresh`
8. `POST /api/auth/phone/send-code`
9. `POST /api/auth/phone/login`
10. `GET /api/auth/wechat/start`
11. `GET /api/auth/wechat/callback`
12. `POST /api/auth/wechat/bind-phone`
13. `POST /api/auth/entry`
### 3.6 Custom World / Agent

View File

@@ -31,7 +31,7 @@ G1 单 owner 文件范围:
| 管理兑换码 | `POST /admin/api/profile/redeem-codes``POST /admin/api/profile/redeem-codes/disable` | 收敛 | 继续走 admin 路由DTO 归入 profile/runtime 管理命令组 | WP-RT、WP-API |
| 内部鉴权调试 | `GET /_internal/auth/claims``GET /_internal/auth/refresh-cookie` | 删除 | 只允许本地诊断脚本或 admin debug 能力使用,不作为前端契约 | WP-DEL |
| 鉴权公开查询 | `GET /api/auth/login-options``GET /api/auth/public-users/by-code/{code}``GET /api/auth/public-users/by-id/{user_id}` | 保留 | `AuthLoginOptionsResponse``PublicUserSearchResponse` | WP-A |
| 鉴权会话 | `GET /api/auth/me``GET /api/auth/sessions``POST /api/auth/refresh``POST /api/auth/logout``POST /api/auth/logout-all` | 保留 | `AuthMeResponse``AuthSessionsResponse``RefreshSessionResponse``LogoutResponse``LogoutAllResponse` | WP-A |
| 鉴权会话 | `GET /api/auth/me``GET /api/auth/sessions``POST /api/auth/sessions/{session_id}/revoke``POST /api/auth/refresh``POST /api/auth/logout``POST /api/auth/logout-all` | 保留 | `AuthMeResponse``AuthSessionsResponse``RevokeAuthSessionResponse``RefreshSessionResponse``LogoutResponse``LogoutAllResponse` | WP-A |
| 鉴权登录 | `POST /api/auth/phone/send-code``POST /api/auth/phone/login``GET /api/auth/wechat/start``GET /api/auth/wechat/callback``POST /api/auth/wechat/bind-phone``POST /api/auth/entry``POST /api/auth/password/change``POST /api/auth/password/reset` | 保留 | TS 命名统一使用 `Auth*` 前缀Rust 命名维持领域语义 | WP-A |
| 旧本地生成资产代理 | `GET /generated-character-drafts/{*path}``/generated-characters/{*path}``/generated-animations/{*path}``/generated-big-fish-assets/{*path}``/generated-puzzle-assets/{*path}``/generated-custom-world-scenes/{*path}``/generated-custom-world-covers/{*path}``/generated-qwen-sprites/{*path}` | 已删除 | 正式读取统一走 `GET /api/assets/read-url` 或 asset object projection`/generated-*` 仅允许作为 legacyPublicPath/object key 标识,不再作为可裸读路由 | WP-AS、WP-FE、WP-DEL |
| LLM 代理 | `POST /api/llm/chat/completions` | 收敛 | 仅作为平台能力代理;玩法 prompt 不允许由前端直接传入 | WP-PF、WP-API |
@@ -59,7 +59,7 @@ G1 单 owner 文件范围:
| --- | --- |
| `shared-contracts/src/api.rs` | `ApiResponseMeta``ApiErrorPayload``ApiSuccessEnvelope<T>``ApiErrorEnvelope` |
| `shared-contracts/src/admin.rs` | `AdminLoginRequest/Response``AdminSessionPayload``AdminMeResponse``AdminOverviewResponse``AdminDebugHttpRequest/Response` |
| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse``AuthUserPayload``PublicUserSummaryPayload``PublicUserSearchResponse``PasswordEntry*``PasswordChange*``PasswordReset*``AuthMeResponse``AuthSessionsResponse``RefreshSessionResponse``Logout*``Phone*``Wechat*` |
| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse``AuthUserPayload``PublicUserSummaryPayload``PublicUserSearchResponse``PasswordEntry*``PasswordChange*``PasswordReset*``AuthMeResponse``AuthSessionsResponse``RevokeAuthSessionResponse``RefreshSessionResponse``Logout*``Phone*``Wechat*` |
| `shared-contracts/src/ai.rs` | `CreateAiTaskRequest``AppendAiTextChunkRequest``CompleteAiStageRequest``AttachAiResultReferenceRequest``FailAiTaskRequest``AiTask*Payload``AiTaskMutationResponse``AiTaskAcceptedResponse` |
| `shared-contracts/src/assets.rs` | Direct upload、read url、asset object、asset binding、asset history、character visual/animation、workflow cache、role asset workflow 相关 DTO |
| `shared-contracts/src/creation_agent_document_input.rs` | `ParseCreationAgentDocumentInputRequest/Response``CreationAgentDocumentInputPayload` |

View File

@@ -115,6 +115,8 @@
1. 从 cookie 读出原始 refresh token
2. 计算 hash
3.`refresh_session.refresh_token_hash` 比较
4. 若 refresh cookie 缺失或不可用,再使用 Bearer access token claims 中的 `sid``refresh_session.session_id` 比较
5. 会话列表按“同设备 + 同 IP”聚合时组内任一 session 命中当前 hash 或当前 `sid`,整组都视为当前设备组
## 5. 表访问级别
@@ -228,9 +230,10 @@
写入规则:
1. 按当前 cookie 找 session
2. `revoked_at = now`
3.`revoked_reason_code = logout`
4. 同时提升 `user_account.token_version`
2. 如果 refresh cookie 缺失,则回退用 Bearer access token claims 中的 `sid` 找当前 session
3.`revoked_at = now`
4. `revoked_reason_code = logout`
5. 同时提升 `user_account.token_version`
### 8.4 吊销全部会话
@@ -248,7 +251,7 @@
触发点:
1. `POST /api/auth/sessions/:sessionId/revoke`
1. `POST /api/auth/sessions/{sessionId}/revoke`
写入规则:
@@ -257,6 +260,13 @@
3. 只改目标 `refresh_session`
4. `revoked_reason_code = session_revoke`
5. 不提升 `token_version`
6. 撤销后必须同步 auth store 到 SpacetimeDB
读取约束:
1. Bearer JWT 中的 `sid` 必须对应 active `refresh_session`
2. 被该接口撤销的设备即使 access token 未过期,后续请求也必须立刻返回未授权
3. 该接口不承担当前设备退出语义;当前设备退出固定走 `/api/auth/logout`
### 8.6 账号被禁用或并入
@@ -315,13 +325,18 @@
1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。
2. `ipMasked``isCurrent` 继续在 Axum 侧派生。
3. 同设备同 IP 的 active sessions 由 Axum 聚合后返回一条记录。
4. `sessionId` 是代表 ID当前组代表 ID 使用当前 `sid` 对应 session。
5. `sessionIds` 返回组内全部 active session ID`sessionCount` 返回组内数量。
6. 聚合组时间语义:`createdAt` 取最早创建时间,`lastSeenAt``expiresAt` 取最新值。
### 10.3 `POST /api/auth/logout`
依赖:
1. 当前 cookie 命中的 `refresh_session`
2. `user_account.token_version`
2. cookie 缺失时 Bearer `sid` 命中的 `refresh_session`
3. `user_account.token_version`
### 10.4 `POST /api/auth/logout-all`
@@ -330,6 +345,22 @@
1. 当前 `user_id` 下全部活跃 `refresh_session`
2. `user_account.token_version`
### 10.5 `POST /api/auth/sessions/{sessionId}/revoke`
依赖:
1. 当前 Bearer JWT 的 `user_id`
2. 当前 Bearer JWT 的 `sid`
3. 目标 `refresh_session.session_id`
4. `refresh_session.revoked_at`
5. `refresh_session.expires_at`
固定行为:
1. 目标 session 必须属于当前用户
2. 目标 session 不能是当前 `sid`
3. 成功只撤销目标 session不递增 `token_version`
## 11. 与当前 Node `user_sessions` 的映射关系
| Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 |

View File

@@ -328,7 +328,8 @@ SELECT * FROM profile_membership WHERE user_id = '<user_id>';
### `profile_recharge_order`
- 作用:充值订单表,记录用户购买光点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。
- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Timestamp`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>`
- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Option<Timestamp>`, `provider_transaction_id: Option<String>`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>`
- 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid``provider_transaction_id` 保存微信支付平台订单号。
- 索引:`user_id`, `(user_id, created_at)`
```sql

View File

@@ -290,6 +290,8 @@ POST /api/auth/wechat/miniprogram-login
4.`auth_binding_status=pending_bind_phone`,页面必须进入绑定手机号界面
5. 绑定成功后,应切回正常已登录状态
小程序原生手机号授权链路中,请求体应携带 `wechatPhoneCode`。后端调用微信 `getuserphonenumber` 后,需要按微信原始响应字段 `phoneNumber` / `purePhoneNumber` / `countryCode` 解析手机号;如果误按 Rust 字段名 `phone_number` / `pure_phone_number` / `country_code` 解析,会出现已传 `wechatPhoneCode` 但返回“微信手机号授权失败:缺少手机号”的假失败。
## 10. 后端验收点
当前后端至少应满足以下检查:

View File

@@ -132,7 +132,7 @@ Content-Type: application/json
}
```
9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。成功后重新签发 `active` 系统 token。
9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。微信返回的手机号字段使用 `phoneNumber` / `purePhoneNumber` / `countryCode`,后端解析时必须兼容这些原始 camelCase 字段;否则会在已收到 `wechatPhoneCode` 的情况下误报“微信手机号授权失败:缺少手机号”。成功后重新签发 `active` 系统 token。
10. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。
补充H5 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。

View File

@@ -10,7 +10,7 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
CARGO_HOME = '/home/dsk/.cache/genarrative-jenkins/api-server/cargo-home'
CARGO_TARGET_DIR = '/home/dsk/.cache/genarrative-jenkins/api-server/cargo-target/prod-release'
CARGO_INCREMENTAL = '0'

View File

@@ -9,6 +9,7 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {
@@ -66,13 +67,28 @@ pipeline {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
])
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
script {
if (params.COMMIT_HASH?.trim()) {
echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。"
@@ -84,7 +100,8 @@ pipeline {
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'

View File

@@ -9,6 +9,7 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {
@@ -82,20 +83,36 @@ pipeline {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
])
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'

View File

@@ -9,6 +9,7 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {
@@ -140,20 +141,36 @@ pipeline {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
])
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'

View File

@@ -12,7 +12,7 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {

View File

@@ -9,6 +9,7 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {
@@ -19,7 +20,8 @@ pipeline {
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置')
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: 'Nginx server_name 与证书域名')
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名')
string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name多个用空格或逗号分隔例如 www.genarrative.world')
string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径')
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
@@ -47,6 +49,17 @@ pipeline {
if (!params.SERVER_NAME?.trim()) {
error('SERVER_NAME 不能为空。')
}
if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
error("SERVER_NAME 只能填写单个域名或 IP不能包含空格、路径或协议: ${params.SERVER_NAME}")
}
def serverAliases = params.SERVER_ALIASES?.trim()
if (serverAliases) {
serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName ->
if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
error("SERVER_ALIASES 只能填写域名或 IP多个用空格或逗号分隔: ${aliasName}")
}
}
}
if (!params.SPACETIME_BIN_SOURCE?.trim()) {
error('SPACETIME_BIN_SOURCE 不能为空。')
}
@@ -69,20 +82,36 @@ pipeline {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
])
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash <<'BASH'
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
BASH

View File

@@ -10,7 +10,7 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
CARGO_HOME = '${env.WORKSPACE_TMP}/cargo-home'
CARGO_TARGET_DIR = '${env.WORKSPACE_TMP}/cargo-target/prod-release'
CARGO_INCREMENTAL = '0'
@@ -49,7 +49,7 @@ pipeline {
$ErrorActionPreference = 'Stop'
$sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' }
$commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' }
$gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git' }
$gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' }
git fetch --no-tags --prune --depth=1 $gitRemoteUrl "+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}"
if ($commitHash) {
git checkout --force $commitHash

View File

@@ -9,6 +9,7 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {
@@ -78,20 +79,36 @@ pipeline {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
])
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'

View File

@@ -10,7 +10,7 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
}

View File

@@ -9,6 +9,7 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
}
@@ -24,6 +25,9 @@ pipeline {
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点软链接')
booleanParam(name: 'SYNC_WEB_ARTIFACT_FROM_BUILD_HOST', defaultValue: true, description: 'release 目标本地缺少 Web 大包时,是否通过 rsync 从构建机内网拉取')
string(name: 'WEB_ARTIFACT_SYNC_HOST', defaultValue: 'genarrative-build-internal', description: 'rsync 源 SSH Host通常来自 release 服务器上 Jenkins 运行用户的 ~/.ssh/config')
string(name: 'WEB_ARTIFACT_SYNC_SSH_CONFIG', defaultValue: '', description: '可选rsync 使用的 ssh config 绝对路径;留空使用当前用户默认 ~/.ssh/config')
}
stages {
@@ -54,20 +58,36 @@ pipeline {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
])
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'
@@ -92,9 +112,36 @@ pipeline {
set -euo pipefail
artifact_dir="${WEB_ARTIFACT_ROOT}/${BUILD_JOB_NAME}/${BUILD_NUMBER_TO_DEPLOY}/${BUILD_VERSION}"
if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then
sync_enabled="${SYNC_WEB_ARTIFACT_FROM_BUILD_HOST:-true}"
sync_host="${WEB_ARTIFACT_SYNC_HOST:-genarrative-build-internal}"
sync_ssh_config="${WEB_ARTIFACT_SYNC_SSH_CONFIG:-}"
if [[ "${DEPLOY_TARGET:-development}" == "release" && "${sync_enabled}" == "true" ]]; then
if [[ -z "${sync_host}" ]]; then
echo "[web-deploy] release 目标需要同步 Web 大包,但 WEB_ARTIFACT_SYNC_HOST 为空。" >&2
exit 1
fi
echo "[web-deploy] release 目标本地缓存缺少 Web 大包,尝试从 ${sync_host} 同步: ${artifact_dir}"
if ! command -v rsync >/dev/null 2>&1; then
echo "[web-deploy] 当前 release agent 缺少 rsync请先安装 rsync 或预先挂载 Web 产物目录。" >&2
exit 1
fi
mkdir -p "${artifact_dir}"
rsync_args=(-av --progress)
if [[ -n "${sync_ssh_config}" ]]; then
rsync_args+=(-e "ssh -F ${sync_ssh_config}")
fi
rsync "${rsync_args[@]}" "${sync_host}:${artifact_dir}/" "${artifact_dir}/"
fi
fi
if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then
echo "[web-deploy] 未找到构建机本地 Web 大包: ${artifact_dir}/web.tar.gz" >&2
echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机release 目标需要预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2
echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机release 目标会默认通过 rsync 从 WEB_ARTIFACT_SYNC_HOST 拉取,也可预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2
exit 1
fi

42
media/files/disclaimer.md Normal file
View File

@@ -0,0 +1,42 @@
## 免责声明
在使用百梦Genarrative平台前请仔细阅读以下声明。
### 一、AI 内容免责
1. 本平台基于第三方 AI 大语言模型提供文字游戏体验,所有 AI 生成内容均为算法自动产出,不代表本平台的观点、立场或价值取向。
2. AI 具有内容生成的不确定性,可能产出不准确、不完整或不当的内容。平台已部署安全过滤机制,但无法做到百分之百的内容审查。
3. AI 生成内容仅供娱乐体验,不构成任何形式的事实陈述或专业建议。用户不应将其作为决策依据。
### 二、用户行为免责
1. 用户在平台上发布的所有内容(包括自定义剧本、论坛帖子、评论等)均由用户自行负责。
2. 用户主动诱导 AI 生成违法违规内容的,由用户自行承担全部法律责任,平台不承担任何连带责任。
3. 用户利用平台进行任何违法活动所导致的后果,由用户自行承担。
本平台论坛功能允许用户自由发布文字、图片等内容。对此,特别声明如下:
- 版权声明:用户在论坛发布的所有内容(文字、图片、截图等)应为用户原创或已获得合法授权。平台不对用户发布内容进行事先版权审查,不对用户内容的合法性承担审核义务。
- 侵权责任:如用户发布的内容侵犯了任何第三方的知识产权(包括但不限于著作权、肖像权、商标权等),一切法律责任由发布该内容的用户独自承担,与本平台无关。
- 平台角色:本平台仅作为用户内容的技术展示平台,提供信息存储与展示服务,不对任何用户内容进行编辑、推荐背书或商业利用。平台不对用户内容的真实性、准确性、完整性或合法性作任何形式的保证。
- 内容处置:平台有权根据法律法规、监管要求或平台规则,对涉嫌侵权或违规的用户内容进行删除、屏蔽等处理,且无需事先通知发布者。
- 追偿权利:如因用户发布的内容导致平台被第三方索赔或遭受任何损失,平台保留向该用户追偿全部损失的权利。
- 图片特别说明用户上传的图片应确保拥有合法使用权。对于AI生成的图片用户应确认其所使用的AI工具允许将生成内容进行公开发布并自行承担由此产生的任何法律责任。
### 三、第三方服务免责
1. 用户自行配置的第三方 API 密钥产生的费用和数据安全问题,由用户自行负责。
2. 因第三方 AI 服务商的服务中断、数据泄露等问题导致的损失,本平台不承担责任。
3. 平台内的外部链接仅供便利,不代表平台对链接内容的认可或担保。
### 四、服务可用性
1. 本平台按"现状"提供服务,不作任何明示或暗示的保证。
2. 平台可能因维护升级、技术故障、不可抗力等原因发生服务中断,平台将尽合理努力恢复服务,但不对此承担赔偿责任。
3. 平台有权根据运营情况调整服务内容、功能或收费标准。
### 五、数据安全
1. 平台将尽合理努力保障用户数据安全,但无法提供绝对安全保证。
2. 因用户自身原因(如密码泄露、浏览器使用不当、未使用推荐浏览器、浏览器或者手机自身崩溃导致数据丢失、浏览器无痕模式、设备丢失)导致的数据损失,平台不承担责任。
3. 用户应自行做好重要数据的备份。
### 六、违规处理与执法配合
1. 平台有权对违反用户协议的账号采取包括但不限于警告、限制功能、封禁账号等措施,且不予退还任何已消费的费用。
2. 对于涉嫌违法犯罪的行为,平台将保留相关证据并依法向有关部门报告。
3. 平台依法配合国家有关部门的监管和调查工作,并根据法律要求提供必要的用户信息和操作记录。
### 七、责任限制
在法律允许的最大范围内,平台就任何间接、附带、特殊、惩罚性损害不承担责任。平台对用户因使用本平台而产生的任何损失所承担的最大责任,不超过用户实际向平台支付的费用金额。

View File

@@ -0,0 +1,61 @@
## 隐私政策
百梦Genarrative平台深知个人信息安全的重要性。本政策说明我们如何收集、使用、存储和保护您的个人信息。
### 一、我们收集的信息
1. 您主动提供的信息
- 注册信息:邮箱地址、密码(加密存储)、昵称
- 论坛内容:您发布的帖子、评论等
2. 自动收集的信息
- 设备信息:浏览器类型、操作系统
- 使用记录:操作日志、访问时间、功能使用频率
- 网络信息IP 地址(用于安全防护和合规要求)
3. 我们不会收集的信息
- 我们不会收集您的身份证号、手机号、银行账号等敏感个人信息
- 我们不会读取您设备上的通讯录、相册、定位等信息
### 二、信息使用目的
我们仅在以下场景使用您的个人信息:
1. 提供和维护平台核心功能(账号管理、内容服务、云端存档)
2. 保障账号和平台安全(登录验证、异常检测、防止滥用)
3. 改进服务质量(使用统计分析,均以匿名、聚合方式进行)
4. 履行法律义务(依法配合有权机关的调查请求)
5. 发送与服务直接相关的通知(账号安全提醒等)
**我们不会将您的个人信息用于任何未经您同意的营销推广目的。**
### 三、信息存储与安全
1. 您的数据存储在具备安全保障的云服务器上。
2. 密码采用单向加密存储,任何人(包括平台管理员)均无法查看明文密码。
3. 用户操作日志保存期限不少于六个月,以满足法律合规要求。
4. 我们采取合理的技术和管理措施防止数据泄露、篡改和丢失,但无法保证绝对安全。
### 四、信息共享
我们**不会**主动向第三方出售或分享您的个人信息,但以下情况除外:
1. 获得您的明确同意;
2. 根据法律法规的要求或有权机关的强制性要求;
3. 为保护平台、其他用户或公众的合法权益所必需;
4. 与平台核心功能直接相关的第三方服务(如邮件发送服务),且该第三方受到严格的数据保护约束。
### 五、用户自行配置 API 的说明
如您选择使用自定义 API 密钥,您的游戏对话内容将直接发送至您指定的第三方 AI 服务提供商。该部分数据的处理受该第三方的隐私政策约束,平台对此不承担责任。
### 六、您的权利
1. 访问权:您可以随时查看和修改您的个人信息。
2. 删除权:您可以申请删除您的账号和相关数据。
3. 导出权:您可以导出您的游戏存档数据。
如需行使以上权利或有隐私相关疑问,请通过平台提供的联系方式与我们沟通。
### 七、未成年人保护
本平台不向未满 18 周岁的未成年人提供服务。如我们发现在未获得家长或监护人同意的情况下收集了未成年人的个人信息,将尽快删除相关信息。
### 八、论坛用户生成内容
当您在论坛发布内容时,我们会收集并存储以下信息:
- 您发布的文字、图片等内容本身
- 发布时间、编辑记录等操作信息
- 与内容关联的互动信息(点赞、评论等)
请注意:您在论坛公开发布的内容对所有平台用户可见。请勿在公开内容中包含个人敏感信息。您上传的图片可能包含元数据(如拍摄时间、地理位置),建议您在上传前自行清除此类信息。
### 九、政策更新
本政策可能根据法律法规变化或平台运营需要进行更新。更新后将在平台公布,继续使用即视为同意更新后的政策。

View File

@@ -0,0 +1,103 @@
## 百梦用户协议
欢迎使用百梦Genarrative平台。请您在注册或使用本平台服务前仔细阅读并充分理解本协议的全部内容。注册、登录或继续使用本平台即表示您已阅读、理解并同意接受本协议的约束。
### 一、服务说明
百梦是一个基于人工智能技术的交互内容创作与体验平台为用户提供优质的内容体验和方便快捷的创作工具。平台所生成的内容由AI模型自动产出不代表本平台的立场或观点。
### 二、用户资格
1. 您必须年满**18周岁**方可注册和使用本平台。
2. 如您为未成年人,请立即停止使用本平台服务,本平台不对未成年人提供服务。
3. 若您的监护人发现您未满18周岁已注册使用本平台可联系平台协助注销相关账号。
4. 您应当提供真实、准确的注册信息,并对账号下的一切行为承担法律责任。
### 三、用户行为规范
使用本平台时,您承诺**不得**从事以下行为:
1. **发布、传播或诱导生成**涉及以下内容的信息:
- 违反宪法所确定的基本原则的内容;
- 危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的内容;
- 损害国家荣誉和利益的内容;
- 煽动民族仇恨、民族歧视,破坏民族团结的内容;
- 破坏国家宗教政策,宣扬邪教和封建迷信的内容;
- 散布谣言,扰乱社会秩序,破坏社会稳定的内容;
- 涉及淫秽、色情、赌博、暴力、凶杀、恐怖的内容;
- **涉及未成年人色情、性暗示或任何形式的未成年人侵害内容(零容忍);**
- 侮辱或者诽谤他人,侵害他人名誉、隐私和其他合法权益的内容;
- 其他违反法律法规、社会公德或公序良俗的内容。
2. 不得利用平台从事任何违法犯罪活动。
3. 不得利用技术手段攻击、干扰平台正常运营。
4. 不得将平台生成内容用于欺诈、造谣或侵犯他人权益的用途。
违反以上规定的,平台有权立即停止服务、封禁账号,且用户已使用的体验额度不予恢复。情节严重的,平台将依法向有关部门举报并配合调查。
### 四、AI 生成内容
1. 本平台基于第三方 AI 大语言模型提供服务AI 生成的内容具有不可预测性。
2. AI 生成的内容不构成任何形式的专业建议,包括但不限于法律、医疗、财务建议。
3. 平台已尽合理努力对 AI 输出进行安全过滤,但无法保证所有输出内容完全合规。如您在使用过程中遇到不当内容,请及时向平台反馈。
4. 用户不得主动诱导 AI 生成违反法律法规或本协议第三条所列的禁止性内容。
### 五、用户生成内容与知识产权
1. 平台的界面设计、代码、商标、标识等知识产权归平台所有。
2. 用户创建的自定义剧本内容,知识产权归用户所有,但用户授予平台在运营范围内使用的许可。
3. 用户在论坛发布的内容,视为授权平台在平台范围内展示和传播。
4. 内容归属与授权
用户在本平台论坛发布的所有内容(包括但不限于文字、图片、截图、评论等,以下统称"用户内容"),其知识产权归原始权利人所有。用户发布内容即表示:
- 用户保证其发布的内容为原创,或已获得合法权利人的明确授权
- 用户保证其发布的内容不侵犯任何第三方的著作权、商标权、肖像权、隐私权及其他合法权益
- 用户授予本平台在平台范围内展示、传播、存储该内容的非排他性、免费许可,该许可仅用于平台正常运营展示,不作商业转售或二次商业开发用途
- 用户可随时删除其发布的内容,删除后平台将在合理时间内停止展示(但因技术原因产生的缓存或备份除外)
5. 用户侵权责任
用户对其在本平台发布的全部内容承担独立且完整的法律责任:
- 如用户上传、发布的图片或文字侵犯了第三方的著作权、肖像权或其他合法权益,由用户自行承担全部法律责任及赔偿义务
- 本平台仅提供信息发布与展示的技术服务,不对用户发布内容进行事先版权审核,不对用户发布内容的合法性、真实性、准确性作任何保证
- 严禁用户发布以下侵权内容:
- 未经授权使用他人原创作品(包括但不限于绘画、摄影、文学作品、音乐等)
- 未经本人同意使用他人肖像、照片
- 未经授权使用受商标权保护的标识、品牌元素
- 其他任何侵犯第三方知识产权的行为
6. 侵权投诉与处理
本平台尊重知识产权,建立了以下侵权处理机制:
- 任何权利人如认为平台上的用户内容侵犯了其合法权益,可通过平台提供的联系方式进行投诉举报
- 平台在收到符合法律规定的有效侵权通知后,将及时审核并依法采取删除、屏蔽、断开链接等必要措施
- 被投诉用户如认为其内容不构成侵权,可提交书面反通知说明理由
- 对于多次侵权的用户,平台有权采取限制发布、封禁账号等措施
7. 平台免责
- 平台不对用户发布的任何内容的知识产权状态作出保证或承诺
- 因用户发布内容引发的任何知识产权纠纷,由发布用户自行负责解决并承担一切法律后果
- 如因用户侵权行为导致平台遭受损失(包括但不限于赔偿金、诉讼费、律师费、商誉损失等),平台有权向该用户追偿
### 六、虚拟额度说明
1. 光点是本平台的游戏体验额度凭证,用户通过兑换码在平台兑换获得。
2. 光点仅限在本平台内用于消耗 AI 交互次数(包括游戏行动和 NPC 对话),不具有货币属性。
3. 光点不可转让、不可提现、不可兑换为法定货币或其他虚拟货币。
4. 兑换码的获取方式以平台公告或官方授权渠道为准,本平台不对非官方渠道获取的兑换码承担任何责任。
5. 因用户违反本协议被封禁账号的,账号内剩余的光点额度不予恢复。
### 七、内容分级声明
1. 本平台部分游戏剧本可能包含虚构的悬疑、惊悚、推理等文学创作元素,所有内容均为虚构,与现实无关。
2. 平台将对含有特殊题材的剧本进行标签标注提示,用户可根据个人偏好自行选择是否体验。
3. 本平台严禁任何涉及未成年人不当内容的剧本创作与传播,违者将被永久封禁并依法追究责任。
### 八、账号安全
1. 您有义务妥善保管账号和密码,因保管不善造成的损失由您自行承担。
2. 平台有权对涉嫌异常操作的账号采取限制措施。
3. 每个用户仅可注册一个账号,禁止批量注册或交易账号。
### 九、服务变更与终止
1. 平台有权根据运营需要调整、中断或终止部分或全部服务,并尽合理努力提前通知用户。
2. 因不可抗力(包括但不限于自然灾害、政策法规变化、技术故障)导致的服务中断,平台不承担责任。
### 十、免责条款
1. 用户因自身行为导致的任何法律后果,由用户自行承担全部责任。
2. 因 AI 模型自身特性产生的内容偏差或错误,平台不承担责任。
3. 因第三方 API 服务商的原因导致的服务异常,平台不承担责任。
4. 平台不对用户使用自定义API密钥产生的任何后果负责。
### 十一、协议修改
平台有权根据需要修改本协议。修改后的协议将在平台上公布,继续使用平台服务即视为接受修改后的协议。
### 十二、适用法律与争议解决
本协议适用中华人民共和国法律。因本协议引起的争议,双方应友好协商解决;协商不成的,任一方均有权向平台所在地有管辖权的人民法院提起诉讼。

View File

@@ -1,7 +1,5 @@
{
"pages": [
"pages/web-view/index"
],
"pages": ["pages/web-view/index", "pages/wechat-pay/index"],
"window": {
"navigationBarTitleText": "百梦",
"navigationBarBackgroundColor": "#0b0f14",

View File

@@ -343,7 +343,7 @@ Page({
},
handleWebViewMessage(event) {
// 中文注释:H5 如需和小程序壳通信,可通过 wx.miniProgram.postMessage 发送轻量消息
// 中文注释:支付由独立 native 页面承接web-view 消息只保留调试输出
console.info('[web-view] message', event.detail);
},
});

View File

@@ -0,0 +1,83 @@
function parsePayParams(rawValue) {
try {
const params = JSON.parse(decodeURIComponent(String(rawValue || '')));
if (!params || typeof params !== 'object') {
return null;
}
return params;
} catch (error) {
console.error('[wechat-pay] parse params failed', error);
return null;
}
}
function requestPayment(payParams) {
return new Promise((resolve) => {
wx.requestPayment({
timeStamp: String(payParams.timeStamp || ''),
nonceStr: String(payParams.nonceStr || ''),
package: String(payParams.package || ''),
signType: payParams.signType || 'RSA',
paySign: String(payParams.paySign || ''),
success() {
resolve('success');
},
fail(error) {
const errMsg = error && error.errMsg ? error.errMsg : '';
resolve(/cancel/i.test(errMsg) ? 'cancel' : 'fail');
},
});
});
}
function appendPayResult(url, requestId, status) {
const value = `${requestId}:${status}`;
const hashIndex = String(url || '').indexOf('#');
const baseUrl =
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
const params = new URLSearchParams(rawHash);
params.set('wx_pay_result', value);
return `${baseUrl}#${params.toString()}`;
}
function notifyPreviousWebView(requestId, status) {
const pages = getCurrentPages();
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
if (previousPage && typeof previousPage.setData === 'function') {
previousPage.setData({
webViewUrl: appendPayResult(
previousPage.data.webViewUrl,
requestId,
status,
),
});
}
}
Page({
data: {
title: '正在拉起支付',
errorMessage: '',
},
async onLoad(query) {
const requestId = String(query.requestId || '');
const payParams = parsePayParams(query.payParams);
if (!requestId || !payParams) {
this.setData({
title: '支付失败',
errorMessage: '缺少支付参数。',
});
return;
}
const status = await requestPayment(payParams);
notifyPreviousWebView(requestId, status);
wx.navigateBack();
},
handleBack() {
wx.navigateBack();
},
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "微信支付"
}

View File

@@ -0,0 +1,11 @@
<view class="pay-screen">
<view class="pay-card">
<view class="pay-title">{{title}}</view>
<view wx:if="{{errorMessage}}" class="pay-text pay-text--danger">
{{errorMessage}}
</view>
<button wx:if="{{errorMessage}}" class="ghost-button" bindtap="handleBack">
返回
</button>
</view>
</view>

View File

@@ -0,0 +1,48 @@
.pay-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
background: #0b0f14;
box-sizing: border-box;
}
.pay-card {
width: 100%;
max-width: 560rpx;
padding: 36rpx;
border: 1rpx solid rgba(255, 255, 255, 0.14);
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.06);
box-sizing: border-box;
}
.pay-title {
font-size: 34rpx;
font-weight: 600;
line-height: 1.35;
color: #f5f7fb;
}
.pay-text {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.55;
color: rgba(245, 247, 251, 0.72);
}
.pay-text--danger {
color: #ffb4a9;
}
.ghost-button {
margin-top: 28rpx;
width: 100%;
border-radius: 8rpx;
border: 1rpx solid rgba(255, 255, 255, 0.24);
background: transparent;
color: rgba(245, 247, 251, 0.86);
font-size: 26rpx;
line-height: 2.6;
}

View File

@@ -23,6 +23,7 @@
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"check:encoding": "node scripts/check-encoding.mjs",
"assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs",
"assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs",
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",

View File

@@ -150,6 +150,8 @@ export type AuthRefreshResponse = {
export type AuthSessionSummary = {
sessionId: string;
sessionIds: string[];
sessionCount: number;
clientType: string;
clientRuntime: string;
clientPlatform: string;

View File

@@ -2,7 +2,10 @@
* 抓大鹅 Match3D 创作 Agent 共享契约。
* 字段按 HTTP facade 的 camelCase DTO 命名,后端领域层 snake_case 字段由 facade 映射。
*/
import type { Match3DGeneratedItemAsset } from './match3dWorks';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
} from './match3dWorks';
export type Match3DCreationStage =
| 'collecting'
@@ -34,6 +37,7 @@ export interface CreateMatch3DAgentSessionRequest {
assetStyleId?: string | null;
assetStyleLabel?: string | null;
assetStylePrompt?: string | null;
generateClickSound?: boolean;
}
export type CreateMatch3DSessionRequest = CreateMatch3DAgentSessionRequest;
@@ -55,6 +59,7 @@ export interface ExecuteMatch3DAgentActionRequest {
coverImageSrc?: string | null;
clearCount?: number;
difficulty?: number;
generateClickSound?: boolean;
}
export type ExecuteMatch3DActionRequest = ExecuteMatch3DAgentActionRequest;
@@ -80,6 +85,7 @@ export interface Match3DCreatorConfig {
assetStyleId?: string | null;
assetStyleLabel?: string | null;
assetStylePrompt?: string | null;
generateClickSound?: boolean;
}
export interface Match3DResultDraft {
@@ -96,6 +102,10 @@ export interface Match3DResultDraft {
totalItemCount?: number;
publishReady?: boolean;
blockers?: string[];
backgroundPrompt?: string | null;
backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null;
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
}

View File

@@ -46,6 +46,7 @@ export type Match3DClickConfirmStatus =
export interface StartMatch3DRunRequest {
profileId: string;
itemTypeCountOverride?: number | null;
}
export interface Match3DClickItemRequest {

View File

@@ -14,18 +14,39 @@ export type Match3DGeneratedItemAssetStatus =
| 'failed'
| string;
export interface Match3DGeneratedBackgroundAsset {
prompt: string;
imageSrc?: string | null;
imageObjectKey?: string | null;
status: string;
error?: string | null;
}
export interface Match3DGeneratedItemImageView {
viewId: string;
viewIndex: number;
imageSrc?: string | null;
imageObjectKey?: string | null;
}
export interface Match3DGeneratedItemAsset {
itemId: string;
itemName: string;
imageSrc?: string | null;
imageObjectKey?: string | null;
imageViews?: Match3DGeneratedItemImageView[];
modelSrc?: string | null;
modelObjectKey?: string | null;
modelFileName?: string | null;
taskUuid?: string | null;
subscriptionKey?: string | null;
soundPrompt?: string | null;
backgroundMusicTitle?: string | null;
backgroundMusicStyle?: string | null;
backgroundMusicPrompt?: string | null;
backgroundMusic?: CreationAudioAsset | null;
clickSound?: CreationAudioAsset | null;
backgroundAsset?: Match3DGeneratedBackgroundAsset | null;
status: Match3DGeneratedItemAssetStatus;
error?: string | null;
}
@@ -34,6 +55,52 @@ export interface PutMatch3DAudioAssetsRequest {
generatedItemAssets: Match3DGeneratedItemAsset[];
}
export interface PersistMatch3DGeneratedModelRequest {
itemId: string;
itemName: string;
sourceUrl: string;
fileName?: string | null;
taskUuid?: string | null;
subscriptionKey?: string | null;
}
export interface PersistMatch3DGeneratedModelResponse {
asset: Match3DGeneratedItemAsset;
}
export interface GenerateMatch3DCoverImageRequest {
prompt: string;
referenceImageSrc?: string | null;
}
export interface GenerateMatch3DCoverImageResponse {
item: Match3DWorkProfile;
coverImageSrc: string;
coverImageObjectKey: string;
prompt: string;
}
export interface GenerateMatch3DBackgroundImageRequest {
prompt: string;
}
export interface GenerateMatch3DBackgroundImageResponse {
item: Match3DWorkProfile;
backgroundImageSrc: string;
backgroundImageObjectKey: string;
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset;
prompt: string;
}
export interface GenerateMatch3DItemAssetsRequest {
itemNames: string[];
}
export interface GenerateMatch3DItemAssetsResponse {
item: Match3DWorkProfile;
generatedItemAssets: Match3DGeneratedItemAsset[];
}
export interface PutMatch3DWorkRequest {
gameName: string;
themeText?: string;
@@ -72,6 +139,10 @@ export interface Match3DWorkSummary {
updatedAt: string;
publishedAt?: string | null;
publishReady: boolean;
backgroundPrompt?: string | null;
backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null;
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
}

View File

@@ -4,6 +4,7 @@ export type PuzzleAgentSuggestedActionType =
| 'request_summary'
| 'compile_puzzle_draft'
| 'generate_puzzle_images'
| 'generate_puzzle_ui_background'
| 'generate_puzzle_tags'
| 'publish_puzzle_work';
@@ -17,6 +18,7 @@ export type PuzzleAgentActionType =
| 'save_puzzle_form_draft'
| 'compile_puzzle_draft'
| 'generate_puzzle_images'
| 'generate_puzzle_ui_background'
| 'generate_puzzle_tags'
| 'select_puzzle_image'
| 'publish_puzzle_work';
@@ -77,6 +79,16 @@ export type PuzzleAgentActionRequest =
themeTags?: string[];
levelsJson?: string;
}
| {
action: 'generate_puzzle_ui_background';
levelId?: string | null;
promptText: string;
workTitle?: string;
workDescription?: string;
summary?: string;
themeTags?: string[];
levelsJson?: string;
}
| {
action: 'generate_puzzle_tags';
workTitle: string;

View File

@@ -48,6 +48,9 @@ export interface PuzzleDraftLevel {
levelName: string;
pictureDescription: string;
pictureReference?: string | null;
uiBackgroundPrompt?: string | null;
uiBackgroundImageSrc?: string | null;
uiBackgroundImageObjectKey?: string | null;
backgroundMusic?: CreationAudioAsset | null;
candidates: PuzzleGeneratedImageCandidate[];
selectedCandidateId: string | null;

View File

@@ -1,3 +1,5 @@
import type { CreationAudioAsset } from './creationAudio';
export type PuzzleGridSize = 3 | 4 | 5 | 6 | 7;
export interface PuzzleCellPosition {
@@ -55,6 +57,8 @@ export interface PuzzleRuntimeLevelSnapshot {
authorDisplayName: string;
themeTags: string[];
coverImageSrc: string | null;
uiBackgroundImageSrc?: string | null;
backgroundMusic?: CreationAudioAsset | null;
board: PuzzleBoardSnapshot;
status: PuzzleRuntimeLevelStatus;
startedAtMs: number;

View File

@@ -78,7 +78,12 @@ export type ProfileWalletLedgerResponse = {
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipStatus = 'normal' | 'active';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
export type ProfileRechargeOrderStatus = 'paid';
export type ProfileRechargeOrderStatus =
| 'pending'
| 'paid'
| 'failed'
| 'closed'
| 'refunded';
export type ProfileRechargeProduct = {
productId: string;
@@ -117,7 +122,8 @@ export type ProfileRechargeOrder = {
amountCents: number;
status: ProfileRechargeOrderStatus;
paymentChannel: string;
paidAt: string;
paidAt: string | null;
providerTransactionId: string | null;
createdAt: string;
pointsDelta: number;
membershipExpiresAt: string | null;
@@ -133,6 +139,14 @@ export type ProfileRechargeCenterResponse = {
hasPointsRecharged: boolean;
};
export type WechatMiniProgramPayParams = {
timeStamp: string;
nonceStr: string;
package: string;
signType: 'RSA';
paySign: string;
};
export type CreateProfileRechargeOrderRequest = {
productId: string;
paymentChannel?: string;
@@ -141,6 +155,7 @@ export type CreateProfileRechargeOrderRequest = {
export type CreateProfileRechargeOrderResponse = {
order: ProfileRechargeOrder;
center: ProfileRechargeCenterResponse;
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
};
export type ProfileFeedbackStatus = 'open';

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,326 @@
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const defaultOutDir = path.join(repoRoot, 'public', 'match3d-style-references');
const defaultTimeoutMs = 180000;
const styleTemplates = [
{
id: 'flat-icon',
title: '扁平图标',
prompt:
'扁平矢量游戏道具图标风格,干净色块,正面视角,深色清晰轮廓,移动端休闲游戏素材,可读性很强。',
},
{
id: 'cel-cartoon',
title: '赛璐璐卡通',
prompt:
'赛璐璐卡通游戏道具风格,明亮配色,清晰线稿,硬边阴影,边缘干净,像轻松休闲手游里的 2D 素材。',
},
{
id: 'pixel-retro',
title: '像素复古',
prompt:
'复古像素游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,像 32-bit 休闲游戏图标。',
},
{
id: 'watercolor',
title: '手绘水彩',
prompt:
'手绘水彩游戏道具风格,柔和纸张纹理,透明叠色,边缘轻微晕染,但主体剪影仍然清楚。',
},
{
id: 'sticker-outline',
title: '贴纸描边',
prompt:
'贴纸描边游戏道具素材风格,粗白边,深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。',
},
{
id: 'painterly-icon',
title: '厚涂图标',
prompt:
'厚涂游戏道具图标风格,细腻笔触,明确体积光影,中心构图,清晰剪影,适合高品质 2D 道具素材。',
},
];
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index];
if (!raw.startsWith('--')) {
continue;
}
const next = process.argv[index + 1];
if (next && !next.startsWith('--')) {
args.set(raw, next);
index += 1;
} else {
args.set(raw, true);
}
}
function readDotenv(fileName) {
const filePath = path.join(repoRoot, fileName);
if (!existsSync(filePath)) {
return {};
}
const values = {};
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (!match) {
continue;
}
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return values;
}
function resolveEnv() {
const loaded = {
...readDotenv('.env.example'),
...readDotenv('.env.local'),
...readDotenv('.env.secrets.local'),
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildVectorEngineImagesGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
}
function buildPrompt(template) {
return [
'请生成一张 1:1 方形抓大鹅入口 2D 素材风格参考图。',
'画面是一组 5 个小型游戏道具样张,题材统一为水果、甜点、玩具和宝石的混合展示。',
`整体风格:${template.prompt}`,
'要求:每个道具都是独立 2D 素材示例,主体集中,轮廓清晰,适合被切成抓大鹅局内物品素材。',
'构图:浅色干净背景,散点排列,留有呼吸感,不要九宫格边框,不要 UI 面板,不要按钮。',
'避免文字、水印、logo、教程标注、真实照片、复杂场景、人物、动物、3D 模型视口、明显透视地面、厚重阴影。',
].join('');
}
function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
return;
}
if (!value || typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (key === targetKey) {
if (typeof nested === 'string' && nested.trim()) {
output.push(nested.trim());
}
if (Array.isArray(nested)) {
nested.forEach((entry) => {
if (typeof entry === 'string' && entry.trim()) {
output.push(entry.trim());
}
});
}
}
collectStringsByKey(nested, targetKey, output);
}
}
function extractImageUrls(payload) {
const urls = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
}
function extractBase64Images(payload) {
const values = [];
collectStringsByKey(payload, 'b64_json', values);
return values;
}
async function fetchWithTimeout(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
}
return text;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function downloadImage(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`download ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(
`Generated image download timed out after ${timeoutMs}ms`,
);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function generateOne(env, template, outDir, size) {
const requestBody = {
model: 'gpt-image-2-all',
prompt: buildPrompt(template),
n: 1,
size,
};
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const payload = JSON.parse(payloadText);
const urls = extractImageUrls(payload);
const base64Images = extractBase64Images(payload);
const imageBytes = urls[0]
? await downloadImage(urls[0], env.timeoutMs)
: base64Images[0]
? Buffer.from(base64Images[0], 'base64')
: null;
if (!imageBytes) {
throw new Error(`VectorEngine returned no image for ${template.id}`);
}
mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, `${template.id}.png`);
writeFileSync(outPath, imageBytes);
return {
file: outPath,
source: urls[0] ? 'url' : 'b64_json',
};
}
const dryRun = args.has('--dry-run') || !args.has('--live');
const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
const size = String(args.get('--size') || '1024x1024');
const onlyIds = String(args.get('--only') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const templates = styleTemplates.filter(
(template) => !onlyIds.length || onlyIds.includes(template.id),
);
if (dryRun) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
outDir,
count: templates.length,
requests: templates.map((template) => ({
id: template.id,
title: template.title,
body: {
model: 'gpt-image-2-all',
prompt: buildPrompt(template),
n: 1,
size,
},
})),
},
null,
2,
),
);
process.exit(0);
}
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
);
process.exit(1);
}
const generated = [];
for (const template of templates) {
console.log(`Generating ${template.id}...`);
generated.push({
id: template.id,
...(await generateOne(env, template, outDir, size)),
});
}
console.log(
JSON.stringify(
{
ok: true,
count: generated.length,
files: generated,
},
null,
2,
),
);

View File

@@ -5,11 +5,14 @@ set -euo pipefail
SOURCE_BRANCH="${SOURCE_BRANCH:-master}"
COMMIT_HASH="${COMMIT_HASH:-}"
GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}"
SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}"
# Windows PowerShell 5.1 的 UTF-8 输出可能带 BOM下游参数校验前先剥离不可见字节。
SOURCE_BRANCH="$(printf "%s" "${SOURCE_BRANCH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
COMMIT_HASH="$(printf "%s" "${COMMIT_HASH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
GIT_REMOTE_URL="$(printf "%s" "${GIT_REMOTE_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
GIT_REMOTE_FALLBACK_URL="$(printf "%s" "${GIT_REMOTE_FALLBACK_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then
echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2
@@ -26,15 +29,59 @@ if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
exit 1
fi
if [[ -n "${GIT_REMOTE_URL}" ]]; then
git remote set-url origin "${GIT_REMOTE_URL}"
fi
GIT_REMOTE_CANDIDATES=()
add_git_remote_candidate() {
local candidate="$1"
local existing
if [[ -z "${candidate}" ]]; then
return
fi
for existing in "${GIT_REMOTE_CANDIDATES[@]}"; do
if [[ "${existing}" == "${candidate}" ]]; then
return
fi
done
GIT_REMOTE_CANDIDATES+=("${candidate}")
}
fetch_source_branch() {
local remote_url="$1"
if [[ -n "${remote_url}" ]]; then
git remote set-url origin "${remote_url}"
fi
echo "[jenkins-checkout-source] 尝试 Git 远端: ${remote_url:-origin}"
if [[ -z "${COMMIT_HASH}" ]]; then
git fetch --no-tags --prune --depth=1 origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
else
git fetch --no-tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
fi
}
add_git_remote_candidate "${GIT_REMOTE_URL}"
add_git_remote_candidate "${GIT_REMOTE_FALLBACK_URL}"
git reset --hard HEAD
git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
if [[ "${#GIT_REMOTE_CANDIDATES[@]}" -eq 0 ]]; then
fetch_source_branch ""
else
fetch_ok=0
for git_remote_candidate in "${GIT_REMOTE_CANDIDATES[@]}"; do
if fetch_source_branch "${git_remote_candidate}"; then
GIT_REMOTE_URL="${git_remote_candidate}"
fetch_ok=1
break
fi
echo "[jenkins-checkout-source] Git 远端拉取失败: ${git_remote_candidate}" >&2
done
if [[ "${fetch_ok}" -ne 1 ]]; then
echo "[jenkins-checkout-source] 所有 Git 远端均拉取失败。" >&2
exit 1
fi
fi
if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then
git fetch --unshallow --tags || true
if [[ -n "${COMMIT_HASH}" && "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then
git fetch --unshallow --no-tags origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" || true
fi
git cat-file -e "refs/remotes/origin/${SOURCE_BRANCH}^{commit}"
@@ -55,4 +102,4 @@ git reset --hard HEAD
git clean -fd
printf "%s\n" "${RESOLVED_COMMIT}" >"${SOURCE_COMMIT_FILE}"
echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT}"
echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT} remote=${GIT_REMOTE_URL:-origin}"

View File

@@ -9,6 +9,28 @@ require_path() {
fi
}
normalize_server_aliases() {
printf "%s" "${SERVER_ALIASES:-}" | tr ',' ' ' | xargs
}
validate_server_names() {
local alias_name
if [[ -z "${SERVER_NAME:-}" ]]; then
echo "[server-provision] SERVER_NAME 不能为空。" >&2
exit 1
fi
if [[ ! "${SERVER_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then
echo "[server-provision] SERVER_NAME 只能填写单个域名或 IP不能包含空格、路径或协议: ${SERVER_NAME}" >&2
exit 1
fi
for alias_name in $(normalize_server_aliases); do
if [[ ! "${alias_name}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then
echo "[server-provision] SERVER_ALIASES 只能填写域名或 IP多个用空格或逗号分隔: ${alias_name}" >&2
exit 1
fi
done
}
run_cmd() {
echo "+ $*"
if [[ "${DRY_RUN}" != "true" ]]; then
@@ -295,12 +317,67 @@ ensure_spacetime_owner_client_token() {
echo "[server-provision] 已同步 SpacetimeDB CLI 登录态;后续首次 publish 将使用同一 client identity。"
}
render_nginx_brotli_directives() {
if ! command -v nginx >/dev/null 2>&1; then
echo " # Brotli 未启用:目标服务器未找到 nginx 命令。"
return
fi
local brotli_snippet
brotli_snippet="$(mktemp)"
cat >"${brotli_snippet}" <<'EOF'
include /etc/nginx/modules-enabled/*.conf;
events {}
http {
brotli on;
brotli_comp_level 4;
brotli_min_length 1024;
brotli_types application/json;
}
EOF
if nginx -t -c "${brotli_snippet}" >/dev/null 2>&1; then
cat <<'EOF'
brotli on;
brotli_comp_level 4;
brotli_min_length 1024;
brotli_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
EOF
else
echo " # Brotli 未启用nginx -t 不接受 brotli 指令。"
fi
rm -f "${brotli_snippet}"
}
render_nginx_template() {
local template="$1"
local rendered_brotli server_names
rendered_brotli="$(render_nginx_brotli_directives)"
server_names="${SERVER_NAME}"
if [[ -n "${SERVER_ALIASES:-}" ]]; then
server_names="${server_names} $(normalize_server_aliases)"
fi
sed \
-e "s/server_name genarrative.example.com;/server_name ${server_names};/g" \
-e "s|/etc/letsencrypt/live/genarrative.example.com/|/etc/letsencrypt/live/${SERVER_NAME}/|g" \
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/r /dev/stdin" \
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/d" \
"${template}" <<<"${rendered_brotli}"
}
render_nginx_https_config() {
sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf
render_nginx_template deploy/nginx/genarrative.conf
}
render_nginx_development_http_config() {
sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative-dev-http.conf
render_nginx_template deploy/nginx/genarrative-dev-http.conf
}
render_api_env_example() {
@@ -454,6 +531,8 @@ require_path scripts/deploy/maintenance-on.sh
require_path scripts/deploy/maintenance-off.sh
require_path scripts/deploy/maintenance-status.sh
validate_server_names
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
run_cmd id

2
server-rs/Cargo.lock generated
View File

@@ -81,6 +81,7 @@ dependencies = [
"async-stream",
"axum",
"base64 0.22.1",
"bytes",
"dotenvy",
"futures-util",
"hmac",
@@ -109,6 +110,7 @@ dependencies = [
"platform-oss",
"platform-speech",
"reqwest 0.12.28",
"ring",
"serde",
"serde_json",
"sha2",

View File

@@ -93,6 +93,7 @@ langchainrust = "0.2.18"
log = "0.4"
rand_core = "0.6"
reqwest = { version = "0.12", default-features = false }
ring = "0.17"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"

View File

@@ -8,6 +8,7 @@ license.workspace = true
async-stream = { workspace = true }
axum = { workspace = true, features = ["ws"] }
base64 = { workspace = true }
bytes = { workspace = true }
dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
@@ -34,8 +35,10 @@ platform-auth = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
ring = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
shared-contracts = { workspace = true, features = ["oss-contracts"] }
shared-kernel = { workspace = true }
shared-logging = { workspace = true }
@@ -56,5 +59,4 @@ base64 = { workspace = true }
hmac = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
sha2 = { workspace = true }
tower = { workspace = true, features = ["util"] }

View File

@@ -776,7 +776,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_ai_tasks".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -33,7 +33,7 @@ use crate::{
},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
auth_sessions::{auth_sessions, revoke_auth_session},
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
@@ -90,10 +90,13 @@ use crate::{
match3d::{
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work,
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
generate_match3d_background_image_for_work, generate_match3d_cover_image,
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail,
get_match3d_works, list_match3d_gallery, persist_match3d_generated_model,
publish_match3d_work, put_match3d_audio_assets, put_match3d_work,
restart_match3d_run, start_match3d_run, stop_match3d_run,
stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry,
password_management::{change_password, reset_password},
@@ -176,6 +179,7 @@ use crate::{
wechat_auth::{
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
},
wechat_pay::handle_wechat_pay_notify,
};
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
@@ -328,6 +332,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/auth/sessions/{session_id}/revoke",
post(revoke_auth_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/me",
axum::routing::patch(update_profile_identity).route_layer(
@@ -957,6 +968,33 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/cover-image",
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/background-image",
post(generate_match3d_background_image_for_work).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/item-assets",
post(generate_match3d_item_assets_for_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/generated-models",
post(persist_match3d_generated_model).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/publish",
post(publish_match3d_work).route_layer(middleware::from_fn_with_state(
@@ -1373,6 +1411,10 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/wechat/notify",
post(handle_wechat_pay_notify),
)
.route(
"/api/profile/feedback",
post(submit_profile_feedback)
@@ -1891,10 +1933,12 @@ mod tests {
user: &module_auth::AuthUser,
session_id: &str,
) -> String {
let now = OffsetDateTime::now_utc();
let active_session_id = state.seed_test_refresh_session_for_user(user, session_id);
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
session_id: active_session_id,
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
@@ -1903,13 +1947,22 @@ mod tests {
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
now,
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn read_access_token(response_body: &[u8]) -> String {
let payload: Value =
serde_json::from_slice(response_body).expect("login payload should be json");
payload["token"]
.as_str()
.expect("access token should exist")
.to_string()
}
async fn password_login_request(
app: Router,
phone_number: &str,
@@ -1933,6 +1986,37 @@ mod tests {
.expect("password login request should succeed")
}
async fn password_login_request_with_client(
app: Router,
phone_number: &str,
password: &str,
client_instance_id: &str,
forwarded_for: &str,
) -> axum::response::Response {
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("x-client-instance-id", client_instance_id)
.header("x-forwarded-for", forwarded_for)
.body(Body::from(
serde_json::json!({
"phone": phone_number,
"password": password
})
.to_string(),
))
.expect("password login request should build"),
)
.await
.expect("password login request should succeed")
}
fn build_internal_creative_agent_app() -> Router {
let mut config = AppConfig::default();
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
@@ -2506,10 +2590,11 @@ mod tests {
let config = AppConfig::default();
let state = AppState::new(config.clone()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
let session_id = state.seed_test_refresh_session_for_user(&seed_user, "sess_auth_debug");
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: seed_user.id.clone(),
session_id: "sess_auth_debug".to_string(),
session_id: session_id.clone(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: seed_user.token_version,
@@ -2547,10 +2632,7 @@ mod tests {
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id));
assert_eq!(
payload["claims"]["sid"],
Value::String("sess_auth_debug".to_string())
);
assert_eq!(payload["claims"]["sid"], Value::String(session_id));
assert_eq!(
payload["claims"]["ver"],
Value::Number(serde_json::Number::from(seed_user.token_version))
@@ -4208,12 +4290,17 @@ mod tests {
session["clientType"] == Value::String("web_browser".to_string())
&& session["clientRuntime"] == Value::String("chrome".to_string())
&& session["clientPlatform"] == Value::String("windows".to_string())
&& session["sessionCount"] == Value::Number(1.into())
&& session["sessionIds"]
.as_array()
.is_some_and(|ids| ids.len() == 1)
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
&& session["isCurrent"] == Value::Bool(true)
}));
assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("mini_program".to_string())
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
&& session["sessionCount"] == Value::Number(1.into())
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
&& session["miniProgramEnv"] == Value::String("release".to_string())
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
@@ -4221,6 +4308,108 @@ mod tests {
}));
}
#[tokio::test]
async fn auth_sessions_groups_same_device_same_ip_and_marks_current_group() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
let app = build_router(state);
let login_body = serde_json::json!({
"phone": "13800138028",
"password": TEST_PASSWORD
})
.to_string();
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("x-client-instance-id", "same-device")
.header("x-forwarded-for", "203.0.113.10")
.body(Body::from(login_body.clone()))
.expect("first login request should build"),
)
.await
.expect("first login should succeed");
let first_cookie = first_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("first cookie should exist")
.to_string();
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let access_token = read_access_token(&first_body);
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("x-client-instance-id", "same-device")
.header("x-forwarded-for", "203.0.113.10")
.body(Body::from(login_body))
.expect("second login request should build"),
)
.await
.expect("second login should succeed");
let sessions_response = app
.oneshot(
Request::builder()
.uri("/api/auth/sessions")
.header("authorization", format!("Bearer {access_token}"))
.header("cookie", first_cookie)
.body(Body::empty())
.expect("sessions request should build"),
)
.await
.expect("sessions request should succeed");
assert_eq!(sessions_response.status(), StatusCode::OK);
let sessions_body = sessions_response
.into_body()
.collect()
.await
.expect("sessions body should collect")
.to_bytes();
let sessions_payload: Value =
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
let sessions = sessions_payload["sessions"]
.as_array()
.expect("sessions should be array");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["sessionCount"], Value::Number(2.into()));
assert_eq!(sessions[0]["isCurrent"], Value::Bool(true));
assert_eq!(
sessions[0]["ipMasked"],
Value::String("203.0.*.*".to_string())
);
assert_eq!(
sessions[0]["sessionIds"]
.as_array()
.expect("session ids should exist")
.len(),
2
);
}
#[tokio::test]
async fn password_entry_reuses_same_user_for_same_phone() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -4332,9 +4521,23 @@ mod tests {
#[tokio::test]
async fn password_change_allows_login_with_new_password_only() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_password_change");
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
let refresh_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let token = read_access_token(&login_body);
let change_response = app
.clone()
@@ -4356,6 +4559,40 @@ mod tests {
.await
.expect("change password request should succeed");
assert_eq!(change_response.status(), StatusCode::OK);
assert!(
change_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
let old_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("me request should build"),
)
.await
.expect("me request should succeed");
assert_eq!(old_me_response.status(), StatusCode::UNAUTHORIZED);
let old_refresh_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", refresh_cookie)
.body(Body::empty())
.expect("refresh request should build"),
)
.await
.expect("refresh request should succeed");
assert_eq!(old_refresh_response.status(), StatusCode::UNAUTHORIZED);
let old_password_response =
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
@@ -4399,23 +4636,16 @@ mod tests {
};
let state = AppState::new(config).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: seed_user.id.clone(),
session_id: "sess_me_query".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: seed_user.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(seed_user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138016", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let token = read_access_token(&login_body);
let response = app
.oneshot(
@@ -4576,6 +4806,141 @@ mod tests {
);
}
#[tokio::test]
async fn revoke_auth_session_revokes_remote_session_without_token_version_bump() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138030", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = password_login_request_with_client(
app.clone(),
"13800138030",
TEST_PASSWORD,
"revoke-current-device",
"203.0.113.30",
)
.await;
let first_cookie = first_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("first cookie should exist")
.to_string();
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let first_access_token = read_access_token(&first_body);
let second_login_response = password_login_request_with_client(
app.clone(),
"13800138030",
TEST_PASSWORD,
"revoke-remote-device",
"203.0.113.31",
)
.await;
let second_cookie = second_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("second cookie should exist")
.to_string();
let second_body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let second_access_token = read_access_token(&second_body);
let remote_sessions_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/sessions")
.header("authorization", format!("Bearer {first_access_token}"))
.header("cookie", first_cookie.clone())
.body(Body::empty())
.expect("sessions request should build"),
)
.await
.expect("sessions request should succeed");
assert_eq!(remote_sessions_response.status(), StatusCode::OK);
let remote_sessions_body = remote_sessions_response
.into_body()
.collect()
.await
.expect("sessions body should collect")
.to_bytes();
let remote_sessions_payload: Value =
serde_json::from_slice(&remote_sessions_body).expect("sessions payload should be json");
let remote_session_id = remote_sessions_payload["sessions"]
.as_array()
.expect("sessions should be array")
.iter()
.find(|session| session["isCurrent"] == Value::Bool(false))
.and_then(|session| session["sessionId"].as_str())
.expect("remote session id should exist")
.to_string();
let revoke_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/auth/sessions/{remote_session_id}/revoke"))
.header("authorization", format!("Bearer {first_access_token}"))
.header("cookie", first_cookie)
.body(Body::empty())
.expect("revoke request should build"),
)
.await
.expect("revoke request should succeed");
assert_eq!(revoke_response.status(), StatusCode::OK);
let current_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {first_access_token}"))
.body(Body::empty())
.expect("current me request should build"),
)
.await
.expect("current me request should succeed");
assert_eq!(current_me_response.status(), StatusCode::OK);
let remote_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {second_access_token}"))
.body(Body::empty())
.expect("remote me request should build"),
)
.await
.expect("remote me request should succeed");
assert_eq!(remote_me_response.status(), StatusCode::UNAUTHORIZED);
let remote_refresh_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", second_cookie)
.body(Body::empty())
.expect("remote refresh request should build"),
)
.await
.expect("remote refresh request should succeed");
assert_eq!(remote_refresh_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_clears_cookie_and_invalidates_current_access_token() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -4658,6 +5023,12 @@ mod tests {
let login_response =
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
let refresh_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let login_body = login_response
.into_body()
.collect()
@@ -4672,6 +5043,7 @@ mod tests {
.to_string();
let logout_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
@@ -4691,6 +5063,19 @@ mod tests {
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
let refresh_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", refresh_cookie)
.body(Body::empty())
.expect("refresh request should build"),
)
.await
.expect("refresh request should succeed");
assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]

View File

@@ -36,10 +36,12 @@ use crate::{
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 9] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
"match3d_cover_image",
"match3d_item_image",
"square_hole_cover_image",
"square_hole_background_image",
"square_hole_shape_image",
@@ -765,6 +767,8 @@ mod tests {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(super::is_supported_asset_history_kind("match3d_cover_image"));
assert!(super::is_supported_asset_history_kind("match3d_item_image"));
assert!(super::is_supported_asset_history_kind(
"square_hole_cover_image"
));
@@ -786,7 +790,7 @@ mod tests {
fn asset_history_kind_message_lists_all_supported_kinds() {
assert_eq!(
super::supported_asset_history_kind_message(),
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、match3d_cover_image、match3d_item_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
);
}

View File

@@ -117,6 +117,34 @@ pub async fn require_bearer_auth(
.with_message("当前登录态已失效,请重新登录"));
}
let session_is_active = state
.refresh_session_service()
.is_session_active_for_user(
claims.user_id(),
claims.session_id(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
warn!(
%request_id,
user_id = %claims.user_id(),
session_id = %claims.session_id(),
error = %error,
"Bearer JWT refresh session 状态读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
if !session_is_active {
warn!(
%request_id,
user_id = %claims.user_id(),
session_id = %claims.session_id(),
"Bearer JWT 对应 refresh session 已失效"
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"));
}
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));

View File

@@ -1,10 +1,15 @@
use std::collections::HashMap;
use axum::{
Json,
extract::{Extension, State},
extract::{Extension, Path, State},
http::StatusCode,
};
use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput};
use platform_auth::hash_refresh_session_token;
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
use shared_contracts::auth::{
AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse,
};
use time::OffsetDateTime;
use crate::{
@@ -37,41 +42,189 @@ pub async fn auth_sessions(
.refresh_session_service()
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
.map_err(map_refresh_session_list_error)?;
let current_session_id = authenticated.claims().session_id().to_string();
let session_groups = group_sessions_by_device_and_ip(sessions.sessions);
Ok(json_success_body(
Some(&request_context),
AuthSessionsResponse {
sessions: sessions
.sessions
sessions: session_groups
.into_iter()
.map(|session| {
let is_current = current_refresh_token_hash
.as_ref()
.is_some_and(|hash| session.refresh_token_hash == *hash);
let client_label = session.client_info.device_display_name.clone();
AuthSessionSummaryPayload {
session_id: session.session_id,
client_type: session.client_info.client_type,
client_runtime: session.client_info.client_runtime,
client_platform: session.client_info.client_platform,
client_label,
device_display_name: session.client_info.device_display_name,
mini_program_app_id: session.client_info.mini_program_app_id,
mini_program_env: session.client_info.mini_program_env,
user_agent: session.client_info.user_agent,
ip_masked: mask_ip(session.client_info.ip.as_deref()),
is_current,
created_at: session.created_at,
last_seen_at: session.last_seen_at,
expires_at: session.expires_at,
}
.map(|group| {
build_session_summary(
group,
current_refresh_token_hash.as_deref(),
&current_session_id,
)
})
.collect(),
},
))
}
pub async fn revoke_auth_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(session_id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let session_id = session_id.trim().to_string();
if session_id.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少会话 ID"));
}
if session_id == authenticated.claims().session_id() {
return Err(
AppError::from_status(StatusCode::CONFLICT).with_message("当前设备请使用退出登录")
);
}
let revoke_result = state
.refresh_session_service()
.revoke_session_by_user_and_session(
RevokeRefreshSessionByUserInput {
user_id: authenticated.claims().user_id().to_string(),
session_id,
},
OffsetDateTime::now_utc(),
)
.map_err(map_refresh_session_revoke_error)?;
if !revoke_result.revoked {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("会话不存在或已失效")
);
}
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
Ok(json_success_body(
Some(&request_context),
RevokeAuthSessionResponse { ok: true },
))
}
fn group_sessions_by_device_and_ip(
sessions: Vec<RefreshSessionRecord>,
) -> Vec<Vec<RefreshSessionRecord>> {
let mut grouped = HashMap::<String, Vec<RefreshSessionRecord>>::new();
for session in sessions {
grouped
.entry(build_session_group_key(&session))
.or_default()
.push(session);
}
let mut groups = grouped.into_values().collect::<Vec<_>>();
for group in &mut groups {
group.sort_by(|left, right| {
right
.last_seen_at
.cmp(&left.last_seen_at)
.then_with(|| right.created_at.cmp(&left.created_at))
});
}
groups.sort_by(|left, right| {
group_latest_last_seen(right)
.cmp(group_latest_last_seen(left))
.then_with(|| group_earliest_created(left).cmp(group_earliest_created(right)))
});
groups
}
fn build_session_group_key(session: &RefreshSessionRecord) -> String {
let client_info = &session.client_info;
let device_key = client_info.device_fingerprint.as_deref().unwrap_or("");
if !device_key.is_empty() {
return format!("{}|{}", device_key, client_info.ip.as_deref().unwrap_or(""));
}
format!(
"{}|{}|{}|{}|{}|{}",
client_info.client_type,
client_info.client_runtime,
client_info.client_platform,
client_info.device_display_name,
client_info.user_agent.as_deref().unwrap_or(""),
client_info.ip.as_deref().unwrap_or("")
)
}
fn build_session_summary(
group: Vec<RefreshSessionRecord>,
current_refresh_token_hash: Option<&str>,
current_session_id: &str,
) -> AuthSessionSummaryPayload {
let is_current = group.iter().any(|session| {
session.session_id == current_session_id
|| current_refresh_token_hash.is_some_and(|hash| session.refresh_token_hash == hash)
});
let representative = group
.iter()
.find(|session| is_current && session.session_id == current_session_id)
.or_else(|| {
group.iter().find(|session| {
is_current
&& current_refresh_token_hash
.is_some_and(|hash| session.refresh_token_hash == hash)
})
})
.unwrap_or_else(|| group.first().expect("session group should not be empty"));
let client_label = representative.client_info.device_display_name.clone();
let session_ids = group
.iter()
.map(|session| session.session_id.clone())
.collect::<Vec<_>>();
let session_count = u32::try_from(session_ids.len()).unwrap_or(u32::MAX);
AuthSessionSummaryPayload {
session_id: representative.session_id.clone(),
session_ids,
session_count,
client_type: representative.client_info.client_type.clone(),
client_runtime: representative.client_info.client_runtime.clone(),
client_platform: representative.client_info.client_platform.clone(),
client_label,
device_display_name: representative.client_info.device_display_name.clone(),
mini_program_app_id: representative.client_info.mini_program_app_id.clone(),
mini_program_env: representative.client_info.mini_program_env.clone(),
user_agent: representative.client_info.user_agent.clone(),
ip_masked: mask_ip(representative.client_info.ip.as_deref()),
is_current,
created_at: group_earliest_created(&group).to_string(),
last_seen_at: group_latest_last_seen(&group).to_string(),
expires_at: group_latest_expires_at(&group).to_string(),
}
}
fn group_latest_last_seen(group: &[RefreshSessionRecord]) -> &str {
group
.iter()
.map(|session| session.last_seen_at.as_str())
.max()
.unwrap_or("")
}
fn group_earliest_created(group: &[RefreshSessionRecord]) -> &str {
group
.iter()
.map(|session| session.created_at.as_str())
.min()
.unwrap_or("")
}
fn group_latest_expires_at(group: &[RefreshSessionRecord]) -> &str {
group
.iter()
.map(|session| session.expires_at.as_str())
.max()
.unwrap_or("")
}
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
match error {
module_auth::RefreshSessionError::UserNotFound => {
@@ -88,3 +241,19 @@ fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> Ap
}
}
}
fn map_refresh_session_revoke_error(error: module_auth::RefreshSessionError) -> AppError {
match error {
module_auth::RefreshSessionError::MissingToken
| module_auth::RefreshSessionError::SessionNotFound => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
module_auth::RefreshSessionError::SessionExpired
| module_auth::RefreshSessionError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
module_auth::RefreshSessionError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}

View File

@@ -71,6 +71,18 @@ pub struct AppConfig {
pub wechat_mock_union_id: Option<String>,
pub wechat_mock_display_name: String,
pub wechat_mock_avatar_url: Option<String>,
pub wechat_pay_enabled: bool,
pub wechat_pay_provider: String,
pub wechat_pay_mch_id: Option<String>,
pub wechat_pay_merchant_serial_no: Option<String>,
pub wechat_pay_private_key_pem: Option<String>,
pub wechat_pay_private_key_path: Option<PathBuf>,
pub wechat_pay_platform_public_key_pem: Option<String>,
pub wechat_pay_platform_public_key_path: Option<PathBuf>,
pub wechat_pay_platform_serial_no: Option<String>,
pub wechat_pay_api_v3_key: Option<String>,
pub wechat_pay_notify_url: Option<String>,
pub wechat_pay_jsapi_endpoint: String,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
@@ -189,6 +201,19 @@ impl Default for AppConfig {
wechat_mock_union_id: Some("wx-mock-union".to_string()),
wechat_mock_display_name: "微信旅人".to_string(),
wechat_mock_avatar_url: None,
wechat_pay_enabled: false,
wechat_pay_provider: "mock".to_string(),
wechat_pay_mch_id: None,
wechat_pay_merchant_serial_no: None,
wechat_pay_private_key_pem: None,
wechat_pay_private_key_path: None,
wechat_pay_platform_public_key_pem: None,
wechat_pay_platform_public_key_path: None,
wechat_pay_platform_serial_no: None,
wechat_pay_api_v3_key: None,
wechat_pay_notify_url: None,
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
.to_string(),
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
@@ -458,6 +483,33 @@ impl AppConfig {
}
config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]);
if let Some(wechat_pay_enabled) = read_first_bool_env(&["WECHAT_PAY_ENABLED"]) {
config.wechat_pay_enabled = wechat_pay_enabled;
}
if let Some(wechat_pay_provider) = read_first_non_empty_env(&["WECHAT_PAY_PROVIDER"]) {
config.wechat_pay_provider = wechat_pay_provider;
}
config.wechat_pay_mch_id = read_first_non_empty_env(&["WECHAT_PAY_MCH_ID"]);
config.wechat_pay_merchant_serial_no =
read_first_non_empty_env(&["WECHAT_PAY_MERCHANT_SERIAL_NO"]);
config.wechat_pay_private_key_pem =
read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PEM"]);
config.wechat_pay_private_key_path =
read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PATH"]).map(PathBuf::from);
config.wechat_pay_platform_public_key_pem =
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM"]);
config.wechat_pay_platform_public_key_path =
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"]).map(PathBuf::from);
config.wechat_pay_platform_serial_no =
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_SERIAL_NO"]);
config.wechat_pay_api_v3_key = read_first_non_empty_env(&["WECHAT_PAY_API_V3_KEY"]);
config.wechat_pay_notify_url = read_first_non_empty_env(&["WECHAT_PAY_NOTIFY_URL"]);
if let Some(wechat_pay_jsapi_endpoint) =
read_first_non_empty_env(&["WECHAT_PAY_JSAPI_ENDPOINT"])
{
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
}
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
@@ -1081,6 +1133,74 @@ mod tests {
}
}
#[test]
fn from_env_reads_wechat_pay_settings() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("WECHAT_PAY_ENABLED");
std::env::remove_var("WECHAT_PAY_PROVIDER");
std::env::remove_var("WECHAT_PAY_MCH_ID");
std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH");
std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH");
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
std::env::set_var("WECHAT_PAY_ENABLED", "true");
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
std::env::set_var("WECHAT_PAY_MERCHANT_SERIAL_NO", "serial-001");
std::env::set_var("WECHAT_PAY_PRIVATE_KEY_PATH", "certs/apiclient_key.pem");
std::env::set_var(
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH",
"certs/wechatpay_platform.pem",
);
std::env::set_var("WECHAT_PAY_PLATFORM_SERIAL_NO", "platform-serial-001");
std::env::set_var("WECHAT_PAY_API_V3_KEY", "12345678901234567890123456789012");
std::env::set_var(
"WECHAT_PAY_NOTIFY_URL",
"https://api.example.com/api/profile/recharge/wechat/notify",
);
}
let config = AppConfig::from_env();
assert!(config.wechat_pay_enabled);
assert_eq!(config.wechat_pay_provider, "real");
assert_eq!(config.wechat_pay_mch_id.as_deref(), Some("1900000109"));
assert_eq!(
config.wechat_pay_private_key_path.as_deref(),
Some(std::path::Path::new("certs/apiclient_key.pem"))
);
assert_eq!(
config.wechat_pay_notify_url.as_deref(),
Some("https://api.example.com/api/profile/recharge/wechat/notify")
);
assert_eq!(
config.wechat_pay_platform_public_key_path.as_deref(),
Some(std::path::Path::new("certs/wechatpay_platform.pem"))
);
assert_eq!(
config.wechat_pay_platform_serial_no.as_deref(),
Some("platform-serial-001")
);
unsafe {
std::env::remove_var("WECHAT_PAY_ENABLED");
std::env::remove_var("WECHAT_PAY_PROVIDER");
std::env::remove_var("WECHAT_PAY_MCH_ID");
std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH");
std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH");
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
}
}
#[test]
fn from_env_ignores_zero_spacetime_pool_size() {
let _guard = ENV_LOCK

View File

@@ -375,14 +375,15 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id,
session_id: "sess_creation_doc_input".to_string(),
user_id: user.id.clone(),
session_id: state
.seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name),
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),

View File

@@ -333,7 +333,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_llm_proxy".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_llm_proxy"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -40,6 +40,7 @@ pub async fn logout(
LogoutCurrentSessionInput {
user_id: authenticated.claims().user_id().to_string(),
refresh_token_hash,
session_id: Some(authenticated.claims().session_id().to_string()),
},
OffsetDateTime::now_utc(),
)

View File

@@ -75,6 +75,7 @@ mod vector_engine_audio_generation;
mod visual_novel;
mod volcengine_speech;
mod wechat_auth;
mod wechat_pay;
mod wechat_provider;
mod work_author;
mod work_play_tracking;

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,8 @@ use crate::{
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
@@ -30,14 +31,17 @@ pub async fn change_password(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PasswordChangeRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
) -> Result<impl IntoResponse, AppError> {
let result = state
.password_entry_service()
.change_password(ChangePasswordInput {
user_id: authenticated.claims().user_id().to_string(),
current_password: payload.current_password,
new_password: payload.new_password,
})
.change_password_and_revoke_all_sessions(
ChangePasswordInput {
user_id: authenticated.claims().user_id().to_string(),
current_password: payload.current_password,
new_password: payload.new_password,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_password_management_error)?;
state
@@ -48,11 +52,20 @@ pub async fn change_password(
.with_message(format!("同步认证快照失败:{error}"))
})?;
Ok(json_success_body(
Some(&request_context),
PasswordChangeResponse {
user: map_auth_user_payload(result.user),
},
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_clear_refresh_session_cookie_header(&state)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PasswordChangeResponse {
user: map_auth_user_payload(result.user),
},
),
))
}

View File

@@ -224,11 +224,23 @@ pub(crate) fn build_visual_novel_creation_user_prompt(
"currentDraft": params.current_draft,
"recentMessages": params.recent_messages,
"nowIso": params.now_iso,
"oneLineGenerationFlow": [
"提取一句话核心创意、故事类型、玩家身份和视觉画风",
"扩展世界观、故事前提、文学风格和默认叙事语气",
"设计 3 到 6 个角色,并为每个角色写出可生成立绘的 appearance",
"设计 3 到 8 个场景,并为 opening 场景写出可生成背景图的 description",
"组织 3 到 6 个剧情阶段,第一阶段必须能从 opening 进入",
"生成 opening.narration、可选 firstDialogue 和 2 到 4 个 initialChoices",
"图片、音乐可先为 null但文字草稿必须可进入结果页编辑、保存并试玩"
],
"draftRequirements": {
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
"scenes": "3 到 8 个,至少 1 个 opening 场景",
"storyPhases": "3 到 6 个,第一阶段可从 opening 进入",
"initialChoices": "2 到 4 个",
"initialChoices": "2 到 4 个 initialChoices",
"openingScene": "opening.sceneId 必须指向存在且 availability 为 opening 的 scene",
"firstPhase": "storyPhases[0] 必须包含 opening scene 和主要角色",
"assetFallback": "图片、音乐可先为 null但 appearance 和 scene description 必须足够后续生成资产",
"runtimeConfigDefaults": "沿用契约默认值attributePanelMode 默认为 off"
},
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
@@ -616,6 +628,29 @@ mod tests {
assert!(repair_prompt.contains("scene_change"));
}
#[test]
fn creation_prompt_guides_one_line_flow_into_playable_draft() {
let asset_ids = source_asset_ids();
let prompt = build_visual_novel_creation_user_prompt(VisualNovelCreationPromptParams {
source_mode: "idea",
seed_text: Some(
"雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。",
),
source_asset_ids: asset_ids.as_slice(),
document_summary: None,
current_draft: None,
recent_messages: &[],
now_iso: "2026-05-13T12:00:00Z",
});
assert!(prompt.contains("oneLineGenerationFlow"));
assert!(prompt.contains("提取一句话核心创意"));
assert!(prompt.contains("视觉画风"));
assert!(prompt.contains("opening.sceneId"));
assert!(prompt.contains("2 到 4 个 initialChoices"));
assert!(prompt.contains("图片、音乐可先为 null"));
}
#[test]
fn llm_requests_use_responses_template_model() {
let asset_ids = source_asset_ids();

View File

@@ -64,9 +64,10 @@ use spacetime_client::{
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
@@ -80,7 +81,10 @@ use crate::{
auth::AuthenticatedAccessToken,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
openai_image_generation::VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
openai_image_generation::{
DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
prompt::puzzle::{
draft::{
@@ -100,6 +104,9 @@ use crate::{
},
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
},
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -119,6 +126,10 @@ const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
const PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH: &str =
"public/ui-previews/puzzle-image-compact-ui-2026-05-08.png";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
@@ -229,6 +240,9 @@ pub async fn generate_puzzle_onboarding_work(
level_name: level_name.clone(),
picture_description: prompt_text.clone(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates,
selected_candidate_id: Some(selected.candidate_id.clone()),
@@ -991,6 +1005,117 @@ pub async fn execute_puzzle_agent_action(
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
@@ -2061,6 +2186,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
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_record_response),
@@ -2379,6 +2507,10 @@ fn map_puzzle_runtime_level_response(
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
ui_background_image_src: level.ui_background_image_src,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
@@ -2669,6 +2801,9 @@ fn parse_puzzle_level_records_from_module_json(
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),
@@ -2839,6 +2974,9 @@ fn serialize_puzzle_levels_response(
"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
@@ -2888,6 +3026,9 @@ fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result<Option
"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
@@ -3178,6 +3319,10 @@ fn build_puzzle_levels_with_primary_update(
.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())
@@ -3188,6 +3333,184 @@ fn build_puzzle_levels_with_primary_update(
levels
}
fn resolve_puzzle_background_music_title(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> String {
let work_title = draft.work_title.trim();
if !work_title.is_empty() {
return work_title.to_string();
}
target_level.level_name.trim().to_string()
}
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::<Vec<_>>()
.join("");
[
title,
draft.work_description.trim(),
target_level.picture_description.trim(),
tags.as_str(),
"移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰",
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.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 竖屏拼图游戏 UI 背景图,中央必须预留清晰正方形拼图区,拼图区与外部 UI 背景必须有明确边界、描边或容器层次;拼图区之外可以生成与作品名称相关的氛围背景、顶部安全区和底部工具区背景,但不要画文字、按钮文字、数字、拼图碎片、完整拼图图像、教程浮层或水印。"
)
}
fn attach_puzzle_level_background_music(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
music: CreationAudioAsset,
) {
let Some(index) = levels
.iter()
.position(|level| level.level_id == level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
else {
return;
};
levels[index].background_music = Some(PuzzleAudioAssetRecord {
task_id: music.task_id,
provider: music.provider,
asset_object_id: music.asset_object_id,
asset_kind: music.asset_kind,
audio_src: music.audio_src,
prompt: music.prompt,
title: music.title,
updated_at: music.updated_at,
});
}
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 try_generate_puzzle_background_music(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
title: &str,
) -> Option<CreationAudioAsset> {
let normalized_title = title.trim();
if normalized_title.is_empty() {
return None;
}
match 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
{
Ok(music) => Some(music),
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
profile_id,
error = %error,
"拼图草稿背景音乐生成失败,保留草稿并允许结果页重试"
);
None
}
}
}
async fn try_generate_puzzle_initial_ui_background(
state: &AppState,
owner_user_id: &str,
session_id: &str,
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> Option<(String, GeneratedPuzzleUiBackgroundResponse)> {
let prompt = normalize_puzzle_ui_background_prompt("", draft, target_level);
match generate_puzzle_ui_background_image(
state,
owner_user_id,
session_id,
target_level.level_name.as_str(),
prompt.as_str(),
)
.await
{
Ok(generated) => Some((prompt, generated)),
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
level_id = %target_level.level_id,
error = %error,
"拼图草稿 UI 背景图自动生成失败,保留草稿并允许结果页重试"
);
None
}
}
}
async fn compile_puzzle_draft_with_initial_cover(
state: &AppState,
session_id: String,
@@ -3253,9 +3576,43 @@ async fn compile_puzzle_draft_with_initial_cover(
target_level.level_name = refined_level_name;
}
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, reference_image_src),
)?);
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
ui_prompt,
ui_background,
);
}
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
@@ -3274,7 +3631,7 @@ async fn compile_puzzle_draft_with_initial_cover(
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,
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
})
@@ -3292,11 +3649,15 @@ async fn compile_puzzle_draft_with_initial_cover(
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let session = apply_generated_puzzle_candidates_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(),
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(),
@@ -3383,7 +3744,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
&target_level.picture_description,
&draft.summary,
);
// 中文注释:关闭 AI 重绘时不请求 VectorEngine,也不进入光点扣费流程;上传图直接成为首关正式图候选。
// 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine上传图直接成为首关正式图候选。
let candidate_id = format!(
"{}-candidate-{}",
compiled_session.session_id,
@@ -3404,9 +3765,43 @@ async fn compile_puzzle_draft_with_uploaded_cover(
target_level.level_name = refined_level_name;
}
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, reference_image_src),
)?);
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
ui_prompt,
ui_background,
);
}
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let persisted_upload = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
@@ -3442,7 +3837,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
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,
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
})
@@ -3459,11 +3854,15 @@ async fn compile_puzzle_draft_with_uploaded_cover(
"拼图上传图草稿回写不可用,降级返回本地快照"
);
let session = apply_generated_puzzle_candidates_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(),
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(),
@@ -3556,6 +3955,23 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
session
}
fn apply_generated_puzzle_levels_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
levels: Vec<PuzzleDraftLevelRecord>,
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,
@@ -3611,6 +4027,38 @@ fn replace_puzzle_session_draft_snapshot(
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<String>,
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
}
async fn generate_puzzle_work_tags(
state: &AppState,
work_title: &str,
@@ -3881,6 +4329,9 @@ fn serialize_puzzle_level_records_for_module(
"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_record_module_json(&level.background_music),
"candidates": level
.candidates
@@ -4309,6 +4760,72 @@ async fn generate_puzzle_image_candidates(
Ok(items)
}
async fn generate_puzzle_ui_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_puzzle_ui_background_reference_data_url().await?;
let generated = create_openai_image_generation(
&http_client,
&settings,
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、角色手指、模糊边界"),
"9:16",
1,
&[reference_image],
"拼图 UI 背景图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 UI 背景图生成失败:未返回图片",
}))
})?;
persist_puzzle_ui_background_image(
state,
owner_user_id,
session_id,
level_name,
generated.task_id.as_str(),
image,
)
.await
}
#[cfg(test)]
fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt: &str) -> String {
build_puzzle_ui_background_generation_prompt(level_name, prompt)
}
async fn load_puzzle_ui_background_reference_data_url() -> Result<String, AppError> {
let bytes = tokio::fs::read(PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH)
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("读取拼图 UI 背景参考图失败:{error}"),
}))
})?;
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图 UI 背景参考图为空",
})),
);
}
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(bytes)
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4587,6 +5104,9 @@ mod tests {
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
@@ -4638,6 +5158,98 @@ mod tests {
);
}
#[test]
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
ui_background_image_src: Some(
"/generated-puzzle-assets/session/ui/background.png".to_string(),
),
ui_background_image_object_key: Some(
"generated-puzzle-assets/session/ui/background.png".to_string(),
),
background_music: None,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
};
let request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["ui_background_prompt"],
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
);
assert!(payload[0].get("uiBackgroundPrompt").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
assert_eq!(
records[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response.ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
#[test]
fn puzzle_ui_background_prompt_keeps_square_boundary_constraint() {
let prompt =
build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
assert!(prompt.contains("9:16"));
assert!(prompt.contains("中央必须预留清晰正方形拼图区"));
assert!(prompt.contains("明确边界"));
assert!(prompt.contains("不要画文字"));
}
#[test]
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
let draft = test_puzzle_draft_record();
let generated = GeneratedPuzzleUiBackgroundResponse {
image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(),
object_key: "generated-puzzle-assets/session/ui/background.png".to_string(),
};
let mut levels = draft.levels.clone();
attach_puzzle_level_ui_background(
&mut levels,
"puzzle-level-1",
"雨夜猫街移动端拼图UI背景".to_string(),
generated,
);
assert_eq!(
levels[0].ui_background_prompt.as_deref(),
Some("雨夜猫街移动端拼图UI背景")
);
assert_eq!(
levels[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
assert_eq!(
levels[0].ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
let item = PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
@@ -4676,6 +5288,9 @@ mod tests {
level_name: "猫画面".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: vec![],
selected_candidate_id: None,
@@ -4849,6 +5464,11 @@ struct GeneratedPuzzleAssetResponse {
asset_id: String,
}
struct GeneratedPuzzleUiBackgroundResponse {
image_src: String,
object_key: String,
}
fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
match value.map(str::trim).filter(|value| !value.is_empty()) {
Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => {
@@ -5477,6 +6097,47 @@ async fn persist_puzzle_generated_asset(
})
}
async fn persist_puzzle_ui_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
task_id: &str,
image: DownloadedOpenAiImage,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::PuzzleAssets,
path_segments: vec![
sanitize_path_segment(session_id, "session"),
sanitize_path_segment(level_name, "puzzle"),
"ui-background".to_string(),
sanitize_path_segment(task_id, "task"),
],
file_name: format!("background.{}", image.extension),
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_puzzle_ui_background_asset_metadata(owner_user_id, session_id),
body: image.bytes,
},
)
.await
.map_err(map_puzzle_asset_oss_error)?;
Ok(GeneratedPuzzleUiBackgroundResponse {
image_src: put_result.legacy_public_path,
object_key: put_result.object_key,
})
}
fn handle_puzzle_asset_spacetime_index_error(
error: SpacetimeClientError,
owner_user_id: &str,
@@ -5515,6 +6176,22 @@ fn build_puzzle_asset_metadata(
])
}
fn build_puzzle_ui_background_asset_metadata(
owner_user_id: &str,
session_id: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
(
"asset_kind".to_string(),
"puzzle_ui_background_image".to_string(),
),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
("entity_id".to_string(), session_id.to_string()),
("slot".to_string(), "ui_background".to_string()),
])
}
fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({

View File

@@ -374,7 +374,10 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_browse_history".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_browse_history",
),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -174,7 +174,10 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_inventory".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_inventory",
),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -6,15 +6,16 @@ use axum::{
};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot,
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord,
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord,
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind,
};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -56,8 +57,13 @@ use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
http_error::AppError, request_context::RequestContext, state::AppState,
admin::AuthenticatedAdmin,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error},
};
pub async fn get_profile_dashboard(
@@ -186,14 +192,15 @@ pub async fn create_profile_recharge_order(
let payment_channel = payload
.payment_channel
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let payment_channel = payment_channel.trim().to_string();
let created_at_micros = current_unix_micros();
let (center, order) = state
.spacetime_client()
.create_profile_recharge_order(
user_id,
payload.product_id,
payment_channel,
created_at_micros as i64,
payment_channel.clone(),
created_at_micros,
)
.await
.map_err(|error| {
@@ -203,11 +210,36 @@ pub async fn create_profile_recharge_order(
)
})?;
let wechat_mini_program_pay_params = if payment_channel
== PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
{
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(
state
.wechat_pay_client()
.create_mini_program_order(build_wechat_payment_request(
order.order_id.clone(),
order.product_title.clone(),
order.amount_cents,
identity,
))
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
)
} else {
None
};
Ok(json_success_body(
Some(&request_context),
CreateProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
wechat_mini_program_pay_params,
},
))
}
@@ -750,6 +782,25 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context))
}
async fn resolve_wechat_identity_for_payment(
state: &AppState,
user_id: &str,
) -> Result<String, AppError> {
if let Some(identity) = state
.wechat_auth_service()
.get_identity_by_user_id(user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信身份失败:{error}"))
})?
{
return Ok(identity.provider_uid);
}
Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
}
fn build_profile_recharge_center_response(
record: RuntimeProfileRechargeCenterRecord,
) -> ProfileRechargeCenterResponse {
@@ -825,6 +876,7 @@ fn build_profile_recharge_order_response(
status: record.status.as_str().to_string(),
payment_channel: record.payment_channel,
paid_at: record.paid_at,
provider_transaction_id: record.provider_transaction_id,
created_at: record.created_at,
points_delta: record.points_delta,
membership_expires_at: record.membership_expires_at,
@@ -1568,7 +1620,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_profile".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -575,7 +575,8 @@ mod tests {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_save".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

Some files were not shown because too many files have changed in this diff Show More