16 Commits

Author SHA1 Message Date
8c6ec9e6e4 Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03
Some checks failed
CI / verify (pull_request) Has been cancelled
# Conflicts:
#	docs/technical/README.md
#	src/components/custom-world-home/CustomWorldCreationHub.tsx
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
2026-05-12 15:02:47 +08:00
33c9079d3b feat: complete bark battle playable demo 2026-05-12 14:42:58 +08:00
7b4ba61b4d Merge remote-tracking branch 'origin/master' into hermes/hermes-3337436a
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 14:18:30 +08:00
ea0b67a951 docs: add security vulnerability scan report 2026-05-12 14:17:23 +08:00
4dfa8452db docs: add disaster recovery plan draft
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 14:13:53 +08:00
22810245f5 refactor match3d runtime adapters 2026-05-12 14:02:42 +08:00
eb76bfc031 Merge remote-tracking branch 'origin/master'
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 13:59:28 +08:00
183e78d475 perf: batch recent play counts for gallery lists 2026-05-12 10:59:51 +08:00
612d105a23 fix: resolve k6 loadtest data path 2026-05-11 22:18:43 +08:00
b994acf635 test: add k6 works list load test 2026-05-11 21:31:24 +08:00
ef4f91a75e 1
Some checks failed
CI / verify (push) Has been cancelled
2026-05-11 20:57:16 +08:00
481a27fc53 1 2026-05-11 20:27:41 +08:00
54968701f0 fix public work detail not found recovery
Some checks failed
CI / verify (push) Has been cancelled
2026-05-11 19:52:25 +08:00
7cea41c911 Add frontend debug mode gate 2026-05-11 18:00:36 +08:00
928acb4302 Extend sccache startup timeout for Windows builds 2026-05-11 17:24:24 +08:00
e30b733b17 1 2026-05-11 16:15:48 +08:00
161 changed files with 14401 additions and 2513 deletions

View File

@@ -1 +0,0 @@
C:/proj/Genarrative/.hermes/skills/behavior-driven-development

View File

@@ -0,0 +1 @@
C:/proj/Genarrative/.hermes/skills/behavior-driven-development

View File

@@ -173,6 +173,10 @@ VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS="150000"
# Keep this off by default for cleaner logs.
VITE_LLM_DEBUG_LOG="false"
# Optional: global frontend debug mode. When empty, it follows Vite dev mode.
# Set to "true" to expose local diagnostic panels, or "false" to hide them.
VITE_DEBUG_MODE=""
# Optional: official VikingDB credentials for regenerating build-tag similarities
# with the Python embedding script. The script auto-loads `.env.local` and uses
# the fixed `bge-large-zh` embedding model.

View File

@@ -56,8 +56,6 @@ LLM_DEBUG_LOG="true"
ALIYUN_OSS_BUCKET="xushi-dev"
ALIYUN_OSS_REGION="oss-cn-beijing"
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f"
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
# Local Rust backend target for Vite dev proxy.
RUST_SERVER_TARGET="http://127.0.0.1:8082"

View File

@@ -37,6 +37,12 @@ module.exports = {
'simple-import-sort/exports': 'off',
},
},
{
files: ['src/components/match3d-runtime/Match3DPhysicsBoard.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
],
plugins: [
'@typescript-eslint',

3
.gitignore vendored
View File

@@ -32,3 +32,6 @@ temp*build*/
/logs
.worktrees/
.env.secrets.local
# Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json

View File

@@ -0,0 +1,310 @@
# K6 作品列表压测计划(使用 spacetime-migration-7.json 作为数据源)
## 目标
使用 K6 对 Genarrative 的“作品列表”相关接口进行压测,并将用户提供的 `spacetime-migration-7.json` 作为压测数据源;数据处理时**只导入作品列表相关数据**,不导入用户、会话、钱包、埋点、运行存档等非作品表,避免把敏感或无关数据带入压测环境。
## 当前上下文
- 工作区:`/c/proj/Genarrative`
- 原始迁移文件:`C:\Users\DSK\AppData\Local\hermes\cache\documents\doc_150e84029b2d_spacetime-migration-7.json`
- 已确认原始迁移文件结构:
- `schema_version = 1`
- `tables = 53`
- 作品相关表中当前有数据的重点表:
- `puzzle_work_profile`80 行
- `custom_world_profile`1 行
- `match3d_work_profile`0 行
- `big_fish_*`:当前样本中相关表为 0 行
- 原始文件还包含 `user_account``auth_identity``refresh_session``profile_wallet_ledger``asset_object`、运行记录等数据,压测导入时必须过滤。
- 当前仓库未发现现成 K6 脚本或 `k6` 相关文件,需要新增压测脚本与数据提取脚本。
- `package.json` 当前有 `dev/dev:rust/test/check` 等脚本,未发现 K6 npm script。
## 范围约束
### 本次只导入/使用
1. 作品列表表:
- `puzzle_work_profile`
- `custom_world_profile`
- 后续若接口覆盖其他玩法,可扩展:
- `match3d_work_profile`
- `square_hole_work_profile`(以实际 SpacetimeDB 表名为准)
- `big_fish_work_profile`(以实际 SpacetimeDB 表名为准)
- `visual_novel_work_profile`(以实际 SpacetimeDB 表名为准)
2. 为作品列表卡片展示所需的最小字段:
- 稳定 ID`profile_id``work_id``public_work_code`
- 标题:`work_title` / `level_name` / `world_name`
- 描述:`work_description` / `summary` / `summary_text` / `subtitle`
- 作者:`owner_user_id``author_display_name``author_public_user_code`
- 封面:`cover_image_src``cover_asset_id`(如果接口只返回 asset id则压测阶段不额外导入二进制 asset
- 状态与计数:`publication_status``published_at``play_count``like_count``remix_count`
- 作品内容摘要:`levels_json``profile_payload_json``theme_tags_json` 等列表渲染或进入作品详情可能需要的 JSON 字段
### 本次不导入/不使用
- 认证与账号:`user_account``auth_identity``refresh_session``auth_store_snapshot`
- 用户资产与钱包:`profile_wallet_ledger``profile_dashboard_state``profile_redeem_*``profile_invite_*`
- 游玩历史/存档/运行态:`profile_played_world``public_work_play_daily_stat``puzzle_runtime_run``profile_save_archive``runtime_snapshot`
- AI 任务过程:`ai_task``ai_task_stage``ai_text_chunk`
- asset 二进制与绑定:`asset_object``asset_entity_binding`,除非后续确认作品列表接口强依赖它们;即便需要,也只导入作品列表封面所需的最小 metadata不导入原始大对象。
## 推荐目录与文件
建议新增:
```text
.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md # 本计划
scripts/loadtest/extract-works-list-data.mjs # 从迁移文件提取作品列表数据
scripts/loadtest/k6-works-list.js # K6 压测脚本
scripts/loadtest/data/works-list.sample.json # 过滤后的样例数据(不要提交敏感原始迁移全量)
scripts/loadtest/README.md # 执行说明与指标阈值
```
可选新增 npm scripts
```json
{
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js"
}
```
## 数据提取方案
### 输入
默认读取:
```bash
node scripts/loadtest/extract-works-list-data.mjs \
--input "C:/Users/DSK/AppData/Local/hermes/cache/documents/doc_150e84029b2d_spacetime-migration-7.json" \
--output scripts/loadtest/data/works-list.local.json
```
### 输出结构
建议输出为 K6 直接可读的 JSON
```json
{
"source": "spacetime-migration-7.json",
"generatedAt": "<iso datetime>",
"tables": {
"puzzle_work_profile": [
{
"profile_id": "...",
"work_id": "...",
"owner_user_id": "...",
"work_title": "...",
"work_description": "...",
"publication_status": "Published",
"published_at": { "__timestamp_micros_since_unix_epoch__": 0 },
"play_count": 0,
"like_count": 0,
"levels_json": "..."
}
],
"custom_world_profile": []
},
"workIds": {
"puzzle": ["<profile_id>"],
"customWorld": ["<profile_id>"]
}
}
```
### 过滤原则
1.`tables[].name` 白名单过滤,只保留作品 profile 表。
2. 对每个 row 再按字段白名单过滤避免误带账号、手机号、token、钱包流水等字段。
3. 对特别大的字段进行处理:
- `cover_image_src` 如果是 `data:image/...base64`,默认替换为占位符或截断,避免压测数据文件过大。
- `levels_json``profile_payload_json` 保留原文,但可以记录大小;如果过大,再提供 `--compact` 选项只保留摘要。
4. 输出 `.local.json` 默认加入 `.gitignore`;如果要提交样例数据,只提交脱敏/裁剪后的 `works-list.sample.json`
## K6 压测接口矩阵
需要先确认本地 api-server 实际端口。默认以 `http://127.0.0.1:8787` 为例,实际运行时通过环境变量覆盖:
```bash
BASE_URL=http://127.0.0.1:<actual-api-port> k6 run scripts/loadtest/k6-works-list.js
```
初版建议覆盖以下“作品列表”读接口,具体路径以仓库服务端路由为准,实施时需要通过搜索 api-server 路由确认:
| 场景 | 目的 | 候选路径 |
| --- | --- | --- |
| 拼图作品列表 | 作品列表主场景之一,当前数据量最多 | `/api/creation/puzzle/works` 或实际 puzzle works list route |
| RPG/自定义世界作品列表 | 使用 `custom_world_profile` 数据 | `/api/creation/custom-world/works` 或实际 custom world works route |
| 作品详情/启动前读取 | 模拟用户从列表点进作品 | `/api/creation/*/works/:profileId``/api/runtime/*/works/:profileId` |
| 公开作品库 | 如果首页/发现页依赖 | `/api/runtime/*/works` 或 gallery/list route |
> 注意:不要凭空固定 endpoint。实施阶段先用 `search_files` / 路由源码确认真实路径,再写入 K6 脚本。
## K6 场景设计
### 阶段 1基线 smoke
目的:确认脚本、数据和目标服务可用。
```js
export const options = {
scenarios: {
smoke: {
executor: 'constant-vus',
vus: 1,
duration: '30s'
}
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800']
}
};
```
### 阶段 2常规读压
目的:模拟日常列表浏览。
- `constant-vus`: 10/25/50 三档
- 每个 VU 随机选择作品类型和列表分页参数
- `sleep(0.5~2s)` 模拟用户停留
- 阈值建议:
- `http_req_failed < 1%`
- `p95 < 800ms`
- `p99 < 1500ms`
### 阶段 3峰值/突刺
目的:模拟首页入口或活动导致的作品列表突增。
- `ramping-arrival-rate`
- 从 5 RPS 增长到 100 RPS维持 2~5 分钟,再降回
- 单独输出 `checks`:列表接口状态码、响应 JSON shape、items 数量
### 阶段 4容量探索
目的:找瓶颈,不作为每次回归必跑。
- 每轮提升 RPS 或 VU
- 观察api-server CPU/内存、SpacetimeDB 日志、错误率、p95/p99
- 一旦 `http_req_failed >= 5%` 或 p95 持续超过 2s停止继续加压并记录容量点。
## K6 脚本设计要点
1. 使用 `SharedArray` 加载 `works-list.local.json`,避免每个 VU 重复解析大 JSON。
2. 基于数据源里的 `profile_id` / `work_id` 随机抽样,保证请求覆盖真实作品 ID。
3. 对列表接口添加分页/排序 query例如
- `?limit=20&offset=0`
- `?pageSize=20&cursor=...`(以真实 API 为准)
4. 使用 `check()` 验证:
- HTTP 200
- 响应体是 JSON
- `items``works` 是数组
- 列表项包含 `profileId/profile_id`、标题字段、状态字段
5. 使用 `Trend` / `Rate` 细分指标:
- `works_list_duration`
- `works_detail_duration`
- `works_list_shape_error_rate`
6. 支持环境变量:
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=scripts/loadtest/data/works-list.local.json \
SCENARIO=baseline \
k6 run scripts/loadtest/k6-works-list.js
```
## 实施步骤
1. **确认路由**
- 搜索 api-server / BFF 的作品列表路由。
- 明确各玩法对应 endpoint、鉴权要求、分页参数、返回字段。
2. **实现数据提取脚本**
- 新增 `scripts/loadtest/extract-works-list-data.mjs`
- 只按表白名单读取作品列表 profile 表。
- 对字段做白名单与脱敏/截断。
- 输出 `works-list.local.json`
3. **生成本地压测数据**
- 用用户提供的迁移文件生成 `scripts/loadtest/data/works-list.local.json`
- 验证输出只包含作品表和作品字段。
4. **实现 K6 脚本**
- 新增 `scripts/loadtest/k6-works-list.js`
- 支持 `BASE_URL``WORKS_DATA``SCENARIO`
- 覆盖列表接口,必要时增加详情/启动前读取接口。
5. **新增执行说明**
-`scripts/loadtest/README.md` 写明:安装 K6、启动本地 dev 栈、提取数据、运行 smoke/baseline/spike、查看结果。
6. **本地验证**
- 启动 Genarrative dev 栈;注意端口可能漂移,使用实际 api-server 端口。
- 跑 smoke`SCENARIO=smoke`
- 确认失败率、p95、响应 shape。
7. **可选集成 npm scripts**
- 如果团队希望标准化入口,再加入 `package.json` scripts。
8. **记录结果**
- 将 smoke/baseline/spike 的结果摘要追加到 `scripts/loadtest/README.md` 或单独保存到 `.hermes/plans/` 的结果文档中。
## 启动与运行建议
本地服务启动按当前 Genarrative dev 栈约定:
```bash
npm run dev
```
如果 SpacetimeDB/API/Vite 端口被占用,项目脚本会寻找可用端口;压测时必须从启动日志中读取实际 api-server 地址,并传给 K6
```bash
BASE_URL=http://127.0.0.1:<actual-api-port> \
WORKS_DATA=scripts/loadtest/data/works-list.local.json \
SCENARIO=smoke \
k6 run scripts/loadtest/k6-works-list.js
```
## 验证标准
### 数据源验证
- `works-list.local.json` 中只出现作品 profile 表。
- 不出现以下字段或内容:
- `password_hash`
- `refresh_token_hash`
- `phone_number_e164`
- `phone_number_masked`
- `wallet_ledger_id`
- `auth_identity`
- `user_account`
- `puzzle_work_profile` 行数应接近原始文件中的 80 行。
- `custom_world_profile` 行数应接近原始文件中的 1 行。
### K6 smoke 验证
- 所有目标接口返回 2xx。
- `http_req_failed < 1%`
- 响应 JSON shape 与 shared contracts 对齐:`items``works` 数组。
- K6 输出中能区分不同 endpoint 的耗时。
### 性能阈值初稿
- Smoke`p95 < 800ms`,失败率 `< 1%`
- Baseline`p95 < 1000ms``p99 < 2000ms`,失败率 `< 1%`
- Spike允许短暂 p95 抖动,但 1 分钟内应恢复;失败率 `< 5%`
阈值后续需要结合本地机器性能、SpacetimeDB 本地模式和正式部署规格调整。
## 风险与注意事项
1. **原始迁移文件包含敏感数据。** 必须只提取作品列表白名单字段,禁止把原始 JSON 全量提交到仓库。
2. **base64 封面可能导致压测数据膨胀。** 默认截断或替换为占位符,除非本次明确要测封面 payload 对响应体积的影响。
3. **本地 SpacetimeDB 与 api-server 端口会漂移。** 不要硬编码端口,运行时通过 `BASE_URL` 注入。
4. **列表接口可能需要鉴权。** 若实际接口要求登录,不要导入真实 refresh session应使用本地测试账号或专门的压测 token 生成流程。
5. **作品表名/接口路径可能与候选名称不完全一致。** 实施前必须以源码路由为准。
6. **本计划仅保存压测方案,不执行实际压测。** 后续执行时再创建/修改脚本、导出过滤数据、跑 K6 并记录结果。
## 开放问题
1. 压测目标是本地 dev 栈、测试环境,还是预发/生产只读接口?不同环境阈值和安全边界不同。
2. “作品列表”是否只包含拼图和自定义世界,还是要覆盖 match3d、square-hole、big-fish、visual-novel 的统一列表入口?
3. 是否允许使用专门压测账号/token如果接口无鉴权则无需处理。
4. 是否需要测封面/asset 加载,还是只测作品列表 JSON API

View File

@@ -0,0 +1,447 @@
# Genarrative 容灾方案设计计划
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** 基于当前 Genarrative 单机生产部署、Jenkins 流水线、SpacetimeDB 与 Rust `api-server` 架构,补齐一套可落地、可演练、可审计的容灾方案。
**Architecture:** 首版容灾不引入复杂多活系统,优先围绕现有 `systemd + Nginx + SpacetimeDB + api-server + Jenkins` 单机生产推荐方案做“备份可恢复、版本可回滚、故障可切换、演练可复盘”。方案采用分层容灾入口层、静态资源层、API 服务层、SpacetimeDB 数据层、外部服务与密钥层、Jenkins/发布链路层。
**Tech Stack:** Nginx、systemd、SpacetimeDB self-hosting、Rust `api-server` / Axum、Jenkins Pipeline、Shell/Node.js 运维脚本、仓库 `deploy/``docs/technical/` 文档体系。
---
## 1. 当前上下文与已确认事实
### 1.1 当前生产部署口径
来自 `docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 的现状:
- 生产为单机推荐方案,不使用 Docker。
- 公网入口为 Nginx负责 HTTPS、静态站点、后台静态页面、维护页、`/admin/api/` 与临时 `/api/*` 反向代理。
- SpacetimeDB 作为 systemd 服务运行:
- `spacetimedb.service`
- 监听:`127.0.0.1:3101`
- 数据根目录:`/stdb`
- Rust `api-server` 作为 systemd 服务运行:
- `genarrative-api.service`
- 监听:`127.0.0.1:8082`
- 环境文件:`/etc/genarrative/api-server.env`
- 静态站点发布到 release/current 目录:
- `/opt/genarrative/releases/<version>/`
- `/opt/genarrative/current`
- `/srv/genarrative/web`
- 已有维护模式:
- 开关文件:`/var/lib/genarrative/maintenance/enabled`
- API 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。
- 已有数据库导入导出 Jenkins Job
- `Genarrative-Database-Export`
- `Genarrative-Database-Import`
- 对应文件:`jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import`
- 已有回滚基本口径:
- Web 回滚:切 `/srv/genarrative/web``/opt/genarrative/current` 到上一版本并 reload Nginx。
- API 回滚:切 `/opt/genarrative/current` 到上一版本并重启 `genarrative-api.service`
- SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`
- 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。
### 1.2 关键风险
- 当前是单机生产拓扑,单机磁盘、系统盘、`/stdb`、Nginx 或公网 IP 故障会造成整体不可用。
- SpacetimeDB 是核心业务真相,容灾重点必须围绕 `/stdb`、数据库导出产物、schema 迁移与导入验证。
- `/etc/genarrative/api-server.env` 持有生产密钥,不能进入 Git也不能写进普通备份明文归档。
- Jenkins controller/agent 同时承担构建、发布、备份、导入导出编排Jenkins 不可用时仍需要有最小人工恢复路径。
- 外部 LLM、图片、语音、3D 网关不是本仓库可控系统,容灾只能做到配置降级、超时隔离、能力熔断与可观测告警。
---
## 2. 容灾目标
### 2.1 恢复目标建议
| 灾难类型 | 目标 RTO | 目标 RPO | 首版策略 |
| --- | ---: | ---: | --- |
| Web 静态资源发布失败 | 5 分钟 | 0 | release/current 原子切换回滚 |
| API 发布失败 | 10 分钟 | 0 | 维护模式 + 上一版二进制回滚 |
| SpacetimeDB wasm 发布失败 | 15 分钟 | 0 或按迁移前备份 | 发布前导出 + 上一版 wasm 回滚 |
| 数据误写 / 迁移失败 | 30-60 分钟 | 最近一次导出点 | 导入流水线从备份恢复 |
| 生产机磁盘损坏 | 2-4 小时 | 最近一次异地备份 | 新机器 provision + 拉取 release 包 + 恢复数据库 |
| Jenkins controller 不可用 | 1-2 小时 | 不影响线上数据 | 手工脚本恢复 + Jenkins 备份恢复 |
| 第三方模型网关不可用 | 5-15 分钟内降级 | 不丢核心数据 | 配置切换 / 功能熔断 / 队列失败可重试 |
### 2.2 首版不做
- 不做跨地域双活写入。
- 不做 SpacetimeDB 在线主从复制,除非后续官方能力与项目压测验证支持。
- 不让前端绕过 `api-server` 直接承担正式业务真相。
- 不把生产密钥、Token、数据库 dump、Jenkins secret 写入 Git。
- 不恢复旧 `server-node`、Express、PostgreSQL 或 Docker 一体化部署方案。
---
## 3. 总体容灾设计
### 3.1 分层策略
1. **入口层Nginx / DNS / HTTPS**
- 保留 Nginx 配置模板在 Git`deploy/nginx/genarrative.conf``deploy/nginx/genarrative-dev-http.conf`
- 为 release 环境建立 Nginx 配置备份与证书恢复流程。
- 明确 DNS 切换预案:生产机不可恢复时,将域名指向灾备机公网 IP。
2. **静态资源层Web / Admin Web**
- 依赖 `web.tar.gz``web.tar.gz.sha256``release-manifest.json`
- 保留最近 N 个 release 目录与构建产物指针。
- 回滚只切软链,不重新构建。
3. **API 服务层Rust `api-server`**
- 依赖归档的 `api-server` 二进制、checksum、`release-manifest.json`
- `/etc/genarrative/api-server.env` 通过加密备份或密钥管理恢复,不进入 release 包。
- systemd unit 由 `deploy/systemd/genarrative-api.service` 重新安装。
4. **数据层SpacetimeDB**
- 每次高风险发布前强制导出数据库。
- 定时导出:建议每天至少 1 次;高活跃期可每 4 小时 1 次。
- 导出产物同时保存在Jenkins 归档 + 生产机 `SERVER_BACKUP_DIRECTORY` + 异地对象存储/备份机。
- 导入前自动生成安全备份,保留当前实现口径。
5. **发布编排层Jenkins**
- Jenkins Job、Jenkinsfile 在 Git 中可恢复。
- Jenkins controller 配置、凭据、插件清单需要额外备份。
- 发布 agent 使用 inbound + systemd 自恢复agent secret 仅存在目标机或 Jenkins 凭据。
6. **密钥与外部服务层**
- `/etc/genarrative/api-server.env`、Jenkins Secret Text、SSH PEM、agent secret 不进 Git。
- 制定密钥清单和恢复责任人,但不在仓库记录明文。
- 外部服务配置按 `docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md` 维护必配项。
---
## 4. 建议新增/更新的文档
### Task 1: 新增生产容灾技术方案文档
**Objective:** 形成团队可共享、可执行的容灾总纲。
**Files:**
- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
- Modify: `docs/technical/README.md`(若已有技术索引,应加入该文档入口)
- Optional Modify: `.hermes/shared-memory/project-overview.md`(只加稳定索引,不写敏感信息)
**文档必须覆盖:**
1. 容灾目标RTO/RPO 表。
2. 生产资产清单Nginx、systemd、release/current、`/stdb``/etc/genarrative/api-server.env`、Jenkins、构建产物。
3. 备份策略:
- 数据库导出。
- release 产物保留。
- Nginx/systemd/env 配置备份。
- Jenkins 配置备份。
4. 恢复流程:
- Web 回滚。
- API 回滚。
- Stdb module 回滚。
- 数据恢复。
- 整机重建。
5. 演练计划:每月一次数据库恢复演练,每季度一次整机重建演练。
6. 安全边界:密钥不进 Git备份加密最小权限。
7. 验收命令与人工检查清单。
**Verification:**
```bash
npm run check:encoding
```
Expected: PASS无中文乱码、无 BOM/CRLF 问题。
---
## 5. 建议新增/更新的脚本与流水线
### Task 2: 增强数据库定时备份流水线
**Objective:** 把现有人工导出扩展为可定时执行、可异地保存、可审计的备份流程。
**Files:**
- Modify: `jenkins/Jenkinsfile.production-database-export`
- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
- Optional Create: `scripts/deploy/production-backup-sync.sh`
**Implementation notes:**
- 在 Jenkins Job 中保留人工触发能力,同时建议配置 cron
- development每天凌晨。
- release每天凌晨或业务低峰。
- 增加备份命名规范:
- `spacetime-migration-<database>-<yyyyMMdd-HHmmss>-<source_commit>.json`
- 增加 `SERVER_BACKUP_DIRECTORY` 默认建议:
- `/var/backups/genarrative/spacetimedb/<database>/`
- 增加备份保留策略:
- 本机保留 7-14 天。
- 异地保留 30-90 天。
- 如实现 `production-backup-sync.sh`,只做同步框架,不硬编码真实 bucket、账号、endpoint 或密钥。
**Verification:**
```bash
bash -n scripts/deploy/production-backup-sync.sh
npm run check:encoding
```
Expected: shell 语法通过;文档编码检查通过。
---
### Task 3: 增加灾备恢复 Runbook
**Objective:** 在真正故障时不依赖临场推理,按清单执行恢复。
**Files:**
- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md`
- Modify: `docs/operations/README.md`(如果存在)
**Runbook sections:**
1. 故障分级P0/P1/P2。
2. 第一响应:
- 判断 Nginx 是否在线。
- 判断 `genarrative-api.service` 是否在线。
- 判断 `spacetimedb.service` 是否在线。
- 判断磁盘是否满。
- 判断 Jenkins agent 是否在线。
3. 快速止血:
- 开维护模式。
- 禁止继续发布。
- 保留现场日志。
4. 回滚流程:
- Web 回滚命令。
- API 回滚命令。
- Stdb wasm 回滚命令。
5. 数据恢复流程:
- 选择备份。
- dry-run 导入。
- 确认导入。
- smoke test。
6. 整机重建流程:
- 新机器 provision。
- 恢复 `/etc/genarrative/api-server.env`
- 恢复 SpacetimeDB 数据。
- 发布最近稳定 release。
- DNS 切换。
7. 复盘模板。
**Verification:**
```bash
npm run check:encoding
```
Expected: PASS。
---
### Task 4: 增加备份健康检查与恢复演练记录模板
**Objective:** 防止“有备份但不可恢复”。
**Files:**
- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md`
- Optional Create: `scripts/deploy/verify-database-backup.sh`
- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
**建议检查项:**
- 备份文件存在且大小非 0。
- 备份文件 checksum 可验证。
- 备份文件可被 `Genarrative-Database-Import` dry-run 解析。
- 最近一次备份时间未超过 RPO 阈值。
- 导入后 `/healthz` 可用。
- 首页、后台登录页、关键 API smoke 可用。
**Verification:**
```bash
bash -n scripts/deploy/verify-database-backup.sh
npm run check:encoding
```
Expected: PASS。
---
## 6. 具体恢复流程草案
### 6.1 Web 静态资源回滚
1. 进入目标机。
2. 查看 release 目录:`/opt/genarrative/releases/`
3. 选择上一个稳定版本。
4. 切换 `/srv/genarrative/web``/opt/genarrative/current` 软链。
5. 执行 Nginx 配置检查与 reload。
6. 访问首页与后台静态入口。
验收:
- `/` 返回最新稳定页面。
- `/admin/` 返回后台页面。
- 静态资源无 404。
### 6.2 API 回滚
1. 开维护模式。
2.`/opt/genarrative/current` 到上一版包含稳定 `api-server` 的 release。
3. 重启 `genarrative-api.service`
4. 本机检查 `http://127.0.0.1:8082/healthz`
5. 检查 Nginx 反代路径。
6. 解除维护模式。
验收:
- `systemctl status genarrative-api.service` 正常。
- `/healthz` 正常。
- 后台 `/admin/api/*` 基础接口正常。
### 6.3 SpacetimeDB 模块回滚
1. 开维护模式。
2. 确认目标数据库名与当前 API 环境一致:`GENARRATIVE_SPACETIME_DATABASE`
3. 选择上一版 `spacetime_module.wasm`
4. 使用 `spacetimedb` 服务用户发布上一版 wasm。
5. 重启或检查 `spacetimedb.service`
6. 检查 `api-server` 对目标数据库访问。
7. 解除维护模式。
注意:如果 schema 已迁移且旧 wasm 不兼容当前数据,需要走数据恢复,不应直接盲目发布旧 wasm。
### 6.4 数据恢复
1. 开维护模式。
2. 从 Jenkins 归档或 `SERVER_BACKUP_DIRECTORY` 选择备份。
3. 先执行导入 dry-run。
4. 真正导入前生成当前数据库安全备份。
5. 执行导入。
6. 执行 smoke test。
7. 解除维护模式。
必须记录:
- 备份文件名。
- 来源 Job/build number。
- 恢复目标 database。
- 恢复开始/结束时间。
- 恢复后验证结果。
### 6.5 整机重建
1. 准备新 Linux 机器。
2. 接入 Jenkins release deploy agent或准备人工 SSH 运维路径。
3. 运行 `Genarrative-Server-Provision`
- 创建用户和目录。
- 安装 SpacetimeDB。
- 安装 systemd unit。
- 安装 Nginx 配置。
4. 恢复 `/etc/genarrative/api-server.env`
5. 发布最近稳定 Web/API/Stdb 产物。
6. 导入最近一次有效数据库备份。
7. smoke test。
8. 切 DNS。
9. 观察 30-60 分钟。
---
## 7. 文件可能变更清单
首版落地建议按以下文件收口:
- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md`
- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md`
- Modify: `docs/technical/README.md`
- Modify: `docs/operations/README.md`(若存在)
- Modify: `.hermes/shared-memory/project-overview.md`(仅增加文档索引)
- Optional Modify: `jenkins/Jenkinsfile.production-database-export`
- Optional Modify: `jenkins/Jenkinsfile.production-database-import`
- Optional Create: `scripts/deploy/production-backup-sync.sh`
- Optional Create: `scripts/deploy/verify-database-backup.sh`
---
## 8. 测试与验收
### 8.1 文档与编码
```bash
npm run check:encoding
```
Expected: PASS。
### 8.2 Shell 脚本语法
如新增 shell 脚本:
```bash
bash -n scripts/deploy/production-backup-sync.sh
bash -n scripts/deploy/verify-database-backup.sh
```
Expected: PASS。
### 8.3 Jenkinsfile 静态检查
建议在 Jenkins UI 或本地 Jenkins Pipeline Linter 中检查:
- `jenkins/Jenkinsfile.production-database-export`
- `jenkins/Jenkinsfile.production-database-import`
Expected: Pipeline syntax valid。
### 8.4 演练验收
至少完成一次 development 目标演练:
1. 触发 `Genarrative-Database-Export`
2. 确认备份产物存在并归档。
3. 使用 `Genarrative-Database-Import` dry-run 验证备份可解析。
4. 不覆盖生产数据的前提下,记录演练报告。
release 目标演练应在业务低峰进行,并先确认通知渠道可用。
---
## 9. 风险、取舍与开放问题
### 9.1 风险
- 单机生产仍存在物理机级单点故障,首版只能通过“快速重建 + 异地备份”降低恢复时间。
- SpacetimeDB schema 回滚不一定可逆,必须把发布前备份作为强约束。
- Jenkins controller 若在本地 Windowscontroller 自身备份和恢复需要单独制定,不应只依赖 agent 自恢复。
- 外部模型网关失败可能影响创作能力,但不应影响已发布作品浏览和后台基础能力。
### 9.2 取舍
- 选择先做可执行 runbook 和备份恢复演练,而不是直接引入复杂多活。
- 选择继续复用现有 Jenkins 导入导出流水线,降低工程改造风险。
- 选择不把密钥恢复细节写死到 Git 文档,避免泄露。
### 9.3 开放问题
1. release 环境是否已经有独立备份机或对象存储?如果有,需要补充备份同步目标,但不能提交密钥。
2. Jenkins controller 的 `JENKINS_HOME` 当前实际部署在哪里?是否已有周期备份?
3. 生产域名 DNS TTL 当前是多少?是否可降低到适合故障切换的值?
4. `/stdb` 所在磁盘是否独立于系统盘?是否已有磁盘水位告警?
5. release 环境的通知渠道除邮件外是否需要接入企业微信/飞书/Telegram
---
## 10. 推荐实施顺序
1. 先只落文档:技术方案 + runbook + 演练模板。
2. 在 development 目标做一次数据库导出 + dry-run 导入演练。
3. 根据演练结果补脚本:备份同步、备份健康检查。
4. 再把 release 备份设置为定时任务。
5. 最后规划整机重建演练与 DNS 切换演练。
首版完成标准:
- 团队任一成员打开 runbook即可在 30 分钟内完成 Web/API 回滚或数据库备份 dry-run 恢复。
- 最近一次数据库备份时间、备份位置、checksum、恢复演练结果可追溯。
- 生产密钥仍只存在于服务器/Jenkins 凭据/加密备份中,不进入 Git。

View File

@@ -0,0 +1,403 @@
# 当前项目安全漏洞检查计划
> **For Hermes:** Use subagent-driven-development skill only if the user later asks to execute this plan. 本计划当前仅用于规划,不实施代码修改。
**Goal:** 对 Genarrative 当前工作区做一次可复现的安全漏洞基线检查,覆盖依赖漏洞、密钥泄露、常见高风险代码模式、后端 Rust crate 风险和前端/Node 供应链风险,并输出可落地的整改清单。
**Architecture:** 采用“只读扫描 → 结果归档 → 人工分级 → 最小修复建议”的方式推进。先不直接升级依赖或改代码,避免安全扫描引入不可控 breaking change执行阶段只在用户确认后运行扫描命令并把报告保存到 `docs/audits/``.hermes/plans/` 附件中。
**Tech Stack:** Node/Vite/React/TypeScript、Rust workspace/Axum/SpacetimeDB、npm lockfile、Cargo.lock、Git worktree。
---
## 当前上下文 / 假设
- 当前有效工作区:`C:/proj/Genarrative/.worktrees/hermes-3337436a`
- 本次用户以 `/plan` 模式要求“检查一下当前项目的安全漏洞”,因此本轮只制定计划,不执行会产生报告、安装工具、修改依赖、提交或推送的操作。
- 已确认项目包含:
-`package.json`,脚本包括 `npm run lint``npm run test``npm run build``npm run check:encoding`
-`package-lock.json`
- `server-rs/Cargo.toml``server-rs/Cargo.lock`
- `apps/admin-web/package.json``packages/shared/package.json`
- `.hermes/shared-memory/development-workflow.md` 要求开发前读取共享记忆,并以当前代码、`docs/``AGENTS.md` 为准。
- 安全扫描不应把真实密钥写入仓库;发现疑似密钥时只记录文件位置、变量名、脱敏片段和处置建议。
## 总体策略
1. 先做仓库状态和范围确认,避免扫描其他 worktree 或错误路径。
2. 优先运行不会修改文件的安全检查:`npm audit --json``cargo audit`、密钥扫描、危险代码模式扫描。
3. 分前端供应链、后端供应链、源码安全、配置/脚本安全四类归档。
4. 对结果按严重级别分层Critical / High / Medium / Low / Informational。
5. 对每个真实问题给出:影响范围、证据、可行修复、验证命令、是否需要业务回归。
6. 只有在用户确认进入执行/修复阶段后,才做依赖升级、代码修复、文档更新、测试和提交。
---
## Step-by-step Plan
### Task 1: 确认扫描工作区和基线状态
**Objective:** 确保后续扫描针对当前 worktree且不会误把既有未提交变更当成安全修复结果。
**Files:**
- Read-only: `AGENTS.md`
- Read-only: `.hermes/README.md`
- Read-only: `.hermes/shared-memory/development-workflow.md`
- Read-only: `package.json`
- Read-only: `server-rs/Cargo.toml`
**Commands:**
```bash
pwd
git status --short
git branch --show-current
git rev-parse --show-toplevel
```
**Expected:**
- `pwd` / `git rev-parse --show-toplevel` 指向 `C:/proj/Genarrative/.worktrees/hermes-3337436a` 对应路径。
- 分支为当前隔离 worktree 分支。
- 记录是否已有未提交变更;如存在,扫描报告需标注“基于含未提交变更的工作区”。
**Validation:**
- 不修改任何项目文件。
- 如发现路径不是当前 worktree停止并重新确认路径。
### Task 2: 生成依赖清单和锁文件基线
**Objective:** 明确 Node 与 Rust 依赖入口,避免漏扫子包或 admin web。
**Files:**
- Read-only: `package.json`
- Read-only: `package-lock.json`
- Read-only: `apps/admin-web/package.json`
- Read-only: `packages/shared/package.json`
- Read-only: `server-rs/Cargo.toml`
- Read-only: `server-rs/Cargo.lock`
**Commands:**
```bash
npm --version
node --version
cargo --version
rustc --version
```
可选只读清单:
```bash
npm ls --all --json > /tmp/genarrative-npm-ls.json
cargo metadata --manifest-path server-rs/Cargo.toml --format-version 1 > /tmp/genarrative-cargo-metadata.json
```
**Expected:**
- 明确 npm / Node / Rust / Cargo 版本。
-`npm ls` 因 peer dependency 或历史依赖问题非 0保留输出并继续 audit。
**Validation:**
- `/tmp` 输出不进入 Git。
- 不运行 `npm install``npm update``cargo update`
### Task 3: Node 供应链漏洞扫描
**Objective:** 检查根 lockfile 覆盖的前端、脚本和 admin web 依赖漏洞。
**Files:**
- Read-only: `package-lock.json`
- Read-only: `package.json`
**Commands:**
```bash
npm audit --json > /tmp/genarrative-npm-audit.json
npm audit --audit-level=moderate
```
**Expected:**
- `npm audit --json` 生成机器可读结果。
- 第二条命令给出人类可读摘要;如返回非 0按漏洞严重度记录不直接执行 `npm audit fix`
**Result fields to extract:**
- package name
- vulnerable versions
- installed version
- severity
- CVE / GHSA
- via chain
- fixAvailable 是否为 major/breaking
- affected direct dependency or transitive dependency
**Validation:**
- 不执行 `npm audit fix`
- 如 npm registry 网络不可用,记录阻塞原因和可重试命令。
### Task 4: Rust 供应链漏洞扫描
**Objective:** 检查 `server-rs` workspace 的 Cargo 依赖漏洞、弃用 crate 和 yanked crate。
**Files:**
- Read-only: `server-rs/Cargo.toml`
- Read-only: `server-rs/Cargo.lock`
**Commands:**
优先:
```bash
cargo audit --json --manifest-path server-rs/Cargo.toml > /tmp/genarrative-cargo-audit.json
cargo audit --manifest-path server-rs/Cargo.toml
```
如果本机没有 `cargo audit`
```bash
cargo install cargo-audit --locked
cargo audit --manifest-path server-rs/Cargo.toml
```
**Execution note:**
- 安装 `cargo-audit` 会改变用户 Cargo 工具目录,不属于纯只读扫描;执行前需用户确认。
- 如果用户不希望安装工具则记录“Rust 漏洞扫描未完成”,并给出本地安装或 CI 执行建议。
**Result fields to extract:**
- advisory id
- package
- version
- patched versions
- unaffected versions
- severity / CVSS if available
- dependency path
- whether it is runtime reachable in `api-server` / `spacetime-module`
**Validation:**
- 不运行 `cargo update`
- 不改 `Cargo.lock`
### Task 5: 密钥和敏感配置泄露扫描
**Objective:** 检查仓库中是否误提交 API key、token、私钥、cookie、`.env` 类文件或个人 Hermes 配置。
**Files / paths to scan:**
- Full repo excluding `.git/`, `node_modules/`, `target/`, `dist/`, build artifacts。
- 特别关注:`.hermes/``scripts/``server-rs/``apps/admin-web/``src/``docs/`
**Preferred commands:**
如果有 gitleaks
```bash
gitleaks detect --source . --no-git --redact --report-format json --report-path /tmp/genarrative-gitleaks.json
```
如果没有 gitleaks先用只读 grep/ripgrep 兜底:
```bash
git ls-files -z | xargs -0 grep -nIE "(api[_-]?key|secret|password|passwd|token|private[_-]?key|BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|AKIA[0-9A-Z]{16}|xox[baprs]-|sk-[A-Za-z0-9_-]{20,})" > /tmp/genarrative-secret-grep.txt || true
```
**Execution note:**
- 安装 gitleaks 需要用户确认。
- grep 结果包含 false positive必须人工分级不得直接当作泄露结论。
**Validation:**
- 报告中对值做脱敏,只保留前后 3-4 位或完全不记录值。
- 如果发现 `.env.local` 或真实 token 被跟踪,立即标为 Critical。
### Task 6: 常见源码安全模式扫描
**Objective:** 快速发现高风险代码模式命令注入、动态执行、路径穿越、危险反序列化、XSS、日志泄密、宽松 CORS 等。
**Files / paths:**
- `src/**/*.{ts,tsx,js,mjs,cjs}`
- `apps/admin-web/**/*.{ts,tsx,js,mjs,cjs}`
- `scripts/**/*.{js,mjs,cjs,ts}`
- `server-rs/crates/**/*.rs`
**Commands:**
```bash
# JS/TS 动态执行与 HTML 注入
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
# Node 命令执行风险
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
# Rust 命令、文件路径、unwrap 风险热点
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
# 宽松 CORS / Cookie / Auth 相关热点
rg -n "allow_origin|Any|cookie|Authorization|Bearer|refresh|access_token|set_cookie|SameSite|Secure|HttpOnly" server-rs/crates src apps scripts
```
**Expected:**
- 输出作为“热点清单”,不等同于漏洞。
- 对 auth/session、文件上传、OSS 签名、外部 LLM/图片服务请求、SpacetimeDB 访问 facade 做人工复核。
**Validation:**
- 每个疑似问题必须能说明可利用条件,无法说明则降级为 Informational。
### Task 7: Web/API 安全配置人工复核
**Objective:** 对项目特有的安全边界做代码审阅,补足工具扫描无法覆盖的业务风险。
**Likely files to review:**
- `server-rs/crates/api-server/src/**`
- `server-rs/crates/platform-auth/src/**`
- `server-rs/crates/platform-oss/src/**`
- `server-rs/crates/platform-llm/src/**`
- `server-rs/crates/spacetime-client/src/**`
- `src/services/**`
- `apps/admin-web/src/**`
- `scripts/*deploy*`
- `scripts/*api-server*`
- `.github/workflows/**` if present
**Checklist:**
- Auth / sessionaccess token 与 refresh cookie 的生命周期、SameSite/Secure/HttpOnly、错误日志是否泄露 token。
- CORS开发环境与生产环境是否区分是否存在生产 `Any`
- SSRF / outboundLLM、图片生成、OSS、任意 URL 下载是否校验协议和大小。
- Upload / Data URL大小限制、MIME 校验、base64 解析错误处理。
- Path traversal脚本和后端是否拼接用户输入路径。
- Admin后台接口是否有权限校验是否复用普通用户 token。
- SpacetimeDBprivate table / reducer 是否绕过 api-server facade 暴露敏感数据。
- Logging日志是否打印 API key、token、cookie、用户私密内容。
**Validation:**
- 对每个命中的真实风险,记录具体文件路径和函数名。
- 对“需要运行环境才能验证”的风险,列出 smoke 或单测建议。
### Task 8: 汇总漏洞分级与整改建议
**Objective:** 把扫描结果转成团队可执行的安全整改报告。
**Deliverable candidates:**
- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md`
- 或如果用户只要临时报告:`.hermes/plans/assets/security-scan-YYYY-MM-DD.md`
**Report structure:**
```markdown
# 安全漏洞扫描报告 YYYY-MM-DD
## 扫描范围
## 扫描命令与环境
## 摘要
## Critical
## High
## Medium
## Low
## Informational / False Positive
## 依赖升级建议
## 代码修复建议
## 需要人工确认的问题
## 验证命令
```
**Validation:**
- 报告不包含真实密钥。
- 每条问题都有“证据、影响、建议、验证”。
- 明确哪些是工具扫描结果,哪些是人工判断。
### Task 9: 如用户要求修复,再分批执行最小修复
**Objective:** 避免一次性大规模升级导致回归,把修复拆为可验证的小批次。
**Suggested order:**
1. Critical secrets立即移除、轮换密钥、补 `.gitignore`/文档约束(注意项目约束:不要在 `.gitignore` 中添加 `.env.local`)。
2. Critical/High direct dependencies优先升级 direct dependency运行最小测试。
3. Critical/High transitive dependencies评估是否由 direct dependency patch/minor 升级带出。
4. 源码漏洞:按入口编写回归测试,再修复。
5. Medium/Low按风险和 breaking change 代价排期。
**Required verification after fixes:**
```bash
npm run check:encoding
npm run lint:eslint
npm run typecheck
npm run test
npm run build
cd server-rs && cargo test --workspace
```
后端 API 或 auth 修复涉及运行态时,还需要:
```bash
npm run api-server
# 另一个终端检查 /healthz 并执行对应 smoke
```
**Validation:**
- 修复后重新跑对应 audit / secret scan。
-`requesting-code-review` 的独立安全复核流程。
---
## Files likely to change仅修复阶段
本计划阶段不修改以下文件;只有用户确认执行修复时才可能变化:
- `package.json`
- `package-lock.json`
- `apps/admin-web/package.json`
- `server-rs/Cargo.toml`
- `server-rs/Cargo.lock`
- `server-rs/crates/api-server/src/**`
- `server-rs/crates/platform-auth/src/**`
- `server-rs/crates/platform-oss/src/**`
- `server-rs/crates/platform-llm/src/**`
- `src/services/**`
- `apps/admin-web/src/**`
- `scripts/**`
- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md`
- `.hermes/shared-memory/pitfalls.md`(仅当发现长期有效、会反复踩的安全排障经验时更新)
## Tests / Validation
安全扫描执行阶段:
```bash
npm audit --json > /tmp/genarrative-npm-audit.json
npm audit --audit-level=moderate
cargo audit --manifest-path server-rs/Cargo.toml
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
```
修复执行阶段:
```bash
npm run check:encoding
npm run lint:eslint
npm run typecheck
npm run test
npm run build
cd server-rs && cargo test --workspace
```
如变更后端运行态、安全中间件、auth/session
```bash
npm run api-server
# 检查 /healthz
# 执行相关 auth / API smoke
```
## Risks, tradeoffs, and open questions
- `npm audit fix` 可能升级 major version破坏 Vite/React/ESLint/Vitest 兼容性;必须先人工审查 `fixAvailable`
- `cargo audit` 可能需要安装 `cargo-audit`;安装工具属于用户环境变更,应先确认。
- 密钥扫描极易产生 false positive必须人工复核报告中禁止输出真实密钥。
- Rust `unwrap/expect` 不是天然漏洞;只有对外部输入、网络、文件、数据库响应等不可信数据造成 panic/DoS 时才升级为真实风险。
- Web 安全检查需要区分开发环境和生产环境;开发 CORS 放宽不等于生产漏洞,但生产配置必须有明确边界。
- 如果扫描发现历史提交中曾泄露密钥,删除当前文件不够,必须轮换密钥并考虑历史清理策略。
- 当前计划未直接访问 CI/Jenkins/生产配置;若用户希望覆盖 CI/CD、镜像、部署主机和运行时端口需要补充 Jenkins console、部署脚本和生产环境配置的只读访问方式。
## Missing artifacts / follow-up checkpoints
- 尚未获得用户确认是否允许安装 `cargo-audit` / `gitleaks` 等工具。
- 尚未执行真实扫描,因此当前没有漏洞结论;执行后需要生成正式报告。
- 如果用户希望“检查当前项目”包含远端仓库历史 secrets、Docker 镜像、Jenkins 凭据和生产运行时配置,需要另行确认访问范围和凭据边界。

View File

@@ -16,6 +16,14 @@
---
## 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 表和字段。
- 影响范围:拼图结果页、抓大鹅结果页、抓大鹅运行态音频播放、通用创作音频 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-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位。
@@ -24,6 +32,14 @@
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件。
- 关联文档:`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 方洞挑战从创作页入口和作品架隐藏
- 背景:运营节奏要求创作页完全隐藏方洞挑战,不能只隐藏新建入口后仍从创作页作品架暴露已有方洞草稿或已发布作品。
- 决策SpacetimeDB `creation_entry_type_config``square-hole.visible=false` 作为创作页统一开关;创作 Tab 模板入口、旧选择弹层、创作 Hub 卡带和创作页作品架都基于该开关隐藏方洞挑战。既有方洞详情、作品号、广场和运行态链路暂不删除api-server 路由熔断只按 `open=false` 禁用玩法 API。
- 影响范围SpacetimeDB 入口配置默认种子、`platformEntryCreationTypes``CustomWorldCreationHub``PlatformEntryFlowShellImpl` 以及创作入口相关文档和回归测试。
- 验证方式:执行入口配置、创作 Hub 和平台入口交互定向测试,确认看不到“方洞挑战” Tab、按钮和作品架条目。
- 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md``docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`
## 2026-05-10 运行态输入设备抽象层全项目通用化
- 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。
@@ -32,6 +48,14 @@
- 验证方式:执行 `npm run test -- src\services\input-devices\runtimeDragInputController.test.ts``npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx``npm run typecheck` 和编码检查。
- 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md``docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md`
## 2026-05-11 前端调试模式统一判断
- 背景:拼图 mocap 调试面板此前在运行态常驻展示,生产构建和正式体验里容易遮挡棋盘内容;后续其它局部诊断 UI 也需要统一的调试模式入口。
- 决策:前端新增 `src/config/debugMode.ts` 作为全局调试模式判断,默认跟随 Vite 开发态,允许 `VITE_DEBUG_MODE=true/false` 显式覆盖。拼图运行态 mocap 调试面板只在调试模式下渲染,并默认折叠,只保留连接状态行。
- 影响范围:前端局部调试 UI、拼图运行态 mocap 诊断面板、`.env.example` 和运行态输入技术文档。
- 验证方式:执行 `npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx``npm run typecheck` 和编码检查。
- 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`
## 2026-05-10 儿童动作热身关直接消费 mocap 数据源
- 背景:儿童动作 Demo 不能只依赖浏览器摄像头状态和键鼠调试输入否则真实硬件接入后会出现“mocap 在线但页面提示摄像头不可用”或“能看到画面但动作不推进”的卡点。
@@ -110,7 +134,7 @@
- 背景:平台计划新增 2048 游戏玩法模板,需要同时适配前端 stage、HTTP 路由、Rust 模块、SpacetimeDB 表和公开作品号;裸 `2048` 不适合作为模块或文件命名前缀。
- 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。
- 影响范围:后续 `src/config/newWorkEntryConfig.ts`、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight``shared-contracts``spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。
- 影响范围:后续 SpacetimeDB 创作入口配置、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight``shared-contracts``spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。
- 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。
- 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md`
@@ -213,10 +237,26 @@
## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择
- 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。
- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风``画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`
- 影响范围:`VisualNovelAgentWorkspace``PlatformEntryFlowShellImpl``platformEntryTypes`、视觉小说 PRD;不新增后端字段或数据库结构。
- 验证方式:视觉小说工作台单测通过,`npm run check:encoding` 通过`npm run typecheck` 仍受仓库里 `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 的既有类型错误影响
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`
- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风``画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的静态参考图,不在前端运行时现场调用生图接口。
- 影响范围:`VisualNovelAgentWorkspace``visualNovelEntryGeneration``PlatformEntryFlowShellImpl`、视觉小说 PRD 和创作 Tab 设计文档;不新增后端字段或数据库结构。
- 验证方式:执行 `npm run test -- VisualNovelAgentWorkspace`、视觉小说工作台相关 ESLint、`npx prettier --check``npm run check:encoding``npm run typecheck` 若失败需先区分是否来自无关 Match3D / RPG 既有改动
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`
## 2026-05-10 用户标签只做后端白名单投影
- 背景:运营邀请码需要给账号打标签,但标签默认不能暴露到前端通用用户资料;拼图排行榜仅需展示特定标签。
- 决策:`user_account.user_tags` 保存账号标签,数据库默认 `None`,业务按空数组读取;后台预置邀请码使用后授予的标签不再使用独立列,统一存放并解析自 `profile_invite_code.metadata_json.userTags`,兼容读取 `user_tags`。通用登录态和个人资料不返回原始标签。首版只在拼图排行榜 `visibleTags` 中白名单投影 `北科`
- 影响范围:用户认证表、邀请码后台、邀请兑换事务、拼图排行榜响应和 UI。
- 验证方式:表结构变更需同步 `migration.rs``SPACETIMEDB_TABLE_CATALOG.md` 和 SpacetimeDB bindings后端运行 `cargo check -p api-server`,后台运行 `npm run admin-web:typecheck`
- 关联文档:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md`
## 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 技术文档。
- 验证方式:执行 `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`
## 2026-05-07 移动端整页缩放由入口统一锁定

View File

@@ -155,6 +155,14 @@
- 验证:重新发布日志应显示创建新的数据库,而不是更新旧数据库;若仍显示更新或继续 `401`,继续检查数据目录、库名和 CLI 身份。
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md``docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`
## SpacetimeDB 模块 publish 报 `wasm-bindgen detected`
- 现象:`spacetime publish` 已经完成 Rust 编译,但随后报 `wasm-bindgen detected`,提示依赖树里有面向 Web 平台的 wasm-bindgen。
- 原因SpacetimeDB 模块是数据库内 WASM不允许拉入 Web/HTTP client 链路;常见误因是 `spacetime-module -> module-* -> shared-contracts -> platform-* -> reqwest -> wasm-bindgen` 这类反向依赖。
- 处理:执行 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 找到链路;把平台实现类型从 `shared-contracts``module-*` 中移除,只保留公开 DTO平台响应到 DTO 的转换放回 `api-server` 等 adapter 层。
- 验证:上述 `cargo tree` 输出 `warning: nothing to print``cargo check -p shared-contracts``cargo check -p api-server` 通过;重新 `spacetime publish ... --module-path server-rs/crates/spacetime-module` 不再报 wasm-bindgen。
- 关联:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md``server-rs/crates/shared-contracts/src/assets.rs``server-rs/crates/api-server/src/assets.rs`
## Vite SPA fallback 吞掉 API 请求
- 现象:本地请求 `/api/profile/*` 等接口时返回 HTML被前端当 JSON 解析报错。
@@ -383,10 +391,10 @@
## Rust 构建不要让不可用的 sccache 阻断 rustc
- 现象Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。
- 处理:本地临时排障可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...``npm run dev:rust` 的 SpacetimeDB publish 已在命中 sccache 通信失败时自动清空 wrapper 重试一次;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;清空 wrapper 后 `cargo check --target=wasm32-unknown-unknown --release` 能通过Jenkins 日志出现“未找到可用 sccache改用 rustc 直接构建”后仍继续真实构建。
- 现象Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)``sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时Cargo 的 `sccache rustc -vV` 可能先超时。
- 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;冷启动后原始 `cargo check -p api-server``cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明仍在使用 sccache/OSS 缓存Jenkins 日志出现“未找到可用 sccache改用 rustc 直接构建”后仍继续真实构建。
- 关联:`scripts/dev-rust-stack.sh``jenkins/Jenkinsfile.production-stdb-module-build``docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
@@ -437,13 +445,21 @@
- 验证:`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 模型下载
## 抓大鹅草稿生成恢复 Rodin 后要并行生成模型、同步长超时和 GLB 私有读取
- 现象:抓大鹅草稿生成时 Hyper3D 状态已完成,但下载列表为空或没有可用模型文件,`/api/creation/match3d/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `Hyper3D 已完成但未返回可下载模型文件`
- 原因:草稿生成链路曾在切割图片后立即并行调用 Rodin 图生模型,并把模型下载成功作为草稿完成前置条件;上游完成态和可下载文件列表不是强一致,容易把本来可用的图片草稿卡死
- 处理:草稿阶段只生成物品名、素材图、切割独立图片并上传 OSS返回 `status = image_ready`Rodin 3D 模型生成留到结果页 `3D素材` Tab 手动触发
- 验证:草稿响应中的 `generatedItemAssets[].imageSrc` 有值、`modelSrc` 为空、状态为 `image_ready`;结果页显示 `图片已就绪``0 文件`,不会自动请求 Hyper3D 下载
- 关联:`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`
- 现象:抓大鹅草稿生成重新接回 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`
## 抓大鹅切图路径不能只用中文物品名
@@ -460,3 +476,35 @@
- 处理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`
- 关联:`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`
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
- 现象AI 或兜底生成的 `3D素材` 标签在后端规范化后变成 `D素材`
- 原因:标签清洗在去掉编号列表前缀后,又无条件剥离开头数字和标点,把合法标签中的 `3D` 当成列表编号处理。
- 处理:只移除明确的编号列表前缀,例如 `1. 标签``1、标签``1) 标签`;不要对普通标签开头数字做二次剥离。
- 验证:`cargo test -p api-server match3d_tag_normalization --manifest-path server-rs/Cargo.toml`,并保留 `normalize_match3d_tag("3D素材") == "3D素材"` 的单测。
- 关联:`server-rs/crates/api-server/src/match3d.rs`
## 用户标签不要直接外显SpacetimeDB Vec 字段不要写 default 宏
- 现象:给 `user_account.user_tags` 或邀请码独立标签列写 `#[default(Vec::<String>::new())]`SpacetimeDB WASM 构建报 `destructor of Vec<String> cannot be evaluated at compile-time`
- 原因SpacetimeDB 的 table default 宏会走编译期常量求值,不能直接使用有析构逻辑的堆分配类型默认值。
- 处理:`user_account.user_tags` 使用 `Option<Vec<String>>` + `#[default(None::<Vec<String>>)]` 表达数据库默认空,业务层统一把 `None` 归一化为空数组;邀请码授予标签复用 `metadata_json.userTags` 存储和解析,不再新增独立 Vec 列。用户标签原始值不得进入登录态、个人资料等通用响应,只能在明确业务白名单里投影,例如拼图排行榜 `visibleTags` 首版仅允许 `北科`
- 验证:`npm run spacetime:generate -- --rust-only` 能通过;`user_account` 旧迁移 JSON 缺字段时能导入,`profile_invite_code``metadata_json` 时按 `{}` 兼容。
- 关联:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 公开作品详情深链找不到作品不能停在空详情页
- 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。
- 原因:旧恢复逻辑只覆盖 `/runtime/...`,没有覆盖 `/works/detail`。同时 `selectionStage === 'work-detail'``selectedPublicWorkDetail === null` 时没有兜底渲染,详情数据为空就只剩空页面。
- 处理:公开详情失效统一走 `resolveWorkNotFoundRecoveryAction(...)`,覆盖 `/works/detail``/gallery/puzzle/detail``/gallery/visual-novel/detail`;搜索失败和拼图详情 404 分支清理详情/运行态临时状态并回首页;`work-detail` 空数据阶段显示轻量读取态,避免异步间隙白屏。
- 验证:`npm run test -- src/routing/runtimeNotFoundRecovery.test.ts``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail alert returns to platform home"`
- 关联:`docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md``src/routing/runtimeNotFoundRecovery.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`

View File

@@ -12,6 +12,20 @@
- 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。
-`.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
## Agent skills
### Issue tracker
Issues are tracked in the self-hosted Gitea remote for this repo. Use Gitea Issues via the configured Gitea UI/API or `tea` CLI when available; do not use GitHub `gh` or GitLab `glab` unless the repo is migrated. See `docs/agents/issue-tracker.md`.
### Triage labels
Use the default canonical triage labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context layout: read root `CONTEXT.md` when present and architecture decisions from `docs/adr/`. See `docs/agents/domain.md`.
## 项目约束
- 代码需要有完善的中文注释
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。

View File

@@ -182,7 +182,6 @@ export interface AdminUpsertProfileRedeemCodeRequest {
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
metadata?: Record<string, unknown>;
grantedUserTags: string[];
startsAt?: string | null;
expiresAt?: string | null;
}
@@ -229,7 +228,6 @@ export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
grantedUserTags: string[];
startsAt?: string | null;
expiresAt?: string | null;
status: 'pending' | 'active' | 'expired';

View File

@@ -78,10 +78,13 @@ export function AdminInviteCodePage({
setIsSaving(true);
try {
const metadata = withMetadataUserTags(
parseMetadata(metadataText),
parseUserTags(grantedTagsText),
);
const payload: AdminUpsertProfileInviteCodeRequest = {
inviteCode: inviteCode.trim(),
metadata: parseMetadata(metadataText),
grantedUserTags: parseUserTags(grantedTagsText),
metadata,
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
};
@@ -117,7 +120,7 @@ export function AdminInviteCodePage({
setInviteCode(entry.inviteCode);
setStartsAt(toDateTimeLocalValue(entry.startsAt));
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
setGrantedTagsText(entry.grantedUserTags.join('、'));
setGrantedTagsText(metadataUserTags(entry.metadata).join('、'));
setMetadataText(JSON.stringify(entry.metadata, null, 2));
}
@@ -252,7 +255,7 @@ export function AdminInviteCodePage({
</button>
</td>
<td>
<TagList tags={entry.grantedUserTags} />
<TagList tags={metadataUserTags(entry.metadata)} />
</td>
<td>
<span className={`admin-status ${inviteValidityClass(entry)}`}>
@@ -291,7 +294,7 @@ export function AdminInviteCodePage({
<div>
<dt></dt>
<dd>
<TagList tags={result.grantedUserTags} />
<TagList tags={metadataUserTags(result.metadata)} />
</dd>
</div>
<div>
@@ -338,6 +341,29 @@ function TagList({tags}: {tags: string[]}) {
);
}
function metadataUserTags(metadata: Record<string, unknown>) {
const raw = metadata.userTags ?? metadata.user_tags;
if (!Array.isArray(raw)) {
return [];
}
return parseUserTags(raw.filter((value): value is string => typeof value === 'string').join('、'));
}
function withMetadataUserTags(
metadata: Record<string, unknown>,
tags: string[],
): Record<string, unknown> {
const next = {...metadata};
delete next.user_tags;
if (tags.length) {
next.userTags = tags;
} else {
delete next.userTags;
}
return next;
}
function parseUserTags(value: string) {
const tags: string[] = [];
for (const raw of value.split(/[\n,;;、]+/)) {

36
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,36 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Layout
This repo uses a **single-context** layout for Matt Pocock engineering skills:
- `CONTEXT.md` at the repo root, when present, is the primary domain glossary/context file.
- `docs/adr/`, when present, contains architecture decision records.
- If either path does not exist, proceed silently; do not block the task just to create it.
## Before exploring, read these
1. Root `CONTEXT.md`, if present.
2. Relevant ADRs under `docs/adr/`, if present.
3. Existing project context that predates this setup:
- `.hermes/README.md`
- `.hermes/shared-memory/project-overview.md`
- `.hermes/shared-memory/team-conventions.md`
- `.hermes/shared-memory/development-workflow.md`
- `.hermes/shared-memory/decision-log.md`
- `.hermes/shared-memory/pitfalls.md`
- Relevant files under `docs/technical/`, `docs/prd/`, `docs/design/`, and `docs/experience/`
Follow `AGENTS.md` when it is more specific than this file. If older docs conflict with current code or newer technical docs, treat current code and newer docs as authoritative and update stale docs when the task requires it.
## Use the glossary's vocabulary
When your output names a domain concept in an issue title, refactor proposal, diagnosis, test name, or implementation plan, use the term as defined in `CONTEXT.md` when available. Do not drift to synonyms the glossary explicitly avoids.
If the concept you need is not in the glossary yet, either use the established vocabulary from `.hermes/shared-memory/` and `docs/`, or note the gap for a future documentation pass.
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding it.

View File

@@ -0,0 +1,35 @@
# Issue tracker: Gitea
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`
- Tracker type: Gitea Issues
## Conventions
- Prefer the Gitea `tea` CLI when it is installed and configured for this host.
- Do not use GitHub `gh` or GitLab `glab` for this repo unless the repository is explicitly migrated to those platforms.
- If `tea` is unavailable, use the Gitea Web UI or Gitea REST API for the same operations.
## Common operations with `tea`
Exact flags can vary by `tea` version. Run `tea issues --help` or `tea issue --help` before using a command in a new environment.
- Create an issue: `tea issues create --title "..." --body "..."`
- Read an issue: `tea issues view <number>`
- List issues: `tea issues list`
- Comment on an issue: use the installed `tea` issue comment command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
- Apply labels: use the installed `tea` issue update/edit command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
- Close an issue: use the installed `tea` issue close/update command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
## When a skill says "publish to the issue tracker"
Create a Gitea issue in `GenarrativeAI/Genarrative` with the requested title, body, labels, and links back to any relevant docs or branch.
## When a skill says "fetch the relevant ticket"
Read the Gitea issue body and comments/notes for the referenced issue number. Include labels and current open/closed state in the working context.
## Authentication
Use the locally configured Gitea credentials for the current developer. Do not commit tokens, cookies, `.env`, or local credential files.

View File

@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's Gitea issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role, use the corresponding Gitea label string from this table.
If the Gitea repository later adopts Chinese labels or a different naming scheme, edit the right-hand column here rather than letting skills create duplicate labels.

View File

@@ -0,0 +1,377 @@
# 安全漏洞扫描报告 2026-05-11
## 扫描范围
- 工作区:`C:/proj/Genarrative/.worktrees/hermes-3337436a`
- 分支:`hermes/hermes-3337436a`
- Git 基线:扫描时存在一个未跟踪计划文件 `.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md`
- 扫描对象:根 Node/Vite/React 依赖、`server-rs` Rust workspace 依赖入口、仓库已跟踪文件中的敏感配置、JS/TS/Rust 源码安全热点。
## 扫描命令与工具状态
已执行:
```bash
pwd
git branch --show-current
git rev-parse --show-toplevel
git status --short
node --version
npm --version
cargo --version
rustc --version
rg --version
npm audit --json
npm audit --audit-level=moderate
git ls-files -z | xargs -0 grep -nIE "(api[_-]?key|secret|password|passwd|token|private[_-]?key|BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|AKIA[0-9A-Z]{16}|xox[baprs]-|sk-[A-Za-z0-9_-]{20,})"
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
rg -n "allow_origin|Any|cookie|Authorization|Bearer|refresh|access_token|set_cookie|SameSite|Secure|HttpOnly" server-rs/crates src apps scripts
```
工具版本:
- Node`v22.22.2`
- npm`10.9.7`
- Cargo`cargo 1.95.0 (f2d3ce0bd 2026-03-21)`
- Rustc`rustc 1.95.0 (59807616e 2026-04-14)`
- ripgrep`15.1.0`
- `gitleaks`:未安装,本次未执行。
- `cargo-audit`:未安装,本次未执行;未擅自安装到用户环境。
原始扫描输出保存于:
- `.hermes/plans/assets/security-scan-2026-05-11/npm-audit.json`
- `.hermes/plans/assets/security-scan-2026-05-11/npm-audit.txt`
- `.hermes/plans/assets/security-scan-2026-05-11/secret-grep.txt`
- `.hermes/plans/assets/security-scan-2026-05-11/js-xss-dynamic.txt`
- `.hermes/plans/assets/security-scan-2026-05-11/node-command-exec.txt`
- `.hermes/plans/assets/security-scan-2026-05-11/rust-hotspots.txt`
- `.hermes/plans/assets/security-scan-2026-05-11/auth-cors-hotspots.txt`
注意:`secret-grep.txt` 可能包含敏感片段,提交前应删除或改为脱敏摘要,不建议直接进入 Git。
## 摘要
| 等级 | 数量 | 说明 |
| --- | ---: | --- |
| Critical | 1 | `.env.local` 被 Git 跟踪且含多项非空真实密钥/凭据形态配置。 |
| High | 2 | npm 依赖存在 8 个 high advisory 聚合项Vite dev server 任意文件读取类漏洞需要优先升级。另有 TypeScript ESLint 链路 ReDoS 风险。 |
| Medium | 2 | esbuild dev server 请求读取、PostCSS CSS stringify XSS。 |
| Low | 3 | jsdom/http-proxy-agent/@tootallnate/once 低危链路。 |
| Unknown | 1 | Rust 依赖漏洞未完成扫描,因为本机未安装 `cargo-audit`。 |
| Informational | 多项 | 源码热点扫描命中大量测试/脚本/unwrap/expect需要按入口人工复核。 |
## Critical
### C-1仓库跟踪了 `.env.local`,且包含多项非空真实密钥形态配置
**证据:**
`git ls-files --error-unmatch .env.local` 显示 `.env.local` 已被 Git 跟踪。扫描确认该文件包含多项非空密钥/凭据形态变量,包括但不限于:
- `LLM_API_KEY`
- `ARK_API_KEY`
- `ARK_CHARACTER_VIDEO_API_KEY`
- `DASHSCOPE_API_KEY`
- `VOLCENGINE_ACCESS_KEY_ID`
- `VOLCENGINE_SECRET_ACCESS_KEY`
- `ALIYUN_SMS_ACCESS_KEY_ID`
- `ALIYUN_SMS_ACCESS_KEY_SECRET`
- `GENARRATIVE_LLM_API_KEY`
- `ALIYUN_OSS_ACCESS_KEY_ID`
- `ALIYUN_OSS_ACCESS_KEY_SECRET`
- `GENARRATIVE_ADMIN_PASSWORD`
报告中不记录具体值。
**影响:**
如果该文件已进入远端仓库或被团队成员拉取相关外部服务密钥、OSS/SMS/LLM/后台密码均应视为已泄露。即使后续从当前工作树删除,也不能撤销历史泄露风险。
**建议修复:**
1. 立即轮换 `.env.local` 中出现过的所有真实密钥、访问密钥、后台密码和 token。
2. 从 Git 跟踪中移除 `.env.local`,但不要删除本地私有文件:
```bash
git rm --cached .env.local
```
3. 按项目约束,不要在 `.gitignore` 中新增 `.env.local`;如果仓库已有其他机制管理本地私密 env应遵循既有约定。若没有应先补一份安全说明文档而不是提交真实 `.env.local`。
4. 将必要的占位示例保留在 `.env.example` 或 `deploy/env/api-server.env.example`,确保示例值不是可用密钥。
5. 如该文件已推送到远端历史,评估是否需要历史清理;无论是否清历史,密钥轮换都是必须步骤。
**验证:**
```bash
git ls-files --error-unmatch .env.local
# 预期:返回非 0表示不再跟踪
git diff --cached -- .env.local
# 预期:只显示从索引移除,不输出真实值到公开报告
```
## High
### H-1Vite 依赖存在高危 dev server 任意文件读取/路径遍历类 advisory
**证据:**
`npm audit` 显示:
- package`vite`
- direct dependency
- installed vulnerable range`<=6.4.1`
- severity`high`
- 相关 advisory 包括:
- `GHSA-p9ff-h696-f583`Vite dev server WebSocket 任意文件读取,高危。
- `GHSA-4w7w-66w2-5vf9`optimized deps `.map` 处理路径遍历,中危。
- 多个 `server.fs` / public directory 相关低中危问题。
- `fixAvailable=true`。
**影响:**
主要影响开发服务器和预览环境。如果开发机、测试机或内网联调环境将 Vite dev server 暴露给不可信网络,攻击者可能读取工作区文件或旁路 `server.fs` 限制。
**建议修复:**
1. 优先将 `vite` 升级到 npm audit 推荐的安全版本范围。
2. 升级后执行:
```bash
npm run check:encoding
npm run lint:eslint
npm run typecheck
npm run test
npm run build
```
3. 检查 `scripts/vite-cli.mjs`、`scripts/dev-web-rust.mjs`、Vite 配置中的 dev server host 暴露范围,开发环境避免绑定 `0.0.0.0` 或暴露到公网。
### H-2`@typescript-eslint/*` 链路经 `minimatch` 存在 ReDoS 高危 advisory
**证据:**
`npm audit` 显示:
- direct packages
- `@typescript-eslint/eslint-plugin`,当前范围 `6.16.0 - 7.5.0`high。
- `@typescript-eslint/parser`,当前范围 `6.16.0 - 7.5.0`high。
- transitive packages
- `@typescript-eslint/type-utils`
- `@typescript-eslint/typescript-estree`
- `@typescript-eslint/utils`
- `minimatch`
- `minimatch` advisory
- `GHSA-3ppc-4f35-3m26`
- `GHSA-7r86-cg39-jmmj`
- `GHSA-23c5-xmqv-rm74`
- npm 建议升级到 `@typescript-eslint/* 8.59.3`,属于 SemVer major。
**影响:**
主要影响 lint/构建工具链。如果 CI 或开发命令处理不可信 glob pattern可能造成 ReDoS。生产运行时直接影响较低但 CI 可用性和供应链安全仍应修复。
**建议修复:**
1. 单独开依赖升级分支,将 `@typescript-eslint/eslint-plugin` 与 `@typescript-eslint/parser` 升级到兼容 ESLint 8/TypeScript 5.8 的安全版本。
2. 因为是 major 升级,先阅读迁移说明并运行 ESLint 全量检查。
3. 验证:
```bash
npm run lint:eslint
npm run typecheck
npm run test
```
### H-3`picomatch` ReDoS / glob matching 高危 advisory
**证据:**
`npm audit` 显示:
- package`picomatch`
- severity`high`
- vulnerable range`4.0.0 - 4.0.3`
- advisory
- `GHSA-c2c7-rcm5-vvqj`extglob quantifiers ReDoS高危。
- `GHSA-3v7f-55p6-f55p`POSIX character classes method injection中危。
- `fixAvailable=true`。
**影响:**
主要影响依赖 picomatch 的构建、测试、文件匹配工具链。生产直接影响取决于是否在服务端运行时用它处理用户输入 glob当前未在扫描摘要中发现明显业务入口直接使用。
**建议修复:**
通过升级引入它的 direct dependency 来消除,不建议手工改 lockfile。
## Medium
### M-1`esbuild <=0.24.2` dev server 允许任意网站请求并读取响应
**证据:**
`npm audit` 显示:
- package`esbuild`
- severity`moderate`
- advisory`GHSA-67mh-4wv8-2f99`
- vulnerable range`<=0.24.2`
- `fixAvailable=true`。
**影响:**
主要影响开发服务器场景。若本地开发服务暴露到不可信网络,风险上升。
**建议修复:**
随 Vite / 构建链路升级一并修复,升级后跑前端检查与构建。
### M-2`postcss <8.5.10` CSS stringify XSS advisory
**证据:**
`npm audit` 显示:
- package`postcss`
- severity`moderate`
- advisory`GHSA-qx2v-qp2m-jg93`
- vulnerable range`<8.5.10`
- `fixAvailable=true`。
**影响:**
如果系统把不可信 CSS 内容 stringify 后注入页面,可能触发 XSS。当前项目是否存在这类业务入口需人工复核从依赖角度建议升级。
**建议修复:**
升级 Tailwind/Vite/PostCSS 链路带出的安全版本,并执行前端构建验证。
## Low
### L-1`jsdom` 链路低危 advisory
**证据:**
`npm audit` 显示:
- `jsdom` direct dependencyseverity low。
- transitive`http-proxy-agent`、`@tootallnate/once`。
- npm 建议升级到 `jsdom 29.1.1`SemVer major。
**影响:**
通常影响测试环境。若测试工具处理不可信 URL/代理输入,风险上升。
**建议修复:**
不要和 Vite/TypeScript ESLint 大升级混在一个提交里。单独升级 jsdom 后运行:
```bash
npm run test
```
## Unknown / 未完成项
### U-1Rust 依赖漏洞未完成扫描
**原因:**
本机没有 `cargo-audit`,本次没有擅自安装用户级 Cargo 工具。
**建议:**
如确认允许安装:
```bash
cargo install cargo-audit --locked
cargo audit --manifest-path server-rs/Cargo.toml
```
或在 CI/具备工具的环境执行并回填结果。
## Informational / 源码热点
### I-1JS/TS XSS / 动态执行热点
扫描命中 1 行:
- `src/routing/RouteImageReadyGate.test.ts` 中测试代码使用 `root.innerHTML`。
初步判断为测试环境构造 DOM不是生产漏洞。若后续发现生产代码使用 `dangerouslySetInnerHTML` 或直接 `innerHTML = userInput`,应升级为 High。
### I-2Node 脚本命令执行热点
扫描命中 21 行,主要集中在:
- `scripts/spacetime-migration-common.mjs`
- `scripts/run-bash-script.mjs`
- `scripts/generate-spacetime-bindings.mjs`
- `scripts/dev-web-rust.mjs`
初步判断为项目脚本启动 `spacetime`、bash、Vite 等工具的正常行为。后续人工复核重点:
- 是否使用固定命令和参数数组,而不是拼接 shell 字符串。
- 是否把用户输入直接作为命令或 shell 参数。
- 是否设置 `shell: true`。
### I-3Rust unwrap/expect、文件路径、CORS/Auth 热点较多
扫描命中:
- `rust-hotspots.txt`1348 行。
- `auth-cors-hotspots.txt`1157 行。
这些是热点,不等于漏洞。建议后续按模块分批人工复核:
1. `server-rs/crates/api-server/src/admin.rs`
2. `server-rs/crates/api-server/src/app.rs`
3. `server-rs/crates/platform-auth/src/**`
4. `server-rs/crates/platform-oss/src/**`
5. `server-rs/crates/platform-llm/src/**`
6. `server-rs/crates/spacetime-client/src/**`
重点看:生产 CORS、Cookie 安全属性、token 日志、路径拼接、外部 URL 下载、Data URL 大小限制、OSS 签名边界。
## 推荐修复顺序
1. 立即处理 C-1轮换 `.env.local` 里所有真实密钥,并从 Git 索引移除 `.env.local`。
2. 升级 `vite` 相关依赖,优先消除 dev server 任意文件读取/路径遍历 advisory。
3. 升级 `@typescript-eslint/*`,消除 minimatch 链路 ReDoS因 major 升级,单独提交。
4. 升级 `postcss` / `esbuild` / `picomatch` 的来源依赖。
5. 单独评估 `jsdom` major 升级。
6. 用户确认后安装或使用 CI 执行 `cargo audit`,补齐 Rust 依赖漏洞结论。
7. 对 `auth-cors-hotspots.txt` 和 `rust-hotspots.txt` 做模块级人工审计。
## 修复后的验证命令
```bash
npm run check:encoding
npm run lint:eslint
npm run typecheck
npm run test
npm run build
```
如修改后端安全、Auth、Cookie、CORS 或 API
```bash
cd server-rs && cargo test --workspace
npm run api-server
# 检查 /healthz并执行相关 API/auth smoke
```
如补齐 Rust audit
```bash
cargo audit --manifest-path server-rs/Cargo.toml
```
## 备注
- 本报告没有输出任何真实密钥值。
- `.hermes/plans/assets/security-scan-2026-05-11/secret-grep.txt` 可能包含敏感内容,仅用于本地排查;提交前应删除或替换为脱敏报告。
- 由于 `gitleaks` 未安装,本次密钥扫描只是 grep 兜底,不等价于完整 secrets audit。

View File

@@ -7,7 +7,7 @@
创作 Tab 恢复为模板选择入口,但不回到旧的大卡片选择面板:
1. 首屏保留现有创作页布局骨架顶部标题固定为“10分钟创作一个精品互动玩法”。
2. 选择模板入口改为横向 Tab数据来自 `src/config/newWorkEntryConfig.ts` 的可见玩法配置。
2. 选择模板入口改为横向 Tab数据来自 `GET /api/creation-entry/config` 返回的可见玩法配置。
3. 默认选中“拼图”模板,并在创作 Tab 内直接展示拼图创作表单。
4. 智能创作入口从可见模板中隐藏,保留既有 `creative-agent` 运行链路用于后续内部恢复或草稿目标打开。
5. 草稿、发现、我的等一级 Tab 职责不变,作品管理仍在草稿 Tab。
@@ -18,7 +18,7 @@
```text
标题10分钟创作一个精品互动玩法
模板 Tab拼图 / 方洞挑战 / 视觉小说 / AIRP
模板 Tab拼图 / 抓大鹅 / 视觉小说(敬请期待)/ AIRP
默认内容:拼图创作表单
```
@@ -34,21 +34,24 @@
1. 打开“创作”一级 Tab 时默认停留在拼图 Tab不主动创建拼图 session。
2. 点击拼图表单“生成草稿”后,才创建拼图 session 并执行 `compile_puzzle_draft`
3. 拼图表单内的模板按钮使用 `tablist / tab` 语义,点击后只填充画面描述。
4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;未开放模板保持禁用。
4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;视觉小说与 AIRP 当前保持敬请期待禁用
5. `creative-agent` 不出现在模板 Tab 和选择弹层中,不再作为创作 Tab 首屏入口。
6. 方洞挑战暂时从创作页完全隐藏,不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中;既有作品链路继续保留。
## 4. 验收
1. 点击“创作”后首屏出现“10分钟创作一个精品互动玩法”。
2. 顶部选择模板入口为 Tab拼图 Tab 默认 `aria-selected=true`
3. 创作 Tab 默认显示拼图创作表单内容且不显示旧“Hi, 朋友”、输入框或智能创作快捷按钮。
4. 隐藏的智能创作类型不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。
4. 隐藏的智能创作类型与方洞挑战不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。
5. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。
## 5. 嵌入式表单 UI 细节
2026-05-10 补充:抓大鹅与视觉小说作为创作 Tab 内嵌表单时,风格类横滑选择器应统一使用浅底卡片、柔和玫瑰色选中态和小圆点确认标记。不要使用大面积黑色渐变、黑底胶囊标签或高饱和红色外框,以免在输入框下方误读为错误提示。
2026-05-10 追加:视觉小说画风选项已改为使用 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的参考图,作为横向卡片主视觉。
嵌入式表单控件保持以下口径:
1. 大文本输入框使用白底、低饱和边框和轻量 focus ring。

View File

@@ -114,3 +114,13 @@
- 未登录用户点击推荐页封面时,再次打开同一个登录弹窗;登录成功后由既有受保护动作继续进入作品详情或玩法入口。
- 未登录状态下点击“下一个”只切换下一张推荐封面,不触发登录弹窗,也不启动玩法。
- 已登录用户继续沿用推荐页内嵌运行态、上下滑切换和底部“下一个”行为。
## 10. 2026-05-11 草稿生成中与新完成标记
草稿生成过程页允许用户直接返回创作中心并自由使用平台其它功能:
- 点击生成过程页的返回按钮时,当前生成任务继续在后台执行,页面回到创作中心,不清空生成状态。
- 用户再进入草稿 Tab 并点击同一草稿时,若生成仍未完成,进入对应生成过程页查看最新进度;若已完成,直接进入对应结果页。
- 草稿作品卡在生成中展示“生成中”状态标记;新生成完成且用户尚未查看的草稿在卡片右上角展示红点。
- 底部一级“草稿”Tab 在存在未查看新完成草稿时展示红点用户点击查看带红点的作品后该作品红点消失。若草稿页已无任何带红点作品底部“草稿”Tab 红点同步消失。
- 生成完成时如果用户仍停留在对应生成过程页,可自动进入结果页;如果用户已经回到创作中心或其它功能页,不打断当前操作。

View File

@@ -124,7 +124,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
3. 不做排行榜正式展示。
4. 不做道具,但需要预留功能口。
5. 不做洗牌、重置、旋转、放大等局内操作。
6. 不做真实 3D 模型。
6. 不做多批次真实 3D 模型生成;当前草稿生成只固定产出 `3` 个 GLB 模型并写入 OSS
7. 不做真实 3D 物理遮挡。
8. 不做真实物理碰撞结算。
9. 不做必须试玩通关才能发布的门槛。
@@ -161,7 +161,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
首版 demo 不接入真实图片生成。当前运行态可消除物统一使用题材方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。
当前抓大鹅草稿生成会固定生成 `3` 个题材物品:素材图切割出的独立图片会作为 Rodin 图生 3D 参考图,生成出的 GLB 模型必须转存 OSS并随作品 profile 的 `generatedItemAssets` 持久化。运行态优先使用这些生成模型;只有模型缺失、加载失败或未进入 3D 渲染模式时,才回退到 25 个默认积木件视觉键。默认素材不使用透明气泡,也不在图案上放文字标识。
可消除物尺寸使用五档相对体积规则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` 下发权威尺寸,前端只按快照表现。
@@ -195,7 +195,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
## 6.3 参考图片
抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;后续需要 3D 模型时,在结果页 `3D素材` Tab 以切割图片作为图生模型参考图手动触发
抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;草稿生成阶段会直接以切割图片作为 Rodin 图生模型参考图生成首批 GLB。结果页 `3D素材` Tab 仍可对单个素材触发重新生成
---
@@ -222,9 +222,9 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
## 7.3 素材生成边界
抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材文本模型生成物品名VectorEngine 生成 `2*2` 素材图并切割独立图片。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。
抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材文本模型生成物品名VectorEngine 生成 `2*2` 素材图并切割独立图片,再以每张独立图片调用 Rodin 图生 3D下载 `.glb` 并转存 OSS。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。
生成出的独立图片作为草稿页 `3D素材` Tab 的预览资产返回,状态为 `image_ready`模型文件为空。正式平台资产绑定、Rodin 生成模型转存和二次编辑流程以后续技术方案为准。
生成出的独立图片与 GLB 模型都必须作为草稿页 `3D素材` Tab 的预览资产返回。模型生成成功时 `generatedItemAssets[].status = model_ready`,并携带 `modelSrc``modelObjectKey``modelFileName``taskUuid``subscriptionKey`;正式平台资产绑定和更完整的二次编辑流程以后续技术方案为准。
## 7.4 发布前试玩
@@ -297,12 +297,14 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25
## 8.5 物品资产
首版 demo 使用 2D 图案素材
当前 demo 使用生成 GLB 优先、默认积木兜底的物品资产策略
1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限。
2. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记
3. 后续可以尝试替换为伪 3D 或 3D 模型
4. 用户题材主题后续会映射为符合常识预期的物品集合
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 积木模型,不能显示为透明气泡或文字标记。
6. 用户题材主题后续会映射为符合常识预期的物品集合。
示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。
@@ -706,10 +708,10 @@ GET /api/runtime/match3d/runs/:runId
3. 入口页不展示参考图上传。
4. 内置风格显示画风参考图,自定义风格通过独立面板填写并进入提交 payload。
5. 移动端入口页所有内容一屏展示,不产生纵向滚动。
6. 系统可生成待发布结果页,并在草稿中返回首批切割图片素材预览。
6. 系统可生成待发布结果页,并在草稿中返回首批切割图片与 OSS GLB 模型素材预览。
7. 用户可编辑游戏名称、标签、封面图等基础信息。
8. 用户可发布前试玩,且试玩失败不阻断发布。
9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏。
9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏;存在 `generatedItemAssets` 模型时必须优先展示生成 GLB而不是默认积木素材
10. 物品可重叠、遮挡、堆叠。
11. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。
12. 点击通过后物品飞入备选栏。

View File

@@ -50,18 +50,18 @@
外部仓库文件只作为玩法语义参考,不能作为目录结构迁入依据:
| 外部参考 | 可参考内容 | Genarrative 落点 |
| --- | --- | --- |
| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` |
| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope |
| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API |
| `interface/handlers/visual/saves.js` | 存档语义 | 平台统一 `profile/save-archives` |
| `services/visual/gameLogic.js` | step 解析、会话状态推进 | `server-rs/crates/module-visual-novel` |
| `services/visual/storyGeneration.js` | 创作底稿生成语义 | `platform-agent` / `platform-llm` + visual novel Tool |
| `prompts/visual.gm.system/1.0.1/body.js` | 视觉小说 GM 输出结构经验 | 新 prompt 必须适配 Genarrative 契约,不原样照搬平台规则 |
| `src/page/Galgame.tsx` | 运行时界面结构 | `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` |
| `src/hooks/galgame/useGalgameController.ts` | 前端运行时状态组织 | visual novel runtime hooks |
| `src/page/GameSettingsEditor.tsx` | 创作编辑器模块划分 | visual novel result / workspace 组件 |
| 外部参考 | 可参考内容 | Genarrative 落点 |
| ----------------------------------------------- | ---------------------------- | ----------------------------------------------------------------- |
| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` |
| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope |
| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API |
| `interface/handlers/visual/saves.js` | 存档语义 | 平台统一 `profile/save-archives` |
| `services/visual/gameLogic.js` | step 解析、会话状态推进 | `server-rs/crates/module-visual-novel` |
| `services/visual/storyGeneration.js` | 创作底稿生成语义 | `platform-agent` / `platform-llm` + visual novel Tool |
| `prompts/visual.gm.system/1.0.1/body.js` | 视觉小说 GM 输出结构经验 | 新 prompt 必须适配 Genarrative 契约,不原样照搬平台规则 |
| `src/page/Galgame.tsx` | 运行时界面结构 | `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` |
| `src/hooks/galgame/useGalgameController.ts` | 前端运行时状态组织 | visual novel runtime hooks |
| `src/page/GameSettingsEditor.tsx` | 创作编辑器模块划分 | visual novel result / workspace 组件 |
---
@@ -69,7 +69,7 @@
### 3.1 必须完成的模板闭环
1. 平台创作中心展示 `视觉小说` 入口,并在实现完成后从 `open: false` 改为 `open: true`
1. 平台创作中心展示 `视觉小说` 入口2026-05-11 起当前运营状态回退为 `open: false`,入口显示敬请期待,不允许创建新视觉小说草稿
2. 创作者可选择 `idea``document``blank` 三种起点创建视觉小说底稿。
3. Agent 或表单工作台生成 / 编辑同一份 `VisualNovelResultDraft`
4. 结果页可编辑世界观、角色、场景、剧情阶段、资产和运行时配置。
@@ -160,8 +160,8 @@
5. 玩家身份。
6. 3 到 6 个主要角色。
7. 3 到 8 个可用场景。
7. 3 到 6 个剧情阶段。
8. 初始场景、初始旁白和第一轮可选行动。
8. 3 到 6 个剧情阶段。
9. 初始场景、初始旁白和第一轮可选行动。
### 5.2 文档创建 `document`
@@ -399,7 +399,15 @@ export type VisualNovelAgentActionKind =
```ts
export type VisualNovelAgentStreamEvent =
| { type: 'start'; sessionId: string }
| { type: 'phase'; phase: 'perception' | 'reasoning' | 'drafting' | 'reflection' | 'finalizing' }
| {
type: 'phase';
phase:
| 'perception'
| 'reasoning'
| 'drafting'
| 'reflection'
| 'finalizing';
}
| { type: 'text_delta'; text: string }
| { type: 'draft_patch'; patch: VisualNovelDraftPatch }
| { type: 'action_required'; action: VisualNovelAgentPendingAction }
@@ -558,35 +566,35 @@ export type VisualNovelRuntimeStreamEvent =
### 9.1 创作 session
| 方法 | 路由 | 用途 |
| --- | --- | --- |
| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session |
| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages/stream` | 流式发送创作消息 |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/actions` | 执行结构化创作 action |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/compile` | 编译为 work profile 草稿 |
| 方法 | 路由 | 用途 |
| ------ | ------------------------------------------------------------------ | ------------------------ |
| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session |
| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages/stream` | 流式发送创作消息 |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/actions` | 执行结构化创作 action |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/compile` | 编译为 work profile 草稿 |
### 9.2 作品草稿与发布
| 方法 | 路由 | 用途 |
| --- | --- | --- |
| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 |
| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 |
| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 |
| `DELETE` | `/api/creation/visual-novel/works/{profile_id}` | 删除未发布草稿或用户自有作品 |
| `POST` | `/api/creation/visual-novel/works/{profile_id}/publish` | 发布到平台作品体系 |
| 方法 | 路由 | 用途 |
| ----------- | ------------------------------------------------------- | -------------------------------- |
| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 |
| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 |
| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 |
| `DELETE` | `/api/creation/visual-novel/works/{profile_id}` | 删除未发布草稿或用户自有作品 |
| `POST` | `/api/creation/visual-novel/works/{profile_id}/publish` | 发布到平台作品体系 |
### 9.3 运行时
| 方法 | 路由 | 用途 |
| --- | --- | --- |
| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 |
| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run |
| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot |
| `POST` | `/api/runtime/visual-novel/runs/{run_id}/actions/stream` | 提交选择 / 自由行动并流式推进 |
| `GET` | `/api/runtime/visual-novel/runs/{run_id}/history` | 读取当前 run 历史 |
| `POST` | `/api/runtime/visual-novel/runs/{run_id}/regenerate` | 从历史节点重生成 |
| 方法 | 路由 | 用途 |
| ------ | -------------------------------------------------------- | ------------------------------------ |
| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 |
| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run |
| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot |
| `POST` | `/api/runtime/visual-novel/runs/{run_id}/actions/stream` | 提交选择 / 自由行动并流式推进 |
| `GET` | `/api/runtime/visual-novel/runs/{run_id}/history` | 读取当前 run 历史 |
| `POST` | `/api/runtime/visual-novel/runs/{run_id}/regenerate` | 从历史节点重生成 |
### 9.4 存档
@@ -627,16 +635,16 @@ GET /api/runtime/profile/save-archives
### 10.1 crate 职责
| crate / 层 | 职责 |
| --- | --- |
| crate / 层 | 职责 |
| -------------------------------------- | ------------------------------------------------------------- |
| `server-rs/crates/module-visual-novel` | 纯领域规则草稿校验、step 解析、run 状态推进、历史重生成边界 |
| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 |
| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 |
| `server-rs/crates/spacetime-client` | api-server 到 SpacetimeDB 的 typed facade |
| `server-rs/crates/api-server` | Axum 路由、鉴权、SSE、LLM 编排、资产和钱包 facade 调用 |
| `server-rs/crates/platform-llm` | 视觉小说创作和运行时 GM 的模型调用 |
| `server-rs/crates/platform-oss` | 文档、图片、音乐等资产对象读写 |
| `server-rs/crates/platform-agent` | 如复用创意 Agent负责 Agent 编排和工具注册 |
| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 |
| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 |
| `server-rs/crates/spacetime-client` | api-server 到 SpacetimeDB 的 typed facade |
| `server-rs/crates/api-server` | Axum 路由、鉴权、SSE、LLM 编排、资产和钱包 facade 调用 |
| `server-rs/crates/platform-llm` | 视觉小说创作和运行时 GM 的模型调用 |
| `server-rs/crates/platform-oss` | 文档、图片、音乐等资产对象读写 |
| `server-rs/crates/platform-agent` | 如复用创意 Agent负责 Agent 编排和工具注册 |
### 10.2 领域规则
@@ -654,14 +662,14 @@ GET /api/runtime/profile/save-archives
新增表必须同步 `migration.rs`、表目录和 bindings
| 表 | 用途 |
| --- | --- |
| `visual_novel_agent_session` | 创作 session 主表 |
| `visual_novel_agent_message` | 创作消息和模型回复 |
| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile |
| `visual_novel_runtime_run` | 玩家或测试 run |
| `visual_novel_runtime_history_entry` | 运行时历史 |
| `visual_novel_runtime_event` | 可审计事件,不用于回放 |
| 表 | 用途 |
| ------------------------------------ | ------------------------------- |
| `visual_novel_agent_session` | 创作 session 主表 |
| `visual_novel_agent_message` | 创作消息和模型回复 |
| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile |
| `visual_novel_runtime_run` | 玩家或测试 run |
| `visual_novel_runtime_history_entry` | 运行时历史 |
| `visual_novel_runtime_event` | 可审计事件,不用于回放 |
不得新增:
@@ -684,27 +692,27 @@ GET /api/runtime/profile/save-archives
### 11.1 入口配置
当前 `src/config/newWorkEntryConfig.ts` 已存在
入口配置事实源已经迁移到 SpacetimeDB 的 `creation_entry_type_config` 表,默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。2026-05-11 起视觉小说当前默认状态为
```ts
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
subtitle: '分支叙事体验',
badge: '敬请期待',
visible: true,
open: false,
}
```
实现完成后更新为:
重新开放创建时再通过后台入口开关或默认种子更新为:
```ts
{
id: 'visual-novel',
title: '视觉小说',
subtitle: 'AI 生成可玩的视觉小说',
badge: '新玩法',
subtitle: '分支叙事体验',
badge: '可创建',
visible: true,
open: true,
}
@@ -781,6 +789,8 @@ service client 要复用现有请求封装、鉴权和错误提示风格,不
3. 生成草稿按钮。
4. 错误、忙碌与禁用态。
视觉画风卡片使用 `public/visual-novel-style-references/` 下的 `gpt-image-2-all` 参考图,分别对应映画动画、水彩绘本、像素霓虹、水墨幻想、柔彩校园和暗色哥特。卡片只承载图像和标签,不额外展示说明文案。
点击生成草稿后进入 `visual-novel-generating` 过程页,过程页复用平台已有生成进度面板,展示一句话输入、画风和草稿生成阶段;完成后自动进入 `visual-novel-result` 草稿页。
不展示:
@@ -975,13 +985,13 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位
### 16.1 并行批次总览
| 批次 | 可并行任务 | 进入条件 | 汇合点 |
| --- | --- | --- | --- |
| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 |
| Batch 1 | `VN-01``VN-02``VN-03``VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 |
| Batch 2 | `VN-05``VN-06``VN-07``VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 |
| Batch 3 | `VN-09``VN-10``VN-11` | `VN-05``VN-06``VN-07``VN-08` 主链路可跑 | 发布、存档、广场、负向扫描收口 |
| Batch 4 | `VN-12``VN-13` | 主链路完成 | 全链路验收和文档同步 |
| 批次 | 可并行任务 | 进入条件 | 汇合点 |
| ------- | ---------------------------------- | --------------------------------------------- | -------------------------------------- |
| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 |
| Batch 1 | `VN-01``VN-02``VN-03``VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 |
| Batch 2 | `VN-05``VN-06``VN-07``VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 |
| Batch 3 | `VN-09``VN-10``VN-11` | `VN-05``VN-06``VN-07``VN-08` 主链路可跑 | 发布、存档、广场、负向扫描收口 |
| Batch 4 | `VN-12``VN-13` | 主链路完成 | 全链路验收和文档同步 |
并行规则:
@@ -992,22 +1002,22 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位
### 16.2 并行任务具体要求速查表
| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 |
| --- | --- | --- | --- | --- | --- |
| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 |
| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI不出现 replay 类型 | TS/Rust 契约一致草稿校验、step 解析、状态推进、重生成测试通过 |
| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 |
| `VN-03` Prompt / LLM 工具 | Batch 1 | `VN-01` draft 与 step 契约 | 创作 prompt、运行 GM prompt、repair prompt、工具参数 | 不照搬外部平台规则、扣费规则、回放规则;不让前端猜业务 step | fixture 输出可解析;坏格式进入 repair 或可重试错误 |
| `VN-04` 前端 UI 骨架 | Batch 1 | PRD 第 12 章 UI 要求 | workspace、result、runtime mock 骨架;移动端优先布局;独立面板模式 | 不接真实 API不写规则长文不出现回放入口 | 桌面 / 移动基础布局可检查;长文本不撑破按钮 |
| `VN-05` API Server | Batch 2 | `VN-01``VN-02`,真实 LLM 依赖 `VN-03` | creation、works、runtime、history、regenerate 路由SSE平台 save archive 接入 | 不把领域规则塞 handler不新增 replay 路由;不新增私有 save API | `/api/creation/visual-novel/*``/api/runtime/visual-novel/*` 可 smoke`cargo check -p api-server` 通过 |
| `VN-06` 前端 service 与入口 | Batch 2 | `VN-01` TS 契约草案mock 可先行 | service client、selection stage、入口分流、壳层挂载、登录态清理 | 不设计表单细节;不写 runtime UI 细节;不绕过平台入口 | 创作中心可进入 workspaceservice 路由与 PRD 一致;`npm run typecheck` 通过 |
| `VN-07` 创作工作台与结果页 | Batch 2 | `VN-04``VN-06`,真实生成依赖 `VN-05` | `idea` / `document` / `blank`、Agent 进度、结果页表单、保存草稿、测试 run 入口 | 不做正式玩家 runtime不新增说明页复杂内容不在卡片下展开 | 三种创建起点可用;草稿写入 `VisualNovelResultDraft`;发布校验 issue 可见 |
| `VN-08` 前端运行时 | Batch 2 | `VN-04``VN-06`,真实运行依赖 `VN-05` | runtime shell、SSE 消费、step 渲染、历史、设置、存档、重生成 | 不做回放 UI历史不生成分享播放页`raw_text` 不作为业务真相 | typed step 可渲染历史与重生成可用save archive 可继续体验 |
| `VN-09` 作品架 / 广场 / 发布 | Batch 3 | `VN-05` works/gallery API、`VN-06` 壳层 | work summary 聚合、作品架、公开聚合、public work code、详情跳转 | 不新增独立视觉小说市场;不迁入外部平台详情页;不做分享回放 | 发布后作品架可见;公开作品可启动 run聚合 key 不冲突 |
| `VN-10` 资产与文档输入 | Batch 3 | `VN-05` action/API、平台资产接口 | 文档资产读取、封面/场景/角色/音乐资产引用、可选图片生成 action | 不迁入外部 R2不存大 Data URL不绕过资产鉴权 | 文档创建使用资产引用SpacetimeDB 不保存二进制或大 base64 |
| `VN-11` 负向扫描 | Batch 1 到发布前 | 所有任务增量改动 | 扫描 replay 和外部平台功能;补负向测试或脚本清单 | 不把历史误判为回放;不做大范围重构 | 新工程代码无 replay外部平台账号/订单/会员/后台等未误入 |
| `VN-12` 全链路验收 | Batch 4 | `VN-05``VN-10` 主链路完成 | 创作、结果页、测试 run、发布、正式 run、历史、重生成、存档联调 | 不跳过移动端;不只测 mock不忽略负向验收 | 全链路可跑;前端 typecheck、后端相关 cargo 检查、编码检查通过 |
| `VN-13` 文档与交接 | Batch 4 | 实际工程落地结果 | PRD、技术方案、表目录、Hermes 决策 / 踩坑同步 | 不让旧 TXT 文档重新成为实现口径;不留下过期接口描述 | 新开发者可按最新文档维护;文档与代码一致 |
| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 |
| ----------------------------- | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 |
| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI不出现 replay 类型 | TS/Rust 契约一致草稿校验、step 解析、状态推进、重生成测试通过 |
| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 |
| `VN-03` Prompt / LLM 工具 | Batch 1 | `VN-01` draft 与 step 契约 | 创作 prompt、运行 GM prompt、repair prompt、工具参数 | 不照搬外部平台规则、扣费规则、回放规则;不让前端猜业务 step | fixture 输出可解析;坏格式进入 repair 或可重试错误 |
| `VN-04` 前端 UI 骨架 | Batch 1 | PRD 第 12 章 UI 要求 | workspace、result、runtime mock 骨架;移动端优先布局;独立面板模式 | 不接真实 API不写规则长文不出现回放入口 | 桌面 / 移动基础布局可检查;长文本不撑破按钮 |
| `VN-05` API Server | Batch 2 | `VN-01``VN-02`,真实 LLM 依赖 `VN-03` | creation、works、runtime、history、regenerate 路由SSE平台 save archive 接入 | 不把领域规则塞 handler不新增 replay 路由;不新增私有 save API | `/api/creation/visual-novel/*``/api/runtime/visual-novel/*` 可 smoke`cargo check -p api-server` 通过 |
| `VN-06` 前端 service 与入口 | Batch 2 | `VN-01` TS 契约草案mock 可先行 | service client、selection stage、入口分流、壳层挂载、登录态清理 | 不设计表单细节;不写 runtime UI 细节;不绕过平台入口 | 创作中心可进入 workspaceservice 路由与 PRD 一致;`npm run typecheck` 通过 |
| `VN-07` 创作工作台与结果页 | Batch 2 | `VN-04``VN-06`,真实生成依赖 `VN-05` | `idea` / `document` / `blank`、Agent 进度、结果页表单、保存草稿、测试 run 入口 | 不做正式玩家 runtime不新增说明页复杂内容不在卡片下展开 | 三种创建起点可用;草稿写入 `VisualNovelResultDraft`;发布校验 issue 可见 |
| `VN-08` 前端运行时 | Batch 2 | `VN-04``VN-06`,真实运行依赖 `VN-05` | runtime shell、SSE 消费、step 渲染、历史、设置、存档、重生成 | 不做回放 UI历史不生成分享播放页`raw_text` 不作为业务真相 | typed step 可渲染历史与重生成可用save archive 可继续体验 |
| `VN-09` 作品架 / 广场 / 发布 | Batch 3 | `VN-05` works/gallery API、`VN-06` 壳层 | work summary 聚合、作品架、公开聚合、public work code、详情跳转 | 不新增独立视觉小说市场;不迁入外部平台详情页;不做分享回放 | 发布后作品架可见;公开作品可启动 run聚合 key 不冲突 |
| `VN-10` 资产与文档输入 | Batch 3 | `VN-05` action/API、平台资产接口 | 文档资产读取、封面/场景/角色/音乐资产引用、可选图片生成 action | 不迁入外部 R2不存大 Data URL不绕过资产鉴权 | 文档创建使用资产引用SpacetimeDB 不保存二进制或大 base64 |
| `VN-11` 负向扫描 | Batch 1 到发布前 | 所有任务增量改动 | 扫描 replay 和外部平台功能;补负向测试或脚本清单 | 不把历史误判为回放;不做大范围重构 | 新工程代码无 replay外部平台账号/订单/会员/后台等未误入 |
| `VN-12` 全链路验收 | Batch 4 | `VN-05``VN-10` 主链路完成 | 创作、结果页、测试 run、发布、正式 run、历史、重生成、存档联调 | 不跳过移动端;不只测 mock不忽略负向验收 | 全链路可跑;前端 typecheck、后端相关 cargo 检查、编码检查通过 |
| `VN-13` 文档与交接 | Batch 4 | 实际工程落地结果 | PRD、技术方案、表目录、Hermes 决策 / 踩坑同步 | 不让旧 TXT 文档重新成为实现口径;不留下过期接口描述 | 新开发者可按最新文档维护;文档与代码一致 |
每个任务的交付回复必须包含:
@@ -1143,20 +1153,20 @@ cd server-rs
cargo check -p spacetime-client
```
阻塞关系:
1. 依赖 `VN-01` 的 Rust 契约。
2. 阻塞 `VN-05` 的真实数据库联调。
阻塞关系:
VN-02 实施收口记录2026-05-05
1. 依赖 `VN-01` 的 Rust 契约。
2. 阻塞 `VN-05` 的真实数据库联调。
1. 已新增 `server-rs/crates/spacetime-module/src/visual_novel.rs`,落地 `visual_novel_agent_session``visual_novel_agent_message``visual_novel_work_profile``visual_novel_runtime_run``visual_novel_runtime_history_entry``visual_novel_runtime_event` 六张表及对应 procedure。
2. 已同步 `server-rs/crates/spacetime-module/src/lib.rs``server-rs/crates/spacetime-module/src/migration.rs``docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings。
3. 已新增 `server-rs/crates/spacetime-client/src/visual_novel.rs` typed facade并从 `server-rs/crates/spacetime-client/src/lib.rs` 导出视觉小说 record / input 类型
4. `visual_novel_runtime_event` 只作为 `public event` 审计事件表使用;`visual_novel_runtime_history_entry` 只保存继续体验与历史重生成所需 step 和快照哈希,二者均不是 replay 数据源
5. 本阶段未新增 Axum 路由、LLM prompt、前端 UI、replay 表、replay 路由或私有 save 表;后续 VN-05 只应通过本阶段 facade 接入真实数据库
### VN-03创作与运行 Prompt / LLM 工具
VN-02 实施收口记录2026-05-05
1. 已新增 `server-rs/crates/spacetime-module/src/visual_novel.rs`,落地 `visual_novel_agent_session``visual_novel_agent_message``visual_novel_work_profile``visual_novel_runtime_run``visual_novel_runtime_history_entry``visual_novel_runtime_event` 六张表及对应 procedure
2. 已同步 `server-rs/crates/spacetime-module/src/lib.rs``server-rs/crates/spacetime-module/src/migration.rs``docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings
3. 已新增 `server-rs/crates/spacetime-client/src/visual_novel.rs` typed facade并从 `server-rs/crates/spacetime-client/src/lib.rs` 导出视觉小说 record / input 类型
4. `visual_novel_runtime_event` 只作为 `public event` 审计事件表使用;`visual_novel_runtime_history_entry` 只保存继续体验与历史重生成所需 step 和快照哈希,二者均不是 replay 数据源。
5. 本阶段未新增 Axum 路由、LLM prompt、前端 UI、replay 表、replay 路由或私有 save 表;后续 VN-05 只应通过本阶段 facade 接入真实数据库。
### VN-03创作与运行 Prompt / LLM 工具
负责范围:
@@ -1744,7 +1754,7 @@ VN-11 从 Batch 1 开始持续运行,最终阻塞发布。
### 17.1 正向验收
1. `visual-novel` 入口可见并可点击创建。
1. `visual-novel` 入口当前可见但处于敬请期待禁用态;重新开放 `open=true` 后,再验收点击创建闭环
2. 一句话创建能生成可编辑底稿。
3. 文档创建能读取平台文档资产并生成底稿。
4. 空白创建能进入结果页。

View File

@@ -65,7 +65,7 @@ Admin Web
-> spacetime-module creation_entry_type_config 表
```
`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态api-server 的运行态熔断继续以 `visible && open` 判断路由是否可用
`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态,并让 api-server 熔断对应玩法创作 / 运行态 API。隐藏入口但仍保留既有作品号、广场详情或试玩链路时应只关闭 `visible`,不要关闭 `open`
## 注意

View File

@@ -36,7 +36,7 @@ SpacetimeDB 新增两张表:
其中:
- `visible=false`:前端隐藏入口。
- `open=false`:前端展示为锁定/暂不可创建api-server 也可据此熔断运行时入口
- `open=false`:前端展示为锁定/暂不可创建api-server 据此熔断对应玩法 API只隐藏创作页入口但保留既有作品链路时不要关闭 `open`
- `sort_order`:数据库排序字段,前端只做可见/锁定分组派生。
## API

View File

@@ -13,6 +13,8 @@
- `https://developer.hyper3d.ai/api-specification/rodin-generation-gen2`
- `https://developer.hyper3d.ai/api-specification/check-status`
- `https://developer.hyper3d.ai/api-specification/download-results`
- `https://developer.hyper3d.ai/api-specification/check-status_reset_v`
- `https://developer.hyper3d.ai/api-specification/download-results_reset_v`
上游接口:
@@ -24,6 +26,11 @@ POST https://api.hyper3d.com/api/v2/download
Rodin Gen-2 提交接口必须使用 `multipart/form-data`。文本生成时提交 `prompt`;图片生成时提交一个或多个 `images` 文件,可选 `prompt` 作为辅助描述。两种模式均固定提交 `tier=Gen-2`
官方 `*_reset_v` 文档对状态和下载有两个关键约束:
1. 生成接口返回的顶层 `uuid` 是后续下载接口的 `task_uuid`,不要使用 `jobs.uuids` 中的子任务 uuid 作为下载参数。
2. 状态接口使用 `subscription_key` 查询,并返回 `jobs[]`;只有所有 job 的 `status` 都为 `Done` 才能进入下载,任一 job `Failed` 都应视为任务失败。
## 3. 环境变量
```text
@@ -111,7 +118,7 @@ RODIN_MODEL_REQUEST_TIMEOUT_MS
}
```
状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`。下载接口只返回上游 `list.name``list.url`,不在后端转存文件
状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`,整体状态必须以 `jobs[]` 聚合结果为准。下载接口只返回上游 `list.name``list.url`,不在 Hyper3D 代理路由中转存文件;具体玩法若需要持久化模型,应在玩法编排层等待 `Done` 后再下载并转存
## 7. 验收

View File

@@ -11,19 +11,22 @@
入口仍复用 `Match3DAgentWorkspace` 表单。点击 `生成抓大鹅草稿` 后:
1. 创建 Match3D session。
2. 进入 `match3d-generating` 生成过程页
3. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构
4. 生成成功后自动进入 `match3d-result`
5. 生成失败时停留在生成过程页,允许重新生成或返回创作中心
2. 后端先用当前题材和本地兜底元信息创建同一个 Match3D 草稿 profile草稿 Tab 必须立即能看到这份存档
3. 进入 `match3d-generating` 生成过程页
4. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构
5. 生成成功后自动进入 `match3d-result`
6. 生成失败时停留在生成过程页,允许重新生成或返回创作中心;重新生成必须复用同一个 session / profile并从缺失的素材阶段继续不新建第二份草稿。
生成页步骤固定为:
```text
生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 写入草稿页
生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 生成3D模型 -> 写入草稿页
```
生成页只展示题材和物品数量,不展示玩法规则说明。
当前 `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 完成`
## 3. 后端编排边界
外部模型和 OSS 上传全部由 `api-server` 编排,不进入 SpacetimeDB reducer。SpacetimeDB 继续只负责 Match3D 会话、草稿和作品 profile 的确定性写入。
@@ -32,17 +35,19 @@
1. 读取 session config。
2. 将本次 MVP 的 `clearCount` 固定为 `3`,并同步用于草稿编译。
3. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写
4. 调用文本模型生成 `3` 个题材下的短物品名称
5. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine不重新接入 APIMart 图片网关
6. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3`
7. 将素材图和每张独立图片上传到 OSS其中独立图片作为草稿页素材预览和后续 Rodin 图生模型参考图
8. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表
9. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,独立图片状态为 `image_ready`,模型字段保持为空;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材
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` 恢复同一批素材。
若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。
草稿生成阶段调用 Hyper3D Rodin,不等待 `subscriptionKey`也不下载模型文件Rodin 生成只在结果页 `3D素材` Tab 由用户手动触发。手动生成得到的上游下载 URL 仍不得直接写入 Match3D profile,后续正式资产绑定以独立技术方案为准。
草稿生成阶段调用 Hyper3D Rodin等待 GLB 下载完成;前端 `match3d_compile_draft` action 请求超时必须覆盖该长耗时链路,当前 Match3D client 使用 20 分钟超时。Rodin 单模型状态轮询预算为 10 分钟,下载列表发布轮询预算为 5 分钟GLB 下载和 OSS PutObject 各自设置 3 分钟 HTTP 超时,避免上游下载或转存连接长期悬挂。由于 3 个模型并行生成,总耗时按最慢模型计算,不能按模型数量线性叠加。结果页 `3D素材` Tab 直接加载已生成模型;用户点击 `重新生成` 时再复用 Rodin 安全代理,首版重新生成只更新当前页面内预览状态,后续正式资产绑定以独立技术方案为准。
## 4. 图片提示词
@@ -83,16 +88,47 @@ generated-match3d-assets
```text
generated-match3d-assets/{sessionId}/{profileId}/material-sheet/{taskId}/sheet.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image/image.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/model/{taskUuid}/model.glb
```
`itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key导致草稿页预览图全部一致。
HTTP DTO 同时返回 `imageSrc``imageObjectKey`空的 `modelSrc`空的 `modelObjectKey``status = image_ready`。前端预览图片继续走 `ResolvedAssetImage` 换签;后续手动生成的模型文件也必须通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 换签后打开,不直接请求裸 `/generated-match3d-assets/...` 路径。
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/...` 路径。
## 5.1 运行态模型消费
生成模型不仅用于结果页预览,也必须进入游戏运行态。运行态入口的传递链路为:
```text
Match3DWorkProfile / PlatformMatch3DGalleryCard
-> Match3DRuntimeShell(generatedItemAssets)
-> Match3DPhysicsBoard / Match3DTrayPreviewBoard
```
`Match3DPhysicsBoard``Match3DTrayPreviewBoard` 按运行快照中的 `itemTypeId` 稳定排序后,把生成出的模型顺序映射到对应类型。当前 MVP 固定 `clearCount = 3`,因此 `match3d-type-01/02/03` 分别对应生成列表的第 `1/2/3` 个模型;后续恢复更多物品生成时,后端必须继续保证 `generatedItemAssets` 顺序与类型编号一致。
前端加载规则:
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 字节读取或解析失败”。
结果页点击 `试玩` 时,前端必须把当前结果页可见的 `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 模型覆盖成空列表。
## 6. 自动保存与草稿恢复
抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。
点击 `生成抓大鹅草稿` 后,草稿存档创建与素材生成解耦:
1. 首次 compile 必须先写 `match3d_work_profile` 草稿行即使后续卡在文本模型、图片生成、OSS 上传、Rodin 生成或下载转存任意阶段。
2. 失败态前端要重新读取 session / work detail并刷新草稿作品架保证用户离开生成页后仍能在草稿 Tab 找到这份作品。
3. 重新生成时优先使用当前 session 的 `draft.profileId``publishedProfileId`,不得重新创建 session后端读取同一 profile 的 `generated_item_assets_json` 后,只补齐缺失图片或缺失模型的阶段。
4. 已有 `status = model_ready` 且带 `modelSrc` / `modelObjectKey` 的素材视为完成,不再重复调用 Rodin。
抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。结果页 `3D素材` Tab 手动点击 `重新生成` 并拿到 GLB 下载文件后,必须把当前素材草稿重新序列化成 `generatedItemAssets` 并写回作品 profile否则页面内预览会显示新模型但试玩、发布和重进草稿仍会读取旧的空模型快照。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。
草稿架重进路径为:
@@ -100,7 +136,7 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的
草稿 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从草稿架重进没有 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` 中已有模型字段时,用 profile 模型字段补齐 draft从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。
结果页 `作品信息` Tab 字段命名对齐拼图草稿:
@@ -111,7 +147,7 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的
`3D素材` 详情页只保留:
1. 模型预览区:优先加载 `modelSrc` 对应 GLB支持拖动旋转没有模型时展示空预览。
1. 模型预览区:优先加载 `modelSrc` 对应 GLB缺失时加载 `modelObjectKey`支持拖动旋转;没有模型时展示空预览。
2. 素材名称输入。
3. `重新生成` 按钮。
@@ -125,6 +161,8 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的
npm run check:encoding
npm run test -- src\services\miniGameDraftGenerationProgress.test.ts
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
npm run typecheck
cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml
cargo test -p spacetime-client match3d --manifest-path server-rs\Cargo.toml
@@ -135,4 +173,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` 和 OSS 访问变量;`HYPER3D_API_KEY` 只在结果页手动生成 3D 模型时需要。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`
真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY``HYPER3D_API_KEY` 和 OSS 访问变量。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`

View File

@@ -18,13 +18,13 @@
## 3. Rodin 任务边界
前端只维护当前页面内的临时重新生成任务状态:
前端只维护当前页面内的临时重新生成任务状态;草稿生成得到的正式模型资产从 `generatedItemAssets.modelSrc` 恢复
1. 素材槽位名称。
2. 模型预览。草稿生成的 `/generated-match3d-assets/...` GLB 必须通过同源 `/api/assets/read-bytes` 由后端换签并读取字节,前端再转成 Blob URL 后交给 Three.js GLTFLoader避免浏览器直接 `fetch` OSS 签名 URL 时被 CORS 拦截。
3. 图生模型参考图只作为重新生成的隐藏输入来源,不在详情页展示。上传图片在前端直接读成 Data URL草稿生成的 `/generated-match3d-assets/...` 图片必须通过 `/api/assets/read-bytes` 转成 Data URL 后提交给 Hyper3D。
4. Hyper3D `taskUuid``subscriptionKey`
5. 查询到的状态、进度与下载文件列表。
4. Hyper3D `taskUuid``subscriptionKey` 仅用于重新生成过程,不在详情页展示
5. 查询到的状态、进度与下载文件列表仅作为内部状态,不在详情页展示
正式资产链后续再接:

View File

@@ -2,18 +2,19 @@
## 背景
创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致
创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在前端配置与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。当前入口配置事实源已经迁移到 SpacetimeDB`api-server` 通过 `GET /api/creation-entry/config` 下发
## 落地规则
1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`
1. 新建作品入口配置统一放在 SpacetimeDB 的 `creation_entry_config` / `creation_entry_type_config` 表;默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。
3. `open` 控制玩法是否允许点击创建`open: false` 时入口保持展示但禁用
3. `open` 控制玩法是否允许点击创建以及对应创作 / runtime API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 API
4. `title``subtitle``badge` 控制玩法卡片文案。
5. `startCard` 控制旧创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制。
6. `typeModal` 控制平台创作类型弹层标题和描述。
7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。
8. `creative-agent` 可以继续保留运行链路,但默认 `visible: false`,不出现在创作 Tab 模板入口。
9. 前端 `src/components/platform-entry/platformEntryCreationTypes.ts` 只做展示派生,不再承载默认入口配置。
## 当前状态
@@ -23,17 +24,17 @@
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
| 拼图 | 是 | 是 | 创作 Tab 默认选中并内嵌展示拼图创作表单,提交后进入拼图草稿生成 |
| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Match3D Agent 共创工作台 |
| 方洞挑战 | | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 |
| 方洞挑战 | | 是 | 创作页入口暂时完全隐藏,既有草稿、结果页、发布、试玩、作品架与广场链路保留 |
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
| 视觉小说 | 是 | | 点击后进入视觉小说创作工作台 |
| 视觉小说 | 是 | | 保留入口,显示敬请期待,暂不允许创建视觉小说草稿 |
| 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 |
## 验收
1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。
1. 修改 SpacetimeDB 入口配置后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。
2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。
3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。
4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。
5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”并默认展示拼图创作表单。
6. 智能创作入口隐藏后不应出现“Hi, 朋友”“问一问百梦”或“一句话生成闪应用”等旧首页入口。
7. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情
7. 方洞挑战入口隐藏后,不应出现在创作 Tab 模板入口、创作中心顶部卡带、平台创作类型弹层和创作页作品架中;既有 `SH-` 作品号、广场详情和试玩 runtime 链路不因此删除

View File

@@ -0,0 +1,27 @@
# 公开作品详情失效回首页修复
日期:`2026-05-11`
## 背景
直接访问 `/works/detail?work=<公开作品号>` 时,如果作品已经删除、下架或当前公开列表无法命中该作品,统一作品详情会先进入 `work-detail` 阶段。此前该阶段在没有 `selectedPublicWorkDetail` 时不会渲染任何内容;用户关闭“作品不存在或已下架”的提示后,页面可能只剩空白区域。
## 修复
1. `resolveWorkNotFoundRecoveryAction(...)` 覆盖 `/works/detail`、拼图公开详情和视觉小说公开详情,并复用运行态深链失效的回首页策略。
2. 拼图公开详情、拼图运行态启动和拼图详情页读取的 `404/NOT_FOUND` 分支改为统一走公开作品失效恢复逻辑。
3. 直接打开 `/works/detail?work=...` 的搜索失败分支会清理详情态、运行态临时数据,切回首页并清掉 URL query。
4. `work-detail` 阶段在详情数据为空时渲染轻量读取态,避免异步间隙或异常分支出现纯白屏。
## 验证
- `npm run test -- src/routing/runtimeNotFoundRecovery.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail alert returns to platform home"`
- `npm run typecheck`
- `npm run check:encoding -- src/routing/runtimeNotFoundRecovery.ts src/routing/runtimeNotFoundRecovery.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md`
## 关联文件
1. `src/routing/runtimeNotFoundRecovery.ts`
2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`

View File

@@ -0,0 +1,89 @@
# 拼图与抓大鹅结果页音乐 Tab 2026-05-11
## 1. 范围
本方案把 VectorEngine 音频生成能力从视觉小说结果页扩展到拼图与抓大鹅结果页:
1. 拼图结果页新增 `音乐` Tab支持通过 Suno 生成作品背景音乐。
2. 抓大鹅结果页新增 `音乐` Tab支持通过 Suno 生成作品背景音乐。
3. 抓大鹅 `3D素材` Tab 支持为每个生成物体通过 Vidu 生成点击音效。
本轮不新增 SpacetimeDB 表,不修改表字段,不把供应商密钥下发到前端。
## 2. 通用音频接口
后端在既有视觉小说音频路由外新增通用创作音频路由:
| 方法 | 路由 | 用途 |
| --- | --- | --- |
| `POST` | `/api/creation/audio/background-music` | 提交 Suno 背景音乐任务 |
| `POST` | `/api/creation/audio/background-music/{task_id}/asset` | 查询并转存 Suno 音频资产 |
| `POST` | `/api/creation/audio/sound-effect` | 提交 Vidu 音效任务 |
| `POST` | `/api/creation/audio/sound-effect/{task_id}/asset` | 查询并转存 Vidu 音效资产 |
通用转存请求由前端传入 `entityKind``entityId``slot``assetKind``profileId`。后端仍负责:
1. 校验 VectorEngine 与 OSS 环境变量。
2. 轮询供应商任务结果。
3. 下载音频字节。
4. 写入 OSS 私有对象。
5. 确认 `asset_object` 并绑定 `asset_entity_binding`
视觉小说原路由保持兼容,内部继续复用同一套提交、轮询、转存逻辑。
## 3. 数据落点
### 3.1 拼图
拼图作品没有独立作品级 metadata 字段。背景音乐随 `levels_json` 保存到首个 `PuzzleDraftLevel.backgroundMusic`
```json
{
"levelId": "puzzle-level-1",
"backgroundMusic": {
"taskId": "suno-task",
"provider": "vector-engine-suno",
"assetObjectId": "assetobj_1",
"assetKind": "puzzle_background_music",
"audioSrc": "/generated-puzzle-assets/..."
}
}
```
运行态后续可从当前关卡快照或作品详情读取该字段作为背景音乐源;若字段为空,继续使用现有程序化背景音乐兜底。
### 3.2 抓大鹅
抓大鹅作品级音频与物体点击音效复用 `generated_item_assets_json` 数组保存,不新增表字段:
1. 作品背景音乐暂存到第一个 `Match3DGeneratedItemAsset.backgroundMusic`,表示当前 work profile 的作品级背景音乐。
2. 单个物体点击音效保存到对应 `Match3DGeneratedItemAsset.clickSound`
这是一个兼容性折中:当前 Match3D work profile 没有 work-level metadata 字段,而 `generated_item_assets_json` 已经随作品详情、草稿架、运行态入口稳定传递。后续若新增正式作品 metadata 表达,应迁移 `backgroundMusic` 到作品级字段。
## 4. 前端交互
结果页 UI 保持轻量:
1. `音乐` Tab 只展示必要输入、生成按钮、状态与音频预览,不展示供应商规则说明。
2. 生成完成后立即写回本地草稿状态,并触发既有保存链路或专用保存接口。
3. 抓大鹅每个物体音效生成入口放在对应素材详情面板内,不在列表下方展开大段配置。
## 5. 验收
建议执行:
```powershell
npm run check:encoding
npm run test -- src\components\puzzle-result\PuzzleResultView.test.tsx
npm run test -- src\components\match3d-result\Match3DResultView.test.tsx
npm run typecheck
cargo test -p shared-contracts creation_audio --manifest-path server-rs\Cargo.toml
cargo test -p shared-contracts puzzle --manifest-path server-rs\Cargo.toml
cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml
cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs\Cargo.toml
cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml
cargo check -p api-server --manifest-path server-rs\Cargo.toml
```
真实生成 smoke 需要本地私密环境配置 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 与 OSS 变量。后端改动后使用 `npm run api-server` 启动,并确认 `/healthz`

View File

@@ -6,9 +6,10 @@
- [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` 空状态白屏。
- [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 差异。
- [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`
- [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。
- [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。
- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、debug 构建参数口径和手动排障命令。

View File

@@ -26,6 +26,12 @@
- mocap 光标按 60Hz 插值更新 UI 位置,并在拖拽中用插值后的当前点持续驱动输入层,避免输入包帧率低或抖动时出现明显跳变。
- 合并大块由拼图运行态把手部坐标命中到任一成员拼块;本地拼图运行时再按 `mergedGroupId` 执行整组平移。
## 调试模式
前端全局调试模式统一通过 `src/config/debugMode.ts` 判断。默认跟随 Vite 开发态:`import.meta.env.DEV` 为真时开启,生产构建默认关闭;如需显式覆盖,可设置 `VITE_DEBUG_MODE=true``VITE_DEBUG_MODE=false`
拼图运行态的 mocap 调试面板只在全局调试模式下渲染。面板默认折叠,只保留一行连接状态,展开后才显示动作、手势、解析告警和原始包预览,避免开发诊断信息遮挡拼图棋盘和底部操作。
## 接入规则
新玩法或新设备接入时遵循以下边界:

View File

@@ -16,7 +16,9 @@
4. 成员 crate 只保留自身需要表达的差异,例如 `features``optional = true` 或 target-specific dependency。
5. 需要关闭 default features 的依赖,应优先在 workspace 根依赖中声明;成员 crate 不再重复覆盖同一项。
6. `module-assets` 这类有默认服务端 feature 的领域 crate在 workspace 根内按 `default-features = false` 维护;需要服务端 OSS/HTTP 能力的 adapter crate 显式启用 `features = ["server-service"]`
7. 面向 SpacetimeDB WASM 的依赖链不得隐式启用原生 HTTP / OSS / Web 平台依赖;例如 `shared-contracts``assets` 模块通过 `oss-contracts` feature 暴露给 `api-server``spacetime-module` 路径只消费关闭默认 feature 后的纯 DTO 子集
7. `shared-contracts` 只能承载前后端公开 DTO 和轻量枚举,禁止直接依赖 `platform-*` 服务实现 crate需要把平台实现响应转换为公开 DTO 时,转换函数放在 `api-server` 等 adapter 层
8. 面向 SpacetimeDB WASM 的依赖链不得隐式启用原生 HTTP / OSS / Web 平台依赖;例如 `shared-contracts``assets` 模块通过不依赖 `platform-oss``oss-contracts` feature 暴露给 `api-server``spacetime-module` 路径只消费关闭默认 feature 后的纯 DTO 子集。
9. `spacetime-module` 的传递依赖不能包含 `reqwest``web-sys``js-sys``wasm-bindgen` 等 Web/HTTP 客户端链路;发布前可用 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 排查。
## 3. 本次收敛范围
@@ -57,14 +59,33 @@ npm.cmd run check:encoding -- docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDA
## 6. SpacetimeDB WASM 依赖边界
2026-05-11 本地重置 SpacetimeDB 并重新发布 `xushi-p4wfr` 时,`spacetime publish` 在 Rust 编译成功后报 `wasm-bindgen detected`。排查命令显示链路为:
```text
spacetime-module -> module-runtime -> shared-contracts -> platform-oss -> reqwest -> wasm-bindgen
```
根因是 `shared-contracts` 为了复用 OSS 直传/读签名返回类型,直接依赖了 `platform-oss`。这违反 DDD 分层边界:契约 crate 不能依赖平台副作用实现,否则所有引用契约的纯领域和 SpacetimeDB 模块都会被迫拉入 HTTP client。
`spacetime publish` 会构建 `spacetime-module``wasm32-unknown-unknown` 目标。这个目标不能包含 `wasm-bindgen`,也不应通过 DTO crate 间接拉入 `reqwest``web-sys` 或浏览器 WebAssembly 平台依赖。
修正口径:
1. `shared-contracts::assets` 定义独立的公开 DTO 和 `DirectUploadObjectAccess` 轻量枚举。
2. `platform-oss` 保持 OSS 签名、读写请求和错误分类实现,不被契约层引用。
3. `api-server::assets` 负责把 `platform_oss::OssPostObjectResponse` / `OssSignedGetObjectUrlResponse` 转成 `shared-contracts` DTO。
4. 后续新增外部平台能力时,重复使用这个边界:平台 crate 不得被 `shared-contracts``module-*``spacetime-module` 反向依赖。
已验证的排查命令:
```powershell
cargo tree -i wasm-bindgen --manifest-path server-rs\Cargo.toml -p spacetime-module --target wasm32-unknown-unknown
cargo tree -i wasm-bindgen --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown
cargo tree --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown | Select-String -Pattern 'wasm-bindgen|platform-oss|reqwest'
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml --target wasm32-unknown-unknown
cargo check -p shared-contracts --manifest-path server-rs\Cargo.toml
cargo check -p api-server --manifest-path server-rs\Cargo.toml
spacetime publish xushi-p4wfr --server local --module-path server-rs\crates\spacetime-module --build-options="--debug" -c=on-conflict --yes
```
若反向树显示 `reqwest -> platform-oss -> shared-contracts -> module-* -> spacetime-module`,优先检查新增的 `shared-contracts` 或领域 crate 依赖是否忘记关闭默认 feature。原生 `api-server` 需要资产上传契约时,应在自身 `Cargo.toml` 显式启用 `shared-contracts``oss-contracts` feature而不是让 workspace 根依赖默认启用。
若反向树显示 `reqwest -> platform-oss -> shared-contracts -> module-* -> spacetime-module`,优先检查新增的 `shared-contracts` 或领域 crate 依赖是否忘记关闭默认 feature,或 `shared-contracts` feature 是否错误依赖了平台实现 crate。原生 `api-server` 需要资产上传契约时,应在自身 `Cargo.toml` 显式启用 `shared-contracts``oss-contracts` feature而不是让 workspace 根依赖默认启用。

View File

@@ -7,6 +7,7 @@ Windows 本地执行 `npm run dev:rust` 或 `spacetime publish` 时,`spacetime
当本机 sccache server 状态损坏、client/server 通信异常或版本残留不一致时,可能出现:
```text
sccache: error: Timed out waiting for server startup. Maybe the remote service is unreachable?
sccache: error: failed to execute compile
sccache: caused by: Failed to send data to or receive data from server
sccache: caused by: Failed to read response header
@@ -15,9 +16,24 @@ sccache: caused by: failed to fill whole buffer
这类错误发生在 rustc wrapper 层,不能说明 SpacetimeDB module 代码本身编译失败。
## 2026-05-11 本机根因定位
本机 `cargo check -p api-server` 失败时Cargo 还没有进入业务 crate 编译,而是在读取 `server-rs/.cargo/config.toml` 后执行 `sccache rustc -vV` 探测编译器版本。失败的 stderr 会被写入 `server-rs/target/.rustc_info.json`,内容为 `Timed out waiting for server startup`
当前 PowerShell 环境设置了 `SCCACHE_OSS_BUCKET=genarrative-sccache``SCCACHE_OSS_ENDPOINT=https://oss-rg-china-mainland.aliyuncs.com``SCCACHE_OSS_KEY_PREFIX=genarrative`,且没有设置本地 `SCCACHE_DIR`。因此 sccache daemon 冷启动时会先初始化 OSS 远端缓存,并执行 `.sccache_check` 的读写检查;日志中可见 `Init oss cache ...``proxy(http://127.0.0.1:7897/) intercepts ...`,随后才出现 `server started, listening on 127.0.0.1:4226`
本次排查的结论是:冷启动失败主要发生在 sccache client 等待 daemon 启动的握手窗口内,而 daemon 启动又依赖 OSS/本机代理链路先完成缓存可读写检查。代理或 OSS 链路稍慢时Cargo 调用的 `sccache rustc -vV` 会先超时daemon 预热后直接执行同一条 `sccache rustc -vV` 又可能成功,所以这是冷启动/通道状态问题,不是 `api-server` 或 Rust 代码错误。
辅助证据:
1. `rustc -vV` 可直接输出版本,说明 Rust 工具链本身可用。
2. `tasklist` 曾只看到 `sccache --show-stats` 客户端进程,`netstat` 只出现到 `127.0.0.1:4226``SYN_SENT`,没有真正的 `LISTEN`,说明当时 client 正在等一个尚未成功监听的 daemon。
3. 在子进程中临时清掉 `SCCACHE_OSS_*` 并设置本地 `SCCACHE_DIR`sccache 退回本地磁盘缓存,日志显示 `Init disk cache ...``rustc -vV``sccache --show-stats` 均能完成。
4. `C:\Users\DSK\AppData\Roaming\Mozilla\sccache\config\config` 缺失只是非致命 warning本机实际配置来自环境变量。
## 本地开发处理
`scripts/dev-rust-stack.sh` 的 publish 阶段继续由 SpacetimeDB CLI 内部调用 Cargo并通过 `--build-options="--debug"` 使用 debug 构建参数。遇到 sccache 通信或 wrapper 失败时,本地排障仍优先绕过 wrapper 验证 rustc 本身可用。
`scripts/dev-rust-stack.sh` 的 publish 阶段继续由 SpacetimeDB CLI 内部调用 Cargo并通过 `--build-options="--debug"` 使用 debug 构建参数。遇到 sccache 冷启动超时时,优先保留 `sccache` wrapper并修复 sccache daemon 的启动等待时间;只有在排除 sccache 本身问题时,才临时绕过 wrapper 验证 rustc 本身可用。
该处理不修改 `server-rs/.cargo/config.toml`,也不删除本地 target 缓存。
@@ -29,13 +45,56 @@ sccache: caused by: failed to fill whole buffer
rustc -vV
```
如果只想绕过本次 Cargo 构建的 sccache wrapper可在 Git Bash 中执行
如果要保留 sccache 并修复冷启动等待时间,在 PowerShell 中创建或更新 sccache 默认配置
```powershell
$configDir = Join-Path $env:APPDATA "Mozilla\sccache\config"
New-Item -ItemType Directory -Force -Path $configDir | Out-Null
@(
"# Windows 本机 sccache 冷启动需要先完成 OSS 缓存读写检查。"
"# 拉长 client 等待 daemon 启动的时间,避免 Cargo 在 rustc -vV 阶段误判超时。"
"server_startup_timeout_ms = 60000"
) | Set-Content -Encoding UTF8 -Path (Join-Path $configDir "config")
```
随后清掉 Cargo 曾缓存的失败探测结果,并从冷启动验证:
```powershell
cd C:\proj\Genarrative\server-rs
sccache --stop-server
Remove-Item -Force target\.rustc_info.json -ErrorAction SilentlyContinue
cargo check -p api-server
```
注意:不要在另一个 `cargo` / `rustc` 仍在编译时执行 `taskkill /F /IM sccache.exe /T`。sccache 对 proc-macro crate 会显示 `Server sent UnhandledCompile` 并把请求转交给真实 rustc如果此时强杀 sccache client/server可能让 `serde_derive``spacetimedb-bindings-macro` 等 proc-macro 编译直接以 `sccache ... exit code: 1` 失败,而 stderr 里看不到真正的 Rust 诊断。这是排障动作打断编译,不是 `spacetime-module` 源码错误。
如果只想临时绕过本次 Cargo 构建的 sccache wrapper可在 Git Bash 中执行:
```bash
cd server-rs/crates/spacetime-module
RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo check --target=wasm32-unknown-unknown
```
PowerShell 原生 Cargo 的一次性 wrapper 绕过命令是:
```powershell
cd C:\proj\Genarrative\server-rs
cargo check -p api-server --config "build.rustc-wrapper=''"
```
如果需要验证是否为 OSS/代理冷启动问题,可只在当前 PowerShell 进程中切到本地缓存做对照:
```powershell
$env:SCCACHE_LOG = "debug"
$env:SCCACHE_ERROR_LOG = "C:\proj\Genarrative\logs\sccache-local-start-error.log"
$env:SCCACHE_DIR = Join-Path $env:TEMP "genarrative-sccache-local-test"
Remove-Item Env:SCCACHE_OSS_BUCKET -ErrorAction SilentlyContinue
Remove-Item Env:SCCACHE_OSS_ENDPOINT -ErrorAction SilentlyContinue
Remove-Item Env:SCCACHE_OSS_KEY_PREFIX -ErrorAction SilentlyContinue
sccache "C:\Users\DSK\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin\rustc.exe" -vV
sccache --show-stats
```
如果需要排查 sccache server 状态:
```bash
@@ -44,10 +103,12 @@ sccache --stop-server
sccache --start-server
```
`sccache --stop-server` 本身也可能因为 server 通道已损坏而失败;此时不应阻断本地开发 publish先使用 wrapper 降级完成验证。
`sccache --stop-server` 本身也可能因为 server 通道已损坏而失败;只有确认当前没有 `cargo``rustc``link` 进程后,才用 `taskkill /F /IM sccache.exe /T` 清理残留进程。此时不应阻断本地开发 publish先使用 wrapper 降级完成验证。
## 验证
1. `bash -n scripts/dev-rust-stack.sh`
2. `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo check --target=wasm32-unknown-unknown`
3. 重新运`npm run dev:rust`,确认 publish 命令带有 `--build-options="--debug"`
2. 冷启动后直接执行 `cargo check -p api-server`,确认不再出现 `Timed out waiting for server startup`
3. `cargo check -p spacetime-module`,确认 proc-macro 依赖和 SpacetimeDB module 都能在 sccache wrapper 下通过。
4. `sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,确认仍在使用 sccache/OSS 缓存。
5. 重新运行 `npm run dev:rust`,确认 publish 命令带有 `--build-options="--debug"`

View File

@@ -72,8 +72,8 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default';
### `user_account`
- 作用用户账号主表保存用户名、公开百梦号、手机号掩码、登录方式、密码登录开关、token 版本和默认不前端展示的运营标签。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option<String>`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Vec<String>`
- 说明:`user_tags` 默认空数组,只允许后端白名单投影到特定业务接口不得在登录态、个人资料等通用前端响应中直接暴露。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option<String>`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Option<Vec<String>>`
- 说明:`user_tags` 数据库默认 `None`,业务读取时按空数组归一化;只允许后端白名单投影到特定业务接口不得在登录态、个人资料等通用前端响应中直接暴露。
- 索引:`username`, `public_user_code`
```sql
@@ -256,11 +256,11 @@ SELECT * FROM profile_redeem_code_usage WHERE user_id = '<user_id>';
### `profile_invite_code`
- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签。
- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option<Timestamp>`, `expires_at: Option<Timestamp>`, `granted_user_tags: Vec<String>`
- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签配置
- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option<Timestamp>`, `expires_at: Option<Timestamp>`
- 索引:主键 `user_id`,唯一索引 `invite_code`
- 后台读取:`GET /admin/api/profile/invite-codes` 只返回 `user_id``admin:` 开头的后台预置码;普通用户自己的邀请码不得进入后台运营列表。
- 说明:`granted_user_tags` 默认空数组;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。
- 说明:使用该邀请码后授予的标签存放在 `metadata_json.userTags`,服务端兼容读取 `metadata_json.user_tags`;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。
```sql
SELECT * FROM profile_invite_code WHERE user_id = '<user_id>';
@@ -665,7 +665,7 @@ SELECT * FROM match3d_agent_message WHERE session_id = '<session_id>' ORDER BY c
- 作用:抓大鹅 Match3D 作品主表,保存作品基础信息、配置、发布状态、游玩次数和草稿生成出的独立物品素材引用。
- 结构:`profile_id PK: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `cover_asset_id: String`, `clear_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option<Timestamp>`, `generated_item_assets_json: Option<String>`
- 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片预览;基础信息自动保存和发布必须保留该字段。
- 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片和 GLB 模型预览;运行态也通过该字段拿到 `modelSrc` / `modelObjectKey` 并优先渲染生成模型。基础信息自动保存和发布必须保留该字段。
- 索引:`owner_user_id`, `publication_status`
```sql

View File

@@ -1,13 +1,13 @@
# 用户标签、邀请码授予与拼图榜单展示方案
更新时间:`2026-05-10`
更新时间:`2026-05-11`
## 1. 目标
本次新增用户标签系统的最小闭环:
1. `user_account` 增加账号标签字段,默认空
2. 后台预置邀请码可配置授予标签。
1. `user_account` 增加账号标签字段,数据库默认空,业务读取时按空数组处理
2. 后台预置邀请码可通过原有 `metadata` 字段配置授予标签。
3. 用户填写带标签的邀请码后,把标签合并到自己的账号。
4. 标签默认不在前端资料页、邀请中心或通用接口展示。
5. 拼图排行榜仅对白名单标签做展示,首版只展示 `北科`
@@ -16,19 +16,21 @@
### `user_account.user_tags`
- 类型:`Vec<String>`
- 默认:空数组。
- 类型:`Option<Vec<String>>`
- 默认:`None`,业务层读取时统一按空数组处理
- 语义:账号级运营标签,属于后台与服务端投影数据,不作为普通前端个人资料字段。
- 写入:首版只由邀请码兑换链路合并写入。
- 迁移:旧迁移包和旧数据库按空数组兼容
- 迁移:旧迁移包和旧数据库按 `null` 兼容,再由业务层归一化为空数组。
### `profile_invite_code.granted_user_tags`
### `profile_invite_code.metadata_json.userTags`
- 类型:`Vec<String>`
- 默认:空数组
- 类型:`metadata_json` 对象里的 `userTags: string[]`
- 默认:字段缺失或空数组时不授予标签
- 语义:使用该邀请码后授予被邀请账号的标签列表。
- 范围:后台运营预置码和普通用户个人邀请码都可存字段,但后台表单首版只允许管理员配置预置码。
- 迁移:旧邀请码按空数组兼容
- 存储:不再新增或使用独立的邀请码标签列;后台保存时把用户标签写回 `metadata.userTags`
- 解析:服务端优先读取 `metadata_json.userTags`,并兼容解析 `metadata_json.user_tags`
- 迁移:旧邀请码缺少 `metadata_json` 时按 `{}` 兼容;旧迁移包里已废弃的独立字段会在导入时丢弃。
## 3. 标签归一化
@@ -45,35 +47,38 @@
1. 写入 `profile_referral_relation`
2. 发放原有双方奖励。
3. 读取 `profile_invite_code.granted_user_tags`
3. `profile_invite_code.metadata_json` 解析 `userTags` / `user_tags`
4. 将这些标签合并进 `user_account.user_tags`
管理员更新邀请码时,`grantedUserTags` 代表覆盖该邀请码之后授予的标签集合;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。
管理员更新邀请码时,后台表单里的用户标签会覆盖写入 `metadata.userTags`;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。
## 5. API 契约
后台邀请码 upsert 请求增加
后台邀请码 upsert 请求继续只提交 `metadata`,标签写在 `metadata.userTags`
```json
{
"inviteCode": "BEIKE2026",
"grantedUserTags": ["北科"],
"metadata": {},
"metadata": {
"userTags": ["北科"]
},
"startsAt": null,
"expiresAt": null
}
```
后台邀请码列表和 upsert 返回增加同名字段
后台邀请码列表和 upsert 返回继续回传 `metadata`
```json
{
"inviteCode": "BEIKE2026",
"grantedUserTags": ["北科"]
"metadata": {
"userTags": ["北科"]
}
}
```
用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags`
后台表单展示时从 `metadata.userTags` 回显用户标签;用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags`
## 6. 拼图排行榜展示
@@ -94,9 +99,10 @@
## 7. 验收
1. 新账号 `user_account.user_tags` 默认为空
2. 后台创建邀请码时可填写 `北科`列表和结果面板可回显。
1. 新账号 `user_account.user_tags` 数据库默认为 `None`,业务读取为空数组
2. 后台创建邀请码时可填写 `北科`请求和返回的 `metadata.userTags` 可回显。
3. 用户填写该邀请码后,账号表 `user_tags` 包含 `北科`
4. 不带标签的邀请码不改变账号标签。
5. 拼图排行榜中带 `北科` 的用户昵称下方显示 `北科`,其它标签不显示。
6. 执行 `npm run check:encoding``cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。
6. 生成 SpacetimeDB bindings 后,不再出现独立的邀请码标签字段。
7. 执行 `npm run check:encoding``cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。

View File

@@ -36,6 +36,8 @@
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js",
"check": "npm run lint && npm run test && npm run build && npm run check:content",
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",

View File

@@ -0,0 +1,53 @@
export type CreationAudioGenerationKind =
| 'background_music'
| 'sound_effect';
export interface CreationAudioAsset {
taskId: string;
provider: string;
assetObjectId?: string | null;
assetKind?: string | null;
audioSrc: string;
prompt?: string | null;
title?: string | null;
updatedAt?: string | null;
}
export interface CreateBackgroundMusicRequest {
prompt: string;
title: string;
tags?: string | null;
model?: string | null;
}
export interface CreateSoundEffectRequest {
prompt: string;
duration?: number | null;
seed?: number | null;
}
export interface AudioGenerationTaskResponse {
kind: CreationAudioGenerationKind;
taskId: string;
provider: string;
status: string;
}
export interface PublishGeneratedAudioAssetRequest {
entityKind: string;
entityId: string;
slot: string;
assetKind: string;
profileId?: string | null;
storagePrefix?: 'puzzle_assets' | 'match3d_assets' | 'custom_world_scenes' | null;
}
export interface GeneratedAudioAssetResponse {
kind: CreationAudioGenerationKind;
taskId: string;
provider: string;
status: string;
assetObjectId?: string | null;
assetKind?: string | null;
audioSrc?: string | null;
}

View File

@@ -1,4 +1,5 @@
export type * from './creativeAgent';
export type * from './creationAudio';
export type * from './hyper3d';
export type * from './puzzleCreativeTemplate';
export type * from './visualNovel';

View File

@@ -2,6 +2,8 @@
* 抓大鹅 Match3D 作品读写共享契约。
* 首版作品发布必须补齐游戏名称、标签、封面、题材、消除次数和难度。
*/
import type { CreationAudioAsset } from './creationAudio';
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
export type Match3DGeneratedItemAssetStatus =
@@ -22,10 +24,16 @@ export interface Match3DGeneratedItemAsset {
modelFileName?: string | null;
taskUuid?: string | null;
subscriptionKey?: string | null;
backgroundMusic?: CreationAudioAsset | null;
clickSound?: CreationAudioAsset | null;
status: Match3DGeneratedItemAssetStatus;
error?: string | null;
}
export interface PutMatch3DAudioAssetsRequest {
generatedItemAssets: Match3DGeneratedItemAsset[];
}
export interface PutMatch3DWorkRequest {
gameName: string;
themeText?: string;
@@ -37,6 +45,15 @@ export interface PutMatch3DWorkRequest {
difficulty: number;
}
export interface GenerateMatch3DWorkTagsRequest {
gameName: string;
themeText: string;
}
export interface GenerateMatch3DWorkTagsResponse {
tags: string[];
}
export interface Match3DWorkSummary {
workId: string;
profileId: string;

View File

@@ -1,4 +1,5 @@
import type { JsonObject } from './common';
import type { CreationAudioAsset } from './creationAudio';
export type PuzzleAnchorStatus =
| 'missing'
@@ -47,6 +48,7 @@ export interface PuzzleDraftLevel {
levelName: string;
pictureDescription: string;
pictureReference?: string | null;
backgroundMusic?: CreationAudioAsset | null;
candidates: PuzzleGeneratedImageCandidate[];
selectedCandidateId: string | null;
coverImageSrc: string | null;

View File

@@ -352,7 +352,6 @@ export type AdminDisableProfileRedeemCodeRequest = {
export type AdminUpsertProfileInviteCodeRequest = {
inviteCode: string;
metadata?: Record<string, unknown> | null;
grantedUserTags?: string[];
startsAt?: string | null;
expiresAt?: string | null;
};
@@ -361,7 +360,6 @@ export type ProfileInviteCodeAdminResponse = {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
grantedUserTags: string[];
startsAt?: string | null;
expiresAt?: string | null;
status: 'pending' | 'active' | 'expired';

View File

@@ -3,6 +3,7 @@ export * from './contracts/auth';
export type * from './contracts/bigFish';
export * from './contracts/common';
export type * from './contracts/creationAgentDocumentInput';
export type * from './contracts/creationAudio';
export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export type * from './contracts/hyper3d';

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

205
scripts/loadtest/README.md Normal file
View File

@@ -0,0 +1,205 @@
# Genarrative 作品列表 K6 压测
本目录用于对“作品列表/公开广场”读接口做本地压测。数据源来自私有 SpacetimeDB migration但提取脚本只输出作品 profile 白名单表并对用户、作者、作品号、asset id 等标识做稳定映射。
## 文件
- `extract-works-list-data.mjs`:从 migration JSON 提取作品列表压测数据;本地输出也会脱敏路由 ID因此默认用于列表接口压测详情接口需先把同一份脱敏数据导入目标环境。
- `k6-works-list.js`K6 压测脚本。
- `data/spacetime-migration-7.local.json`:本地私有原始数据副本,已被 `.gitignore` 忽略,不要提交。
- `data/works-list.local.json`:本地脱敏压测数据,已被 `.gitignore` 忽略,不要提交。
- `data/works-list.sample.json`:可提交的少量脱敏样例。
## 数据边界
允许导入的表:
- `puzzle_work_profile`
- `custom_world_profile`
- `match3d_work_profile`
- `square_hole_work_profile`
- `big_fish_work_profile`
- `visual_novel_work_profile`
明确不导入:
- 账号/认证:`user_account``auth_identity``refresh_session``auth_store_snapshot`
- 钱包/邀请:`profile_wallet_ledger``profile_redeem_*``profile_invite_*`
- 游玩历史/埋点/存档:`public_work_play_daily_stat``profile_played_world``puzzle_runtime_run``profile_save_archive``runtime_snapshot`
- AI 任务过程:`ai_task``ai_task_stage``ai_text_chunk`
- asset 二进制:`asset_object``asset_entity_binding`
提取脚本会移除 `source_session_id` / `source_agent_session_id` 等会话派生字段;这些字段不属于作品列表卡片压测必要字段。
## 重新提取数据
从仓库根目录执行:
```bash
npm run loadtest:extract-works -- \
--input scripts/loadtest/data/spacetime-migration-7.local.json \
--output scripts/loadtest/data/works-list.local.json \
--sample-output scripts/loadtest/data/works-list.sample.json
```
也可以直接执行:
```bash
node scripts/loadtest/extract-works-list-data.mjs \
--input scripts/loadtest/data/spacetime-migration-7.local.json \
--output scripts/loadtest/data/works-list.local.json \
--sample-output scripts/loadtest/data/works-list.sample.json
```
当前 local 全量提取结果:
- `puzzle_work_profile`: 80
- `custom_world_profile`: 1
- `match3d_work_profile`: 0
- `normalizedWorks`: 81
当前可提交 sample 结果:
- `puzzle_work_profile`: 3
- `custom_world_profile`: 1
- `match3d_work_profile`: 0
- `normalizedWorks`: 4
## 真实接口
已从 `server-rs/crates/api-server/src/app.rs` 确认的读接口:
公开接口,无需 Bearer token
- `GET /api/runtime/puzzle/gallery`
- `GET /api/runtime/puzzle/gallery/{profile_id}`
- `GET /api/runtime/custom-world-gallery`
- `GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}`
- `GET /api/runtime/custom-world-gallery/by-code/{code}`
需要 Bearer token 的个人作品列表接口:
- `GET /api/runtime/puzzle/works`
- `GET /api/runtime/puzzle/works/{profile_id}`
- `GET /api/runtime/custom-world/works`
K6 脚本默认只跑公开列表接口;传入 `AUTH_TOKEN` 后会额外跑需要登录态的个人作品列表接口。当前真实列表 handler 未暴露分页/排序 query 参数,因此脚本不追加 `limit/offset`;若后续接口增加分页参数,再在 K6 中补随机分页。
详情接口默认不压测,因为本地数据中的 `profile_id` / `owner_user_id` 已脱敏,直接请求未导入脱敏数据的目标服务会 404。只有在目标环境已导入同一份脱敏数据或改用真实 ID 本地文件时,才设置 `DETAIL_RATIO` 大于 0详情请求不把 404 视为成功。
## 启动服务
按项目约定启动本地 dev 栈:
```bash
npm run dev
```
注意端口可能漂移。以启动日志中的实际 api-server 端口为准,然后传给 K6。
注意K6 的 `open()` 会按 `k6-works-list.js` 所在目录解析相对路径,因此 `WORKS_DATA` 应写成 `data/works-list.local.json`,不要写成 `scripts/loadtest/data/works-list.local.json`
Bash / Git Bash
```bash
BASE_URL=http://127.0.0.1:<actual-api-port> WORKS_DATA=data/works-list.local.json npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
```
PowerShell
```powershell
$env:BASE_URL="http://127.0.0.1:<actual-api-port>"
$env:WORKS_DATA="data/works-list.local.json"
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
```
## Smoke
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=smoke \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认1 VU / 30s。
## Baseline
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=baseline \
VUS=10 \
DURATION=3m \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认阈值:
- `http_req_failed < 1%`
- `http_req_duration p95 < 800ms`
- `http_req_duration p99 < 1500ms`
- `works_list_shape_error_rate < 1%`
## Spike
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=spike \
START_RPS=5 \
PEAK_RPS=100 \
HOLD=2m \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认阈值:
- `http_req_failed < 5%`
- `http_req_duration p95 < 2000ms`
- `works_list_shape_error_rate < 5%`
## 带登录态压测个人作品列表
先通过本地登录或接口获取 access token然后传入
```bash
BASE_URL=http://127.0.0.1:8787 \
AUTH_TOKEN='<access-token>' \
SCENARIO=smoke \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
不要把 token 写入仓库文件、README 或 shell history 中可共享的位置。
## 详情接口压测
仅当目标环境存在 `WORKS_DATA` 中的同一批 `profileId/ownerUserId` 时启用:
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=smoke \
DETAIL_RATIO=0.35 \
npm run loadtest:k6:works
```
如果详情请求返回 404说明压测数据 ID 未导入目标环境或目标服务数据不一致,应先修正数据源,不要把 404 当成功。
## 排障
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503检查本地 creation entry 配置是否禁用了对应入口。
- 如果个人作品列表返回 401确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
- 如果详情全部 404确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
## 验证命令
```bash
npx vitest run scripts/loadtest/extract-works-list-data.test.ts
npx eslint scripts/loadtest/extract-works-list-data.mjs scripts/loadtest/extract-works-list-data.test.ts scripts/loadtest/k6-works-list.js
```

View File

@@ -0,0 +1,214 @@
{
"source": "spacetime-migration-7.local.json",
"generatedAt": "2026-05-11T13:09:51.569Z",
"counts": {
"puzzle_work_profile": 3,
"custom_world_profile": 1,
"match3d_work_profile": 0
},
"tables": {
"puzzle_work_profile": [
{
"profile_id": "profile-001",
"work_id": "work-001",
"owner_user_id": "user-001",
"author_display_name": "author-001",
"cover_asset_id": "asset-001",
"cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
"work_title": "化学家",
"level_name": "文学家",
"summary": "几个文学家正站在山上面对着瀑布侃侃而谈",
"work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室",
"levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…",
"theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]",
"publication_status": {
"Published": []
},
"play_count": 1,
"like_count": 0,
"remix_count": 1,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777703338322544
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777648804043558
},
"published_at": {
"__timestamp_micros_since_unix_epoch__": 1777649364112270
}
},
{
"profile_id": "profile-002",
"work_id": "work-002",
"owner_user_id": "user-002",
"author_display_name": "author-002",
"work_title": "我不知道",
"level_name": "",
"summary": "你猜我是谁",
"work_description": "你猜我是谁",
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}",
"theme_tags_json": "[\"我不知道\"]",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777619351714201
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777619336673245
}
},
{
"profile_id": "profile-003",
"work_id": "work-003",
"owner_user_id": "user-003",
"author_display_name": "author-002",
"work_title": "",
"level_name": "",
"summary": "",
"work_description": "",
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}",
"theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
}
}
],
"custom_world_profile": [
{
"profile_id": "profile-081",
"owner_user_id": "user-002",
"author_display_name": "author-012",
"author_public_user_code": "author-code-001",
"world_name": "青春飞扬校园",
"summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长",
"subtitle": "反内卷的自由学习之旅",
"profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777532006629209
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777531745887256
}
}
],
"match3d_work_profile": []
},
"profileIds": {
"puzzle": [
"profile-001",
"profile-002",
"profile-003"
],
"customWorld": [
"profile-081"
],
"match3d": [],
"squareHole": [],
"bigFish": [],
"visualNovel": []
},
"workIds": {
"puzzle": [
"work-001",
"work-002",
"work-003"
],
"customWorld": [],
"match3d": [],
"squareHole": [],
"bigFish": [],
"visualNovel": []
},
"normalizedWorks": [
{
"type": "puzzle",
"workId": "work-001",
"profileId": "profile-001",
"ownerUserId": "user-001",
"title": "化学家",
"subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈",
"publicationStatus": {
"Published": []
},
"playCount": 1,
"likeCount": 0,
"remixCount": 1,
"coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777703338322544
}
},
{
"type": "puzzle",
"workId": "work-002",
"profileId": "profile-002",
"ownerUserId": "user-002",
"title": "我不知道",
"subtitle": "你猜我是谁",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777619351714201
}
},
{
"type": "puzzle",
"workId": "work-003",
"profileId": "profile-003",
"ownerUserId": "user-003",
"title": "",
"subtitle": "",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
}
},
{
"type": "customWorld",
"profileId": "profile-081",
"ownerUserId": "user-002",
"title": "青春飞扬校园",
"subtitle": "反内卷的自由学习之旅",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777532006629209
}
}
]
}

View File

@@ -0,0 +1,370 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const ALLOWED_TABLES = new Set([
'puzzle_work_profile',
'custom_world_profile',
'match3d_work_profile',
'square_hole_work_profile',
'big_fish_work_profile',
'visual_novel_work_profile',
]);
const WORK_TABLE_TYPES = {
puzzle_work_profile: 'puzzle',
custom_world_profile: 'customWorld',
match3d_work_profile: 'match3d',
square_hole_work_profile: 'squareHole',
big_fish_work_profile: 'bigFish',
visual_novel_work_profile: 'visualNovel',
};
const TABLE_OUTPUT_ORDER = [
'puzzle_work_profile',
'custom_world_profile',
'match3d_work_profile',
'square_hole_work_profile',
'big_fish_work_profile',
'visual_novel_work_profile',
];
const WORK_TYPES = ['puzzle', 'customWorld', 'match3d', 'squareHole', 'bigFish', 'visualNovel'];
const SHORT_TEXT_LIMIT = 120;
const LONG_TEXT_LIMIT = 500;
const SENSITIVE_PATTERN = /(token|secret|password|passwd|phone|wallet|credential|authorization|auth[_-]?key|api[_-]?key)/giu;
class StableMapper {
constructor(prefix) {
this.prefix = prefix;
this.values = new Map();
}
map(value) {
if (value === undefined || value === null || value === '') return value;
const key = String(value);
if (!this.values.has(key)) {
this.values.set(
key,
`${this.prefix}-${String(this.values.size + 1).padStart(3, '0')}`,
);
}
return this.values.get(key);
}
}
function createContext() {
return {
user: new StableMapper('user'),
session: new StableMapper('session'),
author: new StableMapper('author'),
authorCode: new StableMapper('author-code'),
publicWorkCode: new StableMapper('public-work-code'),
coverAsset: new StableMapper('asset'),
work: new StableMapper('work'),
profile: new StableMapper('profile'),
};
}
function createWorkTypeBuckets() {
return Object.fromEntries(WORK_TYPES.map((type) => [type, []]));
}
function unwrapSpacetimeOption(value) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length === 1
) {
if (Object.prototype.hasOwnProperty.call(value, 'some')) return value.some;
if (Object.prototype.hasOwnProperty.call(value, 'none')) return undefined;
}
return value;
}
function truncateText(value, limit) {
if (value === undefined || value === null) return value;
const text = String(value).replace(/\s+/g, ' ').trim();
if (text.length <= limit) return text;
return `${text.slice(0, limit)}`;
}
function redactSensitiveText(value) {
if (value === undefined || value === null) return value;
return String(value).replace(SENSITIVE_PATTERN, '[redacted]');
}
function sanitizeCoverImageSrc(value) {
const unwrapped = unwrapSpacetimeOption(value);
if (unwrapped === undefined || unwrapped === null || unwrapped === '') return unwrapped;
const text = String(unwrapped);
if (text.startsWith('data:image/')) return '[redacted-data-image]';
let withoutQuery = text.split('?')[0].split('#')[0];
if (withoutQuery.length > 180) withoutQuery = `${withoutQuery.slice(0, 180)}`;
return withoutQuery;
}
function sanitizeLargeJson(value) {
const unwrapped = unwrapSpacetimeOption(value);
if (unwrapped === undefined || unwrapped === null) return unwrapped;
if (typeof unwrapped === 'string') {
return truncateText(redactSensitiveText(unwrapped), LONG_TEXT_LIMIT);
}
try {
return truncateText(redactSensitiveText(JSON.stringify(unwrapped)), LONG_TEXT_LIMIT);
} catch {
return truncateText(redactSensitiveText(String(unwrapped)), LONG_TEXT_LIMIT);
}
}
function firstDefined(row, keys) {
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) return row[key];
}
return undefined;
}
function sanitizeShortField(row, sanitized, key) {
if (row[key] !== undefined) {
sanitized[key] = truncateText(unwrapSpacetimeOption(row[key]), SHORT_TEXT_LIMIT);
}
}
function sanitizeWorkRow(row, ctx) {
const sanitized = {};
const profileId = unwrapSpacetimeOption(firstDefined(row, ['profile_id', 'profileId']));
const workId = unwrapSpacetimeOption(firstDefined(row, ['work_id', 'workId']));
if (profileId !== undefined) sanitized.profile_id = ctx.profile.map(profileId);
if (workId !== undefined) sanitized.work_id = ctx.work.map(workId);
if (row.owner_user_id !== undefined) {
sanitized.owner_user_id = ctx.user.map(unwrapSpacetimeOption(row.owner_user_id));
}
if (row.user_id !== undefined) sanitized.user_id = ctx.user.map(unwrapSpacetimeOption(row.user_id));
if (row.author_display_name !== undefined) {
sanitized.author_display_name = ctx.author.map(unwrapSpacetimeOption(row.author_display_name));
}
if (row.public_work_code !== undefined) {
sanitized.public_work_code = ctx.publicWorkCode.map(unwrapSpacetimeOption(row.public_work_code));
}
if (row.author_public_user_code !== undefined) {
sanitized.author_public_user_code = ctx.authorCode.map(
unwrapSpacetimeOption(row.author_public_user_code),
);
}
if (row.cover_asset_id !== undefined) {
sanitized.cover_asset_id = ctx.coverAsset.map(unwrapSpacetimeOption(row.cover_asset_id));
}
if (row.cover_image_src !== undefined) sanitized.cover_image_src = sanitizeCoverImageSrc(row.cover_image_src);
for (const key of [
'title',
'work_title',
'level_name',
'world_name',
'summary',
'summary_text',
'description',
'work_description',
'subtitle',
]) {
sanitizeShortField(row, sanitized, key);
}
for (const key of ['levels_json', 'profile_payload_json', 'anchor_pack_json', 'theme_tags_json']) {
if (row[key] !== undefined) sanitized[key] = sanitizeLargeJson(row[key]);
}
const passthroughKeys = [
'publication_status',
'publicationStatus',
'play_count',
'playCount',
'like_count',
'likeCount',
'remix_count',
'remixCount',
'updated_at',
'created_at',
'published_at',
'visibility',
'status',
'category',
'tags',
];
for (const key of passthroughKeys) {
if (row[key] !== undefined) sanitized[key] = unwrapSpacetimeOption(row[key]);
}
return sanitized;
}
function normalizeWork(tableName, row) {
const type = WORK_TABLE_TYPES[tableName];
return {
type,
workId: row.work_id,
profileId: row.profile_id,
ownerUserId: row.owner_user_id,
publicWorkCode: row.public_work_code,
title: row.title ?? row.work_title ?? row.level_name ?? row.world_name,
subtitle: row.subtitle ?? row.summary_text ?? row.summary ?? row.work_description ?? row.description,
publicationStatus: row.publicationStatus ?? row.publication_status ?? row.status,
playCount: row.playCount ?? row.play_count ?? 0,
likeCount: row.likeCount ?? row.like_count ?? 0,
remixCount: row.remixCount ?? row.remix_count ?? 0,
coverImageSrc: row.cover_image_src,
updatedAt: row.updated_at,
};
}
function toRowsByTable(input) {
const tables = Array.isArray(input?.tables) ? input.tables : [];
const result = new Map();
for (const table of tables) {
if (!ALLOWED_TABLES.has(table?.name)) continue;
result.set(table.name, Array.isArray(table.rows) ? table.rows : []);
}
return result;
}
export function extractWorksListData(input, options = {}) {
const ctx = createContext();
const rowsByTable = toRowsByTable(input);
const outputTables = {};
const counts = {};
const profileIds = createWorkTypeBuckets();
const workIds = createWorkTypeBuckets();
const normalizedWorks = [];
for (const tableName of TABLE_OUTPUT_ORDER) {
const sourceRows = rowsByTable.get(tableName);
if (!sourceRows) continue;
const sanitizedRows = sourceRows.map((row) => sanitizeWorkRow(row, ctx));
outputTables[tableName] = sanitizedRows;
counts[tableName] = sanitizedRows.length;
const type = WORK_TABLE_TYPES[tableName];
if (type) {
for (const row of sanitizedRows) {
if (row.profile_id) profileIds[type].push(row.profile_id);
if (row.work_id) workIds[type].push(row.work_id);
normalizedWorks.push(normalizeWork(tableName, row));
}
}
}
return {
source: options.source ?? 'unknown',
generatedAt: options.generatedAt ?? new Date().toISOString(),
counts,
tables: outputTables,
profileIds,
workIds,
normalizedWorks,
};
}
function createSampleOutput(output, maxRowsPerTable = 3) {
const tables = {};
const counts = {};
const allowedWorkIds = new Set();
const allowedProfileIds = new Set();
for (const [tableName, rows] of Object.entries(output.tables)) {
tables[tableName] = rows.slice(0, maxRowsPerTable);
counts[tableName] = tables[tableName].length;
const type = WORK_TABLE_TYPES[tableName];
if (type) {
for (const row of tables[tableName]) {
if (row.work_id) allowedWorkIds.add(row.work_id);
if (row.profile_id) allowedProfileIds.add(row.profile_id);
}
}
}
const profileIds = Object.fromEntries(
Object.entries(output.profileIds).map(([type, ids]) => [
type,
ids.filter((id) => allowedProfileIds.has(id)).slice(0, maxRowsPerTable),
]),
);
const workIds = Object.fromEntries(
Object.entries(output.workIds).map(([type, ids]) => [
type,
ids.filter((id) => allowedWorkIds.has(id)).slice(0, maxRowsPerTable),
]),
);
const normalizedWorks = output.normalizedWorks
.filter((work) => allowedWorkIds.has(work.workId) || allowedProfileIds.has(work.profileId))
.slice(0, maxRowsPerTable * 6);
return {
...output,
counts,
tables,
profileIds,
workIds,
normalizedWorks,
};
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--input' || arg === '--output' || arg === '--sample-output') {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
args[arg.slice(2)] = value;
index += 1;
} else if (arg === '--help' || arg === '-h') {
args.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function usage() {
return 'Usage: node scripts/loadtest/extract-works-list-data.mjs --input <migration.json> --output <works-list.local.json> [--sample-output <works-list.sample.json>]';
}
export async function runCli(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
if (args.help) {
console.log(usage());
return;
}
if (!args.input) throw new Error('Missing required --input. ' + usage());
if (!args.output) throw new Error('Missing required --output. ' + usage());
const raw = await readFile(args.input, 'utf8');
const migration = JSON.parse(raw);
const output = extractWorksListData(migration, { source: basename(args.input) });
await writeFile(args.output, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
if (args['sample-output']) {
const sample = createSampleOutput(output);
await writeFile(args['sample-output'], `${JSON.stringify(sample, null, 2)}\n`, 'utf8');
}
console.log(
`works-list extracted: source=${output.source}, tables=${Object.keys(output.tables).length}, normalizedWorks=${output.normalizedWorks.length}`,
);
for (const [tableName, count] of Object.entries(output.counts)) {
console.log(` ${tableName}: ${count}`);
}
}
const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isDirectRun) {
runCli().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,247 @@
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { describe, expect, it } from 'vitest';
import { extractWorksListData } from './extract-works-list-data.mjs';
const execFileAsync = promisify(execFile);
const scriptPath = fileURLToPath(new URL('./extract-works-list-data.mjs', import.meta.url));
const fixtureMigration = {
schema_version: 7,
tables: [
{
name: 'puzzle_work_profile',
rows: [
{
profile_id: 'profile-real-aaa',
work_id: 'work-real-aaa',
owner_user_id: 'owner-secret-123',
author_display_name: 'Alice Secret',
author_public_user_code: 'author-code-secret',
public_work_code: 'public-code-secret',
title: '超长标题'.repeat(20),
summary: 'summary '.repeat(80),
description: 'description '.repeat(120),
publication_status: 'published',
play_count: 42,
like_count: 7,
cover_asset_id: { some: 'asset-secret-cover' },
cover_image_src: { some: 'https://cdn.example.test/cover.png?token=***&sig=abc' },
levels_json: JSON.stringify({ secret: 'level-token-value', data: 'x'.repeat(2000) }),
theme_tags_json: JSON.stringify(['化学家', '实验室']),
remix_count: 2,
updated_at: '2026-05-01T00:00:00Z',
},
{
profile_id: 'profile-real-bbb',
work_id: 'work-real-bbb',
owner_user_id: 'owner-secret-123',
author_display_name: 'Alice Secret',
publication_status: 'draft',
play_count: 3,
},
],
},
{
name: 'custom_world_profile',
rows: [
{
profile_id: 'world-profile-secret',
work_id: 'world-work-secret',
owner_user_id: 'world-owner-secret',
title: '世界作品',
profile_payload_json: '{"large":"' + 'y'.repeat(2000) + '"}',
},
],
},
{
name: 'public_work_play_daily_stat',
rows: [
{
source_type: 'puzzle',
profile_id: 'profile-real-aaa',
owner_user_id: 'owner-secret-123',
user_id: 'player-secret-456',
source_session_id: 'session-secret-789',
played_day: '2026-05-01',
play_count: 12,
updated_at: '2026-05-02T00:00:00Z',
},
],
},
{
name: 'user_account',
rows: [
{
user_id: 'owner-secret-123',
phone: '+8613800138000',
auth_token: 'auth-token-secret',
wallet_balance: 999,
},
],
},
{
name: 'refresh_session',
rows: [{ token: 'refresh-token-secret', source_session_id: 'session-secret-789' }],
},
{
name: 'profile_wallet_ledger',
rows: [{ wallet_id: 'wallet-secret', amount: 100 }],
},
],
};
async function withTempDir(fn) {
const dir = await mkdtemp(path.join(tmpdir(), 'works-list-test-'));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
describe('extractWorksListData', () => {
it('只保留作品 profile 白名单表,禁用的行为/敏感表不会出现在输出 JSON 字符串中', () => {
const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' });
const serialized = JSON.stringify(output);
expect(Object.keys(output.tables).sort()).toEqual([
'custom_world_profile',
'puzzle_work_profile',
]);
expect(serialized).not.toContain('public_work_play_daily_stat');
expect(serialized).not.toContain('user_account');
expect(serialized).not.toContain('refresh_session');
expect(serialized).not.toContain('profile_wallet_ledger');
expect(serialized).not.toContain('+8613800138000');
expect(serialized).not.toContain('auth-token-secret');
expect(serialized).not.toContain('wallet-secret');
});
it('不会输出 owner/user/session/auth/token/phone/wallet 等敏感原值owner 稳定映射', () => {
const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' });
const serialized = JSON.stringify(output);
for (const secret of [
'owner-secret-123',
'player-secret-456',
'session-secret-789',
'Alice Secret',
'author-code-secret',
'public-code-secret',
'asset-secret-cover',
'SECRET_TOKEN',
]) {
expect(serialized).not.toContain(secret);
}
expect(output.tables.puzzle_work_profile[0].owner_user_id).toBe('user-001');
expect(output.tables.puzzle_work_profile[1].owner_user_id).toBe('user-001');
expect(output.tables.puzzle_work_profile[0].author_display_name).toBe('author-001');
expect(serialized).not.toContain('level-token-value');
});
it('puzzle 数据生成 profileIds/workIds 和 normalizedWorks并保留列表展示字段', () => {
const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' });
expect(output.source).toBe('fixture.local.json');
expect(output.generatedAt).toEqual(expect.any(String));
expect(output.counts.puzzle_work_profile).toBe(2);
expect(output.profileIds.puzzle).toEqual(['profile-001', 'profile-002']);
expect(output.workIds.puzzle).toEqual(['work-001', 'work-002']);
expect(output.normalizedWorks).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'puzzle',
workId: 'work-001',
profileId: 'profile-001',
publicationStatus: 'published',
playCount: 42,
title: expect.any(String),
remixCount: 2,
}),
]),
);
expect(output.tables.puzzle_work_profile[0].cover_image_src).toBe('https://cdn.example.test/cover.png');
expect(output.tables.puzzle_work_profile[0].theme_tags_json).toBe('["化学家","实验室"]');
});
it('data image、URL token 和绝对输入路径不会泄露到输出', async () => {
await withTempDir(async (dir) => {
const input = path.join(dir, 'migration.local.json');
const output = path.join(dir, 'works-list.local.json');
await writeFile(
input,
JSON.stringify({
tables: [
{
name: 'puzzle_work_profile',
rows: [
{
profile_id: 'profile-real',
work_id: 'work-real',
cover_image_src: { some: 'data:image/png;base64,SECRET_IMAGE_BYTES' },
levels_json: JSON.stringify({ token: 'SECRET_TOKEN_VALUE', title: 'safe' }),
},
],
},
],
}),
'utf8',
);
await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output]);
const extracted = JSON.parse(await readFile(output, 'utf8'));
const serialized = JSON.stringify(extracted);
expect(extracted.source).toBe('migration.local.json');
expect(serialized).not.toContain(dir);
expect(serialized).not.toContain('SECRET_IMAGE_BYTES');
expect(serialized).not.toContain('SECRET_TOKEN_VALUE');
expect(extracted.tables.puzzle_work_profile[0].cover_image_src).toBe('[redacted-data-image]');
});
});
it('sample-output 只输出少量脱敏样例', async () => {
await withTempDir(async (dir) => {
const input = path.join(dir, 'migration.local.json');
const output = path.join(dir, 'works-list.local.json');
const sampleOutput = path.join(dir, 'works-list.sample.json');
const manyRows = Array.from({ length: 5 }, (_, index) => ({
profile_id: `profile-real-${index}`,
work_id: `work-real-${index}`,
owner_user_id: `owner-secret-${index}`,
title: `作品 ${index}`,
publication_status: 'published',
play_count: index,
}));
await writeFile(
input,
JSON.stringify({ tables: [{ name: 'puzzle_work_profile', rows: manyRows }] }),
'utf8',
);
await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output, '--sample-output', sampleOutput]);
const sample = JSON.parse(await readFile(sampleOutput, 'utf8'));
const serialized = JSON.stringify(sample);
expect(sample.tables.puzzle_work_profile).toHaveLength(3);
expect(sample.normalizedWorks).toHaveLength(3);
expect(serialized).not.toContain('owner-secret-0');
expect(serialized).not.toContain('work-real-0');
});
});
it('CLI 参数缺失时退出非 0 并输出清晰错误', async () => {
await expect(execFileAsync(process.execPath, [scriptPath, '--input', 'missing.json'])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining('--output'),
});
});
});

View File

@@ -0,0 +1,229 @@
/* global __ENV */
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import http from 'k6/http';
import { Rate, Trend } from 'k6/metrics';
// k6 resolves open() paths relative to this script file, not the shell cwd.
const DEFAULT_WORKS_DATA = 'data/works-list.local.json';
const WORKS_DATA = __ENV.WORKS_DATA || DEFAULT_WORKS_DATA;
const BASE_URL = (__ENV.BASE_URL || 'http://127.0.0.1:8787').replace(/\/+$/u, '');
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
const SCENARIO = __ENV.SCENARIO || 'smoke';
const REQUEST_TIMEOUT = __ENV.REQUEST_TIMEOUT || '30s';
const SLEEP_MIN_SECONDS = Number(__ENV.SLEEP_MIN_SECONDS || '0.5');
const SLEEP_MAX_SECONDS = Number(__ENV.SLEEP_MAX_SECONDS || '2');
const DETAIL_RATIO = Number(__ENV.DETAIL_RATIO || '0');
const worksListShapeErrorRate = new Rate('works_list_shape_error_rate');
const worksDetailShapeErrorRate = new Rate('works_detail_shape_error_rate');
const worksListDuration = new Trend('works_list_duration');
const worksDetailDuration = new Trend('works_detail_duration');
const data = new SharedArray('works-list-data', () => [JSON.parse(open(WORKS_DATA))])[0];
const normalizedWorks = Array.isArray(data.normalizedWorks) ? data.normalizedWorks : [];
const scenarioOptions = {
smoke: {
scenarios: {
smoke: {
executor: 'constant-vus',
vus: Number(__ENV.VUS || 1),
duration: __ENV.DURATION || '30s',
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800'],
works_list_shape_error_rate: ['rate<0.01'],
},
},
baseline: {
scenarios: {
baseline: {
executor: 'constant-vus',
vus: Number(__ENV.VUS || 10),
duration: __ENV.DURATION || '3m',
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800', 'p(99)<1500'],
works_list_shape_error_rate: ['rate<0.01'],
},
},
spike: {
scenarios: {
spike: {
executor: 'ramping-arrival-rate',
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
maxVUs: Number(__ENV.MAX_VUS || 200),
timeUnit: '1s',
stages: [
{ target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' },
{ target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' },
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
],
},
},
thresholds: {
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(95)<2000'],
works_list_shape_error_rate: ['rate<0.05'],
},
},
};
export const options = scenarioOptions[SCENARIO] || scenarioOptions.smoke;
const PUBLIC_ENDPOINTS = [
{
name: 'puzzle_gallery_list',
method: 'GET',
path: '/api/runtime/puzzle/gallery',
expectCollectionKeys: ['items', 'works', 'entries'],
},
{
name: 'custom_world_gallery_list',
method: 'GET',
path: '/api/runtime/custom-world-gallery',
expectCollectionKeys: ['entries', 'items', 'works'],
},
];
const AUTH_ENDPOINTS = [
{
name: 'puzzle_works_list',
method: 'GET',
path: '/api/runtime/puzzle/works',
expectCollectionKeys: ['items', 'works'],
},
{
name: 'custom_world_works_list',
method: 'GET',
path: '/api/runtime/custom-world/works',
expectCollectionKeys: ['items', 'entries', 'works'],
},
];
function requestParams(endpointName) {
const headers = { 'x-genarrative-response-envelope': 'v1' };
if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`;
return {
headers,
timeout: REQUEST_TIMEOUT,
tags: { endpoint: endpointName },
};
}
function buildUrl(path) {
return `${BASE_URL}${path}`;
}
function parseJson(response) {
try {
return response.json();
} catch (_) {
return null;
}
}
function unwrapPayload(json) {
if (!json || typeof json !== 'object') return null;
if (json.data && typeof json.data === 'object') return json.data;
return json;
}
function hasCollection(payload, keys) {
return keys.some((key) => Array.isArray(payload?.[key]));
}
function firstCollection(payload, keys) {
for (const key of keys) {
if (Array.isArray(payload?.[key])) return payload[key];
}
return [];
}
function hasListItemShape(payload, keys) {
const collection = firstCollection(payload, keys);
if (collection.length === 0) return true;
const item = collection[0];
const hasId = Boolean(
item?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode,
);
const hasTitle = Boolean(
item?.title || item?.workTitle || item?.work_title || item?.levelName || item?.worldName,
);
return hasId && hasTitle;
}
function randomItem(items) {
if (!items.length) return null;
return items[Math.floor(Math.random() * items.length)];
}
function listEndpoints() {
return AUTH_TOKEN ? PUBLIC_ENDPOINTS.concat(AUTH_ENDPOINTS) : PUBLIC_ENDPOINTS;
}
function detailEndpointFor(work) {
if (!work || !work.profileId) return null;
if (work.type === 'puzzle') {
return {
name: 'puzzle_gallery_detail',
path: `/api/runtime/puzzle/gallery/${encodeURIComponent(work.profileId)}`,
expectKeys: ['item', 'work', 'entry'],
};
}
if (work.type === 'customWorld' && work.profileId && work.ownerUserId) {
return {
name: 'custom_world_gallery_detail',
path: `/api/runtime/custom-world-gallery/${encodeURIComponent(work.ownerUserId)}/${encodeURIComponent(work.profileId)}`,
expectKeys: ['entry', 'item', 'work'],
};
}
return null;
}
function performListRequest(endpoint) {
const url = buildUrl(endpoint.path);
const response = http.request(endpoint.method, url, null, requestParams(endpoint.name));
worksListDuration.add(response.timings.duration, { endpoint: endpoint.name });
const json = parseJson(response);
const payload = unwrapPayload(json);
const ok = check(response, {
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
[`${endpoint.name} returns json object`]: () => Boolean(payload),
[`${endpoint.name} has collection`]: () => hasCollection(payload, endpoint.expectCollectionKeys),
[`${endpoint.name} list item shape`]: () => hasListItemShape(payload, endpoint.expectCollectionKeys),
});
worksListShapeErrorRate.add(!ok, { endpoint: endpoint.name });
}
function performDetailRequest() {
const endpoint = detailEndpointFor(randomItem(normalizedWorks));
if (!endpoint) return;
const response = http.get(buildUrl(endpoint.path), requestParams(endpoint.name));
worksDetailDuration.add(response.timings.duration, { endpoint: endpoint.name });
const json = parseJson(response);
const payload = unwrapPayload(json);
const ok = check(response, {
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
[`${endpoint.name} has detail payload`]: () => endpoint.expectKeys.some((key) => payload?.[key]),
});
worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name });
}
export default function () {
for (const endpoint of listEndpoints()) {
performListRequest(endpoint);
}
if (normalizedWorks.length && DETAIL_RATIO > 0 && Math.random() < DETAIL_RATIO) {
performDetailRequest();
}
const jitter = SLEEP_MIN_SECONDS + Math.random() * Math.max(0, SLEEP_MAX_SECONDS - SLEEP_MIN_SECONDS);
sleep(jitter);
}

1
server-rs/Cargo.lock generated
View File

@@ -3009,7 +3009,6 @@ dependencies = [
name = "shared-contracts"
version = "0.1.0"
dependencies = [
"platform-oss",
"serde",
"serde_json",
]

View File

@@ -19,13 +19,12 @@ use serde::Deserialize;
use serde_json::{Map, Value};
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminUpsertCreationEntryTypeConfigRequest,
AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload,
AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest,
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse,
AdminTrackingEventListResponse, AdminUpsertCreationEntryTypeConfigRequest,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -196,13 +195,15 @@ pub async fn admin_list_database_table_rows(
Ok(json_success_body(Some(&request_context), response))
}
pub async fn admin_get_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, AppError> {
let config = state.get_creation_entry_config().await.map_err(map_admin_spacetime_error)?;
let config = state
.get_creation_entry_config()
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
@@ -1328,8 +1329,10 @@ mod tests {
use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminUpsertCreationEntryTypeConfigRequest,AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
AdminUpsertCreationEntryTypeConfigRequest,
};
#[test]
fn normalize_debug_path_rejects_absolute_url() {

View File

@@ -5,7 +5,7 @@ use axum::{
http::Request,
middleware,
response::Response,
routing::{delete, get, post},
routing::{delete, get, post, put},
};
use tower_http::{
classify::ServerErrorsFailureClass,
@@ -16,8 +16,8 @@ use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, admin_overview,
admin_upsert_creation_entry_config, require_admin_auth,
admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me,
admin_overview, admin_upsert_creation_entry_config, require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
@@ -90,10 +90,10 @@ use crate::{
match3d::{
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run,
start_match3d_run, stop_match3d_run, stream_match3d_agent_message,
submit_match3d_agent_message,
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work,
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry,
password_management::{change_password, reset_password},
@@ -156,7 +156,9 @@ use crate::{
},
tracking::record_route_tracking_event_after_success,
vector_engine_audio_generation::{
create_background_music_task, create_sound_effect_task,
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
publish_background_music_asset, publish_sound_effect_asset,
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
},
visual_novel::{
@@ -924,6 +926,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/tags",
post(generate_match3d_work_tags).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}",
get(get_match3d_work_detail)
@@ -935,6 +944,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/audio-assets",
put(put_match3d_audio_assets).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/publish",
post(publish_match3d_work).route_layer(middleware::from_fn_with_state(
@@ -1510,7 +1526,7 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route("/api/auth/password/reset", post(reset_password))
// 后端 runtime/API 路由读取入口配置做统一熔断,避免前端隐藏后后端仍可被直接访问
// 后端创作/运行态 API 路由只按 open 做熔断visible 仅控制创作页入口展示
.layer(middleware::from_fn_with_state(
state.clone(),
require_creation_entry_route_enabled,
@@ -1763,6 +1779,34 @@ fn visual_novel_router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/audio/background-music",
post(create_background_music_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/background-music/{task_id}/asset",
post(publish_background_music_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/sound-effect",
post(create_sound_effect_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/sound-effect/{task_id}/asset",
post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/gallery",
get(list_visual_novel_gallery),
@@ -1993,10 +2037,45 @@ mod tests {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = read_json_response(response).await;
assert_eq!(body["error"]["details"]["reason"], "creation_entry_disabled");
assert_eq!(
body["error"]["details"]["reason"],
"creation_entry_disabled"
);
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
}
#[tokio::test]
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/creation/visual-novel/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"sourceMode": "idea",
"seedText": "雨夜书店",
"sourceAssetIds": []
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = read_json_response(response).await;
assert_eq!(
body["error"]["details"]["reason"],
"creation_entry_disabled"
);
assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel");
}
#[tokio::test]
async fn healthz_returns_standard_envelope_when_requested() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -4683,7 +4762,9 @@ mod tests {
#[tokio::test]
async fn visual_novel_creation_route_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
state.set_test_creation_entry_route_enabled("visual-novel", true);
let app = build_router(state);
let response = app
.oneshot(

View File

@@ -19,8 +19,9 @@ use shared_contracts::assets::{
AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery,
AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload,
GetAssetReadUrlResponse, GetReadUrlQuery,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadObjectAccess,
DirectUploadTicketFormFields, DirectUploadTicketPayload, GetAssetReadUrlResponse,
GetReadUrlQuery,
};
use spacetime_client::SpacetimeClientError;
@@ -44,7 +45,8 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
"square_hole_shape_image",
"square_hole_hole_image",
];
const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 10 * 1024 * 1024;
// 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览Rodin GLB 可能明显超过图片上限。
const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024;
const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300;
pub async fn create_direct_upload_ticket(
@@ -73,7 +75,10 @@ pub async fn create_direct_upload_ticket(
path_segments: payload.path_segments,
file_name: payload.file_name,
content_type: payload.content_type,
access: payload.access.unwrap_or(OssObjectAccess::Private),
access: payload
.access
.map(direct_upload_access_to_oss)
.unwrap_or(OssObjectAccess::Private),
metadata: payload.metadata,
max_size_bytes: payload.max_size_bytes,
expire_seconds: payload.expire_seconds,
@@ -85,7 +90,7 @@ pub async fn create_direct_upload_ticket(
"message": error.to_string(),
}))
})?;
let upload = DirectUploadTicketPayload::from(signed);
let upload = direct_upload_ticket_payload_from_oss(signed);
record_asset_tracking_event(
&state,
@@ -149,11 +154,76 @@ pub async fn get_asset_read_url(
Ok(json_success_body(
Some(&request_context),
GetAssetReadUrlResponse {
read: AssetReadUrlPayload::from(signed),
read: asset_read_url_payload_from_oss(signed),
},
))
}
fn direct_upload_access_to_oss(value: DirectUploadObjectAccess) -> OssObjectAccess {
match value {
DirectUploadObjectAccess::Public => OssObjectAccess::Public,
DirectUploadObjectAccess::Private => OssObjectAccess::Private,
}
}
fn direct_upload_access_from_oss(value: OssObjectAccess) -> DirectUploadObjectAccess {
match value {
OssObjectAccess::Public => DirectUploadObjectAccess::Public,
OssObjectAccess::Private => DirectUploadObjectAccess::Private,
}
}
fn direct_upload_ticket_payload_from_oss(
value: platform_oss::OssPostObjectResponse,
) -> DirectUploadTicketPayload {
DirectUploadTicketPayload {
signature_version: value.signature_version.to_string(),
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
legacy_public_path: value.legacy_public_path,
content_type: value.content_type,
access: direct_upload_access_from_oss(value.access),
key_prefix: value.key_prefix,
expires_at: value.expires_at,
max_size_bytes: value.max_size_bytes,
success_action_status: value.success_action_status,
form_fields: direct_upload_ticket_form_fields_from_oss(value.form_fields),
}
}
fn direct_upload_ticket_form_fields_from_oss(
value: platform_oss::OssPostObjectFormFields,
) -> DirectUploadTicketFormFields {
DirectUploadTicketFormFields {
key: value.key,
policy: value.policy,
signature_version: value.signature_version,
credential: value.credential,
date: value.date,
signature: value.signature,
success_action_status: value.success_action_status,
content_type: value.content_type,
metadata: value.metadata,
}
}
fn asset_read_url_payload_from_oss(
value: platform_oss::OssSignedGetObjectUrlResponse,
) -> AssetReadUrlPayload {
AssetReadUrlPayload {
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
expires_at: value.expires_at,
signed_url: value.signed_url,
}
}
pub async fn get_asset_read_bytes(
State(state): State<AppState>,
Query(query): Query<GetReadUrlQuery>,

View File

@@ -8,6 +8,9 @@ use axum::{
};
use serde_json::{Value, json};
#[cfg(test)]
use module_runtime::build_creation_entry_config_response;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
@@ -31,7 +34,7 @@ pub async fn get_creation_entry_config_handler(
Ok(json_success_body(Some(&request_context), config))
}
/// 中文注释api-server 路由熔断只拦运行态/API 请求,不改变前端入口展示规则。
/// 中文注释api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。
pub async fn require_creation_entry_route_enabled(
State(state): State<AppState>,
request: Request<Body>,
@@ -84,6 +87,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
if normalized.starts_with("/api/runtime/visual-novel") {
return Some("visual-novel");
}
if normalized.starts_with("/api/creation/visual-novel") {
return Some("visual-novel");
}
None
}
@@ -92,8 +98,8 @@ fn creation_entry_error_response(request_context: &RequestContext, error: AppErr
}
#[cfg(test)]
pub(crate) fn test_creation_entry_config_response(
) -> shared_contracts::creation_entry_config::CreationEntryConfigResponse {
pub(crate) fn test_creation_entry_config_response()
-> shared_contracts::creation_entry_config::CreationEntryConfigResponse {
build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot {
config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
start_card: module_runtime::CreationEntryStartCardSnapshot {
@@ -111,8 +117,8 @@ pub(crate) fn test_creation_entry_config_response(
test_creation_type("big-fish", false, true, 20),
test_creation_type("puzzle", true, true, 30),
test_creation_type("match3d", true, true, 40),
test_creation_type("square-hole", true, true, 50),
test_creation_type("visual-novel", true, true, 60),
test_creation_type("square-hole", false, true, 50),
test_creation_type("visual-novel", true, false, 60),
test_creation_type("airp", true, false, 70),
test_creation_type("creative-agent", false, true, 80),
],
@@ -162,6 +168,10 @@ mod tests {
resolve_creation_entry_route_id("/api/runtime/visual-novel/works"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
}

View File

@@ -89,6 +89,14 @@ impl IntoResponse for AppError {
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.body_text().as_str())
}
}
impl std::error::Error for AppError {}
impl From<AppError> for Response {
fn from(error: AppError) -> Self {
error.into_response()

View File

@@ -231,12 +231,7 @@ pub(crate) async fn query_task_status(
.await?;
let jobs = extract_job_statuses(&response);
let status = normalize_task_status(
find_first_string_by_key(&response, "status")
.or_else(|| jobs.first().map(|job| job.status.clone()))
.as_deref()
.unwrap_or("unknown"),
);
let status = resolve_hyper3d_overall_status(&response, &jobs);
Ok(contract::Hyper3dTaskStatusResponse {
ok: true,
@@ -539,13 +534,38 @@ fn extract_job_statuses(payload: &Value) -> Vec<contract::Hyper3dJobStatusPayloa
.collect()
}
fn resolve_hyper3d_overall_status(
payload: &Value,
jobs: &[contract::Hyper3dJobStatusPayload],
) -> String {
if !jobs.is_empty() {
if jobs.iter().any(|job| job.status == "failed") {
return "failed".to_string();
}
if jobs.iter().all(|job| job.status == "done") {
return "done".to_string();
}
if jobs.iter().any(|job| job.status == "generating") {
return "generating".to_string();
}
if jobs.iter().any(|job| job.status == "waiting") {
return "waiting".to_string();
}
return "unknown".to_string();
}
normalize_task_status(
find_first_string_by_key(payload, "status")
.as_deref()
.unwrap_or("unknown"),
)
}
fn extract_job_uuids(payload: &Value) -> Vec<String> {
let mut job_uuids = Vec::new();
if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) {
for job in jobs {
if let Some(uuid) = find_first_string_by_keys(job, &["uuid", "task_uuid", "taskUuid"])
&& !job_uuids.contains(&uuid)
{
if let Some(jobs) = payload.get("jobs") {
for uuid in collect_strings_by_keys(jobs, &["uuid", "task_uuid", "taskUuid", "uuids"]) {
if !job_uuids.contains(&uuid) {
job_uuids.push(uuid);
}
}
@@ -580,6 +600,12 @@ fn collect_download_files(value: &Value, output: &mut Vec<contract::Hyper3dDownl
.get("url")
.or_else(|| object.get("download_url"))
.or_else(|| object.get("downloadUrl"))
.or_else(|| object.get("file_url"))
.or_else(|| object.get("fileUrl"))
.or_else(|| object.get("signed_url"))
.or_else(|| object.get("signedUrl"))
.or_else(|| object.get("presigned_url"))
.or_else(|| object.get("presignedUrl"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with("http://") || value.starts_with("https://"));
@@ -588,6 +614,9 @@ fn collect_download_files(value: &Value, output: &mut Vec<contract::Hyper3dDownl
.get("name")
.or_else(|| object.get("file_name"))
.or_else(|| object.get("filename"))
.or_else(|| object.get("fileName"))
.or_else(|| object.get("display_name"))
.or_else(|| object.get("displayName"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -1045,8 +1074,10 @@ mod tests {
contract::Hyper3dGenerationMode::TextToModel,
json!({
"uuid": "task-1",
"subscription_key": "sub-1",
"jobs": [{ "uuid": "job-1" }],
"jobs": {
"uuids": ["job-1", "job-2"],
"subscription_key": "sub-1"
},
"message": "submitted"
}),
)
@@ -1054,7 +1085,7 @@ mod tests {
assert_eq!(response.task_uuid, "task-1");
assert_eq!(response.subscription_key, "sub-1");
assert_eq!(response.job_uuids, vec!["job-1"]);
assert_eq!(response.job_uuids, vec!["job-1", "job-2"]);
}
#[test]
@@ -1070,6 +1101,28 @@ mod tests {
assert_eq!(files[0].name, "model.glb");
}
#[test]
fn extracts_download_files_from_file_url_aliases() {
let files = extract_download_files(&json!({
"result": {
"files": [
{
"fileName": "rodin-result.glb",
"fileUrl": "https://cdn.example/rodin-result.glb?token=1"
},
{
"displayName": "preview.png",
"signedUrl": "https://cdn.example/preview.png?token=1"
}
]
}
}));
assert_eq!(files.len(), 2);
assert_eq!(files[0].name, "rodin-result.glb");
assert_eq!(files[0].url, "https://cdn.example/rodin-result.glb?token=1");
}
#[test]
fn normalizes_status_values() {
assert_eq!(normalize_task_status("Waiting"), "waiting");
@@ -1077,4 +1130,34 @@ mod tests {
assert_eq!(normalize_task_status("Done"), "done");
assert_eq!(normalize_task_status("Failed"), "failed");
}
#[test]
fn resolves_status_done_only_when_all_jobs_done() {
let jobs = extract_job_statuses(&json!({
"jobs": [
{ "uuid": "preview", "status": "Done" },
{ "uuid": "model", "status": "Generating" }
]
}));
assert_eq!(
resolve_hyper3d_overall_status(&json!({ "status": "Done" }), &jobs),
"generating"
);
}
#[test]
fn resolves_status_failed_when_any_job_failed() {
let jobs = extract_job_statuses(&json!({
"jobs": [
{ "uuid": "preview", "status": "Done" },
{ "uuid": "model", "status": "Failed", "message": "bad input" }
]
}));
assert_eq!(
resolve_hyper3d_overall_status(&json!({ "status": "Generating" }), &jobs),
"failed"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ use platform_oss::{
};
use serde_json::{Map, Value, json};
use shared_contracts::{
creation_audio::CreationAudioAsset,
puzzle_agent::{
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
@@ -56,7 +57,7 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
@@ -228,6 +229,7 @@ pub async fn generate_puzzle_onboarding_work(
level_name: level_name.clone(),
picture_description: prompt_text.clone(),
picture_reference: None,
background_music: None,
candidates,
selected_candidate_id: Some(selected.candidate_id.clone()),
cover_image_src: Some(selected.image_src.clone()),
@@ -2059,6 +2061,7 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
level_name: level.level_name,
picture_description: level.picture_description,
picture_reference: level.picture_reference,
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
candidates: level
.candidates
.into_iter()
@@ -2071,6 +2074,70 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
}
}
fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset {
CreationAudioAsset {
task_id: asset.task_id,
provider: asset.provider,
asset_object_id: asset.asset_object_id,
asset_kind: asset.asset_kind,
audio_src: asset.audio_src,
prompt: asset.prompt,
title: asset.title,
updated_at: asset.updated_at,
}
}
fn map_puzzle_audio_asset_domain_record(
asset: module_puzzle::PuzzleAudioAsset,
) -> PuzzleAudioAssetRecord {
PuzzleAudioAssetRecord {
task_id: asset.task_id,
provider: asset.provider,
asset_object_id: asset.asset_object_id,
asset_kind: asset.asset_kind,
audio_src: asset.audio_src,
prompt: asset.prompt,
title: asset.title,
updated_at: asset.updated_at,
}
}
fn puzzle_audio_asset_response_module_json(asset: &Option<CreationAudioAsset>) -> Value {
asset
.as_ref()
.map(|asset| {
json!({
"task_id": asset.task_id,
"provider": asset.provider,
"asset_object_id": asset.asset_object_id,
"asset_kind": asset.asset_kind,
"audio_src": asset.audio_src,
"prompt": asset.prompt,
"title": asset.title,
"updated_at": asset.updated_at,
})
})
.unwrap_or(Value::Null)
}
fn puzzle_audio_asset_record_module_json(asset: &Option<PuzzleAudioAssetRecord>) -> Value {
asset
.as_ref()
.map(|asset| {
json!({
"task_id": asset.task_id,
"provider": asset.provider,
"asset_object_id": asset.asset_object_id,
"asset_kind": asset.asset_kind,
"audio_src": asset.audio_src,
"prompt": asset.prompt,
"title": asset.title,
"updated_at": asset.updated_at,
})
})
.unwrap_or(Value::Null)
}
fn map_puzzle_creator_intent_response(
intent: PuzzleCreatorIntentRecord,
) -> PuzzleCreatorIntentResponse {
@@ -2600,6 +2667,7 @@ fn parse_puzzle_level_records_from_module_json(
level_name: level.level_name,
picture_description: level.picture_description,
picture_reference: level.picture_reference,
background_music: level.background_music.map(map_puzzle_audio_asset_domain_record),
candidates: level
.candidates
.into_iter()
@@ -2767,6 +2835,7 @@ fn serialize_puzzle_levels_response(
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
"candidates": level
.candidates
.iter()
@@ -2815,6 +2884,7 @@ fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result<Option
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
"candidates": level
.candidates
.iter()
@@ -3806,6 +3876,8 @@ fn serialize_puzzle_level_records_for_module(
"level_id": level.level_id,
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
"candidates": level
.candidates
.iter()
@@ -4504,6 +4576,65 @@ mod tests {
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
}
#[test]
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
background_music: Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("puzzle_background_music".to_string()),
audio_src: "/generated-puzzle-assets/audio.mp3".to_string(),
prompt: Some("轻快拼图音乐".to_string()),
title: Some("雨夜猫街背景音乐".to_string()),
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
}),
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "ready".to_string(),
};
let request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value =
serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["background_music"]["audio_src"],
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
);
assert!(payload[0]["background_music"].get("audioSrc").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
let music = records[0]
.background_music
.as_ref()
.expect("background music should exist");
assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3");
assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music"));
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response
.background_music
.as_ref()
.map(|asset| asset.audio_src.as_str()),
Some("/generated-puzzle-assets/audio.mp3")
);
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
let item = PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
@@ -4542,6 +4673,7 @@ mod tests {
level_name: "猫画面".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
background_music: None,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,

View File

@@ -675,7 +675,6 @@ pub async fn admin_upsert_profile_invite_code(
admin.session().username.clone(),
payload.invite_code,
metadata_json,
payload.granted_user_tags,
starts_at_micros,
expires_at_micros,
updated_at_micros as i64,
@@ -1124,7 +1123,6 @@ fn build_profile_invite_code_admin_response(
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
granted_user_tags: record.granted_user_tags,
starts_at: record.starts_at,
expires_at: record.expires_at,
status: record.status.as_str().to_string(),

View File

@@ -11,7 +11,6 @@ use module_auth::{
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
use module_runtime::RuntimeSnapshotRecord;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_agent::MockLangChainRustAgentExecutor;
@@ -23,6 +22,7 @@ use platform_auth::{
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
@@ -274,7 +274,7 @@ impl AppState {
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.map(|item| item.visible && item.open)
.map(|item| item.open)
.unwrap_or(true))
}
@@ -291,7 +291,6 @@ impl AppState {
.iter_mut()
.find(|item| item.id == creation_type_id)
{
item.visible = enabled;
item.open = enabled;
} else {
config.creation_types.push(

View File

@@ -13,7 +13,7 @@ use module_assets::{
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use reqwest::header;
use serde_json::{Map, Value, json};
use shared_contracts::visual_novel as contract;
use shared_contracts::{creation_audio, visual_novel as contract};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -51,6 +51,17 @@ struct DownloadedAudio {
extension: String,
}
#[derive(Clone, Debug)]
struct AudioAssetBindingTarget {
entity_kind: String,
entity_id: String,
slot: String,
asset_kind: String,
profile_id: Option<String>,
storage_prefix: LegacyAssetPrefix,
storage_scope: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioAssetSlot {
BackgroundMusic,
@@ -58,13 +69,6 @@ enum AudioAssetSlot {
}
impl AudioAssetSlot {
fn contract_kind(self) -> contract::VisualNovelAudioGenerationKind {
match self {
Self::BackgroundMusic => contract::VisualNovelAudioGenerationKind::BackgroundMusic,
Self::SoundEffect => contract::VisualNovelAudioGenerationKind::SoundEffect,
}
}
fn provider(self) -> &'static str {
match self {
Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER,
@@ -92,6 +96,13 @@ impl AudioAssetSlot {
Self::SoundEffect => "sound-effect",
}
}
fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind {
match self {
Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic,
Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect,
}
}
}
pub async fn create_visual_novel_background_music_task(
@@ -148,6 +159,25 @@ pub async fn create_visual_novel_background_music_task(
))
}
pub async fn create_background_music_task(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
create_background_music_task_response(
&state,
&request_context,
payload.prompt,
payload.title,
payload.tags,
payload.model,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub async fn create_visual_novel_sound_effect_task(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
@@ -198,6 +228,116 @@ pub async fn create_visual_novel_sound_effect_task(
))
}
pub async fn create_sound_effect_task(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
create_sound_effect_task_response(&state, payload.prompt, payload.duration, payload.seed)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
async fn create_background_music_task_response(
state: &AppState,
_request_context: &RequestContext,
prompt: String,
title: String,
tags: Option<String>,
model: Option<String>,
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
let settings = require_vector_engine_audio_settings(state)?;
let http_client = build_vector_engine_audio_http_client(&settings)?;
let prompt = normalize_limited_text(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let tags = tags
.as_deref()
.map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS))
.transpose()?;
let model =
normalize_optional_text(model.as_deref()).unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
let mut body = Map::from_iter([
("prompt".to_string(), Value::String(prompt)),
("mv".to_string(), Value::String(model)),
("title".to_string(), Value::String(title)),
("task".to_string(), Value::String("generate".to_string())),
]);
if let Some(tags) = tags {
body.insert("tags".to_string(), Value::String(tags));
}
let response = post_vector_engine_json(
&http_client,
&settings,
"/suno/submit/music",
Value::Object(body),
"提交 Suno 背景音乐任务失败",
)
.await?;
let task_id = extract_string_by_path(&response, &["data"])
.or_else(|| find_first_string_by_key(&response, "task_id"))
.or_else(|| find_first_string_by_key(&response, "taskId"))
.ok_or_else(|| {
vector_engine_bad_gateway("提交 Suno 背景音乐任务失败:上游未返回任务 ID")
})?;
Ok(creation_audio::AudioGenerationTaskResponse {
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
task_id,
provider: VECTOR_ENGINE_SUNO_PROVIDER.to_string(),
status: "submitted".to_string(),
})
}
async fn create_sound_effect_task_response(
state: &AppState,
prompt: String,
duration: Option<u8>,
seed: Option<u64>,
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
let settings = require_vector_engine_audio_settings(state)?;
let http_client = build_vector_engine_audio_http_client(&settings)?;
let prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let duration = duration
.unwrap_or(DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
.clamp(2, 10);
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(VIDU_AUDIO_MODEL.to_string()),
),
("prompt".to_string(), Value::String(prompt)),
("duration".to_string(), json!(duration)),
]);
if let Some(seed) = seed {
body.insert("seed".to_string(), json!(seed));
}
let response = post_vector_engine_json(
&http_client,
&settings,
"/ent/v2/text2audio",
Value::Object(body),
"提交 Vidu 音效任务失败",
)
.await?;
let task_id = find_first_string_by_key(&response, "task_id")
.or_else(|| find_first_string_by_key(&response, "taskId"))
.ok_or_else(|| vector_engine_bad_gateway("提交 Vidu 音效任务失败:上游未返回任务 ID"))?;
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
Ok(creation_audio::AudioGenerationTaskResponse {
kind: creation_audio::CreationAudioGenerationKind::SoundEffect,
task_id,
provider: VECTOR_ENGINE_VIDU_PROVIDER.to_string(),
status,
})
}
pub async fn publish_visual_novel_background_music_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
@@ -205,16 +345,30 @@ pub async fn publish_visual_novel_background_music_asset(
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?;
publish_generated_audio_asset(
&state,
&request_context,
authenticated.claims().user_id(),
task_id,
parse_json_payload(&request_context, payload)?.0,
AudioAssetSlot::BackgroundMusic,
target,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map(|payload| {
json_success_body(
Some(&request_context),
contract::VisualNovelGeneratedAudioAssetResponse {
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
task_id: payload.task_id,
provider: payload.provider,
status: payload.status,
asset_object_id: payload.asset_object_id,
asset_kind: payload.asset_kind,
audio_src: payload.audio_src,
},
)
})
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
@@ -225,13 +379,69 @@ pub async fn publish_visual_novel_sound_effect_asset(
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?;
publish_generated_audio_asset(
&state,
&request_context,
authenticated.claims().user_id(),
task_id,
parse_json_payload(&request_context, payload)?.0,
AudioAssetSlot::SoundEffect,
target,
)
.await
.map(|payload| {
json_success_body(
Some(&request_context),
contract::VisualNovelGeneratedAudioAssetResponse {
kind: contract::VisualNovelAudioGenerationKind::SoundEffect,
task_id: payload.task_id,
provider: payload.provider,
status: payload.status,
asset_object_id: payload.asset_object_id,
asset_kind: payload.asset_kind,
audio_src: payload.audio_src,
},
)
})
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub async fn publish_background_music_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_creation_audio_target(payload)?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::BackgroundMusic,
target,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub async fn publish_sound_effect_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_creation_audio_target(payload)?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::SoundEffect,
target,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
@@ -240,15 +450,12 @@ pub async fn publish_visual_novel_sound_effect_asset(
async fn publish_generated_audio_asset(
state: &AppState,
_request_context: &RequestContext,
owner_user_id: &str,
task_id: String,
payload: contract::PublishVisualNovelGeneratedAudioAssetRequest,
slot: AudioAssetSlot,
) -> Result<contract::VisualNovelGeneratedAudioAssetResponse, AppError> {
target: AudioAssetBindingTarget,
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
let task_id = normalize_limited_text(&task_id, "taskId", 160)?;
let scene_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?;
let profile_id = normalize_optional_text(payload.profile_id.as_deref());
let settings = require_vector_engine_audio_settings(state)?;
let http_client = build_vector_engine_audio_http_client(&settings)?;
let task_payload = fetch_audio_task_payload(&http_client, &settings, slot, &task_id).await?;
@@ -277,8 +484,8 @@ async fn publish_generated_audio_asset(
}
if is_pending_task_status(&status) && audio_urls.is_empty() {
return Ok(contract::VisualNovelGeneratedAudioAssetResponse {
kind: slot.contract_kind(),
return Ok(creation_audio::GeneratedAudioAssetResponse {
kind: slot.creation_contract_kind(),
task_id,
provider: slot.provider().to_string(),
status,
@@ -303,21 +510,20 @@ async fn publish_generated_audio_asset(
state,
&http_client,
owner_user_id,
profile_id,
scene_id,
&task_id,
slot,
target.clone(),
audio,
)
.await?;
Ok(contract::VisualNovelGeneratedAudioAssetResponse {
kind: slot.contract_kind(),
Ok(creation_audio::GeneratedAudioAssetResponse {
kind: slot.creation_contract_kind(),
task_id,
provider: slot.provider().to_string(),
status: "completed".to_string(),
asset_object_id: Some(persisted.asset_object_id),
asset_kind: Some(slot.asset_kind().to_string()),
asset_kind: Some(target.asset_kind),
audio_src: Some(persisted.audio_src),
})
}
@@ -360,10 +566,9 @@ async fn persist_generated_audio_asset(
state: &AppState,
http_client: &reqwest::Client,
owner_user_id: &str,
profile_id: Option<String>,
scene_id: String,
task_id: &str,
slot: AudioAssetSlot,
target: AudioAssetBindingTarget,
audio: DownloadedAudio,
) -> Result<PersistedAudioAsset, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
@@ -378,20 +583,26 @@ async fn persist_generated_audio_asset(
.put_object(
http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::CustomWorldScenes,
prefix: target.storage_prefix,
path_segments: vec![
"visual-novel".to_string(),
profile_id.clone().unwrap_or_else(|| "draft".to_string()),
scene_id.clone(),
slot.slot().to_string(),
],
target.storage_scope.clone(),
target
.profile_id
.clone()
.unwrap_or_else(|| "draft".to_string()),
target.entity_id.clone(),
target.slot.clone(),
]
.into_iter()
.map(|segment| sanitize_audio_path_segment(segment.as_str(), "audio"))
.collect(),
file_name,
content_type: Some(audio.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_audio_asset_metadata(
owner_user_id,
profile_id.as_deref(),
&scene_id,
target.profile_id.as_deref(),
&target,
slot,
),
body: audio.bytes,
@@ -420,11 +631,11 @@ async fn persist_generated_audio_asset(
head.content_type.or(Some(audio.mime_type)),
head.content_length,
head.etag,
slot.asset_kind().to_string(),
target.asset_kind.clone(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
profile_id.clone(),
Some(scene_id.clone()),
target.profile_id.clone(),
Some(target.entity_id.clone()),
now_micros,
)
.map_err(map_asset_field_error)?,
@@ -437,12 +648,12 @@ async fn persist_generated_audio_asset(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id.clone(),
AUDIO_ENTITY_KIND.to_string(),
scene_id,
slot.slot().to_string(),
slot.asset_kind().to_string(),
target.entity_kind,
target.entity_id,
target.slot,
target.asset_kind,
Some(owner_user_id.to_string()),
profile_id,
target.profile_id,
now_micros,
)
.map_err(map_asset_field_error)?,
@@ -459,15 +670,15 @@ async fn persist_generated_audio_asset(
fn build_audio_asset_metadata(
owner_user_id: &str,
profile_id: Option<&str>,
scene_id: &str,
target: &AudioAssetBindingTarget,
slot: AudioAssetSlot,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
("asset-kind".to_string(), slot.asset_kind().to_string()),
("asset-kind".to_string(), target.asset_kind.clone()),
("owner-user-id".to_string(), owner_user_id.to_string()),
("entity-kind".to_string(), AUDIO_ENTITY_KIND.to_string()),
("entity-id".to_string(), scene_id.to_string()),
("slot".to_string(), slot.slot().to_string()),
("entity-kind".to_string(), target.entity_kind.clone()),
("entity-id".to_string(), target.entity_id.clone()),
("slot".to_string(), target.slot.clone()),
("provider".to_string(), slot.provider().to_string()),
]);
if let Some(profile_id) = profile_id {
@@ -476,6 +687,51 @@ fn build_audio_asset_metadata(
metadata
}
fn build_visual_novel_audio_target(
payload: contract::PublishVisualNovelGeneratedAudioAssetRequest,
slot: AudioAssetSlot,
) -> Result<AudioAssetBindingTarget, AppError> {
let entity_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?;
Ok(AudioAssetBindingTarget {
entity_kind: AUDIO_ENTITY_KIND.to_string(),
entity_id,
slot: slot.slot().to_string(),
asset_kind: slot.asset_kind().to_string(),
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
storage_prefix: LegacyAssetPrefix::CustomWorldScenes,
storage_scope: "visual-novel".to_string(),
})
}
fn build_creation_audio_target(
payload: creation_audio::PublishGeneratedAudioAssetRequest,
) -> Result<AudioAssetBindingTarget, AppError> {
let entity_kind = normalize_limited_text(&payload.entity_kind, "entityKind", 80)?;
let entity_id = normalize_limited_text(&payload.entity_id, "entityId", 160)?;
let slot = normalize_limited_text(&payload.slot, "slot", 80)?;
let asset_kind = normalize_limited_text(&payload.asset_kind, "assetKind", 80)?;
let storage_prefix = match payload.storage_prefix {
Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets) => {
LegacyAssetPrefix::PuzzleAssets
}
Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets) => {
LegacyAssetPrefix::Match3DAssets
}
Some(creation_audio::CreationAudioStoragePrefix::CustomWorldScenes) | None => {
LegacyAssetPrefix::CustomWorldScenes
}
};
Ok(AudioAssetBindingTarget {
storage_scope: entity_kind.clone(),
entity_kind,
entity_id,
slot,
asset_kind,
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
storage_prefix,
})
}
fn require_vector_engine_audio_settings(
state: &AppState,
) -> Result<VectorEngineAudioSettings, AppError> {
@@ -878,6 +1134,30 @@ fn encode_path_segment(value: &str) -> String {
urlencoding::encode(value).into_owned()
}
fn sanitize_audio_path_segment(raw: &str, fallback: &str) -> String {
let normalized = raw
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let collapsed = normalized
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
fallback.to_string()
} else {
collapsed.chars().take(80).collect()
}
}
fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}

View File

@@ -186,6 +186,7 @@ pub fn compile_result_draft_from_seed(
level_name: level_name.clone(),
picture_description,
picture_reference: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
@@ -242,6 +243,7 @@ pub fn build_form_draft_from_parts(
level_name: String::new(),
picture_description: picture_description.clone().unwrap_or_default(),
picture_reference: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
@@ -347,6 +349,7 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft
&draft.summary,
),
picture_reference: None,
background_music: None,
candidates: draft.candidates.clone(),
selected_candidate_id: draft.selected_candidate_id.clone(),
cover_image_src: draft.cover_image_src.clone(),
@@ -433,6 +436,7 @@ pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft
),
picture_description,
picture_reference: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
@@ -2798,6 +2802,7 @@ mod tests {
level_name: format!("{profile_id} 关"),
picture_description: "summary".to_string(),
picture_reference: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/cover.png".to_string()),
@@ -3012,6 +3017,7 @@ mod tests {
level_name: "第一关".to_string(),
picture_description: "第一关画面".to_string(),
picture_reference: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-1.png".to_string()),
@@ -3023,6 +3029,7 @@ mod tests {
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()),

View File

@@ -169,6 +169,7 @@ pub fn build_puzzle_draft_from_creative_fields(
.unwrap_or_else(|| format!("{}", index + 1)),
picture_description,
picture_reference: level.picture_reference.and_then(normalize_required_string),
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,

View File

@@ -131,6 +131,8 @@ pub struct PuzzleDraftLevel {
pub picture_description: String,
#[serde(default)]
pub picture_reference: Option<String>,
#[serde(default)]
pub background_music: Option<PuzzleAudioAsset>,
pub candidates: Vec<PuzzleGeneratedImageCandidate>,
pub selected_candidate_id: Option<String>,
pub cover_image_src: Option<String>,
@@ -138,6 +140,24 @@ pub struct PuzzleDraftLevel {
pub generation_status: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAudioAsset {
pub task_id: String,
pub provider: String,
#[serde(default)]
pub asset_object_id: Option<String>,
#[serde(default)]
pub asset_kind: Option<String>,
pub audio_src: String,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleResultDraft {

View File

@@ -751,7 +751,6 @@ pub fn build_runtime_profile_invite_code_record(
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
granted_user_tags: snapshot.granted_user_tags,
starts_at: snapshot.starts_at_micros.map(format_utc_micros),
starts_at_micros: snapshot.starts_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),

View File

@@ -428,7 +428,6 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
metadata_json: String,
granted_user_tags: Vec<String>,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
@@ -437,7 +436,6 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
let granted_user_tags = normalize_profile_user_tags(granted_user_tags)?;
crate::commands::validate_runtime_profile_invite_code_validity_window(
starts_at_micros,
expires_at_micros,
@@ -447,7 +445,6 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id,
invite_code,
metadata_json,
granted_user_tags,
starts_at_micros,
expires_at_micros,
updated_at_micros,
@@ -767,13 +764,54 @@ pub fn normalize_invite_code_metadata_json(
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
let parsed = serde_json::from_str::<Value>(trimmed)
let mut parsed = serde_json::from_str::<Value>(trimmed)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
if !parsed.is_object() {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
normalize_invite_code_metadata_user_tags(&mut parsed)?;
let normalized = serde_json::to_string(&parsed)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
if normalized.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
Ok(normalized)
}
fn normalize_invite_code_metadata_user_tags(
metadata: &mut Value,
) -> Result<(), RuntimeProfileFieldError> {
let Some(object) = metadata.as_object_mut() else {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
};
// 中文注释:邀请码授予标签复用 metadata保存时统一收敛成 camelCase 字段。
let raw = object
.remove("userTags")
.or_else(|| object.remove("user_tags"));
object.remove("user_tags");
let Some(raw) = raw else {
return Ok(());
};
let Value::Array(items) = raw else {
return Err(RuntimeProfileFieldError::InvalidUserTag);
};
let mut raw_tags = Vec::new();
for item in items {
let Value::String(value) = item else {
return Err(RuntimeProfileFieldError::InvalidUserTag);
};
raw_tags.push(value);
}
let tags = normalize_profile_user_tags(raw_tags)?;
if !tags.is_empty() {
object.insert(
"userTags".to_string(),
Value::Array(tags.into_iter().map(Value::String).collect()),
);
}
Ok(())
}
pub fn normalize_profile_user_tags(
@@ -801,7 +839,7 @@ pub fn validate_runtime_profile_invite_code_validity_window(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
) -> Result<(), RuntimeProfileFieldError> {
if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at > expires_at)
if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at >= expires_at)
{
return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow);
}

View File

@@ -1190,7 +1190,6 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub updated_at_micros: i64,
@@ -1208,7 +1207,6 @@ pub struct RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub created_at_micros: i64,
@@ -1581,7 +1579,6 @@ pub struct RuntimeProfileInviteCodeRecord {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at: Option<String>,
pub starts_at_micros: Option<i64>,
pub expires_at: Option<String>,

View File

@@ -1,6 +1,7 @@
use module_runtime::{
RuntimeProfileFieldError, RuntimeProfileInviteCodeSnapshot, RuntimeProfileInviteCodeStatus,
build_runtime_profile_invite_code_record, resolve_runtime_profile_invite_code_status,
build_runtime_profile_invite_code_record, normalize_invite_code_metadata_json,
resolve_runtime_profile_invite_code_status,
validate_runtime_profile_invite_code_validity_window,
};
@@ -15,11 +16,14 @@ fn invite_code_validity_window_rejects_start_after_expire() {
}
#[test]
fn invite_code_validity_window_allows_open_ended_or_equal_boundary() {
fn invite_code_validity_window_allows_open_ended_and_rejects_equal_boundary() {
assert!(validate_runtime_profile_invite_code_validity_window(None, None).is_ok());
assert!(validate_runtime_profile_invite_code_validity_window(Some(10), None).is_ok());
assert!(validate_runtime_profile_invite_code_validity_window(None, Some(10)).is_ok());
assert!(validate_runtime_profile_invite_code_validity_window(Some(10), Some(10)).is_ok());
assert_eq!(
validate_runtime_profile_invite_code_validity_window(Some(10), Some(10)),
Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow)
);
}
#[test]
@@ -48,7 +52,6 @@ fn invite_code_record_formats_window_and_status() {
user_id: "user-1".to_string(),
invite_code: "SY00000001".to_string(),
metadata_json: "{}".to_string(),
granted_user_tags: Vec::new(),
starts_at_micros: Some(0),
expires_at_micros: Some(1_000_000),
created_at_micros: 0,
@@ -59,3 +62,33 @@ fn invite_code_record_formats_window_and_status() {
assert_eq!(record.expires_at.as_deref(), Some("1970-01-01T00:00:01Z"));
assert_eq!(record.status, RuntimeProfileInviteCodeStatus::Expired);
}
#[test]
fn invite_code_metadata_normalizes_user_tags() {
let normalized = normalize_invite_code_metadata_json(
r#"{"source":"admin","user_tags":[" 北科 ","北科",""]}"#.to_string(),
)
.expect("metadata should normalize");
assert_eq!(normalized, r#"{"source":"admin","userTags":["北科"]}"#);
}
#[test]
fn invite_code_metadata_removes_empty_user_tags() {
let normalized = normalize_invite_code_metadata_json(r#"{"userTags":[]}"#.to_string())
.expect("empty tags should be valid");
assert_eq!(normalized, "{}");
}
#[test]
fn invite_code_metadata_rejects_invalid_user_tags_shape() {
assert_eq!(
normalize_invite_code_metadata_json(r#"{"userTags":"北科"}"#.to_string()),
Err(RuntimeProfileFieldError::InvalidUserTag)
);
assert_eq!(
normalize_invite_code_metadata_json(r#"{"userTags":["北科",1]}"#.to_string()),
Err(RuntimeProfileFieldError::InvalidUserTag)
);
}

View File

@@ -5,11 +5,10 @@ version.workspace = true
license.workspace = true
[features]
# 默认给 api-server 等原生后端保留资产上传契约SpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。
# 默认给 api-server 等原生后端暴露资产上传 DTOSpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。
default = ["oss-contracts"]
oss-contracts = ["dep:platform-oss"]
oss-contracts = []
[dependencies]
platform-oss = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -11,7 +11,6 @@ pub struct AdminLoginRequest {
// 登录成功后返回管理员访问令牌与基础会话信息。
/// 后台创作入口开关列表响应。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]

View File

@@ -1,8 +1,5 @@
use std::collections::BTreeMap;
use platform_oss::{
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -16,7 +13,7 @@ pub struct CreateDirectUploadTicketRequest {
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub access: Option<OssObjectAccess>,
pub access: Option<DirectUploadObjectAccess>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
#[serde(default)]
@@ -45,6 +42,13 @@ pub enum ConfirmAssetObjectAccessPolicy {
PublicRead,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DirectUploadObjectAccess {
Public,
Private,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmAssetObjectRequest {
@@ -513,7 +517,7 @@ pub struct DirectUploadTicketPayload {
pub legacy_public_path: String,
#[serde(default)]
pub content_type: Option<String>,
pub access: OssObjectAccess,
pub access: DirectUploadObjectAccess,
pub key_prefix: String,
pub expires_at: String,
pub max_size_bytes: u64,
@@ -614,57 +618,6 @@ pub struct AssetBindingPayload {
pub updated_at: String,
}
impl From<OssPostObjectFormFields> for DirectUploadTicketFormFields {
fn from(value: OssPostObjectFormFields) -> Self {
Self {
key: value.key,
policy: value.policy,
signature_version: value.signature_version,
credential: value.credential,
date: value.date,
signature: value.signature,
success_action_status: value.success_action_status,
content_type: value.content_type,
metadata: value.metadata,
}
}
}
impl From<OssPostObjectResponse> for DirectUploadTicketPayload {
fn from(value: OssPostObjectResponse) -> Self {
Self {
signature_version: value.signature_version.to_string(),
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
legacy_public_path: value.legacy_public_path,
content_type: value.content_type,
access: value.access,
key_prefix: value.key_prefix,
expires_at: value.expires_at,
max_size_bytes: value.max_size_bytes,
success_action_status: value.success_action_status,
form_fields: value.form_fields.into(),
}
}
}
impl From<OssSignedGetObjectUrlResponse> for AssetReadUrlPayload {
fn from(value: OssSignedGetObjectUrlResponse) -> Self {
Self {
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
expires_at: value.expires_at,
signed_url: value.signed_url,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -708,21 +661,21 @@ mod tests {
#[test]
fn direct_upload_ticket_response_keeps_form_fields_shape() {
let payload = serde_json::to_value(CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(OssPostObjectResponse {
signature_version: "v4",
provider: "aliyun-oss",
upload: DirectUploadTicketPayload {
signature_version: "v4".to_string(),
provider: "aliyun-oss".to_string(),
bucket: "genarrative-assets".to_string(),
endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(),
host: "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com".to_string(),
object_key: "generated-characters/hero/master.png".to_string(),
legacy_public_path: "/generated-characters/hero/master.png".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Private,
access: DirectUploadObjectAccess::Private,
key_prefix: "generated-characters/hero".to_string(),
expires_at: "2026-04-21T00:00:00Z".to_string(),
max_size_bytes: 1024,
success_action_status: 200,
form_fields: OssPostObjectFormFields {
form_fields: DirectUploadTicketFormFields {
key: "generated-characters/hero/master.png".to_string(),
policy: "policy".to_string(),
signature_version: "OSS4-HMAC-SHA256".to_string(),
@@ -736,7 +689,7 @@ mod tests {
"character_visual".to_string(),
)]),
},
}),
},
})
.expect("payload should serialize");

View File

@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CreationAudioGenerationKind {
BackgroundMusic,
SoundEffect,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationAudioAsset {
pub task_id: String,
pub provider: String,
#[serde(default)]
pub asset_object_id: Option<String>,
#[serde(default)]
pub asset_kind: Option<String>,
pub audio_src: String,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreateBackgroundMusicRequest {
pub prompt: String,
pub title: String,
#[serde(default)]
pub tags: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreateSoundEffectRequest {
pub prompt: String,
#[serde(default)]
pub duration: Option<u8>,
#[serde(default)]
pub seed: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AudioGenerationTaskResponse {
pub kind: CreationAudioGenerationKind,
pub task_id: String,
pub provider: String,
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CreationAudioStoragePrefix {
PuzzleAssets,
#[serde(rename = "match3d_assets")]
Match3DAssets,
CustomWorldScenes,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublishGeneratedAudioAssetRequest {
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub storage_prefix: Option<CreationAudioStoragePrefix>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GeneratedAudioAssetResponse {
pub kind: CreationAudioGenerationKind,
pub task_id: String,
pub provider: String,
pub status: String,
#[serde(default)]
pub asset_object_id: Option<String>,
#[serde(default)]
pub asset_kind: Option<String>,
#[serde(default)]
pub audio_src: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn creation_audio_contracts_use_camel_case_fields() {
let request = PublishGeneratedAudioAssetRequest {
entity_kind: "match3d_item".to_string(),
entity_id: "match3d-item-1".to_string(),
slot: "click_sound".to_string(),
asset_kind: "match3d_click_sound".to_string(),
profile_id: Some("profile-1".to_string()),
storage_prefix: Some(CreationAudioStoragePrefix::Match3DAssets),
};
let payload = serde_json::to_value(request).expect("request should serialize");
assert_eq!(payload["entityKind"], json!("match3d_item"));
assert_eq!(payload["storagePrefix"], json!("match3d_assets"));
let asset = CreationAudioAsset {
task_id: "task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("puzzle_background_music".to_string()),
audio_src: "/generated-puzzle-assets/a.mp3".to_string(),
prompt: Some("轻快音乐".to_string()),
title: Some("拼图音乐".to_string()),
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
};
let payload = serde_json::to_value(asset).expect("asset should serialize");
assert_eq!(payload["taskId"], json!("task-1"));
assert_eq!(payload["audioSrc"], json!("/generated-puzzle-assets/a.mp3"));
}
}

View File

@@ -7,6 +7,7 @@ pub mod auth;
pub mod big_fish;
pub mod big_fish_works;
pub mod creation_agent_document_input;
pub mod creation_audio;
pub mod creation_entry_config;
pub mod creative_agent;
pub mod hyper3d;

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::creation_audio::CreationAudioAsset;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateMatch3DAgentSessionRequest {
@@ -108,6 +110,10 @@ pub struct Match3DGeneratedItemAssetResponse {
pub task_uuid: Option<String>,
#[serde(default)]
pub subscription_key: Option<String>,
#[serde(default)]
pub background_music: Option<CreationAudioAsset>,
#[serde(default)]
pub click_sound: Option<CreationAudioAsset>,
pub status: String,
#[serde(default)]
pub error: Option<String>,

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::creation_audio::CreationAudioAsset;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PutMatch3DWorkRequest {
@@ -16,6 +18,12 @@ pub struct PutMatch3DWorkRequest {
pub difficulty: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PutMatch3DAudioAssetsRequest {
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DWorkSummaryResponse {
@@ -63,6 +71,10 @@ pub struct Match3DGeneratedItemAssetResponse {
pub task_uuid: Option<String>,
#[serde(default)]
pub subscription_key: Option<String>,
#[serde(default)]
pub background_music: Option<CreationAudioAsset>,
#[serde(default)]
pub click_sound: Option<CreationAudioAsset>,
pub status: String,
#[serde(default)]
pub error: Option<String>,

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::creation_audio::CreationAudioAsset;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreatePuzzleAgentSessionRequest {
@@ -151,6 +153,8 @@ pub struct PuzzleDraftLevelResponse {
pub picture_description: String,
#[serde(default)]
pub picture_reference: Option<String>,
#[serde(default)]
pub background_music: Option<CreationAudioAsset>,
pub candidates: Vec<PuzzleGeneratedImageCandidateResponse>,
#[serde(default)]
pub selected_candidate_id: Option<String>,

View File

@@ -486,8 +486,6 @@ pub struct AdminUpsertProfileInviteCodeRequest {
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub granted_user_tags: Vec<String>,
#[serde(default)]
pub starts_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
@@ -526,7 +524,6 @@ pub struct ProfileInviteCodeAdminResponse {
pub user_id: String,
pub invite_code: String,
pub metadata: serde_json::Value,
pub granted_user_tags: Vec<String>,
pub starts_at: Option<String>,
pub expires_at: Option<String>,
pub status: String,

View File

@@ -40,8 +40,9 @@ pub use mapper::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleFormDraftSaveRecordInput,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,

View File

@@ -46,9 +46,7 @@ impl From<module_assets::AssetHistoryListInput> for AssetHistoryListInput {
}
}
impl From<module_runtime::CreationEntryTypeAdminUpsertInput>
for CreationEntryTypeAdminUpsertInput
{
impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTypeAdminUpsertInput {
fn from(input: module_runtime::CreationEntryTypeAdminUpsertInput) -> Self {
Self {
id: input.id,
@@ -337,7 +335,6 @@ impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
admin_user_id: input.admin_user_id,
invite_code: input.invite_code,
metadata_json: input.metadata_json,
granted_user_tags: input.granted_user_tags,
starts_at_micros: input.starts_at_micros,
expires_at_micros: input.expires_at_micros,
updated_at_micros: input.updated_at_micros,
@@ -723,7 +720,8 @@ pub(crate) fn map_asset_history_list_result(
.collect())
}
pub type CreationEntryConfigRecord = shared_contracts::creation_entry_config::CreationEntryConfigResponse;
pub type CreationEntryConfigRecord =
shared_contracts::creation_entry_config::CreationEntryConfigResponse;
pub(crate) fn map_creation_entry_config_procedure_result(
result: CreationEntryConfigProcedureResult,
@@ -2385,7 +2383,6 @@ pub(crate) fn map_runtime_profile_invite_code_snapshot(
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
granted_user_tags: snapshot.granted_user_tags,
starts_at_micros: snapshot.starts_at_micros,
expires_at_micros: snapshot.expires_at_micros,
created_at_micros: snapshot.created_at_micros,
@@ -2920,6 +2917,7 @@ pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> Puzzle
level_name: snapshot.level_name,
picture_description: snapshot.picture_description,
picture_reference: snapshot.picture_reference,
background_music: snapshot.background_music.map(map_puzzle_audio_asset),
candidates: snapshot
.candidates
.into_iter()
@@ -2932,6 +2930,21 @@ pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> Puzzle
}
}
pub(crate) fn map_puzzle_audio_asset(
asset: module_puzzle::PuzzleAudioAsset,
) -> PuzzleAudioAssetRecord {
PuzzleAudioAssetRecord {
task_id: asset.task_id,
provider: asset.provider,
asset_object_id: asset.asset_object_id,
asset_kind: asset.asset_kind,
audio_src: asset.audio_src,
prompt: asset.prompt,
title: asset.title,
updated_at: asset.updated_at,
}
}
pub(crate) fn map_puzzle_creator_intent(
snapshot: DomainPuzzleCreatorIntent,
) -> PuzzleCreatorIntentRecord {
@@ -7272,6 +7285,7 @@ pub struct PuzzleDraftLevelRecord {
pub level_name: String,
pub picture_description: String,
pub picture_reference: Option<String>,
pub background_music: Option<PuzzleAudioAssetRecord>,
pub candidates: Vec<PuzzleGeneratedImageCandidateRecord>,
pub selected_candidate_id: Option<String>,
pub cover_image_src: Option<String>,
@@ -7279,6 +7293,18 @@ pub struct PuzzleDraftLevelRecord {
pub generation_status: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleAudioAssetRecord {
pub task_id: String,
pub provider: String,
pub asset_object_id: Option<String>,
pub asset_kind: Option<String>,
pub audio_src: String,
pub prompt: Option<String>,
pub title: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleAgentMessageRecord {
pub message_id: String,

View File

@@ -5,8 +5,8 @@
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
use super::creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot;
use super::creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot;
use super::creation_entry_type_snapshot_type::CreationEntryTypeSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]

View File

@@ -0,0 +1,161 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::creation_entry_config_type::CreationEntryConfig;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `creation_entry_config`.
///
/// Obtain a handle from the [`CreationEntryConfigTableAccess::creation_entry_config`] method on [`super::RemoteTables`],
/// like `ctx.db.creation_entry_config()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.creation_entry_config().on_insert(...)`.
pub struct CreationEntryConfigTableHandle<'ctx> {
imp: __sdk::TableHandle<CreationEntryConfig>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `creation_entry_config`.
///
/// Implemented for [`super::RemoteTables`].
pub trait CreationEntryConfigTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`CreationEntryConfigTableHandle`], which mediates access to the table `creation_entry_config`.
fn creation_entry_config(&self) -> CreationEntryConfigTableHandle<'_>;
}
impl CreationEntryConfigTableAccess for super::RemoteTables {
fn creation_entry_config(&self) -> CreationEntryConfigTableHandle<'_> {
CreationEntryConfigTableHandle {
imp: self
.imp
.get_table::<CreationEntryConfig>("creation_entry_config"),
ctx: std::marker::PhantomData,
}
}
}
pub struct CreationEntryConfigInsertCallbackId(__sdk::CallbackId);
pub struct CreationEntryConfigDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for CreationEntryConfigTableHandle<'ctx> {
type Row = CreationEntryConfig;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = CreationEntryConfig> + '_ {
self.imp.iter()
}
type InsertCallbackId = CreationEntryConfigInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> CreationEntryConfigInsertCallbackId {
CreationEntryConfigInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: CreationEntryConfigInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = CreationEntryConfigDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> CreationEntryConfigDeleteCallbackId {
CreationEntryConfigDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: CreationEntryConfigDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct CreationEntryConfigUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for CreationEntryConfigTableHandle<'ctx> {
type UpdateCallbackId = CreationEntryConfigUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> CreationEntryConfigUpdateCallbackId {
CreationEntryConfigUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: CreationEntryConfigUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `config_id` unique index on the table `creation_entry_config`,
/// which allows point queries on the field of the same name
/// via the [`CreationEntryConfigConfigIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.creation_entry_config().config_id().find(...)`.
pub struct CreationEntryConfigConfigIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<CreationEntryConfig, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> CreationEntryConfigTableHandle<'ctx> {
/// Get a handle on the `config_id` unique index on the table `creation_entry_config`.
pub fn config_id(&self) -> CreationEntryConfigConfigIdUnique<'ctx> {
CreationEntryConfigConfigIdUnique {
imp: self.imp.get_unique_constraint::<String>("config_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> CreationEntryConfigConfigIdUnique<'ctx> {
/// Find the subscribed row whose `config_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<CreationEntryConfig> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<CreationEntryConfig>("creation_entry_config");
_table.add_unique_constraint::<String>("config_id", |row| &row.config_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<CreationEntryConfig>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<CreationEntryConfig>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `CreationEntryConfig`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait creation_entry_configQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `CreationEntryConfig`.
fn creation_entry_config(&self) -> __sdk::__query_builder::Table<CreationEntryConfig>;
}
impl creation_entry_configQueryTableAccess for __sdk::QueryTableAccessor {
fn creation_entry_config(&self) -> __sdk::__query_builder::Table<CreationEntryConfig> {
__sdk::__query_builder::Table::new("creation_entry_config")
}
}

View File

@@ -0,0 +1,70 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CreationEntryConfig {
pub config_id: String,
pub start_title: String,
pub start_description: String,
pub start_idle_badge: String,
pub start_busy_badge: String,
pub modal_title: String,
pub modal_description: String,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for CreationEntryConfig {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `CreationEntryConfig`.
///
/// Provides typed access to columns for query building.
pub struct CreationEntryConfigCols {
pub config_id: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub start_title: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub start_description: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub start_idle_badge: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub start_busy_badge: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub modal_title: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub modal_description: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub updated_at: __sdk::__query_builder::Col<CreationEntryConfig, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for CreationEntryConfig {
type Cols = CreationEntryConfigCols;
fn cols(table_name: &'static str) -> Self::Cols {
CreationEntryConfigCols {
config_id: __sdk::__query_builder::Col::new(table_name, "config_id"),
start_title: __sdk::__query_builder::Col::new(table_name, "start_title"),
start_description: __sdk::__query_builder::Col::new(table_name, "start_description"),
start_idle_badge: __sdk::__query_builder::Col::new(table_name, "start_idle_badge"),
start_busy_badge: __sdk::__query_builder::Col::new(table_name, "start_busy_badge"),
modal_title: __sdk::__query_builder::Col::new(table_name, "modal_title"),
modal_description: __sdk::__query_builder::Col::new(table_name, "modal_description"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `CreationEntryConfig`.
///
/// Provides typed access to indexed columns for query building.
pub struct CreationEntryConfigIxCols {
pub config_id: __sdk::__query_builder::IxCol<CreationEntryConfig, String>,
}
impl __sdk::__query_builder::HasIxCols for CreationEntryConfig {
type IxCols = CreationEntryConfigIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
CreationEntryConfigIxCols {
config_id: __sdk::__query_builder::IxCol::new(table_name, "config_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for CreationEntryConfig {}

View File

@@ -0,0 +1,162 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::creation_entry_type_config_type::CreationEntryTypeConfig;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `creation_entry_type_config`.
///
/// Obtain a handle from the [`CreationEntryTypeConfigTableAccess::creation_entry_type_config`] method on [`super::RemoteTables`],
/// like `ctx.db.creation_entry_type_config()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.creation_entry_type_config().on_insert(...)`.
pub struct CreationEntryTypeConfigTableHandle<'ctx> {
imp: __sdk::TableHandle<CreationEntryTypeConfig>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `creation_entry_type_config`.
///
/// Implemented for [`super::RemoteTables`].
pub trait CreationEntryTypeConfigTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`CreationEntryTypeConfigTableHandle`], which mediates access to the table `creation_entry_type_config`.
fn creation_entry_type_config(&self) -> CreationEntryTypeConfigTableHandle<'_>;
}
impl CreationEntryTypeConfigTableAccess for super::RemoteTables {
fn creation_entry_type_config(&self) -> CreationEntryTypeConfigTableHandle<'_> {
CreationEntryTypeConfigTableHandle {
imp: self
.imp
.get_table::<CreationEntryTypeConfig>("creation_entry_type_config"),
ctx: std::marker::PhantomData,
}
}
}
pub struct CreationEntryTypeConfigInsertCallbackId(__sdk::CallbackId);
pub struct CreationEntryTypeConfigDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for CreationEntryTypeConfigTableHandle<'ctx> {
type Row = CreationEntryTypeConfig;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = CreationEntryTypeConfig> + '_ {
self.imp.iter()
}
type InsertCallbackId = CreationEntryTypeConfigInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> CreationEntryTypeConfigInsertCallbackId {
CreationEntryTypeConfigInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: CreationEntryTypeConfigInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = CreationEntryTypeConfigDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> CreationEntryTypeConfigDeleteCallbackId {
CreationEntryTypeConfigDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: CreationEntryTypeConfigDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct CreationEntryTypeConfigUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for CreationEntryTypeConfigTableHandle<'ctx> {
type UpdateCallbackId = CreationEntryTypeConfigUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> CreationEntryTypeConfigUpdateCallbackId {
CreationEntryTypeConfigUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: CreationEntryTypeConfigUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `id` unique index on the table `creation_entry_type_config`,
/// which allows point queries on the field of the same name
/// via the [`CreationEntryTypeConfigIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.creation_entry_type_config().id().find(...)`.
pub struct CreationEntryTypeConfigIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<CreationEntryTypeConfig, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> CreationEntryTypeConfigTableHandle<'ctx> {
/// Get a handle on the `id` unique index on the table `creation_entry_type_config`.
pub fn id(&self) -> CreationEntryTypeConfigIdUnique<'ctx> {
CreationEntryTypeConfigIdUnique {
imp: self.imp.get_unique_constraint::<String>("id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> CreationEntryTypeConfigIdUnique<'ctx> {
/// Find the subscribed row whose `id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<CreationEntryTypeConfig> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<CreationEntryTypeConfig>("creation_entry_type_config");
_table.add_unique_constraint::<String>("id", |row| &row.id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<CreationEntryTypeConfig>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<CreationEntryTypeConfig>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `CreationEntryTypeConfig`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait creation_entry_type_configQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `CreationEntryTypeConfig`.
fn creation_entry_type_config(&self) -> __sdk::__query_builder::Table<CreationEntryTypeConfig>;
}
impl creation_entry_type_configQueryTableAccess for __sdk::QueryTableAccessor {
fn creation_entry_type_config(&self) -> __sdk::__query_builder::Table<CreationEntryTypeConfig> {
__sdk::__query_builder::Table::new("creation_entry_type_config")
}
}

View File

@@ -0,0 +1,75 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CreationEntryTypeConfig {
pub id: String,
pub title: String,
pub subtitle: String,
pub badge: String,
pub image_src: String,
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for CreationEntryTypeConfig {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `CreationEntryTypeConfig`.
///
/// Provides typed access to columns for query building.
pub struct CreationEntryTypeConfigCols {
pub id: __sdk::__query_builder::Col<CreationEntryTypeConfig, String>,
pub title: __sdk::__query_builder::Col<CreationEntryTypeConfig, String>,
pub subtitle: __sdk::__query_builder::Col<CreationEntryTypeConfig, String>,
pub badge: __sdk::__query_builder::Col<CreationEntryTypeConfig, String>,
pub image_src: __sdk::__query_builder::Col<CreationEntryTypeConfig, String>,
pub visible: __sdk::__query_builder::Col<CreationEntryTypeConfig, bool>,
pub open: __sdk::__query_builder::Col<CreationEntryTypeConfig, bool>,
pub sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
pub updated_at: __sdk::__query_builder::Col<CreationEntryTypeConfig, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
type Cols = CreationEntryTypeConfigCols;
fn cols(table_name: &'static str) -> Self::Cols {
CreationEntryTypeConfigCols {
id: __sdk::__query_builder::Col::new(table_name, "id"),
title: __sdk::__query_builder::Col::new(table_name, "title"),
subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"),
badge: __sdk::__query_builder::Col::new(table_name, "badge"),
image_src: __sdk::__query_builder::Col::new(table_name, "image_src"),
visible: __sdk::__query_builder::Col::new(table_name, "visible"),
open: __sdk::__query_builder::Col::new(table_name, "open"),
sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `CreationEntryTypeConfig`.
///
/// Provides typed access to indexed columns for query building.
pub struct CreationEntryTypeConfigIxCols {
pub id: __sdk::__query_builder::IxCol<CreationEntryTypeConfig, String>,
pub sort_order: __sdk::__query_builder::IxCol<CreationEntryTypeConfig, i32>,
}
impl __sdk::__query_builder::HasIxCols for CreationEntryTypeConfig {
type IxCols = CreationEntryTypeConfigIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
CreationEntryTypeConfigIxCols {
id: __sdk::__query_builder::IxCol::new(table_name, "id"),
sort_order: __sdk::__query_builder::IxCol::new(table_name, "sort_order"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for CreationEntryTypeConfig {}

View File

@@ -25,6 +25,7 @@ pub trait get_creation_entry_config {
fn get_creation_entry_config_then(
&self,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
@@ -36,6 +37,7 @@ pub trait get_creation_entry_config {
impl get_creation_entry_config for super::RemoteProcedures {
fn get_creation_entry_config_then(
&self,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,

View File

@@ -188,6 +188,16 @@ pub mod create_profile_recharge_order_and_return_procedure;
pub mod create_puzzle_agent_session_procedure;
pub mod create_square_hole_agent_session_procedure;
pub mod create_visual_novel_agent_session_procedure;
pub mod creation_entry_config_procedure_result_type;
pub mod creation_entry_config_snapshot_type;
pub mod creation_entry_config_table;
pub mod creation_entry_config_type;
pub mod creation_entry_start_card_snapshot_type;
pub mod creation_entry_type_admin_upsert_input_type;
pub mod creation_entry_type_config_table;
pub mod creation_entry_type_config_type;
pub mod creation_entry_type_modal_snapshot_type;
pub mod creation_entry_type_snapshot_type;
pub mod custom_world_agent_action_execute_input_type;
pub mod custom_world_agent_action_execute_result_type;
pub mod custom_world_agent_card_detail_get_input_type;
@@ -249,12 +259,6 @@ pub mod custom_world_theme_mode_type;
pub mod custom_world_work_summary_snapshot_type;
pub mod custom_world_works_list_input_type;
pub mod custom_world_works_list_result_type;
pub mod creation_entry_config_procedure_result_type;
pub mod creation_entry_config_snapshot_type;
pub mod creation_entry_start_card_snapshot_type;
pub mod creation_entry_type_admin_upsert_input_type;
pub mod creation_entry_type_config_snapshot_type;
pub mod creation_entry_type_modal_snapshot_type;
pub mod database_migration_authorize_operator_input_type;
pub mod database_migration_export_input_type;
pub mod database_migration_import_chunk_input_type;
@@ -296,11 +300,11 @@ pub mod finish_match_3_d_time_up_procedure;
pub mod finish_square_hole_time_up_procedure;
pub mod generate_big_fish_asset_procedure;
pub mod get_auth_store_snapshot_procedure;
pub mod get_creation_entry_config_procedure;
pub mod get_battle_state_procedure;
pub mod get_big_fish_run_procedure;
pub mod get_big_fish_session_procedure;
pub mod get_chapter_progression_procedure;
pub mod get_creation_entry_config_procedure;
pub mod get_custom_world_agent_card_detail_procedure;
pub mod get_custom_world_agent_operation_procedure;
pub mod get_custom_world_agent_session_procedure;
@@ -759,8 +763,8 @@ pub mod update_square_hole_work_procedure;
pub mod update_visual_novel_work_procedure;
pub mod upsert_auth_store_snapshot_procedure;
pub mod upsert_chapter_progression_and_return_procedure;
pub mod upsert_creation_entry_type_config_procedure;
pub mod upsert_chapter_progression_reducer;
pub mod upsert_creation_entry_type_config_procedure;
pub mod upsert_custom_world_agent_operation_progress_procedure;
pub mod upsert_custom_world_profile_and_return_procedure;
pub mod upsert_custom_world_profile_reducer;
@@ -992,6 +996,16 @@ pub use create_profile_recharge_order_and_return_procedure::create_profile_recha
pub use create_puzzle_agent_session_procedure::create_puzzle_agent_session;
pub use create_square_hole_agent_session_procedure::create_square_hole_agent_session;
pub use create_visual_novel_agent_session_procedure::create_visual_novel_agent_session;
pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult;
pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot;
pub use creation_entry_config_table::*;
pub use creation_entry_config_type::CreationEntryConfig;
pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
pub use creation_entry_type_config_table::*;
pub use creation_entry_type_config_type::CreationEntryTypeConfig;
pub use creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot;
pub use creation_entry_type_snapshot_type::CreationEntryTypeSnapshot;
pub use custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput;
pub use custom_world_agent_action_execute_result_type::CustomWorldAgentActionExecuteResult;
pub use custom_world_agent_card_detail_get_input_type::CustomWorldAgentCardDetailGetInput;
@@ -1053,12 +1067,6 @@ pub use custom_world_theme_mode_type::CustomWorldThemeMode;
pub use custom_world_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot;
pub use custom_world_works_list_input_type::CustomWorldWorksListInput;
pub use custom_world_works_list_result_type::CustomWorldWorksListResult;
pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult;
pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot;
pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
pub use creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot;
pub use creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot;
pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput;
pub use database_migration_export_input_type::DatabaseMigrationExportInput;
pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput;
@@ -1100,11 +1108,11 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up;
pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up;
pub use generate_big_fish_asset_procedure::generate_big_fish_asset;
pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot;
pub use get_creation_entry_config_procedure::get_creation_entry_config;
pub use get_battle_state_procedure::get_battle_state;
pub use get_big_fish_run_procedure::get_big_fish_run;
pub use get_big_fish_session_procedure::get_big_fish_session;
pub use get_chapter_progression_procedure::get_chapter_progression;
pub use get_creation_entry_config_procedure::get_creation_entry_config;
pub use get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail;
pub use get_custom_world_agent_operation_procedure::get_custom_world_agent_operation;
pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session;
@@ -1563,8 +1571,8 @@ pub use update_square_hole_work_procedure::update_square_hole_work;
pub use update_visual_novel_work_procedure::update_visual_novel_work;
pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot;
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config;
pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config;
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return;
pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile;
@@ -1904,6 +1912,8 @@ pub struct DbUpdate {
big_fish_event: __sdk::TableUpdate<BigFishEvent>,
big_fish_runtime_run: __sdk::TableUpdate<BigFishRuntimeRun>,
chapter_progression: __sdk::TableUpdate<ChapterProgression>,
creation_entry_config: __sdk::TableUpdate<CreationEntryConfig>,
creation_entry_type_config: __sdk::TableUpdate<CreationEntryTypeConfig>,
custom_world_agent_message: __sdk::TableUpdate<CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableUpdate<CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableUpdate<CustomWorldAgentSession>,
@@ -2026,6 +2036,12 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"chapter_progression" => db_update
.chapter_progression
.append(chapter_progression_table::parse_table_update(table_update)?),
"creation_entry_config" => db_update.creation_entry_config.append(
creation_entry_config_table::parse_table_update(table_update)?,
),
"creation_entry_type_config" => db_update.creation_entry_type_config.append(
creation_entry_type_config_table::parse_table_update(table_update)?,
),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(
custom_world_agent_message_table::parse_table_update(table_update)?,
),
@@ -2311,6 +2327,18 @@ impl __sdk::DbUpdate for DbUpdate {
&self.chapter_progression,
)
.with_updates_by_pk(|row| &row.chapter_progression_id);
diff.creation_entry_config = cache
.apply_diff_to_table::<CreationEntryConfig>(
"creation_entry_config",
&self.creation_entry_config,
)
.with_updates_by_pk(|row| &row.config_id);
diff.creation_entry_type_config = cache
.apply_diff_to_table::<CreationEntryTypeConfig>(
"creation_entry_type_config",
&self.creation_entry_type_config,
)
.with_updates_by_pk(|row| &row.id);
diff.custom_world_agent_message = cache
.apply_diff_to_table::<CustomWorldAgentMessage>(
"custom_world_agent_message",
@@ -2683,6 +2711,12 @@ impl __sdk::DbUpdate for DbUpdate {
"chapter_progression" => db_update
.chapter_progression
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"creation_entry_config" => db_update
.creation_entry_config
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"creation_entry_type_config" => db_update
.creation_entry_type_config
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_message" => db_update
.custom_world_agent_message
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -2930,6 +2964,12 @@ impl __sdk::DbUpdate for DbUpdate {
"chapter_progression" => db_update
.chapter_progression
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"creation_entry_config" => db_update
.creation_entry_config
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"creation_entry_type_config" => db_update
.creation_entry_type_config
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_message" => db_update
.custom_world_agent_message
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -3143,6 +3183,8 @@ pub struct AppliedDiff<'r> {
big_fish_event: __sdk::TableAppliedDiff<'r, BigFishEvent>,
big_fish_runtime_run: __sdk::TableAppliedDiff<'r, BigFishRuntimeRun>,
chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>,
creation_entry_config: __sdk::TableAppliedDiff<'r, CreationEntryConfig>,
creation_entry_type_config: __sdk::TableAppliedDiff<'r, CreationEntryTypeConfig>,
custom_world_agent_message: __sdk::TableAppliedDiff<'r, CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableAppliedDiff<'r, CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableAppliedDiff<'r, CustomWorldAgentSession>,
@@ -3299,6 +3341,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.chapter_progression,
event,
);
callbacks.invoke_table_row_callbacks::<CreationEntryConfig>(
"creation_entry_config",
&self.creation_entry_config,
event,
);
callbacks.invoke_table_row_callbacks::<CreationEntryTypeConfig>(
"creation_entry_type_config",
&self.creation_entry_type_config,
event,
);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentMessage>(
"custom_world_agent_message",
&self.custom_world_agent_message,
@@ -4265,6 +4317,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
big_fish_event_table::register_table(client_cache);
big_fish_runtime_run_table::register_table(client_cache);
chapter_progression_table::register_table(client_cache);
creation_entry_config_table::register_table(client_cache);
creation_entry_type_config_table::register_table(client_cache);
custom_world_agent_message_table::register_table(client_cache);
custom_world_agent_operation_table::register_table(client_cache);
custom_world_agent_session_table::register_table(client_cache);
@@ -4345,6 +4399,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
"big_fish_event",
"big_fish_runtime_run",
"chapter_progression",
"creation_entry_config",
"creation_entry_type_config",
"custom_world_agent_message",
"custom_world_agent_operation",
"custom_world_agent_session",

View File

@@ -14,7 +14,6 @@ pub struct ProfileInviteCode {
pub updated_at: __sdk::Timestamp,
pub starts_at: Option<__sdk::Timestamp>,
pub expires_at: Option<__sdk::Timestamp>,
pub granted_user_tags: Vec<String>,
}
impl __sdk::InModule for ProfileInviteCode {
@@ -32,7 +31,6 @@ pub struct ProfileInviteCodeCols {
pub updated_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
pub starts_at: __sdk::__query_builder::Col<ProfileInviteCode, Option<__sdk::Timestamp>>,
pub expires_at: __sdk::__query_builder::Col<ProfileInviteCode, Option<__sdk::Timestamp>>,
pub granted_user_tags: __sdk::__query_builder::Col<ProfileInviteCode, Vec<String>>,
}
impl __sdk::__query_builder::HasCols for ProfileInviteCode {
@@ -46,7 +44,6 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode {
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
starts_at: __sdk::__query_builder::Col::new(table_name, "starts_at"),
expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"),
granted_user_tags: __sdk::__query_builder::Col::new(table_name, "granted_user_tags"),
}
}
}

View File

@@ -10,7 +10,6 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub updated_at_micros: i64,

View File

@@ -10,7 +10,6 @@ pub struct RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub created_at_micros: i64,

View File

@@ -29,6 +29,7 @@ pub trait upsert_creation_entry_type_config {
fn upsert_creation_entry_type_config_then(
&self,
input: CreationEntryTypeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
@@ -41,6 +42,7 @@ impl upsert_creation_entry_type_config for super::RemoteProcedures {
fn upsert_creation_entry_type_config_then(
&self,
input: CreationEntryTypeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,

View File

@@ -20,7 +20,7 @@ pub struct UserAccount {
pub password_hash: String,
pub password_login_enabled: bool,
pub token_version: u64,
pub user_tags: Vec<String>,
pub user_tags: Option<Vec<String>>,
}
impl __sdk::InModule for UserAccount {
@@ -44,7 +44,7 @@ pub struct UserAccountCols {
pub password_hash: __sdk::__query_builder::Col<UserAccount, String>,
pub password_login_enabled: __sdk::__query_builder::Col<UserAccount, bool>,
pub token_version: __sdk::__query_builder::Col<UserAccount, u64>,
pub user_tags: __sdk::__query_builder::Col<UserAccount, Vec<String>>,
pub user_tags: __sdk::__query_builder::Col<UserAccount, Option<Vec<String>>>,
}
impl __sdk::__query_builder::HasCols for UserAccount {

View File

@@ -23,15 +23,14 @@ impl SpacetimeClient {
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
let procedure_input: CreationEntryTypeAdminUpsertInput = input.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().upsert_creation_entry_type_config_then(
procedure_input,
move |_, result| {
connection
.procedures()
.upsert_creation_entry_type_config_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_creation_entry_config_procedure_result);
send_once(&sender, mapped);
},
);
});
})
.await
}
@@ -687,7 +686,6 @@ impl SpacetimeClient {
admin_user_id: String,
invite_code: String,
metadata_json: String,
granted_user_tags: Vec<String>,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
@@ -696,7 +694,6 @@ impl SpacetimeClient {
admin_user_id,
invite_code,
metadata_json,
granted_user_tags,
starts_at_micros,
expires_at_micros,
updated_at_micros,

View File

@@ -33,6 +33,12 @@ pub struct AuthStoreSnapshotProcedureResult {
pub error_message: Option<String>,
}
fn normalize_user_account_tags(
tags: Option<Vec<String>>,
) -> Result<Vec<String>, module_runtime::RuntimeProfileFieldError> {
module_runtime::normalize_profile_user_tags(tags.unwrap_or_default())
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotImportRecord {
pub imported_user_count: u32,
@@ -210,8 +216,10 @@ fn import_auth_store_snapshot_tx(
password_hash: stored_user.password_hash,
password_login_enabled: stored_user.password_login_enabled,
token_version: user.token_version,
user_tags: module_runtime::normalize_profile_user_tags(user.user_tags)
.map_err(|error| error.to_string())?,
user_tags: Some(
module_runtime::normalize_profile_user_tags(user.user_tags)
.map_err(|error| error.to_string())?,
),
});
imported_user_count += 1;
@@ -341,7 +349,8 @@ fn export_auth_store_snapshot_from_tables_tx(
binding_status: user.binding_status,
wechat_bound: user.wechat_bound,
token_version: user.token_version,
user_tags: user.user_tags,
user_tags: normalize_user_account_tags(user.user_tags)
.map_err(|error| error.to_string())?,
};
users_by_username.insert(
user.username,

View File

@@ -28,7 +28,8 @@ pub struct UserAccount {
pub(crate) password_hash: String,
pub(crate) password_login_enabled: bool,
pub(crate) token_version: u64,
pub(crate) user_tags: Vec<String>,
#[default(None::<Vec<String>>)]
pub(crate) user_tags: Option<Vec<String>>,
}
#[spacetimedb::table(

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