Compare commits
2 Commits
master
...
codex/showcase
| Author | SHA1 | Date | |
|---|---|---|---|
| 417f922303 | |||
| ecac0dc3fc |
@@ -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 或 failed;inline 模式下不应产生新的 `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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user