Compare commits

...

2 Commits

Author SHA1 Message Date
kdletters 417f922303 补齐API启动controller弱依赖
API systemd unit 增加 controller 弱依赖
生产 ops 护栏校验 API unit 依赖
同步运维文档和项目记忆说明
2026-07-05 16:37:00 +08:00
kdletters ecac0dc3fc 修复画板提示与失效项目跳转
画板参考图选择提示改为持续显示并支持手动关闭。

显式项目访问失效时同步切回项目页状态。

补充提示关闭和项目失效回退测试。
2026-07-05 10:45:55 +08:00
14 changed files with 132 additions and 29 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
[Unit]
Description=Genarrative Rust API Server
After=network-online.target spacetimedb.service
Wants=network-online.target
Wants=network-online.target genarrative-external-generation-controller.service
Requires=spacetimedb.service
[Service]
@@ -420,7 +420,7 @@
- 现象:拼图首关生成接口返回 `queued`,但生成页长时间不完成,重启 `genarrative-api.service` 也没有推进任务。
- 原因:HTTP 角色只入队,不再直接调用外部 provider;如果没有运行 `GENARRATIVE_PROCESS_ROLE=external-generation-worker``all` 的进程,`external_generation_job` 会停留在 `pending/running`,直到有 worker claim。
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service` 启动保底 worker 和 controller;首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`、等待 worker active,并重启验活 controller。扩容默认交给 controller 按队列统计启动 `@2.service` 等实例,手动扩缩容只作为兜底;worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`;本地若只想同步排查可通过 `.env.local` 或本机环境设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,但这不会创建 job,也不能验证 worker 扩缩容。
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service` 启动保底 worker 和 controller`genarrative-api.service` 对 controller 使用 systemd `Wants` 弱依赖,启动 API 时会尝试一并拉起 controller,但不会让 HTTP 进程自己执行 `systemctl`首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`、等待 worker active,并重启验活 controller。扩容默认交给 controller 按队列统计启动 `@2.service` 等实例,手动扩缩容只作为兜底;worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`;本地若只想同步排查可通过 `.env.local` 或本机环境设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,但这不会创建 job,也不能验证 worker 扩缩容。
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id``lease_expires_at` 会更新,完成后 session 进入 ready 或 failedinline 模式下不应产生新的 `external_generation_job`
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service``deploy/systemd/genarrative-external-generation-controller.service``deploy/env/external-generation-controller.env.example``server-rs/crates/spacetime-module/src/external_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
@@ -460,7 +460,7 @@
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504`genarrative-api.service` 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,`external_generation_job` 有 claimable pending,但 `genarrative-external-generation-worker@1.service` / controller 是 inactive。
- 原因:`genarrative-api.service``genarrative-external-generation-worker@*.service``genarrative-external-generation-controller.service` 都配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时这些服务会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只恢复数据库或只重启 API,外部生成队列就不会被消费。
- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service``--restart-service-after genarrative-external-generation-worker@1.service``--restart-service-after genarrative-external-generation-controller.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。
- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service``--restart-service-after genarrative-external-generation-worker@1.service``--restart-service-after genarrative-external-generation-controller.service``genarrative-api.service` 也保留对 controller 的 `Wants` 弱依赖,覆盖“只恢复 API”的现场兜底。仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。
- 验证:`systemctl cat genarrative-database-backup.service` 应包含这些参数;`systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service` 全为 `active``curl -fsS http://127.0.0.1:3101/v1/ping``/healthz``/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功;`get_external_generation_queue_stats_and_return` 不应长期出现 claimable pending。
- 关联:`deploy/systemd/genarrative-database-backup.service``scripts/database-backup-to-oss.mjs``scripts/ops/production-health-patrol.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
@@ -214,7 +214,7 @@ curl -f http://127.0.0.1:<api-port>/healthz
本地 `npm run dev``npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api``external-generation-worker``external-generation-controller`,也可以使用隔离容器 smoke。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service``genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
systemd 生产 controller 与手动兜底示例:
File diff suppressed because one or more lines are too long
@@ -18,6 +18,12 @@ const checks = [
includes: '--restart-service-after genarrative-external-generation-controller.service',
reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起外部生成 worker controller。',
},
{
file: 'deploy/systemd/genarrative-api.service',
includes: 'Wants=network-online.target genarrative-external-generation-controller.service',
reason:
'生产 API service 启动时必须弱依赖拉起外部生成 worker controller,避免只恢复 API 后队列无人消费。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file',
@@ -110,6 +110,10 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'publication-cover',
];
type ImageCanvasEditorViewProps = {
onProjectAccessLost?: () => void;
};
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
}
@@ -278,7 +282,9 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
};
}
export function ImageCanvasEditorView() {
export function ImageCanvasEditorView({
onProjectAccessLost,
}: ImageCanvasEditorViewProps = {}) {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
@@ -996,6 +1002,7 @@ export function ImageCanvasEditorView() {
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
onProjectAccessLost,
});
const applyGeneratedProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
@@ -311,6 +311,8 @@ describe('useImageCanvasGenerationSurface', () => {
expect(screen.getByRole('alert').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }));
expect(screen.queryByRole('alert')).toBeNull();
});
it('anchors the music menu to the music tool and closes after hover leaves', () => {
@@ -11,6 +11,7 @@ import {
useRef,
} from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import {
PlatformFloatingMenu,
@@ -481,7 +482,15 @@ export function useImageCanvasGenerationSurface({
className="image-canvas-editor__reference-pick-toast"
role="alert"
>
{generationWorkflow.referencePickWarning}
<span>{generationWorkflow.referencePickWarning}</span>
<button
type="button"
className="image-canvas-editor__reference-pick-toast-close"
aria-label="关闭提示"
onClick={generationWorkflow.clearReferencePickWarning}
>
<X aria-hidden="true" size={14} strokeWidth={2.4} />
</button>
</PlatformRuntimeStatusToast>
) : null}
<ImageCanvasGenerationComposerView
@@ -1291,6 +1291,30 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
});
it('keeps reference pick warnings visible until the user takes a follow-up action', () => {
vi.useFakeTimers();
try {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
act(() => {
vi.advanceTimersByTime(10_000);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
} finally {
vi.useRealTimers();
}
});
it('restores the last picked character spec from local cache after remounting the editor', () => {
const firstRender = render(<GenerationWorkflowHarness />);
@@ -189,7 +189,6 @@ const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastCharacterSpecReference';
const LAST_ICON_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastIconSpecReference';
const REFERENCE_PICK_WARNING_DISMISS_MS = 2600;
const INVALID_CHARACTER_SPEC_WARNING =
'选择的图片不是角色规范图,请选择生成规范里的角色规范。';
const INVALID_ICON_SPEC_WARNING =
@@ -697,16 +696,6 @@ export function useImageCanvasGenerationWorkflow({
generateDialog?.mode,
]);
useEffect(() => {
if (!referencePickWarning) {
return undefined;
}
const warningTimer = window.setTimeout(() => {
setReferencePickWarning(null);
}, REFERENCE_PICK_WARNING_DISMISS_MS);
return () => window.clearTimeout(warningTimer);
}, [referencePickWarning]);
const quickEditSourceLayer = quickEditPanel
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
null)
@@ -89,9 +89,11 @@ function createDeferred<T>() {
function ProjectPersistenceHarness({
canAccessProtectedData = true,
initialGenerationDialogs = [],
onProjectAccessLost,
}: {
canAccessProtectedData?: boolean;
initialGenerationDialogs?: CanvasGenerationDialogState[];
onProjectAccessLost?: () => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [generationDialogs, setGenerationDialogs] = useState<
@@ -151,6 +153,7 @@ function ProjectPersistenceHarness({
isViewportInteracting,
canAccessProtectedData,
openEditorLoginModal: openEditorLoginModalRef.current,
onProjectAccessLost,
});
return (
@@ -1355,4 +1358,29 @@ describe('useImageCanvasProjectPersistence', () => {
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
},
);
it('notifies the shell to leave the canvas when an explicit project becomes inaccessible', async () => {
const onProjectAccessLost = vi.fn();
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=missing-project',
);
loadEditorProjectMock.mockRejectedValueOnce(
new ApiClientError({
message: '项目不存在',
status: 404,
code: 'not_found',
}),
);
render(
<ProjectPersistenceHarness onProjectAccessLost={onProjectAccessLost} />,
);
await waitFor(() => {
expect(onProjectAccessLost).toHaveBeenCalledTimes(1);
});
expect(loadEditorProjectMock).toHaveBeenCalledWith('missing-project');
});
});
@@ -78,6 +78,19 @@ type ImageCanvasProjectPersistenceSetters = {
) => void;
};
type ImageCanvasProjectPersistenceOptions = {
refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
isViewportInteracting: boolean;
canAccessProtectedData: boolean;
currentUserId?: string | null;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
onProjectAccessLost?: () => void;
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
@@ -284,17 +297,8 @@ export function useImageCanvasProjectPersistence({
canAccessProtectedData,
currentUserId,
openEditorLoginModal,
}: {
refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
isViewportInteracting: boolean;
canAccessProtectedData: boolean;
currentUserId?: string | null;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
}) {
onProjectAccessLost,
}: ImageCanvasProjectPersistenceOptions) {
const projectIdRef = useRef<string | null>(null);
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
@@ -704,6 +708,10 @@ export function useImageCanvasProjectPersistence({
}
if (projectIdFromQuery && isEditorProjectAccessError(error)) {
removeEditorProjectSessionCache(projectIdFromQuery);
if (onProjectAccessLost) {
onProjectAccessLost();
return;
}
replaceAppHistoryPath('/project');
}
});
@@ -715,6 +723,7 @@ export function useImageCanvasProjectPersistence({
canAccessProtectedData,
applyProjectSnapshot,
createProjectResourceForLayer,
onProjectAccessLost,
openEditorLoginModal,
]);
@@ -119,6 +119,7 @@ import { useHostNetworkOnline } from '../../hooks/useHostNetworkOnline';
import {
buildPublicWorkStagePath,
pushAppHistoryPath,
replaceAppHistoryPath,
resolvePathForSelectionStage,
} from '../../routing/appPageRoutes';
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
@@ -3757,6 +3758,11 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('project');
}, [setSelectionStage]);
const replaceWithProjectGallery = useCallback(() => {
replaceAppHistoryPath('/project');
setSelectionStage('project');
}, [setSelectionStage]);
const openCreationCommunity = useCallback(() => {
setIsCreationCommunityOpen(true);
}, []);
@@ -15716,7 +15722,9 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载编辑器..." />}
>
<ImageCanvasEditorView />
<ImageCanvasEditorView
onProjectAccessLost={replaceWithProjectGallery}
/>
</Suspense>
</motion.div>
)}
+19
View File
@@ -8944,6 +8944,25 @@ button.image-canvas-editor__reference-chip:disabled {
pointer-events: none;
}
.image-canvas-editor__reference-pick-toast-close {
display: inline-grid;
width: 1.35rem;
height: 1.35rem;
place-items: center;
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentColor 16%, transparent);
color: inherit;
cursor: pointer;
pointer-events: auto;
}
.image-canvas-editor__reference-pick-toast-close:hover,
.image-canvas-editor__reference-pick-toast-close:focus-visible {
background: color-mix(in srgb, currentColor 26%, transparent);
}
.image-canvas-editor__generate-submit {
justify-self: end;
}