diff --git a/.gitignore b/.gitignore
index b3dd2a2dc..9ef28446b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,8 @@ temp*build*/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
/apps/mobile-shell/.expo-export-smoke/
+/apps/preview-deployer-web/dist/
+/apps/preview-deployer-web/node_modules/
/server-rs/.spacetimedb/
/server-rs/.data/
/public/generated-animations
diff --git a/apps/preview-deployer-web/index.html b/apps/preview-deployer-web/index.html
new file mode 100644
index 000000000..6efc9d5e2
--- /dev/null
+++ b/apps/preview-deployer-web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Docker 预览发布
+
+
+
+
+
+
diff --git a/apps/preview-deployer-web/package.json b/apps/preview-deployer-web/package.json
new file mode 100644
index 000000000..d535b82fd
--- /dev/null
+++ b/apps/preview-deployer-web/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@genarrative/preview-deployer-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1",
+ "typecheck": "tsc --noEmit -p tsconfig.json",
+ "test": "vitest run -c vitest.config.ts",
+ "build": "npm run typecheck && vite build",
+ "preview": "vite preview --host 127.0.0.1"
+ },
+ "dependencies": {
+ "@vitejs/plugin-react": "^5.0.4",
+ "lucide-react": "^0.546.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "vite": "^6.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "~5.8.2",
+ "vitest": "^0.34.6"
+ }
+}
diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
new file mode 100644
index 000000000..d0b68bc21
--- /dev/null
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
@@ -0,0 +1,108 @@
+/* @vitest-environment jsdom */
+
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+
+import * as api from './api';
+import { PreviewDeployerApp } from './PreviewDeployerApp';
+
+vi.mock('./api');
+
+afterEach(() => {
+ cleanup();
+});
+
+beforeEach(() => {
+ vi.mocked(api.getSession).mockResolvedValue({ authenticated: true });
+ vi.mocked(api.createSession).mockResolvedValue({ authenticated: true });
+ vi.mocked(api.deleteSession).mockResolvedValue(undefined);
+ vi.mocked(api.listDeployments).mockResolvedValue([]);
+ vi.mocked(api.createDeployment).mockResolvedValue({
+ id: 'preview-1',
+ branch: 'master',
+ status: 'queued',
+ health: 'pending',
+ createdAt: '2026-08-15T00:00:00Z',
+ updatedAt: '2026-08-15T00:00:00Z',
+ });
+ vi.mocked(api.uninstallDeployment).mockResolvedValue({
+ id: 'preview-1',
+ branch: 'master',
+ status: 'uninstalling',
+ health: 'pending',
+ createdAt: '2026-08-15T00:00:00Z',
+ updatedAt: '2026-08-15T00:02:00Z',
+ });
+});
+
+test('requires an access token before showing deployments', async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.getSession).mockResolvedValue({ authenticated: false });
+ render();
+
+ expect(await screen.findByText('访问口令')).toBeTruthy();
+ await user.type(screen.getByPlaceholderText('请输入访问口令'), 'team-token');
+ await user.click(screen.getByRole('button', { name: '进入发布面板' }));
+
+ await waitFor(() => {
+ expect(api.createSession).toHaveBeenCalledWith('team-token');
+ });
+ expect(await screen.findByText('构建并发布一个分支')).toBeTruthy();
+});
+
+test('submits a branch with an optional commit hash', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const branchInput = await screen.findByPlaceholderText('master');
+ await user.clear(branchInput);
+ await user.type(branchInput, 'feature/preview');
+ await user.type(
+ screen.getByPlaceholderText('留空则构建分支最新提交'),
+ 'aa5221abc',
+ );
+ await user.click(screen.getByRole('button', { name: '开始构建' }));
+
+ await waitFor(() => {
+ expect(api.createDeployment).toHaveBeenCalledWith({
+ branch: 'feature/preview',
+ commitHash: 'aa5221abc',
+ });
+ });
+});
+
+test('shows health and web url, then confirms uninstall', async () => {
+ vi.mocked(api.listDeployments).mockResolvedValue([
+ {
+ id: 'preview-2',
+ branch: 'feature/demo',
+ resolvedCommit: '1234567890abcdef',
+ status: 'running',
+ health: 'healthy',
+ webUrl: 'http://192.168.35.82:8400',
+ createdAt: 1_787_270_400,
+ updatedAt: 1_787_270_460,
+ },
+ ]);
+ render();
+
+ expect(await screen.findByText('健康')).toBeTruthy();
+ expect(
+ screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'),
+ ).toBe('http://192.168.35.82:8400');
+
+ fireEvent.click(screen.getByRole('button', { name: '卸载' }));
+ expect(screen.getByRole('dialog')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: '确认卸载' }));
+
+ await waitFor(() => {
+ expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2');
+ });
+});
diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
new file mode 100644
index 000000000..290e6d3cf
--- /dev/null
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
@@ -0,0 +1,665 @@
+import {
+ Activity,
+ Box,
+ CheckCircle2,
+ Clock3,
+ ExternalLink,
+ GitBranch,
+ Hash,
+ LoaderCircle,
+ RefreshCw,
+ Server,
+ Trash2,
+ TriangleAlert,
+ XCircle,
+} from 'lucide-react';
+import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
+
+import {
+ createDeployment,
+ createSession,
+ deleteSession,
+ getSession,
+ listDeployments,
+ PreviewDeployerApiError,
+ uninstallDeployment,
+} from './api';
+import type {
+ DeploymentHealth,
+ DeploymentStatus,
+ PreviewDeployment,
+} from './types';
+import { validateBranch, validateCommitHash } from './validation';
+
+const POLL_INTERVAL_MS = 5000;
+
+const STATUS_LABELS: Record = {
+ queued: '排队中',
+ building: '构建中',
+ deploying: '发布中',
+ running: '运行中',
+ uninstalling: '卸载中',
+ stopped: '已卸载',
+ failed: '失败',
+ cancelled: '已取消',
+};
+
+const HEALTH_LABELS: Record = {
+ pending: '等待检查',
+ healthy: '健康',
+ unhealthy: '不健康',
+ unknown: '未知',
+};
+
+export function PreviewDeployerApp() {
+ const [sessionStatus, setSessionStatus] = useState<
+ 'checking' | 'guest' | 'authenticated'
+ >('checking');
+ const [accessToken, setAccessToken] = useState('');
+ const [authenticating, setAuthenticating] = useState(false);
+ const [authError, setAuthError] = useState('');
+ const [branch, setBranch] = useState('master');
+ const [commitHash, setCommitHash] = useState('');
+ const [deployments, setDeployments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [notice, setNotice] = useState('');
+ const [error, setError] = useState('');
+ const [pendingUninstall, setPendingUninstall] =
+ useState(null);
+ const [uninstallingId, setUninstallingId] = useState('');
+
+ const refresh = useCallback(async (background = false) => {
+ if (!background) {
+ setRefreshing(true);
+ }
+ try {
+ const nextDeployments = await listDeployments();
+ setDeployments(sortDeployments(nextDeployments));
+ setError('');
+ } catch (refreshError) {
+ if (isUnauthorized(refreshError)) {
+ setSessionStatus('guest');
+ setDeployments([]);
+ setError('');
+ return;
+ }
+ setError(formatError(refreshError));
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ void getSession(controller.signal)
+ .then((session) => {
+ if (session.authenticated) {
+ setSessionStatus('authenticated');
+ void refresh(true);
+ } else {
+ setSessionStatus('guest');
+ setLoading(false);
+ }
+ })
+ .catch((sessionError) => {
+ if (isUnauthorized(sessionError)) {
+ setSessionStatus('guest');
+ setLoading(false);
+ return;
+ }
+ setAuthError(formatError(sessionError));
+ setSessionStatus('guest');
+ setLoading(false);
+ });
+ return () => controller.abort();
+ }, [refresh]);
+
+ useEffect(() => {
+ if (sessionStatus !== 'authenticated') {
+ return;
+ }
+ const interval = window.setInterval(() => {
+ if (document.visibilityState === 'visible') {
+ void refresh(true);
+ }
+ }, POLL_INTERVAL_MS);
+ return () => window.clearInterval(interval);
+ }, [refresh, sessionStatus]);
+
+ const runningCount = useMemo(
+ () =>
+ deployments.filter((deployment) => deployment.status === 'running')
+ .length,
+ [deployments],
+ );
+ const activeCount = useMemo(
+ () =>
+ deployments.filter((deployment) => isActive(deployment.status)).length,
+ [deployments],
+ );
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ const branchError = validateBranch(branch);
+ const commitError = validateCommitHash(commitHash);
+ if (branchError || commitError) {
+ setError(branchError || commitError);
+ return;
+ }
+
+ setSubmitting(true);
+ setError('');
+ setNotice('');
+ try {
+ const trimmedCommit = commitHash.trim();
+ await createDeployment({
+ branch: branch.trim(),
+ ...(trimmedCommit ? { commitHash: trimmedCommit } : {}),
+ });
+ setCommitHash('');
+ setNotice('构建请求已提交');
+ await refresh(true);
+ } catch (submitError) {
+ if (isUnauthorized(submitError)) {
+ handleUnauthorized();
+ return;
+ }
+ setError(formatError(submitError));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ async function handleUninstall() {
+ if (!pendingUninstall) {
+ return;
+ }
+ const deployment = pendingUninstall;
+ setPendingUninstall(null);
+ setUninstallingId(deployment.id);
+ setError('');
+ setNotice('');
+ try {
+ await uninstallDeployment(deployment.id);
+ setNotice(`已提交 ${deployment.branch} 的卸载请求`);
+ await refresh(true);
+ } catch (uninstallError) {
+ if (isUnauthorized(uninstallError)) {
+ handleUnauthorized();
+ return;
+ }
+ setError(formatError(uninstallError));
+ } finally {
+ setUninstallingId('');
+ }
+ }
+
+ async function handleLogin(event: FormEvent) {
+ event.preventDefault();
+ const token = accessToken.trim();
+ if (!token) {
+ setAuthError('请输入访问口令');
+ return;
+ }
+ setAuthenticating(true);
+ setAuthError('');
+ try {
+ const session = await createSession(token);
+ if (!session.authenticated) {
+ throw new Error('访问口令无效');
+ }
+ setAccessToken('');
+ setSessionStatus('authenticated');
+ setLoading(true);
+ await refresh(true);
+ } catch (loginError) {
+ setAuthError(
+ isUnauthorized(loginError) ? '访问口令无效' : formatError(loginError),
+ );
+ } finally {
+ setAuthenticating(false);
+ }
+ }
+
+ async function handleLogout() {
+ try {
+ await deleteSession();
+ } finally {
+ handleUnauthorized();
+ }
+ }
+
+ function handleUnauthorized() {
+ setSessionStatus('guest');
+ setDeployments([]);
+ setAccessToken('');
+ setError('');
+ setNotice('');
+ setPendingUninstall(null);
+ }
+
+ if (sessionStatus === 'checking') {
+ return (
+
+
+ 正在校验访问会话
+
+ );
+ }
+
+ if (sessionStatus === 'guest') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
PREVIEW DEPLOYER
+
构建并发布一个分支
+
+
+ }
+ label="进行中"
+ value={activeCount}
+ />
+ }
+ label="运行中"
+ value={runningCount}
+ />
+
+
+
+
+
+ {error ? (
+
+
+ {error}
+
+ ) : null}
+ {notice ? (
+
+
+ {notice}
+
+ ) : null}
+
+
+
+
+ {loading ? (
+
+
+ 正在读取发布状态
+
+ ) : deployments.length === 0 ? (
+
+
+ 还没有发布记录
+
+ ) : (
+
+ {deployments.map((deployment) => (
+ setPendingUninstall(deployment)}
+ />
+ ))}
+
+ )}
+
+
+
+ {pendingUninstall ? (
+ setPendingUninstall(null)}
+ >
+
event.stopPropagation()}
+ >
+
+
+
+ 卸载这个预览?
+
+ 将停止并移除 {pendingUninstall.branch}{' '}
+ 对应的容器环境。
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+function DeploymentCard({
+ deployment,
+ uninstalling,
+ onUninstall,
+}: {
+ deployment: PreviewDeployment;
+ uninstalling: boolean;
+ onUninstall: () => void;
+}) {
+ const displayedCommit = deployment.resolvedCommit || deployment.commitHash;
+ const canUninstall =
+ deployment.canUninstall ??
+ !['stopped', 'uninstalling', 'cancelled'].includes(deployment.status);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {deployment.branch}
+
+
+ {displayedCommit ? shortCommit(displayedCommit) : '分支最新提交'}
+
+
+
+
+
+ {STATUS_LABELS[deployment.status]}
+
+
+
+ {HEALTH_LABELS[deployment.health]}
+
+
+
+
+
+
+
+ {formatTime(deployment.updatedAt || deployment.createdAt)}
+
+ {deployment.message ? (
+ {deployment.message}
+ ) : null}
+
+
+
+ {deployment.webUrl ? (
+
+ 打开 Web
+
+ ) : (
+
Web 地址等待发布完成
+ )}
+
+ {deployment.jenkinsBuildUrl ? (
+
+ 构建详情
+
+ ) : null}
+ {canUninstall ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function StatusIcon({ status }: { status: DeploymentStatus }) {
+ if (['queued', 'building', 'deploying', 'uninstalling'].includes(status)) {
+ return ;
+ }
+ if (status === 'running') {
+ return ;
+ }
+ if (status === 'failed' || status === 'cancelled') {
+ return ;
+ }
+ return ;
+}
+
+function Summary({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: number;
+}) {
+ return (
+
+ {icon}
+ {value}
+ {label}
+
+ );
+}
+
+function isActive(status: DeploymentStatus) {
+ return ['queued', 'building', 'deploying', 'uninstalling'].includes(status);
+}
+
+function sortDeployments(deployments: PreviewDeployment[]) {
+ return [...deployments].sort(
+ (left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt),
+ );
+}
+
+function shortCommit(value: string) {
+ return value.slice(0, 10);
+}
+
+function formatTime(value: string | number) {
+ const timestamp = toTimestamp(value);
+ if (Number.isNaN(timestamp)) {
+ return String(value || '时间未知');
+ }
+ return new Intl.DateTimeFormat('zh-CN', {
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(timestamp);
+}
+
+function toTimestamp(value: string | number) {
+ if (typeof value === 'number') {
+ return value < 10_000_000_000 ? value * 1000 : value;
+ }
+ return Date.parse(value);
+}
+
+function formatError(error: unknown) {
+ return error instanceof Error && error.message.trim()
+ ? error.message
+ : '请求失败';
+}
+
+function isUnauthorized(error: unknown) {
+ return error instanceof PreviewDeployerApiError && error.status === 401;
+}
diff --git a/apps/preview-deployer-web/src/api.test.ts b/apps/preview-deployer-web/src/api.test.ts
new file mode 100644
index 000000000..6e0595ed4
--- /dev/null
+++ b/apps/preview-deployer-web/src/api.test.ts
@@ -0,0 +1,114 @@
+import { afterEach, expect, test, vi } from 'vitest';
+
+import {
+ createDeployment,
+ createSession,
+ deleteSession,
+ getSession,
+ listDeployments,
+ uninstallDeployment,
+} from './api';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+test('lists deployments from the same-origin control plane', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ deployments: [
+ {
+ id: 'preview-1',
+ branch: 'feature/demo',
+ status: 'running',
+ health: 'healthy',
+ createdAt: '2026-08-15T00:00:00Z',
+ updatedAt: '2026-08-15T00:01:00Z',
+ },
+ ],
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const deployments = await listDeployments();
+
+ expect(deployments).toHaveLength(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/preview-deployer/deployments',
+ expect.objectContaining({ credentials: 'same-origin' }),
+ );
+});
+
+test('uses an http-only session without browser token persistence', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockImplementation((_url: string, init: RequestInit) =>
+ Promise.resolve(
+ new Response(
+ init.method === 'DELETE'
+ ? null
+ : JSON.stringify({ authenticated: true }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await getSession()).toEqual({ authenticated: true });
+ await createSession('team-access-token');
+ await deleteSession();
+
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ '/api/preview-deployer/session',
+ expect.objectContaining({
+ method: 'POST',
+ credentials: 'same-origin',
+ body: JSON.stringify({ accessToken: 'team-access-token' }),
+ }),
+ );
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 3,
+ '/api/preview-deployer/session',
+ expect.objectContaining({ method: 'DELETE' }),
+ );
+});
+
+test('submits only branch and optional commit to fixed endpoints', async () => {
+ const buildResponse = () =>
+ new Response(
+ JSON.stringify({
+ id: 'preview-2',
+ branch: 'master',
+ status: 'queued',
+ health: 'pending',
+ createdAt: '2026-08-15T00:00:00Z',
+ updatedAt: '2026-08-15T00:00:00Z',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ const fetchMock = vi
+ .fn()
+ .mockImplementation(() => Promise.resolve(buildResponse()));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await createDeployment({ branch: 'master', commitHash: 'aa5221abc' });
+ await uninstallDeployment('preview/2');
+
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 1,
+ '/api/preview-deployer/deployments',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ branch: 'master', commitHash: 'aa5221abc' }),
+ }),
+ );
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ '/api/preview-deployer/deployments/preview%2F2/uninstall',
+ expect.objectContaining({ method: 'POST' }),
+ );
+});
diff --git a/apps/preview-deployer-web/src/api.ts b/apps/preview-deployer-web/src/api.ts
new file mode 100644
index 000000000..8c980743b
--- /dev/null
+++ b/apps/preview-deployer-web/src/api.ts
@@ -0,0 +1,128 @@
+import type { CreateDeploymentInput, PreviewDeployment } from './types';
+
+const API_BASE = '/api/preview-deployer';
+
+export class PreviewDeployerApiError extends Error {
+ status: number;
+
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = 'PreviewDeployerApiError';
+ this.status = status;
+ }
+}
+
+export interface PreviewDeployerSession {
+ authenticated: boolean;
+}
+
+export async function getSession(signal?: AbortSignal) {
+ const session = await request('/session', {
+ signal,
+ });
+ return session ?? { authenticated: true };
+}
+
+export function createSession(accessToken: string) {
+ return request('/session', {
+ method: 'POST',
+ body: { accessToken },
+ });
+}
+
+export function deleteSession() {
+ return request('/session', { method: 'DELETE' });
+}
+
+export async function listDeployments(signal?: AbortSignal) {
+ const payload = await request<
+ PreviewDeployment[] | { deployments: PreviewDeployment[] }
+ >('/deployments', { signal });
+ return Array.isArray(payload) ? payload : payload.deployments;
+}
+
+export function createDeployment(input: CreateDeploymentInput) {
+ return request('/deployments', {
+ method: 'POST',
+ body: input,
+ });
+}
+
+export function uninstallDeployment(deploymentId: string) {
+ return request(
+ `/deployments/${encodeURIComponent(deploymentId)}/uninstall`,
+ { method: 'POST', body: {} },
+ );
+}
+
+interface RequestOptions {
+ method?: string;
+ body?: unknown;
+ signal?: AbortSignal;
+}
+
+async function request(
+ path: string,
+ options: RequestOptions = {},
+): Promise {
+ const headers: Record = { Accept: 'application/json' };
+ const init: RequestInit = {
+ method: options.method ?? 'GET',
+ headers,
+ signal: options.signal,
+ credentials: 'same-origin',
+ };
+ if (options.body !== undefined) {
+ headers['Content-Type'] = 'application/json';
+ init.body = JSON.stringify(options.body);
+ }
+
+ const response = await fetch(`${API_BASE}${path}`, init);
+ const responseText = await response.text();
+ const payload = parseJson(responseText);
+ if (!response.ok) {
+ throw new PreviewDeployerApiError(
+ readErrorMessage(payload) || `请求失败(HTTP ${response.status})`,
+ response.status,
+ );
+ }
+ return unwrapPayload(payload);
+}
+
+function parseJson(value: string): unknown {
+ if (!value.trim()) {
+ return null;
+ }
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ return value;
+ }
+}
+
+function unwrapPayload(payload: unknown): T {
+ if (isRecord(payload) && 'data' in payload) {
+ return payload.data as T;
+ }
+ return payload as T;
+}
+
+function readErrorMessage(payload: unknown) {
+ if (typeof payload === 'string') {
+ return payload.trim();
+ }
+ if (!isRecord(payload)) {
+ return '';
+ }
+ if (typeof payload.message === 'string') {
+ return payload.message;
+ }
+ if (isRecord(payload.error) && typeof payload.error.message === 'string') {
+ return payload.error.message;
+ }
+ return '';
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
diff --git a/apps/preview-deployer-web/src/main.tsx b/apps/preview-deployer-web/src/main.tsx
new file mode 100644
index 000000000..8217f6507
--- /dev/null
+++ b/apps/preview-deployer-web/src/main.tsx
@@ -0,0 +1,17 @@
+import './styles.css';
+
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { PreviewDeployerApp } from './PreviewDeployerApp';
+
+const rootElement = document.getElementById('root');
+if (!rootElement) {
+ throw new Error('Missing #root container');
+}
+
+createRoot(rootElement).render(
+
+
+ ,
+);
diff --git a/apps/preview-deployer-web/src/styles.css b/apps/preview-deployer-web/src/styles.css
new file mode 100644
index 000000000..3316766b6
--- /dev/null
+++ b/apps/preview-deployer-web/src/styles.css
@@ -0,0 +1,613 @@
+:root {
+ color: #1c2533;
+ background: #f3f6fa;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+}
+
+* {
+ box-sizing: border-box;
+}
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ background: #f3f6fa;
+}
+button,
+input {
+ font: inherit;
+}
+button {
+ cursor: pointer;
+}
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+a {
+ color: inherit;
+}
+
+.session-screen {
+ display: grid;
+ min-height: 100dvh;
+ place-content: center;
+ justify-items: center;
+ gap: 11px;
+ color: #778397;
+ padding: 20px;
+}
+.session-login-screen {
+ background: radial-gradient(
+ circle at 50% 20%,
+ rgba(37, 99, 235, 0.12),
+ transparent 34%
+ ),
+ #f3f6fa;
+}
+.login-card {
+ display: grid;
+ width: min(100vw - 32px, 400px);
+ gap: 19px;
+ border: 1px solid #dbe2eb;
+ border-radius: 16px;
+ background: #fff;
+ padding: 28px;
+ box-shadow: 0 24px 70px rgba(28, 37, 51, 0.13);
+}
+.login-card h1 {
+ margin: 5px 0 0;
+ color: #1c2533;
+ font-size: 25px;
+ letter-spacing: -0.025em;
+}
+.login-card label {
+ display: grid;
+ gap: 8px;
+ color: #39465a;
+ font-size: 13px;
+ font-weight: 700;
+}
+.login-brand-mark {
+ width: 44px;
+ height: 44px;
+}
+.login-error {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: #b42318;
+ font-size: 12px;
+}
+
+.app-shell {
+ min-height: 100vh;
+}
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ min-height: 64px;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid #dde3eb;
+ background: rgba(255, 255, 255, 0.92);
+ padding: 0 max(20px, calc((100vw - 1120px) / 2));
+ backdrop-filter: blur(12px);
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+}
+.brand-mark {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border-radius: 10px;
+ color: #fff;
+ background: #2563eb;
+ box-shadow: 0 6px 16px rgba(37, 99, 235, 0.24);
+}
+.brand div {
+ display: grid;
+ gap: 1px;
+}
+.brand strong {
+ font-size: 15px;
+}
+.brand div span {
+ color: #778397;
+ font-size: 11px;
+}
+.topbar-actions {
+ display: flex;
+ gap: 8px;
+}
+.content {
+ width: min(1120px, calc(100% - 40px));
+ margin: 0 auto;
+ padding: 42px 0 64px;
+}
+.hero {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 24px;
+}
+.eyebrow {
+ margin: 0 0 7px;
+ color: #2563eb;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+}
+.hero h1 {
+ margin: 0;
+ font-size: clamp(27px, 4vw, 36px);
+ letter-spacing: -0.035em;
+}
+.summary-row {
+ display: flex;
+ gap: 9px;
+}
+.summary-chip {
+ display: flex;
+ min-height: 38px;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid #dde3eb;
+ border-radius: 10px;
+ color: #657188;
+ background: #fff;
+ padding: 0 12px;
+ font-size: 12px;
+}
+.summary-chip strong {
+ color: #1c2533;
+ font-size: 15px;
+}
+.deploy-form {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.2fr) auto;
+ align-items: end;
+ gap: 16px;
+ border: 1px solid #dbe2eb;
+ border-radius: 14px;
+ background: #fff;
+ padding: 22px;
+ box-shadow: 0 12px 32px rgba(28, 37, 51, 0.055);
+}
+.deploy-form label {
+ display: grid;
+ gap: 8px;
+ color: #39465a;
+ font-size: 13px;
+ font-weight: 700;
+}
+.deploy-form label small {
+ color: #8994a5;
+ font-weight: 500;
+}
+.input-shell {
+ display: flex;
+ height: 44px;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid #cfd7e3;
+ border-radius: 9px;
+ color: #8994a5;
+ padding: 0 12px;
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s;
+}
+.input-shell:focus-within {
+ border-color: #4b7bec;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11);
+}
+.input-shell input {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ color: #202b3b;
+ background: transparent;
+}
+.input-shell input::placeholder {
+ color: #a4adba;
+}
+.primary-button,
+.secondary-button,
+.danger-button,
+.icon-danger-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border-radius: 9px;
+ font-weight: 700;
+}
+.primary-button {
+ height: 44px;
+ border: 1px solid #2563eb;
+ color: #fff;
+ background: #2563eb;
+ padding: 0 18px;
+ box-shadow: 0 6px 15px rgba(37, 99, 235, 0.2);
+}
+.primary-button:hover {
+ background: #1d55ca;
+}
+.secondary-button {
+ min-height: 38px;
+ border: 1px solid #d5dce6;
+ color: #455266;
+ background: #fff;
+ padding: 0 13px;
+}
+.secondary-button:hover {
+ background: #f7f9fc;
+}
+.flash {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin-top: 14px;
+ border-radius: 9px;
+ padding: 11px 13px;
+ font-size: 13px;
+}
+.flash-error {
+ border: 1px solid #fecaca;
+ color: #b42318;
+ background: #fff3f2;
+}
+.flash-success {
+ border: 1px solid #bbf7d0;
+ color: #167044;
+ background: #f0fdf4;
+}
+.deployments-section {
+ margin-top: 38px;
+}
+.section-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ margin-bottom: 14px;
+}
+.section-heading h2 {
+ margin: 0 0 4px;
+ font-size: 19px;
+}
+.section-heading p {
+ margin: 0;
+ color: #8490a2;
+ font-size: 12px;
+}
+.deployment-list {
+ display: grid;
+ gap: 11px;
+}
+.deployment-card {
+ border: 1px solid #dce3ec;
+ border-radius: 12px;
+ background: #fff;
+ padding: 17px 18px 14px;
+ box-shadow: 0 4px 14px rgba(28, 37, 51, 0.035);
+}
+.card-main {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+.deployment-title-row {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 11px;
+}
+.status-icon {
+ display: grid;
+ width: 36px;
+ height: 36px;
+ flex: 0 0 auto;
+ place-items: center;
+ border-radius: 9px;
+ color: #64748b;
+ background: #f0f3f7;
+}
+.status-running {
+ color: #168052;
+ background: #eafaf1;
+}
+.status-failed,
+.status-cancelled {
+ color: #c43b32;
+ background: #fff0ef;
+}
+.status-building,
+.status-deploying,
+.status-queued,
+.status-uninstalling {
+ color: #2563eb;
+ background: #edf4ff;
+}
+.deployment-title {
+ display: grid;
+ min-width: 0;
+ gap: 4px;
+}
+.deployment-title strong {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+ font-size: 15px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.deployment-title span {
+ color: #7c8798;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+}
+.badges {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 7px;
+}
+.badge {
+ display: inline-flex;
+ min-height: 25px;
+ align-items: center;
+ gap: 6px;
+ border-radius: 999px;
+ padding: 0 9px;
+ font-size: 11px;
+ font-weight: 700;
+}
+.badge-running {
+ color: #14734a;
+ background: #e9f9f0;
+}
+.badge-failed,
+.badge-cancelled {
+ color: #b83129;
+ background: #fff0ef;
+}
+.badge-building,
+.badge-deploying,
+.badge-queued,
+.badge-uninstalling {
+ color: #275db9;
+ background: #edf4ff;
+}
+.badge-stopped {
+ color: #667085;
+ background: #f0f2f5;
+}
+.health-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+.health-healthy {
+ color: #14734a;
+ background: #e9f9f0;
+}
+.health-unhealthy {
+ color: #b83129;
+ background: #fff0ef;
+}
+.health-pending,
+.health-unknown {
+ color: #7b6650;
+ background: #f8f3eb;
+}
+.deployment-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 13px 0 0 47px;
+ color: #8792a2;
+ font-size: 11px;
+}
+.deployment-meta > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+.deployment-message {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.card-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ margin-top: 14px;
+ border-top: 1px solid #edf0f4;
+ padding-top: 12px;
+}
+.primary-link,
+.text-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 700;
+ text-decoration: none;
+}
+.primary-link:hover,
+.text-link:hover {
+ text-decoration: underline;
+}
+.url-placeholder {
+ color: #98a2b2;
+ font-size: 12px;
+}
+.right-actions {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+.icon-danger-button {
+ min-height: 32px;
+ border: 1px solid transparent;
+ color: #b93a31;
+ background: transparent;
+ padding: 0 8px;
+ font-size: 12px;
+}
+.icon-danger-button:hover {
+ border-color: #ffd4d1;
+ background: #fff4f3;
+}
+.empty-state {
+ display: flex;
+ min-height: 170px;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ border: 1px dashed #cfd7e2;
+ border-radius: 12px;
+ color: #8490a2;
+ background: rgba(255, 255, 255, 0.55);
+ font-size: 13px;
+}
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ place-items: center;
+ background: rgba(21, 29, 42, 0.42);
+ padding: 20px;
+ backdrop-filter: blur(3px);
+}
+.confirm-dialog {
+ width: min(100%, 410px);
+ border: 1px solid #e2e7ee;
+ border-radius: 14px;
+ background: #fff;
+ padding: 24px;
+ box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
+}
+.danger-icon {
+ display: grid;
+ width: 42px;
+ height: 42px;
+ place-items: center;
+ border-radius: 11px;
+ color: #bd382f;
+ background: #fff0ef;
+}
+.confirm-dialog h2 {
+ margin: 17px 0 8px;
+ font-size: 20px;
+}
+.confirm-dialog p {
+ margin: 0;
+ color: #667085;
+ font-size: 14px;
+ line-height: 1.65;
+}
+.dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 9px;
+ margin-top: 24px;
+}
+.danger-button {
+ min-height: 38px;
+ border: 1px solid #c33b32;
+ color: #fff;
+ background: #c33b32;
+ padding: 0 13px;
+}
+.spin {
+ animation: spin 0.8s linear infinite;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (max-width: 760px) {
+ .topbar {
+ padding: 0 16px;
+ }
+ .content {
+ width: min(100% - 28px, 1120px);
+ padding-top: 28px;
+ }
+ .hero {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .deploy-form {
+ grid-template-columns: 1fr;
+ padding: 17px;
+ }
+ .card-main {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 12px;
+ }
+ .badges {
+ margin-left: 47px;
+ }
+ .card-actions {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .right-actions {
+ width: 100%;
+ justify-content: space-between;
+ }
+}
+
+@media (max-width: 430px) {
+ .brand div span,
+ .secondary-button {
+ font-size: 0;
+ }
+ .secondary-button {
+ width: 38px;
+ padding: 0;
+ }
+ .summary-row {
+ width: 100%;
+ }
+ .summary-chip {
+ flex: 1;
+ }
+ .badges {
+ margin-left: 0;
+ }
+ .deployment-meta {
+ align-items: flex-start;
+ flex-direction: column;
+ margin-left: 0;
+ }
+}
diff --git a/apps/preview-deployer-web/src/types.ts b/apps/preview-deployer-web/src/types.ts
new file mode 100644
index 000000000..d538aee26
--- /dev/null
+++ b/apps/preview-deployer-web/src/types.ts
@@ -0,0 +1,31 @@
+export type DeploymentStatus =
+ | 'queued'
+ | 'building'
+ | 'deploying'
+ | 'running'
+ | 'uninstalling'
+ | 'stopped'
+ | 'failed'
+ | 'cancelled';
+
+export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
+
+export interface PreviewDeployment {
+ id: string;
+ branch: string;
+ commitHash?: string | null;
+ resolvedCommit?: string | null;
+ status: DeploymentStatus;
+ health: DeploymentHealth;
+ webUrl?: string | null;
+ jenkinsBuildUrl?: string | null;
+ createdAt: string | number;
+ updatedAt: string | number;
+ message?: string | null;
+ canUninstall?: boolean;
+}
+
+export interface CreateDeploymentInput {
+ branch: string;
+ commitHash?: string;
+}
diff --git a/apps/preview-deployer-web/src/validation.test.ts b/apps/preview-deployer-web/src/validation.test.ts
new file mode 100644
index 000000000..dc3c85690
--- /dev/null
+++ b/apps/preview-deployer-web/src/validation.test.ts
@@ -0,0 +1,15 @@
+import { expect, test } from 'vitest';
+
+import { validateBranch, validateCommitHash } from './validation';
+
+test('accepts normal branch names and rejects unsafe values', () => {
+ expect(validateBranch('feature/preview-panel')).toBe('');
+ expect(validateBranch('../master')).toBe('分支名格式不正确');
+ expect(validateBranch('feature;rm')).toBe('分支名格式不正确');
+});
+
+test('commit hash is optional but must be a git hash when present', () => {
+ expect(validateCommitHash('')).toBe('');
+ expect(validateCommitHash('a0242e35')).toBe('');
+ expect(validateCommitHash('not-a-hash')).not.toBe('');
+});
diff --git a/apps/preview-deployer-web/src/validation.ts b/apps/preview-deployer-web/src/validation.ts
new file mode 100644
index 000000000..ad363332a
--- /dev/null
+++ b/apps/preview-deployer-web/src/validation.ts
@@ -0,0 +1,27 @@
+const BRANCH_PATTERN = /^[0-9A-Za-z._/-]+$/u;
+const COMMIT_PATTERN = /^[0-9a-fA-F]{7,40}$/u;
+
+export function validateBranch(value: string) {
+ const branch = value.trim();
+ if (!branch) {
+ return '请输入分支名';
+ }
+ if (
+ branch.length > 200 ||
+ !BRANCH_PATTERN.test(branch) ||
+ branch.startsWith('/') ||
+ branch.endsWith('/') ||
+ branch.includes('..')
+ ) {
+ return '分支名格式不正确';
+ }
+ return '';
+}
+
+export function validateCommitHash(value: string) {
+ const commitHash = value.trim();
+ if (commitHash && !COMMIT_PATTERN.test(commitHash)) {
+ return 'Commit Hash 需为 7 到 40 位十六进制字符';
+ }
+ return '';
+}
diff --git a/apps/preview-deployer-web/src/vite-env.d.ts b/apps/preview-deployer-web/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/apps/preview-deployer-web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/preview-deployer-web/tsconfig.json b/apps/preview-deployer-web/tsconfig.json
new file mode 100644
index 000000000..8fb985872
--- /dev/null
+++ b/apps/preview-deployer-web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": false,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "allowJs": false,
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "jsx": "react-jsx",
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/apps/preview-deployer-web/vite.config.ts b/apps/preview-deployer-web/vite.config.ts
new file mode 100644
index 000000000..99f2728cd
--- /dev/null
+++ b/apps/preview-deployer-web/vite.config.ts
@@ -0,0 +1,36 @@
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import react from '@vitejs/plugin-react';
+import { defineConfig, loadEnv } from 'vite';
+
+const appRoot = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(appRoot, '../..');
+
+export default defineConfig(({ mode }) => {
+ const env = {
+ ...loadEnv(mode, repoRoot, ''),
+ ...loadEnv(mode, appRoot, ''),
+ };
+ const apiTarget = env.PREVIEW_DEPLOYER_API_TARGET ?? 'http://127.0.0.1:8410';
+
+ return {
+ root: appRoot,
+ envDir: repoRoot,
+ base: env.PREVIEW_DEPLOYER_WEB_BASE ?? '/build/',
+ plugins: [react()],
+ server: {
+ proxy: {
+ '/api/preview-deployer': {
+ target: apiTarget,
+ changeOrigin: true,
+ secure: false,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+ };
+});
diff --git a/apps/preview-deployer-web/vitest.config.ts b/apps/preview-deployer-web/vitest.config.ts
new file mode 100644
index 000000000..c4588ab8c
--- /dev/null
+++ b/apps/preview-deployer-web/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ },
+});
diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml
index 1268bc485..93475d8d9 100644
--- a/deploy/container/docker-compose.loadtest.yml
+++ b/deploy/container/docker-compose.loadtest.yml
@@ -118,7 +118,7 @@ services:
soft: 4096
hard: 4096
healthcheck:
- test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 12
diff --git a/deploy/env/preview-deployer.env.example b/deploy/env/preview-deployer.env.example
new file mode 100644
index 000000000..629505d7e
--- /dev/null
+++ b/deploy/env/preview-deployer.env.example
@@ -0,0 +1,13 @@
+# 仅部署在本机内网 HTTP 入口 http://192.168.35.82/build/。
+GENARRATIVE_PREVIEW_DEPLOYER_BIND=127.0.0.1:8410
+GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL=http://127.0.0.1:8080/jenkins/
+GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
+GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
+GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令>
+GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
+GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
+GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST=192.168.35.82
+GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json
+GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web
+GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false
+RUST_LOG=info,tower_http=info
diff --git a/deploy/nginx/genarrative-preview-deployer-lan.conf b/deploy/nginx/genarrative-preview-deployer-lan.conf
new file mode 100644
index 000000000..01cfb6817
--- /dev/null
+++ b/deploy/nginx/genarrative-preview-deployer-lan.conf
@@ -0,0 +1,35 @@
+location = /build {
+ return 301 /build/;
+}
+
+location ^~ /build/ {
+ proxy_pass http://127.0.0.1:8410;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Port $server_port;
+
+ proxy_connect_timeout 10s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 90s;
+}
+
+location ^~ /api/preview-deployer/ {
+ proxy_pass http://127.0.0.1:8410;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Port $server_port;
+
+ proxy_connect_timeout 10s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 90s;
+}
diff --git a/deploy/systemd/genarrative-preview-deployer.service b/deploy/systemd/genarrative-preview-deployer.service
new file mode 100644
index 000000000..01e9990f1
--- /dev/null
+++ b/deploy/systemd/genarrative-preview-deployer.service
@@ -0,0 +1,26 @@
+[Unit]
+Description=Genarrative Jenkins Preview Deployer
+After=network-online.target jenkins.service
+Wants=network-online.target
+Requires=jenkins.service
+
+[Service]
+Type=simple
+User=jenkins
+Group=jenkins
+WorkingDirectory=/opt/genarrative/preview-deployer
+EnvironmentFile=/etc/genarrative/preview-deployer.env
+ExecStart=/opt/genarrative/preview-deployer/preview-deployer-server
+Restart=on-failure
+RestartSec=5
+KillSignal=SIGINT
+TimeoutStopSec=30
+
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/genarrative/preview-deployer
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/README.md b/docs/README.md
index cbc417097..b05797a0d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -46,6 +46,7 @@
### 后台、宿主壳与运维
- [Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)
+- [Jenkins 容器预览部署控制面](./technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)
- [后台多账号与 Tab 访问权限](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md)
- [宿主壳能力统一协议](./【前端架构】宿主壳能力统一协议-2026-06-17.md)
- [Expo React Native 与 Tauri 宿主壳方案](./【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md)
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 20fa33f5c..bd8566e07 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -14170,3 +14170,12 @@
- merge master 后保留角色图层浮动工具栏和右键菜单中的 `生成动画` 快捷入口,统一进入同一个角色动作 dialog;`character-animation` 结果不支持快速编辑,仍保留 `改造 / 去背景 / 拆帧 / 下载`。快速编辑入口与提交门禁使用统一正向白名单,未知媒体或素材类型默认拒绝。
- Spine 序列帧工具栏的“去背景”暂定为纯算法绿幕扣除:只读取正式序列帧 objectKey,使用本地 `screen-color-keying` 处理约定的 `#00FF00` 绿幕,不调用 BgFilter、阿里云或其它模型;普通图片 `/api/editor/images/background-removals` 的通用去背景链路不受影响。
- `/api/external/v1` 不开放上述内部字段或直接转换路由,不修改 OpenAPI;本次不改 SpacetimeDB 表结构、迁移或生成 bindings。
+
+## 2026-08-15 Jenkins 容器预览部署使用独立控制面
+
+- 决策:多人内网容器预览不把操作表单塞进 Jenkins 页面,也不让 SPA 直接操作 Docker。独立 `preview-deployer` SPA 通过同源 Axum 代理触发固定 `shared/Genarrative-Preview-Deployer` Job;浏览器只持有控制面 HttpOnly 会话,Jenkins service account 和 API Token 只存在服务端环境。
+- 部署入口只使用内网 `http://192.168.35.82/build/`,不配置公网域名;预览 Web 端口固定为 `8400..8499`,卸载后立即释放租约,运行状态由页面刷新时的实时 Web 探针更新。
+- Jenkins 的 Compose 编排、Dockerfile 入口和执行脚本固定取自受保护的 master 控制器 checkout,目标分支只作为应用源码构建上下文;控制 Job 只授予受信任开发者和专用服务账号。
+- 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。
+- 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。
+- 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md
new file mode 100644
index 000000000..23436d15c
--- /dev/null
+++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md
@@ -0,0 +1,106 @@
+# Jenkins 容器预览部署控制面技术方案
+
+日期:`2026-08-15`
+
+## 目标
+
+为内网同事提供独立 SPA,用于填写源码分支和可选 commit,触发 Jenkins 构建并发布完整 Docker Compose 预览栈。页面持续刷新排队、构建、发布和卸载状态,成功后展示容器健康状态与 Web 内网地址。
+
+控制面不替代 Jenkins:Jenkins 继续负责源码检出、全量镜像构建、SpacetimeDB 模块发布、容器启动、健康验证、卸载和构建日志。浏览器不得持有 Jenkins 用户名、API Token、Crumb 或 Docker 权限。
+
+## 架构
+
+```text
+同事浏览器
+ -> preview-deployer SPA
+ -> preview-deployer-server 同源 API
+ -> 固定 Jenkins Job shared/Genarrative-Preview-Deployer
+ -> 独立 Compose project + 端口租约 + preview-result.json
+```
+
+- SPA 只通过内网 `http://192.168.35.82/build/` 提供页面,API 使用同源 `/api/preview-deployer/`;不配置公网域名,不复用后台管理员 Token 或业务 API 登录态。
+- `preview-deployer-server` 只代理固定 Jenkins Job,Jenkins 凭据只从服务端环境变量读取,不进入前端 bundle、JSON 响应或日志。
+- Jenkins Job 使用仓库现有 `deploy/container/` 全量容器资产,并通过独立 Compose project 支持多个分支实例并存;为保护端口和共享 Docker 状态,构建动作在首版中串行排队。
+- 页面只读取控制面归一化后的状态,不解析 Jenkins console,也不直连 Jenkins API。
+
+## 部署身份与端口
+
+`deploymentId` 由规范化分支名与分支名摘要确定,同一分支稳定得到同一 ID;commit 不进入 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。
+
+Web 端口池固定为 `8400..8499`:
+
+1. Jenkins 在全局文件锁内读取受控状态目录。
+2. 已登记分支复用原端口;新分支选择状态表、宿主监听和 Docker 映射均未占用的端口。
+3. Jenkins 为实例使用独立 Compose project,例如 `genarrative-preview-a81f39c2d43e76ab`。
+4. 端口租约必须在容器完成真实绑定前持续受锁保护;失败构建不得把端口分配给第二个实例。
+5. 卸载成功后删除该实例的 Compose 栈、受控状态和端口租约。
+
+同一分支重新构建时先完成新镜像构建,再停止旧 Compose 栈并启动新版本;源码或镜像构建失败不会提前删除原有健康实例。容器切换后的失败仍会在页面明确显示,不伪装成旧版本继续运行。
+
+页面每次刷新运行中实例时,控制服务直接探测受控 Web URL;后续容器异常会将健康状态更新为 `unhealthy`,不会永久沿用 Jenkins 部署完成时的快照。
+
+目标分支只提供应用构建上下文。Compose 编排文件、Dockerfile 入口和 Jenkins 执行脚本固定取自受保护的 `master` 控制器 checkout,避免普通分支替换编排文件挂载宿主路径或启用特权容器。该 Job 仍只应开放给受信任开发者,并建议在隔离构建节点运行。
+
+SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络中的 SpacetimeDB 容器地址完成模块发布,运行服务之间继续使用 Compose DNS。页面只展示 Web 内网地址 `http://<预览宿主>:`。
+
+## Jenkins 参数与产物
+
+固定 Job:`shared/Genarrative-Preview-Deployer`。
+
+参数:
+
+- `ACTION`:`DEPLOY`、`UNINSTALL` 或 `STATUS`。
+- `SOURCE_BRANCH`:部署时必填。
+- `COMMIT_HASH`:部署时可选,7 到 40 位十六进制。
+- `DEPLOYMENT_ID`:卸载和状态查询时必填。
+
+分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。
+
+每次运行归档 `preview-result.json`,至少包含:
+
+```json
+{
+ "schemaVersion": 1,
+ "deploymentId": "feature-login-a81f39c2",
+ "branch": "feature/login",
+ "requestedCommit": "",
+ "resolvedCommit": "0123456789abcdef",
+ "status": "running",
+ "health": "healthy",
+ "webPort": 8403,
+ "webUrl": "http://192.168.35.82:8403",
+ "composeProject": "genarrative-preview-feature-login-a81f39c2",
+ "updatedAt": "2026-08-15T08:00:00.000Z"
+}
+```
+
+控制面只读取固定 artifact 路径和受限字段;不得把完整 console 内容返回浏览器。
+
+## 控制面 API
+
+- `POST /api/preview-deployer/session`:使用控制面访问口令建立 `HttpOnly + SameSite=Strict` 会话。
+- `GET /api/preview-deployer/session`:查询当前会话状态。
+- `DELETE /api/preview-deployer/session`:退出。
+- `GET /api/preview-deployer/deployments`:列出由控制面触发和恢复的部署。
+- `POST /api/preview-deployer/deployments`:提交 `{ branch, commitHash? }`。
+- `GET /api/preview-deployer/deployments/{id}`:刷新队列、构建与 artifact 状态。
+- `POST /api/preview-deployer/deployments/{id}/uninstall`:触发固定 Job 的卸载动作。
+
+页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。
+
+## 安全边界
+
+- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
+- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。
+- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
+- Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
+- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
+- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
+- 卸载只接受受控状态中存在且 ID 完全匹配的实例,并进行二次确认;禁止执行任意 Docker、Git、shell 或 Compose project 参数。
+
+## 验收
+
+- 后端:输入校验、登录会话、Origin、Crumb、Jenkins `401/403/404/5xx`、queue 到 build 状态机、artifact schema、卸载所有权和幂等测试。
+- 前端:登录、分支与可选 commit、自动刷新、排队/构建/成功/失败状态、内网链接、卸载确认和刷新恢复测试。
+- Jenkins:两个分支依次发布后在不同端口并存;同一分支换 commit 优先复用端口;非分支 commit 被拒绝;卸载只删除目标实例并释放端口。
+- 通用:`npm run check:encoding`、相关 typecheck/build/test、Rust 定向测试和 `git diff --check`。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index d986e0571..7f37c7477 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -674,6 +674,8 @@ npm run container:down
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
+
+多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。
diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer
new file mode 100644
index 000000000..3f4e5961e
--- /dev/null
+++ b/jenkins/Jenkinsfile.preview-deployer
@@ -0,0 +1,151 @@
+pipeline {
+ agent {
+ label 'linux && genarrative-build'
+ }
+
+ options {
+ disableConcurrentBuilds()
+ skipDefaultCheckout(true)
+ timeout(time: 120, unit: 'MINUTES')
+ buildDiscarder(logRotator(numToKeepStr: '50', artifactNumToKeepStr: '50'))
+ }
+
+ environment {
+ GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
+ GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
+ GENARRATIVE_PREVIEW_STATE_ROOT = '/data/jenkins/preview-deployments'
+ }
+
+ parameters {
+ choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL'], description: '部署、查询状态或卸载分支预览容器')
+ string(name: 'SOURCE_BRANCH', defaultValue: '', description: '源码分支;DEPLOY 必填,同一分支稳定映射到同一个预览实例')
+ string(name: 'COMMIT_HASH', defaultValue: '', description: '可选;必须是 SOURCE_BRANCH 历史中的 commit')
+ string(name: 'DEPLOYMENT_ID', defaultValue: '', description: '可选;留空时由 SOURCE_BRANCH 稳定生成,查询和卸载可直接传部署 ID')
+ }
+
+ stages {
+ stage('Checkout Controller') {
+ steps {
+ checkout([
+ $class: 'GitSCM',
+ branches: [[name: '*/master']],
+ doGenerateSubmoduleConfigurations: false,
+ extensions: [
+ [$class: 'CleanBeforeCheckout'],
+ [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
+ ],
+ userRemoteConfigs: [[
+ url: env.GIT_REMOTE_URL,
+ credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
+ refspec: '+refs/heads/master:refs/remotes/origin/master',
+ ]],
+ ])
+ }
+ }
+
+ stage('Validate Request') {
+ steps {
+ sh '''
+ set -euo pipefail
+ ACTION="${ACTION:-}"
+ SOURCE_BRANCH="${SOURCE_BRANCH:-}"
+ COMMIT_HASH="${COMMIT_HASH:-}"
+ DEPLOYMENT_ID="${DEPLOYMENT_ID:-}"
+ case "${ACTION}" in DEPLOY|STATUS|UNINSTALL) ;; *) exit 1 ;; esac
+ if [ -n "${SOURCE_BRANCH}" ]; then
+ git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null
+ fi
+ if [ -n "${COMMIT_HASH}" ] && ! printf '%s' "${COMMIT_HASH}" | grep -Eq '^[0-9a-fA-F]{7,40}$'; then
+ echo 'COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。' >&2
+ exit 1
+ fi
+ if [ -n "${DEPLOYMENT_ID}" ] && ! printf '%s' "${DEPLOYMENT_ID}" | grep -Eq '^preview-[0-9a-f]{16}$'; then
+ echo 'DEPLOYMENT_ID 格式非法。' >&2
+ exit 1
+ fi
+ '''
+ }
+ }
+
+ stage('Checkout Requested Source') {
+ when {
+ expression { params.ACTION == 'DEPLOY' }
+ }
+ steps {
+ dir('source') {
+ script {
+ def remoteConfig = [
+ url: env.GIT_REMOTE_URL,
+ credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
+ refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}",
+ ]
+ checkout([
+ $class: 'GitSCM',
+ branches: [[name: "*/${params.SOURCE_BRANCH}"]],
+ doGenerateSubmoduleConfigurations: false,
+ extensions: [
+ [$class: 'CleanBeforeCheckout'],
+ [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
+ ],
+ userRemoteConfigs: [remoteConfig],
+ ])
+ }
+ withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
+ sh '''
+ set -euo pipefail
+ SOURCE_BRANCH="${SOURCE_BRANCH}" \
+ COMMIT_HASH="${COMMIT_HASH}" \
+ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
+ SOURCE_COMMIT_FILE=".jenkins-source-commit" \
+ GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \
+ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
+ "${WORKSPACE}/scripts/jenkins-checkout-source.sh"
+ '''
+ }
+ }
+ }
+ }
+
+ stage('Execute Preview Action') {
+ steps {
+ sh '''
+ set -euo pipefail
+ chmod +x scripts/jenkins-preview-deployer.sh
+ SOURCE_DIR="${WORKSPACE}/source" \
+ RESULT_FILE="${WORKSPACE}/preview-result.json" \
+ DESCRIPTION_FILE="${WORKSPACE}/.jenkins-preview-description" \
+ scripts/jenkins-preview-deployer.sh
+ '''
+ script {
+ if (fileExists('.jenkins-preview-description')) {
+ currentBuild.description = readFile('.jenkins-preview-description').trim()
+ }
+ }
+ }
+ }
+ }
+
+ post {
+ always {
+ script {
+ if (!fileExists('preview-result.json')) {
+ writeFile file: 'preview-result.json', text: groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([
+ schemaVersion: 1,
+ action: params.ACTION ?: 'UNKNOWN',
+ deploymentId: params.DEPLOYMENT_ID ?: null,
+ projectName: params.DEPLOYMENT_ID ? "genarrative-${params.DEPLOYMENT_ID}" : null,
+ branch: params.SOURCE_BRANCH ?: null,
+ requestedCommit: params.COMMIT_HASH ?: null,
+ resolvedCommit: null,
+ status: 'failed',
+ health: 'unknown',
+ webUrl: null,
+ message: 'Jenkins 在生成部署结果前失败,请查看构建日志。',
+ updatedAt: new Date().format("yyyy-MM-dd'T'HH:mm:ssXXX"),
+ ])) + '\n'
+ }
+ }
+ archiveArtifacts artifacts: 'preview-result.json', allowEmptyArchive: false, fingerprint: true
+ }
+ }
+}
diff --git a/jenkins/preview-deployer-job-config.xml b/jenkins/preview-deployer-job-config.xml
new file mode 100644
index 000000000..a0cbd4247
--- /dev/null
+++ b/jenkins/preview-deployer-job-config.xml
@@ -0,0 +1,73 @@
+
+
+
+ 内网分支容器预览,仅由 Preview Deployer 控制面触发。
+ false
+
+
+
+
+ ACTION
+ 部署、查询状态或卸载分支预览容器
+
+
+ DEPLOY
+ STATUS
+ UNINSTALL
+
+
+
+
+ SOURCE_BRANCH
+ 源码分支;DEPLOY 和 UNINSTALL 必填
+
+ true
+
+
+ COMMIT_HASH
+ 可选;必须是 SOURCE_BRANCH 历史中的 commit
+
+ true
+
+
+ DEPLOYMENT_ID
+ 预览实例稳定 ID
+
+ true
+
+
+
+
+
+
+ 2
+
+
+ ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git
+ genarrative-local-gitea-ssh
+ +refs/heads/master:refs/remotes/origin/master
+
+
+
+
+ */master
+
+
+ false
+
+
+
+ true
+ true
+
+ 1
+ true
+
+
+
+ jenkins/Jenkinsfile.preview-deployer
+ false
+
+
+ false
+
diff --git a/package.json b/package.json
index d57f551fe..8e6902e9d 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,11 @@
"dev:web": "node scripts/dev.mjs web",
"dev:admin-web": "node scripts/dev.mjs admin-web",
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
+ "preview-deployer:server": "cargo run -p preview-deployer-server --manifest-path server-rs/Cargo.toml",
+ "preview-deployer:web:dev": "npm --prefix apps/preview-deployer-web run dev",
+ "preview-deployer:web:typecheck": "npm --prefix apps/preview-deployer-web run typecheck",
+ "preview-deployer:web:test": "npm --prefix apps/preview-deployer-web run test",
+ "preview-deployer:web:build": "npm --prefix apps/preview-deployer-web run build",
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"otel:debug": "node scripts/run-otelcol.mjs debug",
"otel:rider": "node scripts/run-otelcol.mjs rider",
@@ -46,6 +51,7 @@
"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check",
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
+ "check:preview-deployer": "node scripts/check-preview-deployer.mjs",
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
"check:database-backup": "node scripts/check-database-backup-to-oss.mjs",
"check:production-health-patrol": "node scripts/check-production-health-patrol.mjs",
@@ -93,7 +99,7 @@
"lint:guardrails": "npm run lint:eslint",
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
"typecheck:guardrails": "npm run typecheck",
- "lint": "npm run check:encoding && npm run check:pre-commit-format && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck",
+ "lint": "npm run check:encoding && npm run check:pre-commit-format && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
"format": "prettier --write .",
"format:check": "prettier --check .",
diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs
new file mode 100644
index 000000000..32d1b0497
--- /dev/null
+++ b/scripts/check-preview-deployer.mjs
@@ -0,0 +1,194 @@
+import { readFileSync } from 'node:fs';
+
+const jenkinsfile = readFileSync(
+ 'jenkins/Jenkinsfile.preview-deployer',
+ 'utf8',
+);
+const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8');
+const resultWriter = readFileSync(
+ 'scripts/preview-deployment-status.mjs',
+ 'utf8',
+);
+const compose = readFileSync(
+ 'deploy/container/docker-compose.loadtest.yml',
+ 'utf8',
+);
+const webConfig = readFileSync(
+ 'apps/preview-deployer-web/vite.config.ts',
+ 'utf8',
+);
+const systemd = readFileSync(
+ 'deploy/systemd/genarrative-preview-deployer.service',
+ 'utf8',
+);
+const server = readFileSync(
+ 'server-rs/crates/preview-deployer-server/src/lib.rs',
+ 'utf8',
+);
+const nginx = readFileSync(
+ 'deploy/nginx/genarrative-preview-deployer-lan.conf',
+ 'utf8',
+);
+const jobConfig = readFileSync(
+ 'jenkins/preview-deployer-job-config.xml',
+ 'utf8',
+);
+
+const failures = [];
+assertIncludes(
+ jenkinsfile,
+ "choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL']",
+ 'Jenkins 参数必须固定为 DEPLOY/STATUS/UNINSTALL。',
+);
+assertIncludes(
+ jenkinsfile,
+ "string(name: 'SOURCE_BRANCH'",
+ 'Jenkins 必须暴露 SOURCE_BRANCH。',
+);
+assertIncludes(
+ jenkinsfile,
+ "string(name: 'COMMIT_HASH'",
+ 'Jenkins 必须暴露 COMMIT_HASH。',
+);
+assertIncludes(
+ jenkinsfile,
+ "string(name: 'DEPLOYMENT_ID'",
+ 'Jenkins 必须暴露 DEPLOYMENT_ID。',
+);
+assertIncludes(
+ jenkinsfile,
+ "archiveArtifacts artifacts: 'preview-result.json'",
+ 'Jenkins 必须归档 preview-result.json。',
+);
+assertIncludes(
+ jenkinsfile,
+ "writeFile file: 'preview-result.json'",
+ 'Jenkins 失败路径也必须生成 preview-result.json。',
+);
+assertIncludes(
+ jenkinsfile,
+ 'disableConcurrentBuilds()',
+ 'Jenkins Job 必须禁止并发构建。',
+);
+assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。');
+assertIncludes(
+ deployer,
+ 'git check-ref-format --branch',
+ '部署脚本必须校验分支格式。',
+);
+assertIncludes(
+ deployer,
+ 'genarrative-preview-[0-9a-f]{16}',
+ '卸载必须限制到预览 compose project 白名单。',
+);
+assertIncludes(
+ deployer,
+ 'allocate_port webPort 8400 8499',
+ 'Web 端口必须从 8400-8499 分配。',
+);
+assertIncludes(
+ deployer,
+ 'ports: !reset []',
+ '预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。',
+);
+assertIncludes(
+ deployer,
+ '^preview-[0-9a-f]{16}$',
+ '部署 ID 必须固定为 preview- 加 16 位摘要。',
+);
+assertIncludes(
+ deployer,
+ 'state.active === true && String(state[key]) === expected',
+ '只有活动实例才能占用端口租约。',
+);
+assertIncludes(
+ deployer,
+ '--yes=migrate,break-clients',
+ 'SpacetimeDB 发布不得默认删除实例数据。',
+);
+assertExcludes(
+ deployer,
+ '--delete-data',
+ '预览部署不得使用 SpacetimeDB 删除数据参数。',
+);
+assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。');
+assertIncludes(
+ systemd,
+ 'User=jenkins',
+ '控制服务必须使用 Jenkins 运行用户以访问受控构建状态。',
+);
+assertIncludes(
+ nginx,
+ 'location ^~ /build/',
+ '内网 Nginx 必须提供 /build/ 路由。',
+);
+assertIncludes(
+ server,
+ '.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))',
+ '控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。',
+);
+assertIncludes(
+ jobConfig,
+ 'jenkins/Jenkinsfile.preview-deployer',
+ 'Jenkins Job 必须从受保护 master 读取固定流水线。',
+);
+assertIncludes(
+ jobConfig,
+ '',
+ 'Jenkins Job 首次触发前必须已经声明 buildWithParameters 参数。',
+);
+assertIncludes(
+ nginx,
+ 'location ^~ /api/preview-deployer/',
+ '内网 Nginx 必须代理预览控制 API。',
+);
+assertIncludes(
+ resultWriter,
+ 'schemaVersion: 1',
+ '结果文件必须带版本化 schema。',
+);
+assertIncludes(resultWriter, 'healthStatus', '结果文件必须提供健康状态。');
+assertIncludes(resultWriter, 'webUrl', '结果文件必须提供 Web URL。');
+assertIncludes(
+ resultWriter,
+ 'resolvedCommit',
+ '结果文件必须提供 resolvedCommit。',
+);
+assertIncludes(
+ resultWriter,
+ 'status: publicStatus(phase)',
+ '结果文件必须提供小写 status 契约。',
+);
+assertIncludes(
+ resultWriter,
+ 'health: publicHealth(healthStatus)',
+ '结果文件必须提供小写 health 契约。',
+);
+assertIncludes(
+ compose,
+ 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]',
+ 'Nginx 容器健康检查必须验证 Web 首页。',
+);
+assertExcludes(
+ compose,
+ 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]',
+ 'Nginx 容器健康检查不能访问公开拒绝的 /healthz。',
+);
+
+if (failures.length > 0) {
+ for (const failure of failures)
+ console.error(`[check:preview-deployer] ${failure}`);
+ process.exit(1);
+}
+
+console.log(
+ '[check:preview-deployer] Jenkins 参数、隔离部署、卸载白名单、结果契约和 Nginx 健康检查通过。',
+);
+
+function assertIncludes(content, expected, message) {
+ if (!content.includes(expected)) failures.push(message);
+}
+
+function assertExcludes(content, expected, message) {
+ if (content.includes(expected)) failures.push(message);
+}
diff --git a/scripts/deploy/preview-deployer-install.sh b/scripts/deploy/preview-deployer-install.sh
new file mode 100644
index 000000000..346588b1c
--- /dev/null
+++ b/scripts/deploy/preview-deployer-install.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+INSTALL_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_INSTALL_ROOT:-/opt/genarrative/preview-deployer}"
+STATE_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_STATE_ROOT:-/var/lib/genarrative/preview-deployer}"
+ENV_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_ENV_FILE:-/etc/genarrative/preview-deployer.env}"
+UNIT_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_UNIT_FILE:-/etc/systemd/system/genarrative-preview-deployer.service}"
+
+if [[ "${EUID}" -ne 0 ]]; then
+ echo "[preview-deployer-install] 必须以 root 执行安装。" >&2
+ exit 1
+fi
+if [[ ! -f "${ENV_FILE}" ]]; then
+ echo "[preview-deployer-install] 缺少私密配置 ${ENV_FILE};请从 deploy/env/preview-deployer.env.example 创建并填入 Jenkins Token 和控制面口令。" >&2
+ exit 1
+fi
+
+cd "${ROOT_DIR}"
+PREVIEW_DEPLOYER_WEB_BASE=/build/ npm run preview-deployer:web:build
+cargo build -p preview-deployer-server --release --manifest-path server-rs/Cargo.toml
+
+install -d -o jenkins -g jenkins -m 0750 "${INSTALL_ROOT}" "${INSTALL_ROOT}/web"
+install -d -o jenkins -g jenkins -m 0700 "${STATE_ROOT}"
+install -o root -g root -m 0755 \
+ server-rs/target/release/preview-deployer-server \
+ "${INSTALL_ROOT}/preview-deployer-server"
+find "${INSTALL_ROOT}/web" -mindepth 1 -delete
+cp -a apps/preview-deployer-web/dist/. "${INSTALL_ROOT}/web/"
+chown -R root:root "${INSTALL_ROOT}/web"
+find "${INSTALL_ROOT}/web" -type d -exec chmod 0755 {} +
+find "${INSTALL_ROOT}/web" -type f -exec chmod 0644 {} +
+install -o root -g root -m 0644 \
+ deploy/systemd/genarrative-preview-deployer.service "${UNIT_FILE}"
+
+systemctl daemon-reload
+systemctl enable --now genarrative-preview-deployer.service
+curl --fail --silent --show-error --max-time 5 \
+ --header 'Host: 192.168.35.82' http://127.0.0.1:8410/healthz >/dev/null
+echo "[preview-deployer-install] 控制服务已安装;请将 deploy/nginx/genarrative-preview-deployer-lan.conf 嵌入内网默认 server 后执行 nginx -t && systemctl reload nginx。"
diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh
new file mode 100644
index 000000000..6a92d6293
--- /dev/null
+++ b/scripts/jenkins-preview-deployer.sh
@@ -0,0 +1,478 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ACTION="${ACTION:-STATUS}"
+SOURCE_BRANCH="${SOURCE_BRANCH:-}"
+COMMIT_HASH="${COMMIT_HASH:-}"
+DEPLOYMENT_ID="${DEPLOYMENT_ID:-}"
+SOURCE_DIR="${SOURCE_DIR:-${WORKSPACE:-$(pwd)}/source}"
+RESULT_FILE="${RESULT_FILE:-${WORKSPACE:-$(pwd)}/preview-result.json}"
+DESCRIPTION_FILE="${DESCRIPTION_FILE:-${WORKSPACE:-$(pwd)}/.jenkins-preview-description}"
+STATE_ROOT="${GENARRATIVE_PREVIEW_STATE_ROOT:-/data/jenkins/preview-deployments}"
+WEB_HOST="${GENARRATIVE_PREVIEW_WEB_HOST:-}"
+LOCK_FILE="${GENARRATIVE_PREVIEW_LOCK_FILE:-${STATE_ROOT}/.lock}"
+
+STATE_DIR=""
+STATE_FILE=""
+PROJECT_NAME=""
+SCRIPT_ROOT=""
+SCRIPT_FAILED=1
+
+fail() {
+ echo "[preview-deployer] $*" >&2
+ exit 1
+}
+
+validate_request() {
+ case "${ACTION}" in
+ DEPLOY|UNINSTALL|STATUS) ;;
+ *) fail "ACTION 只支持 DEPLOY、UNINSTALL 或 STATUS: ${ACTION}" ;;
+ esac
+
+ if [[ -n "${SOURCE_BRANCH}" ]]; then
+ [[ "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]] || fail "SOURCE_BRANCH 包含不允许的字符。"
+ [[ "${SOURCE_BRANCH}" != /* && "${SOURCE_BRANCH}" != */ && "${SOURCE_BRANCH}" != *..* ]] || \
+ fail "SOURCE_BRANCH 不能以斜杠开头/结尾,也不能包含连续点号。"
+ git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null 2>&1 || fail "SOURCE_BRANCH 不是合法 Git 分支名。"
+ fi
+
+ if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
+ fail "COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。"
+ fi
+
+ if [[ "${ACTION}" == "DEPLOY" && -z "${SOURCE_BRANCH}" ]]; then
+ fail "DEPLOY 必须填写 SOURCE_BRANCH。"
+ fi
+ if [[ "${ACTION}" == "UNINSTALL" && -z "${SOURCE_BRANCH}" ]]; then
+ fail "UNINSTALL 必须同时填写受控 SOURCE_BRANCH,以验证部署所有权。"
+ fi
+ if [[ "${ACTION}" != "DEPLOY" && -z "${SOURCE_BRANCH}" && -z "${DEPLOYMENT_ID}" ]]; then
+ fail "UNINSTALL/STATUS 至少需要 SOURCE_BRANCH 或 DEPLOYMENT_ID。"
+ fi
+
+ if [[ -n "${DEPLOYMENT_ID}" && ! "${DEPLOYMENT_ID}" =~ ^preview-[0-9a-f]{16}$ ]]; then
+ fail "DEPLOYMENT_ID 必须符合 preview- 加 16 位小写十六进制摘要。"
+ fi
+}
+
+derive_deployment_id() {
+ local digest
+ [[ -n "${SOURCE_BRANCH}" ]] || return 0
+ digest="$(printf '%s' "${SOURCE_BRANCH}" | sha256sum)"
+ digest="${digest%% *}"
+ printf 'preview-%s\n' "${digest:0:16}"
+}
+
+resolve_identity() {
+ local derived_id=""
+ derived_id="$(derive_deployment_id)"
+ if [[ -z "${DEPLOYMENT_ID}" ]]; then
+ DEPLOYMENT_ID="${derived_id}"
+ elif [[ -n "${derived_id}" && "${DEPLOYMENT_ID}" != "${derived_id}" ]]; then
+ fail "DEPLOYMENT_ID 与 SOURCE_BRANCH 的稳定映射不一致,预期 ${derived_id}。"
+ fi
+
+ [[ -n "${DEPLOYMENT_ID}" ]] || fail "无法确定 DEPLOYMENT_ID。"
+ PROJECT_NAME="genarrative-${DEPLOYMENT_ID}"
+ STATE_DIR="${STATE_ROOT}/${DEPLOYMENT_ID}"
+ STATE_FILE="${STATE_DIR}/state.json"
+}
+
+ensure_safe_state_root() {
+ [[ "${STATE_ROOT}" == /* && "${STATE_ROOT}" != "/" ]] || fail "状态目录必须是非根目录绝对路径。"
+ if [[ -L "${STATE_ROOT}" ]]; then
+ fail "状态目录不能是符号链接: ${STATE_ROOT}"
+ fi
+ install -d -m 0700 "${STATE_ROOT}"
+ if [[ -e "${STATE_DIR}" && ( -L "${STATE_DIR}" || ! -d "${STATE_DIR}" ) ]]; then
+ fail "部署状态路径必须是普通目录且不能是符号链接: ${STATE_DIR}"
+ fi
+ install -d -m 0700 "${STATE_DIR}"
+}
+
+state_value() {
+ local key="$1"
+ [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0
+ node -e '
+ const fs = require("node:fs");
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))[process.argv[2]];
+ if (value !== undefined && value !== null) process.stdout.write(String(value));
+ ' "${STATE_FILE}" "${key}"
+}
+
+validate_existing_state() {
+ local saved_id saved_project saved_branch
+ [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0
+ saved_id="$(state_value deploymentId)"
+ saved_project="$(state_value projectName)"
+ saved_branch="$(state_value sourceBranch)"
+ [[ "${saved_id}" == "${DEPLOYMENT_ID}" ]] || fail "状态文件 deploymentId 不匹配。"
+ [[ "${saved_project}" == "${PROJECT_NAME}" ]] || fail "状态文件 compose project 不匹配。"
+ if [[ -n "${SOURCE_BRANCH}" && "${saved_branch}" != "${SOURCE_BRANCH}" ]]; then
+ fail "部署实例属于分支 ${saved_branch},不能用 ${SOURCE_BRANCH} 操作。"
+ fi
+ if [[ -z "${SOURCE_BRANCH}" ]]; then
+ SOURCE_BRANCH="${saved_branch}"
+ fi
+}
+
+write_state() {
+ local phase="$1"
+ local active="$2"
+ local source_commit="$3"
+ local web_port="$4"
+ local spacetime_port="$5"
+ local otlp_grpc_port="$6"
+ local otlp_http_port="$7"
+ local temporary_file="${STATE_FILE}.tmp.$$"
+ node - "${temporary_file}" "${DEPLOYMENT_ID}" "${PROJECT_NAME}" "${SOURCE_BRANCH}" \
+ "${COMMIT_HASH}" "${source_commit}" "${phase}" "${active}" "${web_port}" "${spacetime_port}" \
+ "${otlp_grpc_port}" "${otlp_http_port}" <<'NODE'
+const fs = require('node:fs');
+const [file, deploymentId, projectName, sourceBranch, requestedCommit, sourceCommit, phase, active,
+ webPort, spacetimePort, otlpGrpcPort, otlpHttpPort] = process.argv.slice(2);
+const number = (value) => value ? Number(value) : null;
+fs.writeFileSync(file, `${JSON.stringify({
+ schemaVersion: 1,
+ deploymentId,
+ projectName,
+ sourceBranch,
+ requestedCommit: requestedCommit || null,
+ sourceCommit: sourceCommit || null,
+ phase,
+ active: active === 'true',
+ webPort: number(webPort),
+ spacetimePort: number(spacetimePort),
+ otlpGrpcPort: number(otlpGrpcPort),
+ otlpHttpPort: number(otlpHttpPort),
+ updatedAt: new Date().toISOString(),
+}, null, 2)}\n`, {mode: 0o600});
+NODE
+ mv -f "${temporary_file}" "${STATE_FILE}"
+ chmod 0600 "${STATE_FILE}"
+}
+
+render_result() {
+ local phase="${1:-}"
+ local message="${2:-}"
+ local args=(
+ "${SCRIPT_ROOT}/preview-deployment-status.mjs"
+ --action "${ACTION}"
+ --deployment-id "${DEPLOYMENT_ID}"
+ --project-name "${PROJECT_NAME}"
+ --source-branch "${SOURCE_BRANCH}"
+ --state-file "${STATE_FILE}"
+ --output "${RESULT_FILE}"
+ --description-file "${DESCRIPTION_FILE}"
+ --web-host "$(resolve_web_host)"
+ )
+ if [[ -n "${phase}" ]]; then
+ args+=(--phase "${phase}")
+ fi
+ if [[ -n "${message}" ]]; then
+ args+=(--message "${message}")
+ fi
+ node "${args[@]}"
+}
+
+resolve_web_host() {
+ local route_source
+ if [[ -n "${WEB_HOST}" ]]; then
+ printf '%s\n' "${WEB_HOST}"
+ return
+ fi
+ route_source="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([^ ]*\).*/\1/p' | head -n 1)"
+ printf '%s\n' "${route_source:-127.0.0.1}"
+}
+
+on_exit() {
+ local exit_code=$?
+ if [[ "${SCRIPT_FAILED}" == "1" && -n "${STATE_FILE}" ]]; then
+ set +e
+ if [[ "${ACTION}" == "DEPLOY" && -f "${STATE_FILE}" ]]; then
+ write_state "FAILED" "false" "$(state_value sourceCommit)" \
+ "$(state_value webPort)" "$(state_value spacetimePort)" \
+ "$(state_value otlpGrpcPort)" "$(state_value otlpHttpPort)"
+ fi
+ render_result "FAILED" "预览部署执行失败,请查看 Jenkins 构建日志。"
+ fi
+ exit "${exit_code}"
+}
+trap on_exit EXIT
+
+port_used_by_other_state() {
+ local key="$1"
+ local port="$2"
+ node - "${STATE_ROOT}" "${DEPLOYMENT_ID}" "${key}" "${port}" <<'NODE'
+const fs = require('node:fs');
+const path = require('node:path');
+const [root, currentId, key, expected] = process.argv.slice(2);
+let used = false;
+for (const entry of fs.readdirSync(root, {withFileTypes: true})) {
+ if (!entry.isDirectory() || entry.name === currentId) continue;
+ try {
+ const state = JSON.parse(fs.readFileSync(path.join(root, entry.name, 'state.json'), 'utf8'));
+ if (state.active === true && String(state[key]) === expected) used = true;
+ } catch {}
+}
+process.exit(used ? 0 : 1);
+NODE
+}
+
+port_is_listening() {
+ local port="$1"
+ ss -H -ltn "sport = :${port}" | grep -q .
+}
+
+allocate_port() {
+ local key="$1"
+ local start="$2"
+ local end="$3"
+ local preferred="${4:-}"
+ local port
+ if [[ -n "${preferred}" ]] && ! port_used_by_other_state "${key}" "${preferred}" && ! port_is_listening "${preferred}"; then
+ printf '%s\n' "${preferred}"
+ return
+ fi
+ for ((port = start; port <= end; port += 1)); do
+ if ! port_used_by_other_state "${key}" "${port}" && ! port_is_listening "${port}"; then
+ printf '%s\n' "${port}"
+ return
+ fi
+ done
+ fail "端口范围 ${start}-${end} 已无可用端口。"
+}
+
+remove_project_resources() {
+ local ids=()
+ local images=()
+ local volumes=()
+ local networks=()
+ [[ "${PROJECT_NAME}" =~ ^genarrative-preview-[0-9a-f]{16}$ ]] || fail "拒绝操作非白名单 compose project: ${PROJECT_NAME}"
+ mapfile -t ids < <(docker ps -aq --filter "label=com.docker.compose.project=${PROJECT_NAME}")
+ if [[ "${#ids[@]}" -gt 0 ]]; then
+ docker rm -f "${ids[@]}"
+ fi
+ mapfile -t volumes < <(docker volume ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}")
+ if [[ "${#volumes[@]}" -gt 0 ]]; then
+ docker volume rm "${volumes[@]}"
+ fi
+ mapfile -t networks < <(docker network ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}")
+ if [[ "${#networks[@]}" -gt 0 ]]; then
+ docker network rm "${networks[@]}"
+ fi
+ mapfile -t images < <(docker image ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}")
+ if [[ "${#images[@]}" -gt 0 ]]; then
+ docker image rm "${images[@]}"
+ fi
+}
+
+compose() {
+ env \
+ COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \
+ GENARRATIVE_PREVIEW_SOURCE_DIR="${SOURCE_DIR}" \
+ GENARRATIVE_PREVIEW_CONTROLLER_ROOT="${SCRIPT_ROOT}/.." \
+ GENARRATIVE_CONTAINER_API_ENV_FILE="${STATE_DIR}/api-server.env" \
+ GENARRATIVE_CONTAINER_HTTP_PORT="${WEB_PORT}" \
+ GENARRATIVE_CONTAINER_SPACETIME_PORT="${SPACETIME_PORT}" \
+ GENARRATIVE_CONTAINER_OTLP_GRPC_PORT="${OTLP_GRPC_PORT}" \
+ GENARRATIVE_CONTAINER_OTLP_HTTP_PORT="${OTLP_HTTP_PORT}" \
+ docker compose \
+ -f "${SCRIPT_ROOT}/../deploy/container/docker-compose.loadtest.yml" \
+ -f "${STATE_DIR}/docker-compose.preview.yml" \
+ "$@"
+}
+
+write_compose_override() {
+ local override_file="${STATE_DIR}/docker-compose.preview.yml"
+ cat >"${override_file}" <<'YAML'
+services:
+ api-server:
+ build:
+ context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
+ dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
+ external-generation-worker:
+ build:
+ context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
+ dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
+ nginx:
+ build:
+ context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
+ dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
+ spacetimedb:
+ ports: !reset []
+ otelcol:
+ ports: !reset []
+YAML
+ chmod 0600 "${override_file}"
+}
+
+wait_for_url() {
+ local url="$1"
+ local attempts="${2:-60}"
+ local attempt
+ for ((attempt = 1; attempt <= attempts; attempt += 1)); do
+ if curl --fail --silent --show-error --max-time 3 "${url}" >/dev/null 2>&1; then
+ return 0
+ fi
+ sleep 2
+ done
+ return 1
+}
+
+prepare_env() {
+ local env_file="${STATE_DIR}/api-server.env"
+ local internal_secret jwt_secret
+ cp "${SOURCE_DIR}/deploy/container/api-server.env.example" "${env_file}"
+ chmod 0600 "${env_file}"
+ internal_secret="$(openssl rand -hex 32)"
+ jwt_secret="$(openssl rand -hex 32)"
+ node - "${env_file}" "${internal_secret}" "${jwt_secret}" <<'NODE'
+const fs = require('node:fs');
+const [file, internalSecret, jwtSecret] = process.argv.slice(2);
+let content = fs.readFileSync(file, 'utf8');
+content = content.replace(/^GENARRATIVE_INTERNAL_API_SECRET=.*$/mu, `GENARRATIVE_INTERNAL_API_SECRET=${internalSecret}`);
+content = content.replace(/^GENARRATIVE_JWT_SECRET=.*$/mu, `GENARRATIVE_JWT_SECRET=${jwtSecret}`);
+fs.writeFileSync(file, content, {encoding: 'utf8', mode: 0o600});
+NODE
+ unset internal_secret jwt_secret
+}
+
+configure_spacetime_identity() {
+ local identity_payload spacetime_identity spacetime_token runtime_secret bootstrap_hash env_file
+ env_file="${STATE_DIR}/api-server.env"
+ identity_payload="$(curl --fail --silent --show-error --request POST \
+ --header 'Accept: application/json' --header 'Content-Type: application/json' \
+ "${SPACETIME_PUBLISH_URL}/v1/identity")"
+ spacetime_identity="$(printf '%s' "${identity_payload}" | node -e '
+ let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => {
+ const data=JSON.parse(body); process.stdout.write(data.identity || data.Identity || data.identity_hex || data.identityHex || "");
+ });
+ ')"
+ spacetime_token="$(printf '%s' "${identity_payload}" | node -e '
+ let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => {
+ const data=JSON.parse(body); process.stdout.write(data.token || data.Token || "");
+ });
+ ')"
+ [[ -n "${spacetime_identity}" && -n "${spacetime_token}" ]] || fail "SpacetimeDB identity 响应缺少 identity 或 token。"
+ runtime_secret="$(openssl rand -hex 32)"
+ bootstrap_hash="$(printf '%s' "${runtime_secret}" | sha256sum)"
+ bootstrap_hash="${bootstrap_hash%% *}"
+ {
+ printf '\nGENARRATIVE_SPACETIME_TOKEN=%s\n' "${spacetime_token}"
+ printf 'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET=%s\n' "${runtime_secret}"
+ } >>"${env_file}"
+ printf '%s\n' "${bootstrap_hash}" >"${STATE_DIR}/bootstrap-secret.sha256"
+ chmod 0600 "${env_file}" "${STATE_DIR}/bootstrap-secret.sha256"
+ spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" login --token "${spacetime_token}" >/dev/null
+ chmod 0600 "${STATE_DIR}/spacetime-cli.toml"
+ printf '[preview-deployer] SpacetimeDB identity: %s...\n' "${spacetime_identity:0:12}"
+ unset identity_payload spacetime_token runtime_secret bootstrap_hash
+}
+
+deploy() {
+ local saved_web source_commit
+ [[ -d "${SOURCE_DIR}/.git" ]] || fail "缺少已经校验的源码 checkout: ${SOURCE_DIR}"
+ [[ -f "${SOURCE_DIR}/deploy/container/docker-compose.loadtest.yml" ]] || fail "目标提交缺少容器 compose 文件。"
+ [[ -f "${SOURCE_DIR}/deploy/container/api-server.env.example" ]] || fail "目标提交缺少容器 env 示例。"
+ source_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify HEAD^{commit})"
+ if [[ -n "${COMMIT_HASH}" ]]; then
+ local requested_commit
+ requested_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify "${COMMIT_HASH}^{commit}")"
+ [[ "${source_commit}" == "${requested_commit}" ]] || fail "源码 checkout 与 COMMIT_HASH 不一致。"
+ fi
+
+ saved_web="$(state_value webPort)"
+ WEB_PORT="${saved_web:-8400}"
+ SPACETIME_PORT=""
+ OTLP_GRPC_PORT=""
+ OTLP_HTTP_PORT=""
+
+ prepare_env
+ write_compose_override
+ compose config --quiet
+ compose build
+ remove_project_resources
+ WEB_PORT="$(allocate_port webPort 8400 8499 "${saved_web}")"
+ write_state "BUILDING" "false" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}"
+ compose up -d spacetimedb otelcol
+ local spacetime_container_id spacetime_ip
+ spacetime_container_id="$(compose ps -q spacetimedb)"
+ [[ -n "${spacetime_container_id}" ]] || fail "未找到 SpacetimeDB 容器。"
+ spacetime_ip="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${spacetime_container_id}")"
+ [[ -n "${spacetime_ip}" ]] || fail "未取得 SpacetimeDB 容器地址。"
+ SPACETIME_PUBLISH_URL="http://${spacetime_ip}:3101"
+ wait_for_url "${SPACETIME_PUBLISH_URL}/v1/ping" || fail "SpacetimeDB 未在超时内就绪。"
+ configure_spacetime_identity
+
+ (
+ cd "${SOURCE_DIR}"
+ # shellcheck disable=SC1091
+ source scripts/jenkins-prepare-toolchain-env.sh
+ export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256
+ GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="$(cat "${STATE_DIR}/bootstrap-secret.sha256")"
+ spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" \
+ publish genarrative-loadtest \
+ --server "${SPACETIME_PUBLISH_URL}" \
+ --module-path server-rs/crates/spacetime-module \
+ --yes=migrate,break-clients \
+ --build-options="--debug" \
+ --no-config
+ )
+
+ compose up -d
+ wait_for_url "http://127.0.0.1:${WEB_PORT}/" || fail "Web 未在超时内就绪。"
+ write_state "RUNNING" "true" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}"
+ local health_attempt
+ for ((health_attempt = 1; health_attempt <= 30; health_attempt += 1)); do
+ render_result
+ if node -e '
+ const result = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
+ if (result.healthStatus !== "HEALTHY") process.exit(1);
+ ' "${RESULT_FILE}"; then
+ return
+ fi
+ sleep 2
+ done
+ fail "容器健康检查未全部通过。"
+}
+
+uninstall() {
+ [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || fail "未找到可卸载的部署状态: ${DEPLOYMENT_ID}"
+ local source_commit web_port spacetime_port otlp_grpc_port otlp_http_port
+ source_commit="$(state_value sourceCommit)"
+ web_port="$(state_value webPort)"
+ spacetime_port="$(state_value spacetimePort)"
+ otlp_grpc_port="$(state_value otlpGrpcPort)"
+ otlp_http_port="$(state_value otlpHttpPort)"
+ remove_project_resources
+ write_state "UNINSTALLED" "false" "${source_commit}" "${web_port}" "${spacetime_port}" "${otlp_grpc_port}" "${otlp_http_port}"
+ render_result "UNINSTALLED"
+}
+
+status() {
+ if [[ ! -f "${STATE_FILE}" ]]; then
+ render_result "NOT_FOUND"
+ return
+ fi
+ render_result
+}
+
+validate_request
+SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+resolve_identity
+ensure_safe_state_root
+validate_existing_state
+
+exec 9>"${LOCK_FILE}"
+flock -x 9
+
+case "${ACTION}" in
+ DEPLOY) deploy ;;
+ UNINSTALL) uninstall ;;
+ STATUS) status ;;
+esac
+
+SCRIPT_FAILED=0
+trap - EXIT
diff --git a/scripts/preview-deployment-status.mjs b/scripts/preview-deployment-status.mjs
new file mode 100644
index 000000000..19a09aa02
--- /dev/null
+++ b/scripts/preview-deployment-status.mjs
@@ -0,0 +1,297 @@
+import { execFileSync } from 'node:child_process';
+import { readFileSync, renameSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+
+const input = parseArgs(process.argv.slice(2));
+const state = readState(input.stateFile);
+const deploymentId = input.deploymentId || state?.deploymentId || '';
+const projectName = input.projectName || state?.projectName || '';
+const action = input.action || 'STATUS';
+const phaseOverride = input.phase || '';
+const containers = projectName ? inspectProjectContainers(projectName) : [];
+const serviceMap = new Map(
+ containers.map((container) => [container.service, container]),
+);
+const webPort = numberOrNull(state?.webPort);
+const webHost = input.webHost || '127.0.0.1';
+const webUrl = webPort ? `http://${webHost}:${webPort}` : null;
+
+const probes = {
+ web: await probe(webPort ? `http://127.0.0.1:${webPort}/` : null),
+ spacetime: probeSpacetimeContainer(serviceMap.get('spacetimedb')),
+};
+
+const requiredServices = [
+ 'spacetimedb',
+ 'api-server',
+ 'external-generation-worker',
+ 'nginx',
+ 'otelcol',
+];
+const services = requiredServices.map((service) => {
+ const container = serviceMap.get(service);
+ return (
+ container || {
+ service,
+ containerId: null,
+ state: 'missing',
+ health: 'missing',
+ }
+ );
+});
+const containersHealthy = services.every(
+ (service) =>
+ service.state === 'running' &&
+ (service.health === 'healthy' || service.health === 'none'),
+);
+const probesHealthy = probes.web.ok && probes.spacetime.ok;
+const active = Boolean(state?.active);
+
+let phase = phaseOverride || state?.phase || 'NOT_FOUND';
+let healthStatus = 'UNKNOWN';
+if (!state) {
+ phase = phaseOverride || 'NOT_FOUND';
+ healthStatus = phase === 'FAILED' ? 'UNHEALTHY' : 'NOT_FOUND';
+} else if (phase === 'UNINSTALLED' || action === 'UNINSTALL') {
+ phase = 'UNINSTALLED';
+ healthStatus = 'UNINSTALLED';
+} else if (phase === 'FAILED') {
+ healthStatus = 'UNHEALTHY';
+} else if (active && containersHealthy && probesHealthy) {
+ phase = 'RUNNING';
+ healthStatus = 'HEALTHY';
+} else if (active || containers.length > 0) {
+ phase = 'UNHEALTHY';
+ healthStatus = 'UNHEALTHY';
+}
+
+const result = {
+ schemaVersion: 1,
+ action,
+ deploymentId,
+ projectName: projectName || null,
+ branch: state?.sourceBranch || input.sourceBranch || null,
+ requestedCommit: state?.requestedCommit || null,
+ resolvedCommit: state?.sourceCommit || null,
+ status: publicStatus(phase),
+ health: publicHealth(healthStatus),
+ sourceBranch: state?.sourceBranch || input.sourceBranch || null,
+ sourceCommit: state?.sourceCommit || null,
+ phase,
+ healthStatus,
+ active: phase === 'RUNNING' || phase === 'UNHEALTHY',
+ webPort,
+ webUrl,
+ ports: {
+ web: webPort,
+ spacetime: null,
+ otlpGrpc: null,
+ otlpHttp: null,
+ },
+ probes,
+ services,
+ message: input.message || defaultMessage(phase),
+ updatedAt: new Date().toISOString(),
+};
+
+writeJsonAtomic(input.output, result);
+if (input.descriptionFile) {
+ const description = [
+ deploymentId || 'unknown',
+ result.sourceBranch || '-',
+ phase,
+ webUrl || '-',
+ ].join(' | ');
+ writeFileSync(input.descriptionFile, `${description}\n`, 'utf8');
+}
+process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+
+function parseArgs(args) {
+ const parsed = {};
+ for (let index = 0; index < args.length; index += 1) {
+ const key = args[index];
+ if (!key.startsWith('--')) {
+ throw new Error(`未知参数: ${key}`);
+ }
+ const value = args[index + 1];
+ if (value === undefined || value.startsWith('--')) {
+ throw new Error(`参数缺少值: ${key}`);
+ }
+ parsed[toCamelCase(key.slice(2))] = value;
+ index += 1;
+ }
+ if (!parsed.output) {
+ throw new Error('必须提供 --output。');
+ }
+ return parsed;
+}
+
+function toCamelCase(value) {
+ return value.replace(/-([a-z])/gu, (_match, character) =>
+ character.toUpperCase(),
+ );
+}
+
+function readState(filePath) {
+ if (!filePath) {
+ return null;
+ }
+ try {
+ return JSON.parse(readFileSync(filePath, 'utf8'));
+ } catch (error) {
+ if (error?.code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ }
+}
+
+function inspectProjectContainers(projectName) {
+ let lines = '';
+ try {
+ lines = execFileSync(
+ 'docker',
+ [
+ 'ps',
+ '-a',
+ '--filter',
+ `label=com.docker.compose.project=${projectName}`,
+ '--format',
+ '{{.ID}}|{{.Label "com.docker.compose.service"}}|{{.State}}',
+ ],
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
+ );
+ } catch {
+ return [];
+ }
+
+ return lines
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [containerId, service, state] = line.split('|');
+ return {
+ service,
+ containerId,
+ state,
+ health: inspectHealth(containerId),
+ };
+ });
+}
+
+function inspectHealth(containerId) {
+ try {
+ const value = execFileSync(
+ 'docker',
+ [
+ 'inspect',
+ '--format',
+ '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}',
+ containerId,
+ ],
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
+ ).trim();
+ return value || 'none';
+ } catch {
+ return 'unknown';
+ }
+}
+
+async function probe(url) {
+ if (!url) {
+ return { ok: false, status: null, elapsedMs: null };
+ }
+ const startedAt = Date.now();
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(3_000) });
+ await response.body?.cancel();
+ return {
+ ok: response.ok,
+ status: response.status,
+ elapsedMs: Date.now() - startedAt,
+ };
+ } catch {
+ return { ok: false, status: null, elapsedMs: Date.now() - startedAt };
+ }
+}
+
+function probeSpacetimeContainer(container) {
+ if (!container?.containerId || container.state !== 'running') {
+ return { ok: false, status: null, elapsedMs: null };
+ }
+ const startedAt = Date.now();
+ try {
+ execFileSync(
+ 'docker',
+ [
+ 'exec',
+ container.containerId,
+ 'spacetime',
+ 'server',
+ 'ping',
+ 'http://127.0.0.1:3101',
+ ],
+ { stdio: 'ignore', timeout: 3_000 },
+ );
+ return { ok: true, status: 200, elapsedMs: Date.now() - startedAt };
+ } catch {
+ return { ok: false, status: null, elapsedMs: Date.now() - startedAt };
+ }
+}
+
+function numberOrNull(value) {
+ const number = Number(value);
+ return Number.isInteger(number) && number > 0 ? number : null;
+}
+
+function defaultMessage(phase) {
+ switch (phase) {
+ case 'RUNNING':
+ return '预览容器运行正常。';
+ case 'UNHEALTHY':
+ return '预览容器已启动,但健康检查未全部通过。';
+ case 'UNINSTALLED':
+ return '预览容器已卸载。';
+ case 'NOT_FOUND':
+ return '未找到对应的预览部署。';
+ case 'FAILED':
+ return '预览部署执行失败,请查看 Jenkins 构建日志。';
+ default:
+ return `预览部署状态:${phase}`;
+ }
+}
+
+function publicStatus(phase) {
+ switch (phase) {
+ case 'RUNNING':
+ case 'UNHEALTHY':
+ return 'running';
+ case 'UNINSTALLED':
+ case 'NOT_FOUND':
+ return 'stopped';
+ default:
+ return 'failed';
+ }
+}
+
+function publicHealth(healthStatus) {
+ switch (healthStatus) {
+ case 'HEALTHY':
+ return 'healthy';
+ case 'UNHEALTHY':
+ return 'unhealthy';
+ default:
+ return 'unknown';
+ }
+}
+
+function writeJsonAtomic(filePath, value) {
+ const absolutePath = path.resolve(filePath);
+ const temporaryPath = `${absolutePath}.tmp-${process.pid}`;
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
+ encoding: 'utf8',
+ mode: 0o600,
+ });
+ renameSync(temporaryPath, absolutePath);
+}
diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock
index d9f12ef32..d4ea42f93 100644
--- a/server-rs/Cargo.lock
+++ b/server-rs/Cargo.lock
@@ -2233,6 +2233,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -4315,6 +4321,25 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "preview-deployer-server"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "http-body-util",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "uuid",
+]
+
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -6318,10 +6343,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.0",
"bytes",
+ "futures-core",
"futures-util",
"http",
"http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index 1274350bf..4684a8528 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -48,6 +48,7 @@ members = [
"crates/platform-speech",
"crates/platform-editor-agent",
"crates/pingora-gateway",
+ "crates/preview-deployer-server",
"crates/server-manager-panel",
"crates/shared-contracts",
"crates/shared-kernel",
diff --git a/server-rs/crates/preview-deployer-server/Cargo.toml b/server-rs/crates/preview-deployer-server/Cargo.toml
new file mode 100644
index 000000000..ed2e8ba3c
--- /dev/null
+++ b/server-rs/crates/preview-deployer-server/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "preview-deployer-server"
+edition.workspace = true
+version.workspace = true
+license.workspace = true
+
+[dependencies]
+axum = { workspace = true }
+reqwest = { workspace = true, features = ["json", "rustls-tls"] }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sha2 = { workspace = true }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal"] }
+tower-http = { workspace = true, features = ["fs", "trace"] }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true, features = ["env-filter"] }
+url = { workspace = true }
+uuid = { workspace = true, features = ["v4"] }
+
+[dev-dependencies]
+http-body-util = { workspace = true }
+tower = { workspace = true, features = ["util"] }
diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs
new file mode 100644
index 000000000..68fae583d
--- /dev/null
+++ b/server-rs/crates/preview-deployer-server/src/config.rs
@@ -0,0 +1,177 @@
+use std::{env, fmt, path::PathBuf, time::Duration};
+
+use url::Url;
+
+const JOB_PATH: &str = "job/shared/job/Genarrative-Preview-Deployer/";
+
+#[derive(Clone)]
+pub struct Config {
+ pub bind_address: String,
+ pub jenkins_root_url: Url,
+ pub jenkins_base_url: Url,
+ pub jenkins_username: String,
+ pub jenkins_api_token: String,
+ pub access_token: String,
+ pub allowed_hosts: Vec,
+ pub allowed_origins: Vec,
+ pub preview_web_host: String,
+ pub secure_cookie: bool,
+ pub static_dir: Option,
+ pub state_file: PathBuf,
+ pub poll_interval: Duration,
+}
+
+impl fmt::Debug for Config {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("Config")
+ .field("bind_address", &self.bind_address)
+ .field("jenkins_root_url", &self.jenkins_root_url)
+ .field("jenkins_base_url", &self.jenkins_base_url)
+ .field(
+ "jenkins_username_configured",
+ &!self.jenkins_username.is_empty(),
+ )
+ .field(
+ "jenkins_api_token_configured",
+ &!self.jenkins_api_token.is_empty(),
+ )
+ .field("access_token_configured", &!self.access_token.is_empty())
+ .field("allowed_hosts", &self.allowed_hosts)
+ .field("allowed_origins", &self.allowed_origins)
+ .field("preview_web_host", &self.preview_web_host)
+ .field("secure_cookie", &self.secure_cookie)
+ .field("static_dir", &self.static_dir)
+ .field("state_file", &self.state_file)
+ .field("poll_interval", &self.poll_interval)
+ .finish()
+ }
+}
+
+impl Config {
+ pub fn from_env() -> Result {
+ let bind_address = required("GENARRATIVE_PREVIEW_DEPLOYER_BIND")?;
+ let mut jenkins_root_url = Url::parse(&required(
+ "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL",
+ )?)
+ .map_err(|_| "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL 不是有效 URL".to_string())?;
+ if !matches!(jenkins_root_url.scheme(), "http" | "https")
+ || jenkins_root_url.host_str().is_none()
+ {
+ return Err("Jenkins base URL 只允许 http/https 绝对 URL".to_string());
+ }
+ if !jenkins_root_url.username().is_empty() || jenkins_root_url.password().is_some() {
+ return Err("Jenkins base URL 不能包含用户名或密码".to_string());
+ }
+ jenkins_root_url.set_query(None);
+ jenkins_root_url.set_fragment(None);
+ if !jenkins_root_url.path().ends_with('/') {
+ let path = format!("{}/", jenkins_root_url.path());
+ jenkins_root_url.set_path(&path);
+ }
+ let jenkins_base_url = jenkins_root_url
+ .join(JOB_PATH)
+ .map_err(|_| "无法构造固定 Jenkins Job URL".to_string())?;
+
+ let access_token = required("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN")?;
+ if access_token.len() < 24 {
+ return Err("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN 至少需要 24 个字符".to_string());
+ }
+ let allowed_hosts = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS")?;
+ let allowed_origins = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS")?;
+ let preview_web_host = required("GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST")?;
+ if preview_web_host.contains(['/', ':', '@']) {
+ return Err(
+ "GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST 只能填写不带协议和端口的主机名或 IP"
+ .to_string(),
+ );
+ }
+ for origin in &allowed_origins {
+ let parsed =
+ Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?;
+ if !matches!(parsed.scheme(), "http" | "https")
+ || parsed.host_str().is_none()
+ || parsed.path() != "/"
+ || parsed.query().is_some()
+ || parsed.fragment().is_some()
+ {
+ return Err(format!(
+ "allowed origin 必须是无路径的 http/https 源: {origin}"
+ ));
+ }
+ }
+
+ let secure_cookie = env::var("GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE")
+ .map(|value| value != "false")
+ .unwrap_or_else(|_| {
+ allowed_origins
+ .iter()
+ .all(|origin| origin.starts_with("https://"))
+ });
+ let static_dir = env::var_os("GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR")
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from);
+ let state_file = PathBuf::from(required("GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE")?);
+ validate_state_file(&state_file)?;
+
+ Ok(Self {
+ bind_address,
+ jenkins_root_url,
+ jenkins_base_url,
+ jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?,
+ jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?,
+ access_token,
+ allowed_hosts,
+ allowed_origins,
+ preview_web_host,
+ secure_cookie,
+ static_dir,
+ state_file,
+ poll_interval: Duration::from_secs(2),
+ })
+ }
+}
+
+fn validate_state_file(path: &std::path::Path) -> Result<(), String> {
+ if !path.is_absolute() || path == std::path::Path::new("/") || path.file_name().is_none() {
+ return Err(
+ "GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE 必须是非根目录绝对文件路径".to_string(),
+ );
+ }
+ let parent = path
+ .parent()
+ .ok_or_else(|| "状态文件缺少父目录".to_string())?;
+ let metadata = std::fs::symlink_metadata(parent)
+ .map_err(|_| "预览部署状态文件父目录必须已存在".to_string())?;
+ if !metadata.is_dir() || metadata.file_type().is_symlink() {
+ return Err("预览部署状态文件父目录必须是普通目录且不能是符号链接".to_string());
+ }
+ if let Ok(metadata) = std::fs::symlink_metadata(path) {
+ if !metadata.is_file() || metadata.file_type().is_symlink() {
+ return Err("预览部署状态文件必须是普通文件且不能是符号链接".to_string());
+ }
+ }
+ Ok(())
+}
+
+fn required(name: &str) -> Result {
+ env::var(name)
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| format!("缺少必需环境变量 {name}"))
+}
+
+fn csv_required(name: &str) -> Result, String> {
+ let values: Vec<_> = required(name)?
+ .split(',')
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned)
+ .collect();
+ if values.is_empty() {
+ Err(format!("{name} 至少需要一个值"))
+ } else {
+ Ok(values)
+ }
+}
diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs
new file mode 100644
index 000000000..fb4f45437
--- /dev/null
+++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs
@@ -0,0 +1,297 @@
+use std::{future::Future, time::Duration};
+
+use reqwest::{Client, StatusCode, header};
+use serde::Deserialize;
+use url::Url;
+
+use crate::{Config, DeploymentStatus, HealthStatus};
+
+#[derive(Clone)]
+pub struct JenkinsClient {
+ http: Client,
+ root_url: Url,
+ job_url: Url,
+ username: String,
+ api_token: String,
+ poll_interval: Duration,
+}
+
+pub enum BuildAction<'a> {
+ Deploy {
+ deployment_id: &'a str,
+ branch: &'a str,
+ commit_hash: Option<&'a str>,
+ },
+ Uninstall {
+ deployment_id: &'a str,
+ branch: &'a str,
+ },
+}
+
+pub struct BuildReference {
+ queue_url: Url,
+}
+
+impl BuildReference {
+ pub fn as_str(&self) -> &str {
+ self.queue_url.as_str()
+ }
+}
+
+pub struct JenkinsOutcome {
+ pub success: bool,
+ pub cancelled: bool,
+ pub result: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PreviewResult {
+ pub schema_version: Option,
+ pub action: Option,
+ #[serde(alias = "id")]
+ pub deployment_id: Option,
+ pub project_name: Option,
+ #[serde(alias = "sourceBranch")]
+ pub branch: Option,
+ #[serde(alias = "sourceCommit")]
+ pub resolved_commit: Option,
+ pub status: Option,
+ pub health: Option,
+ pub phase: Option,
+ pub health_status: Option,
+ pub web_url: Option,
+ pub message: Option,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Crumb {
+ crumb_request_field: String,
+ crumb: String,
+}
+
+#[derive(Deserialize)]
+struct QueueItem {
+ cancelled: Option,
+ executable: Option,
+}
+
+#[derive(Deserialize)]
+struct Executable {
+ url: String,
+}
+
+#[derive(Deserialize)]
+struct BuildState {
+ building: bool,
+ result: Option,
+}
+
+impl JenkinsClient {
+ pub fn new(config: &Config) -> Result {
+ let http = Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .timeout(Duration::from_secs(20))
+ .build()
+ .map_err(|error| format!("无法创建 Jenkins HTTP client: {error}"))?;
+ Ok(Self {
+ http,
+ root_url: config.jenkins_root_url.clone(),
+ job_url: config.jenkins_base_url.clone(),
+ username: config.jenkins_username.clone(),
+ api_token: config.jenkins_api_token.clone(),
+ poll_interval: config.poll_interval,
+ })
+ }
+
+ pub async fn trigger(&self, action: BuildAction<'_>) -> Result {
+ let mut parameters = Vec::new();
+ match action {
+ BuildAction::Deploy {
+ deployment_id,
+ branch,
+ commit_hash,
+ } => {
+ parameters.push(("ACTION", "DEPLOY"));
+ parameters.push(("DEPLOYMENT_ID", deployment_id));
+ parameters.push(("SOURCE_BRANCH", branch));
+ parameters.push(("COMMIT_HASH", commit_hash.unwrap_or("")));
+ }
+ BuildAction::Uninstall {
+ deployment_id,
+ branch,
+ } => {
+ parameters.push(("ACTION", "UNINSTALL"));
+ parameters.push(("DEPLOYMENT_ID", deployment_id));
+ parameters.push(("SOURCE_BRANCH", branch));
+ parameters.push(("COMMIT_HASH", ""));
+ }
+ }
+
+ let trigger_url = self
+ .job_url
+ .join("buildWithParameters")
+ .map_err(|error| error.to_string())?;
+ let mut request = self
+ .http
+ .post(trigger_url)
+ .basic_auth(&self.username, Some(&self.api_token))
+ .form(¶meters);
+ if let Some((field, value)) = self.crumb().await? {
+ let field = header::HeaderName::from_bytes(field.as_bytes())
+ .map_err(|_| "Jenkins crumb header 名称无效".to_string())?;
+ let value = header::HeaderValue::from_str(&value)
+ .map_err(|_| "Jenkins crumb header 值无效".to_string())?;
+ request = request.header(field, value);
+ }
+ let response = request
+ .send()
+ .await
+ .map_err(|error| format!("Jenkins trigger 请求失败: {error}"))?;
+ if !response.status().is_success() {
+ return Err(format!("Jenkins trigger 返回 HTTP {}", response.status()));
+ }
+ let location = response
+ .headers()
+ .get(header::LOCATION)
+ .and_then(|value| value.to_str().ok())
+ .ok_or_else(|| "Jenkins trigger 响应缺少 queue Location".to_string())?;
+ let queue_url = self.resolve_trusted_url(location)?;
+ Ok(BuildReference { queue_url })
+ }
+
+ pub fn restore_reference(&self, value: &str) -> Result {
+ Ok(BuildReference {
+ queue_url: self.resolve_trusted_url(value)?,
+ })
+ }
+
+ async fn crumb(&self) -> Result