Merge remote-tracking branch 'refs/remotes/origin/master' into editor-agent-refactored
# Conflicts: # vitest.config.ts
This commit is contained in:
@@ -128,6 +128,8 @@ export interface AdminDashboardMetricsPayload {
|
||||
consumedMudPoints: number;
|
||||
totalRegisteredUsers: number;
|
||||
newRegisteredUsers: number;
|
||||
day1Retention: AdminDashboardRetentionMetricPayload;
|
||||
day7Retention: AdminDashboardRetentionMetricPayload;
|
||||
visitUsers: number;
|
||||
totalVisitUsers: number;
|
||||
visitCount: number;
|
||||
@@ -135,6 +137,12 @@ export interface AdminDashboardMetricsPayload {
|
||||
currentUsers: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardRetentionMetricPayload {
|
||||
eligibleUsers: number;
|
||||
retainedUsers: number;
|
||||
rateBasisPoints: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardChartPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -459,6 +467,10 @@ export interface AdminEditorAssetPayload {
|
||||
generationCostMudPoints: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
generator: string;
|
||||
taskGenerator: string;
|
||||
taskCostMudPoints: number;
|
||||
children: AdminEditorAssetPayload[];
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListResponse {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -29,6 +35,16 @@ const dashboardResponse: AdminDashboardResponse = {
|
||||
consumedMudPoints: 88,
|
||||
totalRegisteredUsers: 1200,
|
||||
newRegisteredUsers: 16,
|
||||
day1Retention: {
|
||||
eligibleUsers: 12,
|
||||
retainedUsers: 3,
|
||||
rateBasisPoints: 2500,
|
||||
},
|
||||
day7Retention: {
|
||||
eligibleUsers: 0,
|
||||
retainedUsers: 0,
|
||||
rateBasisPoints: 0,
|
||||
},
|
||||
visitUsers: 34,
|
||||
totalVisitUsers: 456,
|
||||
visitCount: 98,
|
||||
@@ -41,7 +57,10 @@ const dashboardResponse: AdminDashboardResponse = {
|
||||
title: '生产素材',
|
||||
unit: '个',
|
||||
total: 12,
|
||||
buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }],
|
||||
buckets: [
|
||||
{ key: '2026-06-23', label: '2026-06-23', value: 12 },
|
||||
{ key: '2026-06-24', label: '2026-06-24', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
operations: {
|
||||
@@ -82,8 +101,21 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
|
||||
expect(screen.getByText('本日生产素材数')).toBeTruthy();
|
||||
expect(screen.getByText('总注册用户')).toBeTruthy();
|
||||
expect(screen.getByText('本日新增用户数')).toBeTruthy();
|
||||
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
|
||||
expect(screen.getByText('新增用户留存')).toBeTruthy();
|
||||
expect(screen.getByText('次日留存')).toBeTruthy();
|
||||
expect(screen.getByText('25%')).toBeTruthy();
|
||||
expect(screen.getByText('3 / 12 人')).toBeTruthy();
|
||||
const day7Card = screen.getByText('七日留存').closest('article');
|
||||
expect(day7Card).toBeTruthy();
|
||||
expect(within(day7Card as HTMLElement).getByText('-')).toBeTruthy();
|
||||
expect(within(day7Card as HTMLElement).getByText('0 / 0 人')).toBeTruthy();
|
||||
expect(screen.getByText('近 5 分钟活跃用户')).toBeTruthy();
|
||||
expect(screen.getByText('生产素材')).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByTitle('2026-06-24: 0 个')
|
||||
.firstElementChild?.getAttribute('style'),
|
||||
).toContain('height: 0%');
|
||||
expect(screen.queryByRole('button', { name: '本时段' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '运营汇总' }));
|
||||
@@ -108,7 +140,7 @@ test('Dashboard 默认日期使用北京时间', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择本周时按今天填充整周范围', async () => {
|
||||
test('Dashboard 选择本周时按北京时间今天截断未来日期', async () => {
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
@@ -123,12 +155,12 @@ test('Dashboard 选择本周时按今天填充整周范围', async () => {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-22',
|
||||
endDate: '2026-06-28',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择本月时按今天填充整月范围', async () => {
|
||||
test('Dashboard 选择本月时按北京时间今天截断未来日期', async () => {
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
@@ -143,7 +175,7 @@ test('Dashboard 选择本月时按今天填充整月范围', async () => {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-06-30',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -188,6 +220,107 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
|
||||
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => {
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日生产素材数');
|
||||
const endDateInput = screen.getByLabelText('终止日期') as HTMLInputElement;
|
||||
expect(endDateInput.max).toBe('2026-06-27');
|
||||
|
||||
fireEvent.change(endDateInput, { target: { value: '2026-07-12' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endDateInput.value).toBe('2026-06-27');
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-27',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 四张趋势图共享横向日期窗口', async () => {
|
||||
const baseChart = dashboardResponse.charts[0]!;
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue({
|
||||
...dashboardResponse,
|
||||
charts: [
|
||||
baseChart,
|
||||
{
|
||||
...baseChart,
|
||||
id: 'consumed-mud-points',
|
||||
title: '消耗泥点',
|
||||
unit: '泥点',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
const chartRegions = await screen.findAllByRole('region', {
|
||||
name: /趋势图$/,
|
||||
});
|
||||
const [sourceChart, targetChart] = chartRegions;
|
||||
if (!sourceChart || !targetChart) {
|
||||
throw new Error('趋势图未完整渲染');
|
||||
}
|
||||
setScrollableDimensions(sourceChart, {
|
||||
clientWidth: 100,
|
||||
scrollWidth: 500,
|
||||
scrollLeft: 200,
|
||||
});
|
||||
setScrollableDimensions(targetChart, {
|
||||
clientWidth: 100,
|
||||
scrollWidth: 500,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
fireEvent.scroll(sourceChart);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(targetChart.scrollLeft).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 访问人数趋势明确区分每日桶与时段去重总数', async () => {
|
||||
const baseChart = dashboardResponse.charts[0]!;
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue({
|
||||
...dashboardResponse,
|
||||
charts: [
|
||||
{
|
||||
...baseChart,
|
||||
id: 'visit-users',
|
||||
title: '每日访问人数',
|
||||
unit: '人',
|
||||
total: 34,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('每日访问人数')).toBeTruthy();
|
||||
expect(screen.getByText('时段去重 34 人')).toBeTruthy();
|
||||
});
|
||||
|
||||
function setupUser() {
|
||||
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
}
|
||||
|
||||
function setScrollableDimensions(
|
||||
element: HTMLElement,
|
||||
dimensions: {
|
||||
clientWidth: number;
|
||||
scrollWidth: number;
|
||||
scrollLeft: number;
|
||||
},
|
||||
) {
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: dimensions.clientWidth },
|
||||
scrollWidth: { configurable: true, value: dimensions.scrollWidth },
|
||||
scrollLeft: {
|
||||
configurable: true,
|
||||
value: dimensions.scrollLeft,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getAdminDashboard } from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminDashboardBreakdownRowPayload,
|
||||
AdminDashboardChartPayload,
|
||||
AdminDashboardGranularity,
|
||||
AdminDashboardRetentionMetricPayload,
|
||||
AdminDashboardResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { handlePageError } from './pageUtils';
|
||||
@@ -42,24 +43,29 @@ export function AdminDashboardPage({
|
||||
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [chartScrollLeft, setChartScrollLeft] = useState(0);
|
||||
const today = formatBeijingDateInput(new Date());
|
||||
|
||||
const loadDashboard = useCallback(async (range = dateRange) => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminDashboard(token, {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
});
|
||||
setDashboard(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [dateRange, onUnauthorized, token]);
|
||||
const loadDashboard = useCallback(
|
||||
async (range = dateRange) => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminDashboard(token, {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
});
|
||||
setDashboard(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[dateRange, onUnauthorized, token],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
@@ -75,6 +81,10 @@ export function AdminDashboardPage({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
setChartScrollLeft(0);
|
||||
}, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]);
|
||||
|
||||
const metrics = dashboard?.metrics;
|
||||
const totalMetricCards = useMemo(
|
||||
() => [
|
||||
@@ -98,7 +108,7 @@ export function AdminDashboardPage({
|
||||
},
|
||||
{
|
||||
id: 'current-users',
|
||||
label: '当前使用人数(五分钟统计一次)',
|
||||
label: '近 5 分钟活跃用户',
|
||||
value: metrics?.currentUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
@@ -154,6 +164,7 @@ export function AdminDashboardPage({
|
||||
<span>起始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.startDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('startDate', event.target.value)
|
||||
@@ -164,6 +175,7 @@ export function AdminDashboardPage({
|
||||
<span>终止日期</span>
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.endDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('endDate', event.target.value)
|
||||
@@ -278,9 +290,25 @@ export function AdminDashboardPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>新增用户留存</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-retention-grid">
|
||||
<RetentionCard label="次日留存" metric={metrics?.day1Retention} />
|
||||
<RetentionCard label="七日留存" metric={metrics?.day7Retention} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-dashboard-chart-grid">
|
||||
{(dashboard?.charts ?? []).map((chart) => (
|
||||
<ChartPanel key={chart.id} chart={chart} />
|
||||
<ChartPanel
|
||||
key={chart.id}
|
||||
chart={chart}
|
||||
scrollLeft={chartScrollLeft}
|
||||
onScrollLeftChange={setChartScrollLeft}
|
||||
/>
|
||||
))}
|
||||
{dashboard && dashboard.charts.length === 0 ? (
|
||||
<div className="admin-empty-state">暂无图表</div>
|
||||
@@ -347,9 +375,10 @@ export function AdminDashboardPage({
|
||||
if (!parseDateValueAsUtc(value)) {
|
||||
return;
|
||||
}
|
||||
const clampedValue = value > today ? today : value;
|
||||
setGranularity('period');
|
||||
setDateRange((current) =>
|
||||
normalizeDateRange({ ...current, [field]: value }),
|
||||
normalizeDateRange({ ...current, [field]: clampedValue }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -377,17 +406,75 @@ function MetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
|
||||
function RetentionCard({
|
||||
label,
|
||||
metric,
|
||||
}: {
|
||||
label: string;
|
||||
metric?: AdminDashboardRetentionMetricPayload;
|
||||
}) {
|
||||
const hasEligibleUsers = Boolean(metric?.eligibleUsers);
|
||||
return (
|
||||
<article className="admin-dashboard-retention-card">
|
||||
<span>{label}</span>
|
||||
<strong>
|
||||
{hasEligibleUsers && metric
|
||||
? formatRateBasisPoints(metric.rateBasisPoints)
|
||||
: '-'}
|
||||
</strong>
|
||||
<div>
|
||||
<small>留存人数 / 可观察新增人数</small>
|
||||
<b>
|
||||
{metric
|
||||
? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)} 人`
|
||||
: '-'}
|
||||
</b>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPanel({
|
||||
chart,
|
||||
scrollLeft,
|
||||
onScrollLeftChange,
|
||||
}: {
|
||||
chart: AdminDashboardChartPayload;
|
||||
scrollLeft: number;
|
||||
onScrollLeftChange: (scrollLeft: number) => void;
|
||||
}) {
|
||||
const barsRef = useRef<HTMLDivElement>(null);
|
||||
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
|
||||
|
||||
useEffect(() => {
|
||||
const element = barsRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(element.scrollLeft - scrollLeft) > 1) {
|
||||
element.scrollLeft = scrollLeft;
|
||||
}
|
||||
}, [chart.buckets.length, scrollLeft]);
|
||||
|
||||
return (
|
||||
<section className="admin-panel admin-dashboard-chart-card">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>{chart.title}</h3>
|
||||
<span>
|
||||
{chart.id === 'visit-users' ? '时段去重 ' : ''}
|
||||
{formatNumber(chart.total)} {chart.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-bars">
|
||||
<div
|
||||
ref={barsRef}
|
||||
className="admin-dashboard-bars"
|
||||
role="region"
|
||||
aria-label={`${chart.title}趋势图`}
|
||||
tabIndex={0}
|
||||
onScroll={(event) => {
|
||||
onScrollLeftChange(event.currentTarget.scrollLeft);
|
||||
}}
|
||||
>
|
||||
{chart.buckets.map((bucket) => (
|
||||
<div className="admin-dashboard-bar-item" key={bucket.key}>
|
||||
<div
|
||||
@@ -396,7 +483,10 @@ function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
height: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
|
||||
height:
|
||||
bucket.value === 0
|
||||
? '0%'
|
||||
: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -450,6 +540,13 @@ function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN').format(value);
|
||||
}
|
||||
|
||||
function formatRateBasisPoints(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 10_000);
|
||||
}
|
||||
|
||||
function formatBeijingDateInput(date: Date) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
@@ -498,9 +595,7 @@ function buildWeekDateRange(dateValue: string) {
|
||||
const weekday = date.getUTCDay() || 7;
|
||||
const monday = new Date(date);
|
||||
monday.setUTCDate(date.getUTCDate() - weekday + 1);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setUTCDate(monday.getUTCDate() + 6);
|
||||
return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) };
|
||||
return { startDate: formatUtcDate(monday), endDate: dateValue };
|
||||
}
|
||||
|
||||
function buildMonthDateRange(dateValue: string) {
|
||||
@@ -512,12 +607,9 @@ function buildMonthDateRange(dateValue: string) {
|
||||
const firstDate = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
|
||||
);
|
||||
const lastDate = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
|
||||
);
|
||||
return {
|
||||
startDate: formatUtcDate(firstDate),
|
||||
endDate: formatUtcDate(lastDate),
|
||||
endDate: dateValue,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ const generatedAsset: AdminEditorAssetPayload = {
|
||||
generationCostMudPoints: 12,
|
||||
createdAt: '2026-07-04T10:00:00Z',
|
||||
updatedAt: '2026-07-04T10:00:00Z',
|
||||
generator: '角色抠图',
|
||||
taskGenerator: '角色生成',
|
||||
taskCostMudPoints: 12,
|
||||
children: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -245,6 +249,80 @@ test('后台素材查询不展示分类筛选和分类列', async () => {
|
||||
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
|
||||
});
|
||||
|
||||
test('后台素材查询将中间产物按真实类型逐行收纳在最终产物下', async () => {
|
||||
const providerSource: AdminEditorAssetPayload = {
|
||||
...generatedAsset,
|
||||
assetId: 'asset-source',
|
||||
label: '角色形象生成原图',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/character-images/provider-source-task-1.png',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/character-images/provider-source-task-1.png',
|
||||
assetKind: 'character',
|
||||
generator: '角色生图',
|
||||
taskGenerator: '角色生成',
|
||||
generationCostMudPoints: 12,
|
||||
taskCostMudPoints: 12,
|
||||
children: [],
|
||||
};
|
||||
const postprocessOutput: AdminEditorAssetPayload = {
|
||||
...generatedAsset,
|
||||
assetId: 'asset-postprocess',
|
||||
label: '角色抠图阶段产物',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/character-images/background-removal-task-1.png',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/character-images/background-removal-task-1.png',
|
||||
assetKind: 'character',
|
||||
generator: '角色抠图',
|
||||
taskGenerator: '角色生成',
|
||||
generationCostMudPoints: 0,
|
||||
taskCostMudPoints: 0,
|
||||
children: [],
|
||||
};
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
generationCostMudPoints: 0,
|
||||
taskCostMudPoints: 12,
|
||||
children: [providerSource, postprocessOutput],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const finalRow = (await screen.findByText('角色形象 1')).closest('tr');
|
||||
expect(finalRow).not.toBeNull();
|
||||
expect(within(finalRow!).getByText('12 泥点')).toBeTruthy();
|
||||
expect(screen.queryByText('角色形象生成原图')).toBeNull();
|
||||
expect(screen.queryByText('角色抠图阶段产物')).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(finalRow!).getByRole('button', { name: '展开中间产物' }),
|
||||
);
|
||||
|
||||
const sourceRow = (await screen.findByText('角色形象生成原图')).closest('tr');
|
||||
const postprocessRow = screen.getByText('角色抠图阶段产物').closest('tr');
|
||||
expect(sourceRow).not.toBeNull();
|
||||
expect(postprocessRow).not.toBeNull();
|
||||
expect(within(sourceRow!).getByText('角色生图')).toBeTruthy();
|
||||
expect(within(sourceRow!).getByText('12 泥点')).toBeTruthy();
|
||||
expect(within(postprocessRow!).getByText('角色抠图')).toBeTruthy();
|
||||
expect(
|
||||
within(postprocessRow!).getByText('本阶段不额外扣费'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' }));
|
||||
const detail = await screen.findByRole('dialog', { name: '素材详情' });
|
||||
expect(within(detail).getByText('角色生图')).toBeTruthy();
|
||||
expect(within(detail).getByText('12 泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Eye, FileText, RefreshCcw, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react';
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -44,6 +44,9 @@ export function AdminEditorAssetQueryPage({
|
||||
useState<AdminEditorAssetPayload | null>(null);
|
||||
const [previewEntry, setPreviewEntry] =
|
||||
useState<AdminEditorAssetPayload | null>(null);
|
||||
const [expandedTaskIds, setExpandedTaskIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [promptPreview, setPromptPreview] = useState<{
|
||||
title: string;
|
||||
prompt: string;
|
||||
@@ -149,7 +152,7 @@ export function AdminEditorAssetQueryPage({
|
||||
<label className="admin-field">
|
||||
<span>搜索</span>
|
||||
<input
|
||||
placeholder="素材名 / 用户 / 模型 / 提示词"
|
||||
placeholder="素材名 / 用户 / 生成器 / 模型 / 提示词"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
@@ -164,6 +167,7 @@ export function AdminEditorAssetQueryPage({
|
||||
<th>创建时间</th>
|
||||
<th>用户</th>
|
||||
<th>提示词</th>
|
||||
<th>生成器</th>
|
||||
<th>生成成本</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
@@ -171,64 +175,143 @@ export function AdminEditorAssetQueryPage({
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const promptText = entry.prompt || entry.actualPrompt || '-';
|
||||
const expanded = expandedTaskIds.has(entry.assetId);
|
||||
const children = entry.children ?? [];
|
||||
return (
|
||||
<tr key={entry.assetId}>
|
||||
<td>
|
||||
<button
|
||||
className="admin-asset-query-thumb-button"
|
||||
title="预览素材"
|
||||
type="button"
|
||||
onClick={() => setPreviewEntry(entry)}
|
||||
>
|
||||
<AdminAssetThumbnail entry={entry} token={token} />
|
||||
</button>
|
||||
<small>{entry.label || '-'}</small>
|
||||
</td>
|
||||
<td>{formatDateTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{authorDisplayName(entry)}
|
||||
<small>
|
||||
{entry.authorPublicUserCode?.trim() || '-'}
|
||||
</small>
|
||||
<Fragment key={entry.assetId}>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="admin-asset-query-resource-cell">
|
||||
{children.length ? (
|
||||
<button
|
||||
aria-label={expanded ? '收起中间产物' : '展开中间产物'}
|
||||
className="admin-asset-query-expand-button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedTaskIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(entry.assetId)) {
|
||||
next.delete(entry.assetId);
|
||||
} else {
|
||||
next.add(entry.assetId);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={17} aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="admin-asset-query-expand-spacer" />
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
className="admin-asset-query-thumb-button"
|
||||
title="预览素材"
|
||||
type="button"
|
||||
onClick={() => setPreviewEntry(entry)}
|
||||
>
|
||||
<AdminAssetThumbnail entry={entry} token={token} />
|
||||
</button>
|
||||
<small>{entry.label || '-'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
</td>
|
||||
<td>{formatDateTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{authorDisplayName(entry)}
|
||||
<small>
|
||||
{entry.authorPublicUserCode?.trim() || '-'}
|
||||
</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<AdminAssetPromptButton
|
||||
entry={entry}
|
||||
promptText={promptText}
|
||||
onOpen={setPromptPreview}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button admin-asset-query-prompt-text"
|
||||
disabled={promptText === '-'}
|
||||
title={promptText}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPromptPreview({
|
||||
title: entry.label || entry.assetId,
|
||||
prompt: promptText,
|
||||
})
|
||||
}
|
||||
>
|
||||
{promptText}
|
||||
</button>
|
||||
</td>
|
||||
<td>{entry.generationCostMudPoints} 泥点</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={() => setDetailEntry(entry)}
|
||||
>
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
<span>详情</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<td>{entry.taskGenerator || entry.generator || '-'}</td>
|
||||
<td>{taskGenerationCostLabel(entry)}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={() => setDetailEntry(entry)}
|
||||
>
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
<span>详情</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded
|
||||
? children.map((child) => {
|
||||
const childPrompt =
|
||||
child.prompt || child.actualPrompt || '-';
|
||||
return (
|
||||
<tr
|
||||
className="admin-asset-query-child-row"
|
||||
key={child.assetId}
|
||||
>
|
||||
<td>
|
||||
<div className="admin-asset-query-resource-cell admin-asset-query-resource-cell-child">
|
||||
<span className="admin-asset-query-child-branch" />
|
||||
<div>
|
||||
<button
|
||||
className="admin-asset-query-thumb-button"
|
||||
title="预览中间产物"
|
||||
type="button"
|
||||
onClick={() => setPreviewEntry(child)}
|
||||
>
|
||||
<AdminAssetThumbnail
|
||||
entry={child}
|
||||
token={token}
|
||||
/>
|
||||
</button>
|
||||
<small>{child.label || '-'}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatDateTime(child.createdAt)}</td>
|
||||
<td>{authorDisplayName(child)}</td>
|
||||
<td>
|
||||
<AdminAssetPromptButton
|
||||
entry={child}
|
||||
promptText={childPrompt}
|
||||
onOpen={setPromptPreview}
|
||||
/>
|
||||
</td>
|
||||
<td>{child.generator || '-'}</td>
|
||||
<td>{stageGenerationCostLabel(child)}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={() => setDetailEntry(child)}
|
||||
>
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
<span>详情</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
@@ -306,6 +389,33 @@ export function AdminEditorAssetQueryPage({
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAssetPromptButton({
|
||||
entry,
|
||||
promptText,
|
||||
onOpen,
|
||||
}: {
|
||||
entry: AdminEditorAssetPayload;
|
||||
promptText: string;
|
||||
onOpen: (value: { title: string; prompt: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="admin-text-button admin-asset-query-prompt-text"
|
||||
disabled={promptText === '-'}
|
||||
title={promptText}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpen({
|
||||
title: entry.label || entry.assetId,
|
||||
prompt: promptText,
|
||||
})
|
||||
}
|
||||
>
|
||||
{promptText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAssetThumbnail({
|
||||
entry,
|
||||
token,
|
||||
@@ -516,8 +626,20 @@ function AdminAssetDetailDialog({
|
||||
{entry.width} x {entry.height}
|
||||
</AdminInfoItem>
|
||||
<AdminInfoItem label="成本">
|
||||
{entry.generationCostMudPoints} 泥点
|
||||
{entry.children?.length
|
||||
? taskGenerationCostLabel(entry)
|
||||
: stageGenerationCostLabel(entry)}
|
||||
</AdminInfoItem>
|
||||
<AdminInfoItem label="生成器">
|
||||
{entry.children?.length
|
||||
? entry.taskGenerator || entry.generator || '-'
|
||||
: entry.generator || '-'}
|
||||
</AdminInfoItem>
|
||||
{entry.children?.length ? (
|
||||
<AdminInfoItem label="当前产物阶段">
|
||||
{entry.generator || '-'}
|
||||
</AdminInfoItem>
|
||||
) : null}
|
||||
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
|
||||
<AdminInfoItem label="Provider">
|
||||
{entry.provider || '-'}
|
||||
@@ -558,6 +680,16 @@ function AdminAssetDetailDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function taskGenerationCostLabel(entry: AdminEditorAssetPayload) {
|
||||
return `${entry.taskCostMudPoints ?? entry.generationCostMudPoints} 泥点`;
|
||||
}
|
||||
|
||||
function stageGenerationCostLabel(entry: AdminEditorAssetPayload) {
|
||||
return entry.generationCostMudPoints > 0
|
||||
? `${entry.generationCostMudPoints} 泥点`
|
||||
: '本阶段不额外扣费';
|
||||
}
|
||||
|
||||
function AdminAssetPreviewDialog({
|
||||
entry,
|
||||
token,
|
||||
|
||||
@@ -361,6 +361,63 @@ button:disabled {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
|
||||
border-left: 1px solid #ead8ca;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > span {
|
||||
color: #8f7868;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > strong {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 2;
|
||||
color: #8f3f27;
|
||||
font-size: 30px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card small {
|
||||
min-width: 0;
|
||||
color: #a38f80;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card b {
|
||||
flex: 0 0 auto;
|
||||
color: #755a49;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operations {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -417,7 +474,10 @@ button:disabled {
|
||||
gap: 8px;
|
||||
min-height: 196px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
padding: 4px 2px 0;
|
||||
scrollbar-color: #bdaea3 #f4e5d7;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item {
|
||||
@@ -661,7 +721,17 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-inline-identity > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-inline-identity small {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-database-user-cell,
|
||||
@@ -750,6 +820,55 @@ button:disabled {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell > div:last-child small {
|
||||
display: block;
|
||||
max-width: 92px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-asset-query-expand-button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #78523e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-asset-query-expand-spacer,
|
||||
.admin-asset-query-child-branch {
|
||||
display: block;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.admin-asset-query-child-row {
|
||||
background: #fffaf5;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell-child {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.admin-asset-query-child-branch {
|
||||
height: 34px;
|
||||
border-bottom: 1px solid #d8c3b3;
|
||||
border-left: 1px solid #d8c3b3;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
@@ -1212,7 +1331,7 @@ button:disabled {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-info-list div {
|
||||
.admin-info-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.34fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
@@ -1402,29 +1521,30 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-asset-query-table {
|
||||
min-width: 1080px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(1),
|
||||
.admin-asset-query-table td:nth-child(1) {
|
||||
width: 10%;
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(2),
|
||||
.admin-asset-query-table td:nth-child(2),
|
||||
.admin-asset-query-table th:nth-child(3),
|
||||
.admin-asset-query-table td:nth-child(3) {
|
||||
width: 14%;
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(4),
|
||||
.admin-asset-query-table td:nth-child(4) {
|
||||
width: 34%;
|
||||
width: 27%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(5),
|
||||
.admin-asset-query-table td:nth-child(5) {
|
||||
width: 8%;
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(6),
|
||||
@@ -1432,6 +1552,11 @@ button:disabled {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(7),
|
||||
.admin-asset-query-table td:nth-child(7) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
@@ -1990,7 +2115,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
.admin-dashboard-operation-grid,
|
||||
.admin-dashboard-retention-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -2126,11 +2252,19 @@ button:disabled {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.admin-info-list div {
|
||||
.admin-info-list > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-thumb-button {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2139,8 +2273,30 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
|
||||
border-top: 1px solid #ead8ca;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > strong {
|
||||
grid-row: auto;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
.admin-dashboard-operation-grid,
|
||||
.admin-dashboard-retention-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,19 +31,19 @@
|
||||
- 验证方式:SpacetimeDB schema / client / API 定向测试、`npm run check:admin-account-procedures` 隔离 procedure smoke、完整路由矩阵测试、admin-web 权限路由与账号 API 测试、owner/member 浏览器 smoke、`npm run check:spacetime-schema`、编码与 diff 门禁。
|
||||
- 关联文档:`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`。
|
||||
|
||||
## 2026-07-13 图片画布生成资源支持提交前统一命名
|
||||
## 2026-07-13 图片画布生成资源统一命名
|
||||
|
||||
- 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。
|
||||
- 决策:主生成状态统一使用可选 `assetLabel`,所有生成面板提供同一个资源名称输入。名称最多 80 个字符,提交时去除首尾空格;留空时继续使用现有自动编号名称。一次提交解析出的名称必须同时用于 `assetLabel`、`canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。
|
||||
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、图片修改 provider 原始输出等中间产物基于主名称追加“(原图)”或“(原始输出)”;图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。
|
||||
- 决策:主生成状态继续使用可选 `assetLabel`,名称最多 80 个字符并在提交时去除首尾空格;当前生成面板不展示“资源名称”标签和输入框,默认沿用现有自动编号名称,历史状态或内部调用若携带非空名称,仍必须让同一个名称贯穿 `assetLabel`、`canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。移除名称输入后,角色、图标图集、UI 设计和角色动作等提示词输入恢复统一可见边框。
|
||||
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、角色动作绿幕预览等具有独立复用价值的中间产物基于主名称追加“(原图)”等后缀;普通图片和图片修改的纯尺寸变换在内存完成后只上传一次,不生成“原始输出”副本。图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。
|
||||
- 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。
|
||||
- 验证方式:覆盖自定义名、空白回退、长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 验证方式:覆盖生成面板不渲染资源名称输入、提示词边框、空白回退、内部自定义名与长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、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,不得借用失败错误字段。
|
||||
- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。
|
||||
- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。
|
||||
- 影响范围:`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`。
|
||||
@@ -350,7 +350,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"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成先把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。本条新决策取代“图标素材生成只保留图集”的旧口径。
|
||||
- 决策:`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` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。
|
||||
- 影响范围:图片画布浮动工具栏、编辑器图片生成 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`。
|
||||
@@ -4108,3 +4108,21 @@
|
||||
- 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `user_id` 或陶泥号经认证服务解析,返回头像、昵称、脱敏手机号、绑定状态、钱包分桶、占用、欠账和最近订单;后台语义明确的用户字段复用同一个图标按钮和弹窗,管理员主体及 `admin:*` 合成 ID 不打开用户详情。
|
||||
- 部分退款预检:微信支付查单 `trade_state=REFUND` 只表示已发生退款,不代表全额退款。刷新已登记退款后,本地累计成功退款大于 0 且小于订单总额、且不存在非终态退款、活动 hold、欠账或人工冻结时,可以继续退本地剩余额度;没有本地成功退款事实能解释 `REFUND` 时继续失败关闭并要求登记或账单对账。
|
||||
- 影响范围:`module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 管理员 BFF / refund worker、`shared-contracts` 与 `apps/admin-web`。
|
||||
|
||||
## 2026-07-14 Jenkins Git 源收口到本机 loopback
|
||||
|
||||
- 背景:Jenkins controller 与 Gitea SSH 当前同机运行,live Job 的 `Pipeline script from SCM` 已使用 `127.0.0.1:2222`,但仓库 Jenkinsfile 内部 checkout 仍固定到局域网 IP,导致入口 SCM 与执行阶段来源不一致。
|
||||
- 决策:所有生产 Job 的 SCM URL 和 Jenkinsfile 内部源码准备统一使用 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,继续使用 `genarrative-local-gitea-ssh`,不保留局域网 IP、HTTP 内网或公网 fallback。该决策覆盖 2026-06-19 的局域网 SSH 地址口径。
|
||||
- 目标机边界:`127.0.0.1` 只允许在 Jenkins controller / Built-In Node 用于 Git。数据库导入导出与 Server-Provision 都必须在带 `linux && genarrative-build` 标签的 Built-In Node 完成 checkout 和 commit 校验,再通过 stash 把必要脚本交给 dev / release 目标 agent;目标 agent 不得自行 checkout Git 或挂载 Git SSH 凭据。
|
||||
- 影响范围:生产构建、Full、数据库导入导出和 Server-Provision Jenkinsfile,生产运维文档、共享踩坑记录与生产运维静态门禁。
|
||||
- 验证方式:`npm run check:production-ops`、`npm run check:encoding`、`bash -n scripts/jenkins-checkout-source.sh`、`git diff --check`;只读核对 live Job `config.xml` 的 SCM URL,并在 Jenkins 凭据环境对 loopback SSH 地址执行 `git ls-remote ... HEAD`。
|
||||
|
||||
## 2026-07-14 后台 Dashboard 修正访问趋势并增加新增用户留存
|
||||
|
||||
- 背景:Dashboard 本月范围包含未来日期,四张图可停在不同横向窗口;“访问人数”又把整个时段 UV 塞到终止日,0 值仍显示短柱。访问模块分布从 `tracking_event LIMIT 50000` 的任意截断样本计算,页面却继续展示精确值并产生置顶告警。
|
||||
- 决策:本周、本月快捷范围和手动日期均不晚于北京时间今天;访问人数趋势按日去重登录用户绘制,图头与时段卡保留跨日去重 UV,0 值不绘柱,四图同步横向滚动。“当前使用人数(五分钟统计一次)”纠正为滚动口径“近 5 分钟活跃用户”。
|
||||
- 聚合边界:不新增持久化表或字段;新增仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure,在 SpacetimeDB 事务快照内聚合现有 profile、tracking 私有事实,经 `spacetime-client` facade 返回紧凑投影;素材与钱包仍走原查询。api-server 不再依赖固定 50,000 行原始明细读取来生成访问模块分布或精确统计;该权威聚合失败时 Dashboard 请求失败,不把未知统计降级成 0。
|
||||
- 扩展性边界:现有表缺少覆盖跨 scope、跨日期统计的现成索引,本阶段为保持精确性仍在 procedure 内遍历相关事实并监控耗时;数据规模继续增长时改为日期前缀索引或持久化日聚合事实,不恢复固定 `LIMIT` 截断。
|
||||
- 留存口径:筛选范围内 `profile_dashboard_state.created_at` 的北京时间注册日构成 cohort;在精确 `D+1` / `D+7` 存在有效登录 user scope 日聚合即留存。观察日必须早于今天;D1、D7 分别返回留存人数、可观察人数和四舍五入后的基点率,按人数加权汇总,零分母前端显示 `-`。
|
||||
- 影响范围:SpacetimeDB Dashboard 聚合 procedure、`spacetime-client` facade、`/admin/api/dashboard` 与 shared contracts、`apps/admin-web` Dashboard 页面和运营文档。
|
||||
- 验证方式:SpacetimeDB 聚合与 api-server 定向 Rust 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对留存、日期、每日 UV、零值与滚动同步。
|
||||
|
||||
@@ -384,6 +384,14 @@
|
||||
- 验证:`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`。
|
||||
|
||||
## 图片画布纯尺寸变换必须先在内存决策再单次上传
|
||||
|
||||
- 现象:`nanobanana2` 已返回并成功解码图片,任务随后报“尺寸无效”;普通生图若把尺寸恢复作为硬失败,provider 已成功回图后仍可能没有素材进入素材库和画布。
|
||||
- 原因:通用尺寸恢复把 `nanobanana2` 的标量清晰度档位 `512 / 1024 / 2K` 当成 `WIDTHxHEIGHT` 解析;普通图片与快速编辑还把 OSS 持久化放在尺寸恢复之后。同步 `generateContent` 返回的是内联 base64,本地兜底 task id 不能用于向 provider 回查原图。
|
||||
- 处理:`nanobanana2` 保留 provider 输出几何尺寸;其它模型仍按显式像素目标尝试恢复。普通生图和快速编辑先把 provider 回图留在内存,尺寸恢复成功后只上传变换结果,恢复失败则降级为只上传 provider 原图;每个主结果只执行一次 OSS 持久化并只创建一个素材。角色、图标图集、UI 提取和角色动作的 provider 原始输出按多产物语义单独保留并承载任务模型成本,后续抠图、逐帧处理和切片阶段成本为 0;中间产物沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,不新增“原图类型”。扣费确认仍以 provider 成功为界,不延长到 OSS、后处理或画布回填;后台按任务显示最终产物父行,并把每个中间产物作为独立子行展开,分别展示阶段生成器和阶段成本。
|
||||
- 验证:`cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml` 覆盖 nanobanana 标量尺寸、变换失败回落 provider 原图、变换先于单次持久化;`cargo test -p api-server character_animation_assets --manifest-path server-rs/Cargo.toml` 覆盖角色动作原始预览的真实类型和成本归因;`npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx` 覆盖中间产物逐行展开和成本文案。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`。
|
||||
|
||||
## 图片画布快速编辑元数据必须记录原图引用
|
||||
|
||||
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
|
||||
@@ -946,12 +954,12 @@
|
||||
- 验证:`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "wooden fish"`,并观察木鱼生成页在 5 分钟以上等待时仍停留在合理阶段。
|
||||
- 关联:`src/services/miniGameDraftGenerationProgress.ts`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 敲木鱼点击生成出现 SpacetimeDB procedure 超时先查版本错配
|
||||
## 本地 SpacetimeDB procedure 超时或缺失先查版本错配
|
||||
|
||||
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。
|
||||
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作。
|
||||
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml` 的 `spacetimedb = "..."` 对齐;遇到版本不匹配时先直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
|
||||
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout。
|
||||
- 现象:敲木鱼创作时点击“生成”提示 `SpacetimeDB procedure 调用超时`,或后台 Dashboard 的指标与柱状图同时消失;服务端日志更早出现 `Failed to BSATN deserialize procedure return value`、`No such procedure`,Dashboard 请求返回 `502`。
|
||||
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 worktree 已删除但其 orphan standalone 仍监听原端口,API 还可能连到旧 wasm:健康检查正常,新 bindings 对应的 procedure 却尚未发布。
|
||||
- 处理:先用 `spacetime --version` 和监听端口对应的 `/proc/<pid>/exe --version` 分别核对 CLI 与真实宿主,再和 `server-rs/Cargo.toml` 的锁定版本对齐;不能把新版本模块硬发布到旧宿主。旧实例仍有需要保留的本地数据时,先用迁移 procedure 导出,在独立端口启动匹配版本、发布当前模块并增量导入,逐表对账后再把本次 API 切到新实例;旧实例在对账前不停止。当前 dev 脚本会对带版本记录的本地实例校验 `dev-spacetime-tool-version`,但显式连接历史端口时仍要核对真实进程和 module schema。
|
||||
- 验证:CLI、standalone 与 Cargo 锁定版本一致,`/v1/ping` 正常,`spacetime describe` 可找到调用中的 procedure;Dashboard 接口返回 `200` 且包含 4 张图,敲木鱼生成不再卡在 procedure timeout。另执行 `npm run test -- scripts/dev.test.ts` 验证本地调度门禁。
|
||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/Cargo.toml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 拼图 UI spritesheet 运行态不要二次包圆底或拉伸比例
|
||||
@@ -1896,13 +1904,13 @@
|
||||
- 验证:deploy 工作区应直接出现 `build/<version>/web.tar.gz` 与 `web.tar.gz.sha256`;后续仍由 `scripts/deploy/production-web-deploy.sh` 执行 checksum 校验和解压 smoke。
|
||||
- 关联:`jenkins/Jenkinsfile.production-web-deploy`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Jenkins 生产流水线拉 Git 统一走内网 SSH
|
||||
## Jenkins 生产流水线拉 Git 统一走本机 SSH
|
||||
|
||||
- 后续更新:2026-06-19 起常规构建 / 导入导出 / Full Build 流水线的 Jenkinsfile 内部 checkout 统一使用内网 SSH 地址 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git` 和凭据 `genarrative-local-gitea-ssh`,不再把 `https://git.genarrative.world/git/GenarrativeAI/Genarrative.git` 作为默认主源或 fallback,也不再配置公网 Git fallback;`Genarrative-Server-Provision` 仍是服务器初始化专用口径,Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源。
|
||||
- 后续更新:2026-07-14 起所有生产 Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 统一使用本机 SSH 地址 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git` 与凭据 `genarrative-local-gitea-ssh`,不再保留局域网 IP、HTTP 内网地址或公网 fallback。
|
||||
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15`、`curl 56 GnuTLS recv error (-9)`、`early EOF`、`invalid index-pack output` 失败;写死 `127.0.0.1:3000` 也会在当前执行 agent 不是 Gitea 所在机器时失败。
|
||||
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;公网域名会绕外部链路并受公网代理、TLS、带宽和凭据影响。HTTP 私有仓库入口如果没有配置 Jenkins 凭据,会在 Git 插件日志中显示 `No credentials specified` 并以 `Failed to authenticate user` 失败。即使只使用内网 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`,Jenkins Git 插件也会拉取所有分支。
|
||||
- 处理:运行于 `linux && genarrative-build` 的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` / `Genarrative-Api-Build` / `Genarrative-Stdb-Module-Build` checkout 阶段,以及数据库导入导出流水线的首次 `checkout([$class: 'GitSCM', ...])` 层统一使用 `GIT_REMOTE_URL=ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git` 和 `GIT_REMOTE_CREDENTIAL_ID=genarrative-local-gitea-ssh`,`GIT_REMOTE_FALLBACK_URL` 留空。这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,构建类流水线以 `GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT=true` 复用首次 `GitSCM` 带凭据浅克隆,只有指定 commit 不在浅克隆里时才通过同一 SSH 凭据继续 fetch 和加深;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,指定 commit 时也先保持 `depth=1` 校验,浅历史无法证明归属时才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS` 逐步加深,最后才展开完整历史。发布流水线不得为了缩短 checkout 时间清空上游构建传入的 `COMMIT_HASH`。
|
||||
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 不再指向 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `GIT_REMOTE_URL` 是 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`、`GIT_REMOTE_CREDENTIAL_ID` 是 `genarrative-local-gitea-ssh`、`GIT_REMOTE_FALLBACK_URL` 为空,`userRemoteConfigs` 带 `+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}`,`CloneOption` 带 `honorRefspec: true`;重放 Jenkins 时 checkout 日志不应再出现 `No credentials specified`;扫描发布流水线确认传给 `scripts/jenkins-checkout-source.sh` 的 `COMMIT_HASH` 未被硬编码为空;运行 `bash -n scripts/jenkins-checkout-source.sh`。
|
||||
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身,因此 Git checkout 必须收口到同机运行 Gitea SSH、带 `linux && genarrative-build` 标签的 Jenkins Built-In Node;公网域名和局域网 IP 会引入额外网络、代理、TLS 与地址漂移。即使使用本机 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`,Jenkins Git 插件仍会拉取所有分支。
|
||||
- 处理:Full、Web、API、Stdb、Server-Provision 与数据库导入导出的源码准备统一在 Jenkins Built-In Node 使用 `GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git` 和 `GIT_REMOTE_CREDENTIAL_ID=genarrative-local-gitea-ssh`,`GIT_REMOTE_FALLBACK_URL` 留空。数据库导入导出把经过 commit 校验的必要脚本 stash 给目标 agent,release / dev 目标阶段只 unstash,不再 checkout Git 或挂载 Git SSH 凭据。首次 checkout 保留目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`,随后由 `scripts/jenkins-checkout-source.sh` 复用并在必要时逐步加深。
|
||||
- 验证:扫描本地 Jenkins live Job `config.xml` 和所有生产 Jenkinsfile,确认 Git URL 均为 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,凭据仍为 `genarrative-local-gitea-ssh` 且 fallback 为空;确认数据库导入导出在 Prepare 阶段 checkout / stash,目标阶段只 unstash;在 Jenkins 凭据环境运行 `git ls-remote ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git HEAD`,并运行 `npm run check:production-ops`、`bash -n scripts/jenkins-checkout-source.sh`。
|
||||
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
## Jenkins 可选参数在 set -u 下不能裸读
|
||||
@@ -3080,3 +3088,24 @@
|
||||
- 处理:网络结果未知时保留活动 hold,页面把原 `requestId`、订单、金额、原因和已知 `out_refund_no` 保存到当前后台标签页、当前管理员会话隔离的 `sessionStorage`,有效期 2 小时;刷新后必须与后端 active hold 的金额、原因和退款号对账一致才允许直接复用原请求,服务端明确拒绝时清理上下文。没有原请求上下文、上下文过期或管理员会话已切换时只开放预填 `out_refund_no` 的安全查单登记,不生成新 ID 硬撞活动 hold。worker 在占用创建至少 10 分钟后查同一退款号,查到退款就将验签事实写入统一 observation,只有连续 3 次查单收到官方 `RESOURCE_NOT_EXISTS` 才释放。进程重启清空连续次数并重新观察;超时、签名、配置、解析等错误一律重置次数并继续占用。
|
||||
- 补充:退款查单适配器必须保留微信 `RESOURCE_NOT_EXISTS` 业务码,并兼容同类 `ORDER_NOT_EXIST`,不能把所有非 2xx 都抹平成通用上游错误;签名有效的退款申请/查询响应仍须与本次 `out_refund_no`、订单号、交易号和金额做关联校验。已释放 hold 复用旧 `requestId` 时必须在调用微信前拒绝,并要求重新预检生成新的请求 ID。
|
||||
- 关联:`server-rs/crates/api-server/src/admin_recharge.rs`、`server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs`、`profile_recharge_refund_hold`。
|
||||
|
||||
## 时段 UV 不能伪装成单日趋势
|
||||
|
||||
- 现象:访问人数卡显示整段时间有数百人,但趋势图只有终止日一根满柱,其余日期是同样高度的小短柱;横向滚动后还容易误以为后半月突然出现访问。
|
||||
- 原因:把时段跨日去重 UV 通过单值 series 塞进 `anchor_date_key`,再对 0 值强制设置最小可见高度。时段 UV、每日 UV 和每日 UV 之和是三个不同指标,不能互相替代。
|
||||
- 处理:趋势 bucket 按 `day_key + user_id` 每日去重,图头单独使用整个筛选范围跨日去重人数;0 值高度必须为 0。多张同轴图使用同一日期范围并同步横向滚动,快捷范围不生成未来日 bucket。
|
||||
- 验证:构造同一用户跨两日访问与某日零访问的 fixture,断言每日 bucket、时段去重总数和零值柱分别正确;浏览器核对四图首尾日期窗口一致。
|
||||
|
||||
## 运营聚合不能用固定 LIMIT 的原始事实冒充精确结果
|
||||
|
||||
- 现象:Dashboard 出现“达到单次读取上限 50000 行”告警,但模块分布和累计值仍以无“不完整”标识的精确数字展示;数据增长后结果会随任意截断样本漂移。
|
||||
- 原因:api-server 拉取 `SELECT ... LIMIT 50000` 原始事实再聚合,没有完整分页、稳定排序或数据库侧聚合。提高上限只会推迟错误,并增加响应体与内存压力。
|
||||
- 处理:精确运营指标通过受 runtime service identity 限制的 SpacetimeDB procedure 在事务内聚合,只返回紧凑统计投影,再由 `spacetime-client` facade 交给 BFF。权威聚合失败时请求必须失败,不能用默认 0 代替未知值;现有索引无法覆盖跨 scope、跨日期统计时先监控事务扫描耗时,数据增长后补日期前缀索引或持久化日聚合事实,不能退回固定 `LIMIT`。若某查询只能采样,契约和 UI 必须明确标为采样,不能展示成精确值。
|
||||
- 验证:聚合结果不随 HTTP SQL 行上限变化;超过 50,000 条事实时仍无截断告警,并用数据库事实抽样对账每日、时段、累计与分布结果。
|
||||
|
||||
## 后台详情列表的 grid 规则不要命中嵌套身份组件
|
||||
|
||||
- 现象:素材查询或精选素材详情弹窗中的作者陶泥号被逐字符竖排,用户详情按钮也被挤到编号旁边;窄屏下图片与详情列继续互相挤压。
|
||||
- 原因:`.admin-info-list div` 会命中列表内所有后代 `div`,把字段值内部的 `.admin-inline-identity` 和昵称容器也覆盖成双列 grid;陶泥号又允许任意位置换行,最终只剩单字符宽度。素材详情布局若始终固定为 `220px + 信息列`,移动端也没有足够空间。
|
||||
- 处理:信息列表的行布局只使用直接子选择器 `.admin-info-list > div`;作者昵称与陶泥号在身份组件内分行,陶泥号保持单行并在真正不足时省略。`560px` 以下的素材详情改为单列,缩略图居中;素材查询与精选审核共用该规则。
|
||||
- 验证:在桌面、560px、390px 和 320px 浏览器宽度打开素材详情,确认 `.admin-inline-identity` 的 computed `display` 为 `flex`、陶泥号横向显示、详情字段不溢出页面。
|
||||
|
||||
@@ -45,7 +45,7 @@ server-rs + Axum + SpacetimeDB
|
||||
- `spacetime-client`:后端访问 SpacetimeDB 的 typed facade。
|
||||
- `module-*`:纯领域模型、命令、应用规则、领域事件和领域错误。
|
||||
- `platform-*`:OSS、LLM、认证、语音等外部平台能力。
|
||||
- `shared-contracts` / `packages/shared`:前后端 DTO 和公开契约。
|
||||
- `shared-contracts` / `packages/shared`:前后端 DTO、公开契约和跨页面复用的无业务真相 TypeScript 代码;`packages/shared` 可承载共享 UI 组件与纯工具,但不承载领域规则、后端副作用或正式状态。
|
||||
- 前端:表现、交互、临时 UI 状态和后端结果渲染。
|
||||
|
||||
明确废弃:旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud`、人工 `spacetime --root-dir` 口径,以及前端承接正式业务真相的路线。
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
- 新增 Markdown 文档时,文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;只在任务需要时重命名历史文档,避免无关大 diff。
|
||||
- 涉及中文文本时注意 UTF-8 编码和乱码排查。
|
||||
- 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。
|
||||
- `packages/shared` 用于前后端 DTO、公开契约及跨页面复用的无业务真相 UI 组件和纯工具;不得把领域规则、后端副作用或正式状态放入其中。
|
||||
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run dev:api-server` 与 `/healthz`。
|
||||
- 涉及 SpacetimeDB 表结构、发布或迁移时,先看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 和 `SPACETIMEDB_TABLE_CATALOG.md`。
|
||||
- 涉及生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
|
||||
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。
|
||||
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
|
||||
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
|
||||
- 生成面板的资源命名统一写入可选 `assetLabel`,最大 80 字符并在提交时 trim。留空时提交模型保留原有自动编号;非空时同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样支持该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。
|
||||
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
|
||||
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。
|
||||
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
|
||||
- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。
|
||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||
@@ -85,11 +85,11 @@
|
||||
- `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/<objectKey>` 轻量路径,不允许把 Data URL / signed URL 写入素材库。
|
||||
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
|
||||
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=<screenColor>`、`seg_model=<segModel>` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=<screenColor>`、`seg_model=<segModel>` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后恢复到业务目标精确尺寸,再落 OSS、project resource、账号素材和画布快照。16 对齐尺寸不得泄漏到响应、持久化资源或图层 Resolution。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸,成功时只上传恢复结果,失败时只上传 provider 原图。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
|
||||
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。
|
||||
- 本次不修改 SpacetimeDB schema,不新增统计表;读取现有 private 表后在 api-server 聚合。
|
||||
- 不新增持久化统计表或字段;profile / tracking 私有事实由仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure 在同一事务快照内聚合,再经 `spacetime-client` facade 返回 api-server;素材与钱包继续复用原有查询。api-server 不再通过固定 `LIMIT` 拉取原始访问明细后自行拼装精确指标;该权威聚合失败时整个 Dashboard 请求失败,不用 0 伪装未知值。
|
||||
- 当前 profile / tracking 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。
|
||||
|
||||
## 查询参数
|
||||
|
||||
@@ -13,10 +14,10 @@
|
||||
- `granularity` 默认为 `day`。
|
||||
- `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径。`week` 和 `month` 选择包含该日期的自然周 / 自然月。
|
||||
- `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天。
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”按当天真实日期自动填充对应自然日 / 自然周 / 自然月范围并刷新,手动修改起止日期后按当前日期范围查询。
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”分别填充当天、当周周一至当天、当月月初至当天并刷新,不生成北京时间今天之后的未来日期;手动修改起止日期也不能超过北京时间今天。
|
||||
- 前端默认日期和“本日 / 本周 / 本月”快捷入口都按 `Asia/Shanghai` 计算,不使用浏览器本地时区。
|
||||
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
|
||||
- 图表横轴按后端返回的 bucket label 展示;本月和较长自定义时段使用固定最小日期列宽并允许横向滚动,避免日期刻度互相挤压。
|
||||
- 图表横轴按后端返回的 bucket label 展示;本月和较长自定义时段使用固定最小日期列宽并允许横向滚动,四张趋势图同步滚动位置,保证同屏日期可横向比较。0 值 bucket 不绘制伪柱。
|
||||
|
||||
## 指标口径
|
||||
|
||||
@@ -25,14 +26,19 @@
|
||||
- 总注册用户:`profile_dashboard_state` 行数。
|
||||
- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。
|
||||
- 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
|
||||
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user` 的 `scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。
|
||||
- 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
|
||||
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user`、`count > 0` 且 `scope_id` 非空、非 `anonymous` 的用户去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。时段指标按整个筛选范围跨日去重,趋势图按 `day_key + scope_id` 每日去重,不能把时段 UV 塞到终止日,也不能把每日 UV 之和当成时段 UV。
|
||||
- 近 5 分钟活跃用户:以请求时刻为终点,最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。这是滚动窗口,不是每 5 分钟才采样一次。
|
||||
- 次日 / 七日留存 cohort:`profile_dashboard_state.created_at` 按北京时间归日,注册日落在当前筛选闭区间内。该投影是现有运营注册口径,不等同认证账号表;未来若切换正式注册事实,必须单独变更契约,不能静默替换。
|
||||
- 留存活跃:用户在注册日恰好 `D+1` / `D+7` 的 `tracking_daily_stat` 中存在上述有效 user scope 行;同一用户同日多个事件只计一次,不按“1 / 7 天内累计回访”计算。筛选范围约束注册 cohort,观察日允许晚于筛选结束日。
|
||||
- 留存成熟条件:目标观察日必须早于当前北京时间业务日;观察日为今天时因当天尚未完整结束而排除。D1、D7 的可观察人数通常不同,分别返回 `eligibleUsers`、`retainedUsers` 与 `rateBasisPoints = round(retainedUsers * 10000 / eligibleUsers)`;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。汇总率按总人数加权,不平均每日百分比。
|
||||
- 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。
|
||||
|
||||
## 前后端文件
|
||||
|
||||
- 后端路由:`server-rs/crates/api-server/src/modules/admin.rs`
|
||||
- 后端聚合:`server-rs/crates/api-server/src/admin.rs`
|
||||
- SpacetimeDB 聚合 procedure:`server-rs/crates/spacetime-module/src/admin_dashboard.rs`
|
||||
- 后端访问 facade:`server-rs/crates/spacetime-client/src/admin_dashboard.rs`
|
||||
- 契约:`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`
|
||||
- 前端页面:`apps/admin-web/src/pages/AdminDashboardPage.tsx`
|
||||
- 后台路由:`apps/admin-web/src/app/adminRoutes.ts`
|
||||
@@ -47,5 +53,8 @@
|
||||
## 验证
|
||||
|
||||
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`
|
||||
- `cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml admin_dashboard`
|
||||
- `npm run check:spacetime-schema`
|
||||
- `npm run check:spacetime-runtime-access`
|
||||
- `npm run admin-web:typecheck`
|
||||
- `npx vitest run apps/admin-web/src/pages/AdminDashboardPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose`
|
||||
|
||||
@@ -66,7 +66,7 @@ RAG 默认不安装运行时依赖,也不把 LanceDB、Transformers.js 或本
|
||||
- 领域规则沉到 `module-*`;SpacetimeDB 表、reducer、procedure、事务 adapter 和 row mapper 留在 `spacetime-module`。
|
||||
- 后端访问 SpacetimeDB 统一经 `spacetime-client` facade。
|
||||
- HTTP / SSE / BFF 和外部副作用编排留在 `api-server`;OSS、LLM、认证、语音等外部平台能力留在 `platform-*`。
|
||||
- 前后端 DTO 和公开契约留在 `shared-contracts` / `packages/shared`。
|
||||
- 前后端 DTO、公开契约和跨页面复用的无业务真相 TypeScript 代码留在 `shared-contracts` / `packages/shared`;共享 UI 组件与纯工具可留在 `packages/shared`,但领域规则、后端副作用和正式状态不得下沉。
|
||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准。
|
||||
|
||||
后端修改后按当前 DDD 文档执行验收。涉及 API smoke 时,使用 `npm run dev:api-server` 重新拉起后端并检查 `/healthz`;不要使用旧 `maincloud` 启动口径。
|
||||
|
||||
@@ -34,7 +34,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
|
||||
3. SpacetimeDB 表结构、reducer、procedure 和持久化 row shape 留在 `spacetime-module`。
|
||||
4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。
|
||||
5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`。
|
||||
6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口。
|
||||
6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口;`packages/shared` 还可复用无业务真相的 UI 组件和纯工具,领域规则、后端副作用与正式状态仍留在各自分层。
|
||||
7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载微信协议 client、订阅消息 `stable_token` / `subscribeMessage.send`、微信支付 V3 / 虚拟支付消息推送的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter,不在业务 handler 内散落 provider 构造。
|
||||
|
||||
验证:
|
||||
@@ -62,7 +62,7 @@ npm run check:server-rs-ddd
|
||||
- 外部 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/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。
|
||||
- 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset` 中 `source_type = 'generated'` 的素材,支持 `ownerUserId`、`keyword`、`createdAfter`、`createdBefore`、`cursor` 和 `limit`;查询先按 `task_id` 分组并按最终产物父项分页,响应中每个父项通过 `children` 携带可展开的中间产物,子项各占一行而不单独占分页名额。父项返回任务生成器和任务总成本,子项返回阶段生成器和阶段成本。provider 原图 / 角色动作预览承载模型生成成本,抠图、逐帧处理、透明图集和切片成本为 0;中间产物使用所属任务真实 `asset_kind`,不再新写 `editor_green_screen_source`,历史旧值只在 read model 中按 Object Key 映射为 `character`、`icon-spritesheet` 或 `character-animation` 并把旧任务成本只读归回 provider 原始产物。接口只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 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/*`。
|
||||
|
||||
@@ -144,7 +144,7 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
|
||||
|
||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.6.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时不要继续深挖业务超时,直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
|
||||
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前,按原比例尽量收敛 `gpt-image-2` 的显式像素尺寸:最大边长不超过 `3840`、宽高均为 `16` 的倍数、长短边比不超过 `3:1`,总像素范围为 `655360` 至 `8294400`。无法满足全部条件时回退为 `1024x1024`;其他模型保留其传入尺寸。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
|
||||
|
||||
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会对同一请求最多发送 5 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504 / 502。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。
|
||||
|
||||
@@ -466,9 +466,9 @@ Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸
|
||||
|
||||
版本化默认维护页固定为 `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。
|
||||
生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。所有生产 Job 的 SCM URL,以及 Jenkinsfile 内部在 Jenkins Built-In Node 执行的源码准备,统一使用 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置局域网 IP、`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,统一执行上游构建归档里的部署脚本;Server-Provision 和数据库导入导出也由带 `linux && genarrative-build` 标签的 Jenkins Built-In Node 先 checkout 并 stash 所需脚本,再交给目标 dev / release agent,避免目标机把 `127.0.0.1` 误解为远端 Gitea 或让产物 commit 与执行脚本漂移。
|
||||
|
||||
当前 Jenkins / 本机内网 Git 入口固定为 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,用于 controller、构建节点和本机 Agent 直接拉取仓库,避免绕公网 `git.genarrative.world`。验证时在具备对应 SSH key 和 known_hosts 的环境执行 `git ls-remote ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git HEAD`,应能返回 HEAD。若机器仍保留旧的 `https://git.genarrative.world/git/GenarrativeAI/Genarrative.git` 或 `http://10.2.0.10/GenarrativeAI/Genarrative.git` 内网入口,只作为历史兼容和排障参考,新流水线不再默认使用。
|
||||
当前 Jenkins / 本机 Git 入口固定为 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`。验证时在具备 Jenkins SSH key 和 `[127.0.0.1]:2222` known_hosts 的环境执行 `git ls-remote ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git HEAD`,应能返回 HEAD;同时扫描 live Job `config.xml` 和仓库 Jenkinsfile,确认没有残留局域网 IP 或公网 Git 地址。旧的局域网、公网和 HTTP 内网入口只作为历史兼容与排障参考,新流水线不再默认使用。
|
||||
|
||||
`scripts/jenkins-checkout-source.sh` 是生产 Jenkinsfile 内部二次确认源码的统一入口。构建流水线由 Jenkins `GitSCM checkout` 先用凭据完成浅克隆,再以 `GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT=true` 调用脚本复用当前 checkout;只有显式 `COMMIT_HASH` 不在这次浅克隆里时,脚本才通过传入的 SSH 远端继续 fetch 并逐步加深。构建流水线和服务器初始化流水线传入 `COMMIT_HASH` 时,脚本必须先保持 `depth=1` 浅拉,若上游 commit 已在浅历史内则直接校验并 checkout;只有浅历史无法证明 commit 属于目标分支时,才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS`(默认 `50 200 1000 5000`)逐步加深,最后才尝试展开完整历史。`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy` 和 `Genarrative-Stdb-Module-Publish` 仍保留上游构建传入的 `COMMIT_HASH` 作为通知和追溯字段,但不再用它在目标机器重新 checkout 部署脚本。
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U
|
||||
- 前端生成请求统一通过 `/api/editor/images/generations`。
|
||||
- 宣发素材请求携带 `kind: publication-material`,前端提交和后端 handler 都固定归一为 `gpt-image-2`;即使旧前端或外部请求传入 `nanobanana2`,后端也按 `gpt-image-2` 生成和计费,但价格仍来自运行时模型定价配置,不写死数值。
|
||||
- `publication-detail-gallery` 不再携带 `candidateCount: 5`;后端仍兼容多候选请求,但当前宣发素材入口不主动批量生成。
|
||||
- 尺寸请求必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。VectorEngine 适配层对明确像素值保持原样透传;只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。
|
||||
- 尺寸请求和成品交付规格必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。这些值是画布图层与成品的业务规格;由于三种规格并非都满足 `gpt-image-2` 的上游尺寸约束,VectorEngine 适配层会在发送前等比归一到合法请求尺寸(最大边 `3840`、两边为 `16` 的倍数、长短边比不超过 `3:1`、总像素 `655360..8294400`),不能将上游归一后的尺寸当作宣发成品规格。只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。
|
||||
- 参考图在提交 `/api/editor/images/generations` 前由前端压缩成适合生成理解的图片 Data URL,避免原图 Data URL 撑爆 JSON 请求体;后端该路由保留 `12MB` body limit 作为兼容兜底。
|
||||
- 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。
|
||||
- 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
- 一次生成任务产生多个可复用产物时,全部产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能只保留最终产物或由前端临时追加。角色形象、图标 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`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。
|
||||
- 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。
|
||||
- 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。
|
||||
- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ server-rs + Axum + SpacetimeDB
|
||||
- `spacetime-client`:后端访问 SpacetimeDB 的 typed facade。
|
||||
- `module-*`:纯领域模型、命令、应用规则、领域事件和领域错误。
|
||||
- `platform-*`:OSS、LLM、认证、语音等外部平台能力。
|
||||
- `shared-contracts` / `packages/shared`:前后端 DTO 和公开契约。
|
||||
- `shared-contracts` / `packages/shared`:前后端 DTO、公开契约和跨页面复用的无业务真相 TypeScript 代码;`packages/shared` 可承载共享 UI 组件与纯工具,但不承载领域规则、后端副作用或正式状态。
|
||||
- 前端:表现、交互、临时 UI 状态和后端结果渲染。
|
||||
|
||||
明确废弃:
|
||||
|
||||
@@ -10,7 +10,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GIT_REMOTE_FALLBACK_URL = ''
|
||||
GIT_REMOTE_FALLBACK_CREDENTIAL_ID = ''
|
||||
|
||||
@@ -8,7 +8,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GIT_REMOTE_FALLBACK_URL = ''
|
||||
GIT_REMOTE_FALLBACK_CREDENTIAL_ID = ''
|
||||
@@ -77,14 +77,6 @@ pipeline {
|
||||
env.EFFECTIVE_SPACETIME_ROOT_DIR = spacetimeRootDir
|
||||
env.EFFECTIVE_SERVER_BACKUP_DIRECTORY = serverBackupDirectory ?: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Export Database') {
|
||||
agent {
|
||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
def checkoutFromRemote = { String remoteUrl, String credentialsId ->
|
||||
def remoteConfig = [url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]
|
||||
@@ -132,6 +124,22 @@ pipeline {
|
||||
'
|
||||
'''
|
||||
}
|
||||
script {
|
||||
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
|
||||
}
|
||||
stash name: 'database-export-source',
|
||||
includes: '.jenkins-source-commit,scripts/jenkins-prepare-toolchain-env.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-export-migration-json.mjs,scripts/spacetime-migration-common.mjs',
|
||||
allowEmpty: false
|
||||
}
|
||||
}
|
||||
|
||||
stage('Export Database') {
|
||||
agent {
|
||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||
}
|
||||
steps {
|
||||
deleteDir()
|
||||
unstash 'database-export-source'
|
||||
script {
|
||||
def credentialBindings = []
|
||||
if (params.TOKEN_CREDENTIAL_ID?.trim()) {
|
||||
|
||||
@@ -8,7 +8,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GIT_REMOTE_FALLBACK_URL = ''
|
||||
GIT_REMOTE_FALLBACK_CREDENTIAL_ID = ''
|
||||
@@ -135,14 +135,6 @@ pipeline {
|
||||
env.PRE_IMPORT_BACKUP_DIRECTORY = backupDirectory
|
||||
env.EFFECTIVE_PRE_IMPORT_BACKUP_NAME = "pre-import-${env.BUILD_NUMBER}.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Import Database') {
|
||||
agent {
|
||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
def checkoutFromRemote = { String remoteUrl, String credentialsId ->
|
||||
def remoteConfig = [url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]
|
||||
@@ -190,6 +182,22 @@ pipeline {
|
||||
'
|
||||
'''
|
||||
}
|
||||
script {
|
||||
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
|
||||
}
|
||||
stash name: 'database-import-source',
|
||||
includes: '.jenkins-source-commit,scripts/jenkins-prepare-toolchain-env.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-export-migration-json.mjs,scripts/spacetime-import-migration-json.mjs,scripts/spacetime-migration-common.mjs',
|
||||
allowEmpty: false
|
||||
}
|
||||
}
|
||||
|
||||
stage('Import Database') {
|
||||
agent {
|
||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||
}
|
||||
steps {
|
||||
deleteDir()
|
||||
unstash 'database-import-source'
|
||||
script {
|
||||
if (params.INPUT_SOURCE == 'pipeline_archive') {
|
||||
echo "[database-import] 使用归档数据源: job=${params.EXPORT_JOB_NAME}, build=${params.EXPORT_BUILD_NUMBER_TO_IMPORT}, file=${env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE}"
|
||||
|
||||
@@ -12,7 +12,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GIT_REMOTE_FALLBACK_URL = ''
|
||||
GIT_REMOTE_FALLBACK_CREDENTIAL_ID = ''
|
||||
|
||||
@@ -8,7 +8,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GIT_REMOTE_FALLBACK_URL = ''
|
||||
GIT_REMOTE_FALLBACK_CREDENTIAL_ID = ''
|
||||
|
||||
@@ -10,7 +10,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GIT_REMOTE_FALLBACK_URL = ''
|
||||
GIT_REMOTE_FALLBACK_CREDENTIAL_ID = ''
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user