From 29369e6376203d8a95556ad42ecae817911420a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 21 Jul 2026 20:50:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E7=94=BB=E5=B8=83=E9=BC=A0=E6=A0=87?= =?UTF-8?q?/=E8=A7=A6=E6=91=B8=E6=9D=BF=E5=8F=AF=E4=BB=A5=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=BB=9A=E5=8A=A8=EF=BC=8C=E6=97=A0=E6=B3=95=E5=B7=A6?= =?UTF-8?q?=E5=8F=B3=E6=BB=9A=E5=8A=A8=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/100 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../shared-memory/decision-log.md | 6 +++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 4 +- .../ImageCanvasEditorView.test.tsx | 13 ++++- .../ImageCanvasInteractionModel.test.ts | 10 ++-- .../ImageCanvasInteractionModel.ts | 14 +++-- .../image-editor/ImageCanvasShortcutModel.ts | 3 +- .../useImageCanvasStageInteractions.test.tsx | 16 ++++++ .../useImageCanvasViewportControls.test.tsx | 54 +++++++++++++++---- .../useImageCanvasViewportControls.ts | 12 ++++- 10 files changed, 109 insertions(+), 27 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9d3707cb2..01345b3cb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4373,3 +4373,9 @@ - 回退边界:明确模型不存在 / 不支持、408、非内容拒绝类 429、5xx、响应解析失败或非拒绝类缺图可以切模型;401 / 403、普通参数 / 内容安全拒绝、本地配置与参考图错误、发送 / 连接错误、request budget 耗尽和已生成图片下载失败不切模型。一次业务请求总发送上限仍为 5 次,两个模型共享同一 worker provider deadline 和 attempt 预算。 - 观测边界:审计 `image_model` 记录实际 provider attempt;首选 `gpt-image-2` 失败但兜底 `gpt-image-2-c` 恢复成功时,首选失败仍写入 `external_api_call_failure`,最终成功运行摘要记录 `recoveredFailureCount`。日志用 `fallback_from_model` / `fallback_to_model` 标识切换,不改变业务模型、扣费、素材 metadata 或终态语义。 - 脚本边界:仓库 `gpt-image-2-apimart` skill 的现役生成脚本采用同一首选 / 回退顺序;认证、请求发送不确定错误和下载失败不重新生图,避免重复上游成本。 + +## 2026-07-21 图片画布滚轮与中键平移统一为二维视口移动 + +- 背景:画布中键拖拽的平移模型已同时计算 X / Y,但普通滚轮分支只消费 `deltaY`,横向滚轮或触控板的 `deltaX` 被丢弃,且缺少中键横向拖动的状态机回归覆盖。 +- 决策:普通滚轮原样消费设备上报的 `deltaX / deltaY` 二维平移 viewport;当按住 Shift 且设备上报 `deltaX = 0` 时,输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型不感知修饰键。`Ctrl / Cmd + 滚轮` 继续只负责围绕指针缩放;中键和抓手拖拽继续同时更新 X / Y。 +- 验证:交互模型单测覆盖原始 `deltaX / deltaY` 和缩放边界;viewport hook 单测覆盖二维滚轮、Shift 横向适配与 Ctrl 缩放;stage 状态机单测覆盖中键水平、垂直同时移动。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 59b7c2141..cca6665d0 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -11,7 +11,7 @@ - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 - 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额优先展示画板按扣费、退回、到账和页面恢复链路刷新的 `profileDashboard.walletBalance`,充值中心 `mudPointBalance` 仅用于展开面板的账户明细,不覆盖已刷新的总余额。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 -- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 +- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 - 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。 - 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 @@ -149,7 +149,7 @@ - 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。 - 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 - 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。 -- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 +- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 - 工程刷新后能从后端恢复资源、图层布局和 viewport。 - “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=` 并按 query 加载该工程。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 4a633479f..68c306b07 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -68,7 +68,7 @@ ## 第六阶段模块 - `ImageCanvasInteractionModel.ts` - - 承载画布交互纯计算:适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。 + - 承载画布交互纯计算:适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时由输入适配层把 `deltaY` 映射为横向位移、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。 - 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。 - 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。 @@ -151,7 +151,7 @@ ## 第十七阶段模块 - `useImageCanvasViewportControls.ts` - - 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。 + - 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时的横向位移适配、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。 - 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。 - 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 6add43c9a..b4380fdb2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2320,15 +2320,24 @@ describe('ImageCanvasEditorView', () => { }).not.toThrow(); }); - it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => { + it('uses normal wheel for two-axis canvas pan and ctrl wheel for zoom', () => { render(); const viewport = screen.getByLabelText('画布工作区'); + const world = screen.getByTestId('image-canvas-world') as HTMLElement; expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); - fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 }); + fireEvent.wheel(viewport, { + deltaX: 80, + deltaY: 120, + clientX: 400, + clientY: 280, + }); + expect(world.style.transform).toBe( + 'translate(-80px, -120px) scale(0.5)', + ); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index f9579cf51..2428748af 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -19,8 +19,8 @@ import { moveViewportFromMinimapDrag, moveViewportFromMinimapPointer, moveViewportFromPan, + panViewportFromWheel, scaleViewportFromScreenPoint, - scrollViewportVertically, selectCanvasObjectsInsideMarquee, selectLayersInsideMarquee, zoomViewportFromWheel, @@ -133,11 +133,13 @@ describe('ImageCanvasInteractionModel', () => { ).toEqual({ x: 100, y: 60 }); }); - it('scrolls vertically and zooms around a screen point', () => { + it('pans from two-axis wheel input and zooms around a screen point', () => { const viewport = { x: 10, y: 20, scale: 1 }; - expect(scrollViewportVertically(viewport, 120)).toEqual({ - x: 10, + expect( + panViewportFromWheel({ viewport, deltaX: 40, deltaY: 120 }), + ).toEqual({ + x: -30, y: -100, scale: 1, }); diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts index 3780870d8..1140366cd 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -212,12 +212,18 @@ export function scaleViewportFromScreenPoint({ }; } -export function scrollViewportVertically( - viewport: CanvasViewport, - deltaY: number, -) { +export function panViewportFromWheel({ + viewport, + deltaX, + deltaY, +}: { + viewport: CanvasViewport; + deltaX: number; + deltaY: number; +}) { return { ...viewport, + x: viewport.x - deltaX, y: viewport.y - deltaY, }; } diff --git a/src/components/image-editor/ImageCanvasShortcutModel.ts b/src/components/image-editor/ImageCanvasShortcutModel.ts index 518793a36..7863e599a 100644 --- a/src/components/image-editor/ImageCanvasShortcutModel.ts +++ b/src/components/image-editor/ImageCanvasShortcutModel.ts @@ -29,7 +29,8 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [ { title: '视图', items: [ - { action: '纵向滚动画布', keys: ['鼠标滚轮'], status: 'ready' }, + { action: '平移画布', keys: ['鼠标滚轮 / 触控板'], status: 'ready' }, + { action: '横向平移画布', keys: ['Shift', '鼠标滚轮'], status: 'ready' }, { action: '以鼠标位置缩放', keys: ['Ctrl', '鼠标滚轮'], status: 'ready' }, { action: '显示全部元素', keys: ['Ctrl', '0'], status: 'new' }, { action: '缩放至 100%', keys: ['Ctrl', '1'], status: 'new' }, diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index 3ec5856a0..7d62ce7f7 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -1083,6 +1083,22 @@ describe('useImageCanvasStageInteractions', () => { }); expect(screen.getByTestId('panning').textContent).toBe('true'); + act(() => { + fireEvent( + viewport, + new MouseEvent('pointermove', { + bubbles: true, + clientX: 140, + clientY: 70, + button: 1, + buttons: 4, + }), + ); + }); + expect(screen.getByTestId('viewport-state').textContent).toBe( + '40.0,-30.0,1', + ); + act(() => { fireEvent( viewport, diff --git a/src/components/image-editor/useImageCanvasViewportControls.test.tsx b/src/components/image-editor/useImageCanvasViewportControls.test.tsx index c9f3baf14..0f0ab01eb 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.test.tsx +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -158,19 +158,12 @@ describe('useImageCanvasViewportControls', () => { expect(captureCanvasHistory).toHaveBeenCalledTimes(2); }); - it('handles vertical wheel scroll, ctrl wheel zoom and minimap movement', () => { - const rafCallbacks: FrameRequestCallback[] = []; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation( - (callback: FrameRequestCallback) => { - rafCallbacks.push(callback); - return rafCallbacks.length; - }, - ); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + it('keeps ordinary mouse wheel vertical and maps Shift wheel to horizontal', () => { const { result, viewportElement } = renderViewportControls(); act(() => { fireEvent.wheel(viewportElement, { + deltaX: 0, deltaY: 120, clientX: 260, clientY: 190, @@ -182,6 +175,47 @@ describe('useImageCanvasViewportControls', () => { scale: 0.5, }); + act(() => { + fireEvent.wheel(viewportElement, { + shiftKey: true, + deltaX: 0, + deltaY: 80, + clientX: 260, + clientY: 190, + }); + }); + expectViewport(result.current.viewport, { + x: -340, + y: -50, + scale: 0.5, + }); + }); + + it('handles native two-axis wheel pan, ctrl wheel zoom and minimap movement', () => { + const rafCallbacks: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }, + ); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + const { result, viewportElement } = renderViewportControls(); + + act(() => { + fireEvent.wheel(viewportElement, { + deltaX: 40, + deltaY: 120, + clientX: 260, + clientY: 190, + }); + }); + expectViewport(result.current.viewport, { + x: -300, + y: -50, + scale: 0.5, + }); + act(() => { fireEvent.wheel(viewportElement, { ctrlKey: true, @@ -191,7 +225,7 @@ describe('useImageCanvasViewportControls', () => { }); }); expectViewport(result.current.viewport, { - x: -312, + x: -356, y: -74, scale: 0.55, }); diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index 4e5d0ba1d..2f74c1b8f 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -23,8 +23,8 @@ import { getWorldPointFromClient, moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag, moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer, + panViewportFromWheel, scaleViewportFromScreenPoint, - scrollViewportVertically, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; @@ -260,8 +260,16 @@ export function useImageCanvasViewportControls({ } if (!event.ctrlKey && !event.metaKey) { + const useShiftWheelHorizontalFallback = + event.shiftKey && event.deltaX === 0; setViewport((currentViewport) => - scrollViewportVertically(currentViewport, event.deltaY), + panViewportFromWheel({ + viewport: currentViewport, + deltaX: useShiftWheelHorizontalFallback + ? event.deltaY + : event.deltaX, + deltaY: useShiftWheelHorizontalFallback ? 0 : event.deltaY, + }), ); return; } From ae9502d9eda29702ca0eb42f37921bce4b21e85e Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Jul 2026 22:23:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E7=A8=B3=E5=AE=9AGitea=20CI=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留隔离 runner 的受控代理环境 为 Cargo 与 rustup 下载增加有界重试 记录 Runner systempaths 修补和真实 job 验收口径 --- .gitea/workflows/project-ci.yml | 120 ++++++++++++++---- .../shared-memory/development-workflow.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 9 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +- 4 files changed, 114 insertions(+), 26 deletions(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index d519e74b8..dc06c6375 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -14,7 +14,10 @@ permissions: env: CI: 'true' CARGO_INCREMENTAL: '0' + CARGO_HTTP_MULTIPLEXING: 'false' + CARGO_NET_RETRY: '10' CARGO_TERM_COLOR: always + RUSTUP_MAX_RETRIES: '10' RUSTC_WRAPPER: '' CARGO_BUILD_RUSTC_WRAPPER: '' @@ -37,12 +40,16 @@ jobs: echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2 exit 1 } - sudo_command='' - if command -v sudo >/dev/null 2>&1; then - sudo_command='sudo' + sudo_command=() + if [[ "$(id -u)" -ne 0 ]]; then + command -v sudo >/dev/null 2>&1 || { + echo 'non-root runner user requires sudo for system dependencies.' >&2 + exit 1 + } + sudo_command=(sudo -E) fi - ${sudo_command} apt-get update - ${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + "${sudo_command[@]}" apt-get update + "${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates \ curl @@ -80,14 +87,33 @@ jobs: run: | set -euo pipefail if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain none + for attempt in $(seq 1 10); do + if curl --retry 3 --retry-all-errors --retry-delay 2 \ + --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'rustup bootstrap failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done fi echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" export PATH="${HOME}/.cargo/bin:${PATH}" toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" test -n "${toolchain}" - rustup toolchain install "${toolchain}" --profile minimal --component rustfmt + for attempt in $(seq 1 10); do + if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'Rust toolchain installation failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done rustc --version cargo --version rustfmt --version @@ -151,12 +177,16 @@ jobs: echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2 exit 1 } - sudo_command='' - if command -v sudo >/dev/null 2>&1; then - sudo_command='sudo' + sudo_command=() + if [[ "$(id -u)" -ne 0 ]]; then + command -v sudo >/dev/null 2>&1 || { + echo 'non-root runner user requires sudo for system dependencies.' >&2 + exit 1 + } + sudo_command=(sudo -E) fi - ${sudo_command} apt-get update - ${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + "${sudo_command[@]}" apt-get update + "${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ clang \ @@ -203,14 +233,33 @@ jobs: run: | set -euo pipefail if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain none + for attempt in $(seq 1 10); do + if curl --retry 3 --retry-all-errors --retry-delay 2 \ + --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'rustup bootstrap failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done fi echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" export PATH="${HOME}/.cargo/bin:${PATH}" toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" test -n "${toolchain}" - rustup toolchain install "${toolchain}" --profile minimal --component rustfmt + for attempt in $(seq 1 10); do + if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'Rust toolchain installation failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done rustc --version cargo --version rustfmt --version @@ -248,12 +297,16 @@ jobs: echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2 exit 1 } - sudo_command='' - if command -v sudo >/dev/null 2>&1; then - sudo_command='sudo' + sudo_command=() + if [[ "$(id -u)" -ne 0 ]]; then + command -v sudo >/dev/null 2>&1 || { + echo 'non-root runner user requires sudo for system dependencies.' >&2 + exit 1 + } + sudo_command=(sudo -E) fi - ${sudo_command} apt-get update - ${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + "${sudo_command[@]}" apt-get update + "${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ clang \ @@ -280,14 +333,33 @@ jobs: run: | set -euo pipefail if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain none + for attempt in $(seq 1 10); do + if curl --retry 3 --retry-all-errors --retry-delay 2 \ + --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'rustup bootstrap failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done fi echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" export PATH="${HOME}/.cargo/bin:${PATH}" toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" test -n "${toolchain}" - rustup toolchain install "${toolchain}" --profile minimal --component rustfmt + for attempt in $(seq 1 10); do + if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'Rust toolchain installation failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done rustc --version cargo --version rustfmt --version diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index dc8ea1061..d45d327b9 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -269,7 +269,8 @@ npm run check:server-rs-ddd - 四个 job 共同覆盖 `npm run check`,并追加 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。后端 runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支的原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check` 和 release build smoke。 - checkout 必须使用完整历史。PR 将 base SHA 写入 `SPACETIME_SCHEMA_BASE_REF`,直接推送 `master` 使用 before SHA;事件基线不可解析时直接失败。Gitea 检查的是 PR head 而非预合并 commit,workflow 必须拒绝不包含最新 base commit 的过期 PR,分支保护同时保持“PR 过期禁止合并”。 - 普通 PR job 不读取业务 secret,不运行真实 API/SpacetimeDB/OSS/支付/生成/live smoke,也不执行会修改外部状态的维护、迁移、发布或备份命令。 -- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发和 crates.io;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Gitea 1.26 的任务超时由 runner 全局配置控制;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 +- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发和 crates.io;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Runner 2.0.0 支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 +- `genarrative-station` 当前使用 Gitea `1.26.4` + 基于 Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像:只修复 `systempaths=unconfined` 的空 slice 被 `mergo` 丢失,真实 job 必须保持 `MaskedPaths=[]`、`ReadonlyPaths=[]`;外层仍非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 只监听 Unix socket,`docker_host: "-"` 阻止 socket 进入 job。job 只在 `gitea-actions` internal network,通过 `/git` reverse gateway 访问 Gitea,通过拒绝私网、保留地址和 metadata 的 80/443 proxy 访问公共依赖;直连公网和 Gitea 数据网必须失败。内层 bwrap 所需 namespace/proc 选项只能用于该 rootless DinD,不能放宽宿主 rootful runner。系统依赖步骤在 root job 中不调用 sudo,非 root 时用 `sudo -E` 保留受控 proxy;Cargo 关闭 HTTP multiplexing 并设置 10 次网络重试,rustup bootstrap/toolchain 安装也按有界次数重试。AI 原生壳 job 把 Node 发行目录与 rustup proxy 安装到 `/usr/local` 的受信任只读路径,并在测试前执行完整 bwrap canary。宿主 compose helper 必须把 `/opt/gitea-stack` 挂到同名绝对路径,避免相对 volume 错误落到 `/stack` 空目录。 ## 后端相关默认验证 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cf3d0996f..8389e7a08 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3262,3 +3262,12 @@ - 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。 - 原因:单 attempt timeout、重试退避、图片下载与 worker / lease 分别使用独立的相对计时,没有共享同一绝对 deadline;只抬高 worker timeout 或单独压低 provider timeout 都无法保证留出终态写回窗口。 - 处理:实际调用 VectorEngine 的四类图片 job 使用 `1800s` long 预算;从 job 开始的同一起点派生 provider deadline,常规提前 `60s`、短预算提前一半。每次 attempt、退避、下一次 attempt 和图片下载都必须在该 deadline 内;普通 HTTP / `inline` 不伪造 worker deadline。修复时不改动 lease fencing、迟到写回仲裁和原子退款语义。 + +## Gitea PR runner 不能把宿主 Docker socket 当普通 volume + +- 现象:`act_runner` 的 `container.docker_host` 留空时,job inspect 会出现 `/var/run/docker.sock:/var/run/docker.sock`;PR 脚本即使 `valid_volumes: []`,仍可直接调用 Docker API 读取其它容器、挂宿主目录或取得 runner 配置。另一类迁移故障是 rootless daemon 可以启动,但 bwrap 在 job 内报 `No permissions to create new namespace`、`Failed to make / slave` 或 `Mount too revealing`。 +- 原因:空 `docker_host` 会自动发现并把控制 socket 传播到 job;rootful Docker 的 seccomp、AppArmor 与 system-path masks 又不支持完整 nested bwrap。Runner 2.0.0 还有一处独立合并缺陷:`parseSystemPaths` 把 `systempaths=unconfined` 转成显式空 slice 后,`mergo.WithOverride` 不覆盖 empty value,真实 job 又恢复 Docker 默认 masks,表现为配置文件写了 unconfined、手工 `docker run` canary 也成功,但 Actions job 仍在 `--proc /proc` 返回 EPERM。直接使用 `--privileged`、外层 `CAP_SYS_ADMIN` 或 host executor 会把测试跑绿建立在破坏 PR 隔离的前提上。宿主启用 Clash fake-IP 时,简单按 DNS 的 `198.18.0.0/15` 判断公网还会误拒所有公共依赖。 +- 处理:先把 Gitea 升到至少 `1.26.4`,再用固定 digest 的 Runner 2.0.0 rootless DinD;外层保持非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 仅 Unix socket,runner 固定 `docker_host: "-"`。若版本仍有上述 empty-slice 缺陷,只做 merge 后保留 `MaskedPaths=[]` / `ReadonlyPaths=[]` 的最小补丁并固定自有镜像 digest,不对整个 HostConfig 启用 overwrite-empty。job 使用独立 internal network,Gitea 经 reverse gateway,公共 80/443 经使用公共 DoH 验证真实 IP、拒绝私网/保留地址/metadata 的 proxy;DoH 要缓存并合并同域并发,长下载 timeout 不能只有 60 秒。rootless job 内的 bwrap namespace/proc 选项不能复制到宿主 rootful runner。apt 步骤在 root 时直接执行,非 root 时用 `sudo -E`,否则 sudo `env_reset` 会让 apt 丢失 proxy。 +- 运维陷阱:从容器内运行 Compose 时,宿主 `/opt/gitea-stack` 必须挂到容器同名绝对路径;挂成 `/stack` 会让相对 bind source 被 daemon 解析为宿主 `/stack/...`,表现为 Gitea 进入空安装页、gateway 脚本“缺失”。发现后不要迁移空库,立即用同路径 mount 重建并核对原数据大小、installed 日志、仓库数和 API。切换前保留冷数据 tar、pg_dumpall 和原 compose/env/runner 配置,备份与 token 不提交 Git。 +- 验证:同时检查 Gitea 版本、runner declare、外层 `Privileged=false`/无 CapAdd/无宿主 socket、inner job `Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]`、固定 image digest、`/var/run/docker.sock` 不存在、公共 proxy 可用、直连公网/Postgres/metadata 失败,以及完整 bwrap canary。最后重跑四个 CI job;checkout 成功但 apt/rustup/npm 同时失败时,先排 proxy/env,而不是改测试。 +- 关联:`.gitea/workflows/project-ci.yml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 18c85ddcb..ebb7948a0 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -211,7 +211,13 @@ npm run check PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF`。`check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA,手工触发时回退到 `origin/master`。 -启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 必须提供 `ubuntu-latest` 标签,并将其映射到经验证且固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像;禁止使用浮动镜像 tag,禁止将该标签映射到 host 执行器,禁止向 job 暴露 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 会安装 Node 22、仓库 `rust-toolchain.toml` 固定的 Rust 1.96.0,以及 clang/lld 和 Tauri Linux 依赖;受控 runner 镜像应预装 rustup,fallback 下载只用于首次引导。runner 仍需能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm registry、Rust 分发和 crates.io。workflow 中的 `actions/checkout` / `actions/setup-node` 固定到完整 commit;内网 runner 不允许访问 GitHub 时,先把对应 commit 镜像到当前 Gitea 并把 workflow 改为绝对 action URL。首版不使用 Actions cache,避免未配置 runner cache 网络时把缓存恢复错误变成 PR 失败。Gitea 1.26 不执行 workflow 的 `timeout-minutes`,任务最长运行时间在 runner 全局配置收口,不能只在 YAML 写一个不会生效的超时值。 +启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 必须提供 `ubuntu-latest` 标签,并将其映射到经验证且固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像;禁止使用浮动镜像 tag,禁止将该标签映射到 host 执行器,禁止向 job 暴露 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 会安装 Node 22、仓库 `rust-toolchain.toml` 固定的 Rust 1.96.0,以及 clang/lld 和 Tauri Linux 依赖;受控 runner 镜像应预装 rustup,fallback 下载只用于首次引导。runner 仍需能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm registry、Rust 分发和 crates.io。workflow 中的 `actions/checkout` / `actions/setup-node` 固定到完整 commit;内网 runner 不允许访问 GitHub 时,先把对应 commit 镜像到当前 Gitea 并把 workflow 改为绝对 action URL。首版不使用 Actions cache,避免未配置 runner cache 网络时把缓存恢复错误变成 PR 失败。当前 Runner 2.0.0 已支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限;若 workflow 以后新增更短 timeout,不能删除全局兜底。 + +当前 `genarrative-station` 已于 2026-07-21 升级到 Gitea `1.26.4`,并使用基于 Gitea Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像与固定 digest 的 Ubuntu 24.04 job image。Runner 2.0.0 会先把 `systempaths=unconfined` 解析为空 `MaskedPaths` / `ReadonlyPaths`,再被 `mergo.WithOverride` 当成 empty value 丢失;站点修补只在 merge 后保留这两个显式空 slice,不改其它 runner 行为。真实 job inspect 必须看到 `MaskedPaths=[]`、`ReadonlyPaths=[]`、`SecurityOpt=[seccomp=unconfined]`、`Privileged=false`、无 CapAdd 且 `Binds=[]`。外层 runner 以 `rootless` 用户运行,`privileged=false`、不增加 `CAP_SYS_ADMIN`,只映射 `/dev/net/tun`,内部 Docker 只监听私有 Unix socket;runner 配置必须保持 `docker_host: "-"`、`valid_volumes: []` 和 `bind_workdir: false`,防止内部 Docker socket 或宿主 bind mount 进入 job。job 只连接 `gitea-actions` internal network:`genarrative-station` 由只转发 `/git` 到 Gitea 的内部 gateway 解析,公网依赖只经拒绝私网、保留地址和 metadata 的 80/443 egress proxy;绕过 proxy 的公网和 Postgres/Redis 数据网都必须不可达。完整 bwrap canary 需要 rootless DinD 外层的 rootlesskit AppArmor/userns 边界,以及内层 job 的 namespace/proc 挂载支持;相关 `seccomp/systempaths` 放宽只允许存在于这个无宿主 socket 的 rootless DinD 内层,禁止复制回控制宿主 rootful Docker 的 runner。 + +workflow 的系统依赖步骤在 UID 0 时不再调用 sudo;非 root runner 必须使用 `sudo -E` 保留受控 proxy 环境,避免 `sudo` 的 `env_reset` 让 apt 绕过 gateway。Cargo 通过 proxy 下载 sparse index/crate 时固定关闭 HTTP multiplexing 并设置 `CARGO_NET_RETRY=10`;rustup bootstrap 与 toolchain 安装也执行有界重试,降低代理链瞬时 TLS EOF 对后端测试的影响,但不能用重试掩盖持续不可达。AI 原生壳 job 不能把 `$HOME/.cargo/bin` 或 setup-node toolcache 直接加入 `command.exec` 的受信任 PATH;应把完整 Node 发行目录复制到 `/usr/local/lib`,把 root-owned rustup proxy 安装到 `/usr/local/bin`,并通过 `RUSTUP_HOME` 只读挂载工具链。测试前先运行与应用一致的完整 bwrap canary,失败时停止测试,不允许跳过 sandbox 用例。 + +站点 stack 仍由宿主 `/opt/gitea-stack` 管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到同一个绝对路径再执行;若挂成 `/stack`,相对 bind source 会被 Docker daemon 误解析为宿主 `/stack/...` 并启动空数据目录。升级或 runner 迁移前先停止 Gitea 写入并保留 `data/gitea` 冷快照、`pg_dumpall`、compose/.env 与 runner config/.runner;本次可回滚快照位于 `backups/gitea-ci-migration-20260721-211018`。备份文件、绝对宿主配置和注册 token 不得提交 Git。 workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)` 四个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 ` / ()`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `ubuntu-latest` 标签的 runner。