Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -269,6 +269,14 @@
|
|||||||
- 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。
|
- 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。
|
||||||
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||||
|
|
||||||
|
## Jenkins 可选参数在 set -u 下不能裸读
|
||||||
|
|
||||||
|
- 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。
|
||||||
|
- 原因:Jenkins string/boolean 参数留空时不一定会导出同名环境变量,而生产数据库导入导出脚本块启用了 `set -u`。
|
||||||
|
- 处理:进入 Bash 执行块后先使用 `${VAR:-}` 或 `${VAR:-默认值}` 收敛成本地变量;必填项使用 `${VAR:?中文错误}` 明确失败原因。
|
||||||
|
- 验证:扫描 `jenkins/Jenkinsfile.production-database-export` 与 `jenkins/Jenkinsfile.production-database-import`,确认 `INCLUDE_TABLES`、`CHUNK_SIZE`、`SERVER_BACKUP_DIRECTORY`、`SMOKE_HEALTH_URL` 等可选参数不再裸读。
|
||||||
|
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`。
|
||||||
|
|
||||||
## 个人任务 scope 不得扩成 work/site/module
|
## 个人任务 scope 不得扩成 work/site/module
|
||||||
|
|
||||||
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
|
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
|
||||||
|
|||||||
107
apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx
Normal file
107
apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import {render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminDatabaseTableRows,
|
||||||
|
getAdminDatabaseTables,
|
||||||
|
} from '../api/adminApiClient';
|
||||||
|
import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage';
|
||||||
|
|
||||||
|
vi.mock('../api/adminApiClient', () => ({
|
||||||
|
formatAdminApiError: vi.fn((error: unknown) =>
|
||||||
|
error instanceof Error ? error.message : '请求失败',
|
||||||
|
),
|
||||||
|
getAdminDatabaseTableRows: vi.fn(),
|
||||||
|
getAdminDatabaseTables: vi.fn(),
|
||||||
|
isAdminApiError: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.location.hash = '#tables?table=profile_referral_relation';
|
||||||
|
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
|
||||||
|
fetchErrors: [],
|
||||||
|
tables: ['profile_referral_relation'],
|
||||||
|
});
|
||||||
|
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
|
||||||
|
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||||
|
limit: 100,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: {
|
||||||
|
bound_at: '2026-05-02T00:00:00Z',
|
||||||
|
invitee_user_id: 'u-b',
|
||||||
|
invite_code: 'INV-1001',
|
||||||
|
inviter_user_id: 'u-a',
|
||||||
|
},
|
||||||
|
raw: [
|
||||||
|
'u-b',
|
||||||
|
'u-a',
|
||||||
|
'INV-1001',
|
||||||
|
'2026-05-02T00:00:00Z',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cells: {
|
||||||
|
bound_at: '2026-05-01T00:00:00Z',
|
||||||
|
invitee_user_id: 'u-a',
|
||||||
|
invite_code: 'INV-1002',
|
||||||
|
inviter_user_id: 'u-c',
|
||||||
|
},
|
||||||
|
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cells: {
|
||||||
|
bound_at: '2026-05-03T00:00:00Z',
|
||||||
|
invitee_user_id: 'u-c',
|
||||||
|
invite_code: 'INV-1003',
|
||||||
|
inviter_user_id: 'u-a',
|
||||||
|
},
|
||||||
|
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tableName: 'profile_referral_relation',
|
||||||
|
totalReturned: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const {container} = render(
|
||||||
|
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText('u-b');
|
||||||
|
|
||||||
|
const tableWrap = container.querySelector('.admin-table-wrap');
|
||||||
|
expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull();
|
||||||
|
expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation)'}).getAttribute('title')).toBe(
|
||||||
|
'原始表名:profile_referral_relation。邀请关系记录表。',
|
||||||
|
);
|
||||||
|
expect(screen.getByText('已选表:邀请关系(profile_referral_relation)')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe(
|
||||||
|
'原始表名:profile_referral_relation。邀请关系记录表。',
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe(
|
||||||
|
'原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。',
|
||||||
|
);
|
||||||
|
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function readFirstColumnValues(container: HTMLElement) {
|
||||||
|
return Array.from(container.querySelectorAll('tbody tr')).map(
|
||||||
|
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -350,11 +350,6 @@ button:disabled {
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-query-reset-button {
|
|
||||||
width: auto;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-field {
|
.admin-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -603,6 +598,13 @@ button:disabled {
|
|||||||
background: #eef3f6;
|
background: #eef3f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-ghost-button.admin-query-reset-button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 76px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-text-button {
|
.admin-text-button {
|
||||||
display: inline;
|
display: inline;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -650,7 +652,10 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table-wrap {
|
.admin-table-wrap {
|
||||||
|
max-width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
@@ -687,6 +692,65 @@ button:disabled {
|
|||||||
min-width: 1180px;
|
min-width: 1180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-database-table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-database-table th,
|
||||||
|
.admin-database-table td {
|
||||||
|
width: 220px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-database-table th:last-child,
|
||||||
|
.admin-database-table td:last-child {
|
||||||
|
width: 112px;
|
||||||
|
min-width: 112px;
|
||||||
|
max-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-sort-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 0;
|
||||||
|
color: #667682;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-sort-button span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-sort-button svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-cell-ellipsis {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-sort-button:hover,
|
||||||
|
.admin-table-sort-button:focus-visible,
|
||||||
|
.admin-table-sort-button[data-active="true"] {
|
||||||
|
color: #0f5666;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-json-preview {
|
.admin-json-preview {
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
1. 产品名称:百梦。
|
1. 产品名称:百梦。
|
||||||
2. 产品愿景:百梦AI团队致力于打造AI互动内容UGC平台。
|
2. 产品愿景:百梦AI团队致力于打造AI互动内容UGC平台。
|
||||||
3. 产品slogan:每个人都可以在10分钟内轻松创作出一款精品互动作品。
|
3. 产品slogan:不用代码,不用美术,10分钟把脑洞变成有趣的体验。
|
||||||
4. 产品特点:低门槛创作、高完成度作品、玩过后可改造并发布。
|
4. 产品特点:低门槛创作、高完成度作品、玩过后可改造并发布。
|
||||||
5. 关键技术:Harness Engineering、多Agent调度、AI创作工具、AI原生游戏框架。
|
5. 关键技术:Harness Engineering、多Agent调度、AI创作工具、AI原生游戏框架。
|
||||||
6. 产品心智:想玩但找不到、玩到不满意、平台外体验不满意时,都可以来百梦做成自己满意的。
|
6. 产品心智:想玩但找不到、玩到不满意、平台外体验不满意时,都可以来百梦做成自己满意的。
|
||||||
@@ -28,16 +28,14 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
百梦
|
百梦
|
||||||
AI互动内容UGC平台
|
10分钟做自己的互动内容
|
||||||
把想玩的世界,亲手做出来
|
|
||||||
```
|
```
|
||||||
|
|
||||||
第二层:远读slogan
|
第二层:远读slogan
|
||||||
|
|
||||||
```text
|
```text
|
||||||
每个人都可以在10分钟内
|
不用代码,不用美术,
|
||||||
轻松创作出一款
|
10分钟把脑洞变成有趣的体验
|
||||||
精品互动作品
|
|
||||||
```
|
```
|
||||||
|
|
||||||
第三层:产品特点
|
第三层:产品特点
|
||||||
@@ -88,6 +86,8 @@ reference image: 百梦气泡共创logo方向图
|
|||||||
output: output/imagegen/baimeng-expo-rollup/baimeng-rollup-background-gpt-image-2.png
|
output: output/imagegen/baimeng-expo-rollup/baimeng-rollup-background-gpt-image-2.png
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-05-08 根据新文案重新调用 `gpt-image-2` 生成新版底图。新版底图在中上部保留更干净的两行 slogan 留白,并在下半部增加轻量内容卡、创作路径和 AI 辅助创作氛围,最终再叠加精确中文排版。
|
||||||
|
|
||||||
因为图片模型直接生成中文长文案存在错字风险,最终稿采用“gpt-image-2 底图 + 本地精确中文排版”的方式生成:
|
因为图片模型直接生成中文长文案存在错字风险,最终稿采用“gpt-image-2 底图 + 本地精确中文排版”的方式生成:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -78,9 +78,13 @@ Query:
|
|||||||
|
|
||||||
页面能力:
|
页面能力:
|
||||||
|
|
||||||
- 表选择下拉,支持 URL hash `#tables?table=xxx` 直达指定表。
|
- 表选择下拉展示中文表名并保留原始表名,支持 URL hash `#tables?table=xxx` 直达指定表。
|
||||||
- 查询表单:表名、关键词、JSON 条件、条数。
|
- 查询表单:表名、关键词、JSON 条件、条数。
|
||||||
- 查询结果表格横向滚动,移动端不撑坏布局。
|
- 查询结果表格横向滚动,移动端不撑坏布局。
|
||||||
|
- 查询结果标题和已选表摘要展示中文表名,鼠标悬浮显示原始表名和表说明,方便运营识别真实数据域。
|
||||||
|
- 表头支持点击排序,排序只作用于当前已拉取的行数据,不改变后端 SQL。
|
||||||
|
- 表头展示中文字段名,鼠标悬浮显示原始字段名、字段说明和排序提示,方便运营阅读且保留排障所需的真实列名。
|
||||||
|
- 单元格内容过长时在表格内单行省略,完整内容可通过悬浮标题或行详情弹层查看。
|
||||||
- 每行提供“详情”按钮,以独立弹层展示完整 JSON。
|
- 每行提供“详情”按钮,以独立弹层展示完整 JSON。
|
||||||
- 总览表统计行点击后跳转到 `#tables?table={tableName}`。
|
- 总览表统计行点击后跳转到 `#tables?table={tableName}`。
|
||||||
|
|
||||||
|
|||||||
@@ -250,3 +250,15 @@ cannon-es
|
|||||||
3. 只有水平外接半径发生重叠的已有物体会影响本次生成高度;远处物体不能把新物体整体抬高,避免破坏原有随机洒落和分层节奏。
|
3. 只有水平外接半径发生重叠的已有物体会影响本次生成高度;远处物体不能把新物体整体抬高,避免破坏原有随机洒落和分层节奏。
|
||||||
4. 该避让只解决“直接创建在已有模型内部”的初始穿插,后续沉降、翻滚、堆叠仍交给 cannon-es 物理模拟。
|
4. 该避让只解决“直接创建在已有模型内部”的初始穿插,后续沉降、翻滚、堆叠仍交给 cannon-es 物理模拟。
|
||||||
5. 本节不允许额外引入中心引力、扩大锅容量或修改模型生成规则;若后续仍需优化,只继续围绕生成高度、入场节拍和沉降窗口做局部迭代。
|
5. 本节不允许额外引入中心引力、扩大锅容量或修改模型生成规则;若后续仍需优化,只继续围绕生成高度、入场节拍和沉降窗口做局部迭代。
|
||||||
|
|
||||||
|
## 20. 从小到大的生成动画
|
||||||
|
|
||||||
|
2026-05-08 追加生成动画优化,参考原型中物体逐个出现、从小到大补入容器的观感。
|
||||||
|
|
||||||
|
编码口径:
|
||||||
|
|
||||||
|
1. 该优化只作用于前端 3D 表现层的可见 mesh 缩放,不改变后端快照、碰撞体尺寸、物品数量、锅半径、点击判定、备选栏、三消和胜负规则。
|
||||||
|
2. 物理 body 在创建时仍使用最终尺寸碰撞体,并立即加入 cannon-es 物理世界,确保生成动画过程中碰撞已经按完整体积稳定占位。
|
||||||
|
3. 可见 mesh 初始以较小比例显示,再用缓动动画放大到完整尺寸;视觉缩放不得反向修改 body shape、质量、边界半径或生成高度避让计算。
|
||||||
|
4. 入场动画继续服从第 18 节的创建限流和第 19 节的生成高度避让;不能为了动画效果把物体直接放进已有堆叠内部。
|
||||||
|
5. 动画结束后 mesh 缩放必须回到 `1`,避免影响后续点击可读性和托盘对应关系。
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
|
|||||||
- `COMMIT_HASH` 非空时,先解析到完整 commit,再用 `git merge-base --is-ancestor <commit> refs/remotes/origin/<SOURCE_BRANCH>` 校验该提交属于指定分支,校验通过后 detached checkout。
|
- `COMMIT_HASH` 非空时,先解析到完整 commit,再用 `git merge-base --is-ancestor <commit> refs/remotes/origin/<SOURCE_BRANCH>` 校验该提交属于指定分支,校验通过后 detached checkout。
|
||||||
- 流水线日志必须输出最终 `SOURCE_BRANCH` 与实际 `SOURCE_COMMIT`。
|
- 流水线日志必须输出最终 `SOURCE_BRANCH` 与实际 `SOURCE_COMMIT`。
|
||||||
- 构建产物必须写入 `release-manifest.json`,至少包含 `version`、`source_branch`、`source_commit`、`built_at` 和组件类型,供发布、回滚和审计使用。
|
- 构建产物必须写入 `release-manifest.json`,至少包含 `version`、`source_branch`、`source_commit`、`built_at` 和组件类型,供发布、回滚和审计使用。
|
||||||
|
- Windows 构建 Job 写入 `.jenkins-source-commit` 时必须使用 UTF-8 无 BOM;部署脚本在校验 `COMMIT_HASH` 前也会剥离 UTF-8 BOM 和 CRLF,避免上游 PowerShell 5.1 `Set-Content -Encoding UTF8` 产生的不可见 BOM 让下游发布误判 commit hash 非法。
|
||||||
|
|
||||||
构建流水线使用上述参数决定实际构建源码。发布流水线也暴露同名参数,但只用于选择本次发布使用的部署脚本、配置模板和 smoke test 逻辑;被发布的应用文件仍必须来自 Jenkins 归档产物或指定 release 包,不允许在发布流水线中重新构建。
|
构建流水线使用上述参数决定实际构建源码。发布流水线也暴露同名参数,但只用于选择本次发布使用的部署脚本、配置模板和 smoke test 逻辑;被发布的应用文件仍必须来自 Jenkins 归档产物或指定 release 包,不允许在发布流水线中重新构建。
|
||||||
|
|
||||||
@@ -509,8 +510,10 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
|
|||||||
构建:
|
构建:
|
||||||
|
|
||||||
- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。
|
- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。
|
||||||
- 使用 `spacetime build` 构建 `spacetime_module.wasm`。
|
- 构建 `spacetime_module.wasm` 前默认生成 32 字节随机 hex 迁移引导密钥,注入 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET`,并把同一份密钥写入 `build/<version>/migration-bootstrap-secret.txt`。构建日志只输出密钥来源和长度,不打印明文。
|
||||||
- 归档 wasm、发布脚本和 `release-manifest.json`。
|
- `Genarrative-Stdb-Module-Build` 提供 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 参数:留空时自动生成新密钥;填写 Jenkins Secret Text 凭据 ID 时,构建环境注入 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET` 并复用该值。仅在明确传 `--no-migration-bootstrap-secret` 时才构建不带引导密钥的 wasm。
|
||||||
|
- 使用 Rust wasm target 构建 `spacetime_module.wasm`。
|
||||||
|
- 归档 wasm、`migration-bootstrap-secret.txt` 和 `release-manifest.json`。`migration-bootstrap-secret.txt` 属于敏感产物,只用于创建首个迁移操作员或录入数据库导入/导出流水线的 `BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的 Jenkins Secret Text;授权完成后不要把明文留在公开归档或聊天记录中。
|
||||||
|
|
||||||
发布:
|
发布:
|
||||||
|
|
||||||
@@ -520,6 +523,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
|
|||||||
- 在生产实例本机执行 `spacetime --root-dir=/stdb publish <database-name> --server http://127.0.0.1:3101 --bin-path spacetime_module.wasm --yes --no-config`。
|
- 在生产实例本机执行 `spacetime --root-dir=/stdb publish <database-name> --server http://127.0.0.1:3101 --bin-path spacetime_module.wasm --yes --no-config`。
|
||||||
- 发布动作默认以 `spacetimedb` 服务用户执行,避免 root 默认 CLI 身份对自托管数据库验签失败,也避免 root 写入 `/stdb/config` 造成后续服务用户启动权限错误。
|
- 发布动作默认以 `spacetimedb` 服务用户执行,避免 root 默认 CLI 身份对自托管数据库验签失败,也避免 root 写入 `/stdb/config` 造成后续服务用户启动权限错误。
|
||||||
- `Stdb publish` 固定追加 `--no-config`,只依赖显式传入的 `--root-dir`、`--server`、`--bin-path` 与数据库名,避免 agent 工作区、本机用户目录或仓库内 `spacetime` 配置干扰发布目标。
|
- `Stdb publish` 固定追加 `--no-config`,只依赖显式传入的 `--root-dir`、`--server`、`--bin-path` 与数据库名,避免 agent 工作区、本机用户目录或仓库内 `spacetime` 配置干扰发布目标。
|
||||||
|
- 首次迁移操作员授权时,使用本次 Stdb module 构建归档的 `migration-bootstrap-secret.txt` 创建 Jenkins Secret Text,然后在 `Genarrative-Database-Export` / `Genarrative-Database-Import` 的 `BOOTSTRAP_SECRET_CREDENTIAL_ID` 中填写该凭据 ID。后续已有迁移操作员时优先改用 `TOKEN_CREDENTIAL_ID`。
|
||||||
- 成功后执行必要 smoke test。
|
- 成功后执行必要 smoke test。
|
||||||
- 成功后解除维护模式。
|
- 成功后解除维护模式。
|
||||||
- 失败时保留维护模式并发邮件。
|
- 失败时保留维护模式并发邮件。
|
||||||
@@ -547,6 +551,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
|
|||||||
- 从目标机器本机 SpacetimeDB 导出指定数据库数据,默认连接 `SPACETIME_SERVER_URL=http://127.0.0.1:3101`,自托管 `root-dir` 默认 `/stdb`。
|
- 从目标机器本机 SpacetimeDB 导出指定数据库数据,默认连接 `SPACETIME_SERVER_URL=http://127.0.0.1:3101`,自托管 `root-dir` 默认 `/stdb`。
|
||||||
- 产物归档到 Jenkins,并可额外保存到 `SERVER_BACKUP_DIRECTORY`。
|
- 产物归档到 Jenkins,并可额外保存到 `SERVER_BACKUP_DIRECTORY`。
|
||||||
- 敏感 token 与 bootstrap secret 只通过 Jenkins Secret Text 凭据 ID 注入,不作为明文 Job 参数。
|
- 敏感 token 与 bootstrap secret 只通过 Jenkins Secret Text 凭据 ID 注入,不作为明文 Job 参数。
|
||||||
|
- 导出和导入流水线的 Bash 执行块启用 `set -u`;所有可选 Jenkins 参数必须先通过 `${VAR:-}` 收敛成本地默认值,再传给 Node 迁移脚本,避免空参数没有导出时触发 `unbound variable`。
|
||||||
- 成功后解除维护模式。
|
- 成功后解除维护模式。
|
||||||
- 失败时保留维护模式并邮件通知。
|
- 失败时保留维护模式并邮件通知。
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ node scripts/spacetime-revoke-migration-operator.mjs \
|
|||||||
|
|
||||||
- `npm run dev:rust`:在本地 `spacetime publish --module-path` 前生成密钥,控制台输出 `[dev:rust] 迁移引导密钥: ...`。
|
- `npm run dev:rust`:在本地 `spacetime publish --module-path` 前生成密钥,控制台输出 `[dev:rust] 迁移引导密钥: ...`。
|
||||||
- `npm run deploy:rust:remote`:在构建发布包 wasm 前生成密钥,控制台输出 `[deploy:rust] 迁移引导密钥: ...`,并把同一份密钥写入发布包根目录的 `migration-bootstrap-secret.txt`。服务器执行 `./start.sh` 发布 wasm 时也会再次显示该文件里的密钥。
|
- `npm run deploy:rust:remote`:在构建发布包 wasm 前生成密钥,控制台输出 `[deploy:rust] 迁移引导密钥: ...`,并把同一份密钥写入发布包根目录的 `migration-bootstrap-secret.txt`。服务器执行 `./start.sh` 发布 wasm 时也会再次显示该文件里的密钥。
|
||||||
|
- `npm run build:production-release -- --component spacetime-module`:在生产 Stdb module 构建前默认生成或复用 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET`,注入 `spacetime_module.wasm`,并写入 `build/<version>/migration-bootstrap-secret.txt`。生产构建日志只显示密钥来源和长度,不打印明文;该文件应保存为 Jenkins Secret Text,供 `Genarrative-Database-Export` / `Genarrative-Database-Import` 的 `BOOTSTRAP_SECRET_CREDENTIAL_ID` 使用。
|
||||||
|
|
||||||
如果迁移完成后不希望 wasm 继续携带引导密钥,重新发布时传 `--no-migration-bootstrap-secret`。远端发布包若使用 `--skip-spacetime-build`,必须同时传 `--no-migration-bootstrap-secret`,否则脚本会拒绝生成一个无法注入旧 wasm 的新密钥。
|
如果迁移完成后不希望 wasm 继续携带引导密钥,重新发布时传 `--no-migration-bootstrap-secret`。远端发布包若使用 `--skip-spacetime-build`,必须同时传 `--no-migration-bootstrap-secret`,否则脚本会拒绝生成一个无法注入旧 wasm 的新密钥。
|
||||||
|
|
||||||
|
|||||||
@@ -116,8 +116,15 @@ pipeline {
|
|||||||
|
|
||||||
chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh
|
chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh
|
||||||
|
|
||||||
export_dir="${WORKSPACE_EXPORT_DIRECTORY}"
|
database="${DATABASE:?DATABASE 不能为空}"
|
||||||
output_path="${export_dir}/${EFFECTIVE_EXPORT_NAME}"
|
spacetime_server_url="${SPACETIME_SERVER_URL:-}"
|
||||||
|
spacetime_server="${SPACETIME_SERVER:-}"
|
||||||
|
spacetime_root_dir="${EFFECTIVE_SPACETIME_ROOT_DIR:-}"
|
||||||
|
include_tables="${INCLUDE_TABLES:-}"
|
||||||
|
server_backup_directory="${EFFECTIVE_SERVER_BACKUP_DIRECTORY:-}"
|
||||||
|
export_dir="${WORKSPACE_EXPORT_DIRECTORY:-database-exports}"
|
||||||
|
export_name="${EFFECTIVE_EXPORT_NAME:-spacetime-migration-${BUILD_NUMBER:-manual}.json}"
|
||||||
|
output_path="${export_dir}/${export_name}"
|
||||||
mkdir -p "${export_dir}"
|
mkdir -p "${export_dir}"
|
||||||
|
|
||||||
maintenance_entered=0
|
maintenance_entered=0
|
||||||
@@ -132,20 +139,20 @@ pipeline {
|
|||||||
}
|
}
|
||||||
trap on_exit EXIT
|
trap on_exit EXIT
|
||||||
|
|
||||||
scripts/deploy/maintenance-on.sh "database export ${DATABASE}"
|
scripts/deploy/maintenance-on.sh "database export ${database}"
|
||||||
maintenance_entered=1
|
maintenance_entered=1
|
||||||
|
|
||||||
args=(scripts/spacetime-export-migration-json.mjs --out "${output_path}" --database "${DATABASE}")
|
args=(scripts/spacetime-export-migration-json.mjs --out "${output_path}" --database "${database}")
|
||||||
if [[ -n "${SPACETIME_SERVER_URL}" ]]; then
|
if [[ -n "${spacetime_server_url}" ]]; then
|
||||||
args+=(--server-url "${SPACETIME_SERVER_URL}")
|
args+=(--server-url "${spacetime_server_url}")
|
||||||
elif [[ -n "${SPACETIME_SERVER}" ]]; then
|
elif [[ -n "${spacetime_server}" ]]; then
|
||||||
args+=(--server "${SPACETIME_SERVER}")
|
args+=(--server "${spacetime_server}")
|
||||||
fi
|
fi
|
||||||
if [[ -n "${EFFECTIVE_SPACETIME_ROOT_DIR}" ]]; then
|
if [[ -n "${spacetime_root_dir}" ]]; then
|
||||||
args+=(--root-dir "${EFFECTIVE_SPACETIME_ROOT_DIR}")
|
args+=(--root-dir "${spacetime_root_dir}")
|
||||||
fi
|
fi
|
||||||
if [[ -n "${INCLUDE_TABLES}" ]]; then
|
if [[ -n "${include_tables}" ]]; then
|
||||||
args+=(--include "${INCLUDE_TABLES}")
|
args+=(--include "${include_tables}")
|
||||||
fi
|
fi
|
||||||
args+=(--note "jenkins database export ${BUILD_TAG}")
|
args+=(--note "jenkins database export ${BUILD_TAG}")
|
||||||
|
|
||||||
@@ -153,10 +160,10 @@ pipeline {
|
|||||||
test -s "${output_path}"
|
test -s "${output_path}"
|
||||||
sha256sum "${output_path}" >"${output_path}.sha256"
|
sha256sum "${output_path}" >"${output_path}.sha256"
|
||||||
|
|
||||||
if [[ -n "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}" ]]; then
|
if [[ -n "${server_backup_directory}" ]]; then
|
||||||
mkdir -p "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}"
|
mkdir -p "${server_backup_directory}"
|
||||||
install -m 0640 "${output_path}" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}"
|
install -m 0640 "${output_path}" "${server_backup_directory}/${export_name}"
|
||||||
install -m 0640 "${output_path}.sha256" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}.sha256"
|
install -m 0640 "${output_path}.sha256" "${server_backup_directory}/${export_name}.sha256"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[database-export] 完成: ${output_path}, source_commit=$(cat .jenkins-source-commit)"
|
echo "[database-export] 完成: ${output_path}, source_commit=$(cat .jenkins-source-commit)"
|
||||||
|
|||||||
@@ -181,11 +181,12 @@ pipeline {
|
|||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
manual_filename="${MANUAL_INPUT_FILE_FILENAME:-}"
|
manual_filename="${MANUAL_INPUT_FILE_FILENAME:-}"
|
||||||
|
confirm_input_file="${CONFIRM_INPUT_FILE:-}"
|
||||||
if [[ -z "${manual_filename}" ]]; then
|
if [[ -z "${manual_filename}" ]]; then
|
||||||
echo "[database-import] 无法读取 MANUAL_INPUT_FILE_FILENAME,不能确认手动上传文件名。" >&2
|
echo "[database-import] 无法读取 MANUAL_INPUT_FILE_FILENAME,不能确认手动上传文件名。" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ "${CONFIRM_INPUT_FILE}" != "${manual_filename}" ]]; then
|
if [[ "${confirm_input_file}" != "${manual_filename}" ]]; then
|
||||||
echo "[database-import] CONFIRM_INPUT_FILE 必须与手动上传文件原始文件名一致: ${manual_filename}" >&2
|
echo "[database-import] CONFIRM_INPUT_FILE 必须与手动上传文件原始文件名一致: ${manual_filename}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -209,7 +210,20 @@ pipeline {
|
|||||||
|
|
||||||
chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh
|
chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh
|
||||||
|
|
||||||
input_path="${EFFECTIVE_INPUT_FILE}"
|
database="${DATABASE:?DATABASE 不能为空}"
|
||||||
|
spacetime_server_url="${SPACETIME_SERVER_URL:-}"
|
||||||
|
spacetime_server="${SPACETIME_SERVER:-}"
|
||||||
|
spacetime_root_dir="${SPACETIME_ROOT_DIR:-}"
|
||||||
|
server_backup_directory="${SERVER_BACKUP_DIRECTORY:-}"
|
||||||
|
include_tables="${INCLUDE_TABLES:-}"
|
||||||
|
chunk_size="${CHUNK_SIZE:-}"
|
||||||
|
dry_run="${DRY_RUN:-true}"
|
||||||
|
incremental="${INCREMENTAL:-true}"
|
||||||
|
replace_existing="${REPLACE_EXISTING:-false}"
|
||||||
|
run_smoke_test="${RUN_SMOKE_TEST:-true}"
|
||||||
|
smoke_health_url="${SMOKE_HEALTH_URL:-}"
|
||||||
|
|
||||||
|
input_path="${EFFECTIVE_INPUT_FILE:?EFFECTIVE_INPUT_FILE 不能为空}"
|
||||||
if [[ "${input_path}" != /* ]]; then
|
if [[ "${input_path}" != /* ]]; then
|
||||||
input_path="${WORKSPACE}/${input_path}"
|
input_path="${WORKSPACE}/${input_path}"
|
||||||
fi
|
fi
|
||||||
@@ -218,8 +232,9 @@ pipeline {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
backup_dir="${PRE_IMPORT_BACKUP_DIRECTORY}"
|
backup_dir="${PRE_IMPORT_BACKUP_DIRECTORY:-database-pre-import-backups}"
|
||||||
backup_path="${backup_dir}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}"
|
backup_name="${EFFECTIVE_PRE_IMPORT_BACKUP_NAME:-pre-import-${BUILD_NUMBER:-manual}.json}"
|
||||||
|
backup_path="${backup_dir}/${backup_name}"
|
||||||
mkdir -p "${backup_dir}"
|
mkdir -p "${backup_dir}"
|
||||||
|
|
||||||
completed=0
|
completed=0
|
||||||
@@ -232,20 +247,20 @@ pipeline {
|
|||||||
}
|
}
|
||||||
trap on_exit EXIT
|
trap on_exit EXIT
|
||||||
|
|
||||||
scripts/deploy/maintenance-on.sh "database import ${DATABASE}"
|
scripts/deploy/maintenance-on.sh "database import ${database}"
|
||||||
|
|
||||||
backup_args=(scripts/spacetime-export-migration-json.mjs --out "${backup_path}" --database "${DATABASE}")
|
backup_args=(scripts/spacetime-export-migration-json.mjs --out "${backup_path}" --database "${database}")
|
||||||
import_args=(scripts/spacetime-import-migration-json.mjs --in "${input_path}" --database "${DATABASE}")
|
import_args=(scripts/spacetime-import-migration-json.mjs --in "${input_path}" --database "${database}")
|
||||||
for args_name in backup_args import_args; do
|
for args_name in backup_args import_args; do
|
||||||
declare -n current_args="${args_name}"
|
declare -n current_args="${args_name}"
|
||||||
# server-url 明确指向目标实例时,不再同时透传默认 alias,避免 CLI 授权与 HTTP 导入落到不同目标。
|
# server-url 明确指向目标实例时,不再同时透传默认 alias,避免 CLI 授权与 HTTP 导入落到不同目标。
|
||||||
if [[ -n "${SPACETIME_SERVER_URL}" ]]; then
|
if [[ -n "${spacetime_server_url}" ]]; then
|
||||||
current_args+=(--server-url "${SPACETIME_SERVER_URL}")
|
current_args+=(--server-url "${spacetime_server_url}")
|
||||||
elif [[ -n "${SPACETIME_SERVER}" ]]; then
|
elif [[ -n "${spacetime_server}" ]]; then
|
||||||
current_args+=(--server "${SPACETIME_SERVER}")
|
current_args+=(--server "${spacetime_server}")
|
||||||
fi
|
fi
|
||||||
if [[ -n "${SPACETIME_ROOT_DIR}" ]]; then
|
if [[ -n "${spacetime_root_dir}" ]]; then
|
||||||
current_args+=(--root-dir "${SPACETIME_ROOT_DIR}")
|
current_args+=(--root-dir "${spacetime_root_dir}")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -254,25 +269,25 @@ pipeline {
|
|||||||
test -s "${backup_path}"
|
test -s "${backup_path}"
|
||||||
sha256sum "${backup_path}" >"${backup_path}.sha256"
|
sha256sum "${backup_path}" >"${backup_path}.sha256"
|
||||||
|
|
||||||
if [[ -n "${SERVER_BACKUP_DIRECTORY}" ]]; then
|
if [[ -n "${server_backup_directory}" ]]; then
|
||||||
mkdir -p "${SERVER_BACKUP_DIRECTORY}"
|
mkdir -p "${server_backup_directory}"
|
||||||
install -m 0640 "${backup_path}" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}"
|
install -m 0640 "${backup_path}" "${server_backup_directory}/${backup_name}"
|
||||||
install -m 0640 "${backup_path}.sha256" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}.sha256"
|
install -m 0640 "${backup_path}.sha256" "${server_backup_directory}/${backup_name}.sha256"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${INCLUDE_TABLES}" ]]; then
|
if [[ -n "${include_tables}" ]]; then
|
||||||
import_args+=(--include "${INCLUDE_TABLES}")
|
import_args+=(--include "${include_tables}")
|
||||||
fi
|
fi
|
||||||
if [[ -n "${CHUNK_SIZE}" ]]; then
|
if [[ -n "${chunk_size}" ]]; then
|
||||||
import_args+=(--chunk-size "${CHUNK_SIZE}")
|
import_args+=(--chunk-size "${chunk_size}")
|
||||||
fi
|
fi
|
||||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
if [[ "${dry_run}" == "true" ]]; then
|
||||||
import_args+=(--dry-run)
|
import_args+=(--dry-run)
|
||||||
fi
|
fi
|
||||||
if [[ "${INCREMENTAL}" == "true" ]]; then
|
if [[ "${incremental}" == "true" ]]; then
|
||||||
import_args+=(--incremental)
|
import_args+=(--incremental)
|
||||||
fi
|
fi
|
||||||
if [[ "${REPLACE_EXISTING}" == "true" ]]; then
|
if [[ "${replace_existing}" == "true" ]]; then
|
||||||
import_args+=(--replace-existing)
|
import_args+=(--replace-existing)
|
||||||
fi
|
fi
|
||||||
import_args+=(--note "jenkins database import ${BUILD_TAG}")
|
import_args+=(--note "jenkins database import ${BUILD_TAG}")
|
||||||
@@ -280,13 +295,13 @@ pipeline {
|
|||||||
node "${import_args[@]}"
|
node "${import_args[@]}"
|
||||||
|
|
||||||
# 导入成功后只做本机健康检查;业务级数据核验仍以迁移脚本的表级统计为准。
|
# 导入成功后只做本机健康检查;业务级数据核验仍以迁移脚本的表级统计为准。
|
||||||
if [[ "${RUN_SMOKE_TEST}" == "true" && -n "${SMOKE_HEALTH_URL}" ]]; then
|
if [[ "${run_smoke_test}" == "true" && -n "${smoke_health_url}" ]]; then
|
||||||
curl -fsS --max-time 10 "${SMOKE_HEALTH_URL}" >/dev/null
|
curl -fsS --max-time 10 "${smoke_health_url}" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
scripts/deploy/maintenance-off.sh
|
scripts/deploy/maintenance-off.sh
|
||||||
completed=1
|
completed=1
|
||||||
echo "[database-import] 完成: dry_run=${DRY_RUN}, database=${DATABASE}, source_commit=$(cat .jenkins-source-commit)"
|
echo "[database-import] 完成: dry_run=${dry_run}, database=${database}, source_commit=$(cat .jenkins-source-commit)"
|
||||||
'
|
'
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pipeline {
|
|||||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
||||||
booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci')
|
booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci')
|
||||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||||
|
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,透传给 Stdb module 构建的迁移 bootstrap secret 凭据 ID;留空则由 Stdb 构建自动生成')
|
||||||
string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名')
|
string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名')
|
||||||
string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
|
string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
|
||||||
string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名')
|
string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名')
|
||||||
@@ -120,6 +121,7 @@ pipeline {
|
|||||||
string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT),
|
string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT),
|
||||||
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
|
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
|
||||||
string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
|
string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
|
||||||
|
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID ?: ''),
|
||||||
string(name: 'DATABASE', value: params.DATABASE),
|
string(name: 'DATABASE', value: params.DATABASE),
|
||||||
]
|
]
|
||||||
env.STDB_BUILD_NUMBER = stdbRun.number.toString()
|
env.STDB_BUILD_NUMBER = stdbRun.number.toString()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pipeline {
|
|||||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
|
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
|
||||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
||||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||||
|
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,复用既有迁移 bootstrap secret 的 Jenkins Secret Text 凭据 ID;留空则本次构建自动生成')
|
||||||
booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Stdb module 发布')
|
booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Stdb module 发布')
|
||||||
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb module 发布流水线作业名')
|
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb module 发布流水线作业名')
|
||||||
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
||||||
@@ -56,10 +57,12 @@ pipeline {
|
|||||||
git checkout --force "origin/$sourceBranch"
|
git checkout --force "origin/$sourceBranch"
|
||||||
}
|
}
|
||||||
git clean -ffdx
|
git clean -ffdx
|
||||||
git rev-parse HEAD | Set-Content -Encoding UTF8 .jenkins-source-commit
|
$resolvedCommit = (git rev-parse HEAD).Trim()
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom)
|
||||||
'''
|
'''
|
||||||
script {
|
script {
|
||||||
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
|
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').replace('\uFEFF', '').trim()
|
||||||
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,50 +70,64 @@ pipeline {
|
|||||||
|
|
||||||
stage('Build Stdb Module') {
|
stage('Build Stdb Module') {
|
||||||
steps {
|
steps {
|
||||||
powershell '''
|
script {
|
||||||
$ErrorActionPreference = 'Stop'
|
def buildStep = {
|
||||||
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
|
powershell '''
|
||||||
$env:CARGO_HOME = "$workspaceTmp/cargo-home"
|
$ErrorActionPreference = 'Stop'
|
||||||
$env:CARGO_TARGET_DIR = "$workspaceTmp/cargo-target/prod-release"
|
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
|
||||||
$env:SCCACHE_DIR = "$env:USERPROFILE/.cache/sccache-stdb-module"
|
$env:CARGO_HOME = "$workspaceTmp/cargo-home"
|
||||||
$env:PATH = "$env:CARGO_HOME/bin;$env:PATH"
|
$env:CARGO_TARGET_DIR = "$workspaceTmp/cargo-target/prod-release"
|
||||||
$gitBash = @(
|
$env:SCCACHE_DIR = "$env:USERPROFILE/.cache/sccache-stdb-module"
|
||||||
$env:GENARRATIVE_BASH,
|
$env:PATH = "$env:CARGO_HOME/bin;$env:PATH"
|
||||||
'C:/Program Files/Git/bin/bash.exe',
|
$gitBash = @(
|
||||||
'C:/Program Files/Git/usr/bin/bash.exe',
|
$env:GENARRATIVE_BASH,
|
||||||
'C:/msys64/usr/bin/bash.exe',
|
'C:/Program Files/Git/bin/bash.exe',
|
||||||
'bash'
|
'C:/Program Files/Git/usr/bin/bash.exe',
|
||||||
) | Where-Object { $_ -and (($_ -eq 'bash') -or (Test-Path $_)) } | Select-Object -First 1
|
'C:/msys64/usr/bin/bash.exe',
|
||||||
if (-not $gitBash) {
|
'bash'
|
||||||
throw '[stdb-build] Windows 构建节点缺少 Git Bash,无法执行仓库现有生产构建脚本。请先安装 Git for Windows,并确保 bash 在 PATH 中。'
|
) | Where-Object { $_ -and (($_ -eq 'bash') -or (Test-Path $_)) } | Select-Object -First 1
|
||||||
|
if (-not $gitBash) {
|
||||||
|
throw '[stdb-build] Windows 构建节点缺少 Git Bash,无法执行仓库现有生产构建脚本。请先安装 Git for Windows,并确保 bash 在 PATH 中。'
|
||||||
|
}
|
||||||
|
$env:GENARRATIVE_BASH = $gitBash
|
||||||
|
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||||
|
throw '[stdb-build] 缺少 cargo。请先在 Windows 构建节点安装 Rust 工具链,并确保 cargo 在 PATH 中。'
|
||||||
|
}
|
||||||
|
# sccache 只是可选缓存;PATH 中存在但不可执行时必须回退到 rustc。
|
||||||
|
$sccacheCommand = Get-Command sccache -ErrorAction SilentlyContinue
|
||||||
|
$sccacheUsable = $false
|
||||||
|
if ($sccacheCommand) {
|
||||||
|
try {
|
||||||
|
& $sccacheCommand.Source --version | Out-Host
|
||||||
|
$sccacheUsable = $true
|
||||||
|
} catch {
|
||||||
|
Write-Host "[stdb-build] sccache 无法执行:$($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $sccacheUsable) {
|
||||||
|
Write-Host '[stdb-build] 未找到可用 sccache,改用 rustc 直接构建。'
|
||||||
|
Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
|
||||||
|
'''
|
||||||
}
|
}
|
||||||
$env:GENARRATIVE_BASH = $gitBash
|
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
||||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
withCredentials([
|
||||||
throw '[stdb-build] 缺少 cargo。请先在 Windows 构建节点安装 Rust 工具链,并确保 cargo 在 PATH 中。'
|
string(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET')
|
||||||
}
|
]) {
|
||||||
# sccache 只是可选缓存;PATH 中存在但不可执行时必须回退到 rustc。
|
buildStep()
|
||||||
$sccacheCommand = Get-Command sccache -ErrorAction SilentlyContinue
|
|
||||||
$sccacheUsable = $false
|
|
||||||
if ($sccacheCommand) {
|
|
||||||
try {
|
|
||||||
& $sccacheCommand.Source --version | Out-Host
|
|
||||||
$sccacheUsable = $true
|
|
||||||
} catch {
|
|
||||||
Write-Host "[stdb-build] sccache 无法执行:$($_.Exception.Message)"
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
buildStep()
|
||||||
}
|
}
|
||||||
if (-not $sccacheUsable) {
|
}
|
||||||
Write-Host '[stdb-build] 未找到可用 sccache,改用 rustc 直接构建。'
|
|
||||||
Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
|
|
||||||
'''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Archive') {
|
stage('Archive') {
|
||||||
steps {
|
steps {
|
||||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true
|
||||||
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt", fingerprint: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ usage() {
|
|||||||
--skip-web-build 跳过主站与后台构建,仅复制已有 dist 产物
|
--skip-web-build 跳过主站与后台构建,仅复制已有 dist 产物
|
||||||
--skip-api-build 跳过 api-server 构建,仅复制已有 release 二进制
|
--skip-api-build 跳过 api-server 构建,仅复制已有 release 二进制
|
||||||
--skip-spacetime-build 跳过 spacetime-module 构建,仅复制已有 wasm
|
--skip-spacetime-build 跳过 spacetime-module 构建,仅复制已有 wasm
|
||||||
|
--no-migration-bootstrap-secret
|
||||||
|
构建不带迁移引导密钥的 spacetime-module wasm
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +75,61 @@ write_sha256_file() {
|
|||||||
) >"${checksum_path}"
|
) >"${checksum_path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generate_migration_bootstrap_secret() {
|
||||||
|
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_migration_bootstrap_secret() {
|
||||||
|
local secret_source="generated"
|
||||||
|
|
||||||
|
if [[ "${BUILD_SPACETIME}" -ne 1 || "${SKIP_SPACETIME_BUILD}" -eq 1 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
|
||||||
|
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||||
|
echo "[production-release] 未启用迁移引导密钥。"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET:-}" ]]; then
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET}"
|
||||||
|
secret_source="environment"
|
||||||
|
else
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||||
|
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||||
|
echo "[production-release] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[production-release] 已准备迁移引导密钥: source=${secret_source}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_migration_bootstrap_secret_file() {
|
||||||
|
local target_path="${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||||
|
|
||||||
|
if [[ "${BUILD_SPACETIME}" -ne 1 || "${SKIP_SPACETIME_BUILD}" -eq 1 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${MIGRATION_BOOTSTRAP_SECRET}" ]]; then
|
||||||
|
echo "[production-release] 迁移引导密钥为空,无法写入发布产物。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${target_path}"
|
||||||
|
chmod 600 "${target_path}" 2>/dev/null || true
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=1
|
||||||
|
echo "[production-release] 已写入迁移引导密钥文件: ${target_path}"
|
||||||
|
}
|
||||||
|
|
||||||
write_release_manifest() {
|
write_release_manifest() {
|
||||||
RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \
|
RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \
|
||||||
RELEASE_VERSION="${BUILD_NAME}" \
|
RELEASE_VERSION="${BUILD_NAME}" \
|
||||||
@@ -83,6 +140,7 @@ write_release_manifest() {
|
|||||||
RELEASE_INCLUDE_WEB="${BUILD_WEB}" \
|
RELEASE_INCLUDE_WEB="${BUILD_WEB}" \
|
||||||
RELEASE_INCLUDE_API="${BUILD_API}" \
|
RELEASE_INCLUDE_API="${BUILD_API}" \
|
||||||
RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \
|
RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \
|
||||||
|
RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET_ARTIFACT}" \
|
||||||
node <<'NODE'
|
node <<'NODE'
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
@@ -108,6 +166,13 @@ if (process.env.RELEASE_INCLUDE_SPACETIME === '1') {
|
|||||||
checksum_path: 'spacetime_module.wasm.sha256',
|
checksum_path: 'spacetime_module.wasm.sha256',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (process.env.RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET === '1') {
|
||||||
|
artifacts.push({
|
||||||
|
component: 'spacetime-module',
|
||||||
|
path: 'migration-bootstrap-secret.txt',
|
||||||
|
sensitive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
version: process.env.RELEASE_VERSION,
|
version: process.env.RELEASE_VERSION,
|
||||||
@@ -131,6 +196,9 @@ COMPONENT="all"
|
|||||||
SKIP_WEB_BUILD=0
|
SKIP_WEB_BUILD=0
|
||||||
SKIP_API_BUILD=0
|
SKIP_API_BUILD=0
|
||||||
SKIP_SPACETIME_BUILD=0
|
SKIP_SPACETIME_BUILD=0
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET=""
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=0
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||||
BUILD_COMPLETED=0
|
BUILD_COMPLETED=0
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -159,6 +227,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
SKIP_SPACETIME_BUILD=1
|
SKIP_SPACETIME_BUILD=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--no-migration-bootstrap-secret)
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET=""
|
||||||
|
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[production-release] 未知参数: $1" >&2
|
echo "[production-release] 未知参数: $1" >&2
|
||||||
usage >&2
|
usage >&2
|
||||||
@@ -262,6 +335,7 @@ mkdir -p "${TARGET_DIR}"
|
|||||||
|
|
||||||
echo "[production-release] 发布包目录: ${TARGET_DIR}"
|
echo "[production-release] 发布包目录: ${TARGET_DIR}"
|
||||||
echo "[production-release] 构建组件: ${COMPONENT}"
|
echo "[production-release] 构建组件: ${COMPONENT}"
|
||||||
|
prepare_migration_bootstrap_secret
|
||||||
|
|
||||||
if [[ "${BUILD_WEB}" -eq 1 ]]; then
|
if [[ "${BUILD_WEB}" -eq 1 ]]; then
|
||||||
mkdir -p "${WEB_DIR}"
|
mkdir -p "${WEB_DIR}"
|
||||||
@@ -364,6 +438,7 @@ fi
|
|||||||
if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
|
if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
|
||||||
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||||||
write_sha256_file "${TARGET_DIR}/spacetime_module.wasm"
|
write_sha256_file "${TARGET_DIR}/spacetime_module.wasm"
|
||||||
|
write_migration_bootstrap_secret_file
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy"
|
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy"
|
||||||
@@ -402,6 +477,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
|||||||
- \`web.tar.gz\` / \`web.tar.gz.sha256\`:Web 发布流水线使用的静态资源压缩包与校验文件。
|
- \`web.tar.gz\` / \`web.tar.gz.sha256\`:Web 发布流水线使用的静态资源压缩包与校验文件。
|
||||||
- \`api-server\`:生产 Linux release 可执行文件。
|
- \`api-server\`:生产 Linux release 可执行文件。
|
||||||
- \`spacetime_module.wasm\`:SpacetimeDB 模块 wasm。
|
- \`spacetime_module.wasm\`:SpacetimeDB 模块 wasm。
|
||||||
|
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
|
||||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ COMMIT_HASH="${COMMIT_HASH:-}"
|
|||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"
|
GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"
|
||||||
SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}"
|
SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}"
|
||||||
|
|
||||||
|
# Windows PowerShell 5.1 的 UTF-8 输出可能带 BOM;下游参数校验前先剥离不可见字节。
|
||||||
|
SOURCE_BRANCH="$(printf "%s" "${SOURCE_BRANCH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
||||||
|
COMMIT_HASH="$(printf "%s" "${COMMIT_HASH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
||||||
|
|
||||||
if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then
|
if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then
|
||||||
echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2
|
echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4;
|
|||||||
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
|
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
|
||||||
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
|
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
|
||||||
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
|
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
|
||||||
|
const MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START = 0.18;
|
||||||
|
const MATCH3D_ITEM_SPAWN_VISUAL_DROP_OFFSET = 0.04;
|
||||||
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
|
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
|
||||||
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
|
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
|
||||||
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
|
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
|
||||||
@@ -558,6 +560,18 @@ function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DSpawnVisualScale(progress: number) {
|
||||||
|
const clampedProgress = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, Number.isFinite(progress) ? progress : 0),
|
||||||
|
);
|
||||||
|
const easedProgress = 1 - Math.pow(1 - clampedProgress, 3);
|
||||||
|
return (
|
||||||
|
MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START +
|
||||||
|
(1 - MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START) * easedProgress
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function applyCenterGravity(entry: PhysicsEntry) {
|
function applyCenterGravity(entry: PhysicsEntry) {
|
||||||
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
|
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1028,7 +1042,7 @@ function createPhysicsEntryFromPendingSpawn(
|
|||||||
0.08,
|
0.08,
|
||||||
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
|
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
|
||||||
);
|
);
|
||||||
visual.mesh.scale.setScalar(0.82);
|
visual.mesh.scale.setScalar(MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START);
|
||||||
|
|
||||||
runtime.world.addBody(body);
|
runtime.world.addBody(body);
|
||||||
runtime.scene.add(visual.mesh);
|
runtime.scene.add(visual.mesh);
|
||||||
@@ -1714,11 +1728,12 @@ export function Match3DPhysicsBoard({
|
|||||||
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
|
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
|
||||||
constrainBodyInsidePot(entry);
|
constrainBodyInsidePot(entry);
|
||||||
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
|
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
|
||||||
const spawnScale = 0.82 + spawnProgress * 0.18;
|
const spawnScale = resolveMatch3DSpawnVisualScale(spawnProgress);
|
||||||
entry.mesh.scale.setScalar(spawnScale);
|
entry.mesh.scale.setScalar(spawnScale);
|
||||||
entry.mesh.position.set(
|
entry.mesh.position.set(
|
||||||
entry.body.position.x,
|
entry.body.position.x,
|
||||||
entry.body.position.y - (1 - spawnProgress) * 0.06,
|
entry.body.position.y -
|
||||||
|
(1 - spawnProgress) * MATCH3D_ITEM_SPAWN_VISUAL_DROP_OFFSET,
|
||||||
entry.body.position.z,
|
entry.body.position.z,
|
||||||
);
|
);
|
||||||
entry.mesh.quaternion.set(
|
entry.mesh.quaternion.set(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
resolveMatch3DSpawnTimingPlan,
|
resolveMatch3DSpawnTimingPlan,
|
||||||
resolveMatch3DStackTargetY,
|
resolveMatch3DStackTargetY,
|
||||||
resolveMatch3DSpawnDelay,
|
resolveMatch3DSpawnDelay,
|
||||||
|
resolveMatch3DSpawnVisualScale,
|
||||||
resolveMatch3DSpawnY,
|
resolveMatch3DSpawnY,
|
||||||
resolveMatch3DTrayPreviewRotation,
|
resolveMatch3DTrayPreviewRotation,
|
||||||
resolveMatch3DTrayPreviewReferenceDimension,
|
resolveMatch3DTrayPreviewReferenceDimension,
|
||||||
@@ -591,6 +592,18 @@ test('3D 新物体生成高度会避让同位置已有堆叠', () => {
|
|||||||
expect(unchangedSpawnY).toBe(plannedSpawnY);
|
expect(unchangedSpawnY).toBe(plannedSpawnY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('3D 新物体生成动画只缩放可见模型并最终回到完整尺寸', () => {
|
||||||
|
const startScale = resolveMatch3DSpawnVisualScale(0);
|
||||||
|
const middleScale = resolveMatch3DSpawnVisualScale(0.5);
|
||||||
|
const endScale = resolveMatch3DSpawnVisualScale(1);
|
||||||
|
|
||||||
|
expect(startScale).toBeGreaterThan(0);
|
||||||
|
expect(startScale).toBeLessThan(0.25);
|
||||||
|
expect(middleScale).toBeGreaterThan(startScale);
|
||||||
|
expect(middleScale).toBeLessThan(endScale);
|
||||||
|
expect(endScale).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
||||||
const run = startLocalMatch3DRun(2);
|
const run = startLocalMatch3DRun(2);
|
||||||
run.items = run.items.slice(0, 2).map((item, index) => ({
|
run.items = run.items.slice(0, 2).map((item, index) => ({
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export default defineConfig({
|
|||||||
include: [
|
include: [
|
||||||
'src/**/*.test.ts',
|
'src/**/*.test.ts',
|
||||||
'src/**/*.test.tsx',
|
'src/**/*.test.tsx',
|
||||||
|
'apps/admin-web/src/**/*.test.ts',
|
||||||
|
'apps/admin-web/src/**/*.test.tsx',
|
||||||
'scripts/**/*.test.ts',
|
'scripts/**/*.test.ts',
|
||||||
'packages/shared/src/**/*.test.ts',
|
'packages/shared/src/**/*.test.ts',
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user