diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 58f03673..ee0b4314 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -269,6 +269,14 @@ - 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。 - 关联:`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 - 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。 diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx new file mode 100644 index 00000000..645127aa --- /dev/null +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -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( + , + ); + + 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() ?? '', + ); +} diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index 62e5eacc..781c2d23 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -1,4 +1,12 @@ -import {Eye, RefreshCcw, Search, X} from 'lucide-react'; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Eye, + RefreshCcw, + Search, + X, +} from 'lucide-react'; import {FormEvent, useEffect, useMemo, useState} from 'react'; import { @@ -16,6 +24,8 @@ interface AdminDatabaseTablesPageProps { onUnauthorized: (message?: string) => void; } +type SortDirection = 'asc' | 'desc'; + export function AdminDatabaseTablesPage({ token, onUnauthorized, @@ -29,6 +39,8 @@ export function AdminDatabaseTablesPage({ const [detailRow, setDetailRow] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [copyMessage, setCopyMessage] = useState(''); + const [sortColumn, setSortColumn] = useState(''); + const [sortDirection, setSortDirection] = useState('asc'); const [isLoadingTables, setIsLoadingTables] = useState(false); const [isLoadingRows, setIsLoadingRows] = useState(false); @@ -76,6 +88,52 @@ export function AdminDatabaseTablesPage({ return firstRow ? Object.keys(firstRow.cells) : []; }, [result]); + const tableOptions = useMemo(() => { + const optionNames = + tableName && !tables.includes(tableName) ? [tableName, ...tables] : tables; + return optionNames.map(getDatabaseTableHeader); + }, [tableName, tables]); + + const selectedTableHeader = useMemo( + () => getDatabaseTableHeader(tableName), + [tableName], + ); + + const resultTableHeader = useMemo( + () => getDatabaseTableHeader(result?.tableName || tableName), + [result?.tableName, tableName], + ); + + const columnHeaders = useMemo( + () => + visibleColumns.map((column) => + getDatabaseTableColumnHeader(tableName, column), + ), + [tableName, visibleColumns], + ); + + const sortedRows = useMemo(() => { + const rows = result?.rows ?? []; + if (!sortColumn || !visibleColumns.includes(sortColumn)) { + return rows; + } + + return [...rows] + .map((row, index) => ({index, row})) + .sort((left, right) => { + const comparison = compareTableCellValues( + left.row.cells[sortColumn], + right.row.cells[sortColumn], + sortDirection, + ); + if (comparison !== 0) { + return comparison; + } + return left.index - right.index; + }) + .map(({row}) => row); + }, [result, sortColumn, sortDirection, visibleColumns]); + async function loadTables() { setIsLoadingTables(true); setErrorMessage(''); @@ -144,6 +202,18 @@ export function AdminDatabaseTablesPage({ void refreshRows(tableName, {search: '', filters: '', limit: '100'}); } + function handleSortColumn(column: string) { + if (sortColumn === column) { + setSortDirection((currentDirection) => + currentDirection === 'asc' ? 'desc' : 'asc', + ); + return; + } + + setSortColumn(column); + setSortDirection('asc'); + } + async function handleCopyDetailJson() { if (!detailRow) { return; @@ -195,12 +265,9 @@ export function AdminDatabaseTablesPage({ value={tableName} onChange={(event) => handleTableChange(event.target.value)} > - {tableName && !tables.includes(tableName) ? ( - {tableName} - ) : null} - {tables.map((name) => ( - - {name} + {tableOptions.map(({name, optionLabel, description}) => ( + + {optionLabel} ))} @@ -244,7 +311,9 @@ export function AdminDatabaseTablesPage({ 重置条件 - 已选表:{tableName || '-'} + + 已选表:{selectedTableHeader.optionLabel} + 显示列:{visibleColumns.length} @@ -258,30 +327,71 @@ export function AdminDatabaseTablesPage({ - {result?.tableName || tableName || '数据行'} + {resultTableHeader.label} {result?.totalReturned ?? 0} 条 - + - {visibleColumns.map((column) => ( - {column} - ))} + {columnHeaders.map(({column, label, description}) => { + const isSorted = sortColumn === column; + return ( + + handleSortColumn(column)} + > + {label} + {isSorted ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + ); + })} 详情 - {result?.rows.length ? ( - result.rows.map((row, rowIndex) => ( + {sortedRows.length ? ( + sortedRows.map((row, rowIndex) => ( setDetailRow(row)} > - {visibleColumns.map((column) => ( - {formatCellValue(row.cells[column])} - ))} + {visibleColumns.map((column) => { + const cellValue = formatCellValue(row.cells[column]); + return ( + + + {cellValue.content} + + + ); + })} {JSON.stringify(value, null, 2)}; +} + +function stringifyUnknownValue(value: unknown) { + try { + const serialized = JSON.stringify(value); + return serialized ?? String(value); + } catch { + return String(value); + } +} + +function getSortKindOrder(kind: SortableTableCellValue['kind']): number { + switch (kind) { + case 'number': + return 0; + case 'boolean': + return 1; + case 'text': + return 2; + case 'empty': + return 3; + default: + return 3; + } +} + +type SortableTableCellValue = + | {kind: 'empty'} + | {kind: 'number'; value: number} + | {kind: 'boolean'; value: boolean} + | {kind: 'text'; value: string}; + +interface DatabaseTableHeader { + name: string; + label: string; + optionLabel: string; + description: string; +} + +interface FormattedTableCellValue { + content: string; + fullText: string; +} + +const tableSortCollator = new Intl.Collator('zh-CN', { + numeric: true, + sensitivity: 'base', +}); + +const databaseTableColumnLabelMap: Record = { + id: 'ID', + table_name: '表名', + row_count: '行数', + error_message: '错误信息', + fetch_errors: '读取异常', + total_returned: '返回总数', + columns: '列名列表', + rows: '行列表', + raw: '原始值', + user_id: '用户ID', + referee_user_id: '被邀请人ID', + invitee_user_id: '被邀请人ID', + owner_user_id: '归属用户ID', + profile_id: '档案ID', + module_key: '模块键', + event_id: '事件ID', + event_key: '事件键', + event_title: '事件名称', + scope_kind: '范围类型', + scope_id: '范围ID', + day_key: '日期键', + occurred_at: '发生时间', + created_at: '创建时间', + updated_at: '更新时间', + starts_at: '开始时间', + expires_at: '到期时间', + last_played_at: '最近游玩时间', + published_at: '发布时间', + sort_order: '排序值', + reward_points: '奖励积分', + threshold: '阈值', + enabled: '启用状态', + status: '状态', + description: '说明', + title: '标题', + name: '名称', + points: '积分', + total: '总数', + count: '数量', + metadata_json: '元数据JSON', + data_json: '数据JSON', + config_json: '配置JSON', + draft_json: '草稿JSON', + snapshot_json: '快照JSON', + payload_json: '载荷JSON', + request_payload_json: '请求载荷JSON', + latest_structured_payload_json: '最新结构化载荷JSON', + result_payload_json: '结果载荷JSON', + evidence_json: '凭证JSON', + source_asset_ids_json: '源资产ID列表', + available_choices_json: '可选项JSON', + visible_character_ids_json: '可见角色ID列表', + played_profile_ids_json: '已玩档案列表', + tags_json: '标签JSON', + theme_tags_json: '主题标签JSON', + cover_image_src: '封面图', + cover_asset_id: '封面资产ID', + public_user_code: '百梦号', + public_work_code: '公开作品号', + author_public_user_code: '作者百梦号', + author_display_name: '作者昵称', + display_name: '显示名', + avatar_url: '头像地址', + phone_number_masked: '手机号掩码', + phone_number_e164: '手机号E164', + login_method: '登录方式', + binding_status: '绑定状态', + token_version: '令牌版本', + wallet_balance: '钱包余额', + amount_delta: '变动金额', + balance_after: '变动后余额', + source_type: '来源类型', + source_module: '来源模块', + source_entity_id: '来源实体ID', + request_label: '请求标签', + task_kind: '任务类型', + failure_message: '失败信息', + latest_text_output: '最新文本输出', + world_key: '世界键', + world_type: '世界类型', + world_name: '世界名称', + world_title: '世界标题', + world_subtitle: '世界副标题', + subtitle: '副标题', + summary_text: '摘要文本', + bottom_tab: '底部Tab', + saved_at: '存档时间', + visited_at: '访问时间', + claimed_at: '领取时间', + paid_at: '支付时间', + started_at: '开始时间', + completed_at: '完成时间', + finished_at_ms: '完成时间毫秒', + started_at_ms: '开始时间毫秒', + elapsed_ms: '耗时毫秒', + duration_limit_ms: '时限毫秒', + play_count: '游玩次数', + clear_count: '通关次数', + like_id: '点赞ID', + liked_at: '点赞时间', + invite_code: '邀请码', + inviter_user_id: '邀请人ID', + inviter_reward_granted: '邀请人奖励发放状态', + invitee_reward_granted: '被邀请人奖励发放状态', + bound_at: '绑定时间', + search: '关键词', + filters: '筛选条件', + limit: '条数上限', + value: '值', + type: '类型', + kind: '类型', + key: '键', + url: '链接地址', + path: '路径', + message: '消息', + content: '内容', + text: '文本', + source: '来源', + target: '目标', + asset_id: '资产ID', + file_id: '文件ID', + task_id: '任务ID', + session_id: '会话ID', + record_id: '记录ID', + created_by: '创建人', + updated_by: '更新人', + total_count: '总数', + max_uses: '最大使用次数', + global_used_count: '全局使用次数', + allowed_user_ids: '允许用户列表', + metadata: '元数据', +}; + +const databaseTableColumnDescriptionMap: Record = { + id: '当前记录的唯一标识', + table_name: '当前查询的表名', + row_count: '当前统计到的行数', + error_message: '读取表统计或行数据时返回的错误信息', + fetch_errors: '读取表列表时积累的异常信息', + total_returned: '本次接口实际返回的行数', + columns: '当前结果集返回的字段名列表', + rows: '当前结果集返回的行列表', + raw: '当前行的原始值', + user_id: '当前记录所属用户的标识', + referee_user_id: '被邀请人的用户标识', + invitee_user_id: '被邀请人的用户标识', + owner_user_id: '当前记录归属用户的标识', + profile_id: '当前用户档案的标识', + module_key: '当前记录所属模块的业务键', + event_id: '当前埋点事件的唯一标识', + event_key: '埋点事件键', + event_title: '埋点事件展示名称', + scope_kind: '埋点统计范围类型', + scope_id: '埋点统计范围标识', + day_key: '按天聚合时使用的日期键', + occurred_at: '事件实际发生时间', + created_at: '记录创建时间', + updated_at: '记录更新时间', + starts_at: '生效开始时间', + expires_at: '失效或到期时间', + last_played_at: '最近一次游玩时间', + published_at: '公开发布时间', + sort_order: '列表或配置项使用的排序值', + reward_points: '完成任务后发放的奖励积分', + threshold: '触发完成条件的阈值', + enabled: '是否启用', + status: '当前状态', + description: '字段说明', + title: '展示标题', + name: '名称', + points: '积分或点数', + metadata_json: '存放结构化附加信息的 JSON 文本', + data_json: '存放业务数据的 JSON 文本', + config_json: '当前记录的配置 JSON', + draft_json: '当前记录的草稿 JSON', + snapshot_json: '当前运行态快照 JSON', + payload_json: '事件或任务携带的载荷 JSON', + request_payload_json: 'AI 任务请求载荷 JSON', + latest_structured_payload_json: 'AI 任务最新结构化输出 JSON', + result_payload_json: '生成或编排完成后的结果载荷 JSON', + evidence_json: '用户反馈提交时附带的凭证元数据 JSON', + source_asset_ids_json: '当前记录引用的源资产 ID 列表', + available_choices_json: '当前运行态可选项 JSON', + visible_character_ids_json: '当前运行态可见角色 ID 列表', + played_profile_ids_json: '当前运行态已串联游玩的档案 ID 列表', + tags_json: '标签列表 JSON', + theme_tags_json: '主题标签列表 JSON', + cover_image_src: '封面图片地址或平台资产引用', + cover_asset_id: '封面对应的平台资产 ID', + public_user_code: '用户对外展示的百梦号', + public_work_code: '作品公开后对外展示的作品号', + author_public_user_code: '作者对外展示的百梦号', + author_display_name: '作者展示昵称', + display_name: '用户展示名称', + avatar_url: '用户头像地址', + phone_number_masked: '脱敏后的手机号', + phone_number_e164: 'E.164 格式手机号', + login_method: '账号最近或主要登录方式', + binding_status: '账号绑定状态', + token_version: '用于令牌吊销和刷新控制的版本号', + wallet_balance: '当前钱包余额', + amount_delta: '本次钱包流水的增减值', + balance_after: '本次变更后的钱包余额', + source_type: '当前记录的来源类型', + source_module: '触发 AI 任务的来源模块', + source_entity_id: '触发 AI 任务的来源实体 ID', + request_label: 'AI 任务请求展示标签', + task_kind: 'AI 任务类型', + failure_message: '失败时记录的错误信息', + latest_text_output: 'AI 任务最近一次文本输出', + world_key: '世界或作品在运行态中的稳定键', + world_type: '世界或作品类型', + world_name: '世界名称', + world_title: '世界标题', + world_subtitle: '世界副标题', + subtitle: '副标题', + summary_text: '摘要文本', + bottom_tab: '保存快照时所在的底部 Tab', + saved_at: '用户保存存档的时间', + visited_at: '用户访问该作品或世界的时间', + claimed_at: '奖励领取时间', + paid_at: '订单支付时间', + started_at: '任务或流程开始时间', + completed_at: '任务或流程完成时间', + finished_at_ms: '运行态完成时的毫秒时间戳', + started_at_ms: '运行态开始时的毫秒时间戳', + elapsed_ms: '运行态已消耗毫秒数', + duration_limit_ms: '运行态时间限制毫秒数', + play_count: '作品累计游玩次数', + clear_count: '累计通关次数', + like_id: '点赞记录唯一标识', + liked_at: '点赞发生时间', + invite_code: '完成绑定时使用的邀请码', + inviter_user_id: '邀请人的用户标识', + inviter_reward_granted: '邀请人奖励是否已经发放', + invitee_reward_granted: '被邀请人奖励是否已经发放', + bound_at: '邀请关系绑定时间', + search: '用于本地过滤的关键词', + filters: '用于等值筛选的 JSON 条件', + limit: '本次查询请求的条数上限', + value: '当前字段值', + type: '当前字段类型', + kind: '当前字段种类', + key: '用于稳定识别记录或业务项的键', + url: '链接地址或资源地址', + path: '资源路径或业务路径', + message: '消息内容或错误信息', + content: '主要内容', + text: '文本内容', + source: '来源字段', + target: '目标字段', + asset_id: '平台资产对象标识', + file_id: '文件标识', + task_id: '任务标识', + session_id: '会话标识', + record_id: '记录标识', + created_by: '创建该记录的主体', + updated_by: '最后更新该记录的主体', + total_count: '累计总数', + max_uses: '允许的最大使用次数', + global_used_count: '当前已使用次数', + allowed_user_ids: '被允许使用的用户 ID 列表', + metadata: '结构化附加信息', +}; + +const databaseTableColumnSegmentLabelMap: Record = { + account: '账户', + action: '动作', + active: '启用', + actor: '参与者', + added: '新增', + address: '地址', + after: '后', + agent: 'Agent', + ai: 'AI', + amount: '金额', + anchor: '锚点', + archive: '存档', + assistant: '助手', + at: '时间', + asset: '资产', + attach: '附加', + attachment: '附件', + author: '作者', + available: '可用', + balance: '余额', + before: '前', + best: '最佳', + binding: '绑定', + blockers: '阻断项', + body: '正文', + bottom: '底部', + built: '构建', + bucket: 'Bucket', + cache: '缓存', + card: '卡片', + cents: '分', + channel: '渠道', + chapter: '章节', + character: '角色', + chat: '聊天', + choice: '选项', + claim: '领取', + claimed: '领取', + claimant: '领取人', + cleared: '已清除', + client: '客户端', + code: '编码', + completed: '完成', + content: '内容', + count: '数量', + create: '创建', + created: '创建', + current: '当前', + custom: '自定义', + data: '数据', + date: '日期', + day: '日期', + default: '默认', + delta: '变动', + delete: '删除', + deleted: '删除', + description: '说明', + detail: '详情', + device: '设备', + difficulty: '难度', + display: '展示', + draft: '草稿', + elapsed: '耗时', + enabled: '启用', + entity: '实体', + entry: '记录', + error: '错误', + evidence: '凭证', + event: '事件', + expires: '到期', + expire: '到期', + expired: '到期', + failure: '失败', + file: '文件', + filter: '筛选', + first: '首个', + flags: '标记', + flow: '流程', + form: '表单', + game: '游戏', + granted: '发放', + global: '全局', + grid: '网格', + group: '分组', + hash: '哈希', + history: '历史', + host: '主机', + id: 'ID', + image: '图片', + info: '信息', + initial: '初始', + input: '输入', + invite: '邀请', + invitee: '被邀请人', + inviter: '邀请人', + issued: '签发', + item: '物品', + job: '任务', + json: 'JSON', + key: '键', + kind: '类型', + last: '最近', + ledger: '流水', + level: '关卡', + liked: '点赞', + limit: '条数上限', + line: '行', + link: '链接', + list: '列表', + login: '登录', + main: '主', + max: '最大', + membership: '会员', + message: '消息', + metadata: '元数据', + metrics: '指标', + module: '模块', + mode: '模式', + name: '名称', + next: '下一个', + npc: 'NPC', + note: '备注', + number: '编号', + object: '对象', + observed: '观察', + order: '订单', + owner: '归属', + pack: '包', + paid: '支付', + password: '密码', + payment: '支付', + percent: '百分比', + phone: '手机', + phase: '阶段', + path: '路径', + pending: '待处理', + picture: '图片', + platform: '平台', + points: '积分', + policy: '策略', + product: '商品', + profile: '档案', + progress: '进度', + prompt: '提示词', + provider: '提供方', + publication: '发布', + publish: '发布', + payload: '载荷', + public: '公开', + query: '查询', + ready: '就绪', + raw: '原始', + record: '记录', + reference: '引用', + referral: '邀请关系', + refresh: '刷新', + register: '注册', + relation: '关系', + reply: '回复', + request: '请求', + response: '响应', + reward: '奖励', + row: '行', + rows: '行', + run: '运行', + runtime: '运行态', + save: '存档', + saved: '存档', + search: '搜索', + seed: '种子', + seen: '可见', + sequence: '序号', + session: '会话', + shape: '形状', + share: '分享', + slot: '槽位', + snapshot: '快照', + sort: '排序', + status: '状态', + source: '来源', + starts: '开始', + started: '开始', + state: '状态', + step: '步骤', + structured: '结构化', + summary: '摘要', + task: '任务', + target: '目标', + text: '文本', + time: '时间', + title: '标题', + total: '总数', + type: '类型', + unique: '唯一', + update: '更新', + updated: '更新', + url: '链接', + user: '用户', + value: '值', + version: '版本', + visible: '可见', + wallet: '钱包', + wechat: '微信', + work: '作品', + world: '世界', + xp: '经验', + occurred: '发生', + played: '游玩', + published: '发布时间', + returned: '返回', + rewardpoints: '奖励积分', + result: '结果', + read: '读取', + write: '写入', + totalreturned: '返回总数', +}; + +const databaseTableLabelMap: Record = { + database_migration_operator: '数据库迁移操作员', + database_migration_import_chunk: '数据库迁移分片', + auth_store_snapshot: '认证仓储快照', + user_account: '用户账号', + auth_identity: '身份绑定', + refresh_session: '刷新会话', + runtime_setting: '运行时设置', + runtime_snapshot: '运行时快照', + user_browse_history: '浏览历史', + profile_dashboard_state: '个人主页状态', + profile_wallet_ledger: '钱包流水', + analytics_date_dimension: '日期维表', + tracking_event: '埋点事件', + tracking_daily_stat: '埋点日统计', + profile_task_config: '个人任务配置', + profile_task_progress: '个人任务进度', + profile_task_reward_claim: '个人任务领奖', + profile_redeem_code: '兑换码', + profile_redeem_code_usage: '兑换码使用记录', + profile_invite_code: '邀请码', + profile_referral_relation: '邀请关系', + profile_played_world: '已玩世界', + public_work_play_daily_stat: '公开作品日游玩统计', + public_work_like: '公开作品点赞', + profile_membership: '会员状态', + profile_recharge_order: '充值订单', + profile_feedback_submission: '反馈提交', + profile_save_archive: '存档记录', + story_session: '剧情会话', + story_event: '剧情事件', + npc_state: 'NPC 状态', + inventory_slot: '背包槽位', + battle_state: '战斗状态', + treasure_record: '宝藏记录', + quest_record: '任务记录', + quest_log: '任务日志', + player_progression: '玩家进度', + chapter_progression: '章节进度', + custom_world_profile: '自定义世界档案', + custom_world_session: '自定义世界会话', + custom_world_agent_session: '自定义世界 Agent 会话', + custom_world_agent_message: '自定义世界 Agent 消息', + custom_world_agent_operation: '自定义世界 Agent 操作', + custom_world_draft_card: '自定义世界草稿卡片', + custom_world_gallery_entry: '自定义世界画廊条目', + puzzle_agent_session: '拼图 Agent 会话', + puzzle_agent_message: '拼图 Agent 消息', + puzzle_work_profile: '拼图作品档案', + puzzle_event: '拼图事件', + puzzle_runtime_run: '拼图运行记录', + puzzle_leaderboard_entry: '拼图排行榜条目', + match3d_agent_session: '抓大鹅 Agent 会话', + match3d_agent_message: '抓大鹅 Agent 消息', + match3d_work_profile: '抓大鹅作品档案', + match3d_runtime_run: '抓大鹅运行记录', + square_hole_agent_session: '方洞挑战 Agent 会话', + square_hole_agent_message: '方洞挑战 Agent 消息', + square_hole_work_profile: '方洞挑战作品档案', + square_hole_runtime_run: '方洞挑战运行记录', + visual_novel_agent_session: '视觉小说 Agent 会话', + visual_novel_agent_message: '视觉小说 Agent 消息', + visual_novel_work_profile: '视觉小说作品档案', + visual_novel_runtime_run: '视觉小说运行记录', + visual_novel_runtime_history_entry: '视觉小说历史条目', + visual_novel_runtime_event: '视觉小说运行事件', + big_fish_creation_session: '大鱼吃小鱼创建会话', + big_fish_agent_message: '大鱼吃小鱼 Agent 消息', + big_fish_asset_slot: '大鱼吃小鱼资产槽位', + big_fish_event: '大鱼吃小鱼事件', + big_fish_runtime_run: '大鱼吃小鱼运行记录', + asset_object: '资产对象', + asset_entity_binding: '资产实体绑定', + asset_event: '资产事件', + ai_task: 'AI 任务', + ai_task_stage: 'AI 任务阶段', + ai_text_chunk: 'AI 文本分片', + ai_result_reference: 'AI 结果引用', + ai_task_event: 'AI 任务事件', +}; + +const databaseTableDescriptionMap: Record = { + database_migration_operator: '管理数据库迁移导出、导入和增量导入权限的操作员表', + database_migration_import_chunk: '大迁移 JSON 分片导入的临时表', + auth_store_snapshot: '旧认证仓储的整份 JSON 快照表', + user_account: '用户账号主表', + auth_identity: '第三方或手机号身份绑定表', + refresh_session: '刷新令牌会话表', + runtime_setting: '用户运行时设置表', + runtime_snapshot: '用户当前运行时快照表', + user_browse_history: '用户浏览历史表', + profile_dashboard_state: '个人主页聚合状态表', + profile_wallet_ledger: '钱包流水账表', + analytics_date_dimension: '分析日期维表', + tracking_event: '埋点原始事件表', + tracking_daily_stat: '埋点按自然日聚合表', + profile_task_config: '个人任务配置表', + profile_task_progress: '个人任务进度表', + profile_task_reward_claim: '个人任务领奖记录表', + profile_redeem_code: '运营兑换码表', + profile_redeem_code_usage: '兑换码使用记录表', + profile_invite_code: '用户邀请中心邀请码表', + profile_referral_relation: '邀请关系记录表', + profile_played_world: '用户已玩世界记录表', + public_work_play_daily_stat: '公开作品日游玩统计表', + public_work_like: '公开作品点赞表', + profile_membership: '会员状态表', + profile_recharge_order: '充值订单表', + profile_feedback_submission: '反馈提交记录表', + profile_save_archive: '用户存档记录表', + story_session: '剧情会话表', + story_event: '剧情事件表', + npc_state: 'NPC 状态表', + inventory_slot: '背包槽位表', + battle_state: '战斗状态表', + treasure_record: '宝藏记录表', + quest_record: '任务记录表', + quest_log: '任务日志表', + player_progression: '玩家进度表', + chapter_progression: '章节进度表', + custom_world_profile: '自定义世界档案表', + custom_world_session: '自定义世界会话表', + custom_world_agent_session: '自定义世界 Agent 会话表', + custom_world_agent_message: '自定义世界 Agent 消息表', + custom_world_agent_operation: '自定义世界 Agent 操作表', + custom_world_draft_card: '自定义世界草稿卡片表', + custom_world_gallery_entry: '自定义世界画廊条目表', + puzzle_agent_session: '拼图 Agent 会话表', + puzzle_agent_message: '拼图 Agent 消息表', + puzzle_work_profile: '拼图作品档案表', + puzzle_event: '拼图事件表', + puzzle_runtime_run: '拼图运行记录表', + puzzle_leaderboard_entry: '拼图排行榜条目表', + match3d_agent_session: '抓大鹅 Agent 会话表', + match3d_agent_message: '抓大鹅 Agent 消息表', + match3d_work_profile: '抓大鹅作品档案表', + match3d_runtime_run: '抓大鹅运行记录表', + square_hole_agent_session: '方洞挑战 Agent 会话表', + square_hole_agent_message: '方洞挑战 Agent 消息表', + square_hole_work_profile: '方洞挑战作品档案表', + square_hole_runtime_run: '方洞挑战运行记录表', + visual_novel_agent_session: '视觉小说 Agent 会话表', + visual_novel_agent_message: '视觉小说 Agent 消息表', + visual_novel_work_profile: '视觉小说作品档案表', + visual_novel_runtime_run: '视觉小说运行记录表', + visual_novel_runtime_history_entry: '视觉小说历史条目表', + visual_novel_runtime_event: '视觉小说运行事件表', + big_fish_creation_session: '大鱼吃小鱼创建会话表', + big_fish_agent_message: '大鱼吃小鱼 Agent 消息表', + big_fish_asset_slot: '大鱼吃小鱼资产槽位表', + big_fish_event: '大鱼吃小鱼事件表', + big_fish_runtime_run: '大鱼吃小鱼运行记录表', + asset_object: '资产对象表', + asset_entity_binding: '资产实体绑定表', + asset_event: '资产事件表', + ai_task: 'AI 任务表', + ai_task_stage: 'AI 任务阶段表', + ai_text_chunk: 'AI 文本分片表', + ai_result_reference: 'AI 结果引用表', + ai_task_event: 'AI 任务事件表', +}; + +function getSortableNumberValue(value: SortableTableCellValue) { + return isSortableNumberValue(value) ? value.value : 0; +} + +function getSortableBooleanValue(value: SortableTableCellValue) { + return isSortableBooleanValue(value) ? value.value : false; +} + +function getSortableTextValue(value: SortableTableCellValue) { + return isSortableTextValue(value) ? value.value : stringifyUnknownValue(value); +} + +function isSortableNumberValue( + value: SortableTableCellValue, +): value is Extract { + return value.kind === 'number'; +} + +function isSortableBooleanValue( + value: SortableTableCellValue, +): value is Extract { + return value.kind === 'boolean'; +} + +function isSortableTextValue( + value: SortableTableCellValue, +): value is Extract { + return value.kind === 'text'; } diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 6152ff73..24096c02 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -350,11 +350,6 @@ button:disabled { font-weight: 650; } -.admin-query-reset-button { - width: auto; - padding: 0 12px; -} - .admin-field { display: grid; min-width: 0; @@ -603,6 +598,13 @@ button:disabled { background: #eef3f6; } +.admin-ghost-button.admin-query-reset-button { + width: auto; + min-width: 76px; + height: 40px; + padding: 0 12px; +} + .admin-text-button { display: inline; border: 0; @@ -650,7 +652,10 @@ button:disabled { } .admin-table-wrap { + max-width: 100%; overflow: auto; + scrollbar-gutter: stable; + -webkit-overflow-scrolling: touch; } .admin-table { @@ -687,6 +692,65 @@ button:disabled { 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 { max-width: 360px; max-height: 160px; diff --git a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md index c02cf0b5..68ee8cbb 100644 --- a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md +++ b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md @@ -6,7 +6,7 @@ 1. 产品名称:百梦。 2. 产品愿景:百梦AI团队致力于打造AI互动内容UGC平台。 -3. 产品slogan:每个人都可以在10分钟内轻松创作出一款精品互动作品。 +3. 产品slogan:不用代码,不用美术,10分钟把脑洞变成有趣的体验。 4. 产品特点:低门槛创作、高完成度作品、玩过后可改造并发布。 5. 关键技术:Harness Engineering、多Agent调度、AI创作工具、AI原生游戏框架。 6. 产品心智:想玩但找不到、玩到不满意、平台外体验不满意时,都可以来百梦做成自己满意的。 @@ -28,16 +28,14 @@ ```text 百梦 -AI互动内容UGC平台 -把想玩的世界,亲手做出来 +10分钟做自己的互动内容 ``` 第二层:远读slogan ```text -每个人都可以在10分钟内 -轻松创作出一款 -精品互动作品 +不用代码,不用美术, +10分钟把脑洞变成有趣的体验 ``` 第三层:产品特点 @@ -88,6 +86,8 @@ reference image: 百梦气泡共创logo方向图 output: output/imagegen/baimeng-expo-rollup/baimeng-rollup-background-gpt-image-2.png ``` +2026-05-08 根据新文案重新调用 `gpt-image-2` 生成新版底图。新版底图在中上部保留更干净的两行 slogan 留白,并在下半部增加轻量内容卡、创作路径和 AI 辅助创作氛围,最终再叠加精确中文排版。 + 因为图片模型直接生成中文长文案存在错字风险,最终稿采用“gpt-image-2 底图 + 本地精确中文排版”的方式生成: ```text diff --git a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md index 3526ad9d..8ed5c4af 100644 --- a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md +++ b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md @@ -78,9 +78,13 @@ Query: 页面能力: -- 表选择下拉,支持 URL hash `#tables?table=xxx` 直达指定表。 +- 表选择下拉展示中文表名并保留原始表名,支持 URL hash `#tables?table=xxx` 直达指定表。 - 查询表单:表名、关键词、JSON 条件、条数。 - 查询结果表格横向滚动,移动端不撑坏布局。 +- 查询结果标题和已选表摘要展示中文表名,鼠标悬浮显示原始表名和表说明,方便运营识别真实数据域。 +- 表头支持点击排序,排序只作用于当前已拉取的行数据,不改变后端 SQL。 +- 表头展示中文字段名,鼠标悬浮显示原始字段名、字段说明和排序提示,方便运营阅读且保留排障所需的真实列名。 +- 单元格内容过长时在表格内单行省略,完整内容可通过悬浮标题或行详情弹层查看。 - 每行提供“详情”按钮,以独立弹层展示完整 JSON。 - 总览表统计行点击后跳转到 `#tables?table={tableName}`。 diff --git a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md index 003129f1..c056f2ac 100644 --- a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md +++ b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md @@ -250,3 +250,15 @@ cannon-es 3. 只有水平外接半径发生重叠的已有物体会影响本次生成高度;远处物体不能把新物体整体抬高,避免破坏原有随机洒落和分层节奏。 4. 该避让只解决“直接创建在已有模型内部”的初始穿插,后续沉降、翻滚、堆叠仍交给 cannon-es 物理模拟。 5. 本节不允许额外引入中心引力、扩大锅容量或修改模型生成规则;若后续仍需优化,只继续围绕生成高度、入场节拍和沉降窗口做局部迭代。 + +## 20. 从小到大的生成动画 + +2026-05-08 追加生成动画优化,参考原型中物体逐个出现、从小到大补入容器的观感。 + +编码口径: + +1. 该优化只作用于前端 3D 表现层的可见 mesh 缩放,不改变后端快照、碰撞体尺寸、物品数量、锅半径、点击判定、备选栏、三消和胜负规则。 +2. 物理 body 在创建时仍使用最终尺寸碰撞体,并立即加入 cannon-es 物理世界,确保生成动画过程中碰撞已经按完整体积稳定占位。 +3. 可见 mesh 初始以较小比例显示,再用缓动动画放大到完整尺寸;视觉缩放不得反向修改 body shape、质量、边界半径或生成高度避让计算。 +4. 入场动画继续服从第 18 节的创建限流和第 19 节的生成高度避让;不能为了动画效果把物体直接放进已有堆叠内部。 +5. 动画结束后 mesh 缩放必须回到 `1`,避免影响后续点击可读性和托盘对应关系。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index f09953e0..a1375337 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -430,6 +430,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - `COMMIT_HASH` 非空时,先解析到完整 commit,再用 `git merge-base --is-ancestor refs/remotes/origin/` 校验该提交属于指定分支,校验通过后 detached checkout。 - 流水线日志必须输出最终 `SOURCE_BRANCH` 与实际 `SOURCE_COMMIT`。 - 构建产物必须写入 `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 包,不允许在发布流水线中重新构建。 @@ -509,8 +510,10 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 构建: - 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。 -- 使用 `spacetime build` 构建 `spacetime_module.wasm`。 -- 归档 wasm、发布脚本和 `release-manifest.json`。 +- 构建 `spacetime_module.wasm` 前默认生成 32 字节随机 hex 迁移引导密钥,注入 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET`,并把同一份密钥写入 `build//migration-bootstrap-secret.txt`。构建日志只输出密钥来源和长度,不打印明文。 +- `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 --server http://127.0.0.1:3101 --bin-path spacetime_module.wasm --yes --no-config`。 - 发布动作默认以 `spacetimedb` 服务用户执行,避免 root 默认 CLI 身份对自托管数据库验签失败,也避免 root 写入 `/stdb/config` 造成后续服务用户启动权限错误。 - `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。 - 成功后解除维护模式。 - 失败时保留维护模式并发邮件。 @@ -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`。 - 产物归档到 Jenkins,并可额外保存到 `SERVER_BACKUP_DIRECTORY`。 - 敏感 token 与 bootstrap secret 只通过 Jenkins Secret Text 凭据 ID 注入,不作为明文 Job 参数。 +- 导出和导入流水线的 Bash 执行块启用 `set -u`;所有可选 Jenkins 参数必须先通过 `${VAR:-}` 收敛成本地默认值,再传给 Node 迁移脚本,避免空参数没有导出时触发 `unbound variable`。 - 成功后解除维护模式。 - 失败时保留维护模式并邮件通知。 diff --git a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md index cc8ab42f..6288eb0b 100644 --- a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md +++ b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md @@ -71,6 +71,7 @@ node scripts/spacetime-revoke-migration-operator.mjs \ - `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 build:production-release -- --component spacetime-module`:在生产 Stdb module 构建前默认生成或复用 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET`,注入 `spacetime_module.wasm`,并写入 `build//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 的新密钥。 diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index 062b180a..e19af792 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -116,8 +116,15 @@ pipeline { chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh - export_dir="${WORKSPACE_EXPORT_DIRECTORY}" - output_path="${export_dir}/${EFFECTIVE_EXPORT_NAME}" + database="${DATABASE:?DATABASE 不能为空}" + 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}" maintenance_entered=0 @@ -132,20 +139,20 @@ pipeline { } trap on_exit EXIT - scripts/deploy/maintenance-on.sh "database export ${DATABASE}" + scripts/deploy/maintenance-on.sh "database export ${database}" maintenance_entered=1 - args=(scripts/spacetime-export-migration-json.mjs --out "${output_path}" --database "${DATABASE}") - if [[ -n "${SPACETIME_SERVER_URL}" ]]; then - args+=(--server-url "${SPACETIME_SERVER_URL}") - elif [[ -n "${SPACETIME_SERVER}" ]]; then - args+=(--server "${SPACETIME_SERVER}") + args=(scripts/spacetime-export-migration-json.mjs --out "${output_path}" --database "${database}") + if [[ -n "${spacetime_server_url}" ]]; then + args+=(--server-url "${spacetime_server_url}") + elif [[ -n "${spacetime_server}" ]]; then + args+=(--server "${spacetime_server}") fi - if [[ -n "${EFFECTIVE_SPACETIME_ROOT_DIR}" ]]; then - args+=(--root-dir "${EFFECTIVE_SPACETIME_ROOT_DIR}") + if [[ -n "${spacetime_root_dir}" ]]; then + args+=(--root-dir "${spacetime_root_dir}") fi - if [[ -n "${INCLUDE_TABLES}" ]]; then - args+=(--include "${INCLUDE_TABLES}") + if [[ -n "${include_tables}" ]]; then + args+=(--include "${include_tables}") fi args+=(--note "jenkins database export ${BUILD_TAG}") @@ -153,10 +160,10 @@ pipeline { test -s "${output_path}" sha256sum "${output_path}" >"${output_path}.sha256" - if [[ -n "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}" ]]; then - mkdir -p "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}" - install -m 0640 "${output_path}" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}" - install -m 0640 "${output_path}.sha256" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}.sha256" + if [[ -n "${server_backup_directory}" ]]; then + mkdir -p "${server_backup_directory}" + install -m 0640 "${output_path}" "${server_backup_directory}/${export_name}" + install -m 0640 "${output_path}.sha256" "${server_backup_directory}/${export_name}.sha256" fi echo "[database-export] 完成: ${output_path}, source_commit=$(cat .jenkins-source-commit)" diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index b43f051e..8013cddb 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -181,11 +181,12 @@ pipeline { bash -lc ' set -euo pipefail manual_filename="${MANUAL_INPUT_FILE_FILENAME:-}" + confirm_input_file="${CONFIRM_INPUT_FILE:-}" if [[ -z "${manual_filename}" ]]; then echo "[database-import] 无法读取 MANUAL_INPUT_FILE_FILENAME,不能确认手动上传文件名。" >&2 exit 1 fi - if [[ "${CONFIRM_INPUT_FILE}" != "${manual_filename}" ]]; then + if [[ "${confirm_input_file}" != "${manual_filename}" ]]; then echo "[database-import] CONFIRM_INPUT_FILE 必须与手动上传文件原始文件名一致: ${manual_filename}" >&2 exit 1 fi @@ -209,7 +210,20 @@ pipeline { 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 input_path="${WORKSPACE}/${input_path}" fi @@ -218,8 +232,9 @@ pipeline { exit 1 fi - backup_dir="${PRE_IMPORT_BACKUP_DIRECTORY}" - backup_path="${backup_dir}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}" + backup_dir="${PRE_IMPORT_BACKUP_DIRECTORY:-database-pre-import-backups}" + backup_name="${EFFECTIVE_PRE_IMPORT_BACKUP_NAME:-pre-import-${BUILD_NUMBER:-manual}.json}" + backup_path="${backup_dir}/${backup_name}" mkdir -p "${backup_dir}" completed=0 @@ -232,20 +247,20 @@ pipeline { } 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}") - import_args=(scripts/spacetime-import-migration-json.mjs --in "${input_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}") for args_name in backup_args import_args; do declare -n current_args="${args_name}" # server-url 明确指向目标实例时,不再同时透传默认 alias,避免 CLI 授权与 HTTP 导入落到不同目标。 - if [[ -n "${SPACETIME_SERVER_URL}" ]]; then - current_args+=(--server-url "${SPACETIME_SERVER_URL}") - elif [[ -n "${SPACETIME_SERVER}" ]]; then - current_args+=(--server "${SPACETIME_SERVER}") + if [[ -n "${spacetime_server_url}" ]]; then + current_args+=(--server-url "${spacetime_server_url}") + elif [[ -n "${spacetime_server}" ]]; then + current_args+=(--server "${spacetime_server}") fi - if [[ -n "${SPACETIME_ROOT_DIR}" ]]; then - current_args+=(--root-dir "${SPACETIME_ROOT_DIR}") + if [[ -n "${spacetime_root_dir}" ]]; then + current_args+=(--root-dir "${spacetime_root_dir}") fi done @@ -254,25 +269,25 @@ pipeline { test -s "${backup_path}" sha256sum "${backup_path}" >"${backup_path}.sha256" - if [[ -n "${SERVER_BACKUP_DIRECTORY}" ]]; then - mkdir -p "${SERVER_BACKUP_DIRECTORY}" - install -m 0640 "${backup_path}" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}" - install -m 0640 "${backup_path}.sha256" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}.sha256" + if [[ -n "${server_backup_directory}" ]]; then + mkdir -p "${server_backup_directory}" + install -m 0640 "${backup_path}" "${server_backup_directory}/${backup_name}" + install -m 0640 "${backup_path}.sha256" "${server_backup_directory}/${backup_name}.sha256" fi - if [[ -n "${INCLUDE_TABLES}" ]]; then - import_args+=(--include "${INCLUDE_TABLES}") + if [[ -n "${include_tables}" ]]; then + import_args+=(--include "${include_tables}") fi - if [[ -n "${CHUNK_SIZE}" ]]; then - import_args+=(--chunk-size "${CHUNK_SIZE}") + if [[ -n "${chunk_size}" ]]; then + import_args+=(--chunk-size "${chunk_size}") fi - if [[ "${DRY_RUN}" == "true" ]]; then + if [[ "${dry_run}" == "true" ]]; then import_args+=(--dry-run) fi - if [[ "${INCREMENTAL}" == "true" ]]; then + if [[ "${incremental}" == "true" ]]; then import_args+=(--incremental) fi - if [[ "${REPLACE_EXISTING}" == "true" ]]; then + if [[ "${replace_existing}" == "true" ]]; then import_args+=(--replace-existing) fi import_args+=(--note "jenkins database import ${BUILD_TAG}") @@ -280,13 +295,13 @@ pipeline { node "${import_args[@]}" # 导入成功后只做本机健康检查;业务级数据核验仍以迁移脚本的表级统计为准。 - if [[ "${RUN_SMOKE_TEST}" == "true" && -n "${SMOKE_HEALTH_URL}" ]]; then - curl -fsS --max-time 10 "${SMOKE_HEALTH_URL}" >/dev/null + if [[ "${run_smoke_test}" == "true" && -n "${smoke_health_url}" ]]; then + curl -fsS --max-time 10 "${smoke_health_url}" >/dev/null fi scripts/deploy/maintenance-off.sh 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)" ' ''' } diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 2e12870a..c7a061eb 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -21,6 +21,7 @@ pipeline { string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') 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: '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: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') 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: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), 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), ] env.STDB_BUILD_NUMBER = stdbRun.number.toString() diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index da5ffbb1..a4946418 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -24,6 +24,7 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') 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 发布') 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') @@ -56,10 +57,12 @@ pipeline { git checkout --force "origin/$sourceBranch" } 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 { - 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 } } @@ -67,50 +70,64 @@ pipeline { stage('Build Stdb Module') { steps { - powershell ''' - $ErrorActionPreference = 'Stop' - $workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" } - $env:CARGO_HOME = "$workspaceTmp/cargo-home" - $env:CARGO_TARGET_DIR = "$workspaceTmp/cargo-target/prod-release" - $env:SCCACHE_DIR = "$env:USERPROFILE/.cache/sccache-stdb-module" - $env:PATH = "$env:CARGO_HOME/bin;$env:PATH" - $gitBash = @( - $env:GENARRATIVE_BASH, - 'C:/Program Files/Git/bin/bash.exe', - 'C:/Program Files/Git/usr/bin/bash.exe', - 'C:/msys64/usr/bin/bash.exe', - 'bash' - ) | 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 中。' + script { + def buildStep = { + powershell ''' + $ErrorActionPreference = 'Stop' + $workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" } + $env:CARGO_HOME = "$workspaceTmp/cargo-home" + $env:CARGO_TARGET_DIR = "$workspaceTmp/cargo-target/prod-release" + $env:SCCACHE_DIR = "$env:USERPROFILE/.cache/sccache-stdb-module" + $env:PATH = "$env:CARGO_HOME/bin;$env:PATH" + $gitBash = @( + $env:GENARRATIVE_BASH, + 'C:/Program Files/Git/bin/bash.exe', + 'C:/Program Files/Git/usr/bin/bash.exe', + 'C:/msys64/usr/bin/bash.exe', + 'bash' + ) | 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 (-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 (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) { + withCredentials([ + string(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET') + ]) { + buildStep() } + } 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') { 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}/migration-bootstrap-secret.txt", fingerprint: false } } diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index 03cd25cb..0967523e 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -18,6 +18,8 @@ usage() { --skip-web-build 跳过主站与后台构建,仅复制已有 dist 产物 --skip-api-build 跳过 api-server 构建,仅复制已有 release 二进制 --skip-spacetime-build 跳过 spacetime-module 构建,仅复制已有 wasm + --no-migration-bootstrap-secret + 构建不带迁移引导密钥的 spacetime-module wasm EOF } @@ -73,6 +75,61 @@ write_sha256_file() { ) >"${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() { RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \ RELEASE_VERSION="${BUILD_NAME}" \ @@ -83,6 +140,7 @@ write_release_manifest() { RELEASE_INCLUDE_WEB="${BUILD_WEB}" \ RELEASE_INCLUDE_API="${BUILD_API}" \ RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \ + RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET_ARTIFACT}" \ node <<'NODE' const fs = require('fs'); @@ -108,6 +166,13 @@ if (process.env.RELEASE_INCLUDE_SPACETIME === '1') { 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 = { version: process.env.RELEASE_VERSION, @@ -131,6 +196,9 @@ COMPONENT="all" SKIP_WEB_BUILD=0 SKIP_API_BUILD=0 SKIP_SPACETIME_BUILD=0 +MIGRATION_BOOTSTRAP_SECRET="" +MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=0 +MIGRATION_BOOTSTRAP_SECRET_MODE="auto" BUILD_COMPLETED=0 while [[ $# -gt 0 ]]; do @@ -159,6 +227,11 @@ while [[ $# -gt 0 ]]; do SKIP_SPACETIME_BUILD=1 shift ;; + --no-migration-bootstrap-secret) + MIGRATION_BOOTSTRAP_SECRET="" + MIGRATION_BOOTSTRAP_SECRET_MODE="disabled" + shift + ;; *) echo "[production-release] 未知参数: $1" >&2 usage >&2 @@ -262,6 +335,7 @@ mkdir -p "${TARGET_DIR}" echo "[production-release] 发布包目录: ${TARGET_DIR}" echo "[production-release] 构建组件: ${COMPONENT}" +prepare_migration_bootstrap_secret if [[ "${BUILD_WEB}" -eq 1 ]]; then mkdir -p "${WEB_DIR}" @@ -364,6 +438,7 @@ fi if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm" write_sha256_file "${TARGET_DIR}/spacetime_module.wasm" + write_migration_bootstrap_secret_file fi mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy" @@ -402,6 +477,7 @@ cat >"${TARGET_DIR}/README.md" <&2 exit 1 diff --git a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx index 0f3c1e9d..254fb79e 100644 --- a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx +++ b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx @@ -140,6 +140,8 @@ const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4; const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14; const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08; 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_HORIZONTAL_SPEED_LIMIT = 4.4; 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) { if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) { return; @@ -1028,7 +1042,7 @@ function createPhysicsEntryFromPendingSpawn( 0.08, 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.scene.add(visual.mesh); @@ -1714,11 +1728,12 @@ export function Match3DPhysicsBoard({ applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan); constrainBodyInsidePot(entry); const spawnProgress = resolveSpawnAnimationProgress(entry, now); - const spawnScale = 0.82 + spawnProgress * 0.18; + const spawnScale = resolveMatch3DSpawnVisualScale(spawnProgress); entry.mesh.scale.setScalar(spawnScale); entry.mesh.position.set( 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.mesh.quaternion.set( diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index 4db8d3a6..62b60a4e 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -31,6 +31,7 @@ import { resolveMatch3DSpawnTimingPlan, resolveMatch3DStackTargetY, resolveMatch3DSpawnDelay, + resolveMatch3DSpawnVisualScale, resolveMatch3DSpawnY, resolveMatch3DTrayPreviewRotation, resolveMatch3DTrayPreviewReferenceDimension, @@ -591,6 +592,18 @@ test('3D 新物体生成高度会避让同位置已有堆叠', () => { 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('积木视觉键不会被统一兜底成红色苹字', () => { const run = startLocalMatch3DRun(2); run.items = run.items.slice(0, 2).map((item, index) => ({ diff --git a/vitest.config.ts b/vitest.config.ts index e334d9c9..9a44f493 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', + 'apps/admin-web/src/**/*.test.ts', + 'apps/admin-web/src/**/*.test.tsx', 'scripts/**/*.test.ts', 'packages/shared/src/**/*.test.ts', ],