合并 master 的画布资源命名更新
合并 origin/master 的资源命名、图集拆分告警与相关后端契约更新 保留编辑 Agent 完成任务后的任务列表和画布刷新流程 保留编辑生成结果的轻量化持久化处理
This commit is contained in:
@@ -553,6 +553,8 @@ export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
allowedPublicUserCodes: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileInviteCodeRequest {
|
||||
@@ -614,6 +616,8 @@ export interface ProfileRedeemCodeAdminResponse {
|
||||
globalUsedCount: number;
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
disableProfileRedeemCode,
|
||||
listProfileRedeemCodes,
|
||||
upsertProfileRedeemCode,
|
||||
} from '../api/adminApiClient';
|
||||
import type { ProfileRedeemCodeAdminResponse } from '../api/adminApiTypes';
|
||||
import { AdminRedeemCodePage } from './AdminRedeemCodePage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
disableProfileRedeemCode: vi.fn(),
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
listProfileRedeemCodes: vi.fn(),
|
||||
upsertProfileRedeemCode: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseEntry: ProfileRedeemCodeAdminResponse = {
|
||||
code: 'LONG-LIVED',
|
||||
mode: 'public',
|
||||
rewardPoints: 100,
|
||||
maxUses: 1,
|
||||
globalUsedCount: 0,
|
||||
enabled: true,
|
||||
allowedUserIds: [],
|
||||
startsAt: null,
|
||||
expiresAt: null,
|
||||
createdBy: 'admin',
|
||||
createdAt: '2026-07-13T01:00:00Z',
|
||||
updatedAt: '2026-07-13T01:00:00Z',
|
||||
};
|
||||
|
||||
const entries: ProfileRedeemCodeAdminResponse[] = [
|
||||
baseEntry,
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'PENDING',
|
||||
startsAt: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'EXPIRED',
|
||||
expiresAt: '2000-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'ACTIVE',
|
||||
startsAt: '2000-01-01T00:00:00Z',
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
...baseEntry,
|
||||
code: 'DISABLED',
|
||||
enabled: false,
|
||||
startsAt: '2000-01-01T00:00:00Z',
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listProfileRedeemCodes).mockResolvedValue({
|
||||
entries,
|
||||
operations: [],
|
||||
});
|
||||
vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry);
|
||||
vi.mocked(disableProfileRedeemCode).mockResolvedValue({
|
||||
...baseEntry,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('兑换码列表展示生效状态与日期范围', async () => {
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect((await rowForCode('LONG-LIVED')).textContent).toContain('长期有效');
|
||||
expect((await rowForCode('LONG-LIVED')).textContent).toContain('立即 / 长期');
|
||||
expect((await rowForCode('PENDING')).textContent).toContain('未生效');
|
||||
expect((await rowForCode('EXPIRED')).textContent).toContain('已过期');
|
||||
expect((await rowForCode('ACTIVE')).textContent).toContain('有效');
|
||||
expect((await rowForCode('DISABLED')).textContent).toContain('停用');
|
||||
});
|
||||
|
||||
test('点击兑换码回填本地日期输入', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'ACTIVE' }));
|
||||
|
||||
expect((screen.getByLabelText('开始时间') as HTMLInputElement).value).toBe(
|
||||
toLocalInputValue('2000-01-01T00:00:00Z'),
|
||||
);
|
||||
expect((screen.getByLabelText('截止时间') as HTMLInputElement).value).toBe(
|
||||
toLocalInputValue('2999-01-01T00:00:00Z'),
|
||||
);
|
||||
});
|
||||
|
||||
test('兑换码拒绝截止时间不晚于开始时间的配置', async () => {
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByRole('button', { name: 'LONG-LIVED' });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Code'), {
|
||||
target: { value: 'INVALID' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('开始时间'), {
|
||||
target: { value: '2026-07-13T10:00' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('截止时间'), {
|
||||
target: { value: '2026-07-13T10:00' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('截止时间必须晚于开始时间')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(upsertProfileRedeemCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('兑换码保存时把本地时间转换为 ISO 并保留空边界', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByRole('button', { name: 'LONG-LIVED' });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Code'), {
|
||||
target: { value: 'WINDOWED' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('开始时间'), {
|
||||
target: { value: '2026-07-13T10:30' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertProfileRedeemCode).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
code: 'WINDOWED',
|
||||
startsAt: new Date('2026-07-13T10:30').toISOString(),
|
||||
expiresAt: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function rowForCode(code: string) {
|
||||
const button = await screen.findByRole('button', { name: code });
|
||||
const row = button.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`未找到兑换码 ${code} 所在行`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function toLocalInputValue(value: string) {
|
||||
const date = new Date(value);
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export function AdminRedeemCodePage({
|
||||
const [rewardPoints, setRewardPoints] = useState('100');
|
||||
const [maxUses, setMaxUses] = useState('1');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [startsAt, setStartsAt] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [allowedUserIds, setAllowedUserIds] = useState('');
|
||||
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
@@ -73,6 +75,12 @@ export function AdminRedeemCodePage({
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
const validityError = validateValidityWindow(startsAt, expiresAt);
|
||||
if (validityError) {
|
||||
setErrorMessage(validityError);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmWrite({
|
||||
action: '保存兑换码',
|
||||
target: code.trim(),
|
||||
@@ -92,6 +100,8 @@ export function AdminRedeemCodePage({
|
||||
allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [],
|
||||
allowedPublicUserCodes:
|
||||
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
|
||||
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
|
||||
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
|
||||
});
|
||||
fillForm(response);
|
||||
await refreshRedeemCodes();
|
||||
@@ -137,11 +147,15 @@ export function AdminRedeemCodePage({
|
||||
setRewardPoints(String(entry.rewardPoints));
|
||||
setMaxUses(String(entry.maxUses));
|
||||
setEnabled(entry.enabled);
|
||||
setStartsAt(toDateTimeLocalValue(entry.startsAt));
|
||||
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
|
||||
setAllowedUserIds(entry.allowedUserIds.join('\n'));
|
||||
setAllowedPublicUserCodes('');
|
||||
setDisableCode(entry.code);
|
||||
}
|
||||
|
||||
const validityError = validateValidityWindow(startsAt, expiresAt);
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
@@ -222,6 +236,25 @@ export function AdminRedeemCodePage({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>开始时间</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startsAt}
|
||||
onChange={(event) => setStartsAt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>截止时间</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={expiresAt}
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === 'private' ? (
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
@@ -250,6 +283,11 @@ export function AdminRedeemCodePage({
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{validityError && validityError !== errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{validityError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
@@ -257,7 +295,8 @@ export function AdminRedeemCodePage({
|
||||
isSaving ||
|
||||
!code.trim() ||
|
||||
!parsePositiveInteger(rewardPoints) ||
|
||||
!parsePositiveInteger(maxUses)
|
||||
!parsePositiveInteger(maxUses) ||
|
||||
Boolean(validityError)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
@@ -280,6 +319,7 @@ export function AdminRedeemCodePage({
|
||||
<th>Code</th>
|
||||
<th>奖励</th>
|
||||
<th>状态</th>
|
||||
<th>有效期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -296,7 +336,16 @@ export function AdminRedeemCodePage({
|
||||
<small>{redeemModeLabel(entry.mode)}</small>
|
||||
</td>
|
||||
<td>{entry.rewardPoints}</td>
|
||||
<td>{entry.enabled ? '启用' : '停用'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-status ${redeemValidityClass(entry)}`}
|
||||
>
|
||||
{redeemValidityLabel(entry)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<small>{formatValidityWindow(entry)}</small>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -400,3 +449,76 @@ function formatDateTime(value: string) {
|
||||
}
|
||||
return date.toLocaleString('zh-CN', {hour12: false});
|
||||
}
|
||||
|
||||
function validateValidityWindow(startsAt: string, expiresAt: string) {
|
||||
if (!startsAt || !expiresAt) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startsAtTime = Date.parse(toIsoDateTime(startsAt));
|
||||
const expiresAtTime = Date.parse(toIsoDateTime(expiresAt));
|
||||
if (!Number.isFinite(startsAtTime) || !Number.isFinite(expiresAtTime)) {
|
||||
return '有效期时间无效';
|
||||
}
|
||||
|
||||
return startsAtTime < expiresAtTime ? '' : '截止时间必须晚于开始时间';
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: string) {
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time)) {
|
||||
throw new Error('有效期时间无效');
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value?: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function redeemValidityLabel(entry: ProfileRedeemCodeAdminResponse) {
|
||||
if (!entry.enabled) {
|
||||
return '停用';
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null;
|
||||
const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null;
|
||||
if (startsAtTime !== null && Number.isFinite(startsAtTime) && now < startsAtTime) {
|
||||
return '未生效';
|
||||
}
|
||||
if (expiresAtTime !== null && Number.isFinite(expiresAtTime) && now >= expiresAtTime) {
|
||||
return '已过期';
|
||||
}
|
||||
if (entry.startsAt || entry.expiresAt) {
|
||||
return '有效';
|
||||
}
|
||||
return '长期有效';
|
||||
}
|
||||
|
||||
function redeemValidityClass(entry: ProfileRedeemCodeAdminResponse) {
|
||||
const label = redeemValidityLabel(entry);
|
||||
if (label === '停用' || label === '已过期') {
|
||||
return 'admin-status-error';
|
||||
}
|
||||
if (label === '未生效') {
|
||||
return 'admin-status-pending';
|
||||
}
|
||||
return 'admin-status-ok';
|
||||
}
|
||||
|
||||
function formatValidityWindow(entry: ProfileRedeemCodeAdminResponse) {
|
||||
const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即';
|
||||
const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期';
|
||||
return `${startsAt} / ${expiresAt}`;
|
||||
}
|
||||
|
||||
@@ -121,17 +121,21 @@ button:disabled {
|
||||
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
grid-template-columns: 232px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
border-right: 1px solid #e1ccbb;
|
||||
background: #ffffff;
|
||||
padding: 22px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-brand strong {
|
||||
@@ -176,7 +180,9 @@ button:disabled {
|
||||
.admin-main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-rows: 64px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
@@ -205,6 +211,7 @@ button:disabled {
|
||||
|
||||
.admin-content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const stylesheet = fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'apps/admin-web/src/styles/admin.css'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('admin shell scrolling contract', () => {
|
||||
test('desktop shell keeps sidebar separate from the scrollable content', () => {
|
||||
expect(ruleFor('.admin-shell')).toContain('\n height: 100dvh;');
|
||||
expect(ruleFor('.admin-shell')).toContain('overflow: hidden');
|
||||
expect(ruleFor('.admin-sidebar')).toContain('min-height: 0');
|
||||
expect(ruleFor('.admin-sidebar')).toContain('overflow-y: auto');
|
||||
expect(ruleFor('.admin-main')).toContain('min-height: 0');
|
||||
expect(ruleFor('.admin-main')).toContain('overflow: hidden');
|
||||
expect(ruleFor('.admin-content')).toContain('min-height: 0');
|
||||
expect(ruleFor('.admin-content')).toContain('overflow: auto');
|
||||
});
|
||||
});
|
||||
|
||||
function ruleFor(selector: string) {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = stylesheet.match(
|
||||
new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`Missing CSS rule for ${selector}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
@@ -298,6 +298,7 @@ server {
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
error_page 404 /404.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
|
||||
@@ -318,6 +318,7 @@ server {
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
error_page 404 /404.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
|
||||
@@ -10,7 +10,26 @@ if ($genarrative_internal_client) {
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
root /srv/genarrative/web;
|
||||
root /var/lib/genarrative/maintenance;
|
||||
try_files /page.html @genarrative_default_maintenance;
|
||||
add_header Cache-Control "no-store";
|
||||
internal;
|
||||
}
|
||||
|
||||
location @genarrative_default_maintenance {
|
||||
root /srv/genarrative/web;
|
||||
try_files /maintenance.html =503;
|
||||
add_header Cache-Control "no-store";
|
||||
internal;
|
||||
}
|
||||
|
||||
location = /404.html {
|
||||
if ($http_accept !~* "text/html") {
|
||||
return 404;
|
||||
}
|
||||
|
||||
root /srv/genarrative/web;
|
||||
try_files /404.html =404;
|
||||
add_header Cache-Control "no-store";
|
||||
internal;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ GENARRATIVE_PINGORA_GATEWAY_SPACETIME_UPSTREAM=127.0.0.1:3101
|
||||
GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT=/srv/genarrative/web
|
||||
GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT=/var/www/html
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/enabled
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html
|
||||
|
||||
GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864
|
||||
|
||||
@@ -2925,6 +2925,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"assetLabel": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 80,
|
||||
"description": "写入素材库时使用的图标图集名称;空白或省略时使用自动名称。"
|
||||
},
|
||||
"canvasCompletion": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3064,6 +3072,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EditorIconSpritesheetSliceWarning": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "自动拆分未完成的稳定原因码。"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "自动拆分未完成的可诊断原因。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorIconSpritesheetGenerationResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -3090,11 +3116,22 @@
|
||||
},
|
||||
"iconImageSrcs": {
|
||||
"type": "array",
|
||||
"description": "兼容旧客户端的切片素材列表;图标素材生成固定为空,UI 设计图提取素材可返回切片。",
|
||||
"description": "按图集 alpha 连通域拆分并持久化的独立素材列表。",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
|
||||
}
|
||||
},
|
||||
"sliceWarning": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorIconSpritesheetSliceWarning"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3256,6 +3293,21 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"assetFolderId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "角色动作绿幕预览视频写入账号素材库的文件夹;省略时写入默认项目素材文件夹。"
|
||||
},
|
||||
"assetLabel": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 80,
|
||||
"description": "角色动作素材名称;空白或省略时使用自动名称。原始预览视频以该名称追加(原始视频)保存。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -23,6 +23,23 @@
|
||||
- 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。
|
||||
|
||||
## 2026-07-13 图片画布生成资源支持提交前统一命名
|
||||
|
||||
- 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。
|
||||
- 决策:主生成状态统一使用可选 `assetLabel`,所有生成面板提供同一个资源名称输入。名称最多 80 个字符,提交时去除首尾空格;留空时继续使用现有自动编号名称。一次提交解析出的名称必须同时用于 `assetLabel`、`canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。
|
||||
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、图片修改 provider 原始输出等中间产物基于主名称追加“(原图)”或“(原始输出)”;图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。
|
||||
- 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。
|
||||
- 验证方式:覆盖自定义名、空白回退、长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-13 图片画布多产物生成任务必须保存全部可恢复产物
|
||||
|
||||
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。
|
||||
- 决策:凡一次资产生成任务产生多个可恢复产物,后端必须先把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、尺寸恢复、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果;图片修改保留模型原始输出与尺寸恢复结果;角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。普通图片、去背景、音频等没有独立上游中间产物的任务不制造重复副本。
|
||||
- 画布与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。
|
||||
- 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-07-12 泥点充值收敛为四档并统一资产入口
|
||||
|
||||
- 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。
|
||||
@@ -325,7 +342,7 @@
|
||||
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
|
||||
|
||||
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留透明 spritesheet 图集,不再额外铺独立图标。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成先把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。本条新决策取代“图标素材生成只保留图集”的旧口径。
|
||||
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
|
||||
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。
|
||||
@@ -3984,6 +4001,7 @@
|
||||
|
||||
- 背景:主站 Nginx 和 Pingora 原先会把任意未知路径回退到 `index.html`,导致 soft 404;共享 `index.html` 也缺少首页 SEO head,robots 和 sitemap 请求会落入 SPA fallback。
|
||||
- 决策:新增真实 `robots.txt` 和仅首页的 `sitemap.xml`,首页共享 head 提供基础 SEO/OG/JSON-LD 文本但不使用未确认的 image/logo URL;首页 DOM 只保留一个稳定产品定位 H1。Nginx 与 Pingora 只允许当前完整 SPA 路径回退 `index.html`,同前缀未知路径必须返回 404;`/admin` 继续走独立子应用。路由变化必须同步三套 Nginx、Pingora、route parity matrix 和自动门禁。
|
||||
- 2026-07-13 补充:浏览器导航到 Web 未知路径时继续保持 HTTP `404`,但正文统一返回 `public/404.html` 品牌页面和 `public/branding/taonier-404-page.png`;API、探针及非 HTML 请求仍返回原有 `404` 响应,不得把品牌页 HTML 混入接口响应。Nginx 最终 Web catch-all 与 Pingora `Accept: text/html` 分支保持一致。
|
||||
- 影响范围:`index.html`、`public/robots.txt`、`public/sitemap.xml`、首页组件、三套 Nginx、Pingora 网关和路由 parity 门禁。
|
||||
- 验证方式:前端定向测试与构建、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`npm run check:pingora-gateway-smoke`、`npm run check:encoding`、`git diff --check`,部署后同时抽查根级未知路径和 `/creation/not-exist` 等同前缀未知路径。
|
||||
|
||||
@@ -4027,3 +4045,36 @@
|
||||
- 边界:查单使用订单所属用户的小程序 `openid`,不把 AppKey、AppSecret、access token 或 `openid` 下发前端;虚拟支付不得误用微信支付 V3 查单。
|
||||
- 历史单:升级前遗留的 pending 订单不会被 expiration catch-up 覆盖,使用 `spacetime:wechat-virtual-payment:reconcile` 逐单 dry-run,再使用当次 `applyFingerprint` 明确 `--apply`。脚本每次重读本地订单与微信查单结果,指纹漂移、非 `2/3/4`、单号/金额/支付类型 `order_type=0/7` 不一致或非官方 endpoint 时默认拒绝入账。
|
||||
- 验证方式:`cargo test -p platform-wechat --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml virtual_payment_query`、`npm run check:wechat-virtual-payment-reconcile`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-13 临时维护公告改为 release 外运行态覆盖
|
||||
|
||||
- 背景:一次性停服公告曾直接提交到 `public/maintenance.html`,后续 Web Build 将它持续打入 `web.tar.gz`,每次 Web Deploy 或再次进入维护都会重新显示已经过期的公告。
|
||||
- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`,Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。
|
||||
- 生命周期:新维护窗口未提供 `--page-file` 时清理 marker 外残留公告;同一窗口内 Stdb / API 发布重复调用 `maintenance-on.sh` 时保留已安装公告;`maintenance-off.sh` 同时清理 marker 和公告页。Web Deploy 不再拥有临时公告事实源。
|
||||
- 影响范围:默认维护页、维护开关脚本、Nginx snippet、Pingora 配置与 smoke、生产 Web 发布包门禁和生产运维文档。
|
||||
- 验证方式:`npm run check:maintenance-page`、`npm run check:nginx-spa-routes`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-13 兑换码生效日期范围对齐邀请码
|
||||
|
||||
- 背景:后台邀请码已支持可选开始时间和截止时间,但兑换码只有启用状态,运营无法预设活动时间窗口,用户兑换也没有后端时间边界校验。
|
||||
- 决策:`profile_redeem_code` 在已有字段末尾追加可空 `starts_at` / `expires_at`,默认均为空;后台请求和响应使用可空 `startsAt` / `expiresAt`。时间窗口与邀请码一致:空边界合法,双边界必须严格满足开始早于截止,有效区间为 `[starts_at, expires_at)`。
|
||||
- 兑换边界:时间是后端事实,`redeem_profile_reward_code` 必须用经后端构造的 `redeemed_at_micros` 拒绝未生效或已过期的兑换码;前端的状态标签只用于运营展示,不代替后端校验。
|
||||
- 影响范围:`module-runtime` 兑换码命令与校验、`spacetime-module` schema / migration / procedure、生成 bindings、`spacetime-client`、`shared-contracts` / `packages/shared`、`api-server` 和 `apps/admin-web` 兑换码页。
|
||||
|
||||
## 2026-07-13 后台侧边栏与主内容独立滚动
|
||||
|
||||
- 背景:后台壳层只给 `.admin-shell` 设置 `min-height: 100dvh`,长页面会撑高 document;滚动时侧边栏与主内容一起移出视口,`.admin-content` 上的 `overflow: auto` 没有成为真正滚动容器。
|
||||
- 决策:后台壳层固定为 `height: 100dvh` 并隐藏壳层 overflow;桌面侧边栏使用独立 `overflow-y: auto`,`.admin-main` 通过 `min-height: 0` 和 `overflow: hidden` 约束网格,`.admin-content` 使用 `min-height: 0; overflow: auto` 独立滚动。
|
||||
- 响应式边界:小于等于 `980px` 时仍隐藏桌面侧边栏,主内容在视口高度内滚动,底部导航继续固定。
|
||||
- 验证方式:`apps/admin-web/src/styles/admin.test.ts` 锁定壳层滚动契约;桌面浏览器滚动后应保持 `window.scrollY = 0`、侧边栏 `top = 0`,只改变 `.admin-content.scrollTop`;移动视口继续由 `.admin-content` 滚动。
|
||||
|
||||
## 2026-07-13 公开作品资产使用派生精确读授权
|
||||
|
||||
- 背景:资产 ACL 严格执行后,已登记为 `private` 的作品封面和正式资产不能再依赖 generated 前缀匿名读取;但公开作品仍需要允许访客读取它实际展示和运行的资产。
|
||||
- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;资产读取 procedure 在同一事务快照内组合 `asset_object` 与该 view,不从连接级长期订阅 cache 判断 ACL。
|
||||
- 授权边界:grant 携带作品 owner,API 只有在它与 `asset_object.owner_user_id` 一致,且 `asset_object_id` 或精确 `object_key` 命中时才允许匿名读取。隐藏、删除或取消发布会使 grant 自动消失;参考图、未选中候选图和 `generationInputs` 明确排除。Custom World 只遍历角色、地标、营地、章节和 opening CG 等已知正式根,不能递归 legacy payload 的未知预览 / 编辑字段。
|
||||
- 禁止项:不得通过放开 `generated-*` 前缀或批量把历史对象改为 `PublicRead` 修复公开作品,两种方式都会让作品可见性生命周期与资产授权脱节,并重新引入跨账号读取。
|
||||
- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view 与权威读取 procedure、`spacetime-client` facade 和 `api-server` 资产读取 ACL。
|
||||
- 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。
|
||||
- 一致性:隐藏、删除或取消发布提交后,后续读取 procedure 的事务快照立即按新状态判断,不等待任意池连接追上订阅水位。公开派生授权、`PublicRead` 和 legacy 兼容读取签名 URL 的有效期最多 600 秒,因此该能力仍不是对既有签名的瞬时吊销机制;owner / admin 读取保持原有效期口径。
|
||||
- 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后新的读取请求立即拒绝,再恢复公开可见时新的读取请求立即恢复;超长公开 `expireSeconds` 被截断为 600 秒。
|
||||
|
||||
@@ -152,11 +152,11 @@
|
||||
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio` 中音效请求体测试必须同时断言 `prompt` 与 `sound`;必要时用线上生成音效 smoke 确认不再出现 `missing field sound`。
|
||||
- 关联:`server-rs/crates/platform-audio/src/request.rs`、`server-rs/crates/platform-audio/tests/vector_engine_audio.rs`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。
|
||||
|
||||
## Suno 任务完成不代表已经拿到 wav 下载地址
|
||||
## Suno 任务完成或返回 audiopipe 不代表已经拿到稳定下载地址
|
||||
|
||||
- 现象:画板生成背景音乐时,前端报 `音频生成尚未返回可下载地址(requestId:...)`;画板生成音效时,前端可能报 `获取 Suno 音效 wav 失败(requestId:...)`。上游任务可能已经完成,但 wav 下载地址还没就绪。
|
||||
- 原因:VectorEngine Suno `/suno/fetch/{task_id}` 可能先在 `data` 中返回歌曲 / 音效 clip id,而不是直接返回 `.wav` / `.mp3` URL;需要再调用 `/suno/act/wav/{clipId}` 获取 `wav_file_url`。如果只兼容 `data` 是字符串,会漏掉 `data` 对象 / 数组里的 `id`、`clip_id`、`audioId` 或 `songId`。
|
||||
- 处理:`platform-audio` 查询 Suno 结果时先提取直接音频 URL;没有 URL 时,从 `data` 字符串、对象或数组提取 clip id,逐个调用 `/suno/act/wav/{clipId}`。已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求暂时返回上游错误时,都保持 `processing` 让上层继续轮询,不能直接判定为缺少可下载地址或 wav 获取失败。
|
||||
- 现象:画板生成背景音乐时,前端可能报 `音频生成尚未返回可下载地址(requestId:...)`、`获取 Suno 音效 wav 失败(requestId:...)` 或 `读取生成音频内容失败:error decoding response body`。上游任务可能已经完成并返回 `https://audiopipe.suno.ai/?item_id=...`,但该地址仍可能以 `200 + chunked` 开始响应后不返回完整正文。
|
||||
- 原因:VectorEngine Suno `/suno/fetch/{task_id}` 可能先在 `data` 中返回歌曲 / 音效 clip id,或返回只携带 `item_id` 的 audiopipe 流式中转地址,而不是稳定 `.wav` / `.mp3` 文件 URL;需要再调用 `/suno/act/wav/{clipId}` 获取实际文件地址。如果只在“完全没有 URL”时回退 wav,会误把 audiopipe 当最终文件并让 worker 在正文读取阶段卡满请求超时。
|
||||
- 处理:`platform-audio` 查询 Suno 结果时保留普通直接音频 URL;遇到 audiopipe 时不直接下载,而是从查询结果的 `id` / `clip_id` / `audioId` / `songId` 或 audiopipe `item_id` 提取 clip id,逐个调用 `/suno/act/wav/{clipId}`。已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求暂时返回上游错误时,都保持 `processing` 让上层继续轮询,不能直接判定为缺少可下载地址或 wav 获取失败。
|
||||
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml`;`cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/platform-audio/src/client.rs`、`server-rs/crates/platform-audio/src/response.rs`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。
|
||||
|
||||
@@ -368,12 +368,12 @@
|
||||
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`;后端验证至少覆盖 `editor_image_edit_request_omits_price_mud_points` 和 `editor_image_edit_can_complete_by_replacing_target_layer`。
|
||||
- 关联:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布快速编辑尺寸要区分业务目标和 provider 对齐尺寸
|
||||
## 图片画布快速编辑尺寸要区分用户目标和 provider 对齐尺寸
|
||||
|
||||
- 现象:原图经过快速编辑后 Resolution 变成近似比例的 1K / 2K 预设;原图或框选标记图宽高不是 16 的倍数时,VectorEngine edits 直接拒绝请求。
|
||||
- 原因:前端已有源图精确 `originalWidth/originalHeight`,提交时却按最近常用比例和 K 档重新计算 `size`;后端又把非 16 倍数的目标尺寸和原始参考图字节直接放进 multipart,并以 provider 回图宽高落库和覆盖画布图层。
|
||||
- 处理:画布快速编辑提交源图精确尺寸作为业务目标;api-server 只在 provider 边界向右、向下复制边缘像素,把每张参考图和目标尺寸临时补齐到 16 的倍数,收到回图后裁回业务目标尺寸再持久化。若上游异常返回其他尺寸,先按目标比例裁切缩放;临时对齐尺寸不能进入 OSS 元数据、`editor_project_resource`、`editor_asset` 或画布 Resolution。前端 inline 回填也保留源图显示尺寸和 Resolution,避免旧回包再次放大图层。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖 `1537x1025` 精确提交和源图回填;`cargo test -p api-server editor_image_edit_aligns_provider_images_and_restores_source_dimensions --manifest-path server-rs/Cargo.toml` 覆盖 provider `1552x1040`、额外参考图独立对齐和回图恢复。
|
||||
- 处理:画布快速编辑展示与常规图片生成一致的模型、比例和尺寸参数,默认继承来源生成器参数;缺少来源生成器时使用图层模型,并按真实分辨率推导比例和尺寸。用户当前选定的比例和尺寸共同决定业务目标分辨率,允许覆盖源图旧分辨率;api-server 只在 provider 边界向右、向下复制边缘像素,把每张参考图和目标尺寸临时补齐到 16 的倍数,收到回图后裁回业务目标尺寸再持久化。若上游异常返回其他尺寸,先按目标比例裁切缩放;临时对齐尺寸不能进入 OSS 元数据、`editor_project_resource`、`editor_asset` 或画布 Resolution。结果覆盖目标图层时更新原始分辨率,并保持图层中心位置不跳动。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布快速编辑元数据必须记录原图引用
|
||||
@@ -2964,8 +2964,8 @@
|
||||
|
||||
- 现象:`/not-exist` 已返回 404,但 `/creation/not-exist`、`/runtime/not-exist` 或 `/puzzle/not-exist` 仍返回 200 首页,搜索引擎继续判定为 soft 404。
|
||||
- 原因:Nginx 或 Pingora 使用 `/creation/*`、`/runtime/*` 等宽前缀作为 SPA fallback,前端对未知路径又回到平台首页;只验收根级未知 URL 无法发现该问题。
|
||||
- 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件,失败返回 404。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。
|
||||
- 验证:除全部真实 SPA 路径外,至少检查 `/not-exist`、`/creation/not-exist`、`/runtime/not-exist` 和 `/puzzle/not-exist` 均返回 404;维护模式仍保持页面 503 优先语义。
|
||||
- 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件。浏览器 HTML 导航失败时返回品牌 `404.html`,但状态码仍为 404;API、探针和非 HTML 请求保持原有 404 响应。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。
|
||||
- 验证:除全部真实 SPA 路径外,至少检查 `/not-exist`、`/creation/not-exist`、`/runtime/not-exist` 和 `/puzzle/not-exist` 均返回 404;带 `Accept: text/html` 的未知 Web 路径正文命中品牌页,不带 HTML Accept 的请求不得命中品牌页;维护模式仍保持页面 503 优先语义。
|
||||
- 关联:`src/routing/appRoutes.tsx`、`src/routing/appPageRoutes.ts`、`deploy/nginx/`、`deploy/container/nginx.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。
|
||||
|
||||
## Jenkins Job UI 参数会被 SCM Jenkinsfile 覆盖
|
||||
@@ -2991,6 +2991,14 @@
|
||||
- 处理:Full 使用 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 表达产品选择,Stdb Publish 和 API Deploy 全程固定保持维护,Web Deploy 成功后才进入独立最终退出阶段;API Deploy 的独立 `KEEP_MAINTENANCE_MODE` 再转换为脚本 `--keep-maintenance-mode`。API deploy 还必须把 `production-api-deploy.sh`、`maintenance-on.sh` 和 `maintenance-off.sh` 从同一 build artifact 复制进 current release,否则 Full 最终阶段即使有选项也找不到随包退出脚本。默认值仍在 Full 结束时退出维护,避免定时 dev 发布行为变化。
|
||||
- 验证:API deploy fixture 必须覆盖成功发布并保留 marker,还要断言 current release 中三个部署 / 维护脚本存在;生产运维静态门禁同时反查 Full 参数、下游透传、API Deploy 参数和脚本 flag。推送后用 fail-closed 首阶段运行刷新 live Job 参数,再核对 `config.xml`,不能只看仓库文件。
|
||||
|
||||
## 临时维护公告不能提交进版本化默认页
|
||||
|
||||
- 现象:现场已恢复通用维护页,但后续 Web Deploy 或下一次进入维护后,又显示昨天的“今天晚上 HH:MM~HH:MM”公告。
|
||||
- 原因:`public/maintenance.html` 会被 Vite 复制进 `web.tar.gz`,Web Deploy 解包后把 `/srv/genarrative/web` 指向新制品;临时公告一旦进入该源码,就会成为每次发布都恢复的长期内容。旧维护 on / off 只控制 marker,浏览器缓存不是根因。
|
||||
- 处理:版本化默认页只保留无日期通用文案;临时公告用 `maintenance-on.sh --page-file <公告HTML>` 安装到 `/var/lib/genarrative/maintenance/page.html`。Nginx / Pingora 优先读取运行态公告,退出维护时同步清理;不要再原地编辑 `/srv/genarrative/web/maintenance.html` 或提交临时公告到 `public/`。
|
||||
- 验证:`npm run check:maintenance-page` 必须拒绝相对日期、具体日期和具体时间,并覆盖公告安装、同窗口保留、退出清理与新窗口清残留;Pingora smoke 必须证明运行态公告优先且删除后回退默认页。
|
||||
- 关联:`public/maintenance.html`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`deploy/nginx/snippets/genarrative-maintenance.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。
|
||||
|
||||
## 遮罩点击关闭必须校验完整指针序列
|
||||
|
||||
- 现象:在弹窗内容内按下鼠标,拖到弹窗外的遮罩上松开时,弹窗被误关闭。
|
||||
@@ -2998,3 +3006,13 @@
|
||||
- 处理:共享弹窗统一记录 `pointerdown` 与 `pointerup` 的目标,只有按下和松开都发生在遮罩自身时才允许关闭。新增弹窗优先复用 `UnifiedModal`,不要继续复制只判断最终 `click` 目标的手写遮罩逻辑。
|
||||
- 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。
|
||||
- 关联:`src/components/common/UnifiedModal.tsx`、`src/components/common/UnifiedModal.test.tsx`、`src/components/auth/PlatformAuthModalShell.test.tsx`。
|
||||
|
||||
## 公开作品资产不能用 generated 前缀或 PublicRead 批量放行
|
||||
|
||||
- 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`。
|
||||
- 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。
|
||||
- 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible`(`custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。
|
||||
- 权威查询边界:不能从 `asset_object` 或 `public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;同时不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。
|
||||
- Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。
|
||||
- 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`server-rs/crates/api-server/src/assets.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -545,7 +545,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
|
||||
| 主站 SPA allowlist | 只对当前前端完整路由及兼容恢复路径 `/creation/rpg/agent` 失败回退 `/index.html`;匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。 |
|
||||
| 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 |
|
||||
|
||||
维护模式下,公网 API-like 路由返回 JSON `503`,公网 Web 静态路由优先返回 `maintenance.html`,不存在时返回纯文本 `503`。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。
|
||||
维护模式下,公网 API-like 路由返回 JSON `503`;公网 Web 静态路由先读取 `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE` 指向的 release 外运行态公告,缺失时回退 `GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT/maintenance.html`,两者都不存在时返回纯文本 `503`。版本化默认页不得包含日期或具体时段,临时公告由 `maintenance-on.sh --page-file` 安装并在 `maintenance-off.sh` 时清理。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。
|
||||
代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。
|
||||
静态 `Range` 只支持单段 bytes range;多段 range 暂按完整文件返回,避免在正式替换前引入 multipart 响应面。`If-None-Match` / `If-Modified-Since` 优先于 `Range` 判定,命中时仍返回 `304`;`If-Range` 日期匹配时继续返回 `206`,日期旧于文件或弱 ETag 校验器时回完整 `200`;`206` / `304` / `416` 不做 gzip 压缩,避免 `Content-Range` 语义被响应体改写破坏。Gateway smoke 会用固定 `X-Request-Id` 对账静态 `304`、`405`、`206`、`416` 的 Pingora access log 行,确认本地响应状态也进入正式切换证据链。
|
||||
静态路由只允许 `GET` / `HEAD` 读取;其它方法在确认命中静态候选后返回 `405` 并写入 `Allow: GET, HEAD`,缺失文件仍返回 `404`,避免直连后错误客户端把静态入口当作可写接口。
|
||||
|
||||
@@ -58,7 +58,7 @@ 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-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` 授权的同源代理。
|
||||
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算 `public_work_asset_read_grant`;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 的公开作品派生授权精确读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品只获得与正式发布快照生命周期一致的派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;公开派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。
|
||||
- 外部 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`。
|
||||
@@ -172,7 +172,7 @@ npm run check:server-rs-ddd
|
||||
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 维持。
|
||||
7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;公开作品资产读取授权投影是 `public_work_asset_read_grant`;各玩法既有 `*_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. 修改后运行:
|
||||
@@ -270,13 +270,13 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,但用户可见任务列表、价格、状态、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。
|
||||
- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;图标图集生成和 UI 素材提取成功但自动拆分降级时,worker 的 `result_payload_json` 只额外保存有界的 `warning.code/reason`,不保存 spritesheet、切片列表或媒体 URL。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。
|
||||
|
||||
### `external_generation_job_summary`
|
||||
|
||||
- Rust 结构体:`ExternalGenerationJobSummary`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。
|
||||
- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、有界非阻断告警 `warning_message`、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误与告警摘要都不复制内联媒体并限制为 2048 字符;`warning_message` 由完成任务的轻量 `result_payload_json.warning.reason` 提取,complete 和历史 backfill 共用同一构建路径。列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。
|
||||
- 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return` 与 `backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。
|
||||
|
||||
### `external_generation_job_event`
|
||||
@@ -309,7 +309,14 @@ 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 白名单兼容。
|
||||
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询与公开作品派生授权,procedure 只允许 runtime service identity 调用。`spacetime-client` 不订阅全量 `asset_object`,也不把公开资产授权 view 的连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。
|
||||
|
||||
### SpacetimeDB view:`public_work_asset_read_grant`
|
||||
|
||||
- Rust view:`public_work_asset_read_grant`
|
||||
- 返回类型:`Vec<PublicWorkAssetReadGrant>`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/public_asset_access.rs`
|
||||
- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;资产读取 procedure 在自己的事务快照中直接执行该 view,作品隐藏、删除或取消发布提交后不能再被不同连接的陈旧订阅 cache 继续授权。已签发的公开 URL 最长保留 600 秒,能力本身不承诺撤销已经签出的 OSS URL。投影明确排除参考图、未选中候选图和 `generationInputs`;Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。
|
||||
|
||||
### `auth_identity`
|
||||
|
||||
@@ -709,6 +716,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ProfileInviteCode`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 生效时间:`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。
|
||||
|
||||
### `profile_code_operation`
|
||||
|
||||
@@ -756,6 +764,8 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ProfileRedeemCode`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 生效时间:复用邀请码时间窗口语义,`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。用户兑换时以后端接收的 `redeemed_at_micros` 判定:未到开始时间拒绝为“兑换码未生效”,到达或超过截止时间拒绝为“兑换码已过期”。
|
||||
- 后台契约:`AdminUpsertProfileRedeemCodeRequest` 通过可空 `startsAt` / `expiresAt` 接收 RFC3339 时间,列表与保存响应同步返回这两个字段;后台页只负责输入、回填和显示,真正兑换判定留在后端事务路径。
|
||||
|
||||
### `profile_redeem_code_usage`
|
||||
|
||||
@@ -942,7 +952,8 @@ npm run check:server-rs-ddd
|
||||
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'`
|
||||
- `SELECT * FROM creation_entry_config`
|
||||
- `SELECT * FROM creation_entry_type_config`
|
||||
- `SELECT * FROM asset_object`
|
||||
|
||||
private `asset_object` 不进入长期订阅;安全判断通过受限 procedure 在服务端按主键或 `(bucket, object_key)` 索引读取事务内真相,避免全表复制、订阅失败和增量同步延迟把已登记私有对象误判为 legacy 未登记对象。
|
||||
|
||||
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry` 与 `public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@ Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸
|
||||
|
||||
该规则只绕过网关维护响应,不会自动拉起 api-server、SpacetimeDB 或其它已停止的服务。人工执行 `maintenance-on.sh` 且后端仍运行时,可以从内网继续访问整站和修改后台数据;`pause-after-stdb` 会停止旧 API/controller/worker,在 API 被停期间静态页面可能仍可加载,但普通 API 与 `/admin/api/**` 仍不可用。验证使用 `npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke` 和 `npm run check:production-ops`,不要在 live 机器上为测试临时创建维护 marker。
|
||||
|
||||
维护页源码固定为 `public/maintenance.html`,正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`。计划内停服需要临时更新公告时,先更新该源码,避免后续 Web Deploy 把现场公告覆盖回旧内容;现场紧急替换必须原子写入当前 `/srv/genarrative/web/maintenance.html`,并同时用 `genarrative.world` 与 `www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。
|
||||
版本化默认维护页固定为 `public/maintenance.html`,使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉,只允许保存无日期、无具体时段的通用文案;正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`,并由 `check-maintenance-page.mjs` 在打包前拒绝“今天 / 今晚”、具体日期或 `HH:MM` 等临时公告。计划内停服的临时公告必须放在 release 外文件中,通过 `/opt/genarrative/current/scripts/deploy/maintenance-on.sh --page-file <公告HTML> <维护原因>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`。Nginx 与 Pingora 在该文件存在时优先返回它,缺失时回退当前 Web 制品的默认维护页;同一维护窗口内 Stdb / API 的后续 `maintenance-on.sh` 调用保留已安装公告,`maintenance-off.sh` 同时删除 marker 和运行态公告,避免下次维护复活旧内容。公告启用后同时用 `genarrative.world` 与 `www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。
|
||||
|
||||
生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。构建类流水线和 `Genarrative-Server-Provision` 的 Jenkinsfile 内部源码准备阶段统一使用 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置 `https://git.genarrative.world/...` 公网 fallback,也不再默认使用 `https://git.genarrative.world/git/GenarrativeAI/Genarrative.git`。所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本,避免产物 commit 与部署脚本 commit 漂移;Server-Provision 也不在目标 dev / release agent checkout Git,而是由 Jenkins 构建节点先准备 provision 脚本与配置并上传给目标 agent。
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `快速编辑`
|
||||
|
||||
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 模型选择的修改面板,不再恢复原来源生成器,也不展示参考图或尺寸控件。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 比例 / 尺寸 + 模型选择的修改面板,不再恢复原来源生成器,也不展示额外参考图。`icon` 与 `icon-spritesheet` 图标类素材不支持快速编辑,`icon-spec` 图标规范仍按普通图片支持快速编辑。
|
||||
|
||||
## 统一布局
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
|
||||
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
|
||||
- 宣发素材的 `游戏首图`、`详情五图`、`运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`。
|
||||
- 图片快速编辑只保留一个提示词输入框和模型选择;提示词 placeholder 为 `写下每个编号要怎么改`,提交按钮显示 `修改`,不展示比例 / 尺寸或参考图控件。
|
||||
- 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。
|
||||
- 不再在底部常驻展开全部可选项。
|
||||
|
||||
## 泥点显示
|
||||
@@ -99,7 +99,7 @@
|
||||
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
|
||||
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
|
||||
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。
|
||||
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。
|
||||
- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。
|
||||
- 画布Agent对话入口例外:画布Agent对话(右侧对话面板)触发的生成不创建"即将生成"画布占位,生成中状态由对话消息流内的条目承载(阶段提示、模型标注、进行中动画);生成完成后结果图才按统一 placement 避让模型落画板为新图层,并在对话消息内显示缩略图。工具失败时也必须在对话消息内保留失败 generation record,而不是只弹一次性错误气泡。该例外仅限画布Agent对话入口,其余生成入口仍必须先落占位。详见 docs/【编辑器】画布Agent对话面板-2026-07-03.md。
|
||||
|
||||
@@ -116,6 +116,10 @@
|
||||
- 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。
|
||||
- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。
|
||||
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
|
||||
- 一次生成任务产生多个可复用产物时,全部产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能只保留最终产物或由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取至少同时回填纯色背景原图与透明后处理结果;UI 素材提取继续一并回填拆分素材。`generatedLayerId` 仍锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。
|
||||
- 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。
|
||||
- 图标和 UI 图集自动拆分属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 warning toast 提示用户可手动重试。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成图集标记为失败。
|
||||
- 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板统一提供“资源名称”输入,状态字段沿用请求契约 `assetLabel`。输入最多 80 个字符,提交时 trim;留空继续使用现有“类型 + 编号”名称。最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。
|
||||
- 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。
|
||||
- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。
|
||||
|
||||
@@ -177,7 +181,7 @@
|
||||
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 图片快速编辑底部只展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示参考图条或比例 / 尺寸控件。
|
||||
- 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示额外参考图条。图标与图集素材不展示快速编辑入口,图标规范仍可快速编辑。
|
||||
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。
|
||||
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
|
||||
```
|
||||
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS、项目资源和账号素材库,再调用 BgFilter 按默认 `segModel=birefnet` 透明化;透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力。调用方未指定素材文件夹时落默认“项目”文件夹。
|
||||
- UI 素材自动拆分与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。
|
||||
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。图集图层同样提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。
|
||||
|
||||
## 验收点
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张绿幕 spritesheet,并在后端扣除绿色背景后作为单个图集铺到画布。
|
||||
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet,在后端去背景后自动拆分为可独立编辑的素材。
|
||||
|
||||
## 入口与画布表现
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
- 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。
|
||||
- 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。
|
||||
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
|
||||
- 生成完成后删除占位态,只把后端返回的已扣绿 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,不额外拆出 `assetKind: "icon"` 图层。
|
||||
- 生成完成后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域拆出的 `assetKind: "icon"` 素材铺到图集右侧。
|
||||
- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。
|
||||
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
|
||||
|
||||
## 面板结构
|
||||
@@ -59,13 +60,15 @@
|
||||
## 去背与保存
|
||||
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
|
||||
- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。
|
||||
- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。
|
||||
- 带背景原图和去背后的透明 spritesheet 都先同时写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。
|
||||
- 自动拆分是生成后的 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;前端在 inline、worker 队列完成和刷新恢复三条路径统一显示 warning toast,用户可在图集工具栏手动重试。
|
||||
- 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。
|
||||
- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。
|
||||
|
||||
## 前端铺放规则
|
||||
|
||||
- spritesheet 图集放在原占位图位置附近。
|
||||
- 不再把图集右侧铺开独立图标,用户需要继续编辑时直接操作图集图层。
|
||||
- 自动拆分素材从图集右侧开始换行铺放;手动拆分使用相同布局,但保留原图集不变。
|
||||
- 生成成功后关闭图标素材面板,选中 spritesheet 图集,并打开图层面板。
|
||||
|
||||
## 验收
|
||||
@@ -76,5 +79,6 @@
|
||||
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
|
||||
- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。
|
||||
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。
|
||||
- 生成成功后画布只出现一个已扣绿的透明 spritesheet 图集,不出现额外独立图标图层。
|
||||
- 生成成功后画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层。
|
||||
- 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
### Prompt 与生成契约
|
||||
|
||||
- 前端提交到 `POST /api/editor/character-animations/generations`。
|
||||
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。
|
||||
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。
|
||||
- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 `doubao-seedance-2-0-fast-260128`。
|
||||
- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。
|
||||
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
### 抽帧与 OSS 存储
|
||||
|
||||
- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。
|
||||
- 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。
|
||||
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
|
||||
- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再优先调用阿里云通用抠图输出透明背景 PNG;阿里云失败时降级执行本地 `editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
- `assetKind="sound-effect"`
|
||||
- `assetKind="background-music"`
|
||||
- 音频结果以小型音频卡加入画布,卡片底部使用融入卡片的自定义播放器组件承载进度、时间和音量,播放 / 暂停只收口到卡片中央图标按钮;生成成功后同时保存为 OSS 私有对象、画布资源和账号级素材库素材,响应携带 `objectKey` / `assetObjectId` 供后续换签和复用。
|
||||
- 从素材库拖放音效或背景音乐时,卡片中心必须落在鼠标松手对应的画布位置,不叠加点击添加素材时使用的级联错位量;音频卡除中央播放按钮、底部进度 / 音量控件、标签和信息按钮外,其余卡片区域都作为选择与拖拽热区。
|
||||
- 普通素材上传入口首版支持图片、MP3 和 MP4;MP3 / MP4 先走 OSS 直传和 asset object confirm,再以素材库素材保存,其他音视频格式暂不开放。
|
||||
- 音频结果卡片底部显示播放器辅助控件,不在卡片左下角或悬停左上角展示时长;提示词固定显示在卡片左上角。
|
||||
- 音频结果卡片底部播放器辅助控件仅在鼠标悬停音频卡片时从下方滑入显示,移出后向下滑出收起。
|
||||
@@ -106,7 +107,7 @@ POST /api/editor/audios/background-music/generations
|
||||
- Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。
|
||||
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"`、`prompt`、`sound: prompt`、`duration` 和可选 `seed`;`model` 和 `prompt` 为文档必填,`sound` 用于兼容线上网关实际校验,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
|
||||
- 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`;Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露。
|
||||
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路,避免误伤 `/api/editor/audios/background-music/generations`。
|
||||
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路;`/suno/fetch/{taskId}` 返回 `audiopipe.suno.ai/?item_id=...` 时,该地址只作为 clip id 来源,不作为最终下载文件,后端继续调用 `/suno/act/wav/{clipId}` 获取稳定 wav URL,避免 worker 在不完整 chunked body 上卡满超时。
|
||||
- VectorEngine 音频响应的 `code` 需要兼容 `"success"`、`"ok"`、`"0"`、`"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
|
||||
- 在 `api-server` 增加编辑器音频 BFF:
|
||||
- `/api/editor/audios/sound-effects/generations`
|
||||
@@ -124,6 +125,7 @@ POST /api/editor/audios/background-music/generations
|
||||
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,右侧固定显示 `Suno` 模型胶囊,不展示 `make_instrumental`。
|
||||
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`。
|
||||
- 成功后画布新增音频卡,能通过卡片中央播放按钮播放,底部进度、时间和音量控件可操作。
|
||||
- 从素材库拖放音效或背景音乐后,音频卡中心位于鼠标松手位置;拖动卡片任意非播放 / 播放器控件区域都能移动图层,点击中央播放按钮只播放或暂停,不启动拖拽。
|
||||
- 成功后音频素材自动出现在账号级素材库;从素材库再次添加到画布时仍恢复为 `mediaType="audio"` 音频图层。
|
||||
- 普通素材上传 MP3 / MP4 会写入 OSS 并进入素材库;MP3 添加到画布为音频图层,MP4 添加到画布为视频图层。
|
||||
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,不在底部重复显示播放 / 暂停按钮,播放器辅助控件直接融入卡片底部。
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@
|
||||
"check:encoding": "node scripts/check-encoding.mjs",
|
||||
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
|
||||
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
|
||||
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
|
||||
"check:database-backup": "node scripts/check-database-backup-to-oss.mjs",
|
||||
"check:production-health-patrol": "node scripts/check-production-health-patrol.mjs",
|
||||
"check:production-health-patrol-env": "node scripts/check-production-health-patrol-env-check.mjs",
|
||||
@@ -82,7 +83,7 @@
|
||||
"lint:guardrails": "npm run lint:eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
||||
"typecheck:guardrails": "npm run typecheck",
|
||||
"lint": "npm run check:encoding && npm run check:spacetime-schema && npm run check:production-ops && npm run lint:eslint && npm run typecheck",
|
||||
"lint": "npm run check:encoding && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ExternalGenerationJobStatusRecord {
|
||||
phaseDetail: string;
|
||||
progress: number;
|
||||
error?: string | null;
|
||||
warning?: string | null;
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ export interface ExternalGenerationTaskRecord {
|
||||
phaseDetail: string;
|
||||
progress: number;
|
||||
error?: string | null;
|
||||
warning?: string | null;
|
||||
priceMudPoints: number;
|
||||
refundLedgerId?: string | null;
|
||||
notificationAcknowledgedAt?: string | null;
|
||||
|
||||
@@ -457,6 +457,8 @@ export type ProfileRedeemCodeAdminResponse = {
|
||||
globalUsedCount: number;
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -484,6 +486,8 @@ export type AdminUpsertProfileRedeemCodeRequest = {
|
||||
enabled?: boolean;
|
||||
allowedUserIds?: string[];
|
||||
allowedPublicUserCodes?: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
export type AdminDisableProfileRedeemCodeRequest = {
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/png" href="/branding/taonier-product-ip.png" />
|
||||
<title>页面无法连接 - 陶泥儿</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background: #fff8f4;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-art {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.mobile-page {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-aspect-ratio: 4 / 5) {
|
||||
.page-art {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-art {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-page {
|
||||
position: relative;
|
||||
visibility: visible;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 36px 24px 30px;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #563018;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.mobile-page h1 {
|
||||
margin: 0;
|
||||
color: #ef762f;
|
||||
font-size: 68px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mobile-page h2 {
|
||||
margin: 12px 0 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mobile-page p {
|
||||
max-width: 300px;
|
||||
margin: 12px 0 0;
|
||||
color: #86563d;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mobile-character {
|
||||
width: min(64vw, 250px);
|
||||
height: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.mobile-actions a,
|
||||
.mobile-actions button {
|
||||
display: inline-flex;
|
||||
height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 18px;
|
||||
border: 1px solid #e98a54;
|
||||
border-radius: 8px;
|
||||
background: #fff8f4;
|
||||
color: #d86228;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-actions .primary {
|
||||
background: #ef7939;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgb(205 100 44 / 16%);
|
||||
}
|
||||
|
||||
.mobile-actions a:focus-visible,
|
||||
.mobile-actions button:focus-visible {
|
||||
outline: 3px solid rgb(239 121 57 / 28%);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) and (max-height: 720px) {
|
||||
.mobile-page {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 22px;
|
||||
}
|
||||
|
||||
.mobile-page h1 {
|
||||
font-size: 58px;
|
||||
}
|
||||
|
||||
.mobile-page h2 {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.mobile-page p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mobile-character {
|
||||
width: 190px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img
|
||||
class="page-art"
|
||||
src="/branding/taonier-404-page.png"
|
||||
alt="404 页面无法连接,您访问的页面不存在或已被移除"
|
||||
width="1672"
|
||||
height="941"
|
||||
/>
|
||||
<main class="mobile-page">
|
||||
<h1>404</h1>
|
||||
<h2>页面无法连接</h2>
|
||||
<p>抱歉,您访问的页面不存在或已被移除。</p>
|
||||
<img
|
||||
class="mobile-character"
|
||||
src="/branding/taonier-404-character.png"
|
||||
alt="陶泥儿从陶罐中探出头,旁边放着施工路障"
|
||||
width="470"
|
||||
height="590"
|
||||
/>
|
||||
<div class="mobile-actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.history.length > 1 ? window.history.back() : (window.location.href = '/')"
|
||||
>
|
||||
返回上一页
|
||||
</button>
|
||||
<a class="primary" href="/">前往首页</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user