Compare commits
23 Commits
hermes/her
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b96265c50 | |||
| 2277b37888 | |||
| be53a90f77 | |||
| bcd7617fb7 | |||
| 49468441bc | |||
| a92dc2b7b0 | |||
| 4fecf9c975 | |||
| b13870f71b | |||
| e4a8bd42bb | |||
| 01c5ab985a | |||
| ac12f1ed5e | |||
| e36a562098 | |||
| 36e134e323 | |||
| 26139f80d3 | |||
| 9b72dbb3ea | |||
| 188c6704db | |||
| d641840098 | |||
| aec9142481 | |||
| d41f260a2a | |||
| cf074837a4 | |||
| ed7a6f48d0 | |||
| ce98a29c4d | |||
| 9baa515a75 |
5
.env
Normal file
@@ -0,0 +1,5 @@
|
||||
# 微信小程序 web-view 登录配置。
|
||||
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
|
||||
WECHAT_MINI_PROGRAM_APP_ID=""
|
||||
WECHAT_MINI_PROGRAM_APP_SECRET=""
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT=""
|
||||
@@ -103,6 +103,9 @@ WECHAT_REDIRECT_PATH="/"
|
||||
WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect"
|
||||
WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo"
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
|
||||
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
|
||||
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
|
||||
WECHAT_STATE_TTL_MINUTES="15"
|
||||
WECHAT_MOCK_USER_ID="wx-mock-user"
|
||||
WECHAT_MOCK_UNION_ID="wx-mock-union"
|
||||
|
||||
44
.github/workflows/ci.yml
vendored
@@ -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
|
||||
5
.gitignore
vendored
@@ -35,3 +35,8 @@ temp*build*/
|
||||
|
||||
# Local load-test data extracted from private migration files
|
||||
scripts/loadtest/data/*.local.json
|
||||
|
||||
# Local load-test run artifacts
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# 远端作品列表压测排查报告
|
||||
|
||||
时间:2026-05-12 06:16 CST
|
||||
目标:`http://82.157.175.59`
|
||||
SSH:远端生产机 root 账号(具体私钥路径仅保留在本机环境,不写入仓库)
|
||||
|
||||
## 背景
|
||||
|
||||
远端 `k6-works-list.js` 压测中:
|
||||
|
||||
- smoke 通过。
|
||||
- baseline 10 VU:无 HTTP 错误,但 p95/p99 超阈值。
|
||||
- 50 RPS spike:`http_req_failed` / `works_list_shape_error_rate` 约 21.99%。
|
||||
- 100 RPS spike:`http_req_failed` / `works_list_shape_error_rate` 约 25.47%。
|
||||
- 从 k6 check 看,失败主要集中在 `puzzle_gallery_list`,`custom_world_gallery_list` 基本正常。
|
||||
|
||||
## 已完成排查
|
||||
|
||||
### 1. 服务器进程与资源
|
||||
|
||||
远端服务监听:
|
||||
|
||||
- Rust api-server:`127.0.0.1:8082`,systemd 服务 `genarrative-api.service`。
|
||||
- SpacetimeDB:`127.0.0.1:3101`,systemd 服务 `spacetimedb.service`。
|
||||
- Nginx:公网 80 反代 `/api/*` 到 `127.0.0.1:8082`。
|
||||
|
||||
服务器规格/状态:
|
||||
|
||||
- 2 vCPU。
|
||||
- 内存约 1.9GiB。
|
||||
- Swap 约 1.9GiB,已有约 600MiB 使用。
|
||||
- `/` 磁盘约 69%。
|
||||
- Rust api-server 当前 CPU 不高。
|
||||
- SpacetimeDB 当前 CPU 不高。
|
||||
|
||||
发现一个独立异常:
|
||||
|
||||
- PM2 下旧 `server-node` 进程 `genarrative` 正在重启风暴。
|
||||
- cwd:`/work/Genarrative/server-node`
|
||||
- 错误:连接 `127.0.0.1:5432` PostgreSQL 被拒绝。
|
||||
- PM2 restart 次数已超过 33 万。
|
||||
- 该进程不是当前公网 `/api/*` 使用的 Rust api-server,但会制造额外 CPU/内存/日志抖动。
|
||||
|
||||
### 2. 压测窗口服务端日志
|
||||
|
||||
子任务聚合了 2026-05-12 04:50-05:05 的 nginx 与 api-server 日志。
|
||||
|
||||
nginx access:
|
||||
|
||||
- `/api/runtime/puzzle/gallery`:4661 次,全部 200。
|
||||
- `/api/runtime/custom-world-gallery`:4659 次,全部 200。
|
||||
|
||||
api-server journal:
|
||||
|
||||
`/api/runtime/puzzle/gallery`:
|
||||
|
||||
- completed:4661
|
||||
- status:200 全部
|
||||
- slow_request:0
|
||||
- latency_ms:min 13 / p50 30 / p90 43 / p95 50 / p99 62 / max 88
|
||||
|
||||
`/api/runtime/custom-world-gallery`:
|
||||
|
||||
- completed:4659
|
||||
- status:200 全部
|
||||
- slow_request:0
|
||||
- latency_ms:min 0 / p50 1 / p90 5 / p95 7 / p99 13 / max 49
|
||||
|
||||
结论:
|
||||
|
||||
- 在服务端视角,两个接口在该窗口都没有 5xx,也没有慢请求。
|
||||
- 这与 k6 客户端侧 30s timeout / failed check 存在明显不一致。
|
||||
- 需要进一步区分:客户端侧网络/连接耗尽/本机 k6 执行环境问题,还是 k6 统计混合/响应解析问题。
|
||||
|
||||
### 3. k6 脚本行为
|
||||
|
||||
文件:`scripts/loadtest/k6-works-list.js`
|
||||
|
||||
无 `AUTH_TOKEN` 时,每轮 iteration 顺序请求两个接口:
|
||||
|
||||
1. `GET /api/runtime/puzzle/gallery`
|
||||
2. `GET /api/runtime/custom-world-gallery`
|
||||
|
||||
`DETAIL_RATIO=0` 时不会请求详情。
|
||||
|
||||
`works_list_shape_error_rate` 不只代表字段结构错误,只要下面任意 check 失败都会计入:
|
||||
|
||||
- status is 200
|
||||
- returns json object
|
||||
- has collection
|
||||
- list item shape
|
||||
|
||||
因此 timeout、非 JSON、非 200、响应结构不符合都会表现为 shape error。
|
||||
|
||||
数据文件实际路径:
|
||||
|
||||
- `scripts/loadtest/data/works-list.local.json`
|
||||
|
||||
脚本里 `data/works-list.local.json` 是相对 k6 脚本文件解析的,因此本身合理。
|
||||
|
||||
### 4. 代码层疑似瓶颈
|
||||
|
||||
虽然这次远端服务端日志没有复现慢请求,但代码层仍发现一个真实性能隐患。
|
||||
|
||||
`/api/runtime/puzzle/gallery` 调用链:
|
||||
|
||||
- `server-rs/crates/api-server/src/app.rs:1192`
|
||||
- `server-rs/crates/api-server/src/puzzle.rs:1385-1409`
|
||||
- `server-rs/crates/spacetime-client/src/puzzle.rs:367-381`
|
||||
- `server-rs/crates/spacetime-module/src/puzzle.rs:430-443`
|
||||
- `server-rs/crates/spacetime-module/src/puzzle.rs:1393-1404`
|
||||
|
||||
关键实现:
|
||||
|
||||
- `list_puzzle_gallery_tx` 对 `puzzle_work_profile().iter()` 全表扫描。
|
||||
- 再过滤 `publication_status == Published`。
|
||||
- 对每个公开作品调用 `build_puzzle_work_profile_from_row_with_recent_count`。
|
||||
- 该函数调用 `count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros)`。
|
||||
|
||||
`count_recent_public_work_plays`:
|
||||
|
||||
- 文件:`server-rs/crates/spacetime-module/src/runtime/profile.rs:1296-1321`
|
||||
- 当前实现对 `public_work_play_daily_stat().iter()` 全表扫描过滤。
|
||||
- 但表定义已有复合索引:
|
||||
- `server-rs/crates/spacetime-module/src/runtime/profile.rs:242-248`
|
||||
- `by_public_work_play_daily_stat_work_day(source_type, profile_id, played_day)`
|
||||
- 当前统计函数未使用该索引。
|
||||
|
||||
复杂度风险:
|
||||
|
||||
```text
|
||||
puzzle gallery ~= O(puzzle_work_profile 全表扫描 + Published作品数 * public_work_play_daily_stat 全表扫描)
|
||||
```
|
||||
|
||||
`custom-world-gallery` 与 puzzle 的差异:
|
||||
|
||||
- custom-world 使用 `CustomWorldGalleryEntry` 公开读模型表。
|
||||
- puzzle 直接从 `puzzle_work_profile` 即席拼装。
|
||||
- 两者都调用 recent count,但 puzzle 更容易受作品表规模和统计表规模影响。
|
||||
|
||||
## 当前判断
|
||||
|
||||
本次排查有两个层面的结论:
|
||||
|
||||
1. 生产服务端日志没有证明 `puzzle/gallery` 在 04:50-05:05 窗口真的 30s 慢或 5xx。
|
||||
- api-server 记录的 p95 只有 50ms。
|
||||
- nginx 看到两个接口都是 200。
|
||||
- 所以 k6 侧的 30s timeout 需要进一步从客户端网络、连接池、Windows/k6 执行环境、summary 混合统计角度验证。
|
||||
|
||||
2. 代码层确实存在可修的性能隐患。
|
||||
- `count_recent_public_work_plays` 未使用已有索引。
|
||||
- puzzle gallery 对每个作品重复做 recent count。
|
||||
- puzzle gallery 未使用 `publication_status` 索引或读模型。
|
||||
|
||||
## 建议下一步
|
||||
|
||||
### A. 先处理服务器 PM2 重启风暴
|
||||
|
||||
建议确认旧 Node 服务是否仍需要。
|
||||
|
||||
如果不需要,应停止并禁用 PM2 中的旧 `server-node`:
|
||||
|
||||
```bash
|
||||
PM2_HOME=/home/ubuntu/.pm2 pm2 stop genarrative
|
||||
PM2_HOME=/home/ubuntu/.pm2 pm2 delete genarrative
|
||||
PM2_HOME=/home/ubuntu/.pm2 pm2 save
|
||||
```
|
||||
|
||||
这是生产侧操作,执行前需要确认。
|
||||
|
||||
### B. 单接口短压验证客户端/服务端不一致
|
||||
|
||||
不要继续用混合脚本大压。
|
||||
|
||||
建议新增或临时使用单接口 k6 脚本,分别只测:
|
||||
|
||||
- `/api/runtime/puzzle/gallery`
|
||||
- `/api/runtime/custom-world-gallery`
|
||||
|
||||
并在同一时间窗口并行采集:
|
||||
|
||||
- k6 客户端 summary
|
||||
- nginx access 请求数/状态码
|
||||
- api-server journal latency
|
||||
- 本机到服务器网络错误/timeout
|
||||
|
||||
目标是确认 timeout 是不是发生在客户端侧连接/网络,而不是服务端处理慢。
|
||||
|
||||
### C. 修复代码性能隐患
|
||||
|
||||
优先级建议:
|
||||
|
||||
1. `count_recent_public_work_plays` 改为使用 `by_public_work_play_daily_stat_work_day` 复合索引,或至少改成批量统计,避免 N 次全表扫描。
|
||||
2. `list_puzzle_gallery_tx` 使用 `by_puzzle_work_publication_status` 索引查询 Published,或参考 custom-world 建立 `puzzle_gallery_entry` 公开读模型。
|
||||
3. gallery 列表页不要实时逐条扫描统计表,可维护读模型或批量聚合 `recent_play_count_7d`。
|
||||
|
||||
### D. 调整 k6 脚本输出
|
||||
|
||||
建议 k6 summary 按 endpoint tag 输出或新增单接口模式,否则 overall 指标会把 puzzle/custom-world 混在一起。
|
||||
|
||||
建议增加:
|
||||
|
||||
- `ENDPOINT=puzzle_gallery_list`
|
||||
- `ENDPOINT=custom_world_gallery_list`
|
||||
|
||||
让脚本只跑一个 endpoint,避免诊断时混淆。
|
||||
@@ -16,20 +16,92 @@
|
||||
|
||||
---
|
||||
|
||||
## 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 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
|
||||
- 决策:小程序壳在 `pending_bind_phone` 时暂不打开 H5,先展示原生 `button open-type="getPhoneNumber"`;用户同意后把 `bindgetphonenumber` 返回的 `code` 作为 `wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`。后端通过微信 `stable_token` 与 `getuserphonenumber` 换取平台验证后的手机号,再复用现有微信待绑定账号合并逻辑并重新签发 active 系统 token。H5 旧短信验证码绑定流程继续作为非小程序环境兜底。
|
||||
- 影响范围:`miniprogram/pages/web-view/index.*`、`server-rs/crates/platform-auth`、`server-rs/crates/api-server/src/wechat_auth.rs`、认证共享契约、微信小程序 web-view 壳技术文档。
|
||||
- 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||
- 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。
|
||||
|
||||
## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路
|
||||
|
||||
- 背景:拼图和抓大鹅结果页需要接入 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`。
|
||||
|
||||
## 2026-05-11 寓教于乐公开作品使用独立 `edutainment` 来源接入
|
||||
|
||||
- 背景:`宝贝识物` 首关需要通过创作模板发布后进入寓教于乐板块,同时关闭入口时必须从发现页、搜索、详情深链、作品号和历史入口完全不可见;若继续落入 RPG 默认公共作品链路,容易出现误启动、误改造或近似标签误归类。
|
||||
- 决策:寓教于乐公开作品在前端公共作品模型中使用 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match`、`templateName = 宝贝识物`;进入“发现 / 寓教于乐”频道仍必须携带精确等于 `寓教于乐` 的公开标签,不因模板名或近似标签自动归类。公开详情、推荐运行态、改造、编辑、点赞和分享链路都必须显式识别 `edutainment`,不得回落到 RPG 默认处理。
|
||||
- 影响范围:公开作品卡、发现页频道、作品号搜索、公开详情深链、分享、作品架聚合、后续儿童动作 Demo 模板的发布结果展示。
|
||||
- 验证方式:执行第4线程定向单测、前端类型检查、ESLint 与编码检查;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时确认精确 `寓教于乐` 作品不可通过任何公开入口访问。
|
||||
- 关联文档:`docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`、`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||
|
||||
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
|
||||
|
||||
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位。
|
||||
- 决策:热身舞台统一采用绘本草地视觉语言,真实背景图默认输出到 `public/child-motion-demo/picture-book-grass-stage.webp`,生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
|
||||
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。
|
||||
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
|
||||
- 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
|
||||
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件。
|
||||
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only <asset-id>` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。
|
||||
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。
|
||||
|
||||
## 2026-05-10 方洞挑战从创作页入口和作品架隐藏
|
||||
@@ -253,10 +325,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 移动端整页缩放由入口统一锁定
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 中文乱码与编码风险
|
||||
|
||||
- 现象:中文文案、注释、剧情或文档显示为乱码,或被改写成英文。
|
||||
@@ -35,6 +43,14 @@
|
||||
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
|
||||
- 关联:`AGENTS.md`、`npm run check:encoding`。
|
||||
|
||||
## 忘记密码后仍提示手机号或密码错误先查认证快照同步
|
||||
|
||||
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
|
||||
- 原因:重置/修改密码会更新 `password_hash`、`password_login_enabled` 和 `token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()`,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。
|
||||
- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照;启动恢复时从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照,本地文件更新时尝试回写 SpacetimeDB。
|
||||
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml` 与 `cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
|
||||
- 关联:`server-rs/crates/api-server/src/password_management.rs`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`。
|
||||
|
||||
## `.hermes` 只放共享内容,不放个人 Hermes 配置
|
||||
|
||||
- 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。
|
||||
@@ -51,14 +67,31 @@
|
||||
- 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。
|
||||
- 关联:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||
|
||||
## 儿童动作 Demo 真实绘本背景图未生成先查 VectorEngine 配置
|
||||
## 宝贝识物选篮误触发先查多套判定和残余轨迹
|
||||
|
||||
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.webp` 不存在,Network 里该图返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
|
||||
- 原因:儿童动作 Demo 的真实背景图使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。
|
||||
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai` 与 `VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git;先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt,再运行 `npm run assets:child-motion-demo -- --live` 生成默认背景图。
|
||||
- 验证:生成后确认 `public/child-motion-demo/picture-book-grass-stage.webp` 存在,重新打开 `/child-motion-demo` 可看到真实绘本草地背景;`npm run check:encoding` 仍通过。
|
||||
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
|
||||
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。
|
||||
- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;礼物盒打开和反馈阶段清空轨迹,不在非 `active` 阶段累计路径。礼物盒激活仍使用 `open_palm -> grab` 抓握序列。
|
||||
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角;`rightHand` 轨迹代表玩家左手并进入左篮,`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左篮、右键=右篮。
|
||||
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试和左右手横向轨迹测试通过。
|
||||
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||
|
||||
## 儿童动作 Demo 绘本风资源未生成先查 VectorEngine 配置
|
||||
|
||||
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.png`、`picture-book-grass-floor.png`、`picture-book-ground-ring.png`、`picture-book-character-outline.png`、`picture-book-ui-panel.png` 或 `picture-book-ui-button.png` 不存在,Network 里对应图片返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
|
||||
- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。
|
||||
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai` 与 `VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git;先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt,再运行 `npm run assets:child-motion-demo -- --live` 或 `npm run assets:child-motion-demo -- --live --only ui-panel` 等小批量命令生成资源。透明资源的品红底源图写入 `tmp/child-motion-demo-assets/`,不要把源图或预览图放入 `public/child-motion-demo/` 作为正式资产。
|
||||
- 验证:生成后确认 `public/child-motion-demo/` 只保留页面引用的最终 PNG,重新打开 `/child-motion-demo` 可看到真实绘本草地背景、地面、圆环、角色轮廓和 UI 资源;`npm run check:encoding` 仍通过。
|
||||
- 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||
|
||||
## 儿童动作 Demo 绘本资源变形先查用途拆分和透明后处理
|
||||
|
||||
- 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。
|
||||
- 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板,就会出现变形和层叠观感。
|
||||
- 处理:使用 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png`、`picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。若只需修透明裁切或品红边,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
|
||||
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`。
|
||||
- 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`public/child-motion-demo/`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||
|
||||
## GPT-image-2 不再读 APIMart 图片配置
|
||||
|
||||
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。
|
||||
@@ -405,6 +438,14 @@
|
||||
- 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。
|
||||
- 关联:`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 中未定义即退出。
|
||||
@@ -445,46 +486,69 @@
|
||||
- 验证:`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 后立即锁定本次完整 profile,runtime 渲染时优先按 `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`。
|
||||
|
||||
## 抓大鹅历史草稿外部 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
@@ -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`。
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -108,3 +108,42 @@ output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn-preview.png
|
||||
2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。
|
||||
3. 若展会现场观众偏投资人或B端合作方,可以把“产品心智”段压缩,放大“关键技术”与平台愿景。
|
||||
4. 若观众偏玩家或普通创作者,可以把“关键技术”段压缩,放大“10分钟创作、玩过就改、发布分享”的闭环。
|
||||
|
||||
## 6. 公司招聘版 2026-05-11
|
||||
|
||||
2026-05-11 根据线下招聘场景,将海报方向从“纯产品宣传”调整为“公司 + 产品 + 岗位”的整体宣传。
|
||||
|
||||
新版定位:
|
||||
|
||||
```text
|
||||
北京亓盒网络科技有限公司
|
||||
岗位名称:AI 原生游戏产品/内容实习生
|
||||
行业方向:AI 原生游戏 × UGC 内容创作 × 互动叙事
|
||||
产品:百梦 AI互动内容创作平台
|
||||
```
|
||||
|
||||
新版保留百梦气泡色彩、轻盈白底和创作流动感,但新增校园实验室、AI 游戏创作、作品卡、产品测试与内容设计氛围。版面结构调整为:
|
||||
|
||||
1. 顶部:公司名、岗位名、行业方向与招聘主标题。
|
||||
2. 中上:百梦产品主张与三枚产品能力标签。
|
||||
3. 中部:按 `游玩 -> 改造 -> 创作` 顺序展示产品体验闭环。
|
||||
4. 中下:介绍“我们正在做的事「百梦」”。
|
||||
5. 下部:实习生参与内容、加分项、团队背景和联系方式。
|
||||
6. 底部:预留两个方形二维码占位,收尾文案为 `百梦 | 让每个人都能做自己的游戏`。
|
||||
|
||||
新版使用当前仓库 `VectorEngine gpt-image-2-all` 路径生成底图:
|
||||
|
||||
```text
|
||||
model: gpt-image-2-all
|
||||
size: 1536x3840
|
||||
reference image 1: 用户提供的上一版海报截图
|
||||
reference image 2: 百梦气泡共创logo方向图
|
||||
output: output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-background-gpt-image-2-all.png
|
||||
```
|
||||
|
||||
最终输出:
|
||||
|
||||
```text
|
||||
output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-final-cn.png
|
||||
output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-final-cn-preview.png
|
||||
```
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
1. 发现页隐藏“寓教于乐”标签;
|
||||
2. 隐藏“寓教于乐”标签下内容;
|
||||
3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果;
|
||||
4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口等平台公开入口都不能打开该内容。
|
||||
4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口、创作入口和创作页作品架等平台入口都不能打开或展示该内容。
|
||||
|
||||
## 4. 内容识别规则
|
||||
|
||||
@@ -114,4 +114,23 @@ no
|
||||
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper,避免推荐运行态自动启动寓教于乐作品,并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。
|
||||
5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。
|
||||
6. `/child-motion-demo` 本地动作 Demo 直达路由也复用同一开关;开关关闭时不匹配独立 Demo 应用,回落到主站入口。
|
||||
7. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/components/platform-entry/platformEdutainmentVisibility.test.ts` 和 `src/routing/appRoutes.test.ts`,包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截和精确标签识别。
|
||||
7. `宝贝识物` 创作入口和创作页作品架也复用同一开关;开关关闭时不展示模板入口,也不展示本地宝贝识物草稿或已发布卡片。
|
||||
8. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/components/platform-entry/platformEdutainmentVisibility.test.ts`、`src/components/platform-entry/platformEntryCreationTypes.test.ts` 和 `src/routing/appRoutes.test.ts`,包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截、创作入口隐藏和精确标签识别。
|
||||
|
||||
## 9. 第 4 项作品架 / 广场接入边界
|
||||
|
||||
`宝贝识物` 首关的公开作品展示接入按以下口径收口:
|
||||
|
||||
1. 平台公共作品模型新增 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match`、`templateName = 宝贝识物`。
|
||||
2. `宝贝识物` 作品仍必须携带精确等于“寓教于乐”的公开标签,才会进入“发现 / 寓教于乐”频道。
|
||||
3. `宝贝识物` 不因为模板名自动归入寓教于乐,也不因为近似标签归入寓教于乐。
|
||||
4. 第 4 项只负责公开作品卡片、发现页专属频道、公开详情、分享作品号和开关隐藏保护。
|
||||
5. 创作模板、image-2 资产生成、发布接口、运行时开始游戏和关卡状态由对应线程接入;当前公共作品卡直接透传后续数据源提供的 `publicWorkCode`,不在前端新增最终作品号前缀规则。
|
||||
6. 在创作和运行时链路真正接入前,公开详情内的启动、改造、编辑和点赞只做保护性占位,不新增玩法规则。
|
||||
|
||||
当前工程落点:
|
||||
|
||||
1. `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 定义 `PlatformEdutainmentGalleryCard` 与 `isEdutainmentGalleryEntry`。
|
||||
2. `src/components/rpg-entry/RpgEntryHomeView.tsx` 将 `宝贝识物` 卡片识别为寓教于乐公开作品,并继续从推荐、今日、分类、排行和搜索结果中过滤。
|
||||
3. `src/components/platform-entry/PlatformWorkDetailView.tsx` 在公开详情中显示 `宝贝识物` 类型标签,并继续复用作品号复制和分享链路。
|
||||
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已识别 `edutainment` 公共作品,避免落入 RPG 默认详情、推荐运行态或错误的改造链路。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
3. 原“排行”页内容并入“发现”页的子 Tab 中,不再作为一级主 Tab 独立展示。
|
||||
4. 创作页只保留新建创作入口;原创作页作品列表拆到一级“草稿” Tab,替换原“存档” Tab。
|
||||
5. 原“存档”列表结构并入“我的”页面的“玩过”列表弹层,作为每个已玩作品的可继续存档入口。
|
||||
6. 移动端推荐页与底部 Tab 栏参考用户给定样式,使用大画面推荐流、顶部品牌/通知、悬浮胶囊底部导航;保留当前平台已有明暗两套主题色 token。
|
||||
6. 移动端推荐页与底部 Tab 栏参考用户给定样式,使用大画面推荐流、顶部品牌与悬浮胶囊底部导航;右上角不保留通知按钮,账号相关入口统一进入“我的”和账号面板;保留当前平台已有明暗两套主题色 token。
|
||||
|
||||
## 2. 状态映射
|
||||
|
||||
|
||||
@@ -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`。_
|
||||
|
||||
@@ -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. 相关中文文档通过编码检查。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 宝贝识物寓教于乐模板 PRD 2026-05-11
|
||||
|
||||
## 1. 目标
|
||||
|
||||
新增寓教于乐内容线的创作模板:
|
||||
|
||||
```text
|
||||
宝贝识物
|
||||
```
|
||||
|
||||
创作者必须通过该模板创作并发布作品后,用户才能在寓教于乐板块体验对应关卡。
|
||||
|
||||
本模板只服务儿童动作 Demo 内容线,不把普通教育题材作品自动归入寓教于乐。
|
||||
|
||||
## 2. 创作输入
|
||||
|
||||
创作者必须填写两个物品名称:
|
||||
|
||||
1. 物品 A 名称;
|
||||
2. 物品 B 名称。
|
||||
|
||||
两个名称都必须去除首尾空白后非空。当前阶段不新增题材、难度、计时、失败次数、分数、体力或递增规则。
|
||||
|
||||
## 3. 生成规则
|
||||
|
||||
提交后生成一份宝贝识物草稿,草稿包含:
|
||||
|
||||
1. 模板 ID:`baby-object-match`;
|
||||
2. 模板名称:`宝贝识物`;
|
||||
3. 两个物品;
|
||||
4. 两个物品图;
|
||||
5. 作品标签。
|
||||
|
||||
物品图使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端或后续后端预留接口,前端不得泄露 `VECTOR_ENGINE_API_KEY`。
|
||||
|
||||
本地 Demo 阶段若真实生图接口未接入完成,允许前端 service 返回明确标记为 `placeholder` 的占位图形,用于打通创作到结果页的交互链路;该占位结果不得伪装成正式 image-2 资产。
|
||||
|
||||
## 4. 标签规则
|
||||
|
||||
发布作品必须携带精确标签:
|
||||
|
||||
```text
|
||||
寓教于乐
|
||||
```
|
||||
|
||||
标签识别只接受精确等于 `寓教于乐`。不接受 `儿童教育`、`动作教育`、`寓教于乐 ` 等近似标签。
|
||||
|
||||
宝贝识物草稿与发布 payload 中都必须保留该标签。发布后的公开展示、搜索、深链和入口开关继续遵循 `CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`。
|
||||
|
||||
## 5. 结果页能力
|
||||
|
||||
结果页展示:
|
||||
|
||||
1. 作品名称;
|
||||
2. 两个物品名称;
|
||||
3. 两个物品图;
|
||||
4. 标签;
|
||||
5. 保存草稿;
|
||||
6. 发布;
|
||||
7. 试玩。
|
||||
|
||||
结果页不展示长规则说明文案。试玩按钮直接进入宝贝识物首关本地运行态。
|
||||
|
||||
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
|
||||
|
||||
## 6. 发布后体验
|
||||
|
||||
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
|
||||
|
||||
入口关闭时,发布作品完全不可见,不能通过推荐、发现普通频道、搜索、作品号、公开详情深链或浏览历史访问。
|
||||
|
||||
## 7. 与运行时线程的边界
|
||||
|
||||
本 PRD 同步约束首关运行态,已确认规则包括:
|
||||
|
||||
1. 礼物盒打开在本地调试绑定 `F` 键;
|
||||
2. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
|
||||
3. 下一关按钮当前占位;
|
||||
4. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
|
||||
5. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
|
||||
6. 礼物盒位于屏幕中下方,任意手抬起后打开并跳出下一个随机物品。
|
||||
7. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
|
||||
8. 明确左手连续横向移动达到阈值时将当前物品送入左侧篮子,明确右手连续横向移动达到阈值时将当前物品送入右侧篮子;选篮不使用动作名判定,侧别未知的手部轨迹不参与选篮。
|
||||
9. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
|
||||
10. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
|
||||
11. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
|
||||
|
||||
## 8. 验收
|
||||
|
||||
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
|
||||
2. 未填写任一物品名称时不能生成草稿。
|
||||
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
|
||||
4. 草稿标签中始终包含精确 `寓教于乐`。
|
||||
5. 发布 payload 始终包含精确 `寓教于乐`。
|
||||
6. 发布完成后出现分享弹窗或发布完成状态。
|
||||
7. 前端不读取或暴露 VectorEngine 密钥。
|
||||
8. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
|
||||
9. 运行态可通过 `F` 打开礼物盒,通过鼠标左键拖动映射左手横向移动,通过鼠标右键拖动映射右手横向移动。
|
||||
10. 成功 20 次后出现“再来一次”和“下一关”按钮。
|
||||
@@ -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`
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 重点入口
|
||||
|
||||
- [宝贝识物寓教于乐模板 PRD](./BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md):定义寓教于乐内容线的 `宝贝识物` 创作模板,覆盖两个物品名称输入、image-2 物品图生成、精确 `寓教于乐` 标签、结果页和发布边界。
|
||||
- [AI 原生幕间文字游戏模板 PRD:参考 MOKU 的剧本模拟器闭环](./AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md):参考 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,但只落为百梦 `text-game` 模板,复用平台接口,不迁入外部社区、支付、私有存档或回放。
|
||||
- [AI 原生视觉小说模板 PRD:TXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。
|
||||
- [AI 原生幸存者类游戏模板 PRD](./AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md):定义 `survivor` 幸存者挑战模板,从 Agent 创作、结果页、资产、试玩、发布到后端权威配置与前端高频运行表现的完整闭环。
|
||||
@@ -12,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):定义个人页法律入口、备案链接、法律内容弹窗和首次登录协议勾选规则。
|
||||
|
||||
## 使用规则
|
||||
|
||||
|
||||
@@ -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. 文档、任务清单与测试已同步更新
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# 宝贝识物创作发布实现方案 2026-05-11
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本方案对应第 2 线程:创作发布线程。
|
||||
|
||||
本线程落地:
|
||||
|
||||
1. 创作入口配置;
|
||||
2. 模板表单;
|
||||
3. 本地草稿生成 service;
|
||||
4. 结果页;
|
||||
5. 发布 payload 约束;
|
||||
6. 本地 Demo 运行态;
|
||||
7. 后端 image-2 / 作品持久化 / 运行态接口预留形状。
|
||||
|
||||
本阶段运行态先做浏览器本地 Demo,并消费现有本地 mocap 动作数据源;正式硬件接口和摄像头调教在后续接口稳定后继续接入。
|
||||
|
||||
## 2. 前端接入点
|
||||
|
||||
新增玩法 ID:
|
||||
|
||||
```text
|
||||
baby-object-match
|
||||
```
|
||||
|
||||
用户展示名:
|
||||
|
||||
```text
|
||||
宝贝识物
|
||||
```
|
||||
|
||||
入口文件:
|
||||
|
||||
1. `src/config/newWorkEntryConfig.ts`
|
||||
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
|
||||
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
|
||||
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
|
||||
|
||||
新增阶段:
|
||||
|
||||
```text
|
||||
baby-object-match-workspace
|
||||
baby-object-match-generating
|
||||
baby-object-match-result
|
||||
baby-object-match-runtime
|
||||
```
|
||||
|
||||
## 3. 契约
|
||||
|
||||
前端共享契约放在:
|
||||
|
||||
```text
|
||||
packages/shared/src/contracts/edutainmentBabyObject.ts
|
||||
```
|
||||
|
||||
核心字段:
|
||||
|
||||
1. `BabyObjectMatchDraft.templateId = "baby-object-match"`;
|
||||
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`;
|
||||
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`;
|
||||
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2` 或 `placeholder`;
|
||||
5. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`。
|
||||
|
||||
## 4. Service 边界
|
||||
|
||||
前端 service 放在:
|
||||
|
||||
```text
|
||||
src/services/edutainment-baby-object/babyObjectMatchClient.ts
|
||||
```
|
||||
|
||||
首版提供:
|
||||
|
||||
1. `createBabyObjectMatchDraft(payload)`;
|
||||
2. `saveBabyObjectMatchDraft(draft)`;
|
||||
3. `publishBabyObjectMatchWork(payload)`。
|
||||
|
||||
当前后端正式接口未在本线程扩表落地,因此 service 先走本地 Demo 存储,并把 asset 结果标记为 `placeholder`。后续后端接入时,应替换为:
|
||||
|
||||
```text
|
||||
POST /api/creation/edutainment/baby-object-match/drafts
|
||||
PUT /api/creation/edutainment/baby-object-match/drafts/{draftId}
|
||||
POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
|
||||
```
|
||||
|
||||
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
|
||||
|
||||
## 5. UI 边界
|
||||
|
||||
工作台只展示两个必填输入和生成按钮。
|
||||
|
||||
结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。
|
||||
|
||||
移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。
|
||||
|
||||
## 6. 运行态边界
|
||||
|
||||
前端运行态放在:
|
||||
|
||||
```text
|
||||
src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
|
||||
```
|
||||
|
||||
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
|
||||
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`。
|
||||
|
||||
首关状态机:
|
||||
|
||||
1. `waiting`:礼物盒关闭,等待任意手抬起;
|
||||
2. `active`:当前物品停留在屏幕中央;
|
||||
3. `correct`:展示“真棒”反馈,成功次数加 1;
|
||||
4. `wrong`:展示“再想一想吧”反馈,当前物品回到中央;
|
||||
5. `complete`:成功次数达到 20,展示“恭喜你!小朋友!”和按钮。
|
||||
|
||||
动作输入:
|
||||
|
||||
1. 任意手完成一次 `open_palm -> grab` 抓握序列:打开礼物盒并生成当前物品;
|
||||
2. 左手连续横向移动达到阈值:将当前物品送入左侧篮子;
|
||||
3. 右手连续横向移动达到阈值:将当前物品送入右侧篮子。
|
||||
|
||||
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。选篮只使用明确 `leftHand` 或 `rightHand` 的连续横向轨迹阈值,不再通过 `wave_left_hand`、`wave_right_hand`、`wave` 等动作名触发;侧别为 `unknown` 的手部轨迹也不参与选篮,以避免多套判定误命中和连续误触发。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:`rightHand` 轨迹映射玩家左手并进入左侧篮子,`leftHand` 轨迹映射玩家右手并进入右侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
|
||||
|
||||
开发者调试输入:
|
||||
|
||||
1. `F`:映射任意手抬起,打开礼物盒并生成当前物品;
|
||||
2. 鼠标左键按下并拖动:映射左手轨迹,抬起后将当前物品送入左侧篮子;
|
||||
3. 鼠标右键按下并拖动:映射右手轨迹,抬起后将当前物品送入右侧篮子。
|
||||
|
||||
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
|
||||
|
||||
音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。
|
||||
|
||||
## 7. 发布约束
|
||||
|
||||
发布前必须执行:
|
||||
|
||||
1. 两个物品名非空;
|
||||
2. 两个物品名对应的 asset 存在;
|
||||
3. 标签补齐精确 `寓教于乐`;
|
||||
4. `publicationStatus` 从 `draft` 变为 `published`。
|
||||
|
||||
发布后首版本地响应返回 `publicWorkCode`,用于分享弹窗;正式后端接入时 public code 生成规则需要纳入统一作品号服务。
|
||||
|
||||
## 8. 热身关衔接
|
||||
|
||||
`/child-motion-demo` 热身完成后的“开始游戏”按钮进入同一个 `BabyObjectMatchRuntimeShell`。
|
||||
|
||||
热身关独立 Demo 没有创作者草稿上下文,因此使用固定本地 Demo 草稿承载两个物品,仅用于热身关后验证首关体验;正式平台体验仍必须从 `宝贝识物` 模板创作发布后进入寓教于乐板块。
|
||||
|
||||
## 9. 验收命令
|
||||
|
||||
```bash
|
||||
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
|
||||
npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
|
||||
npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0
|
||||
npm run check:encoding
|
||||
npm run typecheck
|
||||
npm run build:raw
|
||||
```
|
||||
|
||||
若后续接入真实 Rust API 和 SpacetimeDB 表,再补充 `npm run api-server`、`/healthz`、Rust contract / api-server / spacetime-client 定向测试和 migration 表目录更新。
|
||||
@@ -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` |
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
用户完成热身关所有步骤后,进入关卡选择。
|
||||
|
||||
当前后续游戏仍在设计中。热身结束后可先展示“开始游戏”按钮作为关卡选择占位,用户点击后进入下一关占位界面。
|
||||
热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证;正式平台体验仍必须通过“宝贝识物”创作模板发布后,在寓教于乐板块进入。
|
||||
|
||||
### 3.3 固定流程顺序
|
||||
|
||||
@@ -642,7 +642,7 @@
|
||||
|
||||
1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。
|
||||
2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。
|
||||
3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮和下一关占位界面。
|
||||
3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮,并复用宝贝识物运行态进入首关本地 Demo。
|
||||
4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。
|
||||
5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。
|
||||
|
||||
@@ -669,19 +669,27 @@
|
||||
当前未接入但已保留边界:
|
||||
|
||||
1. 正式语音播报接口暂不接入,当前先展示热身文案。
|
||||
2. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态。
|
||||
3. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。
|
||||
2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接。
|
||||
|
||||
## 16. 当前视觉资产与生图口径补充
|
||||
|
||||
儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台:
|
||||
|
||||
1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。
|
||||
2. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格重做,未生成真实背景图时由 CSS 兜底。
|
||||
3. 真实背景图的默认输出路径固定为 `public/child-motion-demo/picture-book-grass-stage.webp`。
|
||||
4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`。
|
||||
5. 当前本机工作区未检测到 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,因此暂时只能完成 dry-run 或代码层接入,不能直接产出真实 image-2 资产。
|
||||
6. 若后续补齐 VectorEngine 私密配置,再运行 live 生成即可把真实绘本背景写入上述固定路径,页面会自动读取。
|
||||
2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。
|
||||
3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底。
|
||||
4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`。
|
||||
5. 当前已生成并接入以下正式 Demo 资源:
|
||||
- `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。
|
||||
- `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。
|
||||
- `public/child-motion-demo/picture-book-ground-ring-v2.png`:已按透视绘制的地面椭圆指示环,CSS 只等比缩放。
|
||||
- `public/child-motion-demo/picture-book-character-outline-v2.png`:半透明用户角色轮廓,使用独立去背后处理避免内部填充被误删。
|
||||
- `public/child-motion-demo/picture-book-hud-strip-v2.png`:顶部 HUD 细长软纸条。
|
||||
- `public/child-motion-demo/picture-book-calibration-strip-v2.png`:右下角五格热身状态条。
|
||||
- `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。
|
||||
- `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。
|
||||
6. v2 资源按最终用途拆分,CSS 必须按资源原始比例、`aspect-ratio` 或 `background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板,也禁止把底部草坪扩展成覆盖角色脚下的大色块。
|
||||
7. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only <asset-id>` 小批量生成;仅调整透明去背、裁切、画布归一或品红边缘时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用 `tmp/child-motion-demo-assets/` 中的源图,不额外请求 image-2;不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。
|
||||
|
||||
已执行的定向验证命令:
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
黏土手作 / 低多边形 / 玩具塑料 / 木质雕刻 / 体素积木 / 金属机甲 / 自定义
|
||||
扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义
|
||||
```
|
||||
|
||||
自定义风格必须在弹出面板中填写描述后才能应用。入口表单必须在移动端创作页可视区内完成题材、风格、难度和生成按钮的展示,页面自身不产生纵向滚动;风格卡只允许横向滑动。
|
||||
|
||||
@@ -275,6 +275,12 @@ type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
F2 不实现运行态本身;只冻结结果页如何发起试玩。
|
||||
|
||||
### 12.1 生成完成自动试玩补充(2026-05-12)
|
||||
|
||||
抓大鹅表单生成草稿完成后,如果用户仍停留在 `match3d-generating` 进度页,前端应立即用刚生成的 `Match3DWorkProfile` 启动试玩,并把运行态返回目标设置为 `match3d-result`。试玩过程中点击左上角返回时,进入同一份抓大鹅草稿结果页查看与编辑。
|
||||
|
||||
该自动试玩只响应当前等待中的生成页;如果用户已经返回草稿 Tab 或切到其它页面,后台生成完成只更新草稿可见状态,不主动切屏。自动启动失败时,仍保留结果页草稿作为兜底入口。
|
||||
|
||||
---
|
||||
|
||||
## 13. 发布接口
|
||||
|
||||
@@ -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 重置密码
|
||||
|
||||
@@ -76,3 +77,16 @@
|
||||
3. 只有用户显式修改或重置密码后,才允许密码登录。
|
||||
|
||||
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口。
|
||||
|
||||
## 5. 2026-05-12 快照同步修复
|
||||
|
||||
重置密码和修改密码都会改变认证真相:`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,避免旧远端状态覆盖刚重设的密码。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
cargo test -p module-auth password --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p api-server password --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 平台移动端推荐页卡片与滑动热区布局 2026-05-12
|
||||
|
||||
## 背景
|
||||
|
||||
移动端推荐页承载嵌入式作品运行态,顶部品牌栏和底部导航上方留白会压缩首屏可玩区域。推荐页同时需要纵向切换作品,但不能让切换手势覆盖作品运行态内部的点击、拖拽、滑动热区。
|
||||
|
||||
## 落地口径
|
||||
|
||||
1. 仅推荐页隐藏移动端顶部品牌栏,发现、创作、草稿、我的页继续保留原顶部结构。
|
||||
2. 推荐页外层使用独立 shell class,把原顶部区域和底部导航上方额外留白让给推荐卡片。
|
||||
3. 推荐页运行态画面保持独立可交互区域,不挂平台切换作品的 pointer 手势。
|
||||
4. 切换作品的纵向手势只绑定在卡片底部作品信息区;底部信息区可以扩大触控高度,但不得绝对定位覆盖运行态画面。
|
||||
5. 点赞、分享、改造按钮继续阻止 pointer 事件冒泡,避免按钮点击误触发切换作品。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 手机竖屏进入推荐页时,首屏不显示顶部品牌标题。
|
||||
2. 推荐卡片上沿贴近可视区域顶部,下沿贴近底部导航上方。
|
||||
3. 在作品运行态画面内点击、拖拽或滑动,只触发作品自身交互。
|
||||
4. 在底部作品信息区上下滑动,可以切换推荐作品。
|
||||
5. 点赞、分享、改造按钮可正常点击,不触发作品切换。
|
||||
@@ -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 凭证
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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、下载并转存 OSS;SpacetimeDB 只通过 `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 背景区域有明确边界。
|
||||
|
||||
@@ -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. 验收
|
||||
|
||||
|
||||
@@ -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重绘要求(提示词)`。
|
||||
- 展示图片模型切换。
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置、`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。
|
||||
- [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 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。
|
||||
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异;同时冻结 `shared-contracts` 不得反向依赖 `platform-*`,避免 SpacetimeDB 模块发布时拉入 `wasm-bindgen`。
|
||||
@@ -18,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 校验和前端预览/提交边界。
|
||||
@@ -172,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 边界、发送与校验职责、配置项和错误语义。
|
||||
@@ -204,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` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` 字段 | 迁移规则 |
|
||||
|
||||
@@ -92,6 +92,25 @@
|
||||
2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone`。
|
||||
3. 但 access token 中的 `provider` 仍按**本次登录来源**签发,不依赖账号主登录方式推断,因此微信绑定后的当前会话仍会签发 `provider = wechat`。
|
||||
|
||||
### 3.4 `POST /api/auth/wechat/miniprogram-login`
|
||||
|
||||
职责固定为:
|
||||
|
||||
1. 接收微信小程序原生壳通过 `wx.login` 拿到的 `code`。
|
||||
2. 在 `Axum` 内调用微信 `jscode2session`,兑换 `openid/unionid`。
|
||||
3. 复用 `resolve_login` 处理 `unionid/openid -> user_id` 的查找、补写和待绑定账号创建。
|
||||
4. 签发本系统 access token,并创建 refresh session。
|
||||
5. 返回:
|
||||
- `token`
|
||||
- `bindingStatus`
|
||||
- `user`
|
||||
|
||||
关键约束:
|
||||
|
||||
1. 小程序壳不能把裸 `openid` 直接拼给 H5 做登录。
|
||||
2. H5 仍只消费本系统 `auth_token`,小程序壳只是把这枚 token 放入既有 hash 回调格式。
|
||||
3. 小程序请求必须补传 `x-client-type=mini_program` 与 `x-client-runtime=wechat_mini_program`,用于 refresh session 记录来源。
|
||||
|
||||
## 4. 当前最小实现策略
|
||||
|
||||
当前阶段为了先打通 Rust 后端闭环,采用以下最小实现:
|
||||
@@ -125,6 +144,7 @@
|
||||
4. `GET /api/auth/wechat/start`
|
||||
5. `GET /api/auth/wechat/callback`
|
||||
6. `POST /api/auth/wechat/bind-phone`
|
||||
7. `POST /api/auth/wechat/miniprogram-login`
|
||||
|
||||
## 6. 环境变量
|
||||
|
||||
@@ -139,11 +159,14 @@
|
||||
7. `WECHAT_AUTHORIZE_ENDPOINT`
|
||||
8. `WECHAT_ACCESS_TOKEN_ENDPOINT`
|
||||
9. `WECHAT_USER_INFO_ENDPOINT`
|
||||
10. `WECHAT_STATE_TTL_MINUTES`
|
||||
11. `WECHAT_MOCK_USER_ID`
|
||||
12. `WECHAT_MOCK_UNION_ID`
|
||||
13. `WECHAT_MOCK_DISPLAY_NAME`
|
||||
14. `WECHAT_MOCK_AVATAR_URL`
|
||||
10. `WECHAT_JS_CODE_SESSION_ENDPOINT`
|
||||
11. `WECHAT_MINI_PROGRAM_APP_ID`
|
||||
12. `WECHAT_MINI_PROGRAM_APP_SECRET`
|
||||
13. `WECHAT_STATE_TTL_MINUTES`
|
||||
14. `WECHAT_MOCK_USER_ID`
|
||||
15. `WECHAT_MOCK_UNION_ID`
|
||||
16. `WECHAT_MOCK_DISPLAY_NAME`
|
||||
17. `WECHAT_MOCK_AVATAR_URL`
|
||||
|
||||
## 7. 与后续 SpacetimeDB 的衔接要求
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ real 模式行为固定为:
|
||||
| `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 |
|
||||
| `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 |
|
||||
| `WECHAT_USER_INFO_ENDPOINT` | 否 | 默认用户信息接口 |
|
||||
| `WECHAT_JS_CODE_SESSION_ENDPOINT` | 否 | 默认小程序 `jscode2session` 接口 |
|
||||
| `WECHAT_MINI_PROGRAM_APP_ID` | 小程序 `real` 模式必填 | 微信小程序 AppID;不填时回退 `WECHAT_APP_ID` |
|
||||
| `WECHAT_MINI_PROGRAM_APP_SECRET` | 小程序 `real` 模式必填 | 微信小程序 AppSecret;不填时回退 `WECHAT_APP_SECRET` |
|
||||
| `WECHAT_STATE_TTL_MINUTES` | 否 | state 有效期,默认 `15` 分钟 |
|
||||
|
||||
补充说明:
|
||||
@@ -225,7 +228,46 @@ https://game.example.com
|
||||
- `wechatBound = true`
|
||||
- `bindingStatus` 已更新为目标状态
|
||||
|
||||
## 8. 账号命中规则
|
||||
## 8. 小程序 web-view 登录联调步骤
|
||||
|
||||
小程序壳走原生 `wx.login`,不走网页 OAuth callback。联调前需要额外确认:
|
||||
|
||||
```bash
|
||||
WECHAT_AUTH_ENABLED=true
|
||||
WECHAT_AUTH_PROVIDER=real
|
||||
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
|
||||
WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret"
|
||||
```
|
||||
|
||||
在 `miniprogram/config.js` 中确认:
|
||||
|
||||
```js
|
||||
const WEB_VIEW_ENTRY_URL = 'https://你的H5业务域名/';
|
||||
const API_BASE_URL = 'https://你的服务器域名/';
|
||||
const MINI_PROGRAM_APP_ID = '你的微信小程序 AppID';
|
||||
```
|
||||
|
||||
联调流程:
|
||||
|
||||
1. 微信开发者工具打开项目根目录。
|
||||
2. 小程序启动后调用 `wx.login`。
|
||||
3. 小程序壳请求:
|
||||
|
||||
```http
|
||||
POST /api/auth/wechat/miniprogram-login
|
||||
```
|
||||
|
||||
4. 后端通过 `jscode2session` 兑换 `openid/unionid`。
|
||||
5. 后端返回系统 `token`、`bindingStatus` 与 `user`。
|
||||
6. 小程序壳打开 H5,并在 hash 中附加:
|
||||
- `auth_provider=wechat`
|
||||
- `auth_token=...`
|
||||
- `auth_binding_status=active|pending_bind_phone`
|
||||
7. H5 消费 hash 后通过 `/api/auth/me` 恢复登录态。
|
||||
|
||||
这里不能把裸 `openid` 作为 web-view query 登录凭证;`openid` 只能留在后端身份绑定层,H5 只消费本系统 JWT。
|
||||
|
||||
## 9. 账号命中规则
|
||||
|
||||
当前实现固定按以下顺序命中已有账号:
|
||||
|
||||
@@ -238,7 +280,7 @@ https://game.example.com
|
||||
1. 若按 `unionid` 命中了已有微信身份,但本次微信回调带来了新的 `openid`,后端会把新的 `openid -> user_id` 映射补齐
|
||||
2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号
|
||||
|
||||
## 9. 前端验收点
|
||||
## 10. 前端验收点
|
||||
|
||||
前端联调时至少检查以下行为:
|
||||
|
||||
@@ -248,6 +290,8 @@ https://game.example.com
|
||||
4. 若 `auth_binding_status=pending_bind_phone`,页面必须进入绑定手机号界面
|
||||
5. 绑定成功后,应切回正常已登录状态
|
||||
|
||||
小程序原生手机号授权链路中,请求体应携带 `wechatPhoneCode`。后端调用微信 `getuserphonenumber` 后,需要按微信原始响应字段 `phoneNumber` / `purePhoneNumber` / `countryCode` 解析手机号;如果误按 Rust 字段名 `phone_number` / `pure_phone_number` / `country_code` 解析,会出现已传 `wechatPhoneCode` 但返回“微信手机号授权失败:缺少手机号”的假失败。
|
||||
|
||||
## 10. 后端验收点
|
||||
|
||||
当前后端至少应满足以下检查:
|
||||
|
||||
187
docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# 微信小程序 web-view 壳接入记录
|
||||
|
||||
日期:`2026-05-03`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本次先用微信小程序 `web-view` 承载现有 H5,不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。
|
||||
|
||||
当前小程序壳只承担五件事:
|
||||
|
||||
1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。
|
||||
2. 在原生小程序壳中调用 `wx.login` 获取小程序 `code`。
|
||||
3. 调用服务器域名下的 `/api/auth/wechat/miniprogram-login`,由 Rust `api-server` 兑换微信身份并签发系统登录态。
|
||||
4. 若后端返回 `pending_bind_phone`,先在小程序原生层通过 `button open-type="getPhoneNumber"` 取得用户同意后的手机号动态令牌,再调用 `/api/auth/wechat/bind-phone` 完成绑定。
|
||||
5. 用一个全屏 `web-view` 打开现有 H5 入口,并把系统 `auth_token` 放入 H5 现有登录回调 hash。
|
||||
|
||||
重要边界:
|
||||
|
||||
1. `openid` 只作为后端微信身份绑定依据,不直接暴露给 H5 当登录凭证。
|
||||
2. H5 继续消费本系统 JWT,也就是 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`。
|
||||
3. 这与 [`WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md`](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md) 中“微信只提供三方身份,Axum 签发系统 JWT”的边界一致。
|
||||
|
||||
## 2. 文件入口
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `project.config.json` | 指定 `miniprogramRoot: "miniprogram/"`。 |
|
||||
| `miniprogram/app.json` | 小程序全局配置,注册 `pages/web-view/index`。 |
|
||||
| `miniprogram/config.js` | 业务域名入口配置,需要部署时填写。 |
|
||||
| `miniprogram/pages/web-view/index.*` | 最小 web-view 页面。 |
|
||||
| `server-rs/crates/api-server/src/wechat_auth.rs` | 新增小程序登录接口 `/api/auth/wechat/miniprogram-login`。 |
|
||||
| `server-rs/crates/platform-auth/src/lib.rs` | 新增 `jscode2session` 兑换能力。 |
|
||||
|
||||
## 3. 需要手工填写的配置
|
||||
|
||||
在 `miniprogram/config.js` 中填写:
|
||||
|
||||
```js
|
||||
const WEB_VIEW_ENTRY_URL = 'https://你的H5业务域名/';
|
||||
const API_BASE_URL = 'https://你的服务器域名/';
|
||||
const MINI_PROGRAM_APP_ID = '你的微信小程序 AppID';
|
||||
const MINI_PROGRAM_ENV = 'develop';
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
1. 必须是 `https`。
|
||||
2. 不能是 `localhost` 或 IP。
|
||||
3. `WEB_VIEW_ENTRY_URL` 域名需要在微信小程序后台配置为业务域名。
|
||||
4. `API_BASE_URL` 域名需要在微信小程序后台配置为 request 合法域名。
|
||||
5. H5 页面里的 API、图片、音视频、iframe 等外链也要满足微信侧域名与证书要求。
|
||||
|
||||
在 `api-server` 环境变量中填写:
|
||||
|
||||
```bash
|
||||
WECHAT_AUTH_ENABLED=true
|
||||
WECHAT_AUTH_PROVIDER=real
|
||||
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
|
||||
WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret"
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
|
||||
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
|
||||
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
|
||||
```
|
||||
|
||||
如果开放平台网页 OAuth 与小程序使用同一个 AppID/Secret,也可以继续使用已有:
|
||||
|
||||
```bash
|
||||
WECHAT_APP_ID="你的微信 AppID"
|
||||
WECHAT_APP_SECRET="你的微信 AppSecret"
|
||||
```
|
||||
|
||||
但正式部署建议把小程序配置写到 `WECHAT_MINI_PROGRAM_APP_ID` 与 `WECHAT_MINI_PROGRAM_APP_SECRET`,避免和网页 OAuth 配置混淆。
|
||||
|
||||
`WEB_VIEW_SOURCE_QUERY` 默认附加:
|
||||
|
||||
```text
|
||||
clientType=mini_program
|
||||
clientRuntime=wechat_mini_program
|
||||
```
|
||||
|
||||
小程序壳调用登录接口时会补传:
|
||||
|
||||
```text
|
||||
x-client-type=mini_program
|
||||
x-client-runtime=wechat_mini_program
|
||||
x-client-platform=ios|android|unknown
|
||||
x-client-instance-id=<小程序本地持久化随机值>
|
||||
x-mini-program-app-id=<MINI_PROGRAM_APP_ID>
|
||||
x-mini-program-env=<MINI_PROGRAM_ENV>
|
||||
```
|
||||
|
||||
这些字段会进入 refresh session 的客户端身份快照;URL query 只作为 H5 识别宿主来源的轻量标记,不作为鉴权依据。
|
||||
|
||||
## 4. 登录链路
|
||||
|
||||
当前登录链路固定为:
|
||||
|
||||
1. 小程序页面启动。
|
||||
2. 调用 `wx.login` 获取一次性 `code`。
|
||||
3. 小程序壳请求:
|
||||
|
||||
```http
|
||||
POST /api/auth/wechat/miniprogram-login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"code": "wx.login 返回的 code"
|
||||
}
|
||||
```
|
||||
|
||||
4. `api-server` 调用微信 `jscode2session` 兑换 `openid/unionid`。
|
||||
5. `api-server` 复用现有微信身份逻辑:
|
||||
- 先按 `unionid` 命中已有身份
|
||||
- 再按 `openid` 命中已有身份
|
||||
- 都没有命中时创建 `pending_bind_phone` 的微信壳账号
|
||||
6. `api-server` 签发系统 access token,并写入 refresh session。
|
||||
7. 如果返回 `bindingStatus=active`,小程序壳打开:
|
||||
|
||||
```text
|
||||
https://你的H5业务域名/#auth_provider=wechat&auth_token=<系统JWT>&auth_binding_status=active
|
||||
```
|
||||
|
||||
8. 如果返回 `bindingStatus=pending_bind_phone`,小程序壳暂不打开 H5,而是展示原生 `getPhoneNumber` 按钮。用户点击并同意后,小程序把 `bindgetphonenumber` 事件里的 `detail.code` 作为 `wechatPhoneCode` 传给:
|
||||
|
||||
```http
|
||||
POST /api/auth/wechat/bind-phone
|
||||
Authorization: Bearer <小程序登录返回的系统JWT>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"wechatPhoneCode": "getPhoneNumber 返回的 code"
|
||||
}
|
||||
```
|
||||
|
||||
9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。微信返回的手机号字段使用 `phoneNumber` / `purePhoneNumber` / `countryCode`,后端解析时必须兼容这些原始 camelCase 字段;否则会在已收到 `wechatPhoneCode` 的情况下误报“微信手机号授权失败:缺少手机号”。成功后重新签发 `active` 系统 token。
|
||||
10. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。
|
||||
|
||||
补充:H5 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。
|
||||
|
||||
## 5. 微信后台配置
|
||||
|
||||
至少需要在小程序后台配置:
|
||||
|
||||
1. `业务域名`:承载 H5 的域名。
|
||||
2. `request 合法域名`:`API_BASE_URL` 对应的服务器域名。
|
||||
3. `socket 合法域名`:若后续小程序原生层直连 WebSocket 才需要;当前不启用。
|
||||
|
||||
当前仓库的 H5 仍建议通过同域 `/api/*` 访问 Rust `api-server`,避免在小程序和 H5 中分别维护跨域白名单。
|
||||
|
||||
## 6. 当前不做的事
|
||||
|
||||
本次不做原生小程序页面迁移,原因是当前主前端依赖:
|
||||
|
||||
1. React DOM 挂载、浏览器 history 和 `window.location`。
|
||||
2. `localStorage` / `sessionStorage`。
|
||||
3. 浏览器 `fetch` 与 `ReadableStream` SSE。
|
||||
4. DOM、Canvas、Three.js 等浏览器渲染能力。
|
||||
|
||||
这些能力不能稳定原样运行在原生小程序宿主中。后续如要原生化,应新建小程序端宿主,复用 `packages/shared` 契约和 `api-server` BFF,而不是把 `src/` 整体搬过去。
|
||||
|
||||
本次也不做 `openid` query 直登。原因是 `openid` 不是本系统签发的登录凭证,不能表达 token 版本、会话 ID、绑定状态、角色与过期时间,也不能被 H5 直接信任。
|
||||
|
||||
## 7. 验收口径
|
||||
|
||||
可重复自动化 smoke:
|
||||
|
||||
```bash
|
||||
npm run check:wechat-miniprogram-auth
|
||||
```
|
||||
|
||||
该命令固定覆盖三段链路:
|
||||
|
||||
1. 静态确认 `miniprogram/pages/web-view/index.js` 会请求 `/api/auth/wechat/miniprogram-login`,携带 `mini_program / wechat_mini_program` 客户端来源头,并把 `auth_provider/auth_token/auth_binding_status` 拼入 H5 hash。
|
||||
2. 运行 `api-server` 定向测试 `wechat_miniprogram_login_returns_system_token_and_marks_session_source`,断言小程序登录返回 `token/bindingStatus/user`、写入 refresh cookie,并且 `/api/auth/sessions` 能看到 `clientType=mini_program`、`clientRuntime=wechat_mini_program`、`miniProgramAppId`。
|
||||
3. 静态确认小程序壳在 `pending_bind_phone` 时使用 `getPhoneNumber` 和 `wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`,而不是打开 H5 后再要求手输手机号。
|
||||
4. 运行前端 `authService` 定向测试,断言 `consumeAuthCallbackResult()` 会消费 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`、保存 access token,并清理地址栏 hash。
|
||||
|
||||
手工联调仍按以下口径确认真实微信与域名配置:
|
||||
|
||||
1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。
|
||||
2. 未填写 `WEB_VIEW_ENTRY_URL` 或 `API_BASE_URL` 时,页面显示配置提示,不出现空白页。
|
||||
3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`。
|
||||
4. 后端返回 `token/bindingStatus/user`,并写入 refresh cookie。
|
||||
5. 若返回 `pending_bind_phone`,先看到小程序原生授权手机号按钮;用户同意后,小程序请求 `/api/auth/wechat/bind-phone` 且请求体包含 `wechatPhoneCode`。
|
||||
6. 绑定成功后首页全屏打开 H5,URL hash 中包含 `auth_provider=wechat`、`auth_token`、`auth_binding_status=active`。
|
||||
7. H5 内 `consumeAuthCallbackResult()` 消费 hash 后,`/api/auth/me` 能返回当前用户。
|
||||
8. `/api/auth/sessions` 能看到来源为 `mini_program / wechat_mini_program` 的会话记录。
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
'
|
||||
|
||||
@@ -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
|
||||
'
|
||||
|
||||
@@ -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
|
||||
'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -54,20 +55,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
|
||||
'
|
||||
|
||||
42
media/files/disclaimer.md
Normal 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. 平台依法配合国家有关部门的监管和调查工作,并根据法律要求提供必要的用户信息和操作记录。
|
||||
|
||||
### 七、责任限制
|
||||
在法律允许的最大范围内,平台就任何间接、附带、特殊、惩罚性损害不承担责任。平台对用户因使用本平台而产生的任何损失所承担的最大责任,不超过用户实际向平台支付的费用金额。
|
||||
61
media/files/privacy_policy.md
Normal 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 周岁的未成年人提供服务。如我们发现在未获得家长或监护人同意的情况下收集了未成年人的个人信息,将尽快删除相关信息。
|
||||
|
||||
### 八、论坛用户生成内容
|
||||
当您在论坛发布内容时,我们会收集并存储以下信息:
|
||||
- 您发布的文字、图片等内容本身
|
||||
- 发布时间、编辑记录等操作信息
|
||||
- 与内容关联的互动信息(点赞、评论等)
|
||||
请注意:您在论坛公开发布的内容对所有平台用户可见。请勿在公开内容中包含个人敏感信息。您上传的图片可能包含元数据(如拍摄时间、地理位置),建议您在上传前自行清除此类信息。
|
||||
|
||||
### 九、政策更新
|
||||
本政策可能根据法律法规变化或平台运营需要进行更新。更新后将在平台公布,继续使用即视为同意更新后的政策。
|
||||
103
media/files/user_agreement.md
Normal 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密钥产生的任何后果负责。
|
||||
|
||||
### 十一、协议修改
|
||||
平台有权根据需要修改本协议。修改后的协议将在平台上公布,继续使用平台服务即视为接受修改后的协议。
|
||||
|
||||
### 十二、适用法律与争议解决
|
||||
本协议适用中华人民共和国法律。因本协议引起的争议,双方应友好协商解决;协商不成的,任一方均有权向平台所在地有管辖权的人民法院提起诉讼。
|
||||
10
miniprogram/app.js
Normal file
@@ -0,0 +1,10 @@
|
||||
App({
|
||||
globalData: {
|
||||
launchOptions: null,
|
||||
},
|
||||
|
||||
onLaunch(options) {
|
||||
// 中文注释:保留启动参数,后续如果要把分享路径映射到 H5 深链,可以从这里统一读取。
|
||||
this.globalData.launchOptions = options;
|
||||
},
|
||||
});
|
||||
21
miniprogram/app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/web-view/index"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTitleText": "百梦",
|
||||
"navigationBarBackgroundColor": "#0b0f14",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#0b0f14",
|
||||
"backgroundTextStyle": "light"
|
||||
},
|
||||
"networkTimeout": {
|
||||
"request": 60000,
|
||||
"connectSocket": 60000,
|
||||
"uploadFile": 60000,
|
||||
"downloadFile": 60000
|
||||
},
|
||||
"permission": {},
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json"
|
||||
}
|
||||
5
miniprogram/app.wxss
Normal file
@@ -0,0 +1,5 @@
|
||||
page {
|
||||
min-height: 100vh;
|
||||
background: #0b0f14;
|
||||
color: #f5f7fb;
|
||||
}
|
||||
28
miniprogram/config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。
|
||||
// 示例:https://game.example.com/
|
||||
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
|
||||
const WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world/';
|
||||
|
||||
// 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。
|
||||
// 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。
|
||||
const API_BASE_URL = 'https://dev.genarrative.world/';
|
||||
|
||||
// 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。
|
||||
const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
|
||||
|
||||
// 中文注释:按当前上传版本填写 develop / trial / release,后端会写入会话来源快照。
|
||||
const MINI_PROGRAM_ENV = 'develop';
|
||||
|
||||
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
|
||||
const WEB_VIEW_SOURCE_QUERY = {
|
||||
clientType: 'mini_program',
|
||||
clientRuntime: 'wechat_mini_program',
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
API_BASE_URL,
|
||||
MINI_PROGRAM_APP_ID,
|
||||
MINI_PROGRAM_ENV,
|
||||
WEB_VIEW_ENTRY_URL,
|
||||
WEB_VIEW_SOURCE_QUERY,
|
||||
};
|
||||
349
miniprogram/pages/web-view/index.js
Normal file
@@ -0,0 +1,349 @@
|
||||
const {
|
||||
API_BASE_URL,
|
||||
MINI_PROGRAM_APP_ID,
|
||||
MINI_PROGRAM_ENV,
|
||||
WEB_VIEW_ENTRY_URL,
|
||||
WEB_VIEW_SOURCE_QUERY,
|
||||
} = require('../../config');
|
||||
|
||||
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
|
||||
|
||||
function isConfiguredEntryUrl(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
return /^https:\/\/[^/]+/i.test(trimmed);
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
return String(value || '').trim().replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function isConfiguredApiBaseUrl(value) {
|
||||
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function appendQuery(url, query) {
|
||||
const pairs = Object.keys(query)
|
||||
.filter((key) => query[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
||||
);
|
||||
|
||||
if (pairs.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
|
||||
}
|
||||
|
||||
function appendHashParams(url, params) {
|
||||
const pairs = Object.keys(params)
|
||||
.filter((key) => params[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||
);
|
||||
if (pairs.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const hashIndex = url.indexOf('#');
|
||||
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||
const separator = rawHash ? '&' : '';
|
||||
return `${baseUrl}#${rawHash}${separator}${pairs.join('&')}`;
|
||||
}
|
||||
|
||||
function resolveWebViewUrl(authResult) {
|
||||
const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim();
|
||||
if (!isConfiguredEntryUrl(entryUrl)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sourcedUrl = appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY);
|
||||
if (!authResult || !authResult.token) {
|
||||
return sourcedUrl;
|
||||
}
|
||||
|
||||
return appendHashParams(sourcedUrl, {
|
||||
auth_provider: 'wechat',
|
||||
auth_token: authResult.token,
|
||||
auth_binding_status: authResult.bindingStatus,
|
||||
});
|
||||
}
|
||||
|
||||
function getClientInstanceId() {
|
||||
const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return String(stored);
|
||||
}
|
||||
|
||||
const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId);
|
||||
return nextId;
|
||||
}
|
||||
|
||||
function resolveClientPlatform() {
|
||||
const info = wx.getSystemInfoSync();
|
||||
const platform = String(info.platform || '').toLowerCase();
|
||||
if (platform === 'ios') {
|
||||
return 'ios';
|
||||
}
|
||||
if (platform === 'android') {
|
||||
return 'android';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function wxLogin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.login({
|
||||
success(result) {
|
||||
if (result.code) {
|
||||
resolve(result.code);
|
||||
return;
|
||||
}
|
||||
reject(new Error('微信登录未返回 code'));
|
||||
},
|
||||
fail(error) {
|
||||
reject(new Error(error.errMsg || '微信登录失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requestMiniProgramLogin(code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
|
||||
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
|
||||
reject(new Error('请先配置 API_BASE_URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
wx.request({
|
||||
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
|
||||
method: 'POST',
|
||||
data: { code },
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
|
||||
'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME,
|
||||
'x-client-platform': resolveClientPlatform(),
|
||||
'x-client-instance-id': getClientInstanceId(),
|
||||
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
|
||||
'x-mini-program-env': MINI_PROGRAM_ENV,
|
||||
},
|
||||
success(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve(response.data);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
response.data &&
|
||||
response.data.error &&
|
||||
response.data.error.message
|
||||
? response.data.error.message
|
||||
: `微信登录失败:${response.statusCode}`;
|
||||
reject(new Error(message));
|
||||
},
|
||||
fail(error) {
|
||||
reject(new Error(error.errMsg || '微信登录请求失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
|
||||
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
|
||||
reject(new Error('请先配置 API_BASE_URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
wx.request({
|
||||
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
|
||||
method: 'POST',
|
||||
data: { wechatPhoneCode },
|
||||
header: {
|
||||
authorization: `Bearer ${authToken}`,
|
||||
'content-type': 'application/json',
|
||||
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
|
||||
'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME,
|
||||
'x-client-platform': resolveClientPlatform(),
|
||||
'x-client-instance-id': getClientInstanceId(),
|
||||
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
|
||||
'x-mini-program-env': MINI_PROGRAM_ENV,
|
||||
},
|
||||
success(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve(response.data);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
response.data &&
|
||||
response.data.error &&
|
||||
response.data.error.message
|
||||
? response.data.error.message
|
||||
: `绑定手机号失败:${response.statusCode}`;
|
||||
reject(new Error(message));
|
||||
},
|
||||
fail(error) {
|
||||
reject(new Error(error.errMsg || '绑定手机号请求失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAuthResult() {
|
||||
const code = await wxLogin();
|
||||
const response = await requestMiniProgramLogin(code);
|
||||
if (!response || !response.token) {
|
||||
throw new Error('服务器未返回登录态');
|
||||
}
|
||||
return {
|
||||
token: response.token,
|
||||
bindingStatus: response.bindingStatus || 'pending_bind_phone',
|
||||
};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
authResult: null,
|
||||
bindingPhone: false,
|
||||
errorMessage: '',
|
||||
loading: true,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: '',
|
||||
},
|
||||
|
||||
async onLoad() {
|
||||
// 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
|
||||
if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) {
|
||||
this.setData({
|
||||
errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。',
|
||||
loading: false,
|
||||
webViewUrl: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
|
||||
this.setData({
|
||||
errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。',
|
||||
loading: false,
|
||||
webViewUrl: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authResult = await resolveAuthResult();
|
||||
if (authResult.bindingStatus === 'pending_bind_phone') {
|
||||
this.setData({
|
||||
authResult,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
phoneBindingRequired: true,
|
||||
webViewUrl: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setData({
|
||||
authResult,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
authResult: null,
|
||||
errorMessage:
|
||||
error && error.message
|
||||
? error.message
|
||||
: '微信登录失败,请稍后重试。',
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: '',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async handleGetPhoneNumber(event) {
|
||||
if (!this.data.authResult || !this.data.authResult.token) {
|
||||
this.handleRetryLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = event.detail || {};
|
||||
if (!detail.code) {
|
||||
this.setData({
|
||||
errorMessage: detail.errMsg || '需要授权手机号后才能完成绑定。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setData({
|
||||
bindingPhone: true,
|
||||
errorMessage: '',
|
||||
});
|
||||
try {
|
||||
const response = await requestMiniProgramBindPhone(
|
||||
this.data.authResult.token,
|
||||
detail.code,
|
||||
);
|
||||
if (!response || !response.token) {
|
||||
throw new Error('服务器未返回绑定后的登录态');
|
||||
}
|
||||
const nextAuthResult = {
|
||||
token: response.token,
|
||||
bindingStatus: 'active',
|
||||
};
|
||||
this.setData({
|
||||
authResult: nextAuthResult,
|
||||
bindingPhone: false,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(nextAuthResult),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
bindingPhone: false,
|
||||
errorMessage:
|
||||
error && error.message
|
||||
? error.message
|
||||
: '绑定手机号失败,请稍后重试。',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleRetryLogin() {
|
||||
this.setData({
|
||||
authResult: null,
|
||||
bindingPhone: false,
|
||||
errorMessage: '',
|
||||
loading: true,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: '',
|
||||
});
|
||||
this.onLoad();
|
||||
},
|
||||
|
||||
handleWebViewLoad(event) {
|
||||
console.info('[web-view] loaded', event.detail);
|
||||
},
|
||||
|
||||
handleWebViewError(event) {
|
||||
console.error('[web-view] load failed', event.detail);
|
||||
},
|
||||
|
||||
handleWebViewMessage(event) {
|
||||
// 中文注释:H5 如需和小程序壳通信,可通过 wx.miniProgram.postMessage 发送轻量消息。
|
||||
console.info('[web-view] message', event.detail);
|
||||
},
|
||||
});
|
||||
3
miniprogram/pages/web-view/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
47
miniprogram/pages/web-view/index.wxml
Normal file
@@ -0,0 +1,47 @@
|
||||
<block wx:if="{{webViewUrl}}">
|
||||
<web-view
|
||||
src="{{webViewUrl}}"
|
||||
bindload="handleWebViewLoad"
|
||||
binderror="handleWebViewError"
|
||||
bindmessage="handleWebViewMessage"
|
||||
/>
|
||||
</block>
|
||||
|
||||
<view wx:elif="{{loading}}" class="setup-screen">
|
||||
<view class="setup-card">
|
||||
<view class="setup-title">正在登录</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{phoneBindingRequired}}" class="setup-screen">
|
||||
<view class="setup-card">
|
||||
<view class="setup-title">绑定手机号</view>
|
||||
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
|
||||
{{errorMessage}}
|
||||
</view>
|
||||
<button
|
||||
class="retry-button"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="handleGetPhoneNumber"
|
||||
loading="{{bindingPhone}}"
|
||||
disabled="{{bindingPhone}}"
|
||||
>
|
||||
{{bindingPhone ? '正在绑定' : '微信授权手机号'}}
|
||||
</button>
|
||||
<button
|
||||
class="ghost-button"
|
||||
disabled="{{bindingPhone}}"
|
||||
bindtap="handleRetryLogin"
|
||||
>
|
||||
重新登录
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="setup-screen">
|
||||
<view class="setup-card">
|
||||
<view class="setup-title">无法进入</view>
|
||||
<view class="setup-text">{{errorMessage}}</view>
|
||||
<button class="retry-button" bindtap="handleRetryLogin">重试</button>
|
||||
</view>
|
||||
</view>
|
||||
58
miniprogram/pages/web-view/index.wxss
Normal file
@@ -0,0 +1,58 @@
|
||||
.setup-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
background: #0b0f14;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.setup-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;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: #f5f7fb;
|
||||
}
|
||||
|
||||
.setup-text {
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.55;
|
||||
color: rgba(245, 247, 251, 0.72);
|
||||
}
|
||||
|
||||
.setup-text--danger {
|
||||
color: #ffb4a9;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
margin-top: 28rpx;
|
||||
width: 100%;
|
||||
border-radius: 8rpx;
|
||||
background: #f5f7fb;
|
||||
color: #0b0f14;
|
||||
font-size: 28rpx;
|
||||
line-height: 2.6;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
margin-top: 18rpx;
|
||||
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;
|
||||
}
|
||||
8
miniprogram/sitemap.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,8 +23,10 @@
|
||||
"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",
|
||||
"check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs",
|
||||
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
|
||||
"lint:guardrails": "npm run lint:eslint",
|
||||
|
||||
@@ -114,8 +114,9 @@ export type AuthWechatStartResponse = {
|
||||
};
|
||||
|
||||
export type AuthWechatBindPhoneRequest = {
|
||||
phone: string;
|
||||
code: string;
|
||||
phone?: string;
|
||||
code?: string;
|
||||
wechatPhoneCode?: string;
|
||||
};
|
||||
|
||||
export type AuthWechatBindPhoneResponse = {
|
||||
@@ -123,6 +124,16 @@ export type AuthWechatBindPhoneResponse = {
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export type AuthWechatMiniProgramLoginRequest = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type AuthWechatMiniProgramLoginResponse = {
|
||||
token: string;
|
||||
bindingStatus: AuthBindingStatus;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export type AuthPhoneChangeRequest = {
|
||||
phone: string;
|
||||
code: string;
|
||||
@@ -139,6 +150,8 @@ export type AuthRefreshResponse = {
|
||||
|
||||
export type AuthSessionSummary = {
|
||||
sessionId: string;
|
||||
sessionIds: string[];
|
||||
sessionCount: number;
|
||||
clientType: string;
|
||||
clientRuntime: string;
|
||||
clientPlatform: string;
|
||||
|
||||
89
packages/shared/src/contracts/edutainmentBabyObject.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export const BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
|
||||
export const BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
||||
export const BABY_OBJECT_MATCH_EDUTAINMENT_TAG = '寓教于乐';
|
||||
|
||||
export type BabyObjectMatchTemplateId =
|
||||
typeof BABY_OBJECT_MATCH_TEMPLATE_ID;
|
||||
|
||||
export type BabyObjectMatchAssetProvider =
|
||||
| 'vector-engine-gpt-image-2'
|
||||
| 'placeholder';
|
||||
|
||||
export type BabyObjectMatchPublicationStatus = 'draft' | 'published';
|
||||
|
||||
export type BabyObjectMatchItemAsset = {
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
imageSrc: string;
|
||||
assetObjectId: string | null;
|
||||
generationProvider: BabyObjectMatchAssetProvider;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type BabyObjectMatchDraft = {
|
||||
draftId: string;
|
||||
profileId: string;
|
||||
templateId: BabyObjectMatchTemplateId;
|
||||
templateName: typeof BABY_OBJECT_MATCH_TEMPLATE_NAME;
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
itemNames: [string, string];
|
||||
itemAssets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset];
|
||||
themeTags: string[];
|
||||
publicationStatus: BabyObjectMatchPublicationStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt: string | null;
|
||||
};
|
||||
|
||||
export type CreateBabyObjectMatchDraftRequest = {
|
||||
itemAName: string;
|
||||
itemBName: string;
|
||||
};
|
||||
|
||||
export type BabyObjectMatchDraftResponse = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
};
|
||||
|
||||
export type SaveBabyObjectMatchDraftRequest = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
};
|
||||
|
||||
export type BabyObjectMatchPublishRequest = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
};
|
||||
|
||||
export type BabyObjectMatchPublishResponse = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
publicWorkCode: string;
|
||||
};
|
||||
|
||||
export function normalizeBabyObjectMatchItemName(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function normalizeBabyObjectMatchTags(tags: string[]) {
|
||||
return [
|
||||
...new Set([
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
...tags.map((tag) => tag.trim()).filter(Boolean),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function hasBabyObjectMatchRequiredTag(tags: string[]) {
|
||||
return tags.some((tag) => tag === BABY_OBJECT_MATCH_EDUTAINMENT_TAG);
|
||||
}
|
||||
|
||||
export function validateBabyObjectMatchItemNames(
|
||||
payload: CreateBabyObjectMatchDraftRequest,
|
||||
) {
|
||||
const itemAName = normalizeBabyObjectMatchItemName(payload.itemAName);
|
||||
const itemBName = normalizeBabyObjectMatchItemName(payload.itemBName);
|
||||
|
||||
return {
|
||||
itemAName,
|
||||
itemBName,
|
||||
valid: Boolean(itemAName && itemBName),
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export type Match3DClickConfirmStatus =
|
||||
|
||||
export interface StartMatch3DRunRequest {
|
||||
profileId: string;
|
||||
itemTypeCountOverride?: number | null;
|
||||
}
|
||||
|
||||
export interface Match3DClickItemRequest {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ export type * from './contracts/creationAgentDocumentInput';
|
||||
export type * from './contracts/creationAudio';
|
||||
export type * from './contracts/creativeAgent';
|
||||
export type * from './contracts/customWorldAgent';
|
||||
export * from './contracts/edutainmentBabyObject';
|
||||
export type * from './contracts/hyper3d';
|
||||
export * from './contracts/match3dAgent';
|
||||
export * from './contracts/match3dRuntime';
|
||||
@@ -13,8 +14,8 @@ export * from './contracts/match3dWorks';
|
||||
export * from './contracts/puzzleAgentActions';
|
||||
export * from './contracts/puzzleAgentDraft';
|
||||
export * from './contracts/puzzleAgentSession';
|
||||
export * from './contracts/puzzleOnboarding';
|
||||
export type * from './contracts/puzzleCreativeTemplate';
|
||||
export * from './contracts/puzzleOnboarding';
|
||||
export * from './contracts/puzzleResultPreview';
|
||||
export * from './contracts/puzzleRuntimeSession';
|
||||
export * from './contracts/puzzleWorkSummary';
|
||||
|
||||
26
project.config.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"setting": {
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"uglifyFileName": false,
|
||||
"enhance": true,
|
||||
"packNpmRelationList": [],
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"useCompilerPlugins": false,
|
||||
"minifyWXML": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"miniprogramRoot": "miniprogram/",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"appid": "wx3da23ea14ca66b65",
|
||||
"editorSetting": {}
|
||||
}
|
||||
14
project.private.config.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"libVersion": "3.15.2",
|
||||
"projectname": "Genarrative",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"coverView": true,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"compileHotReLoad": true
|
||||
}
|
||||
}
|
||||
BIN
public/audio/ui-click-soft.wav
Normal file
BIN
public/audio/ui-countdown-warning.wav
Normal file
BIN
public/audio/ui-level-clear.wav
Normal file
BIN
public/child-motion-demo/picture-book-calibration-strip-v2.png
Normal file
|
After Width: | Height: | Size: 438 KiB |
BIN
public/child-motion-demo/picture-book-character-outline-v2.png
Normal file
|
After Width: | Height: | Size: 665 KiB |
BIN
public/child-motion-demo/picture-book-foreground-grass-v2.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/child-motion-demo/picture-book-grass-stage.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/child-motion-demo/picture-book-ground-ring-v2.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
public/child-motion-demo/picture-book-hud-strip-v2.png
Normal file
|
After Width: | Height: | Size: 792 KiB |
BIN
public/child-motion-demo/picture-book-start-panel-v2.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
public/child-motion-demo/picture-book-ui-button-v2.png
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
public/match3d-background-references/pot-fused-reference.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
public/match3d-style-references/cel-cartoon.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
BIN
public/match3d-style-references/flat-icon.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
BIN
public/match3d-style-references/painterly-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/match3d-style-references/pixel-retro.png
Normal file
|
After Width: | Height: | Size: 1004 KiB |
BIN
public/match3d-style-references/sticker-outline.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
BIN
public/match3d-style-references/watercolor.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
120
scripts/check-wechat-miniprogram-auth-smoke.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const failures = [];
|
||||
|
||||
const smokeSteps = [
|
||||
{
|
||||
label: '小程序壳请求与 hash 回跳静态检查',
|
||||
run: checkMiniProgramShell,
|
||||
},
|
||||
{
|
||||
label: 'api-server 小程序登录与会话来源测试',
|
||||
run: () =>
|
||||
runCommand('cargo', [
|
||||
'test',
|
||||
'-p',
|
||||
'api-server',
|
||||
'wechat_miniprogram_login_returns_system_token_and_marks_session_source',
|
||||
'--manifest-path',
|
||||
'server-rs/Cargo.toml',
|
||||
'--',
|
||||
'--nocapture',
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'H5 auth hash 消费测试',
|
||||
run: () =>
|
||||
runCommand(process.execPath, [
|
||||
fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)),
|
||||
'run',
|
||||
'src/services/authService.test.ts',
|
||||
'-t',
|
||||
'consumes auth callback hash and persists the returned access token',
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
for (const step of smokeSteps) {
|
||||
console.log(`[wechat-miniprogram-auth-smoke] ${step.label}`);
|
||||
step.run();
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('\n[wechat-miniprogram-auth-smoke] 未通过:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n[wechat-miniprogram-auth-smoke] 通过');
|
||||
|
||||
function checkMiniProgramShell() {
|
||||
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
|
||||
const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml');
|
||||
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
|
||||
|
||||
ensureNeedles(shellPath, [
|
||||
'/api/auth/wechat/miniprogram-login',
|
||||
'/api/auth/wechat/bind-phone',
|
||||
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
|
||||
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
|
||||
'auth_provider',
|
||||
'auth_token',
|
||||
'auth_binding_status',
|
||||
'bindingStatus',
|
||||
'pending_bind_phone',
|
||||
'wechatPhoneCode',
|
||||
]);
|
||||
|
||||
ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']);
|
||||
|
||||
// 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。
|
||||
ensureNeedles(authServiceTestPath, [
|
||||
'#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
|
||||
'consumeAuthCallbackResult()',
|
||||
"bindingStatus: 'pending_bind_phone'",
|
||||
"expect(getStoredAccessToken()).toBe('jwt-callback-token')",
|
||||
]);
|
||||
}
|
||||
|
||||
function ensureNeedles(relativeOrFullPath, needles) {
|
||||
if (!existsSync(relativeOrFullPath)) {
|
||||
failures.push(`缺少文件:${relativeOrFullPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = readFileSync(relativeOrFullPath, 'utf8');
|
||||
for (const needle of needles) {
|
||||
if (!content.includes(needle)) {
|
||||
failures.push(`${relativeOrFullPath} 缺少内容:${needle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
failures.push(`${command} 启动失败:${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
failures.push(`${command} 被信号终止:${result.signal}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
failures.push(`${command} ${args.join(' ')} 退出码 ${result.status}`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -10,16 +11,13 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const defaultOut = path.join(
|
||||
repoRoot,
|
||||
'public',
|
||||
'child-motion-demo',
|
||||
'picture-book-grass-stage.webp',
|
||||
);
|
||||
const defaultSize = '1536x1024';
|
||||
const assetDir = path.join(repoRoot, 'public', 'child-motion-demo');
|
||||
const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets');
|
||||
const defaultTimeoutMs = 180000;
|
||||
const chromaKeyColor = '#ff00ff';
|
||||
const layoutReferenceOutput = 'picture-book-stage-layout-v2.png';
|
||||
|
||||
const prompt = [
|
||||
const backgroundPrompt = [
|
||||
'请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。',
|
||||
'画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。',
|
||||
'远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。',
|
||||
@@ -28,6 +26,252 @@ const prompt = [
|
||||
'不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。',
|
||||
].join('');
|
||||
|
||||
const styleReferenceNote = [
|
||||
'参考图仅用于统一卡通绘本草地舞台的色彩、笔触、纸张纹理和明亮童趣气质。',
|
||||
'不要复制参考图构图,不要出现真实照片质感。',
|
||||
].join('');
|
||||
|
||||
const layoutReferencePrompt = [
|
||||
'请基于参考背景重新设计一张 16:9 儿童动作互动游戏热身关版式参考图,卡通绘本草地风格保持统一。',
|
||||
'背景品质和明亮草地绘本质感沿用参考图,不要把背景做暗或做成科技风。',
|
||||
'画面中心到下方中部保持开阔,留给半透明角色轮廓和地面椭圆指示环。',
|
||||
'底部只放一条自然的前景草坪边缘,占舞台高度约 18% 到 22%,草叶比例真实可爱,不要拉伸成扁平色块。',
|
||||
'顶部居中放一个小型横向 HUD 软纸条,占舞台宽度约 45% 到 52%,高度约 9% 到 12%,不要做成整屏顶部栏。',
|
||||
'右下角放一个小型五格状态条,占舞台宽度约 28% 到 34%,高度约 6% 到 8%,不要压住角色脚下区域。',
|
||||
'开始按钮占位使用小型胶囊按钮和轻盈托盘,整体不要超过舞台宽度 26%。',
|
||||
'所有 UI 都是无文字、无图标的空白资源占位,边缘带少量草叶、水彩纸张纹理和浅蓝高光。',
|
||||
'不要出现人物、动物、文字、数字、水印、摄像头画面、真实照片质感。',
|
||||
].join('');
|
||||
|
||||
const chromaKeyNote = [
|
||||
`背景必须是完全纯色、均匀一致的 ${chromaKeyColor} 品红色,用于后续去背。`,
|
||||
'背景不能有阴影、渐变、纹理、地面、反光或光照变化。',
|
||||
`主体中不要使用 ${chromaKeyColor} 或接近品红的颜色。`,
|
||||
'主体边缘保持清晰,四周留出充足空白。',
|
||||
'不要出现文字、水印、真实照片质感。',
|
||||
].join('');
|
||||
|
||||
const noStretchNote = [
|
||||
'资源自身必须按最终用途设计比例绘制,不要画成方形卡片再留大面积空白。',
|
||||
'网页端会按资源原始比例等比缩放使用,不会把资源横向或纵向强行拉伸。',
|
||||
'不要出现文字、数字、按钮文案、水印、真实照片质感。',
|
||||
].join('');
|
||||
|
||||
const assetDefinitions = [
|
||||
{
|
||||
id: 'background',
|
||||
output: 'picture-book-grass-stage.png',
|
||||
size: '1536x1024',
|
||||
prompt: backgroundPrompt,
|
||||
transparent: false,
|
||||
useBackgroundReference: false,
|
||||
},
|
||||
{
|
||||
id: 'layout-reference-v2',
|
||||
output: layoutReferenceOutput,
|
||||
outputDirectory: 'intermediate',
|
||||
size: '2048x1152',
|
||||
prompt: layoutReferencePrompt,
|
||||
transparent: false,
|
||||
useBackgroundReference: true,
|
||||
},
|
||||
{
|
||||
id: 'floor',
|
||||
output: 'picture-book-foreground-grass-v2.png',
|
||||
sourceOutput: 'picture-book-foreground-grass-v2-source.png',
|
||||
size: '2048x768',
|
||||
transparent: true,
|
||||
useBackgroundReference: true,
|
||||
useLayoutReference: true,
|
||||
layoutNormalization: {
|
||||
canvasWidth: 2048,
|
||||
canvasHeight: 640,
|
||||
fit: 'cover-width',
|
||||
fillWidth: 1.04,
|
||||
anchorY: 'bottom',
|
||||
padding: 18,
|
||||
},
|
||||
prompt: [
|
||||
'请生成儿童动作互动游戏的底部前景草坪资源,不是完整背景。',
|
||||
'主体是一条横向自然草地边缘,用于覆盖 16:9 舞台最下方约五分之一高度。',
|
||||
'草坪顶部边缘有松散手绘草叶和少量浅色小花,底部更厚实,中心不要出现硬平台、椭圆地毯或 UI 栏。',
|
||||
'整体应像绘本背景自然延伸出来的草地前景,比例宽而舒展,草叶不能被压扁或横向拉伸。',
|
||||
'不要天空、远山、人物、角色、按钮、面板、边框。',
|
||||
'风格必须和参考背景一致:明亮、温暖、卡通绘本、水彩笔触、轻微纸张纹理。',
|
||||
styleReferenceNote,
|
||||
noStretchNote,
|
||||
chromaKeyNote,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'ground-ring',
|
||||
output: 'picture-book-ground-ring-v2.png',
|
||||
sourceOutput: 'picture-book-ground-ring-v2-source.png',
|
||||
size: '1536x512',
|
||||
transparent: true,
|
||||
useBackgroundReference: true,
|
||||
useLayoutReference: true,
|
||||
layoutNormalization: {
|
||||
canvasWidth: 1200,
|
||||
canvasHeight: 520,
|
||||
fit: 'contain',
|
||||
fillWidth: 0.92,
|
||||
fillHeight: 0.78,
|
||||
anchorY: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
prompt: [
|
||||
'请生成一个儿童动作互动游戏地面椭圆指示环资产。',
|
||||
'主体是单个透视椭圆环,直接设计成贴在草地地面上的椭圆,不要依赖网页后期压扁。',
|
||||
'圆环由柔软草叶、水彩绿色描边和浅色高光组成,中心留空,边缘带轻微绘本手绘不规则感。',
|
||||
'整体清爽、明亮、儿童绘本风,不要科技感,不要霓虹,不要金属材质。',
|
||||
styleReferenceNote,
|
||||
noStretchNote,
|
||||
chromaKeyNote,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'character-outline',
|
||||
output: 'picture-book-character-outline-v2.png',
|
||||
sourceOutput: 'picture-book-character-outline-v2-source.png',
|
||||
size: '1024x1536',
|
||||
transparent: true,
|
||||
transparencyCleanup: 'character-outline',
|
||||
useBackgroundReference: true,
|
||||
useLayoutReference: true,
|
||||
layoutNormalization: {
|
||||
canvasWidth: 1024,
|
||||
canvasHeight: 1536,
|
||||
fit: 'contain',
|
||||
fillWidth: 0.78,
|
||||
fillHeight: 0.9,
|
||||
anchorY: 'bottom',
|
||||
padding: 28,
|
||||
},
|
||||
prompt: [
|
||||
'请生成一个儿童动作互动游戏的半透明角色轮廓指示器资产。',
|
||||
'主体是正面站立的人形轮廓,儿童友好比例,无五官、无衣服细节、无性别特征,双臂自然微微张开。',
|
||||
'视觉上像浅蓝绿色水彩发光描边加半透明白色填充,用于表示真实用户的位置剪影。',
|
||||
'轮廓需要简洁清晰,适合缩放到游戏舞台中使用。',
|
||||
styleReferenceNote,
|
||||
noStretchNote,
|
||||
chromaKeyNote,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'hud-strip',
|
||||
output: 'picture-book-hud-strip-v2.png',
|
||||
sourceOutput: 'picture-book-hud-strip-v2-source.png',
|
||||
size: '1536x512',
|
||||
transparent: true,
|
||||
transparencyCleanup: 'soft-panel',
|
||||
useBackgroundReference: true,
|
||||
useLayoutReference: true,
|
||||
layoutNormalization: {
|
||||
canvasWidth: 2200,
|
||||
canvasHeight: 420,
|
||||
fit: 'contain',
|
||||
fillWidth: 0.96,
|
||||
fillHeight: 0.92,
|
||||
anchorY: 'center',
|
||||
padding: 18,
|
||||
},
|
||||
prompt: [
|
||||
'请生成儿童动作互动游戏顶部 HUD 软纸条资产,不是方形面板。',
|
||||
'主体是一条细长横向顶部信息条,目标宽高比约 5:1,像轻盈软纸丝带,不要做成圆形徽章、方形卡片或厚重弹窗。',
|
||||
'中间为浅米白到淡浅绿色水彩软纸区域,左右边缘可以有少量草叶装饰,但不能扩大成大圆端。',
|
||||
'边缘有少量草叶、浅蓝高光和绘本纸张纹理,中心必须干净空白,方便网页叠加标题和进度。',
|
||||
'形状轻盈,适合放在 16:9 舞台顶部居中,占画面宽度约一半,不要做成全宽导航栏或后台系统面板。',
|
||||
styleReferenceNote,
|
||||
noStretchNote,
|
||||
chromaKeyNote,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'calibration-strip',
|
||||
output: 'picture-book-calibration-strip-v2.png',
|
||||
sourceOutput: 'picture-book-calibration-strip-v2-source.png',
|
||||
size: '1536x512',
|
||||
transparent: true,
|
||||
transparencyCleanup: 'soft-panel',
|
||||
useBackgroundReference: true,
|
||||
useLayoutReference: true,
|
||||
layoutNormalization: {
|
||||
canvasWidth: 1800,
|
||||
canvasHeight: 360,
|
||||
fit: 'contain',
|
||||
fillWidth: 0.96,
|
||||
fillHeight: 0.9,
|
||||
anchorY: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
prompt: [
|
||||
'请生成儿童动作互动游戏右下角五格状态条资产,不是方形面板。',
|
||||
'主体是横向小型状态条,内部有五个柔和小胶囊或五个浅色分隔留白区域,但不要写任何文字或数字。',
|
||||
'整体用于舞台右下角,轻薄、不厚重,不压住角色脚下区域。',
|
||||
'米白、淡浅绿和浅蓝水彩高光为主,边缘可以有少量草叶和纸张纹理,风格必须和参考背景一致。',
|
||||
styleReferenceNote,
|
||||
noStretchNote,
|
||||
chromaKeyNote,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'start-panel',
|
||||
output: 'picture-book-start-panel-v2.png',
|
||||
sourceOutput: 'picture-book-start-panel-v2-source.png',
|
||||
size: '1024x512',
|
||||
transparent: true,
|
||||
transparencyCleanup: 'soft-panel',
|
||||
useBackgroundReference: true,
|
||||
useLayoutReference: true,
|
||||
layoutNormalization: {
|
||||
canvasWidth: 1280,
|
||||
canvasHeight: 520,
|
||||
fit: 'contain',
|
||||
fillWidth: 0.88,
|
||||
fillHeight: 0.88,
|
||||
anchorY: 'center',
|
||||
padding: 18,
|
||||
},
|
||||
prompt: [
|
||||
'请生成儿童动作互动游戏开始按钮背后的轻盈托盘资产,不是完整弹窗。',
|
||||
'主体是一个小型横向圆角软纸托盘,中心空白,适合只承载一个开始按钮。',
|
||||
'边缘可以有少量草叶、浅蓝高光和淡绿色纸张纹理,整体要比 HUD 更小、更轻,不要做成大卡片。',
|
||||
'不要文字、数字、图标或按钮文案。',
|
||||
styleReferenceNote,
|
||||
noStretchNote,
|
||||
chromaKeyNote,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
id: 'ui-button',
|
||||
output: 'picture-book-ui-button-v2.png',
|
||||
sourceOutput: 'picture-book-ui-button-v2-source.png',
|
||||
size: '1024x512',
|
||||
transparent: true,
|
||||
useBackgroundReference: true,
|
||||
useLayoutReference: true,
|
||||
layoutNormalization: {
|
||||
canvasWidth: 1300,
|
||||
canvasHeight: 520,
|
||||
fit: 'contain',
|
||||
fillWidth: 0.86,
|
||||
fillHeight: 0.76,
|
||||
anchorY: 'center',
|
||||
padding: 18,
|
||||
},
|
||||
prompt: [
|
||||
'请生成一个儿童动作互动游戏主按钮背景资产。',
|
||||
'主体是横向胶囊形按钮,无文字,绿色草地色为主,带浅蓝天空高光和柔和水彩纸张质感。',
|
||||
'按钮中心保持干净,适合网页叠加“开始游戏”等文字。',
|
||||
'整体要圆润、明亮、童趣、绘本感,不要科技感、金属感、真实照片质感。',
|
||||
styleReferenceNote,
|
||||
noStretchNote,
|
||||
chromaKeyNote,
|
||||
].join(''),
|
||||
},
|
||||
];
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const raw = process.argv[index];
|
||||
@@ -36,7 +280,14 @@ for (let index = 2; index < process.argv.length; index += 1) {
|
||||
}
|
||||
const next = process.argv[index + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
args.set(raw, next);
|
||||
const existing = args.get(raw);
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(next);
|
||||
} else if (existing) {
|
||||
args.set(raw, [existing, next]);
|
||||
} else {
|
||||
args.set(raw, next);
|
||||
}
|
||||
index += 1;
|
||||
} else {
|
||||
args.set(raw, true);
|
||||
@@ -138,6 +389,63 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function inferExtensionFromBytes(bytes, preferredPath) {
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (
|
||||
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return path.extname(preferredPath).replace(/^\./u, '') || 'png';
|
||||
}
|
||||
|
||||
function toDataUrl(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const bytes = readFileSync(filePath);
|
||||
const extension = inferExtensionFromBytes(bytes, filePath);
|
||||
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
|
||||
return `data:${mime};base64,${bytes.toString('base64')}`;
|
||||
}
|
||||
|
||||
function pushReferenceImage(body, filePath) {
|
||||
const reference = toDataUrl(filePath);
|
||||
if (!reference) {
|
||||
return false;
|
||||
}
|
||||
body.image = [...(body.image || []), reference];
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRequestBody(asset, size) {
|
||||
const body = {
|
||||
model: 'gpt-image-2-all',
|
||||
prompt: asset.prompt,
|
||||
n: 1,
|
||||
size: size || asset.size,
|
||||
};
|
||||
if (asset.useBackgroundReference) {
|
||||
pushReferenceImage(
|
||||
body,
|
||||
path.join(assetDir, 'picture-book-grass-stage.png'),
|
||||
);
|
||||
}
|
||||
if (asset.useLayoutReference) {
|
||||
pushReferenceImage(
|
||||
body,
|
||||
path.join(intermediateDir, layoutReferenceOutput),
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
@@ -180,27 +488,518 @@ async function downloadImage(url, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
const size = String(args.get('--size') || defaultSize);
|
||||
const outPath = path.resolve(String(args.get('--out') || defaultOut));
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2-all',
|
||||
prompt,
|
||||
n: 1,
|
||||
size,
|
||||
};
|
||||
function outputPathFor(asset) {
|
||||
if (asset.outputDirectory === 'intermediate') {
|
||||
return path.join(intermediateDir, asset.output);
|
||||
}
|
||||
return path.join(assetDir, asset.output);
|
||||
}
|
||||
|
||||
if (args.has('--dry-run') || !args.has('--live')) {
|
||||
function sourceOutputPathFor(asset) {
|
||||
return path.join(intermediateDir, asset.sourceOutput || asset.output);
|
||||
}
|
||||
|
||||
function opaqueSourceOutputPathFor(asset) {
|
||||
return path.join(
|
||||
intermediateDir,
|
||||
`${path.basename(asset.sourceOutput || asset.output, path.extname(asset.sourceOutput || asset.output))}-rgb.png`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOutputPath(preferredPath, imageBytes) {
|
||||
const actualExtension = inferExtensionFromBytes(imageBytes, preferredPath);
|
||||
const outputPath =
|
||||
path.extname(preferredPath).toLowerCase() === `.${actualExtension}`
|
||||
? preferredPath
|
||||
: path.join(
|
||||
path.dirname(preferredPath),
|
||||
`${path.basename(preferredPath, path.extname(preferredPath))}.${actualExtension}`,
|
||||
);
|
||||
return { actualExtension, outputPath };
|
||||
}
|
||||
|
||||
function resolveCodexHome() {
|
||||
if (process.env.CODEX_HOME) {
|
||||
return process.env.CODEX_HOME;
|
||||
}
|
||||
if (process.env.USERPROFILE) {
|
||||
return path.join(process.env.USERPROFILE, '.codex');
|
||||
}
|
||||
if (process.env.HOME) {
|
||||
return path.join(process.env.HOME, '.codex');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findChromaKeyHelper() {
|
||||
const codexHome = resolveCodexHome();
|
||||
if (!codexHome) {
|
||||
return null;
|
||||
}
|
||||
const helper = path.join(
|
||||
codexHome,
|
||||
'skills',
|
||||
'.system',
|
||||
'imagegen',
|
||||
'scripts',
|
||||
'remove_chroma_key.py',
|
||||
);
|
||||
return existsSync(helper) ? helper : null;
|
||||
}
|
||||
|
||||
function removeChromaKey(sourcePath, finalPath) {
|
||||
const helper = findChromaKeyHelper();
|
||||
if (!helper) {
|
||||
throw new Error(
|
||||
'Missing Codex imagegen remove_chroma_key.py helper for transparent assets',
|
||||
);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
'python',
|
||||
[
|
||||
helper,
|
||||
'--input',
|
||||
sourcePath,
|
||||
'--out',
|
||||
finalPath,
|
||||
'--key-color',
|
||||
chromaKeyColor,
|
||||
'--auto-key',
|
||||
'border',
|
||||
'--soft-matte',
|
||||
'--transparent-threshold',
|
||||
'12',
|
||||
'--opaque-threshold',
|
||||
'220',
|
||||
'--despill',
|
||||
'--force',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`remove_chroma_key.py failed: ${(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function removeUiPanelChromaKey(sourcePath, finalPath) {
|
||||
const script = [
|
||||
'from PIL import Image, ImageFilter',
|
||||
'import sys',
|
||||
'source, out = sys.argv[1], sys.argv[2]',
|
||||
'im = Image.open(source).convert("RGBA")',
|
||||
'px = im.load()',
|
||||
'w, h = im.size',
|
||||
'corner = im.getpixel((0, 0))',
|
||||
'key = corner[:3]',
|
||||
'for y in range(h):',
|
||||
' for x in range(w):',
|
||||
' r, g, b, _ = px[x, y]',
|
||||
' brightness = (r + g + b) / 3',
|
||||
' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
|
||||
' magenta_bias = r + b - 1.85 * g',
|
||||
' if brightness < 42 or dist < 155 or (r > 185 and b > 150 and g < 190 and magenta_bias > 235):',
|
||||
' alpha = 0',
|
||||
' elif dist < 225:',
|
||||
' alpha = int(max(0, min(255, (dist - 155) / 70 * 255)))',
|
||||
' else:',
|
||||
' alpha = 255',
|
||||
' if alpha > 0 and r > g + 28 and b > g + 20:',
|
||||
' r = min(r, g + 18)',
|
||||
' b = min(b, g + 14)',
|
||||
' px[x, y] = (r, g, b, alpha)',
|
||||
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.45))',
|
||||
'im.putalpha(alpha)',
|
||||
'im.save(out)',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to clean UI panel transparency: ${(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
|
||||
const script = [
|
||||
'from PIL import Image, ImageFilter',
|
||||
'import sys',
|
||||
'source, out = sys.argv[1], sys.argv[2]',
|
||||
'im = Image.open(source).convert("RGBA")',
|
||||
'px = im.load()',
|
||||
'w, h = im.size',
|
||||
'for y in range(h):',
|
||||
' for x in range(w):',
|
||||
' r, g, b, _ = px[x, y]',
|
||||
' magenta_strength = min(r, b) - g',
|
||||
' magenta_bg = r > 180 and b > 170 and g < 145 and magenta_strength > 70',
|
||||
' hot_bg = r > 225 and b > 205 and g < 190 and magenta_strength > 55',
|
||||
' if magenta_bg or hot_bg:',
|
||||
' alpha = 0',
|
||||
' else:',
|
||||
' alpha = 255',
|
||||
' if alpha > 0 and r > g + 35 and b > g + 22:',
|
||||
' r = min(r, g + 24)',
|
||||
' b = min(b, g + 20)',
|
||||
' px[x, y] = (r, g, b, alpha)',
|
||||
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.35))',
|
||||
'im.putalpha(alpha)',
|
||||
'im.save(out)',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to clean character outline transparency: ${(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTransparentAsset(finalPath, layoutNormalization) {
|
||||
if (!layoutNormalization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = [
|
||||
'from PIL import Image',
|
||||
'import sys',
|
||||
'source, out = sys.argv[1], sys.argv[2]',
|
||||
'canvas_w = int(sys.argv[3])',
|
||||
'canvas_h = int(sys.argv[4])',
|
||||
'fit = sys.argv[5]',
|
||||
'fill_w = float(sys.argv[6])',
|
||||
'fill_h = float(sys.argv[7])',
|
||||
'anchor_y = sys.argv[8]',
|
||||
'padding = int(sys.argv[9])',
|
||||
'im = Image.open(source).convert("RGBA")',
|
||||
'alpha = im.getchannel("A").point(lambda a: 255 if a > 8 else 0)',
|
||||
'bbox = alpha.getbbox()',
|
||||
'if bbox is None:',
|
||||
' im.save(out)',
|
||||
' raise SystemExit(0)',
|
||||
'left, top, right, bottom = bbox',
|
||||
'left = max(0, left - padding)',
|
||||
'top = max(0, top - padding)',
|
||||
'right = min(im.width, right + padding)',
|
||||
'bottom = min(im.height, bottom + padding)',
|
||||
'subject = im.crop((left, top, right, bottom))',
|
||||
'target_w = max(1, int(canvas_w * fill_w))',
|
||||
'target_h = max(1, int(canvas_h * fill_h))',
|
||||
'scale_w = target_w / subject.width',
|
||||
'scale_h = target_h / subject.height',
|
||||
'scale = max(scale_w, scale_h) if fit == "cover-width" else min(scale_w, scale_h)',
|
||||
'new_w = max(1, int(subject.width * scale))',
|
||||
'new_h = max(1, int(subject.height * scale))',
|
||||
'subject = subject.resize((new_w, new_h), Image.Resampling.LANCZOS)',
|
||||
'if new_w > canvas_w:',
|
||||
' crop_left = max(0, (new_w - canvas_w) // 2)',
|
||||
' subject = subject.crop((crop_left, 0, crop_left + canvas_w, new_h))',
|
||||
' new_w = canvas_w',
|
||||
'if new_h > canvas_h:',
|
||||
' if anchor_y == "bottom":',
|
||||
' crop_top = new_h - canvas_h',
|
||||
' elif anchor_y == "top":',
|
||||
' crop_top = 0',
|
||||
' else:',
|
||||
' crop_top = max(0, (new_h - canvas_h) // 2)',
|
||||
' subject = subject.crop((0, crop_top, new_w, crop_top + canvas_h))',
|
||||
' new_h = canvas_h',
|
||||
'canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))',
|
||||
'x = (canvas_w - new_w) // 2',
|
||||
'if anchor_y == "bottom":',
|
||||
' y = canvas_h - new_h',
|
||||
'elif anchor_y == "top":',
|
||||
' y = 0',
|
||||
'else:',
|
||||
' y = (canvas_h - new_h) // 2',
|
||||
'canvas.alpha_composite(subject, (x, y))',
|
||||
'canvas.save(out)',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnSync(
|
||||
'python',
|
||||
[
|
||||
'-c',
|
||||
script,
|
||||
finalPath,
|
||||
finalPath,
|
||||
String(layoutNormalization.canvasWidth),
|
||||
String(layoutNormalization.canvasHeight),
|
||||
layoutNormalization.fit || 'contain',
|
||||
String(layoutNormalization.fillWidth || 0.92),
|
||||
String(layoutNormalization.fillHeight || 0.92),
|
||||
layoutNormalization.anchorY || 'center',
|
||||
String(layoutNormalization.padding || 0),
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to normalize transparent asset canvas: ${(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function scrubChromaFringe(finalPath) {
|
||||
const script = [
|
||||
'from PIL import Image',
|
||||
'import sys',
|
||||
'path = sys.argv[1]',
|
||||
'im = Image.open(path).convert("RGBA")',
|
||||
'px = im.load()',
|
||||
'w, h = im.size',
|
||||
'for y in range(h):',
|
||||
' for x in range(w):',
|
||||
' r, g, b, a = px[x, y]',
|
||||
' if a == 0:',
|
||||
' continue',
|
||||
' magenta_bias = min(r, b) - g',
|
||||
' is_magenta_edge = r > 135 and b > 135 and magenta_bias > 24 and abs(r - b) < 92',
|
||||
' if is_magenta_edge and a < 90:',
|
||||
' px[x, y] = (r, g, b, 0)',
|
||||
' continue',
|
||||
' if is_magenta_edge:',
|
||||
' neutral = max(g, min(248, int((r + b + g) / 3)))',
|
||||
' r = min(r, neutral + 18)',
|
||||
' b = min(b, neutral + 16)',
|
||||
' g = max(g, min(neutral, 230))',
|
||||
' px[x, y] = (r, g, b, a)',
|
||||
'im.save(path)',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnSync('python', ['-c', script, finalPath], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to scrub chroma fringe: ${(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeOpaquePng(sourcePath, outputPath) {
|
||||
const result = spawnSync(
|
||||
'python',
|
||||
[
|
||||
'-c',
|
||||
[
|
||||
'from PIL import Image',
|
||||
'import sys',
|
||||
'Image.open(sys.argv[1]).convert("RGB").save(sys.argv[2])',
|
||||
].join('; '),
|
||||
sourcePath,
|
||||
outputPath,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to normalize transparent source before chroma key removal: ${(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAsset(asset, env, size, force) {
|
||||
const finalPath = outputPathFor(asset);
|
||||
if (!force && existsSync(finalPath)) {
|
||||
return {
|
||||
id: asset.id,
|
||||
ok: true,
|
||||
skipped: true,
|
||||
file: finalPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.has('--postprocess-only')) {
|
||||
if (!asset.transparent) {
|
||||
return {
|
||||
id: asset.id,
|
||||
ok: true,
|
||||
skipped: true,
|
||||
file: finalPath,
|
||||
};
|
||||
}
|
||||
|
||||
const sourcePath = sourceOutputPathFor(asset);
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
|
||||
}
|
||||
mkdirSync(assetDir, { recursive: true });
|
||||
mkdirSync(intermediateDir, { recursive: true });
|
||||
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
|
||||
writeOpaquePng(sourcePath, opaqueSourcePath);
|
||||
if (asset.transparencyCleanup === 'soft-panel') {
|
||||
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
|
||||
} else if (asset.transparencyCleanup === 'character-outline') {
|
||||
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
|
||||
} else {
|
||||
removeChromaKey(opaqueSourcePath, finalPath);
|
||||
}
|
||||
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
|
||||
scrubChromaFringe(finalPath);
|
||||
return {
|
||||
id: asset.id,
|
||||
ok: true,
|
||||
file: finalPath,
|
||||
sourceFile: sourcePath,
|
||||
postprocessedOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const requestBody = buildRequestBody(asset, 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 ${asset.id}`);
|
||||
}
|
||||
|
||||
mkdirSync(assetDir, { recursive: true });
|
||||
mkdirSync(intermediateDir, { recursive: true });
|
||||
const preferredPath = asset.transparent
|
||||
? sourceOutputPathFor(asset)
|
||||
: finalPath;
|
||||
const { actualExtension, outputPath } = normalizeOutputPath(
|
||||
preferredPath,
|
||||
imageBytes,
|
||||
);
|
||||
writeFileSync(outputPath, imageBytes);
|
||||
|
||||
if (asset.transparent) {
|
||||
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
|
||||
writeOpaquePng(outputPath, opaqueSourcePath);
|
||||
if (asset.transparencyCleanup === 'soft-panel') {
|
||||
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
|
||||
} else if (asset.transparencyCleanup === 'character-outline') {
|
||||
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
|
||||
} else {
|
||||
removeChromaKey(opaqueSourcePath, finalPath);
|
||||
}
|
||||
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
|
||||
scrubChromaFringe(finalPath);
|
||||
}
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
ok: true,
|
||||
file: asset.transparent ? finalPath : outputPath,
|
||||
sourceFile: asset.transparent ? outputPath : undefined,
|
||||
size: requestBody.size,
|
||||
extension: actualExtension,
|
||||
source: urls[0] ? 'url' : 'b64_json',
|
||||
usedReferenceImage: Boolean(requestBody.image),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSelection(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function selectAssets() {
|
||||
const selectedIds = new Set([
|
||||
...normalizeSelection(args.get('--asset')),
|
||||
...normalizeSelection(args.get('--only')),
|
||||
]);
|
||||
if (selectedIds.size === 0) {
|
||||
return assetDefinitions;
|
||||
}
|
||||
return assetDefinitions.filter((asset) => selectedIds.has(asset.id));
|
||||
}
|
||||
|
||||
function dryRun(selectedAssets, size) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
outPath,
|
||||
body: requestBody,
|
||||
assets: selectedAssets.map((asset) => {
|
||||
const body = buildRequestBody(asset, size);
|
||||
return {
|
||||
id: asset.id,
|
||||
outputPath: outputPathFor(asset),
|
||||
sourceOutputPath: asset.transparent
|
||||
? sourceOutputPathFor(asset)
|
||||
: undefined,
|
||||
transparent: asset.transparent,
|
||||
body: {
|
||||
...body,
|
||||
image: body.image ? ['<local style reference image>'] : undefined,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const selectedAssets = selectAssets();
|
||||
const unknownAssetRequested =
|
||||
selectedAssets.length === 0 &&
|
||||
(args.has('--asset') || args.has('--only'));
|
||||
|
||||
if (unknownAssetRequested) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'No matching child motion demo asset id',
|
||||
availableIds: assetDefinitions.map((asset) => asset.id),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const size = args.has('--size') ? String(args.get('--size')) : undefined;
|
||||
if (args.has('--dry-run') || !args.has('--live')) {
|
||||
dryRun(selectedAssets, size);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -217,43 +1016,17 @@ if (!env.baseUrl || !env.apiKey) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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');
|
||||
const force = Boolean(args.get('--force'));
|
||||
const results = [];
|
||||
for (const asset of selectedAssets) {
|
||||
results.push(await generateAsset(asset, env, size, force));
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, imageBytes);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
file: outPath,
|
||||
size,
|
||||
source: urls[0] ? 'url' : 'b64_json',
|
||||
results,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
326
scripts/generate-match3d-style-references.mjs
Normal 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,
|
||||
),
|
||||
);
|
||||