Merge branch 'master' into editor-agent-refactored
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,15 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
|
||||
export type ProfileTaskCycle = 'daily';
|
||||
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
|
||||
export type ProfileRechargeProductKind = 'points' | 'membership';
|
||||
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
|
||||
export type ProfileMembershipTier =
|
||||
| 'normal'
|
||||
| 'month'
|
||||
| 'season'
|
||||
| 'year'
|
||||
| 'starter'
|
||||
| 'basic'
|
||||
| 'pro'
|
||||
| 'ultimate';
|
||||
|
||||
export interface AdminTrackingEventListQuery {
|
||||
eventKey?: string;
|
||||
@@ -578,6 +586,10 @@ export interface AdminUpsertProfileRechargeProductRequest {
|
||||
badgeLabel?: string | null;
|
||||
description?: string | null;
|
||||
tier: ProfileMembershipTier;
|
||||
membershipPeriodPoints: number;
|
||||
membershipPeriodDays: number;
|
||||
membershipQueueLimit: number;
|
||||
membershipDiscountBps: number;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
@@ -661,6 +673,10 @@ export interface ProfileRechargeProductConfigAdminResponse {
|
||||
badgeLabel: string;
|
||||
description: string;
|
||||
tier: ProfileMembershipTier;
|
||||
membershipPeriodPoints: number;
|
||||
membershipPeriodDays: number;
|
||||
membershipQueueLimit: number;
|
||||
membershipDiscountBps: number;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdBy: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ const productKinds: Array<{value: ProfileRechargeProductKind; label: string}> =
|
||||
];
|
||||
|
||||
const membershipTiers: Array<{value: ProfileMembershipTier; label: string}> = [
|
||||
{value: 'starter', label: 'Starter'},
|
||||
{value: 'basic', label: 'Basic'},
|
||||
{value: 'pro', label: 'Pro'},
|
||||
{value: 'ultimate', label: 'Ultimate'},
|
||||
{value: 'month', label: '月卡'},
|
||||
{value: 'season', label: '季卡'},
|
||||
{value: 'year', label: '年卡'},
|
||||
@@ -50,6 +54,10 @@ export function AdminRechargeProductPage({
|
||||
const [badgeLabel, setBadgeLabel] = useState('首充双倍');
|
||||
const [description, setDescription] = useState('首充送60泥点');
|
||||
const [tier, setTier] = useState<ProfileMembershipTier>('normal');
|
||||
const [membershipPeriodPoints, setMembershipPeriodPoints] = useState('0');
|
||||
const [membershipPeriodDays, setMembershipPeriodDays] = useState('0');
|
||||
const [membershipQueueLimit, setMembershipQueueLimit] = useState('0');
|
||||
const [membershipDiscountBps, setMembershipDiscountBps] = useState('0');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [sortOrder, setSortOrder] = useState('0');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -110,6 +118,14 @@ export function AdminRechargeProductPage({
|
||||
badgeLabel: kind === 'points' ? badgeLabel.trim() : '',
|
||||
description: description.trim(),
|
||||
tier: kind === 'membership' ? tier : 'normal',
|
||||
membershipPeriodPoints:
|
||||
kind === 'membership' ? parsePositiveInteger(membershipPeriodPoints) : 0,
|
||||
membershipPeriodDays:
|
||||
kind === 'membership' ? parsePositiveInteger(membershipPeriodDays) : 0,
|
||||
membershipQueueLimit:
|
||||
kind === 'membership' ? parseNonNegativeInteger(membershipQueueLimit) : 0,
|
||||
membershipDiscountBps:
|
||||
kind === 'membership' ? parseNonNegativeInteger(membershipDiscountBps) : 0,
|
||||
enabled,
|
||||
sortOrder: parseInteger(sortOrder),
|
||||
});
|
||||
@@ -141,6 +157,10 @@ export function AdminRechargeProductPage({
|
||||
setBadgeLabel(entry.badgeLabel);
|
||||
setDescription(entry.description);
|
||||
setTier(entry.tier);
|
||||
setMembershipPeriodPoints(String(entry.membershipPeriodPoints));
|
||||
setMembershipPeriodDays(String(entry.membershipPeriodDays));
|
||||
setMembershipQueueLimit(String(entry.membershipQueueLimit));
|
||||
setMembershipDiscountBps(String(entry.membershipDiscountBps));
|
||||
setEnabled(entry.enabled);
|
||||
setSortOrder(String(entry.sortOrder));
|
||||
}
|
||||
@@ -200,10 +220,18 @@ export function AdminRechargeProductPage({
|
||||
if (item.value === 'points') {
|
||||
setTier('normal');
|
||||
setDurationDays('0');
|
||||
setMembershipPeriodPoints('0');
|
||||
setMembershipPeriodDays('0');
|
||||
setMembershipQueueLimit('0');
|
||||
setMembershipDiscountBps('0');
|
||||
} else {
|
||||
setBonusPoints('0');
|
||||
setPointsAmount('0');
|
||||
setTier(tier === 'normal' ? 'month' : tier);
|
||||
setTier(tier === 'normal' ? 'starter' : tier);
|
||||
setDurationDays(durationDays === '0' ? '30' : durationDays);
|
||||
setMembershipPeriodDays(
|
||||
membershipPeriodDays === '0' ? '30' : membershipPeriodDays,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -256,6 +284,7 @@ export function AdminRechargeProductPage({
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-stack">
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>会员档位</span>
|
||||
@@ -283,6 +312,59 @@ export function AdminRechargeProductPage({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>每周期泥点</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={membershipPeriodPoints}
|
||||
onChange={(event) =>
|
||||
setMembershipPeriodPoints(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>周期天数</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={membershipPeriodDays}
|
||||
onChange={(event) =>
|
||||
setMembershipPeriodDays(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>队列上限</span>
|
||||
<input
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
value={membershipQueueLimit}
|
||||
onChange={(event) =>
|
||||
setMembershipQueueLimit(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>折扣 bps</span>
|
||||
<input
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
value={membershipDiscountBps}
|
||||
onChange={(event) =>
|
||||
setMembershipDiscountBps(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-row">
|
||||
@@ -409,6 +491,18 @@ function formatProductKind(kind: ProfileRechargeProductKind) {
|
||||
}
|
||||
|
||||
function formatTier(tier: ProfileMembershipTier) {
|
||||
if (tier === 'starter') {
|
||||
return 'Starter';
|
||||
}
|
||||
if (tier === 'basic') {
|
||||
return 'Basic';
|
||||
}
|
||||
if (tier === 'pro') {
|
||||
return 'Pro';
|
||||
}
|
||||
if (tier === 'ultimate') {
|
||||
return 'Ultimate';
|
||||
}
|
||||
if (tier === 'month') {
|
||||
return '月卡';
|
||||
}
|
||||
@@ -425,7 +519,7 @@ function formatProductContent(entry: ProfileRechargeProductConfigAdminResponse)
|
||||
if (entry.kind === 'points') {
|
||||
return `${entry.pointsAmount}+${entry.bonusPoints}`;
|
||||
}
|
||||
return `${formatTier(entry.tier)} ${entry.durationDays}天`;
|
||||
return `${formatTier(entry.tier)} ${entry.durationDays}天 · 每${entry.membershipPeriodDays}天${entry.membershipPeriodPoints}泥点`;
|
||||
}
|
||||
|
||||
function formatPrice(priceCents: number) {
|
||||
|
||||
@@ -394,7 +394,7 @@ button:disabled {
|
||||
.admin-dashboard-bars {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(18px, 1fr);
|
||||
grid-auto-columns: minmax(36px, 1fr);
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
min-height: 196px;
|
||||
@@ -404,7 +404,7 @@ button:disabled {
|
||||
|
||||
.admin-dashboard-bar-item {
|
||||
display: grid;
|
||||
min-width: 18px;
|
||||
min-width: 36px;
|
||||
gap: 7px;
|
||||
align-items: end;
|
||||
justify-items: center;
|
||||
@@ -413,7 +413,8 @@ button:disabled {
|
||||
.admin-dashboard-bar-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
width: 18px;
|
||||
max-width: 100%;
|
||||
min-width: 18px;
|
||||
height: 160px;
|
||||
align-items: flex-end;
|
||||
@@ -430,10 +431,12 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item small {
|
||||
max-width: 48px;
|
||||
width: 100%;
|
||||
max-width: 44px;
|
||||
overflow: hidden;
|
||||
color: #8f7868;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
@@ -578,6 +581,7 @@ button:disabled {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb {
|
||||
@@ -1116,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;
|
||||
@@ -1146,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()],
|
||||
|
||||
@@ -32,9 +32,17 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox
|
||||
GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100
|
||||
GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000
|
||||
GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864
|
||||
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000
|
||||
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000
|
||||
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3
|
||||
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300
|
||||
# BgFilter 失败后的中间兜底:阿里云通用抠图(SegmentCommonImage)。AccessKey 留空则跳过该层,
|
||||
# BgFilter 失败直接本地 editor_green_screen 去背;填入后恢复 BgFilter→阿里云→本地三级兜底。
|
||||
# AccessKey 也可复用标准 SDK 命名 ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET。
|
||||
GENARRATIVE_ALIYUN_MATTING_ENABLED=true
|
||||
GENARRATIVE_ALIYUN_MATTING_ENDPOINT=imageseg.cn-shanghai.aliyuncs.com
|
||||
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID=
|
||||
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET=
|
||||
GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000
|
||||
|
||||
GENARRATIVE_OTEL_ENABLED=true
|
||||
OTEL_SERVICE_NAME=genarrative-api
|
||||
|
||||
Vendored
+11
-1
@@ -32,9 +32,17 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox
|
||||
GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100
|
||||
GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000
|
||||
GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864
|
||||
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000
|
||||
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000
|
||||
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3
|
||||
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300
|
||||
# BgFilter 失败后的中间兜底:阿里云通用抠图(SegmentCommonImage)。AccessKey 留空则跳过该层,
|
||||
# BgFilter 失败直接本地 editor_green_screen 去背;填入后恢复 BgFilter→阿里云→本地三级兜底。
|
||||
# AccessKey 也可复用标准 SDK 命名 ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET。
|
||||
GENARRATIVE_ALIYUN_MATTING_ENABLED=true
|
||||
GENARRATIVE_ALIYUN_MATTING_ENDPOINT=imageseg.cn-shanghai.aliyuncs.com
|
||||
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID=
|
||||
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET=
|
||||
GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000
|
||||
GENARRATIVE_OTEL_ENABLED=true
|
||||
OTEL_SERVICE_NAME=genarrative-api
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||
@@ -59,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)。
|
||||
|
||||
|
||||
@@ -2508,6 +2508,13 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"screenColor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"description": "兼容旧 size 入参;未传 aspectRatio/imageSize 时生效。",
|
||||
@@ -2872,6 +2879,13 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"screenColor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"default": "gemini-3.1-flash-image-preview"
|
||||
@@ -2937,6 +2951,13 @@
|
||||
"type": "string",
|
||||
"description": "UI 设计图 Data URL 或 objectKey。"
|
||||
},
|
||||
"screenColor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"default": "gemini-3.1-flash-image-preview",
|
||||
@@ -3163,6 +3184,13 @@
|
||||
"minLength": 1,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"screenColor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。"
|
||||
},
|
||||
"resolution": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”按当天真实日期自动填充对应自然日 / 自然周 / 自然月范围并刷新,手动修改起止日期后按当前日期范围查询。
|
||||
- 前端默认日期和“本日 / 本周 / 本月”快捷入口都按 `Asia/Shanghai` 计算,不使用浏览器本地时区。
|
||||
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
|
||||
- 图表横轴按后端返回的 bucket label 展示;本月和较长自定义时段使用固定最小日期列宽并允许横向滚动,避免日期刻度互相挤压。
|
||||
|
||||
## 指标口径
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
|
||||
|
||||
- HTTP 服务:`api-server`。
|
||||
- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-editor-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。
|
||||
- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-wechat`、`platform-speech`。
|
||||
- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-wechat`、`platform-speech`。
|
||||
- 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。
|
||||
- SpacetimeDB:`spacetime-client`、`spacetime-module`。
|
||||
- 测试支撑:`tests-support`。
|
||||
@@ -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/*`。
|
||||
@@ -168,13 +168,14 @@ npm run check:server-rs-ddd
|
||||
|
||||
1. 任何 table、view、reducer、procedure、row shape 或 bindings 变化,都必须同步本文件表 / view 目录和生成绑定;真实 table 变化还必须同步 `server-rs/crates/spacetime-module/src/migration.rs`,view 属于派生投影,不写入迁移导入导出表清单。
|
||||
2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]`。
|
||||
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
|
||||
4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>` 加 `#[default(None::<Vec<T>>)]`,业务层归一为空数组。
|
||||
5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
|
||||
6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。
|
||||
7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。
|
||||
8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option<String>` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
|
||||
9. 修改后运行:
|
||||
3. 已发布并持久化的 enum 新增 variant 只能追加到末尾,不能插入中间、删除、重命名或重排;否则会移动既有 variant 的判别序号,导致旧数据解释错误或生产发布 schema 迁移失败。
|
||||
4. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
|
||||
5. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>` 加 `#[default(None::<Vec<T>>)]`,业务层归一为空数组。
|
||||
6. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
|
||||
7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。
|
||||
8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。
|
||||
9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option<String>` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
|
||||
10. 修改后运行:
|
||||
|
||||
```bash
|
||||
npm run spacetime:generate
|
||||
@@ -188,15 +189,18 @@ npm run check:server-rs-ddd
|
||||
## 账户充值数据契约
|
||||
|
||||
1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。
|
||||
2. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、启用状态和排序。
|
||||
2. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。
|
||||
3. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。
|
||||
4. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。
|
||||
5. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。
|
||||
6. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。
|
||||
7. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;微信内浏览器使用 `wechat_jsapi`;普通 Web 使用 `wechat_native`,历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。
|
||||
8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
|
||||
9. 微信 Native 下单显式传 `time_expire`,当前有效期为 5 分钟,并通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
|
||||
10. 普通微信支付渠道的新建 pending 充值订单会写入 `profile_recharge_order_expiration_schedule`。到期处理由 `api-server` 后台 worker claim 调度行后调用微信查单;只有微信返回 `SUCCESS` 才补确认入账,返回 `NOTPAY` / `CLOSED` / `REVOKED` / `PAYERROR` 才关闭本地订单,查询失败或 `USERPAYING` 保留租约等待重试。SpacetimeDB module 不直接发起微信 HTTP 请求。
|
||||
6. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。
|
||||
7. 会员有效期和周期重置时间是两条独立时间线。`expires_at` 只决定会员是否生效;`cycle_resets_at` 只决定当前周期限时泥点何时重置。会员升级只更新档位并补齐当前周期限时泥点差额,不延长 `expires_at`,不移动 `cycle_resets_at` 和周期天数。同级会员购买只从当前 `expires_at` 延长有效期,不发放额外当前周期泥点,也不移动重置时间。
|
||||
8. 会员周期刷新发生在个人中心、充值中心、任务中心、账单读取和钱包扣费入口:到达 `cycle_resets_at` 时先清除上周期剩余限时泥点,再发放当前会员档位周期额度;会员过期时清除剩余限时泥点并把状态降为普通。周期发放和重置流水分别使用 `membership_period_grant`、`membership_period_reset`。
|
||||
9. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。
|
||||
10. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native`;`wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。
|
||||
11. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
|
||||
12. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
|
||||
13. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并执行微信查单补偿:`SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`external-generation-worker` / controller 不处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`。
|
||||
|
||||
## 创作入口泥点扣费契约
|
||||
|
||||
@@ -211,24 +215,27 @@ 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. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;实际扣费真相以后端运行时模型定价配置为准,前端按钮泥点只作为展示。
|
||||
3. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||
4. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
5. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKey;objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图,只提交原图或红框序号标注图作为 `sourceImageSrc`。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。
|
||||
2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费时优先消耗 `profile_membership.cycle_remaining_points` 中的会员周期限时泥点,再消耗普通永久泥点;扣费流水 `metadata_json` 会记录 `membershipPeriodPointsDelta`、`permanentPointsDelta` 和限时泥点所属 `cycleResetsAtMicros`,退款会按原消费流水优先恢复同一周期的限时泥点,前端不得自行决定扣费桶。
|
||||
3. 每日免费泥点当前由每日任务体系发放,流水来源为 `daily_task_reward`,任务进度和可领取状态按北京时间每日刷新;它不参与会员 `cycle_resets_at`,也不由前端合并进会员周期泥点。
|
||||
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. 队列任务按 `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 张额外参考图。
|
||||
|
||||
## 外部服务与资产
|
||||
|
||||
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
|
||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `45000ms`,token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>` 和 `seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,会在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接使用本地 `editor_green_screen` 兜底,避免上游故障占住 worker。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。
|
||||
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>` 和 `seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。
|
||||
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
|
||||
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
|
||||
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
||||
- 敲木鱼敲击物和背景环境图: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` 形态。
|
||||
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `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。
|
||||
- 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` 旧表。
|
||||
|
||||
## SpacetimeDB 表目录
|
||||
@@ -291,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`
|
||||
|
||||
@@ -458,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`
|
||||
|
||||
@@ -530,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` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,当前生产 API / worker / controller 因此继承同一 runtime token;迁移操作员不自动获得在线运行权限,且 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`
|
||||
@@ -680,12 +703,14 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ProfileMembership`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at/cycle_period_days` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点。
|
||||
|
||||
### `profile_recharge_product_config`
|
||||
|
||||
- Rust 结构体:`ProfileRechargeProductConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护。
|
||||
- 字段补充:会员商品追加 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps`;泥点商品这些字段必须为 `0`。
|
||||
|
||||
### `profile_played_world`
|
||||
|
||||
@@ -696,12 +721,19 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ProfileRechargeOrder`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:账户充值订单事实源。`status` 包含 `pending`、`paid`、`failed`、`closed`、`refunded`、`expired`;过期补偿字段 `expired_at`、`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于记录本地过期和微信查单结果。
|
||||
|
||||
### `profile_recharge_order_expiration_schedule`
|
||||
|
||||
- Rust 结构体:`ProfileRechargeOrderExpirationSchedule`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:普通微信充值订单的到期查单调度表。表内只保存待检查订单、计划检查时间和 worker 短租约;支付成功或本地关闭后删除对应行。
|
||||
- 作用:旧普通微信充值订单到期查单调度表,保留 schema 兼容但当前不再写入;当前过期路径改由原生 scheduled 表 `profile_recharge_order_expiration_timer` 触发。
|
||||
|
||||
### `profile_recharge_order_expiration_timer`
|
||||
|
||||
- Rust 结构体:`ProfileRechargeOrderExpirationTimer`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:充值订单原生 scheduled 表,`scheduled_at` 到点触发 `expire_profile_recharge_order_timer`;`order_id` 唯一,支付成功、本地关闭或 scheduled reducer 执行后删除对应行。
|
||||
|
||||
### `profile_redeem_code`
|
||||
|
||||
@@ -744,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 暂不可达时的兜底。只有 HTTP 角色会在启动恢复阶段用当前本地缓存尝试初始化空的 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。`external-generation-worker` / `external-generation-controller` 不调用定价 initializer,而是在启动时通过只读的 queue-stats procedure 验证当前 SpacetimeDB identity 是否具备队列运行权限。生产 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 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。
|
||||
后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为表内 `writer_identity`;procedure 对外返回的定价快照不包含该身份字段,公开主站和后台仍只经 BFF 读取价格。表已存在时 initializer 只接受同一 writer,bootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer;若运行中的配置行意外缺失,保存请求会携带 `AppConfig` 已读取的受保护 bootstrap secret 完成原子首次写入,已有配置不会消费该 secret,也不会隐式轮换 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`,迁移操作员身份不自动获得在线生成或钱包权限;因此当前生产 API、worker 和 controller 必须继承同一份 runtime token。非 HTTP 角色只做 queue procedure 鉴权预检,不具备 seed 或轮换身份的职责。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 保存 / 重启读取、仅 HTTP 角色 seed、非 HTTP 角色 queue-stats 鉴权预检、路由保存与公开读取不返回 `writer_identity` 的测试。
|
||||
- 外部生成任务在配置变更前后仍按入队价格扣费并落资产的测试;旧 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` 也要保留同样分辨率定价配置,但前端模型菜单不展示。
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- 支持自定义画面比例和大小尺寸。
|
||||
- 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`。
|
||||
- 默认画面比例为 `16:9`,默认大小为 `1K`。
|
||||
- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端先在 11 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端先在 12 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
|
||||
## 提示词契约
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
|
||||
|
||||
```text
|
||||
生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光;禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
|
||||
生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景使用后端自动决策出的单一纯色抠像底色;纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带与抠像底色相近的描边、投影或反光;禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
|
||||
动作描述:
|
||||
<用户输入的动画描述>
|
||||
```
|
||||
@@ -162,9 +162,9 @@
|
||||
|
||||
- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。
|
||||
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
|
||||
- 每帧必须先把带 legacy `#00FF00` 绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。角色动作暂不接入角色 / 图标生图的 `screenColor` 选择。
|
||||
- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再优先调用阿里云通用抠图输出透明背景 PNG;阿里云失败时降级执行本地 `editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
|
||||
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
|
||||
- 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。
|
||||
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、绿幕去背和帧素材落盘流程。
|
||||
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、透明化处理和帧素材落盘流程。
|
||||
|
||||
@@ -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}"
|
||||
'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user