修复编辑器定价持久化与资源预览
将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移 修复快速编辑支付弹窗期间框选显示和交互冻结 补齐后台图片放大以及视频音频资源预览 收紧主站和 External 资源换签授权并记录管理员跨用户审计 固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口 加固运行时身份轮换、bootstrap secret 与生产构建发布门禁 同步生成绑定、定向测试、运维脚本和项目文档
This commit is contained in:
@@ -342,9 +342,13 @@ export function updateAdminWorkVisibility(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminAssetReadUrl(query: AdminAssetReadUrlQuery) {
|
||||
export function getAdminAssetReadUrl(
|
||||
token: string,
|
||||
query: AdminAssetReadUrlQuery,
|
||||
) {
|
||||
return request<AdminAssetReadUrlResponse>(
|
||||
`/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
|
||||
`/admin/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -127,12 +128,115 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () =>
|
||||
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith({
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展示', async () => {
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
imageSrc:
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320',
|
||||
objectKey: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
||||
read: {
|
||||
objectKey: 'generated-character-drafts/editor/absolute.png',
|
||||
signedUrl: 'https://signed.example.com/absolute.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
|
||||
await waitFor(() => {
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/absolute.png',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询点击图片缩略图可打开放大预览', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '素材:角色形象 1' }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
||||
const image = within(dialog).getByRole('img', {
|
||||
name: '图片预览:角色形象 1',
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
assetId: 'asset-character-animation-1',
|
||||
label: '角色动作首帧',
|
||||
imageSrc: '/generated-animations/editor/source-1/task-1/frame00.png',
|
||||
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
|
||||
assetKind: 'character-animation',
|
||||
thumbnailSrc:
|
||||
'/generated-animations/editor/source-1/task-1/frame00.png',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
||||
read: {
|
||||
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
|
||||
signedUrl: 'https://signed.example.com/character-animation-frame00.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '素材:角色动作首帧' }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
||||
const image = within(dialog).getByRole('img', {
|
||||
name: '图片预览:角色动作首帧',
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/character-animation-frame00.png',
|
||||
);
|
||||
});
|
||||
expect(within(dialog).queryByLabelText('视频预览:角色动作首帧')).toBeNull();
|
||||
});
|
||||
|
||||
test('后台素材查询音频素材使用统一封面缩略图', async () => {
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
@@ -159,6 +263,194 @@ test('后台素材查询音频素材使用统一封面缩略图', async () => {
|
||||
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('后台素材查询点击音频缩略图后签名音频并展示播放器', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
assetId: 'asset-audio-1',
|
||||
label: '胜利音效',
|
||||
imageSrc: '/generated-editor-audios/sfx.mp3',
|
||||
objectKey: 'generated-editor-audios/sfx.mp3',
|
||||
assetKind: 'sound-effect',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
||||
read: {
|
||||
objectKey: 'generated-editor-audios/sfx.mp3',
|
||||
signedUrl: 'https://signed.example.com/generated-editor-audios/sfx.mp3',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '素材:胜利音效' }),
|
||||
);
|
||||
|
||||
const player = await screen.findByLabelText('音频预览:胜利音效');
|
||||
await waitFor(() => {
|
||||
expect(player.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-editor-audios/sfx.mp3',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-editor-audios/sfx.mp3',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询视频缩略图使用封面并在预览中播放视频', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
assetId: 'asset-video-1',
|
||||
label: '生成视频 1',
|
||||
imageSrc: '/generated-editor-videos/task-1/preview.mp4',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
||||
assetKind: 'editor_video',
|
||||
thumbnailSrc: '/generated-editor-videos/task-1/cover.png',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => {
|
||||
if ('legacyPublicPath' in request) {
|
||||
return {
|
||||
read: {
|
||||
objectKey: 'generated-editor-videos/task-1/cover.png',
|
||||
signedUrl: 'https://signed.example.com/video-cover.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
const objectKey = request.objectKey ?? '';
|
||||
return {
|
||||
read: {
|
||||
objectKey,
|
||||
signedUrl: objectKey.endsWith('.mp4')
|
||||
? 'https://signed.example.com/video-preview.mp4'
|
||||
: 'https://signed.example.com/video-cover-by-key.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', { name: '素材:生成视频 1' });
|
||||
await waitFor(() => {
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/video-cover.png',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
legacyPublicPath: '/generated-editor-videos/task-1/cover.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
|
||||
vi.mocked(getAdminAssetReadUrl).mockClear();
|
||||
await user.click(screen.getByRole('button', { name: '素材:生成视频 1' }));
|
||||
|
||||
const video = await screen.findByLabelText('视频预览:生成视频 1');
|
||||
await waitFor(() => {
|
||||
expect(video.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/video-preview.mp4',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询将无 objectKey 的绝对 OSS 视频和封面分别换签', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
assetId: 'asset-video-2',
|
||||
label: '生成视频 2',
|
||||
imageSrc:
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/preview.mp4?versionId=video',
|
||||
objectKey: null,
|
||||
assetKind: 'editor_video',
|
||||
thumbnailSrc:
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/cover.png?versionId=poster',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => {
|
||||
const legacyPublicPath = request.legacyPublicPath ?? '';
|
||||
return {
|
||||
read: {
|
||||
objectKey: legacyPublicPath.replace(/^\//u, ''),
|
||||
signedUrl: legacyPublicPath.endsWith('/cover.png')
|
||||
? 'https://signed.example.com/video-2-cover.png'
|
||||
: 'https://signed.example.com/video-2-preview.mp4',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const thumbnail = await screen.findByRole('img', {
|
||||
name: '素材:生成视频 2',
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(thumbnail.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/video-2-cover.png',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
|
||||
vi.mocked(getAdminAssetReadUrl).mockClear();
|
||||
await user.click(screen.getByRole('button', { name: '素材:生成视频 2' }));
|
||||
|
||||
const video = await screen.findByLabelText('视频预览:生成视频 2');
|
||||
await waitFor(() => {
|
||||
expect(video.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/video-2-preview.mp4',
|
||||
);
|
||||
expect(video.getAttribute('poster')).toBe(
|
||||
'https://signed.example.com/video-2-cover.png',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
legacyPublicPath: '/generated-editor-videos/task-2/preview.mp4',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询格式化微秒时间文本', async () => {
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
|
||||
@@ -19,7 +19,7 @@ interface AdminEditorAssetQueryPageProps {
|
||||
}
|
||||
|
||||
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
|
||||
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
|
||||
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
|
||||
|
||||
export function AdminEditorAssetQueryPage({
|
||||
token,
|
||||
@@ -36,6 +36,8 @@ export function AdminEditorAssetQueryPage({
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [detailEntry, setDetailEntry] =
|
||||
useState<AdminEditorAssetPayload | null>(null);
|
||||
const [previewEntry, setPreviewEntry] =
|
||||
useState<AdminEditorAssetPayload | null>(null);
|
||||
const [promptPreview, setPromptPreview] = useState<{
|
||||
title: string;
|
||||
prompt: string;
|
||||
@@ -168,11 +170,11 @@ export function AdminEditorAssetQueryPage({
|
||||
<td>
|
||||
<button
|
||||
className="admin-asset-query-thumb-button"
|
||||
title="查看详情"
|
||||
title="预览素材"
|
||||
type="button"
|
||||
onClick={() => setDetailEntry(entry)}
|
||||
onClick={() => setPreviewEntry(entry)}
|
||||
>
|
||||
<AdminAssetThumbnail entry={entry} />
|
||||
<AdminAssetThumbnail entry={entry} token={token} />
|
||||
</button>
|
||||
<small>{entry.label || '-'}</small>
|
||||
</td>
|
||||
@@ -233,7 +235,9 @@ export function AdminEditorAssetQueryPage({
|
||||
{detailEntry ? (
|
||||
<AdminAssetDetailDialog
|
||||
entry={detailEntry}
|
||||
token={token}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
onPreview={(entry) => setPreviewEntry(entry)}
|
||||
onPromptPreview={(entry, prompt) =>
|
||||
setPromptPreview({
|
||||
title: entry.label || entry.assetId,
|
||||
@@ -243,6 +247,14 @@ export function AdminEditorAssetQueryPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewEntry ? (
|
||||
<AdminAssetPreviewDialog
|
||||
entry={previewEntry}
|
||||
token={token}
|
||||
onClose={() => setPreviewEntry(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{promptPreview ? (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
@@ -275,11 +287,18 @@ export function AdminEditorAssetQueryPage({
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) {
|
||||
const isAudio = isAdminAudioAsset(entry);
|
||||
const imageSrc = useAdminResolvedAssetImageSrc(
|
||||
isAudio ? AUDIO_ASSET_COVER_SRC : entry.thumbnailSrc || entry.imageSrc,
|
||||
isAudio ? null : entry.objectKey,
|
||||
function AdminAssetThumbnail({
|
||||
entry,
|
||||
token,
|
||||
}: {
|
||||
entry: AdminEditorAssetPayload;
|
||||
token: string;
|
||||
}) {
|
||||
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
|
||||
const imageSrc = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
thumbnailSource.src,
|
||||
thumbnailSource.objectKey,
|
||||
);
|
||||
const alt = `素材:${entry.label || entry.assetId}`;
|
||||
|
||||
@@ -290,23 +309,89 @@ function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) {
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminAudioAsset(entry: AdminEditorAssetPayload) {
|
||||
function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) {
|
||||
const mediaKind = resolveAdminAssetMediaKind(entry);
|
||||
if (mediaKind === 'audio') {
|
||||
return { src: AUDIO_ASSET_COVER_SRC, objectKey: null };
|
||||
}
|
||||
if (mediaKind === 'video') {
|
||||
return { src: entry.thumbnailSrc || '', objectKey: null };
|
||||
}
|
||||
return {
|
||||
src: entry.thumbnailSrc || entry.imageSrc,
|
||||
objectKey: entry.objectKey,
|
||||
};
|
||||
}
|
||||
|
||||
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
|
||||
|
||||
function resolveAdminAssetMediaKind(
|
||||
entry: AdminEditorAssetPayload,
|
||||
): AdminAssetMediaKind {
|
||||
const pathMediaKind =
|
||||
resolveAdminAssetMediaKindFromPath(entry.imageSrc) ??
|
||||
resolveAdminAssetMediaKindFromPath(entry.objectKey ?? '');
|
||||
if (pathMediaKind) {
|
||||
return pathMediaKind;
|
||||
}
|
||||
|
||||
const assetKind = entry.assetKind?.trim() ?? '';
|
||||
return (
|
||||
if (
|
||||
assetKind === 'sound-effect' ||
|
||||
assetKind === 'background-music' ||
|
||||
assetKind === 'editor_uploaded_audio' ||
|
||||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
|
||||
);
|
||||
assetKind === 'editor_uploaded_audio'
|
||||
) {
|
||||
return 'audio';
|
||||
}
|
||||
if (
|
||||
assetKind === 'video' ||
|
||||
assetKind === 'editor_video' ||
|
||||
assetKind === 'editor-video' ||
|
||||
assetKind === 'editor_uploaded_video'
|
||||
) {
|
||||
return 'video';
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function resolveAdminAssetMediaKindFromPath(
|
||||
value: string,
|
||||
): AdminAssetMediaKind | null {
|
||||
const normalizedValue = value.trim();
|
||||
if (/^data:image\//iu.test(normalizedValue)) {
|
||||
return 'image';
|
||||
}
|
||||
if (/^data:audio\//iu.test(normalizedValue)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (/^data:video\//iu.test(normalizedValue)) {
|
||||
return 'video';
|
||||
}
|
||||
if (
|
||||
/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/iu.test(normalizedValue)
|
||||
) {
|
||||
return 'image';
|
||||
}
|
||||
if (/\.(?:aac|flac|m4a|mp3|ogg|opus|wav)(?:$|[?#])/iu.test(normalizedValue)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (/\.(?:m4v|mov|mp4|ogv|webm)(?:$|[?#])/iu.test(normalizedValue)) {
|
||||
return 'video';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function AdminAssetDetailDialog({
|
||||
entry,
|
||||
token,
|
||||
onClose,
|
||||
onPreview,
|
||||
onPromptPreview,
|
||||
}: {
|
||||
entry: AdminEditorAssetPayload;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onPreview: (entry: AdminEditorAssetPayload) => void;
|
||||
onPromptPreview: (entry: AdminEditorAssetPayload, prompt: string) => void;
|
||||
}) {
|
||||
const promptText = entry.prompt || entry.actualPrompt || '';
|
||||
@@ -332,7 +417,14 @@ function AdminAssetDetailDialog({
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-asset-query-detail-layout">
|
||||
<AdminAssetThumbnail entry={entry} />
|
||||
<button
|
||||
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
|
||||
title="预览素材"
|
||||
type="button"
|
||||
onClick={() => onPreview(entry)}
|
||||
>
|
||||
<AdminAssetThumbnail entry={entry} token={token} />
|
||||
</button>
|
||||
<dl className="admin-info-list admin-detail-list">
|
||||
<AdminInfoItem label="作者">
|
||||
{authorDisplayName(entry)}
|
||||
@@ -385,6 +477,114 @@ function AdminAssetDetailDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAssetPreviewDialog({
|
||||
entry,
|
||||
token,
|
||||
onClose,
|
||||
}: {
|
||||
entry: AdminEditorAssetPayload;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
aria-label="素材预览"
|
||||
className="admin-detail-panel admin-asset-query-preview-dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h3>{entry.label || entry.assetId}</h3>
|
||||
<span>{entry.assetId}</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭素材预览"
|
||||
className="admin-ghost-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<AdminAssetPreviewMedia entry={entry} token={token} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAssetPreviewMedia({
|
||||
entry,
|
||||
token,
|
||||
}: {
|
||||
entry: AdminEditorAssetPayload;
|
||||
token: string;
|
||||
}) {
|
||||
const mediaKind = resolveAdminAssetMediaKind(entry);
|
||||
const isAudio = mediaKind === 'audio';
|
||||
const isVideo = mediaKind === 'video';
|
||||
const mediaSrc = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
entry.imageSrc,
|
||||
entry.objectKey,
|
||||
);
|
||||
const posterSrc = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
isVideo ? (entry.thumbnailSrc ?? '') : '',
|
||||
null,
|
||||
);
|
||||
const label = entry.label || entry.assetId;
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className="admin-asset-query-preview-audio">
|
||||
<img
|
||||
alt={`音频封面:${label}`}
|
||||
className="admin-asset-query-preview-cover"
|
||||
src={AUDIO_ASSET_COVER_SRC}
|
||||
/>
|
||||
{mediaSrc ? (
|
||||
<audio
|
||||
aria-label={`音频预览:${label}`}
|
||||
className="admin-asset-query-preview-player"
|
||||
controls
|
||||
preload="metadata"
|
||||
src={mediaSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-preview-placeholder" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return mediaSrc ? (
|
||||
<video
|
||||
aria-label={`视频预览:${label}`}
|
||||
className="admin-asset-query-preview-media"
|
||||
controls
|
||||
playsInline
|
||||
poster={posterSrc || undefined}
|
||||
preload="metadata"
|
||||
src={mediaSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-preview-placeholder" />
|
||||
);
|
||||
}
|
||||
|
||||
return mediaSrc ? (
|
||||
<img
|
||||
alt={`图片预览:${label}`}
|
||||
className="admin-asset-query-preview-media"
|
||||
src={mediaSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-preview-placeholder" />
|
||||
);
|
||||
}
|
||||
|
||||
function AdminInfoItem({
|
||||
label,
|
||||
children,
|
||||
@@ -400,14 +600,18 @@ function AdminInfoItem({
|
||||
);
|
||||
}
|
||||
|
||||
function useAdminResolvedAssetImageSrc(
|
||||
function useAdminResolvedAssetUrl(
|
||||
token: string,
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
const normalizedImageSrc = imageSrc?.trim() ?? '';
|
||||
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
|
||||
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
|
||||
? normalizedImageSrc
|
||||
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
|
||||
const shouldResolve =
|
||||
Boolean(normalizedObjectKey) || isGeneratedLegacyPath(normalizedImageSrc);
|
||||
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
|
||||
const [resolvedImageSrc, setResolvedImageSrc] = useState(
|
||||
shouldResolve ? '' : normalizedImageSrc,
|
||||
);
|
||||
@@ -426,13 +630,14 @@ function useAdminResolvedAssetImageSrc(
|
||||
setResolvedImageSrc('');
|
||||
|
||||
void getAdminAssetReadUrl(
|
||||
token,
|
||||
normalizedObjectKey
|
||||
? {
|
||||
objectKey: normalizedObjectKey,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
}
|
||||
: {
|
||||
legacyPublicPath: normalizedImageSrc,
|
||||
legacyPublicPath: normalizedLegacyPublicPath,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
)
|
||||
@@ -451,7 +656,13 @@ function useAdminResolvedAssetImageSrc(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve]);
|
||||
}, [
|
||||
normalizedImageSrc,
|
||||
normalizedLegacyPublicPath,
|
||||
normalizedObjectKey,
|
||||
shouldResolve,
|
||||
token,
|
||||
]);
|
||||
|
||||
return resolvedImageSrc;
|
||||
}
|
||||
@@ -464,6 +675,22 @@ function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
}
|
||||
|
||||
function resolveAdminGeneratedLegacyPathFromUrl(value: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(value);
|
||||
if (
|
||||
parsedUrl.protocol !== 'https:' ||
|
||||
!/^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(parsedUrl.hostname)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const legacyPublicPath = decodeURIComponent(parsedUrl.pathname);
|
||||
return isGeneratedLegacyPath(legacyPublicPath) ? legacyPublicPath : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
|
||||
const read = response.read ?? response;
|
||||
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||||
|
||||
@@ -167,6 +167,10 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
|
||||
submittedBefore: null,
|
||||
limit: 80,
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台精选审核格式化微秒时间并显示素材名', async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ interface AdminEditorShowcaseReviewPageProps {
|
||||
}
|
||||
|
||||
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
|
||||
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
|
||||
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
|
||||
|
||||
const showcaseCategoryOptions = [
|
||||
{ value: 'characters', label: '角色' },
|
||||
@@ -345,7 +345,7 @@ export function AdminEditorShowcaseReviewPage({
|
||||
type="button"
|
||||
onClick={() => setDetailEntry(entry)}
|
||||
>
|
||||
<AdminShowcaseThumbnail entry={entry} />
|
||||
<AdminShowcaseThumbnail entry={entry} token={token} />
|
||||
</button>
|
||||
<small>{entry.label || '-'}</small>
|
||||
</td>
|
||||
@@ -597,6 +597,7 @@ export function AdminEditorShowcaseReviewPage({
|
||||
{detailEntry ? (
|
||||
<AdminShowcaseDetailDialog
|
||||
entry={detailEntry}
|
||||
token={token}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
onPromptPreview={(entry, prompt) =>
|
||||
setPromptPreview({
|
||||
@@ -641,11 +642,14 @@ export function AdminEditorShowcaseReviewPage({
|
||||
|
||||
function AdminShowcaseThumbnail({
|
||||
entry,
|
||||
token,
|
||||
}: {
|
||||
entry: AdminEditorShowcaseAssetPayload;
|
||||
token: string;
|
||||
}) {
|
||||
const isAudio = isAdminShowcaseAudioAsset(entry);
|
||||
const imageSrc = useAdminResolvedAssetImageSrc(
|
||||
token,
|
||||
isAudio ? AUDIO_ASSET_COVER_SRC : entry.imageSrc,
|
||||
isAudio ? null : entry.objectKey,
|
||||
);
|
||||
@@ -670,10 +674,12 @@ function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
|
||||
|
||||
function AdminShowcaseDetailDialog({
|
||||
entry,
|
||||
token,
|
||||
onClose,
|
||||
onPromptPreview,
|
||||
}: {
|
||||
entry: AdminEditorShowcaseAssetPayload;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onPromptPreview: (
|
||||
entry: AdminEditorShowcaseAssetPayload,
|
||||
@@ -703,7 +709,7 @@ function AdminShowcaseDetailDialog({
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-asset-query-detail-layout">
|
||||
<AdminShowcaseThumbnail entry={entry} />
|
||||
<AdminShowcaseThumbnail entry={entry} token={token} />
|
||||
<dl className="admin-info-list admin-detail-list">
|
||||
<AdminInfoItem label="作者">
|
||||
{authorDisplayName(entry)}
|
||||
@@ -786,6 +792,7 @@ function AdminInfoItem({
|
||||
}
|
||||
|
||||
function useAdminResolvedAssetImageSrc(
|
||||
token: string,
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
@@ -811,6 +818,7 @@ function useAdminResolvedAssetImageSrc(
|
||||
setResolvedImageSrc('');
|
||||
|
||||
void getAdminAssetReadUrl(
|
||||
token,
|
||||
normalizedObjectKey
|
||||
? {
|
||||
objectKey: normalizedObjectKey,
|
||||
@@ -836,7 +844,7 @@ function useAdminResolvedAssetImageSrc(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve]);
|
||||
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve, token]);
|
||||
|
||||
return resolvedImageSrc;
|
||||
}
|
||||
|
||||
@@ -581,6 +581,7 @@ button:disabled {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb {
|
||||
@@ -1119,17 +1120,20 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-dialog,
|
||||
.admin-asset-query-prompt-dialog {
|
||||
.admin-asset-query-prompt-dialog,
|
||||
.admin-asset-query-preview-dialog {
|
||||
width: min(100%, 860px);
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-dialog .admin-panel-heading > div,
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading > div {
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading > div,
|
||||
.admin-asset-query-preview-dialog .admin-panel-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-dialog .admin-panel-heading span,
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading span {
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading span,
|
||||
.admin-asset-query-preview-dialog .admin-panel-heading span {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 4px;
|
||||
@@ -1149,11 +1153,53 @@ button:disabled {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout > .admin-asset-query-thumb {
|
||||
.admin-asset-query-detail-thumb-button .admin-asset-query-thumb {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.admin-asset-query-preview-dialog {
|
||||
width: min(100%, 960px);
|
||||
}
|
||||
|
||||
.admin-asset-query-preview-media {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: min(72dvh, 680px);
|
||||
margin: 0 auto;
|
||||
border: 1px solid #eaded2;
|
||||
border-radius: 8px;
|
||||
background: #fffdf9;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.admin-asset-query-preview-audio {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.admin-asset-query-preview-cover {
|
||||
width: min(240px, 64vw);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid #eaded2;
|
||||
border-radius: 8px;
|
||||
background: #fffdf9;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.admin-asset-query-preview-player {
|
||||
width: min(100%, 620px);
|
||||
}
|
||||
|
||||
.admin-asset-query-preview-placeholder {
|
||||
min-height: 220px;
|
||||
border: 1px dashed #dfc8b7;
|
||||
border-radius: 8px;
|
||||
background: #fffdf9;
|
||||
}
|
||||
|
||||
.admin-database-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {defineConfig, loadEnv} from 'vite';
|
||||
const adminWebRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(adminWebRoot, '../..');
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
export default defineConfig(({command, mode}) => {
|
||||
const repoEnv = loadEnv(mode, repoRoot, '');
|
||||
const appEnv = loadEnv(mode, adminWebRoot, '');
|
||||
const env = {...repoEnv, ...appEnv};
|
||||
@@ -27,6 +27,7 @@ export default defineConfig(({mode}) => {
|
||||
|
||||
return {
|
||||
root: adminWebRoot,
|
||||
publicDir: command === 'serve' ? resolve(repoRoot, 'public') : false,
|
||||
envDir: repoRoot,
|
||||
base,
|
||||
plugins: [react()],
|
||||
|
||||
Vendored
+2
@@ -67,6 +67,8 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
|
||||
GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101
|
||||
GENARRATIVE_SPACETIME_DATABASE=genarrative-prod
|
||||
GENARRATIVE_SPACETIME_TOKEN=
|
||||
# 只保存受保护文件路径;不要把 bootstrap secret 明文写入生产 env。
|
||||
GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt
|
||||
GENARRATIVE_SPACETIME_POOL_SIZE=8
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# 非 HTTP 角色只保留队列窄订阅唤醒,不需要 API 读模型连接池。
|
||||
|
||||
GENARRATIVE_SPACETIME_POOL_SIZE=1
|
||||
# 与 api-server.env 使用同一受保护文件;不要在 worker env 中保存 secret 明文。
|
||||
GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
# 单次 lease 会由 worker 自动续租;该值覆盖心跳抖动窗口即可。
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
|
||||
|
||||
`/editor/canvas` 右侧画布 Agent 对话面板、会话持久化、SSE 事件、附件与生成落画板例外见 [【编辑器】画布Agent对话面板-2026-07-03.md](./【编辑器】画布Agent对话面板-2026-07-03.md);消息正文存 OSS、元数据进 SpacetimeDB 的取舍见 [【ADR】画布Agent会话消息存OSS-2026-07-03.md](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)。
|
||||
|
||||
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
图片画布生成类面板的模型泥点默认 JSON、SpacetimeDB 运行时事实源、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
|
||||
React 组件测试的用户行为、稳定契约、hook / model 分层断言口径,以及避免内部 DOM 探针、图标 class 和完整对象快照式断言的规则见 [【前端测试】React组件测试准则-2026-06-26.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%B5%8B%E8%AF%95%E3%80%91React%E7%BB%84%E4%BB%B6%E6%B5%8B%E8%AF%95%E5%87%86%E5%88%99-2026-06-26.md)。
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -78,6 +78,14 @@
|
||||
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。
|
||||
|
||||
## 资产换签不能把 generated 前缀当成 objectKey 授权
|
||||
|
||||
- 现象:主站或 External API 只要拿到另一个账号的 generated `objectKey` 就能换签,已登记的私有对象因为 key 同时命中 legacy 前缀而被匿名读取,或者 `/api/assets/read-url` 已拒绝但 `/api/assets/read-bytes` 仍能读出原始字节;后台资源预览为解决跨账号读取又误把主站入口整体放开。
|
||||
- 原因:`legacyPublicPath` 与 `objectKey` 代表两种不同信任边界。前者仅用于未登记历史公开作品兼容,后者是正式对象引用;只检查 generated 前缀、或在查询 `asset_object` metadata 前直接接受 legacy 白名单,都不能证明对象公开或属于调用方。签名 URL 和 bytes proxy 如果各写一套判断也容易漂移。
|
||||
- 处理:`read-url` 与 `read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`;metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;任意 `objectKey` 必须登记。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。
|
||||
- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记 objectKey、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url` 与 `read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。
|
||||
- 关联:`server-rs/crates/api-server/src/assets.rs`、`server-rs/crates/api-server/src/external_assets_api.rs`、`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/api-server/src/modules/admin.rs`。
|
||||
|
||||
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
|
||||
|
||||
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
|
||||
@@ -426,13 +434,13 @@
|
||||
- 顺序补充:正式 runbook 还必须在 `rollback apply` 前预置 Pingora shadow env。启用前和 `--dry-run-cutover` 要求 `80/443` 空闲;启用后 `--require-direct` 复核不再要求端口空闲,因为端口应由 Pingora 占用。回退时要先用 current release 随包 `node -- /opt/genarrative/current/scripts/deploy/pingora-gateway-env-shadow-switch.mjs --apply --env-file /etc/genarrative/pingora-gateway.env` 恢复 `GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081` 并清空 TLS / HTTP redirect 低端口监听,再移除 direct-entry drop-in 和重启 Pingora。
|
||||
- 关联:`deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`、`deploy/env/health-patrol.env.example`、`deploy/env/pingora-direct-live.env.example`、`deploy/env/pingora-canary-live.env.example`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`scripts/deploy/pingora-tls-cert-sync.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/jenkins-server-provision.sh`、`scripts/build-production-release.sh`、`scripts/deploy/production-api-deploy.sh`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`。
|
||||
|
||||
## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等
|
||||
## 外部生成 worker 重领必须按 claim attempt 隔离并持久结算
|
||||
|
||||
- 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。
|
||||
- 原因:资产操作扣费和退款都用稳定 ledger id 做幂等;这能保护 lease 过期后的崩溃重领不重复扣费,但不适合“已明确失败且已退款”的自动业务重试。
|
||||
- 处理:拼图 `puzzle_compile_draft` 首期设置 `max_attempts=1`,业务失败直接 failed,只保留 running lease 过期后的崩溃重领。后续若要恢复自动 retry,必须先引入 attempt-aware billing 或可配对撤销的账本接口。
|
||||
- 验证:检查 `external_generation_job.max_attempts`、worker 失败回写和钱包 ledger;失败后草稿进入 failed,重试应由用户重新触发新任务,而不是旧 job 自动 pending。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
- 现象:同一个外部生成 job 在 worker 崩溃或 lease 过期后重领,可能出现旧 attempt 和新 attempt 都扣费,或者旧 attempt 已退款后新 attempt 因稳定 ledger 被当成幂等而免费执行。
|
||||
- 原因:只按业务资源 ID 或 job ID 生成稳定 ledger 无法区分 claim;仅在新 attempt 开始时“先查旧 consume、存在则退款”仍有竞态,旧 consume RPC 可能在检查之后才提交。
|
||||
- 处理:扣退费 ledger 固定包含 `job_id + claim_attempt`,每次重领先结算所有旧 attempt,再扣当前 attempt。结算必须在 SpacetimeDB `asset_operation_wallet_settlement` 持久化:旧 consume 已存在时原子退款;尚不存在时写取消 intent。任何迟到 consume 在同一事务内看到 intent 后失败关闭。重复 ledger 必须核对用户、金额和来源,不能只按 ID 存在就返回成功。claim 处理 lease 已过期的 `running` job 时还必须先比较 `attempt` 与 `max_attempts`:未耗尽才递增并返回 worker;最终 attempt 已耗尽时在同一事务内把 job 置为 `failed`、清空 lease、写完成时间和失败事件,并按当前 attempt 退款或写取消 intent,绝不能再次返回 provider executor。
|
||||
- 验证:`cargo test -p spacetime-module asset_operation` 覆盖缺 consume 时写 intent、冲突结算拒绝和退款配对;`cargo test -p spacetime-module external_generation::tests::` 覆盖未耗尽 lease 可重领、最终 attempt 只终态收口且不再递增;`cargo test -p spacetime-module wallet_idempotent_replay` 覆盖冲突重放;`cargo test -p api-server asset_billing` 覆盖崩溃重领、重复结算和当前 attempt 扣费。
|
||||
- 关联:`server-rs/crates/api-server/src/asset_billing.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 外部生成队列不再由 HTTP 进程兜底执行
|
||||
|
||||
@@ -2230,6 +2238,15 @@
|
||||
- 验证:重新跑 `Genarrative-Database-Import` 或 `Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain` 的 `node=...` 解析结果,而不是在迁移中途报 `node: command not found`。
|
||||
- 关联:`scripts/jenkins-prepare-toolchain-env.sh`、`jenkins/Jenkinsfile.production-database-import`、`jenkins/Jenkinsfile.production-database-export`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Runtime bootstrap secret 原文不能进入 WASM 或发布归档
|
||||
|
||||
- 现象:下载 Jenkins Stdb artifact 或检查 `spacetime_module.wasm` 能找到原始 bootstrap secret;Stdb Build / Publish 配了不同 credential,或 Publish 使用的 Secret File 摘要与 release manifest 不一致却仍继续发布;或者 module 已发布、`/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt` 也已更新,但模型定价首次初始化、队列 claim 或钱包调用仍报 identity 未授权。
|
||||
- 原因:把原文作为 Rust 编译环境变量会进入可下载 WASM;把 `migration-bootstrap-secret.txt` 归档会把构建凭据变成长生命周期 artifact。只把摘要编进 WASM、却不在 release manifest 绑定摘要并让 Publish 重算核对,仍可能把另一份 secret 配给已构建 module。另一方面,`AppConfig` 只在 api-server / worker 进程启动时读取直传值或 FILE,覆盖文件不会更新已运行进程;Full Build 又先发 Stdb、后发 API,不能等待后续 API deploy 才补旧 env。
|
||||
- 处理:原始 bootstrap secret 固定为 64 位十六进制。WASM 编译只接受 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256`,模块对 procedure 入参原文重新计算 SHA-256 并做常量时间比较。生产 Jenkins Build / Publish 必须使用完全相同的 Secret File credential ID:Build 只计算摘要,WASM、artifact 和 `copyArtifacts` 不含原文,Stdb release manifest 记录 `migration_bootstrap_secret_sha256`;Publish 重新读取同一 Secret File、校验 64 位十六进制并重算摘要,与 manifest 强制匹配后才把临时文件路径交给 `production-stdb-publish.sh`。module 发布后安装固定 runtime 文件为 `root:genarrative 0440`、目录 `root:genarrative 0750`,补齐 API / worker env,再在维护模式内重启发布前 active 的 API、controller 和 worker,并执行 API `/healthz` 门禁。人工构建自动生成的原文只放 gitignored `server-rs/.spacetimedb/build-secrets/<version>.txt`,目录 `0700`、文件 `0600`;本地 dev 的 API token 和按 server/database 作用域 secret 也分别持久化为 `0600` 文件。日志不得 `cat` 或插值打印明文。
|
||||
- 身份轮换:bootstrap secret 只允许空表首次授权,不能重复接管既有 writer。migration operator 与 runtime writer 必须互斥:operator 不能成为 writer,当前 writer 不能被授权为 operator;已有任一 operator 后,bootstrap secret 不得新增或接管 operator。生产 token 确需轮换时,使用 `scripts/deploy/production-runtime-writer-identity-rotate.mjs`,由当前已授权 migration operator 登录态双录新 writer identity、填写操作人和原因;procedure 必须拒绝把新 writer 设为当前 writer 或任一 migration operator,并写 `editor_generation_runtime_identity_rotation` 审计。成功后先核对审计,再切换 token,不得靠重启 API 隐式改 writer。
|
||||
- 验证:运行相关部署脚本 `bash -n`、`node --check scripts/dev.mjs scripts/check-production-ops-guardrails.mjs scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`npm run check:production-ops`,扫描 artifact 清单和 diff,确认 Build / Publish credential ID 一致、manifest 摘要与 Publish Secret File 匹配,并确认不存在 `migration-bootstrap-secret.txt`、原文编译环境变量、`cat` 或生产 env 明文键,同时验证 64 位十六进制规则及手工 secret / 本地 token 文件权限。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/migration.rs`、`scripts/dev.mjs`、`scripts/build-production-release.sh`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-stdb-module-publish`。
|
||||
|
||||
## Windows Jenkins `powershell` step 在 Stdb module 构建里曾触发 CreateProcess error=5
|
||||
|
||||
- 当前状态:已废弃。`Genarrative-Stdb-Module-Build` 已切到 Linux agent,不再执行 Windows PowerShell 流程。
|
||||
@@ -2447,7 +2464,7 @@
|
||||
|
||||
- 现象:`npm run dev` 启动到 api-server 恢复认证投影时,日志出现 `Failed to initiate WebSocket connection ... /v1/database/<db>/subscribe?compression=Brotli: HTTP error: 401 Unauthorized`。
|
||||
- 原因:SpacetimeDB SDK 订阅需要 Web API identity token;本地 `.env.local` 常把 `GENARRATIVE_SPACETIME_TOKEN` 留空,只靠 CLI 登录态 publish 成功并不能让 api-server 的 WebSocket subscribe 获得权限。
|
||||
- 处理:`scripts/dev.mjs` 在 SpacetimeDB 就绪后调用 `/v1/identity` 创建当前进程专用 Web API identity token,并只注入本次 `api-server` 环境;不要把临时 token 写进 `.env.local` 或日志。若仍报 401,先确认是否使用了项目脚本启动、日志是否出现 `已创建本地 Web identity`,以及 `GENARRATIVE_SPACETIME_SERVER_URL` / 数据库名是否指向本次启动的实例。
|
||||
- 处理:`scripts/dev.mjs` 在 SpacetimeDB 就绪后优先读取 `<spacetimeDataDir>/dev-api-identities/<serverSha256>.json`;缺失或不可用时才调用 `/v1/identity` 创建专用 Web API identity token,并以普通 `0600` 文件持久化。token 只注入 `api-server`,不写 `.env.local`、不传 Web / Vite、也不进日志。若仍报 401,先确认是否使用项目脚本启动、记录文件是否因 server 或权限不匹配被重建,以及 `GENARRATIVE_SPACETIME_SERVER_URL` / 数据库名是否指向本次启动的实例。
|
||||
- 验证:`npm run test -- scripts/dev.test.ts`;重新运行 `npm run dev` 后 api-server 启动日志不再出现上述 subscribe 401,`/healthz` 返回 200。
|
||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
|
||||
@@ -58,11 +58,11 @@ npm run check:server-rs-ddd
|
||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。
|
||||
- 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。
|
||||
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并先按配置 bucket 与精确 key 查询 `asset_object`:一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead` 或当前登录 owner 授权;只有同 bucket / key 未登记 metadata 的历史对象,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。任意未登记 `objectKey`、跨 owner 和匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理。
|
||||
- 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。
|
||||
- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/frontend-config`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
|
||||
- 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。
|
||||
- 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset` 中 `source_type = 'generated'` 的素材,支持 `ownerUserId`、`keyword`、`createdAfter`、`createdBefore`、`cursor` 和 `limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。
|
||||
- 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset` 中 `source_type = 'generated'` 的素材,支持 `ownerUserId`、`keyword`、`createdAfter`、`createdBefore`、`cursor` 和 `limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。图片放大、视频和音频预览统一调用仅后台管理员会话可访问的 `GET /admin/api/assets/read-url`;该入口仅为预览允许后台跨 owner 签名,不改变主站 `/api/assets/read-*` 或 External API 的 owner 边界。成功换签后必须写入 `event_key = admin_asset_read_url` 的 `tracking_event`,记录管理员 subject、Object Key / legacy path 和有效期,不记录 signed URL。
|
||||
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
||||
- 拼图:`/api/runtime/puzzle/*`。
|
||||
- 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。
|
||||
@@ -217,10 +217,11 @@ npm run check:server-rs-ddd
|
||||
1. 新用户账号完成注册并成功同步正式认证表后,注册赠送金额读取 `profile_wallet_config.initial_mud_points`;后台通过 `/admin/api/profile/wallet-config` 维护“账号初始泥点数”。未写入配置时默认仍为 `100` 泥点。流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。
|
||||
2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费时优先消耗 `profile_membership.cycle_remaining_points` 中的会员周期限时泥点,再消耗普通永久泥点;扣费流水 `metadata_json` 会记录 `membershipPeriodPointsDelta`、`permanentPointsDelta` 和限时泥点所属 `cycleResetsAtMicros`,退款会按原消费流水优先恢复同一周期的限时泥点,前端不得自行决定扣费桶。
|
||||
3. 每日免费泥点当前由每日任务体系发放,流水来源为 `daily_task_reward`,任务进度和可领取状态按北京时间每日刷新;它不参与会员 `cycle_resets_at`,也不由前端合并进会员周期泥点。
|
||||
4. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;实际扣费真相以后端运行时模型定价配置为准,前端按钮泥点只作为展示。
|
||||
4. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;同步请求以 SpacetimeDB `editor_generation_pricing_config` 当前全局配置计算,外部生成队列则以 `external_generation_job.price_mud_points` 保存的入队价格为准,worker 的扣费、退款、响应和资产成本不得按执行时配置重算。前端按钮泥点只作为展示。
|
||||
5. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||
6. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
7. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKey;objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图,只提交原图或红框序号标注图作为 `sourceImageSrc`。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。
|
||||
6. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
|
||||
7. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
8. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKey;objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图,只提交原图或红框序号标注图作为 `sourceImageSrc`。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。
|
||||
|
||||
## 外部服务与资产
|
||||
|
||||
@@ -233,7 +234,7 @@ npm run check:server-rs-ddd
|
||||
- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物和返回按钮上传 OSS 前只做服务端绿幕去背后处理,避免泛抠图误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
|
||||
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
|
||||
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。
|
||||
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。`editor-agent/` 前缀只用于服务端内部读写画布 Agent 会话消息文档,不属于浏览器直传 legacy public prefix;`/api/assets/direct-upload-tickets` 必须拒绝 `legacyPrefix=editor-agent`,内部读取只允许 `editor-agent/{conversationId}.json` 形态。
|
||||
- OSS:私有 generated path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。请求参数的安全语义不能混用:`legacyPublicPath` 是历史公开作品兼容口,只允许 `platform_oss::LEGACY_PUBLIC_PREFIXES` 中的 curated 前缀匿名换签;`objectKey` 是正式对象引用,绝不能复用该前缀旁路,必须查询 `asset_object` 并校验配置 bucket、精确 key、`PublicRead` 或当前 owner。External OpenAPI 的 `/api/external/v1/assets/read-url` 还必须有 `editor:asset` scope,并始终以 API Key 绑定的 `owner_user_id` 执行同一 owner 校验;后台跨账号预览只能走管理员鉴权后的 `/admin/api/assets/read-url`。`/api/assets/read-bytes` 与主站 read-url 共用完全相同的授权,默认仍应由浏览器使用 signed URL 直读,bytes 只作跨域字节读取 fallback。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。`editor-agent/` 前缀只用于服务端内部读写画布 Agent 会话消息文档,不属于浏览器直传 legacy public prefix;`/api/assets/direct-upload-tickets` 必须拒绝 `legacyPrefix=editor-agent`,内部读取只允许 `editor-agent/{conversationId}.json` 形态。
|
||||
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;编辑器 `screenColor=auto` 的 gpt-5-mini 背景色决策同样必须审计每次已发出的 LLM 调用失败,包括传输 / 超时、上游拒绝、响应体解析、空响应和返回候选外颜色;即使随后降级默认背景色并继续主流程也不得只记 warning。`api-server` 将这些失败映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
|
||||
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
|
||||
|
||||
@@ -297,6 +298,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`AssetObject`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs`
|
||||
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。资产读取必须先查询同 bucket / key metadata,存在时严格执行 `PublicRead` / owner ACL;只有 metadata 不存在的历史对象才能进入 curated legacy 白名单兼容。
|
||||
|
||||
### `auth_identity`
|
||||
|
||||
@@ -464,6 +466,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`DatabaseMigrationOperator`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/migration.rs`
|
||||
- 说明:migration operator 与在线 runtime writer 必须身份互斥。当前 runtime writer 不能被授权为 operator,任何已登记 operator 也不能成为 runtime writer;一旦已有 operator,bootstrap secret 不得新增或接管 operator,后续授权只能由既有 operator 完成。
|
||||
|
||||
### `external_api_key`
|
||||
|
||||
@@ -536,6 +539,20 @@ npm run check:server-rs-ddd
|
||||
- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、提示词、作者和成本文案;上传按钮通过后台受控上传票据把图片写入 OSS,保存时同时落 `image_src`、内部图片 OSS `image_object_key`、`image_width` 和 `image_height`。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示,并按记录的图片宽高决定活动卡比例。
|
||||
- 索引:主键 `config_id`。
|
||||
|
||||
### `editor_generation_pricing_config`
|
||||
|
||||
- Rust 结构体:`EditorGenerationPricingConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec<EditorGenerationModelPricing>` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 记录首次初始化的真实 `ctx.sender()`;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时 `api-server` 通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。
|
||||
- 索引:主键 `config_id`。
|
||||
|
||||
### `editor_generation_runtime_identity_rotation`
|
||||
|
||||
- Rust 结构体:`EditorGenerationRuntimeIdentityRotation`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:模型生成运行时服务 identity 显式轮换审计表。只有已授权迁移操作员可调用 `rotate_editor_generation_runtime_service_identity_and_return`,且新 writer 不能等于当前 writer,也不能是任一已登记 migration operator。每次记录旧 writer、新 writer、迁移操作员 identity、操作人、原因和服务端时间;轮换只修改 writer,不覆盖已有模型价格。生产人工入口为 `scripts/deploy/production-runtime-writer-identity-rotate.mjs`,CLI 会核对当前登录 operator identity 并要求双录新 identity。
|
||||
- 索引:自增主键 `rotation_id`。
|
||||
|
||||
### `inventory_slot`
|
||||
|
||||
- Rust 结构体:`InventorySlot`
|
||||
@@ -759,6 +776,13 @@ npm run check:server-rs-ddd
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 说明:账号钱包流水表。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`。
|
||||
|
||||
### `asset_operation_wallet_settlement`
|
||||
|
||||
- Rust 结构体:`AssetOperationWalletSettlement`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 说明:资产操作 consume/refund 配对结算事实表,主键为 consume ledger ID,并保存配对 refund ledger、用户、金额和结算时间。退款先到且 consume 尚不可见时,该表作为持久化取消 intent;迟到 consume 必须检测该行并拒绝扣费,避免 worker 崩溃重领期间双扣。
|
||||
- 索引:主键 `consume_ledger_id`。
|
||||
|
||||
### `profile_wallet_config`
|
||||
|
||||
- Rust 结构体:`ProfileWalletConfig`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,10 +7,10 @@
|
||||
## 配置来源
|
||||
|
||||
- 默认配置文件:`server-rs/crates/api-server/config/editor-generation-pricing.default.json`。
|
||||
- 运行时覆盖文件:默认 `.app/editor-generation-pricing.override.json`。
|
||||
- 生产或特殊环境可通过 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 指定可写覆盖文件路径。
|
||||
- 运行时事实源:SpacetimeDB `editor_generation_pricing_config` 全局配置表,固定 `config_id = global`,以强类型 `models` 列保存模型、单位、单价和档位列表。
|
||||
- 旧运行时覆盖文件仅作为迁移兼容种子读取;当前兼容路径为 `/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json`,并在默认新路径不存在时兼容读取旧 `.app/editor-generation-pricing.override.json`。后台保存不再写文件。
|
||||
|
||||
默认文件进入 Git,作为空 override 或 override 丢失时的兜底。覆盖文件属于运行态配置,不提交 Git。
|
||||
默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。首次读取发现表为空时,`api-server` 会用当前本地缓存尝试写入 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。
|
||||
|
||||
## 配置结构
|
||||
|
||||
@@ -49,13 +49,29 @@
|
||||
|
||||
后端保存前校验当前正式模型、必要尺寸和必要分辨率都存在且大于 0。
|
||||
|
||||
SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝重复模型、重复档位和单位不匹配。procedure 与 `spacetime-client` 之间传递强类型模型 / 档位列表,不传递 `pricing_json` 字符串。
|
||||
|
||||
## 后端契约
|
||||
|
||||
- `GET /api/editor/generation-pricing`:主站读取当前模型定价。
|
||||
- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。
|
||||
- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 override 文件。
|
||||
- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 SpacetimeDB `editor_generation_pricing_config`;只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。
|
||||
|
||||
后端 `AppState` 启动时加载默认配置和 override。所有会调用外部生成 provider 的编辑器生成请求都必须由后端以运行时配置重新计算价格,前端请求不提交价格字段;计算完成后统一进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。
|
||||
后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为 `writer_identity`;表已存在时 initializer 只接受同一 writer,bootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。
|
||||
|
||||
所有会调用外部生成 provider 的编辑器生成请求都必须由后端计算价格,前端请求不提交价格字段;同步执行按当前运行时配置进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。外部生成队列在入队时把价格写入 `external_generation_job.price_mud_points`,worker 必须用该冻结价格完成扣费、退款、响应和资产成本持久化,配置更新不得改变已入队任务金额。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。
|
||||
|
||||
## 运行时身份首次授权
|
||||
|
||||
模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operator,bootstrap secret 不得再新增或接管 operator。
|
||||
|
||||
运行时 token 轮换必须由已授权迁移操作员显式调用 `rotate_editor_generation_runtime_service_identity_and_return`,传入新 identity、操作员用户 ID 和原因。该 procedure 会拒绝把新 writer 设成当前 writer 或任一已登记 migration operator,只修改 writer,不覆盖模型价格,并向 `editor_generation_runtime_identity_rotation` 写入旧 writer、新 writer、迁移操作员 identity、操作人、原因和服务端时间。生产使用 `scripts/deploy/production-runtime-writer-identity-rotate.mjs`:要求当前 SpacetimeDB CLI 登录 identity 与 `--operator-identity` 一致,并通过 `--next-writer-identity` / `--confirm-next-writer-identity` 双录确认;执行成功后必须核对审计行,再切换 API token。不得把 API 启动、普通定价 upsert 或 bootstrap secret 复用成隐式轮换流程。
|
||||
|
||||
- 本地 `npm run dev` 持久复用专用 API identity token 和 runtime bootstrap secret,分别写入 `<spacetimeDataDir>/dev-api-identities/<serverSha256>.json` 与 `<spacetimeDataDir>/dev-runtime-service-bootstrap-secrets/<scopeSha256>.json`;两个 gitignored 文件都必须是普通文件且权限为 `0600`。token 和 secret 只进入 api-server,不得输出明文,也不得继续传给 Web / Vite 子进程。
|
||||
- 生产环境只配置 `GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt`,不得把 `GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET` 明文写入 env 文件。直传值优先于 FILE,因此生产 env 中残留直传值必须视为配置错误。
|
||||
- `Genarrative-Stdb-Module-Build` 仅通过受保护 Jenkins Secret File 读取并校验 64 位十六进制原始 secret,构建 shell 计算 SHA-256 后只把 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 交给 Rust;WASM、Jenkins artifact 和 `copyArtifacts` 过滤器都不包含原文,Stdb `release-manifest.json` 只记录非敏感的 `migration_bootstrap_secret_sha256`。`Genarrative-Stdb-Module-Publish` 在发布阶段重新挂载同一个 Secret File,重算摘要并与 manifest 强制匹配后才允许发布;Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 必须完全相同,并把文件路径传给 `production-stdb-publish.sh`。module 发布成功后安装为 `root:genarrative 0440`,目录为 `root:genarrative 0750`,目标路径和文件都不能是符号链接。Full Build 中 Stdb 先于 API 发布,因此同一凭据 ID 必须同时透传给 build / publish,Stdb publish 还必须向 API / worker env 原子补齐固定 FILE 路径,覆盖首次 rollout 的旧环境文件。
|
||||
- 替换 FILE 后必须在退出维护模式前重启已运行的 API、外部生成 controller 和 worker。`AppConfig` 只在进程启动时读取 secret,单纯覆盖文件不会刷新已运行进程。
|
||||
- 人工运行 `build-production-release.sh` 且未显式提供 secret / SHA-256 时,脚本把自动生成的 64 位十六进制原始 secret 仅写入 gitignored 的 `server-rs/.spacetimedb/build-secrets/<version>.txt`,目录权限 `0700`、文件权限 `0600`;发布包只含带摘要的 WASM,并在 manifest 记录摘要,不含该文件。Jenkins 日志只能记录来源、长度、目标路径和是否启用,不得 `cat` 或插值打印原文。
|
||||
|
||||
## 管理端
|
||||
|
||||
@@ -73,6 +89,8 @@
|
||||
|
||||
## 验证
|
||||
|
||||
- 后端配置解析、override、路由保存与公开读取测试。
|
||||
- 后端配置解析、模块强校验与 writer 授权、SpacetimeDB 保存 / 重启读取、路由保存与公开读取测试。
|
||||
- 外部生成任务在配置变更前后仍按入队价格扣费并落资产的测试;旧 attempt 退款先于迟到 consume 时,持久化 settlement intent 必须阻止迟到扣费;最终 attempt lease 过期后必须直接失败结算且不再返回 provider executor。
|
||||
- 部署脚本 Bash 语法、生产运维静态门禁、64 位十六进制 secret 校验、manifest 摘要匹配、Build / Publish credential ID 一致性、bootstrap secret 明文日志扫描和 FILE 权限 / 服务重启检查。
|
||||
- 前端价格读取、运行时覆盖、图片尺寸档位计算测试。
|
||||
- 管理端模型定价页面单位展示和档位保存测试。
|
||||
|
||||
@@ -78,8 +78,9 @@
|
||||
- 画板内所有会提交外部生成任务的按钮,展示价格都必须从模型定价配置函数推导,不允许在按钮文案中散落固定泥点数字;生成请求不提交 `priceMudPoints`,修改后端模型定价配置后,后端实际扣费和前端下一次拉取到的按钮展示应同步变化。
|
||||
- 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost`:`/api/editor/images/generations`、`/api/editor/images/edits`、`/api/editor/icon-spritesheets/generations`、`/api/editor/ui-designs/assets/extractions`、`/api/editor/videos/generations`、`/api/editor/character-animations/generations`、`/api/editor/audios/sound-effects/generations`、`/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。
|
||||
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。
|
||||
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整 override 到 `.app/editor-generation-pricing.override.json`(可由 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 覆盖路径),主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
|
||||
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整配置到 SpacetimeDB `editor_generation_pricing_config` 全局表,主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
|
||||
- 生成图标素材、生成视频、角色动画、音效和背景音乐请求只提交生成参数,不提交价格字段;后端按归一后的模型、清晰度、时长或音频模型重新计算并扣费。
|
||||
- 进入外部生成队列的任务在入队时冻结后端计算出的价格;worker 的预扣、退款、响应 `priceMudPoints` 和资产 `generationCostMudPoints` 必须使用同一入队价格,后台修改模型定价只影响之后入队的任务。
|
||||
- `提取素材` 点击后进入 UI 素材提取态:右侧框选工具对齐底部工具栏按钮风格,素材下方显示与生成新素材一致宽度的提取面板。提取面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮;前端提交 `aspectRatio / imageSize` 等生成参数,后端按 `editor_generation_config` 计算扣费,不能写死。
|
||||
- 画板 UI 统一显示 `nanobanana2`,它代表上游真实模型 `gemini-3.1-flash-image-preview`;历史输入或旧布局中的 `nano-banana` 必须先归一为 `nanobanana2` 对应的真实模型 ID 后再提交和计费。定价表仍以真实模型 ID `gemini-3.1-flash-image-preview` 和 `gpt-image-2` 为准,别名不能作为新增正式模型绕过定价表。
|
||||
|
||||
@@ -128,7 +129,7 @@
|
||||
生成背景音乐:chirp-v5 按次 12 泥点
|
||||
```
|
||||
|
||||
当前必须显式覆盖的正式模型定价配置(默认值来自 `editor-generation-pricing.default.json`,运行时可由后台 override 修改):
|
||||
当前必须显式覆盖的正式模型定价配置(默认值来自 `editor-generation-pricing.default.json`,运行时事实源为 SpacetimeDB `editor_generation_pricing_config`,可由后台模型定价页面修改):
|
||||
|
||||
- 图片模型:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)必须配置 `0.5K / 1K / 2K`;`gpt-image-2` 必须配置 `1K / 2K`。
|
||||
- 视频模型:`seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni` 按 `480p / 720p / 1080p` 配每秒泥点。兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留同样分辨率定价配置,但前端模型菜单不展示。
|
||||
|
||||
@@ -22,6 +22,7 @@ pipeline {
|
||||
string(name: 'SERVICE_NAME', defaultValue: 'genarrative-api.service', description: 'systemd 服务名')
|
||||
string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/readyz', description: '本机 readiness 检查地址')
|
||||
string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件')
|
||||
string(name: 'WORKER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-worker.env', description: '外部生成 worker 环境文件')
|
||||
string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'api-server 连接的 SpacetimeDB database')
|
||||
string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'api-server 连接的 SpacetimeDB server URL')
|
||||
}
|
||||
@@ -51,8 +52,11 @@ pipeline {
|
||||
if (!(params.DATABASE.trim() ==~ /^[a-z0-9]+(-[a-z0-9]+)*$/)) {
|
||||
error("DATABASE 必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*\$: ${params.DATABASE}")
|
||||
}
|
||||
if (!params.API_ENV_FILE?.trim()) {
|
||||
error('API_ENV_FILE 不能为空。')
|
||||
[API_ENV_FILE: params.API_ENV_FILE, WORKER_ENV_FILE: params.WORKER_ENV_FILE].each { name, value ->
|
||||
def envFile = value?.trim()
|
||||
if (!envFile || envFile == '/' || !(envFile ==~ /^\/(?!.*\.\.)[A-Za-z0-9._\/-]+$/)) {
|
||||
error("${name} 必须是 Linux 绝对路径且不能包含 ..: ${envFile}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +110,7 @@ pipeline {
|
||||
"${pingora_deploy_args[@]}" \
|
||||
--health-url "${HEALTH_URL}" \
|
||||
--api-env-file "${API_ENV_FILE:-/etc/genarrative/api-server.env}" \
|
||||
--worker-env-file "${WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}" \
|
||||
--database "${DATABASE}" \
|
||||
--spacetime-server-url "${SPACETIME_SERVER_URL:-http://127.0.0.1:3101}"
|
||||
'
|
||||
|
||||
@@ -24,7 +24,7 @@ pipeline {
|
||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
||||
booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci')
|
||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,透传给 Stdb module 构建的迁移 bootstrap secret 凭据 ID;留空则由 Stdb 构建自动生成')
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '必填:Stdb module 构建与发布共用的 Jenkins Secret File 凭据 ID')
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'API release 是否构建、归档并部署 Pingora 影子网关;release 默认必须包含')
|
||||
string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名')
|
||||
string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
|
||||
@@ -38,6 +38,8 @@ pipeline {
|
||||
string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'Stdb 发布目标 URL;默认避开本机 Git/Web 使用的 3000 端口')
|
||||
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'Stdb 发布使用的 spacetime CLI root-dir')
|
||||
string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: 'Stdb 发布使用的本机用户')
|
||||
string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'API 与 Stdb publish 共用的 api-server 环境文件')
|
||||
string(name: 'WORKER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-worker.env', description: 'API 与 Stdb publish 共用的 worker 环境文件')
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -96,6 +98,9 @@ pipeline {
|
||||
script {
|
||||
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
|
||||
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
||||
if (!params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
||||
error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')
|
||||
}
|
||||
if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) {
|
||||
error('release 部署需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。当前 Linux 开发/构建/开发部署 agent 不能执行 release 部署。')
|
||||
}
|
||||
@@ -172,10 +177,13 @@ pipeline {
|
||||
string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT),
|
||||
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
|
||||
string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID),
|
||||
string(name: 'DATABASE', value: params.DATABASE),
|
||||
string(name: 'SPACETIME_SERVER_URL', value: params.SPACETIME_SERVER_URL ?: ''),
|
||||
string(name: 'SPACETIME_ROOT_DIR', value: params.SPACETIME_ROOT_DIR ?: '/stdb'),
|
||||
string(name: 'SPACETIME_RUN_AS_USER', value: params.SPACETIME_RUN_AS_USER ?: 'spacetimedb'),
|
||||
string(name: 'API_ENV_FILE', value: params.API_ENV_FILE ?: '/etc/genarrative/api-server.env'),
|
||||
string(name: 'WORKER_ENV_FILE', value: params.WORKER_ENV_FILE ?: '/etc/genarrative/external-generation-worker.env'),
|
||||
string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET),
|
||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT),
|
||||
string(name: 'BUILD_JOB_NAME', value: params.STDB_BUILD_JOB_NAME),
|
||||
@@ -199,6 +207,8 @@ pipeline {
|
||||
string(name: 'BUILD_JOB_NAME', value: params.API_BUILD_JOB_NAME),
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.API_BUILD_NUMBER),
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY),
|
||||
string(name: 'API_ENV_FILE', value: params.API_ENV_FILE ?: '/etc/genarrative/api-server.env'),
|
||||
string(name: 'WORKER_ENV_FILE', value: params.WORKER_ENV_FILE ?: '/etc/genarrative/external-generation-worker.env'),
|
||||
string(name: 'DATABASE', value: params.DATABASE),
|
||||
string(name: 'SPACETIME_SERVER_URL', value: params.SPACETIME_SERVER_URL ?: ''),
|
||||
]
|
||||
|
||||
@@ -25,7 +25,7 @@ pipeline {
|
||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
|
||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,复用既有迁移 bootstrap secret 的 Jenkins Secret Text 凭据 ID;留空则本次构建自动生成')
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '必填:与生产模块绑定的 Jenkins Secret File 凭据 ID;仅在受控构建和发布阶段短暂挂载')
|
||||
booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Stdb module 发布')
|
||||
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb module 发布流水线作业名')
|
||||
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
||||
@@ -108,6 +108,21 @@ pipeline {
|
||||
export SCCACHE_DIR="${stdb_cache_root}/sccache"
|
||||
export SCCACHE_CACHE_SIZE=30G
|
||||
mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}"
|
||||
if [[ ! -f "${MIGRATION_BOOTSTRAP_SECRET_FILE:-}" || -L "${MIGRATION_BOOTSTRAP_SECRET_FILE}" || ! -r "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
echo "[stdb-build] 缺少受保护的 migration bootstrap secret File credential。" >&2
|
||||
exit 1
|
||||
fi
|
||||
migration_bootstrap_secret="$(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")"
|
||||
carriage_return="$(printf "\\r")"
|
||||
migration_bootstrap_secret="${migration_bootstrap_secret%${carriage_return}}"
|
||||
if [[ ! "${migration_bootstrap_secret}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[stdb-build] migration bootstrap secret 必须是 64 位十六进制高熵值。" >&2
|
||||
exit 1
|
||||
fi
|
||||
digest_line="$(printf "%s" "${migration_bootstrap_secret}" | sha256sum)"
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${digest_line%% *}"
|
||||
unset migration_bootstrap_secret digest_line carriage_return
|
||||
trap "unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256" EXIT
|
||||
chmod +x scripts/jenkins-prepare-cargo-env.sh
|
||||
source scripts/jenkins-prepare-cargo-env.sh
|
||||
if ! command -v sccache >/dev/null 2>&1; then
|
||||
@@ -119,13 +134,12 @@ pipeline {
|
||||
'
|
||||
'''
|
||||
}
|
||||
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
||||
withCredentials([
|
||||
string(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET')
|
||||
]) {
|
||||
buildStep()
|
||||
}
|
||||
} else {
|
||||
if (!params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
||||
error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')
|
||||
}
|
||||
withCredentials([
|
||||
file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')
|
||||
]) {
|
||||
buildStep()
|
||||
}
|
||||
}
|
||||
@@ -134,8 +148,7 @@ pipeline {
|
||||
|
||||
stage('Archive') {
|
||||
steps {
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/database-backup-to-oss.mjs", fingerprint: true
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt", fingerprint: false
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +165,7 @@ pipeline {
|
||||
string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT),
|
||||
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
|
||||
string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID),
|
||||
string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET),
|
||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT),
|
||||
string(name: 'BUILD_JOB_NAME', value: env.JOB_NAME),
|
||||
|
||||
@@ -13,6 +13,7 @@ pipeline {
|
||||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支')
|
||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit')
|
||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '必填:与目标 wasm 一致的 Jenkins Secret File 凭据 ID;仅在发布时受保护挂载')
|
||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
||||
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名')
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号')
|
||||
@@ -21,6 +22,8 @@ pipeline {
|
||||
string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER')
|
||||
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;需与自托管 spacetimedb.service 一致')
|
||||
string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: '执行 spacetime publish 的本机用户,默认使用自托管服务用户')
|
||||
string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: '需补齐 runtime bootstrap secret FILE 的 api-server 环境文件')
|
||||
string(name: 'WORKER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-worker.env', description: '需补齐 runtime bootstrap secret FILE 的 worker 环境文件;文件不存在时跳过')
|
||||
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布')
|
||||
choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: '数据库备份策略:async 在 publish 前生成本地冷备份、后台上传 OSS;sync 在 publish 前等待上传完成且失败阻断;skip 跳过')
|
||||
}
|
||||
@@ -38,12 +41,18 @@ pipeline {
|
||||
if (!params.BUILD_VERSION?.trim()) {
|
||||
error('BUILD_VERSION 不能为空。')
|
||||
}
|
||||
if (params.BUILD_VERSION != params.BUILD_VERSION.trim() || !(params.BUILD_VERSION ==~ /^[A-Za-z0-9][A-Za-z0-9._-]*$/)) {
|
||||
error("BUILD_VERSION 只能包含字母、数字、点、下划线和短横线,且必须以字母或数字开头: ${params.BUILD_VERSION}")
|
||||
}
|
||||
if (!params.BUILD_JOB_NAME?.trim()) {
|
||||
error('BUILD_JOB_NAME 不能为空。')
|
||||
}
|
||||
if (!params.BUILD_NUMBER_TO_DEPLOY?.trim()) {
|
||||
error('BUILD_NUMBER_TO_DEPLOY 不能为空。')
|
||||
}
|
||||
if (!params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
||||
error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')
|
||||
}
|
||||
if (!params.DATABASE?.trim()) {
|
||||
error('DATABASE 不能为空。')
|
||||
}
|
||||
@@ -58,6 +67,12 @@ pipeline {
|
||||
if (spacetimeRunAsUser && !(spacetimeRunAsUser ==~ /^[A-Za-z_][A-Za-z0-9_-]*$/)) {
|
||||
error("SPACETIME_RUN_AS_USER 只能是本机用户名: ${spacetimeRunAsUser}")
|
||||
}
|
||||
[API_ENV_FILE: params.API_ENV_FILE, WORKER_ENV_FILE: params.WORKER_ENV_FILE].each { name, value ->
|
||||
def envFile = value?.trim()
|
||||
if (!envFile || envFile == '/' || !(envFile ==~ /^\/(?!.*\.\.)[A-Za-z0-9._\/-]+$/)) {
|
||||
error("${name} 必须是 Linux 绝对路径且不能包含 ..: ${envFile}")
|
||||
}
|
||||
}
|
||||
def spacetimeServerUrl = params.SPACETIME_SERVER_URL?.trim()
|
||||
if (spacetimeServerUrl && !(spacetimeServerUrl ==~ /^https?:\/\/[A-Za-z0-9._:-]+$/)) {
|
||||
error("SPACETIME_SERVER_URL 只能是 http(s) URL,且不能包含路径或 shell 特殊字符: ${spacetimeServerUrl}")
|
||||
@@ -78,7 +93,7 @@ pipeline {
|
||||
copyArtifacts(
|
||||
projectName: params.BUILD_JOB_NAME,
|
||||
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
||||
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/database-backup-to-oss.mjs",
|
||||
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/database-backup-to-oss.mjs",
|
||||
target: '.',
|
||||
fingerprintArtifacts: true
|
||||
)
|
||||
@@ -115,20 +130,27 @@ pipeline {
|
||||
def serverArg = params.SPACETIME_SERVER_URL?.trim()
|
||||
? "--server-url \"${params.SPACETIME_SERVER_URL.trim()}\""
|
||||
: "--server \"${params.SPACETIME_SERVER}\""
|
||||
sh """
|
||||
bash -lc '
|
||||
withCredentials([
|
||||
file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')
|
||||
]) {
|
||||
sh """
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
chmod +x scripts/deploy/production-stdb-publish.sh scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh
|
||||
chmod +x scripts/deploy/production-stdb-publish.sh scripts/deploy/production-runtime-writer-identity-rotate.mjs scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh
|
||||
scripts/deploy/production-stdb-publish.sh \\
|
||||
--source-dir "build/${params.BUILD_VERSION}" \\
|
||||
--database "${params.DATABASE}" \\
|
||||
${rootArg} \\
|
||||
${runAsArg} \\
|
||||
${serverArg} \\
|
||||
--migration-bootstrap-secret-file "\${MIGRATION_BOOTSTRAP_SECRET_FILE:?MIGRATION_BOOTSTRAP_SECRET_FILE 不能为空}" \\
|
||||
--api-env-file "${params.API_ENV_FILE}" \\
|
||||
--worker-env-file "${params.WORKER_ENV_FILE}" \\
|
||||
${clearArg} \\
|
||||
${backupArg}
|
||||
'
|
||||
"""
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,16 @@ generate_migration_bootstrap_secret() {
|
||||
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||||
}
|
||||
|
||||
persist_generated_migration_bootstrap_secret() {
|
||||
local secret_dir="${SERVER_RS_DIR}/.spacetimedb/build-secrets"
|
||||
local secret_path="${secret_dir}/${BUILD_NAME}.txt"
|
||||
mkdir -p "${secret_dir}"
|
||||
chmod 700 "${secret_dir}" 2>/dev/null || true
|
||||
(umask 077; printf '%s\n' "${MIGRATION_BOOTSTRAP_SECRET}" >"${secret_path}")
|
||||
chmod 600 "${secret_path}" 2>/dev/null || true
|
||||
echo "[production-release] 自动生成的迁移引导密钥仅写入本机受保护文件: ${secret_path}"
|
||||
}
|
||||
|
||||
prepare_migration_bootstrap_secret() {
|
||||
local secret_source="generated"
|
||||
|
||||
@@ -114,48 +124,43 @@ prepare_migration_bootstrap_secret() {
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256
|
||||
echo "[production-release] 未启用迁移引导密钥。"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:-}" ]]; then
|
||||
if [[ ! "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[production-release] 迁移引导密钥 SHA-256 必须是 64 位十六进制。" >&2
|
||||
exit 1
|
||||
fi
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256,,}"
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
echo "[production-release] 已准备迁移引导密钥摘要: source=sha256-environment"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET:-}" ]]; then
|
||||
MIGRATION_BOOTSTRAP_SECRET="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET}"
|
||||
secret_source="environment"
|
||||
else
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
persist_generated_migration_bootstrap_secret
|
||||
fi
|
||||
|
||||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||
echo "[production-release] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||
if [[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[production-release] 迁移引导密钥必须是 64 位十六进制高熵值。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local digest_line
|
||||
digest_line="$(printf '%s' "${MIGRATION_BOOTSTRAP_SECRET}" | sha256sum)"
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${digest_line%% *}"
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
|
||||
echo "[production-release] 已准备迁移引导密钥: source=${secret_source}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
write_migration_bootstrap_secret_file() {
|
||||
local target_path="${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||
|
||||
if [[ "${BUILD_SPACETIME}" -ne 1 || "${SKIP_SPACETIME_BUILD}" -eq 1 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "${MIGRATION_BOOTSTRAP_SECRET}" ]]; then
|
||||
echo "[production-release] 迁移引导密钥为空,无法写入发布产物。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${target_path}"
|
||||
chmod 600 "${target_path}" 2>/dev/null || true
|
||||
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=1
|
||||
echo "[production-release] 已写入迁移引导密钥文件: ${target_path}"
|
||||
}
|
||||
|
||||
write_release_manifest() {
|
||||
RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \
|
||||
RELEASE_VERSION="${BUILD_NAME}" \
|
||||
@@ -167,7 +172,7 @@ write_release_manifest() {
|
||||
RELEASE_INCLUDE_API="${BUILD_API}" \
|
||||
RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \
|
||||
RELEASE_INCLUDE_PINGORA_GATEWAY="${INCLUDE_PINGORA_GATEWAY}" \
|
||||
RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET_ARTIFACT}" \
|
||||
RELEASE_MIGRATION_BOOTSTRAP_SECRET_SHA256="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:-}" \
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -200,20 +205,14 @@ if (process.env.RELEASE_INCLUDE_PINGORA_GATEWAY === '1') {
|
||||
checksum_path: 'pingora-gateway.sha256',
|
||||
});
|
||||
}
|
||||
if (process.env.RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET === '1') {
|
||||
artifacts.push({
|
||||
component: 'spacetime-module',
|
||||
path: 'migration-bootstrap-secret.txt',
|
||||
sensitive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: process.env.RELEASE_VERSION,
|
||||
source_branch: process.env.RELEASE_SOURCE_BRANCH,
|
||||
source_commit: process.env.RELEASE_SOURCE_COMMIT,
|
||||
built_at: process.env.RELEASE_BUILT_AT,
|
||||
component_type: process.env.RELEASE_COMPONENT,
|
||||
migration_bootstrap_secret_sha256:
|
||||
process.env.RELEASE_MIGRATION_BOOTSTRAP_SECRET_SHA256 || null,
|
||||
artifacts,
|
||||
};
|
||||
|
||||
@@ -233,7 +232,6 @@ SKIP_SPACETIME_BUILD=0
|
||||
SKIP_PINGORA_GATEWAY_BUILD=0
|
||||
INCLUDE_PINGORA_GATEWAY=0
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=0
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
BUILD_COMPLETED=0
|
||||
|
||||
@@ -508,7 +506,6 @@ fi
|
||||
if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
|
||||
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||||
write_sha256_file "${TARGET_DIR}/spacetime_module.wasm"
|
||||
write_migration_bootstrap_secret_file
|
||||
fi
|
||||
|
||||
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/scripts/deploy" "${TARGET_DIR}/scripts/ops" "${TARGET_DIR}/deploy"
|
||||
@@ -584,7 +581,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
- \`api-server\`:生产 Linux release 可执行文件。
|
||||
- \`pingora-gateway\`:可选 Pingora 影子网关可执行文件,仅在显式 \`--include-pingora-gateway\` 时包含。
|
||||
- \`spacetime_module.wasm\`:SpacetimeDB 模块 wasm。
|
||||
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
|
||||
- 迁移引导密钥不进入发布包;WASM 仅嵌入 SHA-256 摘要,原始 secret 由 Jenkins Secret File 在 Stdb publish 时受保护地交付。
|
||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、生产健康巡检脚本、Pingora release readiness 聚合门禁、直连启用 / 回退、realpath canary 启用 / 关闭、health patrol env 切换 / TLS 证书同步 / 预检 / direct live smoke / canary live smoke / canary access log 对账 / current release 自审 / 直连彩排状态 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
|
||||
@@ -3,6 +3,304 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const checks = [
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/migration.rs',
|
||||
includes:
|
||||
'option_env!("GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256")',
|
||||
reason: '可下载的 SpacetimeDB WASM 只能嵌入 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/migration.rs',
|
||||
excludes: 'option_env!("GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET")',
|
||||
reason: 'SpacetimeDB WASM 不得嵌入原始 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/migration.rs',
|
||||
includes: 'secret.len() == MIGRATION_BOOTSTRAP_SECRET_HEX_LEN',
|
||||
reason: '模块授权入口必须拒绝非 64 位十六进制 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/build-production-release.sh',
|
||||
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
|
||||
reason: '生产模块构建前必须把受保护 secret 转换为 SHA-256 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/build-production-release.sh',
|
||||
excludes: "path: 'migration-bootstrap-secret.txt'",
|
||||
reason: '生产发布清单不得包含原始 bootstrap secret artifact。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/build-production-release.sh',
|
||||
includes: 'migration_bootstrap_secret_sha256:',
|
||||
reason: 'Stdb release manifest 必须记录非敏感 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
|
||||
reason: '远程 Rust 模块构建也只能向 rustc 传递 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: 'server-rs/.spacetimedb/build-secrets',
|
||||
reason: '旧 Ubuntu 直传入口也必须把原始 bootstrap secret 留在发布包外的受保护 sidecar。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes: '"${TARGET_DIR}/migration-bootstrap-secret.txt"',
|
||||
reason: '旧 Ubuntu 发布包目录不得再次包含原始 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: '<"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"',
|
||||
reason: '远端所需原始 secret 必须通过独立 SSH 标准输入交付,不得进入命令参数或发布包。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: '--migration-bootstrap-secret-file)',
|
||||
reason: '手工直传入口只能从受保护文件读取 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes: '--migration-bootstrap-secret)',
|
||||
reason: 'bootstrap secret 明文不得出现在进程命令参数中。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: '[[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]',
|
||||
reason: '旧 Ubuntu 手工直传入口必须拒绝非 64 位十六进制 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes: "tr -d '\\r\\n'",
|
||||
reason: '旧 Ubuntu 手工直传入口不得把 secret 文件内部换行静默拼接。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'install -o root -g genarrative -m 0440',
|
||||
reason:
|
||||
'生产 Stdb publish 必须把运行时 bootstrap secret 安装成 root 持有、服务组只读的文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'restart_runtime_services_after_bootstrap_secret_install',
|
||||
reason:
|
||||
'替换运行时 bootstrap secret 后必须重启已运行的 API、worker 和 controller,避免进程继续持有旧值。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'get_runtime_service_active_state',
|
||||
reason:
|
||||
'生产 Stdb publish 必须区分 systemctl 明确返回的非 active 状态与查询错误,查询错误不得静默跳过服务重启。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '"${exit_code}" -eq 3',
|
||||
reason:
|
||||
'生产 Stdb publish 只能把 systemctl 明确的非 active 返回码作为可跳过状态,其他错误必须阻断。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'if worker_units_output="$(',
|
||||
reason:
|
||||
'生产 Stdb publish 必须直接检查 worker list-units 的退出状态,不能用会吞掉失败的进程替换管道。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '查询 active worker 服务失败,阻断退出维护模式',
|
||||
reason:
|
||||
'生产 Stdb publish 无法枚举 active worker 时必须阻断,不能在未知 worker 状态下退出维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'restart_runtime_service_and_require_active',
|
||||
reason:
|
||||
'生产 Stdb publish 必须确认重启前 active 的 API、controller 和 worker 在重启后恢复 active。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'if ! restart_runtime_service_and_require_active genarrative-api.service; then',
|
||||
reason:
|
||||
'生产 Stdb publish 必须显式传播 API 重启或验活失败,不能依赖调用上下文中的 set -e。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes:
|
||||
'restart_runtime_services_after_bootstrap_secret_install\nwait_for_api_healthz_ready\n\n"${SCRIPT_DIR}/maintenance-off.sh"',
|
||||
reason:
|
||||
'生产 Stdb publish 在 API 原本 active 时必须先通过本机 /healthz readiness 再退出维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_env_file "${API_ENV_FILE}" true',
|
||||
reason:
|
||||
'Stdb 先于 API 发布时必须先补齐 api-server env 的 FILE 路径,保证首次 rollout 重启即可读取 secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_file_env',
|
||||
reason:
|
||||
'生产 API/worker env 必须统一指向 Stdb publish 写入的受保护 bootstrap secret 文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/jenkins-server-provision.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_file_env',
|
||||
reason:
|
||||
'Server-Provision 必须为新建及存量 API/worker env 补齐 bootstrap secret FILE 配置。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/env/api-server.env.example',
|
||||
includes:
|
||||
'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt',
|
||||
reason: '生产 API env 示例只能登记 bootstrap secret 的固定 FILE 路径。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: "file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')",
|
||||
reason:
|
||||
'Stdb Publish 必须从 Jenkins Secret File credential 受保护地取得与 wasm 匹配的 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
excludes: 'migration-bootstrap-secret.txt',
|
||||
reason:
|
||||
'Stdb Publish 不得通过普通 Jenkins artifact 或工作区副本交付 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: 'MIGRATION_BOOTSTRAP_SECRET_FILE:?MIGRATION_BOOTSTRAP_SECRET_FILE 不能为空',
|
||||
reason:
|
||||
'Stdb Publish 必须把受保护文件直接传给发布脚本,且不能展开明文。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: "file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')",
|
||||
reason: 'Stdb Build 必须用 Jenkins Secret File credential 为 option_env! 提供受控编译输入。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
excludes: 'archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt"',
|
||||
reason: 'bootstrap secret 不得作为普通、可下载的 Jenkins archive artifact。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
|
||||
reason: 'Stdb Build 只能把 File credential 的 SHA-256 摘要传给 Rust 编译。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
excludes: 'export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET=',
|
||||
reason: 'Jenkins Stdb Build 不得把原始 bootstrap secret 导出给 Rust 编译进程。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--migration-bootstrap-secret-file',
|
||||
reason: '生产 Stdb publish 必须只接受受保护的 bootstrap secret FILE。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
excludes: '${SOURCE_DIR}/migration-bootstrap-secret.txt',
|
||||
reason: '生产 Stdb publish 不得从可下载的构建 artifact 读取 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'EXPECTED_MIGRATION_BOOTSTRAP_SECRET_SHA256=',
|
||||
reason: '生产 Stdb publish 必须读取 release manifest 中的 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'Secret File 与构建 WASM 的 bootstrap secret 摘要不一致',
|
||||
reason: '生产 Stdb publish 必须阻断 Secret File 与构建 WASM 摘要不一致。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '[[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]',
|
||||
reason: '生产 Stdb publish 必须拒绝非 64 位十六进制 Secret File。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: '[[ ! "${migration_bootstrap_secret}" =~ ^[0-9a-fA-F]{64}$ ]]',
|
||||
reason: 'Stdb Build 必须在计算摘要前拒绝非 64 位十六进制 Secret File。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
excludes: 'tr -d "\\\\r\\\\n"',
|
||||
reason: 'Stdb Build 不得把 Secret File 内部换行静默拼接。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-migration-common.mjs',
|
||||
includes: "if (!/^[0-9a-f]{64}$/iu.test(secret))",
|
||||
reason: '迁移脚本 FILE 入口必须拒绝非 64 位十六进制 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/dev.mjs',
|
||||
includes: 'const BOOTSTRAP_SECRET_PATTERN = /^[0-9a-fA-F]{64}$/u;',
|
||||
reason: '本地 dev 构建与运行服务必须共用严格的 64 位十六进制 secret 规则。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: 'rotate_editor_generation_runtime_service_identity_and_return',
|
||||
reason: 'runtime writer identity rotation 必须复用模块的受审计 procedure。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: '--confirm-next-writer-identity',
|
||||
reason: 'runtime writer identity rotation 必须要求 operator 二次确认目标 identity。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: 'runtime writer identity 不能设为 migration operator identity',
|
||||
reason: 'rotation 入口不能把 migration operator 自动或直接设为 runtime writer。',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/editor_project_storage.rs',
|
||||
includes:
|
||||
'crate::migration::is_database_migration_operator(ctx, next_writer_identity)',
|
||||
reason:
|
||||
'rotation procedure 必须按数据库中的完整 migration operator 集合校验目标 identity。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: "spawn('spacetime', ['login', 'show']",
|
||||
reason: 'rotation 入口必须校验声明的 operator identity 与当前 CLI 登录 identity 一致。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
excludes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET',
|
||||
reason: 'runtime writer identity rotation 不得读取或传递 migration bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: '--worker-env-file "${params.WORKER_ENV_FILE}"',
|
||||
reason: 'Stdb Publish 必须把 worker env 路径传给随包发布脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes: "string(name: 'WORKER_ENV_FILE', value: params.WORKER_ENV_FILE",
|
||||
reason: '全量发布必须向 Stdb 与 API 两段透传同一 worker env 路径。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')",
|
||||
reason: '全量构建必须在启动并行子流水线前拒绝缺失的 Secret File 凭据。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID)",
|
||||
reason: '全量发布必须把与 wasm 构建一致的 Secret File 凭据透传给 Stdb Publish。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes:
|
||||
'echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"',
|
||||
reason: '发布包构建日志不得输出 bootstrap secret 明文。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/dev.mjs',
|
||||
excludes: '[dev:spacetime] 迁移引导密钥: ${',
|
||||
reason: '本地 dev 日志不得输出 bootstrap secret 明文。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/systemd/genarrative-database-backup.service',
|
||||
includes: '--restart-service-after genarrative-api.service',
|
||||
|
||||
@@ -28,6 +28,8 @@ usage() {
|
||||
--skip-web-build 跳过 Vite 构建,仅用于调试
|
||||
--skip-api-build 跳过 api-server 构建,仅用于调试
|
||||
--skip-spacetime-build 跳过 wasm 构建,仅用于调试;此时必须同时传 --no-migration-bootstrap-secret
|
||||
--migration-bootstrap-secret-file <path>
|
||||
从受保护文件读取迁移引导密钥,明文不得放进命令参数
|
||||
--no-migration-bootstrap-secret 构建不带迁移引导密钥的 spacetime-module wasm
|
||||
|
||||
目标服务器要求:
|
||||
@@ -163,13 +165,20 @@ prepare_migration_bootstrap_secret() {
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||
;;
|
||||
manual)
|
||||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||
if [[ ! -f "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" || -L "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" || ! -r "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥必须来自可读普通文件,且不能是符号链接: ${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(cat "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}")"
|
||||
MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET%$'\r'}"
|
||||
if [[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥必须是 64 位十六进制高熵值。" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
disabled)
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256
|
||||
echo "[deploy:rust] 未启用迁移引导密钥。"
|
||||
return
|
||||
;;
|
||||
@@ -179,8 +188,11 @@ prepare_migration_bootstrap_secret() {
|
||||
;;
|
||||
esac
|
||||
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
local digest_line
|
||||
digest_line="$(printf '%s' "${MIGRATION_BOOTSTRAP_SECRET}" | sha256sum)"
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${digest_line%% *}"
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
echo "[deploy:rust] 已启用迁移引导密钥: mode=${MIGRATION_BOOTSTRAP_SECRET_MODE}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -204,6 +216,7 @@ SKIP_API_BUILD=0
|
||||
SKIP_SPACETIME_BUILD=0
|
||||
BUILD_COMPLETED=0
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -272,8 +285,8 @@ while [[ $# -gt 0 ]]; do
|
||||
SKIP_SPACETIME_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
|
||||
--migration-bootstrap-secret-file)
|
||||
MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE="$(normalize_local_path_for_bash "${2:?缺少 --migration-bootstrap-secret-file 的值}")"
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
|
||||
shift 2
|
||||
;;
|
||||
@@ -313,12 +326,16 @@ WEB_DIR="${TARGET_DIR}/web"
|
||||
ADMIN_WEB_DIR="${WEB_DIR}/admin"
|
||||
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
|
||||
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
|
||||
MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE=""
|
||||
|
||||
cleanup_partial_build() {
|
||||
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
|
||||
echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2
|
||||
rm -rf "${TARGET_DIR}"
|
||||
fi
|
||||
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE:-}" ]]; then
|
||||
rm -f "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_partial_build EXIT
|
||||
@@ -333,6 +350,24 @@ require_command cargo
|
||||
|
||||
prepare_migration_bootstrap_secret
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||||
migration_bootstrap_secret_dir="${SERVER_RS_DIR}/.spacetimedb/build-secrets"
|
||||
MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE="${migration_bootstrap_secret_dir}/${BUILD_NAME}.txt"
|
||||
if [[ -e "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥文件已存在: ${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${migration_bootstrap_secret_dir}"
|
||||
chmod 700 "${migration_bootstrap_secret_dir}"
|
||||
(
|
||||
umask 077
|
||||
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
)
|
||||
chmod 600 "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
echo "[deploy:rust] 迁移引导密钥已单独写入受保护文件: ${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
unset MIGRATION_BOOTSTRAP_SECRET
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
require_command npm
|
||||
fi
|
||||
@@ -402,11 +437,6 @@ fi
|
||||
|
||||
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||||
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||
chmod 600 "${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||
fi
|
||||
|
||||
mkdir -p "${TARGET_DIR}/scripts"
|
||||
for migration_script in \
|
||||
spacetime-migration-common.mjs \
|
||||
@@ -735,37 +765,38 @@ is_publish_conflict_output() {
|
||||
|| [[ "${normalized}" == *"clear database"* && "${normalized}" == *"publish"* ]]
|
||||
}
|
||||
|
||||
read_migration_bootstrap_secret() {
|
||||
resolve_migration_bootstrap_secret_file() {
|
||||
local secret_file="$1"
|
||||
local label="$2"
|
||||
local secret=""
|
||||
|
||||
if [[ ! -f "${secret_file}" ]]; then
|
||||
if [[ ! -f "${secret_file}" || -L "${secret_file}" || ! -r "${secret_file}" ]]; then
|
||||
echo "[start] schema 冲突自动迁移需要${label}: ${secret_file}" >&2
|
||||
echo "[start] 请使用默认带迁移引导密钥的发布包,或设置 GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=false 后人工处理。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
secret="$(tr -d '\r\n' <"${secret_file}")"
|
||||
if [[ -z "${secret}" ]]; then
|
||||
echo "[start] 迁移引导密钥为空${label}: ${secret_file}" >&2
|
||||
secret="$(cat "${secret_file}")"
|
||||
secret="${secret%$'\r'}"
|
||||
if [[ ! "${secret}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[start] 迁移引导密钥必须是 64 位十六进制高熵值${label}: ${secret_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf "%s" "${secret}"
|
||||
printf "%s" "${secret_file}"
|
||||
}
|
||||
|
||||
read_export_migration_bootstrap_secret() {
|
||||
resolve_export_migration_bootstrap_secret_file() {
|
||||
if [[ -f "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
read_migration_bootstrap_secret "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
|
||||
resolve_migration_bootstrap_secret_file "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
|
||||
return
|
||||
fi
|
||||
|
||||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
|
||||
resolve_migration_bootstrap_secret_file "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
|
||||
}
|
||||
|
||||
read_import_migration_bootstrap_secret() {
|
||||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
|
||||
resolve_import_migration_bootstrap_secret_file() {
|
||||
resolve_migration_bootstrap_secret_file "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
|
||||
}
|
||||
|
||||
require_migration_script() {
|
||||
@@ -790,8 +821,8 @@ run_publish() {
|
||||
}
|
||||
|
||||
run_conflict_migration_publish() {
|
||||
local export_bootstrap_secret=""
|
||||
local import_bootstrap_secret=""
|
||||
local export_bootstrap_secret_file=""
|
||||
local import_bootstrap_secret_file=""
|
||||
local export_auth_args=()
|
||||
local import_auth_args=()
|
||||
local migration_database_slug=""
|
||||
@@ -803,16 +834,16 @@ run_conflict_migration_publish() {
|
||||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN 导出旧库"
|
||||
export_auth_args=(--token "${SPACETIME_MIGRATION_EXPORT_TOKEN}")
|
||||
else
|
||||
export_bootstrap_secret="$(read_export_migration_bootstrap_secret)"
|
||||
export_auth_args=(--bootstrap-secret "${export_bootstrap_secret}")
|
||||
export_bootstrap_secret_file="$(resolve_export_migration_bootstrap_secret_file)"
|
||||
export_auth_args=(--bootstrap-secret-file "${export_bootstrap_secret_file}")
|
||||
fi
|
||||
|
||||
if [[ -n "${SPACETIME_MIGRATION_IMPORT_TOKEN}" ]]; then
|
||||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN 导入新库"
|
||||
import_auth_args=(--token "${SPACETIME_MIGRATION_IMPORT_TOKEN}")
|
||||
else
|
||||
import_bootstrap_secret="$(read_import_migration_bootstrap_secret)"
|
||||
import_auth_args=(--bootstrap-secret "${import_bootstrap_secret}")
|
||||
import_bootstrap_secret_file="$(resolve_import_migration_bootstrap_secret_file)"
|
||||
import_auth_args=(--bootstrap-secret-file "${import_bootstrap_secret_file}")
|
||||
fi
|
||||
require_migration_script "${MIGRATION_EXPORT_SCRIPT}"
|
||||
require_migration_script "${MIGRATION_IMPORT_SCRIPT}"
|
||||
@@ -1107,7 +1138,7 @@ fi
|
||||
|
||||
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
|
||||
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
echo "[start] 迁移引导密钥: $(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")"
|
||||
echo "[start] 已启用迁移引导密钥。"
|
||||
else
|
||||
echo "[start] 未启用迁移引导密钥。"
|
||||
fi
|
||||
@@ -1140,7 +1171,14 @@ export GENARRATIVE_API_PORT="${API_PORT}"
|
||||
export GENARRATIVE_API_LOG="${API_LOG}"
|
||||
export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}"
|
||||
export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}"
|
||||
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
export GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE="${MIGRATION_BOOTSTRAP_SECRET_FILE}"
|
||||
else
|
||||
unset GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE
|
||||
fi
|
||||
|
||||
start_process api-server "${SCRIPT_DIR}/api-server"
|
||||
unset GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE
|
||||
|
||||
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
|
||||
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
|
||||
@@ -1216,7 +1254,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
- \`web/\`:主前端 Vite release 静态资源,\`web/admin/\` 为后台管理前端静态资源
|
||||
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||||
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||||
- \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除
|
||||
- \`migration-bootstrap-secret.txt\`:不包含在发布包中;上传模式会通过独立 SSH 输入流安装为 \`0600\`,\`--skip-upload\` 时必须从构建端受保护的 \`server-rs/.spacetimedb/build-secrets/<build-name>.txt\` 单独交付到发布目录
|
||||
- \`scripts/spacetime-*.mjs\`:部署时 schema 冲突自动导出、导入回灌使用的 SpacetimeDB 迁移脚本
|
||||
- \`web-server.mjs\`:静态网站与 API 反代入口
|
||||
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
|
||||
@@ -1255,7 +1293,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
- \`GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT\`:默认 \`true\`,普通发布遇到 schema 冲突时自动导出、清库发布、导入回灌;设为 \`false\` 时保留原始发布失败。
|
||||
- \`GENARRATIVE_SPACETIME_MIGRATION_DIR\`:自动迁移 JSON 输出目录,默认 \`database-migrations/<database>/\`。
|
||||
- OSS、LLM、短信、微信、SpacetimeDB owner token 等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理;后台表统计读取 private 表时需要 \`GENARRATIVE_SPACETIME_TOKEN\` 对目标库有 owner 权限。
|
||||
- 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。
|
||||
- 迁移引导密钥由构建端随机生成,原文不进入发布包、构建日志或生成 README;服务器 \`start.sh\` 只读取同目录受保护文件且不会输出明文。
|
||||
EOF
|
||||
replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}"
|
||||
|
||||
@@ -1267,6 +1305,17 @@ if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
|
||||
|
||||
echo "[deploy:rust] 上传发布包: ${TARGET_DIR} -> ${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||||
scp -r -i "${NORMALIZED_SSH_KEY}" "${TARGET_DIR}" "${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||||
|
||||
if [[ -n "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" ]]; then
|
||||
remote_secret_path="${REMOTE_DIR%/}/${BUILD_NAME}/migration-bootstrap-secret.txt"
|
||||
remote_secret_temp_path="${REMOTE_DIR%/}/${BUILD_NAME}/.migration-bootstrap-secret.$$.tmp"
|
||||
quoted_remote_secret_path="$(remote_shell_quote "${remote_secret_path}")"
|
||||
quoted_remote_secret_temp_path="$(remote_shell_quote "${remote_secret_temp_path}")"
|
||||
echo "[deploy:rust] 通过独立 SSH 输入流安装迁移引导密钥: ${REMOTE_TARGET}:${remote_secret_path}"
|
||||
ssh -i "${NORMALIZED_SSH_KEY}" "${REMOTE_TARGET}" \
|
||||
"set -e; umask 077; cat > ${quoted_remote_secret_temp_path}; chmod 600 ${quoted_remote_secret_temp_path}; mv -f ${quoted_remote_secret_temp_path} ${quoted_remote_secret_path}" \
|
||||
<"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[deploy:rust] 完成: ${TARGET_DIR}"
|
||||
|
||||
@@ -207,6 +207,32 @@ ensure_env_value_migrates_old_default() {
|
||||
write_env_value "${file_path}" "${key}" "${new_default}"
|
||||
}
|
||||
|
||||
ensure_runtime_bootstrap_secret_file_env() {
|
||||
local file_path="$1"
|
||||
local key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"
|
||||
local direct_key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"
|
||||
local canonical_path="/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt"
|
||||
local current_value direct_value
|
||||
|
||||
direct_value="$(read_env_value "${file_path}" "${direct_key}")"
|
||||
if [[ -n "${direct_value}" ]]; then
|
||||
unset direct_value
|
||||
echo "[production-api-deploy] ${file_path} 不得保存 ${direct_key} 明文;生产环境只允许使用 ${key}。" >&2
|
||||
exit 1
|
||||
fi
|
||||
unset direct_value
|
||||
|
||||
current_value="$(read_env_value "${file_path}" "${key}")"
|
||||
if [[ -z "${current_value}" ]]; then
|
||||
ensure_env_value "${file_path}" "${key}" "${canonical_path}"
|
||||
return
|
||||
fi
|
||||
if [[ "${current_value}" != "${canonical_path}" ]]; then
|
||||
echo "[production-api-deploy] ${key} 必须使用 Stdb publish 同步写入的固定路径 ${canonical_path}: ${file_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_privileged() {
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
"$@"
|
||||
@@ -235,6 +261,24 @@ ensure_runtime_dir() {
|
||||
run_privileged install -d -o genarrative -g genarrative -m "${mode}" "${path}"
|
||||
}
|
||||
|
||||
migrate_legacy_editor_generation_pricing_override() {
|
||||
local current_link="$1"
|
||||
local target_dir="/var/lib/genarrative/editor-generation-pricing"
|
||||
local target_file="${target_dir}/editor-generation-pricing.override.json"
|
||||
local legacy_file="${current_link}/.app/editor-generation-pricing.override.json"
|
||||
|
||||
if [[ -f "${target_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "${legacy_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] 迁移旧模型定价 override 到运行态目录: ${legacy_file} -> ${target_file}"
|
||||
ensure_runtime_dir "${target_dir}" "0750"
|
||||
run_privileged install -o genarrative -g genarrative -m 0640 "${legacy_file}" "${target_file}"
|
||||
}
|
||||
|
||||
ensure_runtime_env_and_dirs() {
|
||||
local api_env_file="$1"
|
||||
local tracking_enabled tracking_outbox_dir wallet_refund_enabled wallet_refund_outbox_dir
|
||||
@@ -255,6 +299,7 @@ ensure_runtime_env_and_dirs() {
|
||||
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
|
||||
ensure_runtime_bootstrap_secret_file_env "${api_env_file}"
|
||||
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
|
||||
@@ -285,6 +330,7 @@ ensure_worker_runtime_env_defaults() {
|
||||
ensure_env_value_migrates_old_default "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
|
||||
ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
|
||||
ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
|
||||
ensure_runtime_bootstrap_secret_file_env "${worker_env_file}"
|
||||
}
|
||||
|
||||
extract_pingora_env_files_from_unit() {
|
||||
@@ -1038,6 +1084,7 @@ fi
|
||||
|
||||
ensure_runtime_env_and_dirs "${API_ENV_FILE}"
|
||||
ensure_worker_runtime_env_defaults "${WORKER_ENV_FILE}"
|
||||
migrate_legacy_editor_generation_pricing_override "${CURRENT_LINK}"
|
||||
|
||||
if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then
|
||||
PINGORA_SHADOW_ENV_FILE="$(check_pingora_shadow_service_config "${PINGORA_SERVICE_NAME}")"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
validateSpacetimeDatabaseName,
|
||||
} from '../spacetime-migration-common.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const PROCEDURE = 'rotate_editor_generation_runtime_service_identity_and_return';
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'用法: production-runtime-writer-identity-rotate.mjs --database <database> --server-url <http(s)://host:port> --operator-identity <64-hex> --operator-user-id <user-id> --next-writer-identity <64-hex> --confirm-next-writer-identity <64-hex> --note <audit-note>',
|
||||
);
|
||||
}
|
||||
|
||||
function readValue(argv, index, name) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${name} 缺少参数值。`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIdentity(value, name) {
|
||||
const identity = value.trim().replace(/^0x/u, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/u.test(identity)) {
|
||||
throw new Error(`${name} 必须是 64 位十六进制 SpacetimeDB identity。`);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function readCurrentCliIdentity() {
|
||||
const output = await new Promise((resolve, reject) => {
|
||||
const child = spawn('spacetime', ['login', 'show'], {
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let text = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
text += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
text += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`spacetime login show 被信号中断: ${signal}`));
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`无法读取当前 SpacetimeDB CLI identity,退出码 ${code}`));
|
||||
} else {
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
const identities = [...output.matchAll(/\b[0-9a-f]{64}\b/giu)].map((match) =>
|
||||
match[0].toLowerCase(),
|
||||
);
|
||||
if (identities.length !== 1) {
|
||||
throw new Error('spacetime login show 未返回唯一的当前 CLI identity。');
|
||||
}
|
||||
return identities[0];
|
||||
}
|
||||
|
||||
function parseOptions(argv) {
|
||||
const options = {
|
||||
database: '',
|
||||
serverUrl: '',
|
||||
operatorIdentity: '',
|
||||
operatorUserId: '',
|
||||
nextWriterIdentity: '',
|
||||
confirmNextWriterIdentity: '',
|
||||
note: '',
|
||||
passthrough: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === '--database') {
|
||||
options.database = readValue(argv, index, arg);
|
||||
} else if (arg === '--server-url') {
|
||||
options.serverUrl = readValue(argv, index, arg);
|
||||
} else if (arg === '--operator-identity') {
|
||||
options.operatorIdentity = readValue(argv, index, arg);
|
||||
} else if (arg === '--operator-user-id') {
|
||||
options.operatorUserId = readValue(argv, index, arg);
|
||||
} else if (arg === '--next-writer-identity') {
|
||||
options.nextWriterIdentity = readValue(argv, index, arg);
|
||||
} else if (arg === '--confirm-next-writer-identity') {
|
||||
options.confirmNextWriterIdentity = readValue(argv, index, arg);
|
||||
} else if (arg === '--note') {
|
||||
options.note = readValue(argv, index, arg);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
validateSpacetimeDatabaseName(options.database);
|
||||
if (!/^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?$/u.test(options.serverUrl)) {
|
||||
throw new Error('--server-url 必须是无路径、无查询参数的显式 http(s) URL。');
|
||||
}
|
||||
|
||||
const operatorIdentity = normalizeIdentity(options.operatorIdentity, '--operator-identity');
|
||||
const nextWriterIdentity = normalizeIdentity(
|
||||
options.nextWriterIdentity,
|
||||
'--next-writer-identity',
|
||||
);
|
||||
const confirmedIdentity = normalizeIdentity(
|
||||
options.confirmNextWriterIdentity,
|
||||
'--confirm-next-writer-identity',
|
||||
);
|
||||
if (nextWriterIdentity !== confirmedIdentity) {
|
||||
throw new Error('--confirm-next-writer-identity 必须与 --next-writer-identity 完全一致。');
|
||||
}
|
||||
if (nextWriterIdentity === operatorIdentity) {
|
||||
throw new Error('runtime writer identity 不能设为 migration operator identity。');
|
||||
}
|
||||
|
||||
const currentCliIdentity = await readCurrentCliIdentity();
|
||||
if (operatorIdentity !== currentCliIdentity) {
|
||||
throw new Error('--operator-identity 必须与当前 SpacetimeDB CLI 登录 identity 一致。');
|
||||
}
|
||||
|
||||
const operatorUserId = options.operatorUserId.trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/u.test(operatorUserId)) {
|
||||
throw new Error('--operator-user-id 格式不合法。');
|
||||
}
|
||||
const note = options.note.trim();
|
||||
if (!note || note.length > 500) {
|
||||
throw new Error('--note 必填且不能超过 500 个字符。');
|
||||
}
|
||||
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
{ database: options.database, serverUrl: options.serverUrl, passthrough: [] },
|
||||
PROCEDURE,
|
||||
{
|
||||
next_writer_identity_hex: nextWriterIdentity,
|
||||
operator_user_id: operatorUserId,
|
||||
note,
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
console.log(
|
||||
`[runtime-writer-identity-rotation] 已完成 identity rotation: database=${options.database}, operator_identity=${operatorIdentity}, next_writer_identity=${nextWriterIdentity}; 审计记录已写入 editor_generation_runtime_identity_rotation。`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[runtime-writer-identity-rotation] 操作失败;必须使用已授权的 migration operator CLI 登录态。${error instanceof Error ? ` ${error.message}` : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user