Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -11,9 +11,9 @@
|
||||
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
|
||||
- [参考目录](./reference/README.md):脚本/Function 速查入口。
|
||||
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
|
||||
- [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md)。
|
||||
- [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)。
|
||||
|
||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和 Jenkins 数据库迁移流水线见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md)。
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
|
||||
@@ -207,4 +207,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 18. 2026-04-30 资料兑换码弹窗响应式修正
|
||||
|
||||
- `RpgEntryHomeView.tsx` 的兑换码弹窗现在抽成同一份 `rewardCodeModal`,桌面与移动端分支都挂载,避免竖屏点击头像右侧“兑换码”后只更新状态但不显示窗口。
|
||||
- `src/index.css` 已补齐 `platform-modal-backdrop`、`platform-recharge-modal`、`platform-profile-input`、`platform-primary-button`、`platform-modal-close` 与兑换结果提示样式;后续资料类轻量弹窗可以复用这组类接入平台主题背景。
|
||||
- 兑换码窗口仍只保留输入框、兑换按钮和后端返回提示,不新增规则说明文案。
|
||||
|
||||
---
|
||||
|
||||
_文档目的:交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。_
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
# AI 原生抓大鹅 Match3D 玩法创作工具与玩法系统 PRD
|
||||
|
||||
更新时间:`2026-04-30`
|
||||
|
||||
## 0. 文档目的
|
||||
|
||||
这份 PRD 用于在当前平台内新增一条“抓大鹅”玩法模板链路,并把首版 demo 的产品边界、创作链路、运行规则和工程落点冻结到可以继续拆技术方案的程度。
|
||||
|
||||
本玩法对外展示名称为“抓大鹅”,子标题为“经典消除玩法”;开发代号为 `Match3D`。本文统一使用 `Match3D` 表示工程玩法域。
|
||||
|
||||
本次不是区分“抓大鹅”和 `Match 3D` 两套模板,也不是只做一个前端临时小游戏。它们的基础交互逻辑一致,后续差异只作为节奏和规则优化项继续迭代。
|
||||
|
||||
首版目标先抛出单局 demo,边体验边打磨;最终目标仍要与拼图玩法一样接入作品广场、他人作品游玩和后续关卡推荐。
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话定义
|
||||
|
||||
让陶泥主通过 Agent 对话确认题材、需要消除次数和难度,系统编译出一个可试玩、可发布的单局抓大鹅玩法作品;玩家在 `10` 分钟倒计时内点击圆形空间中可见物品,把物品放入下方 `7` 格备选栏,每凑齐 `3` 个同物品 id 自动消除,最终清空圆形空间内全部物品即胜利。
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品定位
|
||||
|
||||
## 2.1 模板名称
|
||||
|
||||
1. 对外模板名称:`抓大鹅`。
|
||||
2. 对外子标题:`经典消除玩法`。
|
||||
3. 开发代号:`Match3D`。
|
||||
4. 内部玩法域命名应独立于 RPG、拼图和大鱼吃小鱼,不挂在旧 `customWorld`、`rpgWorld`、`puzzle` 或 `bigFish` 语义下。
|
||||
|
||||
## 2.2 首版目标
|
||||
|
||||
首版只做单局 demo 主链:
|
||||
|
||||
```text
|
||||
平台创作入口
|
||||
-> 选择“抓大鹅”
|
||||
-> Agent 对话确认题材、需要消除次数、难度
|
||||
-> 生成待发布结果页
|
||||
-> 编辑作品基础信息
|
||||
-> 发布前试玩
|
||||
-> 发布作品
|
||||
-> 玩家进入单局运行态
|
||||
-> 胜利 / 失败结算
|
||||
```
|
||||
|
||||
## 2.3 最终目标
|
||||
|
||||
后续完整链路需要补齐:
|
||||
|
||||
1. 发布后的作品进入首页、分类页和作品广场。
|
||||
2. 玩家可游玩他人发布的抓大鹅作品。
|
||||
3. 支持后续关卡推荐。
|
||||
4. 记录成绩,并预留排行榜机制。
|
||||
5. 支持已发布作品二次编辑。
|
||||
|
||||
---
|
||||
|
||||
## 3. 与现有平台链路的关系
|
||||
|
||||
## 3.1 复用什么
|
||||
|
||||
1. 复用平台创作中心入口。
|
||||
2. 复用 Agent-first 创作体验。
|
||||
3. 复用“创作会话 -> 结果页 -> 发布 -> 运行态”的平台主链。
|
||||
4. 复用作品基础信息、标签、封面、发布接口和作品管理体验。
|
||||
5. 复用现有 Rust / SpacetimeDB 后端基线。
|
||||
|
||||
## 3.2 不复用什么
|
||||
|
||||
1. 不复用 RPG 的世界、角色、章节、剧情推进结构。
|
||||
2. 不复用拼图的网格切图、交换、合并块和下一关推荐算法。
|
||||
3. 不复用大鱼吃小鱼的实时吞噬、实体等级和摇杆移动规则。
|
||||
4. 不把 Match3D 运行规则写成前端本地真相源。
|
||||
5. 不使用 `server-node` 或 PostgreSQL 作为新增玩法后端。
|
||||
|
||||
## 3.3 独立玩法域要求
|
||||
|
||||
Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
|
||||
|
||||
1. `module-match3d` 纯领域 crate。
|
||||
2. `spacetime-module` 内的 Match3D 表与 procedure。
|
||||
3. `shared-contracts` 内的 Match3D DTO。
|
||||
4. `spacetime-client` 内的 Match3D 调用封装。
|
||||
5. `api-server` 内的 Match3D facade 路由。
|
||||
6. 前端 Match3D 创作、结果页、试玩和运行态组件。
|
||||
|
||||
---
|
||||
|
||||
## 4. 本次目标
|
||||
|
||||
首版 demo 必须满足:
|
||||
|
||||
1. 平台新增“抓大鹅”玩法创作入口。
|
||||
2. 创建流程采用 Agent 对话收集关键配置。
|
||||
3. Agent 必须在进入结果页前确认:
|
||||
- 题材主题
|
||||
- 需要消除次数
|
||||
- 难度 `1~10`
|
||||
4. 支持系统自动补全配置,用户确认后开始创造。
|
||||
5. 题材阶段允许上传参考图片。
|
||||
6. 结果页支持编辑游戏名称、标签、封面图等基础发布信息。
|
||||
7. 发布前支持试玩,并允许随时停止和修改配置。
|
||||
8. 发布不要求试玩通关。
|
||||
9. 单局运行态使用 `10` 分钟倒计时。
|
||||
10. 下方备选栏固定为 `7` 个格子。
|
||||
11. 玩家点击可见物品后,物品飞入备选栏。
|
||||
12. 备选栏中每凑齐 `3` 个相同物品 id 自动消除。
|
||||
13. 清空圆形空间中全部物品即胜利。
|
||||
14. 倒计时结束或备选栏满即失败。
|
||||
15. 胜利 / 失败后展示结算界面。
|
||||
16. 点击判定、入槽、消除、失败、胜利必须由后端裁决。
|
||||
|
||||
---
|
||||
|
||||
## 5. 明确不做
|
||||
|
||||
首版 demo 明确不做:
|
||||
|
||||
1. 不做抓大鹅和 `Match 3D` 的模板分裂。
|
||||
2. 不做多关卡关卡链。
|
||||
3. 不做排行榜正式展示。
|
||||
4. 不做道具,但需要预留功能口。
|
||||
5. 不做洗牌、重置、旋转、放大等局内操作。
|
||||
6. 不做真实 3D 模型。
|
||||
7. 不做真实 3D 物理遮挡。
|
||||
8. 不做真实物理碰撞结算。
|
||||
9. 不做必须试玩通关才能发布的门槛。
|
||||
10. 不把玩法规则说明长文默认写入 UI 面板。
|
||||
11. 不在前端即时完成规则裁决。
|
||||
12. 不使用 `server-node` 或 PostgreSQL 新增实现。
|
||||
|
||||
---
|
||||
|
||||
## 6. 创作端设计
|
||||
|
||||
## 6.1 创作方式
|
||||
|
||||
Match3D 首版参考拼图早期的 Agent 对话收集锚点,而不是拼图后期的纯表单入口。
|
||||
|
||||
Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置:
|
||||
|
||||
1. 题材主题。
|
||||
2. 需要消除次数。
|
||||
3. 游戏难度。
|
||||
|
||||
## 6.2 必填配置
|
||||
|
||||
### 题材主题
|
||||
|
||||
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
|
||||
|
||||
首版 demo 不要求真实生成题材物品素材,只需保留题材字段,并使用 `10` 种颜色形状组合且差异足够明显的素材完成玩法验证。
|
||||
|
||||
### 需要消除次数
|
||||
|
||||
用户输入任意正整数。
|
||||
|
||||
该字段不是胜利条件,而是本局总物品规模配置:
|
||||
|
||||
```text
|
||||
本局最终物品数 = 需要消除次数 * 3
|
||||
```
|
||||
|
||||
例如用户填写需要消除 `4` 次,则场景中最终生成 `12` 件物品。最终目标仍然是清空空间中全部物品。
|
||||
|
||||
### 难度
|
||||
|
||||
用户输入 `1~10` 的数字,代表从低到高的难度感受。
|
||||
|
||||
首版 demo 中,用户只需凭感觉选择难度;具体难度规则由系统内部解释。后续优化阶段再细化难度曲线、生成算法和遮挡策略。
|
||||
|
||||
## 6.3 自动配置
|
||||
|
||||
如果用户不想逐项填写,系统可以自动补全题材、需要消除次数和难度。
|
||||
|
||||
自动补全后的配置必须展示给用户确认,用户确认后才能开始创造。
|
||||
|
||||
## 6.4 参考图片
|
||||
|
||||
题材阶段允许用户上传参考图片。
|
||||
|
||||
首版只支持图片参考,不支持视频参考。参考图片用于影响题材表现,不作为运行时规则裁决依据。
|
||||
|
||||
---
|
||||
|
||||
## 7. 结果页设计
|
||||
|
||||
## 7.1 结果页定位
|
||||
|
||||
结果页是待发布作品的最小工作台,不是只读总结页。
|
||||
|
||||
首版结果页尽量复用其他玩法模板的结果页结构,重点保证基础信息编辑、试玩和发布链路跑通。
|
||||
|
||||
## 7.2 必备字段
|
||||
|
||||
结果页至少包含:
|
||||
|
||||
1. 游戏名称。
|
||||
2. 标签。
|
||||
3. 封面图。
|
||||
4. 题材主题。
|
||||
5. 需要消除次数。
|
||||
6. 难度。
|
||||
7. 试玩入口。
|
||||
8. 发布入口。
|
||||
|
||||
## 7.3 素材生成边界
|
||||
|
||||
首版结果页暂时不生成额外素材。
|
||||
|
||||
后续如果需要生成题材物品、伪 3D 物品、场景背景或封面图,需要先补充本文档或新增技术方案,再进入编码。
|
||||
|
||||
## 7.4 发布前试玩
|
||||
|
||||
发布前需要支持试玩当前关卡。
|
||||
|
||||
试玩目的不是强制验证通关,而是帮助用户确认:
|
||||
|
||||
1. 题材表现是否符合预期。
|
||||
2. 物品数量是否符合预期。
|
||||
3. 难度体感是否符合预期。
|
||||
|
||||
试玩过程中必须支持随时停止并返回修改配置。
|
||||
|
||||
## 7.5 发布门槛
|
||||
|
||||
发布时必须完成基础信息填写。
|
||||
|
||||
首版不要求用户试玩通关后才能发布。
|
||||
|
||||
---
|
||||
|
||||
## 8. 运行时玩法系统
|
||||
|
||||
## 8.1 单局时长
|
||||
|
||||
一局暂定 `10` 分钟倒计时。
|
||||
|
||||
倒计时结束时,如果玩家未清空圆形空间中的全部物品,则关卡失败。
|
||||
|
||||
该设计后续可根据体验调整,但调整前需要先更新文档。
|
||||
|
||||
## 8.2 场景空间
|
||||
|
||||
运行时主交互空间是一个有边界的圆形空间。
|
||||
|
||||
1. 圆形空间使用俯视角。
|
||||
2. 背景环境资源后续可以尝试伪 3D 视角效果。
|
||||
3. 圆形空间边界是中间交互图案的边界。
|
||||
4. 物品不能超出圆形边界。
|
||||
|
||||
## 8.3 物品生成规模
|
||||
|
||||
首版按用户填写的需要消除次数计算总物品数:
|
||||
|
||||
```text
|
||||
totalItemCount = clearCount * 3
|
||||
```
|
||||
|
||||
每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。
|
||||
|
||||
## 8.4 阶段陆续生成
|
||||
|
||||
每局物品允许阶段陆续生成。
|
||||
|
||||
玩家启动游戏时,圆形空间里需要给玩家“已经装满”的视觉感受。
|
||||
|
||||
根据参考游戏推算,首次初始刷新时表层约 `30~40` 件物品。首版需注意性能消耗,具体刷新规则在 demo 体验后继续优化。
|
||||
|
||||
## 8.5 物品资产
|
||||
|
||||
首版 demo 使用 2D 图案素材。
|
||||
|
||||
1. demo 只需提供 `10` 种颜色形状组合。
|
||||
2. `10` 种素材需要差异足够明显。
|
||||
3. 后续可以尝试替换为伪 3D 或 3D 模型。
|
||||
4. 用户题材主题后续会映射为符合常识预期的物品集合。
|
||||
|
||||
示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。
|
||||
|
||||
## 8.6 物品 id 与图案
|
||||
|
||||
同样物品按物品 id 判断。
|
||||
|
||||
1. 每个物品有唯一类型 id。
|
||||
2. 当前 demo 中,一个物品 id 对应一个图案。
|
||||
3. 需要预留一个物品 id 对应多个图案的设计空间。
|
||||
|
||||
## 8.7 遮挡与点击
|
||||
|
||||
圆形空间里的物品可以重叠、遮挡、堆叠。
|
||||
|
||||
首版使用 2D 逻辑实现遮挡和点击判定:
|
||||
|
||||
1. 被完全遮挡的物品不允许点击。
|
||||
2. 如果物品有局部露出,且露出区域可被点击选中,则允许点击。
|
||||
3. 具体露出区域判定使用 2D 逻辑的最优方案,不做真实 3D 遮挡。
|
||||
|
||||
## 8.8 点击入槽
|
||||
|
||||
玩家点击通过后,后端裁决该物品可选中。
|
||||
|
||||
前端播放飞行动画,把物品放入下方备选栏。
|
||||
|
||||
飞行动画过程中,物品不再与其他物品产生碰撞。
|
||||
|
||||
## 8.9 备选栏
|
||||
|
||||
下方备选栏固定为 `7` 个格子。
|
||||
|
||||
1. 每次成功点击后,物品进入备选栏。
|
||||
2. 备选栏中每出现 `3` 个相同物品 id,自动消除并腾出格子。
|
||||
3. 如果备选栏满且无法消除,则判定关卡失败。
|
||||
|
||||
## 8.10 胜利
|
||||
|
||||
圆形空间内全部物品被消除后,播放胜利动画并展示胜利界面。
|
||||
|
||||
胜利结算页至少展示:
|
||||
|
||||
1. 结果状态。
|
||||
2. 使用时间。
|
||||
3. 再来一局按钮。
|
||||
4. 返回作品详情的通用按钮。
|
||||
|
||||
## 8.11 失败
|
||||
|
||||
失败条件:
|
||||
|
||||
1. 倒计时结束。
|
||||
2. 备选栏满。
|
||||
|
||||
失败结算页至少展示:
|
||||
|
||||
1. 失败原因。
|
||||
2. 关卡完成进度。
|
||||
3. 重新开始按钮。
|
||||
4. 返回上层按钮。
|
||||
|
||||
---
|
||||
|
||||
## 9. 难度设计
|
||||
|
||||
## 9.1 首版难度口径
|
||||
|
||||
首版 demo 只有一个关卡,难度递增体感来自单局动态加载过程。
|
||||
|
||||
用户输入的难度 `1~10` 暂作为后端布局和生成算法参数,不在 UI 中展示具体规则说明。
|
||||
|
||||
## 9.2 已确认的难度方向
|
||||
|
||||
后续难度优化需要围绕以下方向细化:
|
||||
|
||||
1. 可选物品逐渐变小,更容易误触或更难分辨。
|
||||
2. 空间表层直观看到可凑成 `3` 个消除的类型变少。
|
||||
3. 更多可消除组合被堆叠在下方,玩家需要利用备选栏作出决策。
|
||||
4. 倒计时带来持续压力。
|
||||
|
||||
## 9.3 待体验后冻结
|
||||
|
||||
以下规则需要 demo 体验后再冻结:
|
||||
|
||||
1. 不同难度对应的物品数量、尺寸、遮挡层数和可见组合比例。
|
||||
2. 阶段加载节奏。
|
||||
3. 首屏表层物品数量的性能上限。
|
||||
4. 局内动态难度变化公式。
|
||||
|
||||
---
|
||||
|
||||
## 10. 前后端职责边界
|
||||
|
||||
## 10.1 后端职责
|
||||
|
||||
后端必须作为规则真相源,负责:
|
||||
|
||||
1. 创建玩法草稿。
|
||||
2. 编译运行时初始局面。
|
||||
3. 生成物品序列与布局。
|
||||
4. 判断物品是否可点击。
|
||||
5. 处理点击入槽。
|
||||
6. 判断 `3` 个相同物品 id 消除。
|
||||
7. 判断备选栏满失败。
|
||||
8. 判断倒计时结束失败。
|
||||
9. 判断清空空间胜利。
|
||||
10. 记录成绩所需的基础数据。
|
||||
|
||||
## 10.2 前端职责
|
||||
|
||||
前端只负责:
|
||||
|
||||
1. 展示 Agent 创作界面。
|
||||
2. 展示结果页和基础编辑表单。
|
||||
3. 上传参考图片。
|
||||
4. 展示运行态场景、物品、倒计时和备选栏。
|
||||
5. 发送玩家点击意图。
|
||||
6. 播放点击、飞入、消除、胜利和失败动画。
|
||||
7. 展示结算界面。
|
||||
|
||||
前端不得自行完成规则裁决。
|
||||
|
||||
## 10.3 防作弊要求
|
||||
|
||||
首版即按正式版本搭建规则裁决链路。
|
||||
|
||||
前端不可信任本地点击、消除、胜利或成绩结果;所有关键状态必须由后端裁决后下发。
|
||||
|
||||
---
|
||||
|
||||
## 11. 状态结构建议
|
||||
|
||||
## 11.1 创作配置
|
||||
|
||||
```ts
|
||||
interface Match3DCreatorConfig {
|
||||
themeText: string;
|
||||
referenceImageSrc?: string;
|
||||
clearCount: number;
|
||||
difficulty: number;
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
1. `themeText`:题材主题。
|
||||
2. `referenceImageSrc`:可选参考图片。
|
||||
3. `clearCount`:需要消除次数,必须为正整数。
|
||||
4. `difficulty`:难度,范围为 `1~10`。
|
||||
|
||||
## 11.2 作品结构
|
||||
|
||||
```ts
|
||||
interface Match3DWorkProfile {
|
||||
profileId: string;
|
||||
ownerUserId: string;
|
||||
sourceSessionId: string;
|
||||
gameName: string;
|
||||
themeText: string;
|
||||
summaryText: string;
|
||||
tags: string[];
|
||||
coverImageSrc: string;
|
||||
clearCount: number;
|
||||
difficulty: number;
|
||||
publicationStatus: 'Draft' | 'Published';
|
||||
playCount: number;
|
||||
updatedAt: number;
|
||||
publishedAt?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## 11.3 运行态快照
|
||||
|
||||
```ts
|
||||
interface Match3DRunSnapshot {
|
||||
runId: string;
|
||||
profileId: string;
|
||||
status: 'Running' | 'Won' | 'Failed' | 'Stopped';
|
||||
startedAtMs: number;
|
||||
durationLimitMs: number;
|
||||
remainingMs: number;
|
||||
clearCount: number;
|
||||
totalItemCount: number;
|
||||
clearedItemCount: number;
|
||||
traySlots: Match3DTraySlot[];
|
||||
items: Match3DItemSnapshot[];
|
||||
failureReason?: 'TimeUp' | 'TrayFull';
|
||||
}
|
||||
```
|
||||
|
||||
## 11.4 物品快照
|
||||
|
||||
```ts
|
||||
interface Match3DItemSnapshot {
|
||||
itemInstanceId: string;
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
layer: number;
|
||||
state: 'InBoard' | 'Flying' | 'InTray' | 'Cleared';
|
||||
clickable: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## 11.5 备选栏快照
|
||||
|
||||
```ts
|
||||
interface Match3DTraySlot {
|
||||
slotIndex: number;
|
||||
itemInstanceId?: string;
|
||||
itemTypeId?: string;
|
||||
visualKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 工程落点建议
|
||||
|
||||
## 12.1 纯领域 crate
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
server-rs/crates/module-match3d
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
1. 创作配置校验。
|
||||
2. 物品生成规划。
|
||||
3. 2D 遮挡与可点击判定。
|
||||
4. 点击入槽规则。
|
||||
5. 三个同物品 id 消除规则。
|
||||
6. 胜负判定。
|
||||
7. 成绩基础数据计算。
|
||||
|
||||
## 12.2 SpacetimeDB 表
|
||||
|
||||
后续技术方案需冻结至少以下表:
|
||||
|
||||
1. `match3d_agent_session`
|
||||
2. `match3d_agent_message`
|
||||
3. `match3d_work_profile`
|
||||
4. `match3d_runtime_run`
|
||||
|
||||
如表结构变化,必须同步对齐 `migration.rs`。
|
||||
|
||||
## 12.3 Procedure
|
||||
|
||||
后续技术方案需冻结至少以下 procedure:
|
||||
|
||||
1. `create_match3d_agent_session`
|
||||
2. `submit_match3d_agent_message`
|
||||
3. `compile_match3d_draft`
|
||||
4. `update_match3d_work`
|
||||
5. `publish_match3d_work`
|
||||
6. `start_match3d_run`
|
||||
7. `stop_match3d_run`
|
||||
8. `click_match3d_item`
|
||||
9. `restart_match3d_run`
|
||||
10. `get_match3d_run`
|
||||
|
||||
## 12.4 shared contracts
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
server-rs/crates/shared-contracts/src/match3d_agent.rs
|
||||
server-rs/crates/shared-contracts/src/match3d_works.rs
|
||||
server-rs/crates/shared-contracts/src/match3d_runtime.rs
|
||||
packages/shared/src/contracts/match3dAgent.ts
|
||||
packages/shared/src/contracts/match3dWorks.ts
|
||||
packages/shared/src/contracts/match3dRuntime.ts
|
||||
```
|
||||
|
||||
## 12.5 api-server facade
|
||||
|
||||
建议新增 Match3D 独立路由,不挂在其他玩法路由下:
|
||||
|
||||
```text
|
||||
POST /api/creation/match3d/sessions
|
||||
POST /api/creation/match3d/sessions/:sessionId/messages
|
||||
POST /api/creation/match3d/sessions/:sessionId/compile
|
||||
PATCH /api/creation/match3d/works/:profileId
|
||||
POST /api/creation/match3d/works/:profileId/publish
|
||||
POST /api/runtime/match3d/works/:profileId/runs
|
||||
POST /api/runtime/match3d/runs/:runId/stop
|
||||
POST /api/runtime/match3d/runs/:runId/click
|
||||
POST /api/runtime/match3d/runs/:runId/restart
|
||||
GET /api/runtime/match3d/runs/:runId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 发布与分发
|
||||
|
||||
## 13.1 首版发布
|
||||
|
||||
首版发布需要完成基础信息:
|
||||
|
||||
1. 游戏名称。
|
||||
2. 标签。
|
||||
3. 封面图。
|
||||
4. 题材主题。
|
||||
5. 需要消除次数。
|
||||
6. 难度。
|
||||
|
||||
## 13.2 广场接入
|
||||
|
||||
最终版本需要接入:
|
||||
|
||||
1. 首页。
|
||||
2. 分类页。
|
||||
3. 作品广场。
|
||||
4. 他人作品游玩。
|
||||
5. 后续关卡推荐。
|
||||
|
||||
首版 demo 可先不完整实现广场推荐,但作品结构必须预留正式发布字段。
|
||||
|
||||
## 13.3 成绩记录
|
||||
|
||||
首版记录并预留:
|
||||
|
||||
1. 用户 id。
|
||||
2. 是否通关。
|
||||
3. 通关时间。
|
||||
4. 失败原因。
|
||||
|
||||
排行榜展示首版暂不做,后续优化可能新增。
|
||||
|
||||
## 13.4 二次编辑
|
||||
|
||||
已发布作品需要支持二次编辑。
|
||||
|
||||
编辑后仍应保留同一个作品归属,不应因为再次发布创建重复作品。
|
||||
|
||||
---
|
||||
|
||||
## 14. UI 要求
|
||||
|
||||
## 14.1 创作入口
|
||||
|
||||
创作入口只展示必要信息,不默认堆叠玩法规则说明。
|
||||
|
||||
## 14.2 Agent 工作区
|
||||
|
||||
Agent 每轮优先追问最影响 demo 生成的一个问题。
|
||||
|
||||
已确认的信息应清晰展示给用户确认:
|
||||
|
||||
1. 题材主题。
|
||||
2. 需要消除次数。
|
||||
3. 难度。
|
||||
|
||||
## 14.3 结果页
|
||||
|
||||
结果页保持清爽,复用平台已有作品结果页风格。
|
||||
|
||||
点击按钮弹出独立面板时,不实现成在当前面板下面展开内容。
|
||||
|
||||
## 14.4 运行态
|
||||
|
||||
运行态需要移动端优先:
|
||||
|
||||
1. 圆形空间占据主要视觉区域。
|
||||
2. 下方 `7` 格备选栏固定清晰。
|
||||
3. 倒计时可见但不遮挡物品。
|
||||
4. 点击、飞入、消除反馈清楚。
|
||||
5. 胜利 / 失败结算面板独立展示。
|
||||
|
||||
---
|
||||
|
||||
## 15. 验收标准
|
||||
|
||||
首版 PRD 对应 demo 验收标准:
|
||||
|
||||
1. 用户可从平台创作入口进入“抓大鹅”模板。
|
||||
2. Agent 能确认题材、需要消除次数和难度。
|
||||
3. 用户可上传参考图片。
|
||||
4. 系统可生成待发布结果页。
|
||||
5. 用户可编辑游戏名称、标签、封面图等基础信息。
|
||||
6. 用户可发布前试玩,且试玩失败不阻断发布。
|
||||
7. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏。
|
||||
8. 物品可重叠、遮挡、堆叠。
|
||||
9. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。
|
||||
10. 点击通过后物品飞入备选栏。
|
||||
11. 备选栏中 `3` 个相同物品 id 自动消除。
|
||||
12. 清空空间中全部物品后胜利。
|
||||
13. 倒计时结束或备选栏满后失败。
|
||||
14. 胜利结算展示使用时间。
|
||||
15. 失败结算展示完成进度和重新开始按钮。
|
||||
16. 关键规则由后端裁决,前端不本地判定胜负。
|
||||
17. 相关中文文档通过编码检查。
|
||||
|
||||
---
|
||||
|
||||
## 16. 推荐落地顺序
|
||||
|
||||
## 阶段 A:冻结技术方案
|
||||
|
||||
1. 基于本文补 `Match3D` 技术落地方案。
|
||||
2. 冻结表结构、procedure、DTO、api-server 路由。
|
||||
3. 对齐 `migration.rs`。
|
||||
|
||||
## 阶段 B:创作与结果页
|
||||
|
||||
1. 新增平台创作入口。
|
||||
2. 接入 Agent 会话。
|
||||
3. 编译草稿并进入结果页。
|
||||
4. 复用基础信息编辑和发布链。
|
||||
|
||||
## 阶段 C:后端运行态
|
||||
|
||||
1. 新增 `module-match3d` 规则。
|
||||
2. 新增 SpacetimeDB 运行态表和 procedure。
|
||||
3. 实现开始、点击、消除、失败、胜利。
|
||||
|
||||
## 阶段 D:前端运行态
|
||||
|
||||
1. 展示圆形空间和 2D 物品。
|
||||
2. 展示 `7` 格备选栏。
|
||||
3. 接入点击接口和后端快照。
|
||||
4. 补飞入、消除、胜负动画。
|
||||
|
||||
## 阶段 E:分发与成绩预留
|
||||
|
||||
1. 接入首页、分类页和广场投影。
|
||||
2. 记录成绩基础数据。
|
||||
3. 预留排行榜和后续关卡推荐。
|
||||
|
||||
---
|
||||
|
||||
## 17. 一句话结论
|
||||
|
||||
Match3D 首版不是临时前端 demo,而是以“抓大鹅”模板为外壳、以后端规则裁决为真相源、以独立玩法域为工程边界的单局经典消除玩法链路;首轮先跑通题材创作、结果页、试玩、发布和单局清空胜负闭环。
|
||||
@@ -25,7 +25,8 @@
|
||||
9. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行。
|
||||
10. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限。
|
||||
11. `WEB_PORT` 必须在 `构建并部署` 与 `部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local` 的 `GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退。
|
||||
12. `DATABASE` 必须使用 SpacetimeDB CLI 可接受的名称字符:数字、字母、点和短横线;不要使用下划线,否则 `spacetime publish` 会报 `invalid characters in database name`。
|
||||
12. `DATABASE` 必须匹配 SpacetimeDB CLI 数据库名规则 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会被拒绝,否则 `spacetime publish` 会报 `invalid characters in database name`。
|
||||
13. Jenkins 日志必须能看到构建参数中的 SpacetimeDB 发布数据库,以及 `start.sh` 最终加载环境文件后的运行时数据库、server 和 root-dir,避免 `.env.local` 覆盖默认值后无法判断实际发布目标。
|
||||
|
||||
## 3. 节点与工作区要求
|
||||
|
||||
@@ -100,13 +101,13 @@ scripts/jenkins-deploy-release.sh \
|
||||
2. 覆盖前如果旧部署目录存在 `migration-bootstrap-secret.txt`,先复制到 `deploy-state/migration-bootstrap-secret.previous.txt`,供新版本 `start.sh` 在 schema 冲突自动迁移时授权导出旧库。该文件属于 Jenkins 部署状态,不放入 `run/`,避免 `sudo` 启停脚本生成的 root 私有运行目录阻断后续部署写入。
|
||||
3. 只删除发布产物白名单中的旧文件,例如 `web/`、`api-server`、`spacetime_module.wasm`、`migration-bootstrap-secret.txt`、`scripts/`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md`。
|
||||
4. 将指定版本目录中的同名发布产物复制到部署目录;文件产物使用普通复制,`web/`、`scripts/` 等目录产物必须递归复制。
|
||||
5. 把 `WEB_PORT`、`MIGRATE_ON_CONFLICT`、`MIGRATION_DIRECTORY` 写入部署目录 `.env.local`,确保通过 sudo 执行 `start.sh` 时仍能读取 Jenkins 参数。
|
||||
5. 把 `WEB_PORT`、`MIGRATE_ON_CONFLICT`、`MIGRATION_DIRECTORY` 写入部署目录 `.env.local`,确保通过 sudo 执行 `start.sh` 时仍能读取 Jenkins 参数;启动前读取 `.env` 与 `.env.local` 中最终的 `GENARRATIVE_SPACETIME_DATABASE`,打印并校验其符合 SpacetimeDB 数据库名规则。Jenkins 参数 `MIGRATION_EXPORT_TOKEN` / `MIGRATION_IMPORT_TOKEN` 会分别写入 `GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN` / `GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN`;如果参数为空,部署目录已有同名变量时会尽量保留。
|
||||
6. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不进入自动导出和回灌。
|
||||
7. 执行新版本 `start.sh`;普通发布遇到 schema 冲突时,默认由发布包内迁移脚本自动导出旧库、清库发布新 wasm、导入回灌。
|
||||
|
||||
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,旧版本 `stop.sh` 和新版本 `start.sh` 会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo,否则部署会直接失败,不会进入交互式密码提示。
|
||||
|
||||
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录,不会因为部署被整体删除。`run/` 只承载 pid 等启停运行状态;`deploy-state/` 承载 Jenkins 覆盖部署前保存的旧迁移引导密钥,必须由 Jenkins 用户保持可写。发布白名单内的 `.env`、`.env.local` 会先以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF,并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local` 的 `GENARRATIVE_WEB_PORT`,把 `MIGRATE_ON_CONFLICT` 写入 `GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT`,把 `MIGRATION_DIRECTORY` 写入 `GENARRATIVE_SPACETIME_MIGRATION_DIR`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口和迁移配置只停留在上游构建阶段。`start.sh` 会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到部署目录 `.spacetimedb/bin/current/spacetimedb-cli`,后续启动、探活和 root-dir 占用判定都使用部署目录内 `.spacetimedb/`,且不再额外设置 `--data-dir`,避免 Jenkins 机器全局 `spacetime login` 变化影响本地库更新;如遇 `403 Forbidden`,按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 排查数据库所有者与 CLI 身份。
|
||||
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录,不会因为部署被整体删除。`run/` 只承载 pid 等启停运行状态;`deploy-state/` 承载 Jenkins 覆盖部署前保存的旧迁移引导密钥,必须由 Jenkins 用户保持可写。发布白名单内的 `.env`、`.env.local` 会先以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF,并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local` 的 `GENARRATIVE_WEB_PORT`,把 `MIGRATE_ON_CONFLICT` 写入 `GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT`,把 `MIGRATION_DIRECTORY` 写入 `GENARRATIVE_SPACETIME_MIGRATION_DIR`,并在启动前输出最终 `GENARRATIVE_SPACETIME_DATABASE`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口、数据库名和迁移配置只停留在上游构建阶段。`start.sh` 会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到部署目录 `.spacetimedb/bin/current/spacetimedb-cli`,后续启动、探活和 root-dir 占用判定都使用部署目录内 `.spacetimedb/`,且不再额外设置 `--data-dir`,避免 Jenkins 机器全局 `spacetime login` 变化影响本地库更新;如遇 `403 Forbidden`,按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 排查数据库所有者与 CLI 身份。
|
||||
|
||||
### 4.3 构建并部署
|
||||
|
||||
@@ -123,7 +124,8 @@ jenkins/Jenkinsfile.build-and-deploy
|
||||
3. 归档 `build/<BUILD_VERSION>/**`。
|
||||
4. 记录当前 `NODE_NAME`、源码根目录、版本号。
|
||||
5. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `25001` 的发布包。
|
||||
6. 触发 `部署` 流水线,并传递:
|
||||
6. 构建日志会输出 `SpacetimeDB 发布数据库: <DATABASE>`,发布包启动日志会输出最终 `database/server/root-dir`。
|
||||
7. 触发 `部署` 流水线,并传递:
|
||||
- `BUILD_VERSION`
|
||||
- `SOURCE_WORKSPACE_ROOT`
|
||||
- `SOURCE_NODE_NAME`
|
||||
@@ -146,6 +148,8 @@ jenkins/Jenkinsfile.build-and-deploy
|
||||
6. `CLEAR_DATABASE`:部署阶段是否清空 SpacetimeDB 数据后再发布 wasm;默认 `false`。
|
||||
7. `MIGRATE_ON_CONFLICT`:普通部署遇到 SpacetimeDB schema 冲突时是否自动导出、清库发布、导入回灌;默认 `true`。
|
||||
8. `MIGRATION_DIRECTORY`:自动迁移 JSON 输出目录;留空时使用部署目录内 `database-migrations/<database>`。
|
||||
9. `MIGRATION_EXPORT_TOKEN`:可选,旧库已授权迁移操作员 token,只在 schema 冲突导出旧库时使用。
|
||||
10. `MIGRATION_IMPORT_TOKEN`:可选,新库已授权迁移操作员 token,只在清库发布新 wasm 后导入回灌时使用。
|
||||
|
||||
如果当前 Jenkins 没有额外配置独立 Agent,而是直接在控制器自身执行任务,`AGENT_LABEL` 应填写 `built-in`。
|
||||
如果 Jenkins 进程以默认 `jenkins` 用户运行,部署目录建议直接放在 `/var/lib/jenkins/deploy/Genarrative` 这类 Jenkins 自有目录下,避免再依赖 `/home/ubuntu/*` 的额外写权限。
|
||||
@@ -162,6 +166,8 @@ jenkins/Jenkinsfile.build-and-deploy
|
||||
7. `RUN_DEPLOY_HOOKS_WITH_SUDO`
|
||||
8. `EXPECTED_UPSTREAM_JOB`
|
||||
9. `WEB_PORT`
|
||||
10. `MIGRATION_EXPORT_TOKEN`
|
||||
11. `MIGRATION_IMPORT_TOKEN`
|
||||
|
||||
其中仅 `构建并部署` 流水线还需要:
|
||||
|
||||
@@ -171,7 +177,9 @@ jenkins/Jenkinsfile.build-and-deploy
|
||||
4. `CLEAR_DATABASE`
|
||||
5. `MIGRATE_ON_CONFLICT`
|
||||
6. `MIGRATION_DIRECTORY`
|
||||
7. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`,只能包含数字、字母、点和短横线。
|
||||
7. `MIGRATION_EXPORT_TOKEN`
|
||||
8. `MIGRATION_IMPORT_TOKEN`
|
||||
9. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`,必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`。
|
||||
|
||||
如果你选择启用 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,推荐提前在服务器上增加一份最小 sudoers 配置,例如:
|
||||
|
||||
|
||||
@@ -72,12 +72,13 @@ Genarrative-Database-Import
|
||||
1. `INPUT_FILE`:必填,迁移 JSON 文件路径。
|
||||
2. `DATABASE`、`SERVER`、`SERVER_URL`、`DEPLOY_DIRECTORY`、`ROOT_DIR`:与导出流水线一致。
|
||||
3. `INCLUDE_TABLES`:可选,只导入指定表。
|
||||
4. `DRY_RUN`:默认 `true`,只校验不写入。
|
||||
5. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。
|
||||
6. `REPLACE_EXISTING`:默认 `false`,只覆盖本次迁移文件中涉及的表;不可与 `INCREMENTAL` 同时启用。
|
||||
7. `BOOTSTRAP_SECRET`:可选,用于授权临时 Web API identity。
|
||||
8. `TOKEN`:可选,SpacetimeDB 客户端连接 token;留空时脚本会自动创建临时 identity 并在结束后撤销。
|
||||
9. `NOTE`:迁移授权备注。
|
||||
4. `CHUNK_SIZE`:迁移 JSON 分片大小,默认 `524288` bytes。导入脚本会在文件超过该大小或直接导入触发 HTTP 413 时自动分片上传。
|
||||
5. `DRY_RUN`:默认 `true`,只校验不写入。
|
||||
6. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。
|
||||
7. `REPLACE_EXISTING`:默认 `false`,只覆盖本次迁移文件中涉及的表;不可与 `INCREMENTAL` 同时启用。
|
||||
8. `BOOTSTRAP_SECRET`:可选,用于授权临时 Web API identity。
|
||||
9. `TOKEN`:可选,SpacetimeDB 客户端连接 token;留空时脚本会自动创建临时 identity 并在结束后撤销。
|
||||
10. `NOTE`:迁移授权备注。
|
||||
|
||||
## 4. 安全边界
|
||||
|
||||
@@ -85,12 +86,13 @@ Genarrative-Database-Import
|
||||
2. `INCREMENTAL` 与 `REPLACE_EXISTING` 互斥,Jenkinsfile 会在执行前阻止同时启用。
|
||||
3. Jenkinsfile 不打印 token;生产环境应通过 Jenkins 凭据或目标机器环境变量传入敏感值。
|
||||
4. 如果不传 `TOKEN`,导入脚本会创建临时 Web API identity,并调用迁移授权/撤销 procedure 收敛权限窗口。
|
||||
5. 如果日志出现 `SpacetimeDB HTTP 413: Failed to buffer the request body: length limit exceeded`,优先把 `CHUNK_SIZE` 调低到 `262144` 或更小后重跑。该参数只降低单次 HTTP body,不改变导入表范围。
|
||||
|
||||
## 5. 本地部署测试参数
|
||||
|
||||
`Genarrative-Build-And-Deploy` 增加以下本地发布包参数,便于在 Jenkins 中测试本地 SpacetimeDB,不依赖 Maincloud:
|
||||
|
||||
1. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`。SpacetimeDB CLI 当前只接受数字、字母、点和短横线,不要使用下划线。
|
||||
1. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`。SpacetimeDB CLI 当前要求数据库名匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`,只能使用小写字母、数字,并用单个短横线分隔;不要使用大写字母、点号、下划线、首尾短横线或连续短横线。
|
||||
2. `API_PORT`:发布包内 api-server 端口,默认 `8082`。
|
||||
3. `WEB_PORT`:发布包内静态网站端口,默认 `25001`。
|
||||
4. `SPACETIME_PORT`:发布包内本地 SpacetimeDB 端口,默认 `3101`。
|
||||
|
||||
@@ -90,3 +90,4 @@ SpacetimeDB 正式表 `user_account` 需要增加 `avatar_url: Option<String>`
|
||||
5. 昵称与头像裁剪弹窗面板不透明,不能露出底层页面内容。
|
||||
6. 非法头像文件不会进入裁剪流程。
|
||||
7. 裁剪保存成功后,资料卡头像展示裁剪后的图片。
|
||||
8. 桌面右上角账号入口与“我的”资料卡共用 `avatarUrl`,有已保存头像时展示头像图片,缺失时才回退到首字头像。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 平台首页移动端底部 Dock 可见视口修复
|
||||
|
||||
## 背景
|
||||
|
||||
手机浏览器会把顶部地址栏纳入传统 `100vh` 的计算,导致平台首页根容器高于真实可见区域。底部 dock 虽然在 flex 布局末尾,但会被推到浏览器可见区域之外,用户需要滚动或收起地址栏后才能看到。
|
||||
|
||||
## 落地口径
|
||||
|
||||
- 平台入口根壳统一使用 `.platform-viewport-shell`,优先按 `100dvh` 约束高度,旧浏览器回退到 `100vh`。
|
||||
- 移动端首页底部 dock 使用 `.platform-mobile-bottom-dock` 固定在可见视口底部,并叠加 `safe-area-inset-bottom`。
|
||||
- 移动端首页内容壳通过 `--platform-bottom-dock-outer-height` 预留底部空间,避免滚动内容被固定 dock 遮挡。
|
||||
- 不新增 UI 说明文案,不改变底部导航业务语义。
|
||||
|
||||
## 验收
|
||||
|
||||
- 手机竖屏打开平台首页时,底部 dock 始终贴住浏览器可见区域底部。
|
||||
- 浏览器地址栏展开时,dock 不应被挤到屏幕外。
|
||||
- 主页、分类、创作、存档、我的五个 tab 均保持原有点击行为。
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。
|
||||
- [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128` 的 `/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201` 的 `/responses`。
|
||||
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
|
||||
- [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。
|
||||
- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。
|
||||
- [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client,角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`。
|
||||
- [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。
|
||||
- [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs` 的 `/api/runtime/custom-world/profile` 生成世界底稿。
|
||||
|
||||
@@ -34,7 +34,7 @@ npm run dev:rust
|
||||
1. 检查 `cargo`、`node` 与 `spacetime` CLI。
|
||||
2. Windows Git Bash 下如 `server-rs/.spacetimedb/local/bin/current/spacetimedb-cli.exe` 不存在,先把本机 `spacetime` 所在安装目录的 `bin/` 与 `spacetime.exe` 同步到 `server-rs/.spacetimedb/local/`。
|
||||
3. 启动 `spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101`,确保本地数据库与 SpacetimeDB 内部日志不会落到开发者全局目录。
|
||||
4. 等待 `spacetime --root-dir=server-rs/.spacetimedb/local server ping http://127.0.0.1:3101` 可用;判定标准必须包含输出中的 `Server is online:`,不能只依赖 CLI 退出码,因为 SpacetimeDB CLI `2.1.0` 在 `502 Bad Gateway` 时也可能返回退出码 `0`。
|
||||
4. 等待 SpacetimeDB 就绪:优先接受 `spacetime --root-dir=server-rs/.spacetimedb/local server ping http://127.0.0.1:3101` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:3101/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。
|
||||
5. 执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`,确保 publish 的签名身份与 standalone 的本地控制库一致,并在当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。
|
||||
6. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。
|
||||
7. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
||||
@@ -103,7 +103,7 @@ npm run dev:rust:logs -- --follow
|
||||
2. `spacetime --root-dir=server-rs/.spacetimedb/local list --server http://127.0.0.1:3101` 应能看到 `spacetime.local.json` 中的库名;若没有,执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`。
|
||||
3. 发布库名与 `GENARRATIVE_SPACETIME_DATABASE` 不一致时,`/api/runtime/custom-world-gallery` 会从 Rust `api-server` 返回 `502`,前端首页只能展示空态或错误提示,无法自行修复。
|
||||
4. 如果 Vite 输出 `/api/auth/refresh`、`/api/auth/login-options` 或 `/api/runtime/custom-world-gallery` 的 `ECONNREFUSED`,先确认当前脚本是否已经打印 `等待 api-server 就绪` 并通过;正常情况下 Vite 只会在 `/healthz` 可访问后启动,不应再因为 Rust 监听未完成而代理失败。
|
||||
5. 如果 `spacetime server ping` 打印 `Server could not be reached (502 Bad Gateway)`,即使命令退出码为 `0` 也必须视为未就绪;脚本会继续等待新启动实例,或在 root-dir 已被其他实例占用时输出占用进程。
|
||||
5. 如果 `spacetime server ping` 打印 `Server could not be reached (502 Bad Gateway)`,即使命令退出码为 `0` 也不能直接视为已就绪;本地脚本会继续探测 `/v1/ping`。若 `/v1/ping` 返回 `200`,说明 standalone 已经可用,可以继续发布模块;若 `/v1/ping` 也失败,脚本会继续等待新启动实例,或在 root-dir 已被其他实例占用时输出占用进程。
|
||||
|
||||
编译警告治理:
|
||||
|
||||
@@ -140,12 +140,14 @@ npm run deploy:rust:remote
|
||||
3. 使用 Vite 构建前端 release 到目标目录的 `web/`。
|
||||
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
|
||||
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
|
||||
6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,避免目标服务器 Bash 加载环境文件失败。
|
||||
6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,并把 `GENARRATIVE_SPACETIME_DATABASE` 覆盖为本次 `--database` 参数,避免 Jenkins 工作区里残留的旧 `.env.local` 覆盖发布包目标库。
|
||||
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。
|
||||
8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。
|
||||
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
|
||||
|
||||
SpacetimeDB database 名称只能包含数字、字母、点和短横线;不要使用下划线,否则 `spacetime publish` 会报 `invalid characters in database name`。发布包构建脚本和 `start.sh` 都会提前拦截这类非法名称。
|
||||
SpacetimeDB database 名称必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会触发 `spacetime publish` 的 `invalid characters in database name`。发布包构建脚本和 `start.sh` 都会提前拦截这类非法名称。
|
||||
|
||||
发布包构建日志会输出 `SpacetimeDB 发布数据库: <database>`;目标服务器执行 `start.sh` 时会在发布前输出最终加载后的 `database/server/root-dir`,用于确认 `.env.local` 或 Jenkins 参数覆盖后的实际发布目标。
|
||||
|
||||
发布包结构:
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ SpacetimeDB reducer 必须保持确定性,不能访问文件系统和网络。
|
||||
|
||||
1. `spacetime-module` 内的导出 procedure 读取迁移白名单表,并直接返回迁移 JSON 字符串。
|
||||
2. Node 运维脚本默认通过 `spacetime call` 调用导出 procedure,把返回的 JSON 字符串写入本地文件。
|
||||
3. Node 运维脚本读取本地 JSON 文件内容。导入时默认先通过 `POST /v1/identity` 创建临时 Web API identity/token,再用当前 CLI 登录态把该 identity 授权为迁移操作员,最后通过 HTTP request body 把 JSON 字符串传给导入 procedure。
|
||||
3. Node 运维脚本读取本地 JSON 文件内容。导入时默认先通过 `POST /v1/identity` 创建临时 Web API identity/token,再用当前 CLI 登录态把该 identity 授权为迁移操作员;小文件直接通过 HTTP request body 传给导入 procedure,大文件自动切成分片上传后再提交。
|
||||
4. 导入 procedure 校验 JSON 与表白名单后,在事务中写入目标数据库。
|
||||
|
||||
procedure 不再访问 HTTP 文件桥,也不接收部署机本地文件路径。这样可以避开 SpacetimeDB 对 private/special-purpose 地址的 HTTP 访问限制,并避免把 private 表内容通过临时 HTTP 服务转发。
|
||||
@@ -30,6 +30,8 @@ SpacetimeDB Wasm 运行环境不支持 `std::time::SystemTime::now()`,procedur
|
||||
|
||||
`database_migration_operator` 只控制迁移 procedure 调用权限,不会被导出或导入,避免把源库的运维权限复制到目标库。
|
||||
|
||||
大文件分片导入额外使用私有临时表 `database_migration_import_chunk` 暂存上传片段。这张表只保存当前导入过程的中间数据,提交成功后自动删除,失败时由脚本尽量调用清理 procedure;它不在迁移白名单内,也不会被导出到业务迁移 JSON。
|
||||
|
||||
首次授权时,操作员表为空,必须通过编译进模块的 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET` 引导密钥授权第一位操作员。发布脚本会在构建或发布 SpacetimeDB 模块时自动生成一份强随机引导密钥、注入 wasm 编译环境,并在控制台显示;运维人员必须记录对应数据库本次发布输出的密钥。表内已经存在操作员后,后续授权与撤销只能由已有操作员发起;此时不再接受引导密钥越权扩权。
|
||||
|
||||
新增 procedure:
|
||||
@@ -99,6 +101,14 @@ node scripts/spacetime-revoke-migration-operator.mjs \
|
||||
|
||||
`import_database_migration_incremental_from_file(ctx, input)`
|
||||
|
||||
`put_database_migration_import_chunk(ctx, input)`
|
||||
|
||||
`import_database_migration_from_chunks(ctx, input)`
|
||||
|
||||
`import_database_migration_incremental_from_chunks(ctx, input)`
|
||||
|
||||
`clear_database_migration_import_chunks(ctx, input)`
|
||||
|
||||
输入字段:
|
||||
|
||||
- `migration_json`: 导出 procedure 生成的完整迁移 JSON 字符串。
|
||||
@@ -106,6 +116,15 @@ node scripts/spacetime-revoke-migration-operator.mjs \
|
||||
- `replace_existing`: 是否先清空本次迁移文件内实际导入的目标表。不会清空迁移文件未包含的表;分批迁移时只覆盖当前批次。
|
||||
- `dry_run`: 只解析和统计,不写表。
|
||||
|
||||
分片导入字段:
|
||||
|
||||
- `upload_id`: 本次分片上传的唯一 ID,只允许 ASCII 字母、数字、短横线或下划线。
|
||||
- `chunk_index`: 当前分片序号,从 `0` 开始。
|
||||
- `chunk_count`: 本次上传总分片数。
|
||||
- `chunk`: 当前迁移 JSON 片段,单片最多 `1048576` bytes。
|
||||
|
||||
Node 导入脚本默认在文件超过 `524288` bytes 时使用分片导入;如果小文件直接导入仍遇到 `SpacetimeDB HTTP 413: Failed to buffer the request body: length limit exceeded`,也会自动退回分片流程。可通过 `--chunk-size <bytes>` 或环境变量 `GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE` 调小单片大小。
|
||||
|
||||
导入模式:
|
||||
|
||||
- 默认严格追加:不清空目标表,逐行插入;遇到主键或唯一约束冲突时失败并回滚,适合确认目标库没有同表旧数据时使用。
|
||||
@@ -175,6 +194,15 @@ Jenkins 参数 `CLEAR_DATABASE=true` 或手工执行 `./start.sh --clear-databas
|
||||
- 导出旧库:优先使用 `deploy-state/migration-bootstrap-secret.previous.txt`,也就是旧模块编译时注入的密钥。
|
||||
- 导入新库:使用当前发布包 `migration-bootstrap-secret.txt`,也就是新模块编译时注入的密钥。
|
||||
|
||||
如果旧库或新库的 `database_migration_operator` 表已经不为空,bootstrap secret 不能再越权授权新的操作员;此时必须由已有迁移操作员发起授权,或在部署目录 `.env.local` 中配置已授权操作员的连接 token:
|
||||
|
||||
```text
|
||||
GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN=<旧库迁移操作员 token>
|
||||
GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN=<新库迁移操作员 token>
|
||||
```
|
||||
|
||||
`GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN` 只用于 schema 冲突时导出旧库;`GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN` 只用于清库发布新 wasm 后导入回灌。Jenkins 覆盖部署会尽量保留部署目录现有 `.env.local` 中的这两个 token,除非新发布包已经显式提供同名变量。
|
||||
|
||||
如果不是通过 Jenkins 部署脚本覆盖发布包,而是手工替换文件,必须在覆盖前保留旧 `migration-bootstrap-secret.txt`;否则旧库迁移 procedure 可能无法授权导出。
|
||||
|
||||
### 删除表和删除字段
|
||||
@@ -243,17 +271,22 @@ node scripts/spacetime-import-migration-json.mjs \
|
||||
|
||||
1. `POST /v1/identity` 创建临时 Web API identity/token。
|
||||
2. 使用当前机器 `spacetime` CLI 登录态调用 `authorize_database_migration_operator`,授权这个临时 identity。
|
||||
3. 使用 `Authorization: Bearer <临时 token>` 调用 `import_database_migration_from_file`,把完整迁移 JSON 放在 HTTP body 中。
|
||||
4. 导入请求结束后,脚本会用同一个临时 Web API token 调用 `revoke_database_migration_operator`,撤销该临时 identity。
|
||||
3. 使用 `Authorization: Bearer <临时 token>` 导入迁移 JSON。文件不超过 `--chunk-size` 时直接调用 `import_database_migration_from_file`;超过阈值或直接导入触发 HTTP 413 时,先逐片调用 `put_database_migration_import_chunk`,再调用 `import_database_migration_from_chunks` 或 `import_database_migration_incremental_from_chunks`。
|
||||
4. 分片上传或提交失败时,脚本会尽量调用 `clear_database_migration_import_chunks` 清理临时分片。
|
||||
5. 导入请求结束后,脚本会用同一个临时 Web API token 调用 `revoke_database_migration_operator`,撤销该临时 identity。
|
||||
|
||||
所有直接访问 SpacetimeDB Web API 的 POST 请求必须显式发送 `Content-Type: application/json`。部分 SpacetimeDB 版本不会接受省略 content type 或附带非预期 media type 的请求,即使 body 本身是合法 JSON,也会返回 `HTTP 415`。
|
||||
|
||||
如果你已经有可用的数据库连接 token,也可以显式传 `--token <web-api-token>`。这种情况下脚本不会自动授权;该 token 对应的 identity 必须已经是迁移操作员。
|
||||
|
||||
如果 `authorize_database_migration_operator` 返回 `当前 identity 未被授权执行数据库迁移`,说明当前机器 `spacetime` CLI 登录身份不是既有迁移操作员。表内已经存在操作员时,即使提供了正确 bootstrap secret,也不会允许非操作员继续扩权;需要先让既有操作员授权当前部署机 identity,或直接使用既有操作员 token 执行导出/导入。
|
||||
|
||||
正式导入前建议先加 `--dry-run`,确认 JSON 可解析、版本匹配、表名都在迁移白名单内。
|
||||
|
||||
`--dry-run` 不会模拟目标库主键或唯一约束冲突,因此增量模式的 `skipped_row_count` 只有真实导入时才准确。
|
||||
|
||||
如果 Jenkins 或 SpacetimeDB 返回 `HTTP 413`,优先降低导入流水线的 `CHUNK_SIZE`,例如 `262144`。该参数只影响上传到 procedure 的单片 request body,不改变迁移 JSON 的表范围和导入语义。
|
||||
|
||||
不要在只想追加数据时使用 `--replace-existing`。该参数会先删除覆盖范围内的目标表旧数据,再插入迁移文件中的数据;如果源文件不是完整快照,会造成目标表数据丢失。
|
||||
|
||||
如需分批迁移,可用逗号分隔表名:
|
||||
@@ -292,6 +325,6 @@ node scripts/spacetime-export-migration-json.mjs \
|
||||
|
||||
## 风险与限制
|
||||
|
||||
迁移 JSON 作为 procedure 返回值和 HTTP request body 传递,会受 SpacetimeDB 调用响应体、请求体以及中间代理大小限制。数据量较大时,先按 `include_tables` 分批迁移;若单表本身过大,再补充分片 procedure,而不是恢复 HTTP 文件桥。
|
||||
迁移 JSON 作为 procedure 返回值和 HTTP request body 传递,会受 SpacetimeDB 调用响应体、请求体以及中间代理大小限制。导入端已经内置分片上传来规避 `HTTP 413` 请求体限制;如果导出响应本身过大,仍需先按 `include_tables` 分批导出。
|
||||
|
||||
`spacetime call` 在 PowerShell 中手写 JSON 容易被剥掉双引号。导入大文件时也不能把完整 JSON 放进命令行参数,否则 Linux 会在启动子进程时返回 `spawn E2BIG`。推荐使用仓库里的 Node 脚本,由脚本直接走 Web API request body,避免 shell 二次处理和命令行长度限制。
|
||||
|
||||
@@ -32,7 +32,8 @@ npm run spacetime:publish:maincloud
|
||||
|
||||
1. 使用 `cargo build -p spacetime-module --target wasm32-unknown-unknown --release` 构建 wasm。
|
||||
2. 使用 `spacetime publish <database> --server maincloud --bin-path <wasm> --yes` 发布到 Maincloud。
|
||||
3. 输出 `api-server` 需要的 Maincloud 环境变量,便于部署进程复用。
|
||||
3. 发布前输出目标数据库名和 server,便于在 Jenkins 或手工日志中确认实际发布目标。
|
||||
4. 输出 `api-server` 需要的 Maincloud 环境变量,便于部署进程复用。
|
||||
|
||||
如需 schema 冲突时清库发布:
|
||||
|
||||
@@ -56,6 +57,7 @@ npm run api-server:maincloud
|
||||
## 设计约束
|
||||
|
||||
- Maincloud 数据库名必须显式配置,不能默认读取本地 `spacetime.local.json`。
|
||||
- Maincloud 数据库名必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`,只能使用小写字母、数字,并用单个短横线分隔;否则 `spacetime publish` 会报 `invalid characters in database name`。
|
||||
- 发布脚本只处理 SpacetimeDB 模块发布,不启动本地 SpacetimeDB。
|
||||
- `api-server` 继续通过 `SpacetimeClientConfig` 的 `server_url / database / token` 连接数据库,不在前端增加逻辑。
|
||||
- Windows 进程清理只能匹配本仓库 `server-rs/target/debug/api-server.exe` 的完整路径,不能按进程名泛化清理,避免影响其他 Rust 服务。
|
||||
|
||||
@@ -30,6 +30,22 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
|
||||
| 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_runtime_run` |
|
||||
| 资产 | `asset_object`, `asset_entity_binding` |
|
||||
| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference` |
|
||||
| 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` |
|
||||
|
||||
## 运维迁移表
|
||||
|
||||
### `database_migration_operator`
|
||||
|
||||
- 作用:迁移操作员白名单,控制导出、导入、授权和撤销迁移 procedure 的调用权限。
|
||||
- 结构:`operator_identity PK: Identity`, `created_at: Timestamp`, `created_by: Identity`, `note: String`。
|
||||
- 索引:主键 `operator_identity`。
|
||||
|
||||
### `database_migration_import_chunk`
|
||||
|
||||
- 作用:大迁移 JSON 分片导入的私有临时表,用于规避单次 HTTP request body 过大导致的 `HTTP 413`;提交成功后由导入 procedure 自动清理,失败时由脚本尽量清理。
|
||||
- 结构:`chunk_key PK: String`, `upload_id: String`, `chunk_index: u32`, `chunk_count: u32`, `operator_identity: Identity`, `created_at: Timestamp`, `chunk: String`。
|
||||
- 索引:主键 `chunk_key`,`upload_id`。
|
||||
- 迁移边界:不加入迁移白名单,不导出到业务迁移 JSON。
|
||||
|
||||
## 认证表
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ pipeline {
|
||||
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm')
|
||||
booleanParam(name: 'MIGRATE_ON_CONFLICT', defaultValue: true, description: '普通发布遇到 SpacetimeDB schema 冲突时自动导出、清库发布并导入回灌')
|
||||
string(name: 'MIGRATION_DIRECTORY', defaultValue: '', description: '自动迁移 JSON 输出目录,留空则使用部署目录内 database-migrations/<database>')
|
||||
password(name: 'MIGRATION_EXPORT_TOKEN', defaultValue: '', description: '可选,旧库已授权迁移操作员 token,仅部署阶段用于 schema 冲突导出')
|
||||
password(name: 'MIGRATION_IMPORT_TOKEN', defaultValue: '', description: '可选,新库已授权迁移操作员 token,仅部署阶段用于 schema 冲突导入')
|
||||
booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci')
|
||||
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名')
|
||||
string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录')
|
||||
@@ -39,10 +41,11 @@ pipeline {
|
||||
if (!database) {
|
||||
error('DATABASE 不能为空。')
|
||||
}
|
||||
if (!(database ==~ /^[0-9A-Za-z.-]+$/)) {
|
||||
error("DATABASE 只能包含数字、字母、点和短横线,不能包含下划线,当前值: ${database}")
|
||||
if (!(database ==~ /^[a-z0-9]+(-[a-z0-9]+)*$/)) {
|
||||
error('DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔,当前值: ' + database)
|
||||
}
|
||||
env.EFFECTIVE_DATABASE = database
|
||||
echo "SpacetimeDB 发布数据库: ${env.EFFECTIVE_DATABASE}"
|
||||
def apiPort = params.API_PORT?.trim()
|
||||
if (!apiPort) {
|
||||
error('API_PORT 不能为空。')
|
||||
@@ -148,6 +151,8 @@ pipeline {
|
||||
booleanParam(name: 'CLEAR_DATABASE', value: params.CLEAR_DATABASE),
|
||||
booleanParam(name: 'MIGRATE_ON_CONFLICT', value: params.MIGRATE_ON_CONFLICT),
|
||||
string(name: 'MIGRATION_DIRECTORY', value: params.MIGRATION_DIRECTORY),
|
||||
password(name: 'MIGRATION_EXPORT_TOKEN', value: params.MIGRATION_EXPORT_TOKEN),
|
||||
password(name: 'MIGRATION_IMPORT_TOKEN', value: params.MIGRATION_IMPORT_TOKEN),
|
||||
booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', value: params.RUN_DEPLOY_HOOKS_WITH_SUDO),
|
||||
string(name: 'EXPECTED_UPSTREAM_JOB', value: env.JOB_NAME),
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ pipeline {
|
||||
string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir,可选,优先于 DEPLOY_DIRECTORY')
|
||||
string(name: 'INPUT_FILE', defaultValue: '', description: '必填,迁移 JSON 文件路径,相对源码根目录或绝对路径')
|
||||
string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单')
|
||||
string(name: 'CHUNK_SIZE', defaultValue: '524288', description: '迁移 JSON 分片大小,默认 512KiB,用于规避 SpacetimeDB HTTP 413')
|
||||
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '仅校验导入,不写入数据')
|
||||
booleanParam(name: 'INCREMENTAL', defaultValue: true, description: '增量导入,跳过已存在或冲突的行')
|
||||
booleanParam(name: 'REPLACE_EXISTING', defaultValue: false, description: '覆盖本次文件内涉及的表,不可与 INCREMENTAL 同时启用')
|
||||
@@ -80,6 +81,9 @@ pipeline {
|
||||
if [[ -n "${params.INCLUDE_TABLES}" ]]; then
|
||||
args+=(--include "${params.INCLUDE_TABLES}")
|
||||
fi
|
||||
if [[ -n "${params.CHUNK_SIZE}" ]]; then
|
||||
args+=(--chunk-size "${params.CHUNK_SIZE}")
|
||||
fi
|
||||
if [[ "${params.DRY_RUN}" == "true" ]]; then
|
||||
args+=(--dry-run)
|
||||
fi
|
||||
|
||||
@@ -15,6 +15,8 @@ pipeline {
|
||||
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm')
|
||||
booleanParam(name: 'MIGRATE_ON_CONFLICT', defaultValue: true, description: '普通发布遇到 SpacetimeDB schema 冲突时自动导出、清库发布并导入回灌')
|
||||
string(name: 'MIGRATION_DIRECTORY', defaultValue: '', description: '自动迁移 JSON 输出目录,留空则使用部署目录内 database-migrations/<database>')
|
||||
password(name: 'MIGRATION_EXPORT_TOKEN', defaultValue: '', description: '可选,旧库已授权迁移操作员 token,仅用于 schema 冲突导出')
|
||||
password(name: 'MIGRATION_IMPORT_TOKEN', defaultValue: '', description: '可选,新库已授权迁移操作员 token,仅用于 schema 冲突导入')
|
||||
booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', defaultValue: true, description: 'start.sh / stop.sh 是否通过 sudo -n 执行')
|
||||
string(name: 'EXPECTED_UPSTREAM_JOB', defaultValue: '', description: '允许触发本作业的上游作业名')
|
||||
}
|
||||
@@ -99,12 +101,18 @@ pipeline {
|
||||
}
|
||||
|
||||
steps {
|
||||
script {
|
||||
// 部署脚本使用当前 Deploy 作业 checkout 出来的版本,避免重放旧 build 目录时继续执行旧脚本。
|
||||
env.DEPLOY_SCRIPT_PATH = "${pwd()}/scripts/jenkins-deploy-release.sh"
|
||||
}
|
||||
|
||||
dir("${params.SOURCE_WORKSPACE_ROOT}") {
|
||||
sh """
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
test -d "build/${params.BUILD_VERSION}"
|
||||
chmod +x scripts/jenkins-deploy-release.sh
|
||||
deploy_script="${env.DEPLOY_SCRIPT_PATH}"
|
||||
chmod +x "\${deploy_script}"
|
||||
deploy_args=(
|
||||
--source-dir "build/${params.BUILD_VERSION}"
|
||||
--deploy-dir "${params.DEPLOY_DIRECTORY}"
|
||||
@@ -121,11 +129,17 @@ pipeline {
|
||||
if [[ -n "${params.MIGRATION_DIRECTORY}" ]]; then
|
||||
deploy_args+=(--migration-dir "${params.MIGRATION_DIRECTORY}")
|
||||
fi
|
||||
if [[ -n "${params.MIGRATION_EXPORT_TOKEN}" ]]; then
|
||||
deploy_args+=(--migration-export-token "${params.MIGRATION_EXPORT_TOKEN}")
|
||||
fi
|
||||
if [[ -n "${params.MIGRATION_IMPORT_TOKEN}" ]]; then
|
||||
deploy_args+=(--migration-import-token "${params.MIGRATION_IMPORT_TOKEN}")
|
||||
fi
|
||||
if [[ "${params.RUN_DEPLOY_HOOKS_WITH_SUDO}" == "true" ]]; then
|
||||
deploy_args+=(--hook-with-sudo)
|
||||
fi
|
||||
# 只部署上游已构建好的版本目录,避免部署阶段再次构建产生漂移。
|
||||
./scripts/jenkins-deploy-release.sh "\${deploy_args[@]}"
|
||||
"\${deploy_script}" "\${deploy_args[@]}"
|
||||
'
|
||||
"""
|
||||
}
|
||||
|
||||
BIN
logs/codex-mobile-dock-fix.png
Normal file
BIN
logs/codex-mobile-dock-fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 992 KiB |
@@ -71,6 +71,31 @@ normalize_env_file() {
|
||||
cp "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
write_env_override() {
|
||||
local env_file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local temp_file="${env_file}.tmp.$$"
|
||||
|
||||
mkdir -p "$(dirname "${env_file}")"
|
||||
if [[ -f "${env_file}" ]]; then
|
||||
# 发布包参数是本次构建的权威值,必须覆盖从 Jenkins 工作区复制进来的旧 .env.local。
|
||||
awk -v target_key="${key}" '
|
||||
BEGIN {
|
||||
pattern = "^[[:space:]]*(export[[:space:]]+)?" target_key "="
|
||||
}
|
||||
$0 !~ pattern {
|
||||
print
|
||||
}
|
||||
' "${env_file}" >"${temp_file}"
|
||||
else
|
||||
: >"${temp_file}"
|
||||
fi
|
||||
|
||||
printf "%s=%s\n" "${key}" "${value}" >>"${temp_file}"
|
||||
cp "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
copy_optional_file() {
|
||||
local source_path="$1"
|
||||
local target_path_a="$2"
|
||||
@@ -270,11 +295,13 @@ if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${DATABASE}" =~ ^[0-9A-Za-z.-]+$ ]]; then
|
||||
echo "[deploy:rust] --database 只能包含数字、字母、点和短横线,不能包含下划线: ${DATABASE}" >&2
|
||||
if [[ ! "${DATABASE}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[deploy:rust] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${DATABASE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy:rust] SpacetimeDB 发布数据库: ${DATABASE}"
|
||||
|
||||
if [[ "${SKIP_SPACETIME_BUILD}" -eq 1 && "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||||
echo "[deploy:rust] --skip-spacetime-build 无法把迁移引导密钥注入 wasm。" >&2
|
||||
echo "[deploy:rust] 请移除 --skip-spacetime-build,或同时传 --no-migration-bootstrap-secret。" >&2
|
||||
@@ -328,6 +355,8 @@ echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
|
||||
|
||||
copy_optional_file "${REPO_ROOT}/.env" "${TARGET_DIR}/.env" "${WEB_DIR}/.env" ".env"
|
||||
copy_optional_file "${REPO_ROOT}/.env.local" "${TARGET_DIR}/.env.local" "${WEB_DIR}/.env.local" ".env.local"
|
||||
write_env_override "${TARGET_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}"
|
||||
write_env_override "${WEB_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}"
|
||||
|
||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
|
||||
@@ -613,6 +642,8 @@ SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-__GENARRATIVE_DEFAULT_SPAC
|
||||
SPACETIME_TIMEOUT_SECONDS="${GENARRATIVE_SPACETIME_TIMEOUT_SECONDS:-60}"
|
||||
SPACETIME_MIGRATE_ON_CONFLICT="${GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT:-true}"
|
||||
SPACETIME_MIGRATION_DIR="${GENARRATIVE_SPACETIME_MIGRATION_DIR:-}"
|
||||
SPACETIME_MIGRATION_EXPORT_TOKEN="${GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN:-}"
|
||||
SPACETIME_MIGRATION_IMPORT_TOKEN="${GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN:-}"
|
||||
API_HOST="${GENARRATIVE_API_HOST:-__GENARRATIVE_DEFAULT_API_HOST__}"
|
||||
API_PORT="${GENARRATIVE_API_PORT:-__GENARRATIVE_DEFAULT_API_PORT__}"
|
||||
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
|
||||
@@ -662,8 +693,8 @@ sanitize_path_segment() {
|
||||
validate_spacetime_database_name() {
|
||||
local database="$1"
|
||||
|
||||
if [[ ! "${database}" =~ ^[0-9A-Za-z.-]+$ ]]; then
|
||||
echo "[start] GENARRATIVE_SPACETIME_DATABASE 只能包含数字、字母、点和短横线,不能包含下划线: ${database}" >&2
|
||||
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[start] GENARRATIVE_SPACETIME_DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -737,13 +768,28 @@ run_publish() {
|
||||
run_conflict_migration_publish() {
|
||||
local export_bootstrap_secret=""
|
||||
local import_bootstrap_secret=""
|
||||
local export_auth_args=()
|
||||
local import_auth_args=()
|
||||
local migration_database_slug=""
|
||||
local migration_root=""
|
||||
local migration_file=""
|
||||
local publish_log=""
|
||||
|
||||
export_bootstrap_secret="$(read_export_migration_bootstrap_secret)"
|
||||
import_bootstrap_secret="$(read_import_migration_bootstrap_secret)"
|
||||
if [[ -n "${SPACETIME_MIGRATION_EXPORT_TOKEN}" ]]; then
|
||||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN 导出旧库"
|
||||
export_auth_args=(--token "${SPACETIME_MIGRATION_EXPORT_TOKEN}")
|
||||
else
|
||||
export_bootstrap_secret="$(read_export_migration_bootstrap_secret)"
|
||||
export_auth_args=(--bootstrap-secret "${export_bootstrap_secret}")
|
||||
fi
|
||||
|
||||
if [[ -n "${SPACETIME_MIGRATION_IMPORT_TOKEN}" ]]; then
|
||||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN 导入新库"
|
||||
import_auth_args=(--token "${SPACETIME_MIGRATION_IMPORT_TOKEN}")
|
||||
else
|
||||
import_bootstrap_secret="$(read_import_migration_bootstrap_secret)"
|
||||
import_auth_args=(--bootstrap-secret "${import_bootstrap_secret}")
|
||||
fi
|
||||
require_migration_script "${MIGRATION_EXPORT_SCRIPT}"
|
||||
require_migration_script "${MIGRATION_IMPORT_SCRIPT}"
|
||||
|
||||
@@ -758,7 +804,7 @@ run_conflict_migration_publish() {
|
||||
--server-url "${SPACETIME_SERVER_URL}" \
|
||||
--root-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${SPACETIME_DATABASE}" \
|
||||
--bootstrap-secret "${export_bootstrap_secret}" \
|
||||
"${export_auth_args[@]}" \
|
||||
--out "${migration_file}" \
|
||||
--note "deploy conflict export $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
@@ -783,7 +829,7 @@ run_conflict_migration_publish() {
|
||||
--server-url "${SPACETIME_SERVER_URL}" \
|
||||
--root-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${SPACETIME_DATABASE}" \
|
||||
--bootstrap-secret "${import_bootstrap_secret}" \
|
||||
"${import_auth_args[@]}" \
|
||||
--in "${migration_file}" \
|
||||
--replace-existing \
|
||||
--note "deploy conflict import $(date -u +%Y-%m-%dT%H:%M:%SZ)"; then
|
||||
@@ -985,9 +1031,15 @@ start_process() {
|
||||
echo "$!" >"${pid_file}"
|
||||
}
|
||||
|
||||
validate_spacetime_database_name "${SPACETIME_DATABASE}"
|
||||
|
||||
echo "[start] SpacetimeDB 发布配置:"
|
||||
echo "[start] - database: ${SPACETIME_DATABASE}"
|
||||
echo "[start] - server: ${SPACETIME_SERVER_URL}"
|
||||
echo "[start] - root-dir: ${SPACETIME_ROOT_DIR}"
|
||||
|
||||
require_command node
|
||||
require_command spacetime
|
||||
validate_spacetime_database_name "${SPACETIME_DATABASE}"
|
||||
|
||||
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_ROOT_DIR}"
|
||||
sync_ubuntu_spacetime_install "${SPACETIME_ROOT_DIR}"
|
||||
@@ -1075,6 +1127,7 @@ echo "[start] 完成"
|
||||
echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}"
|
||||
echo "[start] API: http://${API_HOST}:${API_PORT}"
|
||||
echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}"
|
||||
echo "[start] SpacetimeDB database: ${SPACETIME_DATABASE}"
|
||||
START_SCRIPT
|
||||
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_HOST__" "${SPACETIME_HOST}"
|
||||
|
||||
@@ -96,12 +96,23 @@ is_spacetime_ready() {
|
||||
local root_dir="$2"
|
||||
local output
|
||||
|
||||
if ! output="$(spacetime --root-dir="${root_dir}" server ping "${server}" 2>&1)"; then
|
||||
return 1
|
||||
if output="$(spacetime --root-dir="${root_dir}" server ping "${server}" 2>&1)" &&
|
||||
[[ "${output}" == *"Server is online:"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# SpacetimeDB CLI 2.1.0 在 502 Bad Gateway 时仍可能返回 0,不能只依赖退出码。
|
||||
[[ "${output}" == *"Server is online:"* ]]
|
||||
# SpacetimeDB CLI 2.1.0 在 Windows 下可能对已监听的 standalone 返回 502;
|
||||
# 直接探测 HTTP 健康端点,避免 npm run dev:rust 卡在“等待 SpacetimeDB 就绪”。
|
||||
node -e '
|
||||
const target = new URL("/v1/ping", process.argv[1]);
|
||||
const client = target.protocol === "https:" ? require("https") : require("http");
|
||||
const request = client.get(target, { timeout: 1000 }, (response) => {
|
||||
response.resume();
|
||||
process.exit(response.statusCode >= 200 && response.statusCode < 300 ? 0 : 1);
|
||||
});
|
||||
request.on("timeout", () => request.destroy(new Error("timeout")));
|
||||
request.on("error", () => process.exit(1));
|
||||
' "${server}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
describe_spacetime_root_owner() {
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative --web-port 25001 [--clear-database] [--no-migrate-on-conflict] [--migration-dir /path/to/migrations] [--hook-with-sudo]
|
||||
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative --web-port 25001 [--clear-database] [--no-migrate-on-conflict] [--migration-dir /path/to/migrations] [--migration-export-token <token>] [--migration-import-token <token>] [--hook-with-sudo]
|
||||
|
||||
说明:
|
||||
1. 如果部署目录已有旧版本且存在 stop.sh,则先执行旧版本 stop.sh。
|
||||
@@ -23,6 +23,8 @@ usage() {
|
||||
--migrate-on-conflict 可选,普通发布遇到 schema 冲突时自动迁移,默认启用
|
||||
--no-migrate-on-conflict 可选,禁用 schema 冲突自动迁移
|
||||
--migration-dir <path> 可选,自动迁移 JSON 输出目录,默认部署目录内 database-migrations/<database>
|
||||
--migration-export-token <token> 可选,旧库已授权迁移操作员 token,仅用于 schema 冲突导出
|
||||
--migration-import-token <token> 可选,新库已授权迁移操作员 token,仅用于 schema 冲突导入
|
||||
--hook-with-sudo 可选,仅对 start.sh/stop.sh 使用 sudo -n 执行
|
||||
EOF
|
||||
}
|
||||
@@ -59,6 +61,15 @@ validate_port() {
|
||||
fi
|
||||
}
|
||||
|
||||
validate_spacetime_database_name() {
|
||||
local database="$1"
|
||||
|
||||
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[jenkins-deploy] GENARRATIVE_SPACETIME_DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_env_file() {
|
||||
local env_file="$1"
|
||||
local temp_file="${env_file}.tmp.$$"
|
||||
@@ -72,6 +83,57 @@ normalize_env_file() {
|
||||
cp "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
shift
|
||||
local env_file=""
|
||||
local line=""
|
||||
local line_number=0
|
||||
local parsed_key=""
|
||||
local parsed_value=""
|
||||
local value=""
|
||||
local utf8_bom=$'\xef\xbb\xbf'
|
||||
|
||||
for env_file in "$@"; do
|
||||
if [[ ! -f "${env_file}" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
line_number=0
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line_number=$((line_number + 1))
|
||||
if [[ "${line_number}" -eq 1 ]]; then
|
||||
line="${line#"${utf8_bom}"}"
|
||||
fi
|
||||
line="${line%$'\r'}"
|
||||
|
||||
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
parsed_key="${BASH_REMATCH[2]}"
|
||||
parsed_value="${BASH_REMATCH[3]}"
|
||||
if [[ "${parsed_key}" != "${key}" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
value="${parsed_value}"
|
||||
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
value="${value//\\\"/\"}"
|
||||
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
fi
|
||||
done <"${env_file}"
|
||||
done
|
||||
|
||||
printf "%s" "${value}"
|
||||
}
|
||||
|
||||
normalize_release_env_files() {
|
||||
local release_dir="$1"
|
||||
|
||||
@@ -113,6 +175,10 @@ CLEAR_DATABASE="0"
|
||||
MIGRATE_ON_CONFLICT="true"
|
||||
MIGRATION_DIR=""
|
||||
HOOK_WITH_SUDO="0"
|
||||
MIGRATION_EXPORT_TOKEN=""
|
||||
MIGRATION_IMPORT_TOKEN=""
|
||||
PRESERVED_MIGRATION_EXPORT_TOKEN=""
|
||||
PRESERVED_MIGRATION_IMPORT_TOKEN=""
|
||||
DEPLOY_ITEMS=(
|
||||
".env"
|
||||
".env.local"
|
||||
@@ -162,6 +228,14 @@ while [[ $# -gt 0 ]]; do
|
||||
MIGRATION_DIR="${2:?缺少 --migration-dir 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--migration-export-token)
|
||||
MIGRATION_EXPORT_TOKEN="${2:?缺少 --migration-export-token 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--migration-import-token)
|
||||
MIGRATION_IMPORT_TOKEN="${2:?缺少 --migration-import-token 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--hook-with-sudo)
|
||||
HOOK_WITH_SUDO="1"
|
||||
shift
|
||||
@@ -288,6 +362,8 @@ if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
|
||||
fi
|
||||
|
||||
normalize_release_env_files "${SOURCE_DIR}"
|
||||
PRESERVED_MIGRATION_EXPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
PRESERVED_MIGRATION_IMPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
|
||||
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
|
||||
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
|
||||
@@ -336,6 +412,26 @@ normalize_release_env_files "${DEPLOY_DIR}"
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_WEB_PORT" "${WEB_PORT}"
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT" "${MIGRATE_ON_CONFLICT}"
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_DIR" "${MIGRATION_DIR}"
|
||||
if [[ -n "${MIGRATION_EXPORT_TOKEN}" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${MIGRATION_EXPORT_TOKEN}"
|
||||
elif [[ -n "${PRESERVED_MIGRATION_EXPORT_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${PRESERVED_MIGRATION_EXPORT_TOKEN}"
|
||||
fi
|
||||
if [[ -n "${MIGRATION_IMPORT_TOKEN}" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${MIGRATION_IMPORT_TOKEN}"
|
||||
elif [[ -n "${PRESERVED_MIGRATION_IMPORT_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${PRESERVED_MIGRATION_IMPORT_TOKEN}"
|
||||
fi
|
||||
|
||||
DEPLOY_DATABASE="$(read_env_value "GENARRATIVE_SPACETIME_DATABASE" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
if [[ -z "${DEPLOY_DATABASE}" ]]; then
|
||||
echo "[jenkins-deploy] 部署包未显式写入 GENARRATIVE_SPACETIME_DATABASE;将由 start.sh 使用构建时默认值。" >&2
|
||||
else
|
||||
validate_spacetime_database_name "${DEPLOY_DATABASE}"
|
||||
echo "[jenkins-deploy] SpacetimeDB 发布数据库: ${DEPLOY_DATABASE}"
|
||||
fi
|
||||
|
||||
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
|
||||
if [[ "${CLEAR_DATABASE}" == "1" ]]; then
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedure,
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
@@ -17,11 +18,16 @@ try {
|
||||
operator_identity_hex: options.operatorIdentity,
|
||||
note: options.note || '',
|
||||
};
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
if (options.useHttp && !options.token) {
|
||||
throw new Error('--use-http 需要同时传入 --token。');
|
||||
}
|
||||
const result = options.useHttp
|
||||
? await callSpacetimeProcedure(options, 'authorize_database_migration_operator', input)
|
||||
: await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -57,16 +57,24 @@ async function prepareWebExportOptions(options) {
|
||||
`[spacetime:migration:export] 已通过 Web API 创建临时 identity: ${identity.identity}`,
|
||||
);
|
||||
|
||||
const authorizeResult = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
{
|
||||
bootstrap_secret: options.bootstrapSecret || '',
|
||||
operator_identity_hex: identity.identity,
|
||||
note: options.note || 'temporary web api migration export',
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(authorizeResult);
|
||||
try {
|
||||
const authorizeResult = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
{
|
||||
bootstrap_secret: options.bootstrapSecret || '',
|
||||
operator_identity_hex: identity.identity,
|
||||
note: options.note || 'temporary web api migration export',
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(authorizeResult);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`授权临时 Web API identity 失败。当前 spacetime CLI identity 必须已经是迁移操作员;如果旧库迁移操作员表不为空,bootstrap secret 不会越权授权新的操作员。可先用已有迁移操作员授权当前部署机 identity,或为导出脚本提供已有迁移操作员的 --token。原始错误: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
console.log(`[spacetime:migration:export] 已授权临时 Web API identity`);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
parseArgs,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
const DEFAULT_MIGRATION_IMPORT_CHUNK_SIZE = 512 * 1024;
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.in) {
|
||||
@@ -30,7 +33,7 @@ try {
|
||||
const webOptions = await prepareWebImportOptions(options);
|
||||
let result;
|
||||
try {
|
||||
result = await importMigrationJsonDirect(webOptions, migrationJson);
|
||||
result = await importMigrationJsonWithFallback(webOptions, migrationJson);
|
||||
} finally {
|
||||
await revokeTemporaryWebIdentity(webOptions);
|
||||
}
|
||||
@@ -58,16 +61,24 @@ async function prepareWebImportOptions(options) {
|
||||
`[spacetime:migration:import] 已通过 Web API 创建临时 identity: ${identity.identity}`,
|
||||
);
|
||||
|
||||
const authorizeResult = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
{
|
||||
bootstrap_secret: options.bootstrapSecret || '',
|
||||
operator_identity_hex: identity.identity,
|
||||
note: options.note || 'temporary web api migration import',
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(authorizeResult);
|
||||
try {
|
||||
const authorizeResult = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
{
|
||||
bootstrap_secret: options.bootstrapSecret || '',
|
||||
operator_identity_hex: identity.identity,
|
||||
note: options.note || 'temporary web api migration import',
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(authorizeResult);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`授权临时 Web API identity 失败。当前 spacetime CLI identity 必须已经是迁移操作员;如果目标库迁移操作员表不为空,bootstrap secret 不会越权授权新的操作员。可先用已有迁移操作员授权当前部署机 identity,或为导入脚本提供已有迁移操作员的 --token。原始错误: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
console.log(`[spacetime:migration:import] 已授权临时 Web API identity`);
|
||||
|
||||
return {
|
||||
@@ -78,6 +89,25 @@ async function prepareWebImportOptions(options) {
|
||||
};
|
||||
}
|
||||
|
||||
async function importMigrationJsonWithFallback(options, migrationJson) {
|
||||
const chunkSize = resolveChunkSize(options);
|
||||
if (Buffer.byteLength(migrationJson, 'utf8') > chunkSize) {
|
||||
return importMigrationJsonChunked(options, migrationJson, chunkSize);
|
||||
}
|
||||
|
||||
try {
|
||||
return await importMigrationJsonDirect(options, migrationJson);
|
||||
} catch (error) {
|
||||
if (!isRequestBodyTooLargeError(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`[spacetime:migration:import] 直接导入触发 HTTP 413,改用 ${chunkSize} bytes 分片上传。`,
|
||||
);
|
||||
return importMigrationJsonChunked(options, migrationJson, chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
async function importMigrationJsonDirect(options, migrationJson) {
|
||||
const includeTables = resolveImportIncludeTables(options, migrationJson);
|
||||
const procedureName =
|
||||
@@ -100,6 +130,60 @@ async function importMigrationJsonDirect(options, migrationJson) {
|
||||
return callSpacetimeProcedure(options, procedureName, input);
|
||||
}
|
||||
|
||||
async function importMigrationJsonChunked(options, migrationJson, chunkSize) {
|
||||
const includeTables = resolveImportIncludeTables(options, migrationJson);
|
||||
const procedureName =
|
||||
options.incremental === true
|
||||
? 'import_database_migration_incremental_from_chunks'
|
||||
: 'import_database_migration_from_chunks';
|
||||
const uploadId = `migration-${Date.now()}-${randomUUID()}`;
|
||||
const chunks = splitStringByUtf8Bytes(migrationJson, chunkSize);
|
||||
console.log(
|
||||
`[spacetime:migration:import] 使用分片导入: upload_id=${uploadId}, chunks=${chunks.length}, chunk_size=${chunkSize}`,
|
||||
);
|
||||
if (options.replaceExisting === true) {
|
||||
console.log(
|
||||
`[spacetime:migration:import] replace-existing 仅覆盖本次文件内的表: ${includeTables.join(', ') || '无'}`,
|
||||
);
|
||||
} else if (options.incremental === true) {
|
||||
console.log(`[spacetime:migration:import] 使用增量模式,已存在或冲突的行会跳过`);
|
||||
}
|
||||
|
||||
let committed = false;
|
||||
try {
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunkResult = await callSpacetimeProcedure(
|
||||
options,
|
||||
'put_database_migration_import_chunk',
|
||||
{
|
||||
upload_id: uploadId,
|
||||
chunk_index: index,
|
||||
chunk_count: chunks.length,
|
||||
chunk: chunks[index],
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(chunkResult);
|
||||
console.log(
|
||||
`[spacetime:migration:import] 已上传迁移分片 ${index + 1}/${chunks.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await callSpacetimeProcedure(options, procedureName, {
|
||||
upload_id: uploadId,
|
||||
include_tables: includeTables,
|
||||
replace_existing: options.replaceExisting === true,
|
||||
dry_run: options.dryRun === true,
|
||||
});
|
||||
ensureProcedureOk(result);
|
||||
committed = true;
|
||||
return result;
|
||||
} finally {
|
||||
if (!committed) {
|
||||
await clearMigrationChunksBestEffort(options, uploadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImportIncludeTables(options, migrationJson) {
|
||||
if (options.replaceExisting !== true) {
|
||||
return options.includeTables;
|
||||
@@ -144,6 +228,63 @@ function readMigrationTableNames(migrationJson) {
|
||||
return tableNames;
|
||||
}
|
||||
|
||||
function resolveChunkSize(options) {
|
||||
const chunkSize = options.chunkSize || DEFAULT_MIGRATION_IMPORT_CHUNK_SIZE;
|
||||
if (chunkSize > 1024 * 1024) {
|
||||
throw new Error('--chunk-size 不能超过 1048576,避免触发迁移分片 procedure 单片限制。');
|
||||
}
|
||||
return chunkSize;
|
||||
}
|
||||
|
||||
function splitStringByUtf8Bytes(value, maxBytes) {
|
||||
const chunks = [];
|
||||
let current = '';
|
||||
let currentBytes = 0;
|
||||
for (const character of value) {
|
||||
const characterBytes = Buffer.byteLength(character, 'utf8');
|
||||
if (characterBytes > maxBytes) {
|
||||
throw new Error(`单个字符超过 chunk-size,当前 chunk-size: ${maxBytes}`);
|
||||
}
|
||||
if (currentBytes + characterBytes > maxBytes && current) {
|
||||
chunks.push(current);
|
||||
current = '';
|
||||
currentBytes = 0;
|
||||
}
|
||||
current += character;
|
||||
currentBytes += characterBytes;
|
||||
}
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function isRequestBodyTooLargeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('HTTP 413') ||
|
||||
message.toLowerCase().includes('length limit exceeded')
|
||||
);
|
||||
}
|
||||
|
||||
async function clearMigrationChunksBestEffort(options, uploadId) {
|
||||
try {
|
||||
const result = await callSpacetimeProcedure(
|
||||
options,
|
||||
'clear_database_migration_import_chunks',
|
||||
{ upload_id: uploadId },
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
console.warn(`[spacetime:migration:import] 已清理失败导入的临时分片: ${uploadId}`);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[spacetime:migration:import] 清理临时迁移分片失败: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTemporaryWebIdentity(options) {
|
||||
if (!options.temporaryWebIdentity) {
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,10 @@ import path from 'node:path';
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {
|
||||
chunkSize: parseOptionalPositiveInteger(
|
||||
process.env.GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE,
|
||||
'GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE',
|
||||
),
|
||||
database:
|
||||
process.env.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE ||
|
||||
process.env.GENARRATIVE_SPACETIME_DATABASE ||
|
||||
@@ -48,6 +52,8 @@ export function parseArgs(argv) {
|
||||
options.token = readValue(arg);
|
||||
} else if (arg === '--bootstrap-secret') {
|
||||
options.bootstrapSecret = readValue(arg);
|
||||
} else if (arg === '--chunk-size') {
|
||||
options.chunkSize = parsePositiveInteger(readValue(arg), arg);
|
||||
} else if (arg === '--operator-identity') {
|
||||
options.operatorIdentity = readValue(arg);
|
||||
} else if (arg === '--note') {
|
||||
@@ -81,10 +87,30 @@ export function parseArgs(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
export function parsePositiveInteger(value, name) {
|
||||
if (!/^[1-9][0-9]*$/u.test(String(value).trim())) {
|
||||
throw new Error(`${name} 必须是正整数。`);
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(value).trim(), 10);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${name} 超出安全整数范围。`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value, name) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
return parsePositiveInteger(value, name);
|
||||
}
|
||||
|
||||
export function buildSpacetimeCallArgs(options, procedureName, input) {
|
||||
if (!options.database) {
|
||||
throw new Error('必须传入 --database。');
|
||||
}
|
||||
validateSpacetimeDatabaseName(options.database);
|
||||
|
||||
const args = [];
|
||||
if (options.rootDir) {
|
||||
@@ -108,6 +134,7 @@ export async function callSpacetimeProcedure(options, procedureName, input) {
|
||||
if (!options.database) {
|
||||
throw new Error('必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。');
|
||||
}
|
||||
validateSpacetimeDatabaseName(options.database);
|
||||
|
||||
const serverUrl = resolveServerUrl(options).replace(/\/+$/u, '');
|
||||
const url = `${serverUrl}/v1/database/${encodeURIComponent(options.database)}/call/${encodeURIComponent(procedureName)}`;
|
||||
@@ -195,6 +222,14 @@ export async function callSpacetimeProcedureViaCli(options, procedureName, input
|
||||
return parseProcedureResult(output);
|
||||
}
|
||||
|
||||
export function validateSpacetimeDatabaseName(database) {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/u.test(database)) {
|
||||
throw new Error(
|
||||
`SpacetimeDB 数据库名必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProcedureResult(output) {
|
||||
const candidates = [];
|
||||
const trimmed = output.trim();
|
||||
|
||||
@@ -88,6 +88,15 @@ timestamp_slug() {
|
||||
node -e 'process.stdout.write(new Date().toISOString().replace(/[:.]/g, "-"));'
|
||||
}
|
||||
|
||||
validate_spacetime_database_name() {
|
||||
local database="$1"
|
||||
|
||||
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[spacetime:maincloud] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
is_publish_conflict_output() {
|
||||
local output="$1"
|
||||
[[ "${output}" == *"conflict"* ]] \
|
||||
@@ -209,6 +218,11 @@ if [[ -z "${SPACETIME_DATABASE}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_spacetime_database_name "${SPACETIME_DATABASE}"
|
||||
|
||||
echo "[spacetime:maincloud] SpacetimeDB 发布数据库: ${SPACETIME_DATABASE}"
|
||||
echo "[spacetime:maincloud] SpacetimeDB server: ${SPACETIME_SERVER_ALIAS} (${SPACETIME_SERVER_URL})"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "[spacetime:maincloud] 缺少 cargo 命令。" >&2
|
||||
exit 1
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::puzzle::{
|
||||
|
||||
const MIGRATION_SCHEMA_VERSION: u32 = 1;
|
||||
const MIGRATION_MAX_TABLE_NAME_LEN: usize = 96;
|
||||
const MIGRATION_MAX_IMPORT_UPLOAD_ID_LEN: usize = 128;
|
||||
const MIGRATION_MAX_IMPORT_CHUNK_BYTES: usize = 1024 * 1024;
|
||||
const MIGRATION_MAX_OPERATOR_NOTE_CHARS: usize = 160;
|
||||
const MIGRATION_MIN_BOOTSTRAP_SECRET_LEN: usize = 16;
|
||||
const MIGRATION_BOOTSTRAP_SECRET: Option<&str> =
|
||||
@@ -24,6 +26,21 @@ pub struct DatabaseMigrationOperator {
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = database_migration_import_chunk,
|
||||
index(accessor = by_database_migration_import_upload, btree(columns = [upload_id]))
|
||||
)]
|
||||
pub struct DatabaseMigrationImportChunk {
|
||||
#[primary_key]
|
||||
pub chunk_key: String,
|
||||
pub upload_id: String,
|
||||
pub chunk_index: u32,
|
||||
pub chunk_count: u32,
|
||||
pub operator_identity: Identity,
|
||||
pub created_at: Timestamp,
|
||||
pub chunk: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct DatabaseMigrationExportInput {
|
||||
pub include_tables: Vec<String>,
|
||||
@@ -37,6 +54,27 @@ pub struct DatabaseMigrationImportInput {
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct DatabaseMigrationImportChunkInput {
|
||||
pub upload_id: String,
|
||||
pub chunk_index: u32,
|
||||
pub chunk_count: u32,
|
||||
pub chunk: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct DatabaseMigrationImportChunksInput {
|
||||
pub upload_id: String,
|
||||
pub include_tables: Vec<String>,
|
||||
pub replace_existing: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct DatabaseMigrationImportChunksClearInput {
|
||||
pub upload_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DatabaseMigrationImportMode {
|
||||
Strict,
|
||||
@@ -322,6 +360,76 @@ pub fn import_database_migration_incremental_from_file(
|
||||
}
|
||||
}
|
||||
|
||||
// 大迁移 JSON 先按分片写入私有临时表,避免单次 HTTP request body 触发 SpacetimeDB 413。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn put_database_migration_import_chunk(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationImportChunkInput,
|
||||
) -> DatabaseMigrationProcedureResult {
|
||||
match put_database_migration_import_chunk_inner(ctx, input) {
|
||||
Ok(()) => empty_database_migration_result(true, None),
|
||||
Err(error) => empty_database_migration_result(false, Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
// 分片提交保持与直接导入相同的严格追加语义;提交成功后清理临时分片。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn import_database_migration_from_chunks(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationImportChunksInput,
|
||||
) -> DatabaseMigrationProcedureResult {
|
||||
match import_database_migration_from_chunks_inner(
|
||||
ctx,
|
||||
input,
|
||||
DatabaseMigrationImportMode::Strict,
|
||||
) {
|
||||
Ok((stats, warnings)) => DatabaseMigrationProcedureResult {
|
||||
ok: true,
|
||||
schema_version: MIGRATION_SCHEMA_VERSION,
|
||||
migration_json: None,
|
||||
table_stats: stats,
|
||||
warnings,
|
||||
error_message: None,
|
||||
},
|
||||
Err(error) => empty_database_migration_result(false, Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
// 分片增量提交只插入目标库缺失的行;主键或唯一约束冲突的行会跳过。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn import_database_migration_incremental_from_chunks(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationImportChunksInput,
|
||||
) -> DatabaseMigrationProcedureResult {
|
||||
match import_database_migration_from_chunks_inner(
|
||||
ctx,
|
||||
input,
|
||||
DatabaseMigrationImportMode::Incremental,
|
||||
) {
|
||||
Ok((stats, warnings)) => DatabaseMigrationProcedureResult {
|
||||
ok: true,
|
||||
schema_version: MIGRATION_SCHEMA_VERSION,
|
||||
migration_json: None,
|
||||
table_stats: stats,
|
||||
warnings,
|
||||
error_message: None,
|
||||
},
|
||||
Err(error) => empty_database_migration_result(false, Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
// 调用方上传失败或提交失败时可显式清理同一 upload_id 的临时分片。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn clear_database_migration_import_chunks(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
) -> DatabaseMigrationProcedureResult {
|
||||
match clear_database_migration_import_chunks_inner(ctx, input) {
|
||||
Ok(()) => empty_database_migration_result(true, None),
|
||||
Err(error) => empty_database_migration_result(false, Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn export_database_migration_to_file_inner(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationExportInput,
|
||||
@@ -362,14 +470,7 @@ fn import_database_migration_from_file_inner(
|
||||
}
|
||||
ctx.try_with_tx(|tx| require_migration_operator(tx, caller))?;
|
||||
|
||||
let migration_file = serde_json::from_str::<MigrationFile>(&input.migration_json)
|
||||
.map_err(|error| format!("迁移文件 JSON 解析失败: {error}"))?;
|
||||
if migration_file.schema_version != MIGRATION_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"迁移文件 schema_version 不匹配,期望 {},实际 {}",
|
||||
MIGRATION_SCHEMA_VERSION, migration_file.schema_version
|
||||
));
|
||||
}
|
||||
let migration_file = parse_migration_file(&input.migration_json)?;
|
||||
|
||||
let (stats, warnings) = if input.dry_run {
|
||||
build_import_dry_run_stats(&migration_file.tables, included_tables.as_ref())?
|
||||
@@ -389,6 +490,158 @@ fn import_database_migration_from_file_inner(
|
||||
Ok((stats, warnings))
|
||||
}
|
||||
|
||||
fn put_database_migration_import_chunk_inner(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationImportChunkInput,
|
||||
) -> Result<(), String> {
|
||||
let caller = ctx.sender();
|
||||
let upload_id = normalize_import_upload_id(&input.upload_id)?;
|
||||
if input.chunk_count == 0 {
|
||||
return Err("分片总数必须大于 0".to_string());
|
||||
}
|
||||
if input.chunk_index >= input.chunk_count {
|
||||
return Err(format!(
|
||||
"分片序号越界: {} / {}",
|
||||
input.chunk_index, input.chunk_count
|
||||
));
|
||||
}
|
||||
if input.chunk.is_empty() {
|
||||
return Err("迁移 JSON 分片不能为空".to_string());
|
||||
}
|
||||
if input.chunk.len() > MIGRATION_MAX_IMPORT_CHUNK_BYTES {
|
||||
return Err(format!(
|
||||
"迁移 JSON 分片过大,单片最多 {} bytes",
|
||||
MIGRATION_MAX_IMPORT_CHUNK_BYTES
|
||||
));
|
||||
}
|
||||
|
||||
let chunk_key = build_import_chunk_key(&upload_id, input.chunk_index);
|
||||
ctx.try_with_tx(|tx| {
|
||||
require_migration_operator(tx, caller)?;
|
||||
if let Some(existing) = tx
|
||||
.db
|
||||
.database_migration_import_chunk()
|
||||
.chunk_key()
|
||||
.find(&chunk_key)
|
||||
{
|
||||
if existing.operator_identity != caller {
|
||||
return Err("同名迁移分片已由其他 identity 上传,已拒绝覆盖".to_string());
|
||||
}
|
||||
tx.db
|
||||
.database_migration_import_chunk()
|
||||
.chunk_key()
|
||||
.delete(&chunk_key);
|
||||
}
|
||||
tx.db
|
||||
.database_migration_import_chunk()
|
||||
.insert(DatabaseMigrationImportChunk {
|
||||
chunk_key: chunk_key.clone(),
|
||||
upload_id: upload_id.clone(),
|
||||
chunk_index: input.chunk_index,
|
||||
chunk_count: input.chunk_count,
|
||||
operator_identity: caller,
|
||||
created_at: tx.timestamp,
|
||||
chunk: input.chunk.clone(),
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import_database_migration_from_chunks_inner(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationImportChunksInput,
|
||||
import_mode: DatabaseMigrationImportMode,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<DatabaseMigrationTableStat>,
|
||||
Vec<DatabaseMigrationWarning>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let caller = ctx.sender();
|
||||
let upload_id = normalize_import_upload_id(&input.upload_id)?;
|
||||
let included_tables = normalize_include_tables(&input.include_tables)?;
|
||||
if import_mode == DatabaseMigrationImportMode::Incremental && input.replace_existing {
|
||||
return Err("增量导入不能同时启用 replace_existing".to_string());
|
||||
}
|
||||
|
||||
let migration_json = ctx.try_with_tx(|tx| {
|
||||
require_migration_operator(tx, caller)?;
|
||||
read_database_migration_import_chunks(tx, &upload_id, caller)
|
||||
})?;
|
||||
let migration_file = parse_migration_file(&migration_json)?;
|
||||
|
||||
let (stats, warnings) = if input.dry_run {
|
||||
build_import_dry_run_stats(&migration_file.tables, included_tables.as_ref())?
|
||||
} else {
|
||||
ctx.try_with_tx(|tx| {
|
||||
require_migration_operator(tx, caller)?;
|
||||
apply_migration_file(
|
||||
tx,
|
||||
&migration_file,
|
||||
included_tables.as_ref(),
|
||||
input.replace_existing,
|
||||
import_mode,
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
ctx.try_with_tx(|tx| {
|
||||
require_migration_operator(tx, caller)?;
|
||||
clear_database_migration_import_chunks_tx(tx, &upload_id);
|
||||
Ok::<(), String>(())
|
||||
})?;
|
||||
|
||||
Ok((stats, warnings))
|
||||
}
|
||||
|
||||
fn clear_database_migration_import_chunks_inner(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
) -> Result<(), String> {
|
||||
let caller = ctx.sender();
|
||||
let upload_id = normalize_import_upload_id(&input.upload_id)?;
|
||||
ctx.try_with_tx(|tx| {
|
||||
require_migration_operator(tx, caller)?;
|
||||
clear_database_migration_import_chunks_tx(tx, &upload_id);
|
||||
Ok::<(), String>(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn empty_database_migration_result(
|
||||
ok: bool,
|
||||
error_message: Option<String>,
|
||||
) -> DatabaseMigrationProcedureResult {
|
||||
DatabaseMigrationProcedureResult {
|
||||
ok,
|
||||
schema_version: MIGRATION_SCHEMA_VERSION,
|
||||
migration_json: None,
|
||||
table_stats: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error_message,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_migration_file(migration_json: &str) -> Result<MigrationFile, String> {
|
||||
if migration_json.trim().is_empty() {
|
||||
return Err("migration_json 不能为空".to_string());
|
||||
}
|
||||
|
||||
let migration_file = serde_json::from_str::<MigrationFile>(migration_json)
|
||||
.map_err(|error| format!("迁移文件 JSON 解析失败: {error}"))?;
|
||||
if migration_file.schema_version != MIGRATION_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"迁移文件 schema_version 不匹配,期望 {},实际 {}",
|
||||
MIGRATION_SCHEMA_VERSION, migration_file.schema_version
|
||||
));
|
||||
}
|
||||
|
||||
Ok(migration_file)
|
||||
}
|
||||
|
||||
fn authorize_database_migration_operator_inner(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: DatabaseMigrationAuthorizeOperatorInput,
|
||||
@@ -530,6 +783,96 @@ fn normalize_migration_operator_note(input: &str) -> Result<String, String> {
|
||||
Ok(note.to_string())
|
||||
}
|
||||
|
||||
fn normalize_import_upload_id(input: &str) -> Result<String, String> {
|
||||
let upload_id = input.trim();
|
||||
if upload_id.is_empty() {
|
||||
return Err("upload_id 不能为空".to_string());
|
||||
}
|
||||
if upload_id.len() > MIGRATION_MAX_IMPORT_UPLOAD_ID_LEN {
|
||||
return Err(format!(
|
||||
"upload_id 过长,最多 {} bytes",
|
||||
MIGRATION_MAX_IMPORT_UPLOAD_ID_LEN
|
||||
));
|
||||
}
|
||||
if !upload_id
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
|
||||
{
|
||||
return Err("upload_id 只能使用 ASCII 字母、数字、短横线或下划线".to_string());
|
||||
}
|
||||
Ok(upload_id.to_string())
|
||||
}
|
||||
|
||||
fn build_import_chunk_key(upload_id: &str, chunk_index: u32) -> String {
|
||||
format!("{upload_id}:{chunk_index:010}")
|
||||
}
|
||||
|
||||
fn read_database_migration_import_chunks(
|
||||
ctx: &ReducerContext,
|
||||
upload_id: &str,
|
||||
caller: Identity,
|
||||
) -> Result<String, String> {
|
||||
let mut chunks = ctx
|
||||
.db
|
||||
.database_migration_import_chunk()
|
||||
.by_database_migration_import_upload()
|
||||
.filter(upload_id)
|
||||
.collect::<Vec<_>>();
|
||||
if chunks.is_empty() {
|
||||
return Err(format!("未找到迁移 JSON 分片: {upload_id}"));
|
||||
}
|
||||
if chunks.iter().any(|chunk| chunk.operator_identity != caller) {
|
||||
return Err("迁移 JSON 分片包含其他 identity 上传的片段,已拒绝提交".to_string());
|
||||
}
|
||||
|
||||
let chunk_count = chunks[0].chunk_count;
|
||||
if chunk_count == 0 {
|
||||
return Err("迁移 JSON 分片总数不合法".to_string());
|
||||
}
|
||||
if chunks
|
||||
.iter()
|
||||
.any(|chunk| chunk.chunk_count != chunk_count || chunk.upload_id != upload_id)
|
||||
{
|
||||
return Err("迁移 JSON 分片总数不一致".to_string());
|
||||
}
|
||||
if chunks.len() != chunk_count as usize {
|
||||
return Err(format!(
|
||||
"迁移 JSON 分片未上传完整,已收到 {} / {}",
|
||||
chunks.len(),
|
||||
chunk_count
|
||||
));
|
||||
}
|
||||
|
||||
chunks.sort_by_key(|chunk| chunk.chunk_index);
|
||||
let mut expected_index = 0u32;
|
||||
let mut migration_json = String::new();
|
||||
for chunk in chunks {
|
||||
if chunk.chunk_index != expected_index {
|
||||
return Err(format!("迁移 JSON 分片缺失序号: {expected_index}"));
|
||||
}
|
||||
migration_json.push_str(&chunk.chunk);
|
||||
expected_index = expected_index.saturating_add(1);
|
||||
}
|
||||
|
||||
Ok(migration_json)
|
||||
}
|
||||
|
||||
fn clear_database_migration_import_chunks_tx(ctx: &ReducerContext, upload_id: &str) {
|
||||
let chunk_keys = ctx
|
||||
.db
|
||||
.database_migration_import_chunk()
|
||||
.by_database_migration_import_upload()
|
||||
.filter(upload_id)
|
||||
.map(|chunk| chunk.chunk_key)
|
||||
.collect::<Vec<_>>();
|
||||
for chunk_key in chunk_keys {
|
||||
ctx.db
|
||||
.database_migration_import_chunk()
|
||||
.chunk_key()
|
||||
.delete(&chunk_key);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_include_tables(input: &[String]) -> Result<Option<HashSet<String>>, String> {
|
||||
if input.is_empty() {
|
||||
return Ok(None);
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -1,4 +1,11 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
@@ -133,7 +140,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} flex h-screen max-h-screen flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
|
||||
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
|
||||
>
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
|
||||
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
@@ -221,6 +222,7 @@ function renderProfileView(
|
||||
profileDashboardOverrides: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
userOverrides: Partial<AuthUser> = {},
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
@@ -235,6 +237,7 @@ function renderProfileView(
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
...userOverrides,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
@@ -450,6 +453,18 @@ test('profile total play time card always uses hours', () => {
|
||||
expect(within(playTimeCard).queryByText('90分')).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop account entry uses saved avatar image when available', () => {
|
||||
mockDesktopLayout();
|
||||
const avatarUrl = 'data:image/png;base64,AAAA';
|
||||
|
||||
renderProfileView(vi.fn(), {}, { avatarUrl });
|
||||
|
||||
const accountEntry = screen.getByRole('button', { name: /测试玩家/u });
|
||||
const avatarImage = accountEntry.querySelector('img');
|
||||
expect(avatarImage?.getAttribute('src')).toBe(avatarUrl);
|
||||
expect(within(accountEntry).queryByText('测')).toBeNull();
|
||||
});
|
||||
|
||||
test('wallet ledger modal shows empty and error states', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
||||
@@ -466,6 +481,18 @@ test('wallet ledger modal shows empty and error states', async () => {
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('opens reward code modal from profile action on mobile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /兑换码/u }));
|
||||
|
||||
const modal = await screen.findByPlaceholderText('输入兑换码');
|
||||
expect(modal).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '兑换' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
@@ -1828,7 +1828,7 @@ function RewardCodeRedeemModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-black">兑换码</div>
|
||||
@@ -3478,6 +3478,17 @@ export function RpgEntryHomeView({
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const rewardCodeModal: ReactNode = isRewardCodeOpen ? (
|
||||
<RewardCodeRedeemModal
|
||||
value={rewardCodeInput}
|
||||
isSubmitting={isSubmittingRewardCode}
|
||||
error={rewardCodeError}
|
||||
success={rewardCodeSuccess}
|
||||
onChange={setRewardCodeInput}
|
||||
onSubmit={submitRewardCode}
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
@@ -3501,10 +3512,9 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 min-w-0 shrink-0 border-t pt-2"
|
||||
className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0 border-t pt-2"
|
||||
style={{
|
||||
borderColor: 'var(--platform-line-soft)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -3537,6 +3547,7 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{rewardCodeModal}
|
||||
{isProfilePlayStatsOpen ? (
|
||||
<ProfilePlayedWorksModal
|
||||
stats={profilePlayStats}
|
||||
@@ -3594,13 +3605,21 @@ export function RpgEntryHomeView({
|
||||
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
|
||||
>
|
||||
<span
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full text-base font-black text-white"
|
||||
className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full text-base font-black text-white"
|
||||
style={{
|
||||
background: 'var(--platform-profile-avatar-fill)',
|
||||
boxShadow: 'var(--platform-profile-avatar-shadow)',
|
||||
}}
|
||||
>
|
||||
{avatarLabel}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
avatarLabel
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
@@ -3634,17 +3653,7 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRewardCodeOpen ? (
|
||||
<RewardCodeRedeemModal
|
||||
value={rewardCodeInput}
|
||||
isSubmitting={isSubmittingRewardCode}
|
||||
error={rewardCodeError}
|
||||
success={rewardCodeSuccess}
|
||||
onChange={setRewardCodeInput}
|
||||
onSubmit={submitRewardCode}
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{rewardCodeModal}
|
||||
{profilePopupPanel ? (
|
||||
<ProfileReferralModal
|
||||
panel={profilePopupPanel}
|
||||
|
||||
114
src/index.css
114
src/index.css
@@ -21,6 +21,10 @@
|
||||
:root {
|
||||
--ui-scale: clamp(0.78, 0.72 + 0.45vw, 1.06);
|
||||
--platform-bottom-nav-height: 3.5rem;
|
||||
--platform-bottom-dock-outer-height: calc(
|
||||
var(--platform-bottom-nav-height) + env(safe-area-inset-bottom, 0px) +
|
||||
1.15rem
|
||||
);
|
||||
--platform-bottom-nav-padding: 0.25rem;
|
||||
--platform-bottom-nav-gap: 0.25rem;
|
||||
--platform-bottom-nav-radius: 1.2rem;
|
||||
@@ -50,6 +54,20 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.platform-viewport-shell {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.platform-viewport-shell {
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes character-animator-portrait-death-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) scaleX(1) scale(1);
|
||||
@@ -754,6 +772,10 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.platform-mobile-bottom-dock {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.platform-tab-panel {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -1762,6 +1784,20 @@ body {
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.platform-modal-backdrop {
|
||||
background: var(--platform-overlay-fill);
|
||||
color: var(--platform-text-strong);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.platform-recharge-modal {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: var(--platform-modal-fill);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 24px 80px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.platform-overlay {
|
||||
background: var(--platform-overlay-fill);
|
||||
}
|
||||
@@ -1845,6 +1881,20 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-mobile-entry-shell {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: var(--platform-bottom-dock-outer-height);
|
||||
}
|
||||
|
||||
.platform-mobile-bottom-dock {
|
||||
position: fixed;
|
||||
right: max(0.75rem, env(safe-area-inset-right, 0px));
|
||||
bottom: 0;
|
||||
left: max(0.75rem, env(safe-area-inset-left, 0px));
|
||||
z-index: 60;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 0.5rem);
|
||||
}
|
||||
|
||||
.platform-mobile-home-stage {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -2246,6 +2296,70 @@ body {
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.platform-profile-input {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: var(--platform-input-fill);
|
||||
color: var(--platform-text-strong);
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 var(--platform-input-highlight);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.platform-profile-input::placeholder {
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
|
||||
.platform-profile-input:focus {
|
||||
border-color: var(--platform-nav-active-border);
|
||||
background: var(--platform-input-fill-focus);
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.platform-primary-button {
|
||||
border: 1px solid var(--platform-button-primary-border);
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
box-shadow: var(--platform-profile-action-shadow);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
filter 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.platform-primary-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.platform-modal-close {
|
||||
background: var(--platform-profile-chip-fill);
|
||||
color: var(--platform-profile-chip-text);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.platform-modal-close:hover {
|
||||
background: var(--platform-profile-chip-hover-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-profile-error {
|
||||
border: 1px solid var(--platform-button-danger-border);
|
||||
background: var(--platform-button-danger-fill);
|
||||
color: var(--platform-button-danger-text);
|
||||
}
|
||||
|
||||
.platform-profile-success {
|
||||
border: 1px solid var(--platform-success-border);
|
||||
background: var(--platform-success-bg);
|
||||
color: var(--platform-success-text);
|
||||
}
|
||||
|
||||
.platform-profile-hero {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-profile-hero-border);
|
||||
|
||||
Reference in New Issue
Block a user