Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library

This commit is contained in:
2026-06-24 22:32:51 +08:00
179 changed files with 10156 additions and 1169 deletions
+1
View File
@@ -5,6 +5,7 @@ name = "Genarrative"
[setup]
script = '''
cp "$CODEX_SOURCE_TREE_PATH/.env.secrets.local" "$CODEX_WORKTREE_PATH/.env.secrets.local"
git reset --hard
npm install
npm run codegraph:init
npm run codegraph:index
+2 -1
View File
@@ -1,6 +1,6 @@
# 图片画布编辑器 Lovart 化执行跟踪
更新时间:`2026-06-17`
更新时间:`2026-06-23`
## 目标
@@ -73,6 +73,7 @@
- `npm run check:encoding`
- `git diff --check`
- Headless Playwright smoke`http://127.0.0.1:10000/editor` 可展示画布、缩放菜单、底部工具栏和图片工具栏;只启动 `dev:web``/api/*` 代理 500 属于未启动后端的预期现象。
- 2026-06-23 规范图选择回归修正:角色规范槽只接受画布中的规范图,图标素材与 UI 设计规范槽只接受图标规范图;选择普通角色图、图标图或其它不合格图片时不写回引用,并在画板顶部显示 warning toast。已验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
- 2026-06-12 Lovart 布局修正 smoke`http://127.0.0.1:10000/editor` 已移除左侧竖向工具栏和右侧独立图层栏;素材、已生成文件、图层统一收在左侧可折叠面板,中央画布和周边面板保持浅色一体布局;截图留存于 `output/playwright/editor-left-integrated-light.png`
- 2026-06-12 外圈背景修正 smoke`http://127.0.0.1:10000/editor` 的编辑器宿主和画布根容器已铺成同一块白色工作台,不再通过圆角边框露出底部平台背景;截图留存于 `output/playwright/editor-no-outer-background.png`
- 2026-06-12 画布背景与原生菜单修正 smoke:`http://127.0.0.1:10000/editor` 已拦截编辑器区域右键菜单,禁用长按文本选择 / iOS callout,并移除画布网格线与棋盘格底纹;截图留存于 `output/playwright/editor-plain-background.png`
+45
View File
@@ -2,6 +2,8 @@ import type {
AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest,
AdminCreationEntryConfigResponse,
AdminDashboardQuery,
AdminDashboardResponse,
AdminDebugHttpRequest,
AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest,
@@ -20,6 +22,7 @@ import type {
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
AdminUpsertProfileWalletConfigRequest,
AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
ApiErrorEnvelope,
@@ -34,6 +37,7 @@ import type {
ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminListResponse,
ProfileTaskConfigAdminResponse,
ProfileWalletConfigAdminResponse,
} from './adminApiTypes';
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
@@ -143,6 +147,16 @@ export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', { token });
}
export function getAdminDashboard(
token: string,
query: AdminDashboardQuery = {},
) {
return request<AdminDashboardResponse>(
`/admin/api/dashboard${buildDashboardQuery(query)}`,
{ token },
);
}
export function getAdminDatabaseTables(token: string) {
return request<AdminDatabaseTableListResponse>('/admin/api/database/tables', {
token,
@@ -359,6 +373,27 @@ export function disableProfileTaskConfig(
);
}
export function getProfileWalletConfig(token: string) {
return request<ProfileWalletConfigAdminResponse>(
'/admin/api/profile/wallet-config',
{ token },
);
}
export function upsertProfileWalletConfig(
token: string,
payload: AdminUpsertProfileWalletConfigRequest,
) {
return request<ProfileWalletConfigAdminResponse>(
'/admin/api/profile/wallet-config',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listProfileRechargeProducts(token: string) {
return request<ProfileRechargeProductConfigAdminListResponse>(
'/admin/api/profile/recharge-products',
@@ -402,6 +437,16 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
return queryString ? `?${queryString}` : '';
}
function buildDashboardQuery(query: AdminDashboardQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'granularity', query.granularity);
appendQueryParam(params, 'anchor', query.anchor);
appendQueryParam(params, 'startDate', query.startDate);
appendQueryParam(params, 'endDate', query.endDate);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'search', query.search);
+84
View File
@@ -54,6 +54,77 @@ export interface AdminOverviewResponse {
database: AdminDatabaseOverviewPayload;
}
export type AdminDashboardGranularity = 'day' | 'week' | 'month' | 'period';
export interface AdminDashboardQuery {
granularity?: AdminDashboardGranularity;
anchor?: string;
startDate?: string;
endDate?: string;
}
export interface AdminDashboardResponse {
range: AdminDashboardRangePayload;
metrics: AdminDashboardMetricsPayload;
charts: AdminDashboardChartPayload[];
operations: AdminDashboardOperationsPayload;
warnings: string[];
generatedAt: string;
}
export interface AdminDashboardRangePayload {
granularity: AdminDashboardGranularity;
anchorDate: string;
periodStartDate: string;
periodEndDate: string;
periodLabel: string;
}
export interface AdminDashboardMetricsPayload {
generatedAssets: number;
consumedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
visitUsers: number;
totalVisitUsers: number;
visitCount: number;
totalVisitCount: number;
currentUsers: number;
}
export interface AdminDashboardChartPayload {
id: string;
title: string;
unit: string;
total: number;
buckets: AdminDashboardChartBucketPayload[];
}
export interface AdminDashboardChartBucketPayload {
key: string;
label: string;
value: number;
}
export interface AdminDashboardOperationsPayload {
cards: AdminDashboardOperationMetricPayload[];
assetKindBreakdown: AdminDashboardBreakdownRowPayload[];
moduleVisitBreakdown: AdminDashboardBreakdownRowPayload[];
}
export interface AdminDashboardOperationMetricPayload {
id: string;
label: string;
value: number;
unit: string;
}
export interface AdminDashboardBreakdownRowPayload {
key: string;
label: string;
value: number;
}
export interface AdminServiceOverviewPayload {
bindHost: string;
bindPort: number;
@@ -325,6 +396,10 @@ export interface AdminUpsertProfileRechargeProductRequest {
sortOrder: number;
}
export interface AdminUpsertProfileWalletConfigRequest {
initialMudPoints: number;
}
export interface ProfileRedeemCodeAdminResponse {
code: string;
mode: ProfileRedeemCodeMode;
@@ -401,6 +476,15 @@ export interface ProfileRechargeProductConfigAdminListResponse {
entries: ProfileRechargeProductConfigAdminResponse[];
}
export interface ProfileWalletConfigAdminResponse {
configId: string;
initialMudPoints: number;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface AdminTrackingEventEntryPayload {
eventId: string;
eventKey: string;
+18
View File
@@ -12,6 +12,7 @@ import type {
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminResponse,
ProfileWalletConfigAdminResponse,
} from '../api/adminApiTypes';
import {
clearStoredAdminToken,
@@ -19,12 +20,14 @@ import {
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
@@ -51,6 +54,8 @@ export function AdminApp() {
useState<ProfileInviteCodeAdminResponse | null>(null);
const [taskConfigResult, setTaskConfigResult] =
useState<ProfileTaskConfigAdminResponse | null>(null);
const [profileWalletConfigResult, setProfileWalletConfigResult] =
useState<ProfileWalletConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
@@ -61,6 +66,7 @@ export function AdminApp() {
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
setStatus('guest');
setLoginNotice(message);
@@ -131,6 +137,7 @@ export function AdminApp() {
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
setLoginNotice('');
setStatus('authenticated');
@@ -167,6 +174,9 @@ export function AdminApp() {
onLogout={handleLogout}
onRouteChange={handleRouteChange}
>
{routeId === 'dashboard' ? (
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
@@ -228,6 +238,14 @@ export function AdminApp() {
onResultChange={setTaskConfigResult}
/>
) : null}
{routeId === 'profile-wallet' ? (
<AdminProfileWalletConfigPage
result={profileWalletConfigResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setProfileWalletConfigResult}
/>
) : null}
{routeId === 'recharge-products' ? (
<AdminRechargeProductPage
result={rechargeProductResult}
+5 -1
View File
@@ -1,4 +1,5 @@
import {
Activity,
Bug,
BadgeDollarSign,
Coins,
@@ -6,6 +7,7 @@ import {
LogOut,
Megaphone,
Eye,
WalletCards,
ShieldCheck,
ListChecks,
SlidersHorizontal,
@@ -29,12 +31,14 @@ interface AdminShellProps {
}
const routeIcons = {
overview: LayoutDashboard,
dashboard: LayoutDashboard,
overview: Activity,
tables: Database,
debug: Bug,
tracking: Table2,
redeem: TicketPercent,
invite: TicketCheck,
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'editor-generation-pricing': Coins,
@@ -2,6 +2,17 @@ import {expect, test} from 'vitest';
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
id: 'dashboard',
label: 'Dashboard',
hash: '#dashboard',
});
expect(resolveAdminRoute('')).toBe('dashboard');
expect(resolveAdminRoute('#unknown')).toBe('dashboard');
expect(routeHash('dashboard')).toBe('#dashboard');
});
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
test('后台入口公告路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
+8 -4
View File
@@ -1,11 +1,13 @@
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
export type AdminRouteId =
| 'dashboard'
| 'overview'
| 'tables'
| 'debug'
| 'tracking'
| 'redeem'
| 'invite'
| 'profile-wallet'
| 'tasks'
| 'recharge-products'
| 'editor-generation-pricing'
@@ -21,12 +23,14 @@ export interface AdminRouteDefinition {
}
export const adminRoutes: AdminRouteDefinition[] = [
{id: 'overview', label: '总览', hash: '#overview'},
{id: 'dashboard', label: 'Dashboard', hash: '#dashboard'},
{id: 'overview', label: '服务总览', hash: '#overview'},
{id: 'tables', label: '表查询', hash: '#tables'},
{id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
@@ -35,12 +39,12 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
];
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
return (
adminRoutes.find((route) => route.hash === normalizedHash)?.id ??
'overview'
'dashboard'
);
}
@@ -49,6 +53,6 @@ export function routeHash(routeId: AdminRouteId) {
return (
adminRoutes.find((route) => route.id === routeId)?.hash ??
adminRoutes[0]?.hash ??
'#overview'
'#dashboard'
);
}
@@ -0,0 +1,147 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import { getAdminDashboard } from '../api/adminApiClient';
import type { AdminDashboardResponse } from '../api/adminApiTypes';
import { AdminDashboardPage } from './AdminDashboardPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminDashboard: vi.fn(),
isAdminApiError: vi.fn(() => false),
}));
const dashboardResponse: AdminDashboardResponse = {
range: {
granularity: 'day',
anchorDate: '2026-06-23',
periodStartDate: '2026-06-23',
periodEndDate: '2026-06-23',
periodLabel: '2026-06-23',
},
metrics: {
generatedAssets: 12,
consumedMudPoints: 88,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
visitUsers: 34,
totalVisitUsers: 456,
visitCount: 98,
totalVisitCount: 9876,
currentUsers: 7,
},
charts: [
{
id: 'generated-assets',
title: '生产素材',
unit: '个',
total: 12,
buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }],
},
],
operations: {
cards: [
{
id: 'period-generated-assets',
label: '生产素材',
value: 12,
unit: '个',
},
],
assetKindBreakdown: [
{ key: 'editor_generated_image', label: '画板图片', value: 9 },
],
moduleVisitBreakdown: [{ key: 'auth', label: '认证', value: 30 }],
},
warnings: [],
generatedAt: '2026-06-23T12:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminDashboard).mockResolvedValue(dashboardResponse);
});
test('Dashboard 默认加载今日指标并支持运营汇总页签', async () => {
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('本日总生产素材数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
expect(screen.getByText('生产素材')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '运营汇总' }));
expect(screen.getByText('素材类型分布')).toBeTruthy();
expect(screen.getByText('画板图片')).toBeTruthy();
expect(screen.getByText('访问模块分布')).toBeTruthy();
});
test('Dashboard 选择本周时按当前终止日期填充整周范围', async () => {
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('本日总生产素材数');
fireEvent.change(screen.getByLabelText('终止日期'), {
target: { value: '2026-07-02' },
});
await user.click(screen.getByRole('button', { name: '本周' }));
await waitFor(() => {
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'week',
anchor: '2026-07-05',
startDate: '2026-06-29',
endDate: '2026-07-05',
});
});
});
test('Dashboard 选择本月时按当前终止日期填充整月范围', async () => {
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('本日总生产素材数');
fireEvent.change(screen.getByLabelText('终止日期'), {
target: { value: '2026-07-12' },
});
await user.click(screen.getByRole('button', { name: '本月' }));
await waitFor(() => {
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'month',
anchor: '2026-07-31',
startDate: '2026-07-01',
endDate: '2026-07-31',
});
});
});
test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('本日总生产素材数');
fireEvent.change(screen.getByLabelText('起始日期'), {
target: { value: '2026-06-01' },
});
fireEvent.change(screen.getByLabelText('终止日期'), {
target: { value: '2026-06-12' },
});
await waitFor(() => {
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-01',
endDate: '2026-06-12',
});
});
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {
getProfileWalletConfig,
upsertProfileWalletConfig,
} from '../api/adminApiClient';
import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminProfileWalletConfigPageProps {
token: string;
result: ProfileWalletConfigAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileWalletConfigAdminResponse) => void;
}
export function AdminProfileWalletConfigPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminProfileWalletConfigPageProps) {
const [initialMudPoints, setInitialMudPoints] = useState('100');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [loadErrorMessage, setLoadErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshConfig() {
setIsLoading(true);
setLoadErrorMessage('');
try {
const response = await getProfileWalletConfig(token);
onResultChange(response);
setInitialMudPoints(String(response.initialMudPoints));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setLoadErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const normalizedInitialMudPoints = parsePositiveInteger(initialMudPoints);
if (!normalizedInitialMudPoints) {
setErrorMessage('账号初始泥点数必须是大于 0 的整数');
return;
}
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存账号配置',
target: `${normalizedInitialMudPoints}泥点`,
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileWalletConfig(token, {
initialMudPoints: normalizedInitialMudPoints,
});
onResultChange(response);
setInitialMudPoints(String(response.initialMudPoints));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshConfig}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{loadErrorMessage ? (
<div className="admin-alert" role="status">
{loadErrorMessage}
</div>
) : null}
<div className="admin-two-column">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={initialMudPoints}
onChange={(event) => setInitialMudPoints(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !parsePositiveInteger(initialMudPoints)}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.configId ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{result.initialMudPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedBy || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
</dl>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无记录'}
</div>
)}
</section>
</div>
{confirmDialog}
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
+264
View File
@@ -271,6 +271,223 @@ button:disabled {
gap: 16px;
}
.admin-dashboard-heading {
align-items: flex-start;
}
.admin-dashboard-actions {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: flex-end;
gap: 10px;
}
.admin-dashboard-granularity {
width: 250px;
flex: 0 0 auto;
}
.admin-dashboard-period-fields {
display: grid;
width: 352px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.admin-dashboard-date-field {
width: 170px;
}
.admin-dashboard-tabs {
display: inline-grid;
width: fit-content;
grid-template-columns: repeat(2, minmax(96px, 1fr));
gap: 6px;
border: 1px solid #e1ccbb;
border-radius: 8px;
background: #ffffff;
padding: 4px;
}
.admin-dashboard-tabs button {
min-height: 38px;
border: 0;
border-radius: 6px;
color: #755a49;
background: transparent;
font-weight: 800;
}
.admin-dashboard-tabs button[data-active="true"] {
color: #8f3f27;
background: #f4e5d7;
}
.admin-dashboard-metric-grid,
.admin-dashboard-chart-grid,
.admin-dashboard-operation-grid {
display: grid;
gap: 14px;
}
.admin-dashboard-metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.admin-dashboard-operation-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.admin-dashboard-chart-grid,
.admin-dashboard-operations {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard-operations {
display: grid;
gap: 16px;
align-items: start;
}
.admin-dashboard-operations > .admin-panel:first-child {
grid-column: 1 / -1;
}
.admin-dashboard-metric-card {
gap: 8px;
min-height: 132px;
}
.admin-dashboard-metric-card[data-compact="true"] {
min-height: 112px;
}
.admin-dashboard-metric-card span {
color: #8f7868;
font-size: 13px;
font-weight: 750;
line-height: 1.35;
}
.admin-dashboard-metric-card strong {
color: #3d1f10;
font-size: 30px;
line-height: 1.1;
overflow-wrap: anywhere;
}
.admin-dashboard-metric-card small {
color: #a38f80;
font-size: 12px;
font-weight: 700;
}
.admin-dashboard-chart-card {
min-height: 286px;
}
.admin-dashboard-bars {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(18px, 1fr);
align-items: end;
gap: 8px;
min-height: 196px;
overflow-x: auto;
padding: 4px 2px 0;
}
.admin-dashboard-bar-item {
display: grid;
min-width: 18px;
gap: 7px;
align-items: end;
justify-items: center;
}
.admin-dashboard-bar-track {
position: relative;
display: flex;
width: 100%;
min-width: 18px;
height: 160px;
align-items: flex-end;
overflow: hidden;
border-radius: 7px;
background: #f4e5d7;
}
.admin-dashboard-bar-track span {
display: block;
width: 100%;
border-radius: 7px 7px 0 0;
background: linear-gradient(180deg, #c87955, #8f3f27);
}
.admin-dashboard-bar-item small {
max-width: 48px;
overflow: hidden;
color: #8f7868;
font-size: 11px;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-dashboard-breakdown-panel {
min-height: 300px;
}
.admin-dashboard-breakdown-list {
display: grid;
gap: 12px;
}
.admin-dashboard-breakdown-row {
display: grid;
gap: 7px;
}
.admin-dashboard-breakdown-row > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.admin-dashboard-breakdown-row strong,
.admin-dashboard-breakdown-row span {
min-width: 0;
overflow-wrap: anywhere;
}
.admin-dashboard-breakdown-row strong {
color: #3d1f10;
font-size: 13px;
}
.admin-dashboard-breakdown-row span {
color: #8f7868;
font-size: 12px;
font-weight: 700;
}
.admin-dashboard-breakdown-track {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: #f4e5d7;
}
.admin-dashboard-breakdown-track span {
display: block;
height: 100%;
border-radius: inherit;
background: #8f3f27;
}
.admin-two-column {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
@@ -1048,6 +1265,8 @@ button:disabled {
}
.admin-overview-grid,
.admin-dashboard-chart-grid,
.admin-dashboard-operations,
.admin-two-column,
.admin-two-column-wide,
.admin-pricing-grid,
@@ -1058,6 +1277,30 @@ button:disabled {
grid-template-columns: 1fr;
}
.admin-dashboard-heading {
display: grid;
}
.admin-dashboard-actions {
justify-content: stretch;
}
.admin-dashboard-granularity,
.admin-dashboard-period-fields,
.admin-dashboard-date-field,
.admin-dashboard-actions .admin-secondary-button {
width: 100%;
}
.admin-dashboard-period-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard-metric-grid,
.admin-dashboard-operation-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-field-compact {
max-width: none;
}
@@ -1116,6 +1359,27 @@ button:disabled {
gap: 3px;
}
.admin-dashboard-tabs {
width: 100%;
}
.admin-dashboard-period-fields {
grid-template-columns: 1fr;
}
.admin-dashboard-metric-grid,
.admin-dashboard-operation-grid {
grid-template-columns: 1fr;
}
.admin-dashboard-metric-card {
min-height: 112px;
}
.admin-dashboard-chart-card {
min-height: 250px;
}
.admin-header-row {
grid-template-columns: 1fr;
}
@@ -9,9 +9,10 @@ User=root
Group=root
WorkingDirectory=/opt/genarrative/current
EnvironmentFile=/etc/genarrative/api-server.env
ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service
ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service
# 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。
# 停止 SpacetimeDB 会连带停止 Requires 它的 API / worker / controller,冷备份后必须显式拉起。
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/stdb /var/lib/genarrative
+1 -1
View File
@@ -40,7 +40,7 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
@@ -16,6 +16,14 @@
---
## 2026-06-24 外部生成队列升级为正式生成任务列表
- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。
- 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points``refund_ledger_id``notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。
- 影响范围:`spacetime-module` 外部生成 schema / procedure、`spacetime-client` bindings/facade、`api-server` 外部生成 BFF、worker 失败回写和资产计费退款链路、平台入口“我的”页任务卡和完成提示弹窗。
- 验证方式:运行 `npm run spacetime:generate``npm run check:spacetime-schema``cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml``cargo test -p api-server wallet_refund_outbox --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-23 编辑器宣发素材固定 gpt-image-2
- 背景:画板宣发素材的游戏首图、详情五图和运营海报只应使用稳定的宣发图生成链路,不能被图片模型上次选择或旧请求切到 `nanobanana2`
@@ -32,12 +40,12 @@
- 验证方式:运行品牌标识、绑定手机号页和平台首页相关前端测试,确认品牌图 `src``/branding/taonier-product-ip.png`;执行 `npm run check:encoding``git diff --check`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`
## 2026-06-22 创作主页精选展示项目内画布生成资源
## 2026-06-22 创作主页精选展示全站公开画布生成资源
- 背景:`/creation``陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。
- 决策:`陶泥儿精选` 只从用户图片画布项目中的 `editor_project_resource` 构建素材包和素材,且只保留 `sourceType="generated"` 的画布生成资源;账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源
- 影响范围:`/creation` 创作主页、`creationShowcaseModel`最近项目列表资源读取、创作主页改版计划和精选素材相关测试。
- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts``CreationLandingView.test.tsx`,确认项目内生成资源展示、上传素材过滤、公开作品不再 fallback。
- 决策:`陶泥儿精选` 从全站 `editor_project_resource` 构建素材包和素材,读取路径固定为 `GET /api/editor/showcase/resources`;只保留 `sourceType="generated"`、图片源非空且 `public_showcase_enabled = true` 的画布生成资源,按创建时间倒序展示。账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。任意画布左侧素材列表中,单素材右键菜单承接文字 `删除``公开展示该素材` 勾选项;勾选项默认打开,修改时写回对应 `editor_project_resource.public_showcase_enabled`
- 影响范围:`/creation` 创作主页、`creationShowcaseModel`公开精选 BFF、图片画布素材列表右键菜单、账号素材库快照、创作主页改版计划和精选素材相关测试。
- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts``CreationLandingView.test.tsx``ImageCanvasAssetRowView.test.tsx``useImageCanvasAssetLibrary.test.tsx``src/services/image-editor/editorProjectClient.test.ts`,确认公开生成资源展示、上传素材过滤、公开开关隐藏资源、删除入口在右键菜单中、公开作品不再 fallback。
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-22 编辑器生成模型默认定价调整
@@ -72,6 +80,14 @@
- 验证方式:运行角色动作图层工厂、画布展示、画布持久化、生成提交和素材导出相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## 2026-06-24 图片画布项目封面使用静态快照资源
- 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
- 影响范围:`src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts``src/components/project/ProjectCanvasCover.tsx``src/components/project/ProjectGalleryView.tsx``src/components/creation-home/CreationLandingView.tsx` 和图片画布数据契约文档。
- 验证方式:运行项目页、封面快照模型、图片画布项目持久化和媒体上传相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-21 图片画布生成完成态由后端写入画布布局
- 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。
@@ -3689,3 +3705,12 @@
- 2026-06-22 调整:release 打包资源在 Windows WebView 内可能以 `http://tauri.localhost/index.html` 出现,这仍是 Tauri 内部资源,不允许被导航拦截交给系统浏览器;`shell/navigation.rs` 必须允许 `http` / `https``*.localhost` 留在 WebView。Windows release 二进制必须使用 GUI subsystem,避免正式包启动时额外弹出控制台窗口。
- 影响范围:`apps/desktop-shell/src-tauri/tauri.conf.json``apps/desktop-shell/scripts/check-config.mjs``scripts/dev.test.ts`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"``cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages``npm run desktop-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-23 后台默认入口切到 Dashboard 运营看板
- 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。
- 决策:`apps/admin-web` 默认路由改为 `#dashboard`,原 `#overview` 保留为“服务总览”。Dashboard 统一通过 `GET /admin/api/dashboard` 读取 api-server 后端投影,不让前端绕过 BFF 直接访问 SpacetimeDB。后端不新增 SpacetimeDB schema,聚合现有 `editor_project_resource``profile_wallet_ledger``profile_dashboard_state``tracking_daily_stat``tracking_event`
- 2026-06-24 补充:Dashboard 日期控件改为起始日期 / 终止日期;`granularity=period` 使用 `startDate` / `endDate` 自定义闭区间,`day` / `week` / `month` 保留 `anchor` 兼容并由前端自动填充对应范围,页面周期切换展示为“本日 / 本周 / 本月 / 本时段”。
- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;总注册用户和新增用户数均来自 `profile_dashboard_state`,其中新增用户数按 `created_at` 落入当前时间窗统计;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计。
- 影响范围:`/admin/api/dashboard``shared-contracts` admin DTO、`apps/admin-web` 默认路由和 Dashboard 页面、后台运营文档。
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml admin``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``npm run check:encoding``git diff --check`
+61 -5
View File
@@ -15,6 +15,38 @@
- 关联:相关文件、文档、提交或 Issue
```
## 图片画布素材库删除要匹配 sourceResourceId
- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
- 原因:生成素材进入账号级素材库时可能通过 `editor_asset.sourceResourceId` 指向原项目资源;如果前端素材库映射和级联删除只比较 `sourceAssetId``assetObjectId``objectKey``src`,就会漏掉只靠项目资源 ID 关联的历史 / 后端生成图层。
- 处理:`EditorAsset` 必须保留 `sourceResourceId`;从素材库添加到画布时继续写入图层;删除素材时同时比较 `layer.resourceId` / `layer.sourceResourceId``asset.sourceResourceId`
- 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasAssetCanvasBridge.ts``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 陶泥儿精选重复先查同源同媒体画布副本
- 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。
- 原因:素材拖入画布会为图层实例准备 `editor_project_resource`;如果该素材本来带 `sourceResourceId` 指向原始生成资源,而新资源仍按普通 generated 资源公开,精选就会把原件和每次拖拽产生的同源同媒体副本都展示出来。
- 处理:创建项目资源时保留 `source_resource_id`,并在同项目已有同源同媒体资源时复用已有 resource;确需创建同源同媒体副本时默认 `public_showcase_enabled = false`。公开精选读取和前端精选模型都跳过 `sourceResourceId` 指回同一媒体原件的副本,但不要按图片地址全局去重,避免不同生成步骤共享占位图时被误合并。
- 验证:`creationShowcaseModel.test.ts` 覆盖同源同媒体副本只展示原件;`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖拖拽生成素材到画布时继续提交 `sourceResourceId``cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 确认后端资源复用 / 精选过滤逻辑可编译。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``src/components/creation-home/creationShowcaseModel.ts``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 陶泥儿精选作者丢失先查公开资源 owner 字段
- 现象:`/creation``陶泥儿精选` 卡片和预览弹窗中,素材下方作者 id 消失或只显示占位。
- 原因:精选数据源来自公开 `editor_project_resource` 快照;如果 SpacetimeDB read model、`spacetime-client` mapper 或 `api-server` payload 任一层漏传 `owner_user_id` / `ownerUserId`,前端作者兜底就没有真实值可显示。
- 处理:`EditorProjectResourceSnapshot``EditorProjectResourceRecord``EditorProjectResourcePayload` 需要一路保留 owner 字段;前端 `creationShowcaseModel` 在没有作者昵称字段时读取 `ownerUserId` 作为作者兜底。
- 验证:`creationShowcaseModel.test.ts` 覆盖 owner id 作者兜底;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留 `ownerUserId`;后端改 read model 后运行 `npm run spacetime:generate``cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-client/src/mapper/editor_project.rs``server-rs/crates/api-server/src/editor_project.rs``src/components/creation-home/creationShowcaseModel.ts`
## 画板外部生成排队超时不是失败
- 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。
- 原因:画板生成已经接入后端外部生成任务队列,`queued` / `running` 是正式任务状态;旧前端轮询等待窗口到期时直接抛错,导致正常排队被提交流程 catch 成失败 UI。
- 处理:`waitForEditorGenerationQueue` 等待超时只返回“仍在后端继续执行”,调用方停止本次前端等待并保留生成中状态;只有后端任务终态为 `failed` 才展示失败。
- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
@@ -151,6 +183,14 @@
- 验证:Network 中 `/api/editor/projects*``PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/common/PlatformMediaFrame.tsx``src/services/assetReadUrlService.ts`
## 图片画布框选预览要复用源图换签缓存
- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
- 原因:预览图从原生 `img` 改成 `ResolvedAssetImage` 后,如果没有传入源图同一套 `objectKey` / `refreshKey`,它会另起一条 `/api/assets/read-url` 缓存维度;画布主图已经显示时,预览仍可能处于空签名或失败缓存状态。
- 处理:框选预览继续用 `ResolvedAssetImage` 承接私有资源换签,但必须传源图 `objectKey`,并使用 `taskId ?? resourceId` 作为 `refreshKey`,和主画布图片保持同一签名缓存版本。只允许对 `data:``blob:` 或已带签名参数的 URL 设置 `fallbackSrc`;不要把裸 `/generated...` 私有路径作为 fallback 写进 `img`
- 验证:`npm run test -- src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx --reporter=dot` 应断言私有框选预览带 `objectKey``refreshKey`,且裸 generated 路径没有 fallback。
- 关联:`src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx``src/components/ResolvedAssetImage.tsx``src/hooks/useResolvedAssetReadUrl.ts``src/services/assetReadUrlService.ts`
## 图片画布发布入口 429 先查自动保存 PATCH 并发
- 现象:发布域名访问画板时出现短时间密集 `429`Nginx access log 中 `PATCH /api/editor/projects/<projectId>`、生成接口和资料接口混杂,429 行常见 `request_time=0.000``upstream_status=-`error log 写 `limiting connections by zone "genarrative_api_conn"`
@@ -159,6 +199,14 @@
- 验证:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx -t "serializes project layout saves" --reporter verbose` 应覆盖慢保存期间不启动第二个 PATCH,首个保存完成后只发送最新待保存快照;排查发布现场时 429 行应从 `upstream_status=-` / Nginx `limit_conn` 收敛。
- 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/image-editor/useImageCanvasProjectPersistence.test.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布发布入口 429 也要查 read-url 换签爆发
- 现象:发布域名刚上线或刷新画板后出现短时间 `429`Nginx access log 中集中为同一 IP / 同一 `editor/canvas?projectid=...` referrer 的 `GET /api/assets/read-url?objectKey=generated-character-drafts/editor/ui-design-assets/.../asset-001.png` 到几十上百个 UI 设计切片;429 行常见 `request_time=0.000``upstream_status=-`error log 写 `limiting requests ... zone "genarrative_api_rps"`
- 原因:这类 429 是入口 Nginx `limit_req` 在转发前按 RPS burst 快拒,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。UI 设计提取、角色动画帧或大量私有素材恢复会让多个 `ResolvedAssetImage` 同时挂载;如果 `/api/assets/read-url` 只有同 key pending 去重和缓存,没有跨 objectKey 节流,一个页面能在同一秒内发出数百个不同 objectKey 换签请求并打满 `genarrative_api_rps` burst。
- 处理:不要先放大 Nginx 通用 API 限流;先按 access log 聚合 `read-url` 数量、状态和 referrer,确认是否同一画板页面触发。`assetReadUrlService` 必须统一承接私有 generated 资源换签,并在真实请求前做跨组件轻量节流;画板、素材库、运行态和结果页不得直接绕过该服务调用 `/api/assets/read-url`
- 验证:`npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose` 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx `GET /api/assets/read-url` 429 应从 `upstream_status=-` / `genarrative_api_rps` 收敛。
- 关联:`src/services/assetReadUrlService.ts``src/hooks/useResolvedAssetReadUrl.ts``src/components/ResolvedAssetImage.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL
- 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。
@@ -295,12 +343,12 @@
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs``server-rs/crates/api-server/src/puzzle/generation.rs``server-rs/crates/api-server/src/external_generation_worker.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复
## 生产冷备份后 API 和外部生成 worker 不能只依赖 SpacetimeDB 自恢复
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504`genarrative-api.service` 保持 stopped。
- 原因:`genarrative-api.service` 配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` API 会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service``--stop-service spacetimedb.service` 而漏掉 `--restart-service-after genarrative-api.service`,备份脚本只会恢复数据库,不会再拉起 API
- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。
- 验证:`systemctl cat genarrative-database-backup.service` 应包含参数;`systemctl is-active spacetimedb.service genarrative-api.service nginx.service` 全为 `active``curl -fsS http://127.0.0.1:3101/v1/ping``/healthz``/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功。
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504`genarrative-api.service` 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,`external_generation_job` 有 claimable pending,但 `genarrative-external-generation-worker@1.service` / controller 是 inactive
- 原因:`genarrative-api.service``genarrative-external-generation-worker@*.service``genarrative-external-generation-controller.service`配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service`这些服务会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service`恢复数据库或只重启 API,外部生成队列就不会被消费
- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service``--restart-service-after genarrative-external-generation-worker@1.service``--restart-service-after genarrative-external-generation-controller.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。
- 验证:`systemctl cat genarrative-database-backup.service` 应包含这些参数;`systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service` 全为 `active``curl -fsS http://127.0.0.1:3101/v1/ping``/healthz``/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功`get_external_generation_queue_stats_and_return` 不应长期出现 claimable pending
- 关联:`deploy/systemd/genarrative-database-backup.service``scripts/database-backup-to-oss.mjs``scripts/ops/production-health-patrol.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Pingora Brotli 不能只看 Content-Encoding
@@ -2595,6 +2643,14 @@
- 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=<id>` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。
- 关联:`src/services/jump-hop/jumpHopClient.ts``src/services/miniGameDraftGenerationProgress.ts``server-rs/crates/api-server/src/jump_hop.rs``server-rs/crates/platform-image/src/vector_engine/client.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 画布生成完成态不能被旧 autosave 覆盖
- 现象:release 外部生成 worker 补跑完成后,生成图已进入素材库或项目资源,但画布生成器仍显示 `generating`;刷新后可能仍看到历史生成框卡住。
- 原因:画布前端在提交生成后会把 `generating` layout 放入 450ms 自动保存队列;worker 完成后后端会写入 `idle + generatedLayerId + 生成层`,但旧的 pending / in-flight layout save 可能晚到并覆盖完成态。另有历史 inline 请求在 api-server 重启时只留下前端已保存的 `generating` 框,没有终态任务或生成资源。
- 处理:前端 `applyProjectSnapshot` 必须取消 pending layout save,并跳过一次由后端快照恢复触发的 autosave;后端 `save_editor_project_layout` 要保护已完成的 generation dialog,如果传入旧 `generating` 且无 `generatedLayerId`,而当前 layout 已有同一 dialog 的完成态,则保留完成态和生成层。线上脏数据只在确认无任务 / 无资源时标成 `failed` 并保留原 prompt 供用户重试。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx``cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml editor_project_storage --lib`release 排障用 `list_editor_projects_and_return` / `get_editor_project_and_return``generation-dialog` 状态,不要只看素材库。
- 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts``server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/api-server/src/external_generation_worker.rs`
## Pingora 静态缓存不能只写 Cache-Control
- 现象:直连 Pingora 后,HTML 入口虽然是 `Cache-Control: no-cache`,但浏览器每次都重新下载完整入口页或普通静态文件;或者 Vite 指纹资源长期缓存正常,但旧标签页刷新时协商缓存行为和 Nginx 直连不同。
@@ -132,6 +132,7 @@ assetObjectId > objectKey > sourceAssetId > src
4. 对每个素材源读取 Blob
- `data:image/...` 直接转换为 Blob。
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
- 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`
- 拉取失败时记录失败项,不中断整个导出。
5. 使用 `JSZip` 写入 `images/``metadata.json``manifest.txt`
@@ -46,7 +46,8 @@
- 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId``ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。
- 新增 `editor_asset` 表保存账号级素材:`assetId``ownerUserId``folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind``generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind``generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"``/project``/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合
- 图片、音频、视频和角色动画帧文件本体继续走 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 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `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`;宣发素材生成器还必须保存并恢复 `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` 回读余额,不做本地乐观扣减。视频结果当前只保存为画布视频资源,不进入图片素材库。
@@ -111,6 +112,7 @@
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。
- 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `<video controls preload="metadata" playsInline>` 播放,外层图层仍承接选择和拖拽语义;音频图层播放前继续通过 `/api/assets/read-url` 换签。画布素材导出按 `mediaType` 保留真实媒体格式:图片进入 `images/`,音频 / 视频进入 `media/`,文件扩展名从响应 MIME、`objectKey` 或源 URL 推断,不得把音频 / 视频导出成 PNG。
- 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId``layerId``resourceId``sourceAssetId``sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。
- `生成角色动作` 的完成结果同样按视频素材处理:图层主 `src` 使用后端返回的 `previewVideoPath``mediaType` 固定为 `video``assetKind` 固定为 `character-animation`,首个透明帧只写入 `thumbnailSrc` 供 poster 和后续动作源使用。下载和 ZIP 导出必须因此得到 mp4/webm/mov 等真实视频文件,不能回退为首帧 PNG;后端抽帧、绿幕去背和帧素材落盘流程仍完整保留。
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
@@ -0,0 +1,42 @@
# 后台 Dashboard 运营看板方案
## 范围
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range``metrics``charts``operations``warnings`
- 本次不修改 SpacetimeDB schema,不新增统计表;读取现有 private 表后在 api-server 聚合。
## 查询参数
`GET /admin/api/dashboard?granularity=day|week|month|period&anchor=YYYY-MM-DD&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`
- `granularity` 默认为 `day`
- `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径。`week``month` 选择包含该日期的自然周 / 自然月。
- `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天。
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”会按当前终止日期自动填充对应自然日 / 自然周 / 自然月范围,手动修改起止日期或点击“本时段”后按当前日期范围查询。
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
## 指标口径
- 生产素材数:`editor_project_resource``source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。
- 消耗泥点数:`profile_wallet_ledger``source_type = asset_operation_consume``amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
- 总注册用户:`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` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
- 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。
## 前后端文件
- 后端路由:`server-rs/crates/api-server/src/modules/admin.rs`
- 后端聚合:`server-rs/crates/api-server/src/admin.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`
## 验证
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`
- `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`
@@ -1,6 +1,6 @@
# 外部生成 Worker 化方案
更新时间:`2026-06-22`
更新时间:`2026-06-24`
## 背景
@@ -26,6 +26,8 @@
- `renew_external_generation_job_lease_and_return`worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。
- `complete_external_generation_job_and_return`worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`
- `fail_external_generation_job_and_return`worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`
- `list_external_generation_jobs_and_return`:按当前账号读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格和完成提示确认状态。
- `acknowledge_external_generation_jobs_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入 `notification_acknowledged_at` 并追加审计事件。
- `get_external_generation_queue_stats_and_return`controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
@@ -35,10 +37,12 @@
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table
- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于 `我的` 页签、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领
- `GET /api/runtime/external-generation/queue-overview`当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pendingrunning、未确认终态数量和更新时间
- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints``refundLedgerId``notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务。
- `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId``jobKind``sourceModule``sourceEntityId``status``attempt``maxAttempts``createdAt``startedAt``completedAt``updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见队列概览放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。
## 任务表
@@ -63,6 +67,11 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍
| `available_at` | 下次可领取时间 |
| `result_payload_json` | 完成摘要 |
| `created_at/started_at/completed_at/updated_at` | 审计时间 |
| `price_mud_points` | 后端计算的本任务价格,用于任务列表展示和排障 |
| `refund_ledger_id` | 失败退款产生的钱包退款流水 ID,便于从任务追到退款记录 |
| `notification_acknowledged_at` | 用户已确认完成 / 失败提示的时间,未确认终态任务下次登录继续集中弹出 |
新增私有审计表 `external_generation_job_event`,记录 `enqueued/claimed/lease_renewed/completed/failed/acknowledged` 等事件。事件表只追加状态转换事实,不作为当前状态源;排障时先看 `external_generation_job` 当前状态,再按 `job_id``external_generation_job_event` 时间线。
索引:
@@ -205,7 +214,7 @@ curl -f http://127.0.0.1:<api-port>/healthz
本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
systemd 生产 controller 与手动兜底示例:
@@ -1,6 +1,6 @@
# server-rs 与 SpacetimeDB 数据契约
更新时间:`2026-06-12`
更新时间:`2026-06-24`
## 后端主线
@@ -54,7 +54,7 @@ npm run check:server-rs-ddd
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
- 健康检查:`GET /healthz`
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
- 后台管理:`/admin/api/*`,包括登录、Dashboard 运营看板、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
- 认证与账号:`/api/auth/*``/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
@@ -193,12 +193,12 @@ npm run check:server-rs-ddd
3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。
4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。
5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。
6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED``GENARRATIVE_WALLET_REFUND_OUTBOX_DIR``GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE``GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS``GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`
6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED``GENARRATIVE_WALLET_REFUND_OUTBOX_DIR``GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE``GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS``GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`外部生成任务触发的扣费和退款必须在 `profile_wallet_ledger.metadata_json` 中写入 `externalGenerationJobId`,outbox 重放也必须保留同一任务 ID,便于从退款记录追溯到正式生成任务。
7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。
## 用户钱包与编辑器生成扣费契约
1. 新用户账号完成注册并成功同步正式认证表后,注册赠送固定`100` 泥点流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。
1. 新用户账号完成注册并成功同步正式认证表后,注册赠送金额读取 `profile_wallet_config.initial_mud_points`;后台通过 `/admin/api/profile/wallet-config` 维护“账号初始泥点数”。未写入配置时默认仍`100` 泥点流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。
2. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;实际扣费真相以后端运行时模型定价配置为准,前端按钮泥点只作为展示。
3. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
4. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
@@ -245,7 +245,13 @@ npm run check:server-rs-ddd
- Rust 结构体:`ExternalGenerationJob`
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft``generate_puzzle_images``generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation``editor_image_edit``editor_icon_spritesheet_generation``editor_ui_design_asset_extraction``editor_character_animation_generation``editor_video_generation``editor_sound_effect_generation``editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
- 用途:外部生成 worker 的持久任务队列和用户可见生成任务列表`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行同时保存 `price_mud_points``refund_ledger_id``notification_acknowledged_at`BFF 通过 `GET /api/runtime/external-generation/jobs` 返回当前账号的正式生成任务列表、价格、状态和未确认终态数量;前端只能展示该后端事实,完成 / 失败提示展示后后台调用 `POST /api/runtime/external-generation/jobs/acknowledge` 由后端写确认时间,关闭按钮只收起本地弹窗,未确认终态任务会在下次登录后再次集中弹出。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft``generate_puzzle_images``generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation``editor_image_edit``editor_icon_spritesheet_generation``editor_ui_design_asset_extraction``editor_character_animation_generation``editor_video_generation``editor_sound_effect_generation``editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
### `external_generation_job_event`
- Rust 结构体:`ExternalGenerationJobEvent`
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
- 用途:外部生成任务审计事件表,按 `job_id``owner_user_id` 记录 `enqueued``claimed``lease_renewed``completed``failed``acknowledged` 等状态转换事实。状态转换只能由 SpacetimeDB procedure 写入,不由前端或 worker 直接改表;该表用于追溯任务生命周期和排障,不替代 `external_generation_job` 当前状态。
### `ai_text_chunk`
@@ -462,7 +468,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorProjectResource`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json``asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json``public_showcase_enabled``asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复`public_showcase_enabled` 控制该生成资源是否进入 `/creation``陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot``source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开响应必须透传 `ownerUserId`,供精选卡片在缺少作者昵称时显示作者 id。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
- 索引:`by_editor_project_resource_project_id``by_editor_project_resource_owner_user_id`
### `editor_asset_folder`
@@ -476,7 +482,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorAsset`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind``generation_inputs_json`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,前端只用该快照更新素材栏。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind``generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
- 索引:`by_editor_asset_owner_user_id``by_editor_asset_folder_id`
### `inventory_slot`
@@ -679,6 +685,13 @@ npm run check:server-rs-ddd
- Rust 结构体:`ProfileWalletLedger`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 说明:账号钱包流水表。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`
### `profile_wallet_config`
- Rust 结构体:`ProfileWalletConfig`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:账号钱包全局配置真相源,当前维护新账号注册初始泥点数;表为空时业务回退 `100` 泥点。
### `public_work_like`
@@ -229,10 +229,10 @@ UI 相关修改要重点验证:
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`npm 命令 `npm run database:backup:oss`);生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
```bash
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service
```
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service` 依赖 `spacetimedb.service`,生产定时冷备份还必须传入 `--restart-service-after genarrative-api.service`,确保备份后 API 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node -- ... --upload-archive <tar.gz>` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL``/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service``genarrative-external-generation-worker@*.service``genarrative-external-generation-controller.service`依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node -- ... --upload-archive <tar.gz>` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL``/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
生产环境变量模板在 `deploy/env/api-server.env.example`
@@ -497,6 +497,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
- `profile_task_progress`
- `profile_task_reward_claim`
- `profile_wallet_ledger`
- `profile_wallet_config`
个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。认证成功后的 `daily_login` 必须通过 `SpacetimeClient::record_daily_login_tracking_event(...)` 调用 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return` procedure,由数据库事务时间生成当日幂等事件并推进任务进度;不要改回普通 `record_tracking_event_after_success`、tracking outbox 或旧 `profile.login.daily` 事件键。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`
@@ -15,7 +15,7 @@
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`
- `陶泥儿精选` 展示已经存在于用户图片画布项目中的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。
- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。
- 现有 `/creation/<play>` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。
## 页面结构
@@ -61,9 +61,9 @@
### 陶泥儿精选
`陶泥儿精选` 是页面底部的项目内画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容只使用 `editor_project_resource``sourceType="generated"` 的项目资源,代表用户通过图片画布生成已经落入某个项目的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。若现有数据缺少提示词、作者或成本字段,v1 显示保守占位,不伪造内容。
精选内容只使用 `editor_project_resource``sourceType="generated"``public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源 `ownerUserId`,前端在没有作者昵称字段时用该 id 作为作者兜底。若现有数据缺少提示词、作者标识或成本字段,v1 显示保守占位,不伪造内容。
Tab
@@ -100,10 +100,11 @@ Tab
数据来源:
- 读取用户项目列表中的 `editor_project_resource` 快照,只保留 `sourceType="generated"` 的画布生成资源
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,快照包含 `ownerUserId` 作为作者展示兜底;前端只保留 `sourceType="generated"``publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。
- 未登录、项目为空或项目内没有画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。菜单内 `公开展示该素材` 默认勾选,取消勾选后调用 `PATCH /api/editor/project-resources/{resourceId}/showcase` 写回 `public_showcase_enabled = false`,该资源从 `陶泥儿精选` 移除。
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
@@ -114,7 +115,7 @@ Tab
- 最近项目继续使用编辑器项目接口:`listEditorProjects``createEditorProject``/editor/canvas?projectid=xxx`
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
- `陶泥儿精选` 读取项目内画布生成资源;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相
- `陶泥儿精选` 读取后端公开精选接口返回的全站公开画布生成资源;公开开关以后端 `editor_project_resource.public_showcase_enabled` 为准,前端只做展示过滤、同源同媒体副本兜底过滤和乐观交互回滚
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
## 路由与导航
@@ -127,7 +128,7 @@ Tab
- 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`
- 桌面端平台一级页统一复用 `/creation` 的平台桌面 chrome,保留左侧完整导航栏和右侧顶部栏;推荐、发现、我的、`/creation``/project` 只替换右侧主内容区,不能作为脱离导航的独立全屏页渲染,避免用户进入任一页后无法返回或切换页签。
- 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。
- `/project` 项目页头部必须提供 `返回主站` 按钮,点击后切回平台首页 `/`,行为与桌面侧栏切回推荐页保持一致
- `/project` 项目页头部不展示 `返回主站` 按钮;用户通过左侧导航在主站页签之间切换,项目页右侧只保留项目管理动作
- 移动端导航列表不展示“创作”和“项目”。
- 移动端首次进入或刷新 `/` 平台首页时显示欢迎弹窗,文案提示移动端仅支持作品展示,体验创作工具请使用电脑端访问;点击 `好` 关闭后,本次页面生命周期内切换下方页签再回到首页不再弹出。
- 移动端直接访问 `/creation` 时显示桌面端打开引导,不加载创作工具主页;`/creation/<play>` 继续直达对应玩法工作台。`/project` 暂不新增移动端拦截。
@@ -172,8 +173,8 @@ Tab
- `appPageRoutes.test.ts`:覆盖 `/creation -> creation-home`,并确认现有 `/creation/<play>` 不回归。
- `RpgEntryHomeView.recharge.test.tsx`:桌面显示“创作”“项目”,不显示“草稿”;移动端不显示“创作”和“项目”;我的页不再出现项目快捷入口。
- `CreationLandingView.test.tsx`:覆盖首屏主视觉、九大功能、最近项目、新建入口、查看全部、未登录隐藏最近项目、素材 Tab。
- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`项目页头部“返回主站”回到 `/`新建项目进入 `/editor/canvas?projectid=xxx`
- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、头部“返回主站”、重命名、删除和选择模式保持通过。
- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`,新建项目进入 `/editor/canvas?projectid=xxx`
- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、重命名、删除和选择模式保持通过。
验证命令:
@@ -194,7 +195,7 @@ git diff --check
- 登录后显示最近项目、新建项目、最多 4 个项目和“查看全部”。
- 点击新建项目进入 `/editor/canvas?projectid=xxx`
- 桌面导航显示“项目”,不显示“草稿”。
- `/project` 头部点击“返回主站”回到 `/`,并退出项目页主内容
- `/project` 头部不展示“返回主站”,右侧只保留选择和新建等项目管理动作
- 移动视口底部不显示“创作”和“项目”。
- 移动端进入 `/` 首页时显示 `欢迎` 弹窗,正文包含移动端仅支持作品展示和电脑端访问提示,按钮为 `好`
- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`
@@ -7,6 +7,7 @@ export type ExternalGenerationJobStatus =
export interface ExternalGenerationQueueOverview {
pendingCount: number;
runningCount: number;
unacknowledgedTerminalCount: number;
updatedAtMicros: number;
}
@@ -27,3 +28,37 @@ export interface ExternalGenerationJobStatusRecord {
export interface ExternalGenerationJobStatusResponse {
job: ExternalGenerationJobStatusRecord;
}
export interface ExternalGenerationTaskRecord {
jobId: string;
jobKind: string;
sourceModule: string;
sourceEntityId: string;
requestLabel: string;
status: ExternalGenerationJobStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
priceMudPoints: number;
refundLedgerId?: string | null;
notificationAcknowledgedAt?: string | null;
createdAt: string;
startedAt?: string | null;
completedAt?: string | null;
updatedAt: string;
updatedAtMicros: number;
}
export interface ExternalGenerationTaskListResponse {
tasks: ExternalGenerationTaskRecord[];
overview: ExternalGenerationQueueOverview;
}
export interface ExternalGenerationTaskAcknowledgeRequest {
jobIds: string[];
}
export interface ExternalGenerationTaskAcknowledgeResponse {
acknowledgedTasks: ExternalGenerationTaskRecord[];
}
@@ -8,6 +8,16 @@ const checks = [
includes: '--restart-service-after genarrative-api.service',
reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起依赖它的 API 服务。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: '--restart-service-after genarrative-external-generation-worker@1.service',
reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起保底外部生成 worker。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: '--restart-service-after genarrative-external-generation-controller.service',
reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起外部生成 worker controller。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file',

Some files were not shown because too many files have changed in this diff Show More