From b7b4dd006643cb563fa054c7550bdb46517f048f Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 13 Jul 2026 13:52:40 +0800 Subject: [PATCH 01/18] extract mud point wallet entry component for reuse in tauri app --- .../PlatformMudPointWalletEntry.test.tsx | 5 +++-- .../src/components}/PlatformMudPointWalletEntry.tsx | 7 +++---- .../shared/src/icons}/topbar-wallet.png | Bin .../common/platformMudPointWalletModel.ts | 10 ---------- .../image-editor/ImageCanvasTopbarView.test.tsx | 3 --- .../image-editor/ImageCanvasTopbarView.tsx | 2 +- .../platform-entry/PlatformProfileRechargeModal.tsx | 2 +- .../rpg-entry/RpgEntryHomeView.recharge.test.tsx | 3 --- src/components/rpg-entry/RpgEntryHomeView.tsx | 2 +- 9 files changed, 9 insertions(+), 25 deletions(-) rename {src/components/common => packages/shared/src/components}/PlatformMudPointWalletEntry.test.tsx (97%) rename {src/components/common => packages/shared/src/components}/PlatformMudPointWalletEntry.tsx (97%) rename {public/creation-home => packages/shared/src/icons}/topbar-wallet.png (100%) delete mode 100644 src/components/common/platformMudPointWalletModel.ts diff --git a/src/components/common/PlatformMudPointWalletEntry.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx similarity index 97% rename from src/components/common/PlatformMudPointWalletEntry.test.tsx rename to packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx index 1156a20d4..08d3011d9 100644 --- a/src/components/common/PlatformMudPointWalletEntry.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx @@ -4,8 +4,9 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; -import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry'; -import { formatMudPointCount } from './platformMudPointWalletModel'; +import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry.tsx'; + +import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; const breakdown = { totalPoints: 207, diff --git a/src/components/common/PlatformMudPointWalletEntry.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx similarity index 97% rename from src/components/common/PlatformMudPointWalletEntry.tsx rename to packages/shared/src/components/PlatformMudPointWalletEntry.tsx index 6f2b0f218..d4aec9a44 100644 --- a/src/components/common/PlatformMudPointWalletEntry.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx @@ -8,10 +8,9 @@ import { useState, } from 'react'; -import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; -import { formatMudPointCount } from './platformMudPointWalletModel'; - -const MUD_POINT_ICON_SRC = '/creation-home/topbar-wallet.png'; +import type { ProfileMudPointBalance } from '@/packages/shared/src'; +import MUD_POINT_ICON_SRC from '@/packages/shared/src/icons/topbar-wallet.png'; +import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; export type PlatformMudPointWalletEntryProps = { balance: number | null; diff --git a/public/creation-home/topbar-wallet.png b/packages/shared/src/icons/topbar-wallet.png similarity index 100% rename from public/creation-home/topbar-wallet.png rename to packages/shared/src/icons/topbar-wallet.png diff --git a/src/components/common/platformMudPointWalletModel.ts b/src/components/common/platformMudPointWalletModel.ts deleted file mode 100644 index 0236383c0..000000000 --- a/src/components/common/platformMudPointWalletModel.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function formatMudPointCount(value: number, compact = false) { - const normalizedValue = Math.max(0, Math.round(value)); - if (compact && normalizedValue >= 100_000_000) { - return `${(normalizedValue / 100_000_000).toFixed(1)}亿`; - } - if (compact && normalizedValue >= 10_000) { - return `${(normalizedValue / 10_000).toFixed(1)}万`; - } - return normalizedValue.toLocaleString('zh-CN'); -} diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 1bbf80ec6..c9eb10d4b 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -108,9 +108,6 @@ describe('ImageCanvasTopbarView', () => { }); expect(walletChip.textContent).toBe('泥点 1.2万'); - expect(walletChip.querySelector('img')?.getAttribute('src')).toBe( - '/creation-home/topbar-wallet.png', - ); await user.hover(walletChip); const details = screen.getByRole('dialog', { name: '泥点账户详情' }); diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 72fa0e4ab..49607699a 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; -import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry'; +import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index 26964cc30..329dc3a0f 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -8,7 +8,6 @@ import type { import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; -import { formatMudPointCount } from '../common/platformMudPointWalletModel'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; @@ -16,6 +15,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import type { NativeWechatPaymentState } from './usePlatformProfileCenterController'; +import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; export type PlatformProfileRechargeModalProps = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index d0298e996..789e0e466 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -4235,9 +4235,6 @@ test('logged in create tab shows real wallet balance beside the brand', () => { expect(walletEntry?.className).toContain( 'platform-mobile-create-wallet-chip', ); - expect(walletButton.querySelector('img')?.getAttribute('src')).toBe( - '/creation-home/topbar-wallet.png', - ); expect( within(topbar as HTMLElement).getByRole('button', { name: '充值' }), ).toBeTruthy(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 78b66671a..2a662dfe7 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -89,7 +89,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry'; +import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { From 9831571ddad991fef75965281979c3a52c3984ee Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 13 Jul 2026 14:03:02 +0800 Subject: [PATCH 02/18] fix resolve res in Vitest --- vitest.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 87ae74b8b..30f506d44 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,14 @@ +import path from 'node:path'; + import {defineConfig} from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + // Keep shared components' application-root imports resolvable in tests. + '@': path.resolve(__dirname, '.'), + }, + }, test: { environment: 'node', globals: true, @@ -12,6 +20,7 @@ export default defineConfig({ 'miniprogram/**/*.test.js', 'scripts/**/*.test.ts', 'packages/shared/src/**/*.test.ts', + 'packages/shared/src/**/*.test.tsx', ], }, }); From 9c982992a7db24633da4cb7ab42e28d9e5caaa8b Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 13 Jul 2026 14:18:21 +0800 Subject: [PATCH 03/18] update import paths for PlatformMudPointWalletEntry --- src/components/image-editor/ImageCanvasTopbarView.tsx | 2 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 49607699a..f61a51064 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; -import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; +import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 2a662dfe7..82cc8ced7 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -89,7 +89,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; +import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { From 071dac67c1016c308cd875eea652f2a744869ab3 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 13 Jul 2026 14:46:24 +0800 Subject: [PATCH 04/18] fix import extension for formatMudPointCount in PlatformMudPointWalletEntry --- packages/shared/src/components/PlatformMudPointWalletEntry.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx index d4aec9a44..3b519f9b7 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx @@ -10,7 +10,7 @@ import { import type { ProfileMudPointBalance } from '@/packages/shared/src'; import MUD_POINT_ICON_SRC from '@/packages/shared/src/icons/topbar-wallet.png'; -import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; +import { formatMudPointCount } from '@/packages/shared/src/utils/format'; export type PlatformMudPointWalletEntryProps = { balance: number | null; From c038275e112c56ee74fe6b6f299415a4d85137b4 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 13 Jul 2026 15:49:34 +0800 Subject: [PATCH 05/18] add back moved code to git --- packages/shared/src/utils/format.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/shared/src/utils/format.ts diff --git a/packages/shared/src/utils/format.ts b/packages/shared/src/utils/format.ts new file mode 100644 index 000000000..0236383c0 --- /dev/null +++ b/packages/shared/src/utils/format.ts @@ -0,0 +1,10 @@ +export function formatMudPointCount(value: number, compact = false) { + const normalizedValue = Math.max(0, Math.round(value)); + if (compact && normalizedValue >= 100_000_000) { + return `${(normalizedValue / 100_000_000).toFixed(1)}亿`; + } + if (compact && normalizedValue >= 10_000) { + return `${(normalizedValue / 10_000).toFixed(1)}万`; + } + return normalizedValue.toLocaleString('zh-CN'); +} From a3034b1d5109d3bf320ccbe1643977975f670360 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 13 Jul 2026 18:08:46 +0800 Subject: [PATCH 06/18] fix import --- .../shared/src/components/PlatformMudPointWalletEntry.test.tsx | 3 +-- packages/shared/src/index.ts | 2 ++ src/components/image-editor/ImageCanvasTopbarView.tsx | 2 +- src/components/platform-entry/PlatformProfileRechargeModal.tsx | 2 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx index 08d3011d9..25070c10a 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx @@ -4,10 +4,9 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; +import { formatMudPointCount } from '../index'; import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry.tsx'; -import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; - const breakdown = { totalPoints: 207, permanentPoints: 100, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 23bbb0c45..47ac81b61 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -53,3 +53,5 @@ export type * from './contracts/visualNovel'; export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; +export * from './utils/format'; +export * from './components/PlatformMudPointWalletEntry'; diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index f61a51064..113059241 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; -import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry'; +import { PlatformMudPointWalletEntry } from '@/packages/shared/src'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index 329dc3a0f..deb8f3f87 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -15,7 +15,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import type { NativeWechatPaymentState } from './usePlatformProfileCenterController'; -import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; +import { formatMudPointCount } from '@/packages/shared/src'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; export type PlatformProfileRechargeModalProps = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 82cc8ced7..6f17ea58e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -89,7 +89,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry'; +import { PlatformMudPointWalletEntry } from '@/packages/shared/src'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { From e6d0ccc24313217454754bbee59276ee144dfc77 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 13 Jul 2026 18:48:58 +0800 Subject: [PATCH 07/18] use `import { formatMudPointCount } from '@genarrative/shared'` style --- packages/shared/package.json | 6 +++++- .../src/components/PlatformMudPointWalletEntry.test.tsx | 2 +- .../shared/src/components/PlatformMudPointWalletEntry.tsx | 6 +++--- src/components/image-editor/ImageCanvasTopbarView.tsx | 2 +- .../platform-entry/PlatformProfileRechargeModal.tsx | 2 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 2 +- tsconfig.json | 3 +++ vite.config.ts | 1 + vitest.config.ts | 1 + 9 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index 61866ae7c..455134555 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,5 +2,9 @@ "name": "@genarrative/shared", "private": true, "version": "0.1.0", - "type": "module" + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "types": "./src/index.ts" } diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx index 25070c10a..01edad980 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx @@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; -import { formatMudPointCount } from '../index'; +import { formatMudPointCount } from '@genarrative/shared'; import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry.tsx'; const breakdown = { diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx index 3b519f9b7..4128cd48e 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx @@ -8,9 +8,9 @@ import { useState, } from 'react'; -import type { ProfileMudPointBalance } from '@/packages/shared/src'; -import MUD_POINT_ICON_SRC from '@/packages/shared/src/icons/topbar-wallet.png'; -import { formatMudPointCount } from '@/packages/shared/src/utils/format'; +import type { ProfileMudPointBalance } from '../index'; +import MUD_POINT_ICON_SRC from '../icons/topbar-wallet.png'; +import { formatMudPointCount } from '../utils/format'; export type PlatformMudPointWalletEntryProps = { balance: number | null; diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 113059241..606ab3fb6 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; -import { PlatformMudPointWalletEntry } from '@/packages/shared/src'; +import { PlatformMudPointWalletEntry } from '@genarrative/shared'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index deb8f3f87..1d535ec70 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -15,7 +15,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import type { NativeWechatPaymentState } from './usePlatformProfileCenterController'; -import { formatMudPointCount } from '@/packages/shared/src'; +import { formatMudPointCount } from '@genarrative/shared'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; export type PlatformProfileRechargeModalProps = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 6f17ea58e..7d00dae74 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -89,7 +89,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformMudPointWalletEntry } from '@/packages/shared/src'; +import { PlatformMudPointWalletEntry } from '@genarrative/shared'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { diff --git a/tsconfig.json b/tsconfig.json index fb623bd18..42f06c521 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,9 @@ "paths": { "@/*": [ "./*" + ], + "@genarrative/shared": [ + "./packages/shared/src/index.ts" ] }, "allowImportingTsExtensions": true, diff --git a/vite.config.ts b/vite.config.ts index afe9b24a4..fdfa8ade2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -53,6 +53,7 @@ export default defineConfig(({mode}) => { resolve: { alias: { '@': path.resolve(__dirname, '.'), + '@genarrative/shared': path.resolve(__dirname, 'packages/shared/src/index.ts'), }, }, optimizeDeps: { diff --git a/vitest.config.ts b/vitest.config.ts index 30f506d44..df50756c6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ alias: { // Keep shared components' application-root imports resolvable in tests. '@': path.resolve(__dirname, '.'), + '@genarrative/shared': path.resolve(__dirname, 'packages/shared/src/index.ts'), }, }, test: { From 5244dbfcd8ab3452778733115054faa45cc190fe Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 10:55:22 +0800 Subject: [PATCH 08/18] rollback: all in relative path now --- packages/shared/package.json | 6 +----- .../src/components/PlatformMudPointWalletEntry.test.tsx | 2 +- .../shared/src/components/PlatformMudPointWalletEntry.tsx | 2 +- src/components/image-editor/ImageCanvasTopbarView.tsx | 2 +- .../platform-entry/PlatformProfileRechargeModal.tsx | 2 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 2 +- tsconfig.json | 3 --- vite.config.ts | 1 - vitest.config.ts | 1 - 9 files changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index 455134555..61866ae7c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,9 +2,5 @@ "name": "@genarrative/shared", "private": true, "version": "0.1.0", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "types": "./src/index.ts" + "type": "module" } diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx index 01edad980..e6aca12a9 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx @@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; -import { formatMudPointCount } from '@genarrative/shared'; +import { formatMudPointCount } from '../utils/format'; import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry.tsx'; const breakdown = { diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx index 4128cd48e..41d017a5d 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react'; -import type { ProfileMudPointBalance } from '../index'; +import type { ProfileMudPointBalance } from '../contracts/runtime'; import MUD_POINT_ICON_SRC from '../icons/topbar-wallet.png'; import { formatMudPointCount } from '../utils/format'; diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 606ab3fb6..37f5f1092 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; -import { PlatformMudPointWalletEntry } from '@genarrative/shared'; +import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index 1d535ec70..12d3baa5e 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -15,7 +15,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import type { NativeWechatPaymentState } from './usePlatformProfileCenterController'; -import { formatMudPointCount } from '@genarrative/shared'; +import { formatMudPointCount } from '../../../packages/shared/src/utils/format'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; export type PlatformProfileRechargeModalProps = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 7d00dae74..70272c476 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -89,7 +89,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformMudPointWalletEntry } from '@genarrative/shared'; +import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { diff --git a/tsconfig.json b/tsconfig.json index 42f06c521..fb623bd18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,9 +19,6 @@ "paths": { "@/*": [ "./*" - ], - "@genarrative/shared": [ - "./packages/shared/src/index.ts" ] }, "allowImportingTsExtensions": true, diff --git a/vite.config.ts b/vite.config.ts index fdfa8ade2..afe9b24a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -53,7 +53,6 @@ export default defineConfig(({mode}) => { resolve: { alias: { '@': path.resolve(__dirname, '.'), - '@genarrative/shared': path.resolve(__dirname, 'packages/shared/src/index.ts'), }, }, optimizeDeps: { diff --git a/vitest.config.ts b/vitest.config.ts index df50756c6..30f506d44 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,6 @@ export default defineConfig({ alias: { // Keep shared components' application-root imports resolvable in tests. '@': path.resolve(__dirname, '.'), - '@genarrative/shared': path.resolve(__dirname, 'packages/shared/src/index.ts'), }, }, test: { From d307abf7a3a6edfcb469d327c49b93d0a2658a5e Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 11:04:01 +0800 Subject: [PATCH 09/18] removed component export from index --- packages/shared/src/index.ts | 2 -- src/components/image-editor/ImageCanvasTopbarView.tsx | 2 +- src/components/platform-entry/PlatformProfileRechargeModal.tsx | 2 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 47ac81b61..23bbb0c45 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -53,5 +53,3 @@ export type * from './contracts/visualNovel'; export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; -export * from './utils/format'; -export * from './components/PlatformMudPointWalletEntry'; diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 37f5f1092..5b9e61ca9 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; -import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry'; +import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index 12d3baa5e..329dc3a0f 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -15,7 +15,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import type { NativeWechatPaymentState } from './usePlatformProfileCenterController'; -import { formatMudPointCount } from '../../../packages/shared/src/utils/format'; +import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; export type PlatformProfileRechargeModalProps = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 70272c476..9b5e9c06f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -89,7 +89,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry'; +import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { From 8d7394eda10ee1812eb24a98b5cd2b0729e79756 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 11:05:09 +0800 Subject: [PATCH 10/18] fix simple import --- src/components/image-editor/ImageCanvasTopbarView.tsx | 3 ++- src/components/platform-entry/PlatformProfileRechargeModal.tsx | 3 ++- src/components/rpg-entry/RpgEntryHomeView.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 5b9e61ca9..ba20c2e4c 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -7,8 +7,9 @@ import { X, } from 'lucide-react'; -import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; + +import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index 329dc3a0f..f5acb0023 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -1,6 +1,8 @@ import QRCode from 'qrcode'; import { useCallback, useEffect, useState } from 'react'; +import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; + import type { ProfileRechargeCenterResponse, ProfileRechargeProduct, @@ -15,7 +17,6 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import type { NativeWechatPaymentState } from './usePlatformProfileCenterController'; -import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; export type PlatformProfileRechargeModalProps = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 9b5e9c06f..1dd71dfb8 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -40,6 +40,8 @@ import { useState, } from 'react'; +import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; + import profileClockImage from '../../../media/profile/_Image (1).png'; import profileGamepadImage from '../../../media/profile/_Image (2).png'; import profileStillLifeImage from '../../../media/profile/_Image (3).png'; @@ -89,7 +91,6 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { From b13d73c5cfd8e5d3b9bbebe18209fc6db09f5108 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 11:11:11 +0800 Subject: [PATCH 11/18] sync doc --- docs/project-memory/shared-memory/project-overview.md | 2 +- docs/project-memory/shared-memory/team-conventions.md | 1 + docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md | 2 +- docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- docs/【项目基线】当前产品与工程约束-2026-05-15.md | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index fa617a0c1..c38f161ff 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -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` 口径,以及前端承接正式业务真相的路线。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index ef7364769..d2fe77662 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -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`。 diff --git a/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md b/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md index a2d523728..d93111563 100644 --- a/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md +++ b/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md @@ -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` 启动口径。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 42818aef7..656e33f91 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 构造。 验证: diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 8665e27cf..55fdf5369 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -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 状态和后端结果渲染。 明确废弃: From 11abb434386a6c1f5d42ec894fdff3661b066502 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 11:12:07 +0800 Subject: [PATCH 12/18] if it run , dont del it --- public/creation-home/topbar-wallet.png | Bin 0 -> 95544 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/creation-home/topbar-wallet.png diff --git a/public/creation-home/topbar-wallet.png b/public/creation-home/topbar-wallet.png new file mode 100644 index 0000000000000000000000000000000000000000..37043028d5d63d617ca91d7aeb2d7f1845e8e578 GIT binary patch literal 95544 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 S-~KP)?NgLhlc|$35C1=aO6o2E literal 0 HcmV?d00001 From 34c6ed8756fe3e91dfb11bec508ff233efbb363a Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 21:04:54 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=89=A9=E7=BC=A9edit?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=9A=84=E5=A4=A7=E5=B0=8F=E9=80=82=E5=BA=94?= =?UTF-8?q?gptimage2=E7=9A=84=E7=94=9F=E6=88=90=E5=9B=BE=E7=89=87=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F=E5=8F=82=E6=95=B0=E8=A6=81=E6=B1=82=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 尺寸严格限制规则 (Strict Size Constraints): 图片最大边长 ≤ 3840px <- by hardcoded length 宽高两边像素均为 16px 的倍数 <- by backend do crop 长边 / 短边 比值 ≤ 3:1 <- by hardcoded aspect ratio 总像素范围:最小 655360 ~ 最大 8294400 <- by auto scale in this pr logic impl in the backend --------- Co-authored-by: 段舒康 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/82 Co-authored-by: kvtodev Co-committed-by: kvtodev --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...辑器】宣发素材工具演示入口设计-2026-06-17.md | 2 +- .../crates/api-server/src/editor_project.rs | 75 +++++++++++++ .../src/vector_engine/client.rs | 11 +- .../platform-image/src/vector_engine/mod.rs | 2 +- .../src/vector_engine/request.rs | 100 +++++++++++++++++- .../platform-image/tests/vector_engine.rs | 63 ++++++++++- 7 files changed, 242 insertions(+), 13 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 15e1423a8..f8ab176bc 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -144,7 +144,7 @@ spacetime sql "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 && spacetime version use `,或在目标就是最新版本时执行 `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,避免把一次用户请求内的多次发送误判成多个用户请求。 diff --git a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md index 4f2aa5d21..6a29a90b2 100644 --- a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md +++ b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md @@ -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 作为兼容兜底。 - 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。 - 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 39ec8a3c4..e13a581e6 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1662,6 +1662,9 @@ pub(crate) async fn generate_editor_image_for_owner( None }; + // TODO the image size passed to api is already normalized, should remove those normalize + // and let resize here to get the proper size + image = restore_editor_generated_image_output_dimensions(image, image_size.as_ref())?; let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); @@ -2219,6 +2222,54 @@ fn restore_editor_image_edit_output_dimensions( extension: "png".to_string(), }) } +fn restore_editor_generated_image_output_dimensions( + output: DownloadedOpenAiImage, + // TODO primitive obsession + target_size: &str, +) -> Result { + let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "尺寸无效", + })) + })?; + let target_width = target_width.parse::().map_err(|_| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "宽度无效", + })) + })?; + let target_height = target_height.parse::().map_err(|_| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "高度无效", + })) + })?; + let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("素材生成结果不是有效图片:{error}"), + })) + })?; + if decoded.width() == target_width && decoded.height() == target_height { + return Ok(output); + } + + let restored = decoded.resize_to_fill( + target_width, + target_height, + image::imageops::FilterType::Lanczos3, + ); + Ok(DownloadedOpenAiImage { + bytes: encode_editor_image_edit_png( + restored, + StatusCode::BAD_GATEWAY, + "恢复宣发素材交付尺寸失败", + )?, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} pub async fn edit_editor_image( State(state): State, @@ -7497,6 +7548,30 @@ mod tests { assert_eq!(restored.extension, "png"); } + #[test] + fn publication_material_generation_restores_provider_output_to_workflow_dimensions() { + let image = image::DynamicImage::new_rgba8(944, 704); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("test image should encode"); + + let restored = restore_editor_generated_image_output_dimensions( + DownloadedOpenAiImage { + bytes: bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }, + "720x540", + ) + .expect("provider output should restore delivery dimensions"); + let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); + + assert_eq!((restored_image.width(), restored_image.height()), (720, 540)); + assert_eq!(restored.mime_type, "image/png"); + assert_eq!(restored.extension, "png"); + } + #[test] fn editor_generation_dimensions_follow_model_options() { let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K")); diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index e5024124a..d8ee9271d 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -15,9 +15,10 @@ use super::{ request::{ build_vector_engine_image_edit_request_log_params, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size, - normalize_vector_engine_image_model, vector_engine_images_edit_url, - vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url, + build_vector_engine_nanobanana_generate_content_request_body, + normalize_image_size_for_model, normalize_vector_engine_image_model, + vector_engine_images_edit_url, vector_engine_images_generation_url, + vector_engine_nanobanana_generate_content_url, }, response::handle_vector_engine_response, types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, @@ -79,7 +80,7 @@ pub async fn create_vector_engine_image_generation_with_model( } let request_url = vector_engine_images_generation_url(settings); - let normalized_size = normalize_image_size(size); + let normalized_size = normalize_image_size_for_model(model, size); let request_body = build_vector_engine_image_request_body_with_model( model, prompt, @@ -387,7 +388,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( } let request_url = vector_engine_images_edit_url(settings); - let normalized_size = normalize_image_size(size); + let normalized_size = normalize_image_size_for_model(model, size); let request_params = build_vector_engine_image_edit_request_log_params( model, prompt, diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs index f8d953e8a..1422ecb3a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -23,7 +23,7 @@ pub use error::{PlatformImageError, PlatformImageStatusHint}; pub use image_source::download_remote_image; pub use request::{ build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size, + build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model, vector_engine_images_edit_url, vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url, }; diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index 869f4e374..e67771f15 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -40,7 +40,7 @@ pub fn build_vector_engine_image_request_body_with_model( ("n".to_string(), json!(candidate_count.clamp(1, 4))), ( "size".to_string(), - Value::String(normalize_image_size(size)), + Value::String(normalize_image_size_for_model(model, size)), ), ]); @@ -90,9 +90,9 @@ pub fn normalize_vector_engine_image_model(model: &str) -> &str { } } -pub fn normalize_image_size(size: &str) -> String { +pub fn normalize_image_size_for_model(model: &str, size: &str) -> String { let size = size.trim(); - match size { + let normalized_size = match size { "1:1" => "1024x1024".to_string(), "16:9" | "2k" => "1536x1024".to_string(), "2k-16:9" => "2048x1152".to_string(), @@ -100,6 +100,12 @@ pub fn normalize_image_size(size: &str) -> String { value if is_explicit_pixel_size(value) => normalize_explicit_pixel_size(value), value if !value.is_empty() => value.to_string(), _ => "1024x1024".to_string(), + }; + + if normalize_vector_engine_image_model(model) == GPT_IMAGE_2_MODEL { + clamp_gpt_image_2_pixel_size(normalized_size.as_str()) + } else { + normalized_size } } @@ -115,6 +121,94 @@ fn normalize_explicit_pixel_size(value: &str) -> String { value.replace('*', "x") } +fn clamp_gpt_image_2_pixel_size(size: &str) -> String { + const MIN_PIXELS: u64 = 655_360; + const MAX_PIXELS: u64 = 8_294_400; + const MAX_EDGE: u32 = 3_840; + const DIMENSION_ALIGNMENT: u32 = 16; + const MAX_ASPECT_RATIO: f64 = 3.0; + + // 中文注释:这里是 VectorEngine 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。 + let Some((width, height)) = parse_explicit_pixel_size(size) else { + return size.to_string(); + }; + // 中文注释:零尺寸无法计算比例或像素数,直接使用已知合法的默认尺寸。 + if width == 0 || height == 0 { + return "1024x1024".to_string(); + } + + let (mut width, mut height) = (f64::from(width), f64::from(height)); + // 中文注释:先把短边补至长边的三分之一,确保长短边比不超过 3:1。 + if width / height > MAX_ASPECT_RATIO { + height = width / MAX_ASPECT_RATIO; + } else if height / width > MAX_ASPECT_RATIO { + width = height / MAX_ASPECT_RATIO; + } + + let pixels = width * height; + // 中文注释:等比缩小,优先保留已收紧后的画面比例,同时满足最大边和最大总像素。 + let scale = (MAX_EDGE as f64 / width.max(height)) + .min((MAX_PIXELS as f64 / pixels).sqrt()) + .min(1.0); + width *= scale; + height *= scale; + + let pixels = width * height; + // 中文注释:小于最小总像素时等比放大;此前已处理比例,放大不会重新突破 3:1。 + if pixels < MIN_PIXELS as f64 { + let scale = (MIN_PIXELS as f64 / pixels).sqrt(); + width *= scale; + height *= scale; + } + + // 中文注释:provider 要求两边均为 16px 倍数,向上取整避免对齐后落到最小像素以下。 + let width = align_dimension_up(width, DIMENSION_ALIGNMENT); + let height = align_dimension_up(height, DIMENSION_ALIGNMENT); + // TODO unlikely but can improve + // 中文注释:对齐可能触碰最大边或最大像素,因此发送前重新核验全部约束。 + if is_valid_gpt_image_2_size( + width, + height, + MIN_PIXELS, + MAX_PIXELS, + MAX_EDGE, + MAX_ASPECT_RATIO, + ) { + return format!("{width}x{height}"); + } + + // 中文注释:无法同时满足全部约束时,回退为已知会被 provider 接受的默认尺寸。 + "1024x1024".to_string() +} + +fn align_dimension_up(value: f64, alignment: u32) -> u32 { + ((value.ceil() as u32).saturating_add(alignment - 1) / alignment) * alignment +} + +fn is_valid_gpt_image_2_size( + width: u32, + height: u32, + min_pixels: u64, + max_pixels: u64, + max_edge: u32, + max_aspect_ratio: f64, +) -> bool { + let pixels = u64::from(width) * u64::from(height); + width > 0 + && height > 0 + && width <= max_edge + && height <= max_edge + && width.is_multiple_of(16) + && height.is_multiple_of(16) + && (min_pixels..=max_pixels).contains(&pixels) + && f64::from(width.max(height)) / f64::from(width.min(height)) <= max_aspect_ratio +} + +fn parse_explicit_pixel_size(value: &str) -> Option<(u32, u32)> { + let (width, height) = value.split_once('x')?; + Some((width.parse().ok()?, height.parse().ok()?)) +} + fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str { match aspect_ratio.trim() { "2:3" => "2:3", diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index 66c6dc0b0..0ec6d9c43 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -47,12 +47,12 @@ fn vector_engine_module_exposes_provider_protocol_helpers() { } #[test] -fn vector_engine_keeps_explicit_publication_material_pixel_sizes() { +fn vector_engine_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_budget() { let cover = build_vector_engine_image_request_body("宣发首图", None, "720x540", 1, &[]); let detail = build_vector_engine_image_request_body("详情单图", None, "720x1280", 1, &[]); let poster = build_vector_engine_image_request_body("运营海报", None, "1280x720", 1, &[]); - assert_eq!(cover["size"], "720x540"); + assert_eq!(cover["size"], "944x704"); assert_eq!(detail["size"], "720x1280"); assert_eq!(poster["size"], "1280x720"); } @@ -82,6 +82,65 @@ fn vector_engine_request_body_can_use_nanobanana2_model() { assert_eq!(body["n"], 1); } +#[test] +fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { + let gpt_body = build_vector_engine_image_request_body_with_model( + GPT_IMAGE_2_MODEL, + "小尺寸图", + None, + "640x640", + 1, + &[], + ); + let nanobanana_body = build_vector_engine_image_request_body_with_model( + "gemini-3.1-flash-image-preview", + "小尺寸图", + None, + "640x640", + 1, + &[], + ); + let oversized_gpt_body = build_vector_engine_image_request_body_with_model( + GPT_IMAGE_2_MODEL, + "大尺寸图", + None, + "4096x4096", + 1, + &[], + ); + + assert_eq!(gpt_body["size"], "816x816"); + assert_eq!(nanobanana_body["size"], "640x640"); + assert_eq!(oversized_gpt_body["size"], "2880x2880"); +} + +#[test] +fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { + for size in [ + "1x1", + "720x540", + "3841x1280", + "4096x4096", + "3200x400", + "16x4096", + "3840x3840", + ] { + let body = build_vector_engine_image_request_body("约束测试", None, size, 1, &[]); + let normalized = body["size"].as_str().expect("size should be a string"); + let (width, height) = normalized + .split_once('x') + .expect("gpt-image-2 size should be explicit pixels"); + let width = width.parse::().expect("width should be numeric"); + let height = height.parse::().expect("height should be numeric"); + let pixels = u64::from(width) * u64::from(height); + + assert!(width <= 3_840 && height <= 3_840, "{size} -> {normalized}"); + assert!(width.is_multiple_of(16) && height.is_multiple_of(16)); + assert!((655_360..=8_294_400).contains(&pixels)); + assert!(width.max(height) <= width.min(height) * 3); + } +} + #[test] fn vector_engine_request_body_can_use_nanobanana2_half_k() { let body = build_vector_engine_image_request_body_with_model( From c8dd6cbd3336da78e5cfc8b50c8129c9d8848816 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 22:03:32 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20Jenkins=20=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=20Git=20=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将生产流水线 Git checkout 地址统一为 127.0.0.1:2222 数据库导入导出改由 Built-In Node 准备并传递脚本 新增 Git 源与目标节点边界的生产运维门禁 同步生产运维文档和项目共享记忆 --- .../shared-memory/decision-log.md | 8 ++ docs/project-memory/shared-memory/pitfalls.md | 10 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- jenkins/Jenkinsfile.production-api-build | 2 +- .../Jenkinsfile.production-database-export | 26 ++++-- .../Jenkinsfile.production-database-import | 26 ++++-- ...nkinsfile.production-full-build-and-deploy | 2 +- .../Jenkinsfile.production-server-provision | 2 +- .../Jenkinsfile.production-stdb-module-build | 2 +- jenkins/Jenkinsfile.production-web-build | 2 +- scripts/check-production-ops-guardrails.mjs | 92 +++++++++++++++++++ 11 files changed, 146 insertions(+), 30 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ad437970e..7cfce81eb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4094,3 +4094,11 @@ - 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `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`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5e31109f9..162820a43 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1896,13 +1896,13 @@ - 验证:deploy 工作区应直接出现 `build//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`,不再把 `http://genarrative-station/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 `` 不再指向 `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 下不能裸读 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index f8ab176bc..11be34970 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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,也不再默认使用 `http://genarrative-station/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 或 `http://genarrative-station/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。若机器仍保留旧的 `http://genarrative-station/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 部署脚本。 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index ea9496ae6..31f67989a 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -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 = '' diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index 550f4c2cd..cf2574bd6 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -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()) { diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index c2511e2d2..cdb8f7d9a 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -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}" diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index a3883d7b9..28724d731 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -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 = '' diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index e4d3976b9..e59a82fa9 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -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' } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 7eb6971c7..0f376c974 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -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 = '' diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 63dced22f..b53b13b9f 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -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 = '' diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index d5160c5a3..c33b0b5ec 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -6827,6 +6827,32 @@ const nodeEnvFileCommandFiles = [ 'scripts/deploy/pingora-direct-rollback.sh', ]; +const jenkinsSourceCheckoutFiles = [ + 'jenkins/Jenkinsfile.production-api-build', + 'jenkins/Jenkinsfile.production-web-build', + 'jenkins/Jenkinsfile.production-stdb-module-build', + 'jenkins/Jenkinsfile.production-full-build-and-deploy', + 'jenkins/Jenkinsfile.production-database-export', + 'jenkins/Jenkinsfile.production-database-import', + 'jenkins/Jenkinsfile.production-server-provision', +]; + +const jenkinsLoopbackGitRemote = + "GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'"; + +const databaseTargetSourceStashes = [ + { + file: 'jenkins/Jenkinsfile.production-database-export', + targetStage: "stage('Export Database')", + stashName: 'database-export-source', + }, + { + file: 'jenkins/Jenkinsfile.production-database-import', + targetStage: "stage('Import Database')", + stashName: 'database-import-source', + }, +]; + let failed = false; for (const check of checks) { @@ -6845,6 +6871,72 @@ for (const check of checks) { } } +for (const file of jenkinsSourceCheckoutFiles) { + const content = readFileSync(file, 'utf8'); + if (!content.includes(jenkinsLoopbackGitRemote)) { + failed = true; + console.error( + `[check:production-ops] ${file} 的 Git checkout 必须固定使用本机 127.0.0.1:2222。`, + ); + } + if ( + !content.includes( + "GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'", + ) + ) { + failed = true; + console.error( + `[check:production-ops] ${file} 必须继续显式使用 Jenkins Gitea SSH 凭据。`, + ); + } + if ( + content.includes('192.168.35.82') || + content.includes('git.genarrative.world') || + content.includes('genarrative-station/git') + ) { + failed = true; + console.error( + `[check:production-ops] ${file} 不得保留局域网 IP、HTTP 内网别名或公网 Git 地址。`, + ); + } +} + +for (const { file, targetStage, stashName } of databaseTargetSourceStashes) { + const content = readFileSync(file, 'utf8'); + const targetStageOffset = content.indexOf(targetStage); + const prepareStageContent = + targetStageOffset >= 0 ? content.slice(0, targetStageOffset) : ''; + const targetStageContent = + targetStageOffset >= 0 ? content.slice(targetStageOffset) : ''; + if ( + !prepareStageContent.includes("label 'linux && genarrative-build'") || + !prepareStageContent.includes("$class: 'GitSCM'") + ) { + failed = true; + console.error( + `[check:production-ops] ${file} 必须只在 Jenkins Built-In Node 的 Prepare 阶段 checkout Git。`, + ); + } + if ( + !content.includes(`stash name: '${stashName}'`) || + !targetStageContent.includes(`unstash '${stashName}'`) + ) { + failed = true; + console.error( + `[check:production-ops] ${file} 必须由本机 build 节点 stash 源码脚本,再交给数据库目标阶段。`, + ); + } + if ( + targetStageContent.includes("$class: 'GitSCM'") || + targetStageContent.includes('sshUserPrivateKey(') + ) { + failed = true; + console.error( + `[check:production-ops] ${file} 的数据库目标阶段不得再次 checkout Git 或挂载 Git SSH 凭据。`, + ); + } +} + const fullPipelineContent = readFileSync( 'jenkins/Jenkinsfile.production-full-build-and-deploy', 'utf8', From 9807295a7dcfa7d03c815020b48575b2be2fca6f Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 23:26:35 +0800 Subject: [PATCH 15/18] =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=BE=93=E5=85=A5=E4=B8=8E=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隐藏生成面板资源名称字段并恢复提示词输入边框 恢复图标图集快速编辑并仅对单图标禁用 补充工具栏右键菜单工作流回归测试与编辑器文档 --- .../shared-memory/decision-log.md | 8 +-- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- ...CanvasBasicGenerationComposerView.test.tsx | 11 +--- ...ImageCanvasBasicGenerationComposerView.tsx | 15 ----- ...ImageCanvasCharacterAnimationPanelView.tsx | 8 +-- ...asCharacterGenerationComposerView.test.tsx | 5 +- ...eCanvasCharacterGenerationComposerView.tsx | 17 +---- .../ImageCanvasContextMenusView.test.tsx | 35 ++++++----- ...CanvasEditorGenerationIntegration.test.tsx | 17 +++-- ...ageCanvasGenerationAssetNameField.test.tsx | 63 ------------------- .../ImageCanvasGenerationAssetNameField.tsx | 46 -------------- .../ImageCanvasGenerationComposerView.tsx | 11 ---- .../ImageCanvasGenerationModel.test.ts | 13 ++++ .../ImageCanvasGenerationModel.ts | 2 +- ...ImageCanvasIconSpritesheetComposerView.tsx | 25 +------- ...anvasPublicationMaterialsDemoPanelView.tsx | 16 ----- ...ageCanvasSelectedLayerToolbarView.test.tsx | 25 +++++--- ...mageCanvasSpecGenerationPanelView.test.tsx | 3 +- .../ImageCanvasSpecGenerationPanelView.tsx | 27 +------- ...anvasGenerationSubmissionWorkflow.test.tsx | 31 +++++++++ .../useImageCanvasGenerationWorkflow.test.tsx | 14 +++++ src/index.css | 24 ------- 23 files changed, 126 insertions(+), 294 deletions(-) delete mode 100644 src/components/image-editor/ImageCanvasGenerationAssetNameField.test.tsx delete mode 100644 src/components/image-editor/ImageCanvasGenerationAssetNameField.tsx diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7cfce81eb..eeeef4f94 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -24,13 +24,13 @@ - 验证方式: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`,名称最多 80 个字符并在提交时去除首尾空格;当前生成面板不展示“资源名称”标签和输入框,默认沿用现有自动编号名称,历史状态或内部调用若携带非空名称,仍必须让同一个名称贯穿 `assetLabel`、`canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。移除名称输入后,角色、图标图集、UI 设计和角色动作等提示词输入恢复统一可见边框。 - 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、图片修改 provider 原始输出等中间产物基于主名称追加“(原图)”或“(原始输出)”;图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。 - 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。 -- 验证方式:覆盖自定义名、空白回退、长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、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 图片画布多产物生成任务必须保存全部可恢复产物 @@ -343,7 +343,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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 7f4bc6fef..21b7f9efc 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -56,7 +56,7 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `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`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样支持该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `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 读写。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 00d6c6e7d..e8d8a3677 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -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`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 - 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index 596ea1135..b1dfa2bee 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -58,7 +58,6 @@ function BasicGenerationHarness({ onSubmit={onSubmit} /> {dialog.prompt} - {dialog.assetLabel ?? '-'} {dialog.aspectRatio} {dialog.imageSize} {dialog.imageModel} @@ -87,25 +86,21 @@ describe('ImageCanvasBasicGenerationComposerView', () => { fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '新的提示' }, }); - fireEvent.change(screen.getByLabelText('资源名称'), { - target: { value: '自定义主视觉' }, - }); const panel = screen.getByRole('dialog', { name: '生成图片' }); + expect( + within(panel).queryByRole('textbox', { name: '资源名称' }), + ).toBeNull(); fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' })); fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); fireEvent.click(screen.getByRole('button', { name: '生成' })); expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示'); - expect(screen.getByLabelText('当前资源名称').textContent).toBe( - '自定义主视觉', - ); expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); expect(screen.getByLabelText('当前错误').textContent).toBe('-'); expect(requestUpload).toHaveBeenCalledWith('generation-reference'); expect(submitGeneration).toHaveBeenCalledWith( expect.objectContaining({ prompt: '新的提示', - assetLabel: '自定义主视觉', status: 'idle', }), ); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index e21d649cc..ce63f14c6 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -18,7 +18,6 @@ import type { GenerateDialogState, UploadTarget, } from './ImageCanvasEditorTypes'; -import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; @@ -268,20 +267,6 @@ export function ImageCanvasBasicGenerationComposerView({ ) } /> - - setGenerateDialog((currentDialog) => - currentDialog - ? { - ...resetFailedDialogStatus(currentDialog), - assetLabel, - } - : currentDialog, - ) - } - />
updatePanel({ promptText: event.target.value.slice(0, 4000) }) } /> - updatePanel({ assetLabel })} - />
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
+
+
+

新增用户留存

+ {dashboard?.range.periodLabel ?? '-'} +
+
+ + +
+
+
{(dashboard?.charts ?? []).map((chart) => ( - + ))} {dashboard && dashboard.charts.length === 0 ? (
暂无图表
@@ -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 ( +
+ {label} + + {hasEligibleUsers && metric + ? formatRateBasisPoints(metric.rateBasisPoints) + : '-'} + +
+ 留存人数 / 可观察新增人数 + + {metric + ? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)} 人` + : '-'} + +
+
+ ); +} + +function ChartPanel({ + chart, + scrollLeft, + onScrollLeftChange, +}: { + chart: AdminDashboardChartPayload; + scrollLeft: number; + onScrollLeftChange: (scrollLeft: number) => void; +}) { + const barsRef = useRef(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 (

{chart.title}

+ {chart.id === 'visit-users' ? '时段去重 ' : ''} {formatNumber(chart.total)} {chart.unit}
-
+
{ + onScrollLeftChange(event.currentTarget.scrollLeft); + }} + > {chart.buckets.map((bucket) => (
@@ -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, }; } diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 18a7cebf4..7c10e9644 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -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, @@ -1212,7 +1282,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; @@ -1990,7 +2060,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 +2197,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 +2218,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; } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index eeeef4f94..8ee050490 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4102,3 +4102,13 @@ - 目标机边界:`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、零值与滚动同步。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 162820a43..54b8b0b37 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -946,12 +946,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 && spacetime version use `,或在目标就是最新版本时执行 `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//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 运行态不要二次包圆底或拉伸比例 @@ -3080,3 +3080,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`、陶泥号横向显示、详情字段不溢出页面。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index 51cad8109..1d4ca26e2 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -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` diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 4e331594e..4758727a1 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -27,12 +27,12 @@ use shared_contracts::admin::{ AdminCreationEntryTypeConfigPayload, AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload, AdminDashboardOperationMetricPayload, AdminDashboardOperationsPayload, AdminDashboardQuery, - AdminDashboardRangePayload, AdminDashboardResponse, AdminDatabaseOverviewPayload, - AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, - AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, - AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDirectUploadTicketPayload, - AdminEditorAssetListQuery, AdminEditorAssetListResponse, AdminEditorAssetPayload, - AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse, + AdminDashboardRangePayload, AdminDashboardResponse, AdminDashboardRetentionPayload, + AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, + AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, + AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, + AdminDirectUploadTicketPayload, AdminEditorAssetListQuery, AdminEditorAssetListResponse, + AdminEditorAssetPayload, AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest, AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, @@ -52,12 +52,12 @@ use shared_contracts::creation_entry_config::{ encode_unified_creation_spec_response, validate_unified_creation_spec_for_play, }; use spacetime_client::{ - AdminEditorAssetListRecordInput, AdminEditorAssetRecord, - EditorShowcaseAssetAdminListRecordInput, EditorShowcaseAssetAdminReviewRecordInput, - EditorShowcaseAssetDisplayUpdateRecordInput, EditorShowcaseAssetRecord, - EditorShowcaseAssetRefundMarkRecordInput, EditorShowcaseCampaignConfigGetRecordInput, - EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput, - SpacetimeClientError, + AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, AdminEditorAssetListRecordInput, + AdminEditorAssetRecord, EditorShowcaseAssetAdminListRecordInput, + EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput, + EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput, + EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, + EditorShowcaseCampaignConfigUpsertRecordInput, SpacetimeClientError, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::sync::{OnceCell, Semaphore}; @@ -107,7 +107,6 @@ const ADMIN_DASHBOARD_ROW_LIMIT: u32 = 50_000; const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366; const ADMIN_EDITOR_ASSET_DEFAULT_LIMIT: u32 = 80; const ADMIN_EDITOR_ASSET_MAX_LIMIT: u32 = 200; -const ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000; const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024; #[derive(Clone, Debug)] @@ -1646,67 +1645,51 @@ struct AdminDashboardWalletStats { } #[derive(Default)] -struct AdminDashboardRegistrationStats { +struct AdminDashboardUserStats { + total_registered_users: u64, new_registered_users: AdminDashboardSeries, - warnings: Vec, -} - -#[derive(Default)] -struct AdminDashboardTrackingStats { + day1_retention: AdminDashboardRetentionPayload, + day7_retention: AdminDashboardRetentionPayload, visit_count: AdminDashboardSeries, + visit_users_by_day: AdminDashboardSeries, visit_users: u64, total_visit_count: u64, total_visit_users: u64, current_users: u64, module_visit_breakdown: BTreeMap, - warnings: Vec, } async fn build_admin_dashboard( state: &AppState, query: AdminDashboardQuery, ) -> Result { - let range = resolve_admin_dashboard_range(query)?; + let now_micros = current_admin_dashboard_utc_micros(); + let range = resolve_admin_dashboard_range_at(query, now_micros)?; let mut warnings = Vec::new(); - let total_registered_users = match fetch_admin_dashboard_count( - state, - "SELECT COUNT(*) AS row_count FROM profile_dashboard_state", - ) - .await - { - Ok(value) => value, - Err(message) => { - warnings.push(format!("注册用户总数读取失败:{message}")); - 0 - } - }; - let asset_stats = fetch_admin_dashboard_asset_stats(state, &range).await; warnings.extend(asset_stats.warnings.iter().cloned()); let wallet_stats = fetch_admin_dashboard_wallet_stats(state, &range).await; warnings.extend(wallet_stats.warnings.iter().cloned()); - let registration_stats = fetch_admin_dashboard_registration_stats(state, &range).await; - warnings.extend(registration_stats.warnings.iter().cloned()); - - let tracking_stats = fetch_admin_dashboard_tracking_stats(state, &range).await; - warnings.extend(tracking_stats.warnings.iter().cloned()); + let user_stats = fetch_admin_dashboard_user_stats(state, &range, now_micros).await?; let generated_assets = asset_stats.generated_assets.total(); let consumed_mud_points = wallet_stats.consumed_mud_points.total(); - let new_registered_users = registration_stats.new_registered_users.total(); + let new_registered_users = user_stats.new_registered_users.total(); let metrics = AdminDashboardMetricsPayload { generated_assets, consumed_mud_points, - total_registered_users, + total_registered_users: user_stats.total_registered_users, new_registered_users, - visit_users: tracking_stats.visit_users, - total_visit_users: tracking_stats.total_visit_users, - visit_count: tracking_stats.visit_count.total(), - total_visit_count: tracking_stats.total_visit_count, - current_users: tracking_stats.current_users, + day1_retention: user_stats.day1_retention.clone(), + day7_retention: user_stats.day7_retention.clone(), + visit_users: user_stats.visit_users, + total_visit_users: user_stats.total_visit_users, + visit_count: user_stats.visit_count.total(), + total_visit_count: user_stats.total_visit_count, + current_users: user_stats.current_users, }; let charts = vec![ @@ -1715,6 +1698,7 @@ async fn build_admin_dashboard( "生产素材", "个", &asset_stats.generated_assets, + asset_stats.generated_assets.total(), &range, ), build_admin_dashboard_chart( @@ -1722,20 +1706,23 @@ async fn build_admin_dashboard( "消耗泥点", "泥点", &wallet_stats.consumed_mud_points, + wallet_stats.consumed_mud_points.total(), &range, ), build_admin_dashboard_chart( "visit-users", - "访问人数", + "每日访问人数", "人", - &AdminDashboardSeries::single_total(&range, tracking_stats.visit_users), + &user_stats.visit_users_by_day, + user_stats.visit_users, &range, ), build_admin_dashboard_chart( "visit-count", "访问次数", "次", - &tracking_stats.visit_count, + &user_stats.visit_count, + user_stats.visit_count.total(), &range, ), ]; @@ -1748,16 +1735,25 @@ async fn build_admin_dashboard( &metrics, wallet_stats.recharged_mud_points, asset_stats.asset_kind_breakdown, - tracking_stats.module_visit_breakdown, + user_stats.module_visit_breakdown, ), warnings, - generated_at: module_runtime::format_utc_micros(current_admin_dashboard_utc_micros()), + generated_at: module_runtime::format_utc_micros(now_micros), }) } +#[cfg(test)] fn resolve_admin_dashboard_range( query: AdminDashboardQuery, ) -> Result { + resolve_admin_dashboard_range_at(query, current_admin_dashboard_utc_micros()) +} + +fn resolve_admin_dashboard_range_at( + query: AdminDashboardQuery, + now_micros: i64, +) -> Result { + let today_date_key = module_runtime::runtime_profile_beijing_day_key(now_micros); let granularity = match query .granularity .as_deref() @@ -1777,7 +1773,7 @@ fn resolve_admin_dashboard_range( } }; if matches!(granularity, AdminDashboardGranularity::Period) { - return resolve_admin_dashboard_period_range(query); + return resolve_admin_dashboard_period_range(query, today_date_key); } let anchor_date_key = match query @@ -1792,12 +1788,14 @@ fn resolve_admin_dashboard_range( .with_message("dashboard anchor 必须是合法 YYYY-MM-DD 日期") })? } - None => { - module_runtime::runtime_profile_beijing_day_key(current_admin_dashboard_utc_micros()) - } + None => today_date_key, }; + if anchor_date_key > today_date_key { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("dashboard anchor 不能晚于今天")); + } let dimension = module_runtime::build_analytics_date_dimension_from_date_key(anchor_date_key); - let (start_date_key, end_date_key) = match granularity { + let (start_date_key, natural_end_date_key) = match granularity { AdminDashboardGranularity::Day => (anchor_date_key, anchor_date_key), AdminDashboardGranularity::Week => { (dimension.week_start_date_key, dimension.week_end_date_key) @@ -1807,6 +1805,7 @@ fn resolve_admin_dashboard_range( } AdminDashboardGranularity::Period => unreachable!("period range is resolved above"), }; + let end_date_key = natural_end_date_key.min(today_date_key); Ok(AdminDashboardRangeResolved { granularity, anchor_date_key, @@ -1817,6 +1816,7 @@ fn resolve_admin_dashboard_range( fn resolve_admin_dashboard_period_range( query: AdminDashboardQuery, + today_date_key: i64, ) -> Result { let start_date_key = parse_admin_dashboard_date_param( query.start_date.as_deref(), @@ -1838,6 +1838,11 @@ fn resolve_admin_dashboard_period_range( )), ); } + if start_date_key > today_date_key { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("dashboard startDate 不能晚于今天")); + } + let end_date_key = end_date_key.min(today_date_key); Ok(AdminDashboardRangeResolved { granularity: AdminDashboardGranularity::Period, anchor_date_key: end_date_key, @@ -1980,181 +1985,70 @@ async fn fetch_admin_dashboard_wallet_stats( stats } -async fn fetch_admin_dashboard_registration_stats( +async fn fetch_admin_dashboard_user_stats( state: &AppState, range: &AdminDashboardRangeResolved, -) -> AdminDashboardRegistrationStats { - let mut stats = AdminDashboardRegistrationStats::default(); - let sql = format!( - "SELECT created_at FROM profile_dashboard_state LIMIT {}", - ADMIN_DASHBOARD_ROW_LIMIT - ); - let rows = match fetch_admin_dashboard_rows(state, &sql).await { - Ok(rows) => rows, - Err(message) => { - stats - .warnings - .push(format!("新增用户统计读取失败:{message}")); - return stats; - } + now_micros: i64, +) -> Result { + let input = AdminDashboardStatsRecordInput { + start_day_key: range.start_date_key, + end_day_key: range.end_date_key, + now_micros, }; - warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "新增用户", rows.len()); - apply_admin_dashboard_registration_rows(&mut stats, range, rows.iter()); + admin_dashboard_user_stats_from_result( + state + .spacetime_client() + .get_admin_dashboard_stats(input) + .await, + ) +} + +fn admin_dashboard_user_stats_from_result( + result: Result, +) -> Result { + result + .map(admin_dashboard_user_stats_from_record) + .map_err(map_admin_spacetime_error) +} + +fn admin_dashboard_user_stats_from_record( + record: AdminDashboardStatsRecord, +) -> AdminDashboardUserStats { + let mut stats = AdminDashboardUserStats { + total_registered_users: record.total_registered_users, + day1_retention: AdminDashboardRetentionPayload { + eligible_users: record.day1_retention.eligible_users, + retained_users: record.day1_retention.retained_users, + rate_basis_points: record.day1_retention.rate_basis_points, + }, + day7_retention: AdminDashboardRetentionPayload { + eligible_users: record.day7_retention.eligible_users, + retained_users: record.day7_retention.retained_users, + rate_basis_points: record.day7_retention.rate_basis_points, + }, + visit_users: record.visit_users, + total_visit_count: record.total_visit_count, + total_visit_users: record.total_visit_users, + current_users: record.current_users, + module_visit_breakdown: record + .module_visit_breakdown + .into_iter() + .map(|entry| (entry.key, entry.count)) + .collect(), + ..Default::default() + }; + for entry in record.new_registered_users { + stats.new_registered_users.add(entry.day_key, entry.count); + } + for entry in record.visit_count { + stats.visit_count.add(entry.day_key, entry.count); + } + for entry in record.visit_users_by_day { + stats.visit_users_by_day.add(entry.day_key, entry.count); + } stats } -fn apply_admin_dashboard_registration_rows<'a, I>( - stats: &mut AdminDashboardRegistrationStats, - range: &AdminDashboardRangeResolved, - rows: I, -) where - I: IntoIterator, -{ - for row in rows { - let Some(columns) = row.as_array() else { - continue; - }; - let Some(created_at_micros) = columns.first().and_then(timestamp_value_to_micros) else { - continue; - }; - let day_key = module_runtime::runtime_profile_beijing_day_key(created_at_micros); - if range.contains_day_key(day_key) { - stats.new_registered_users.add(day_key, 1); - } - } -} - -async fn fetch_admin_dashboard_tracking_stats( - state: &AppState, - range: &AdminDashboardRangeResolved, -) -> AdminDashboardTrackingStats { - let mut stats = AdminDashboardTrackingStats::default(); - let daily_sql = format!( - "SELECT event_key, scope_kind, scope_id, day_key, count FROM tracking_daily_stat LIMIT {}", - ADMIN_DASHBOARD_ROW_LIMIT - ); - let daily_rows = match fetch_admin_dashboard_rows(state, &daily_sql).await { - Ok(rows) => rows, - Err(message) => { - stats - .warnings - .push(format!("访问聚合统计读取失败:{message}")); - Vec::new() - } - }; - warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "访问聚合", daily_rows.len()); - let mut period_users = BTreeSet::new(); - let mut total_users = BTreeSet::new(); - for row in daily_rows { - let Some(columns) = row.as_array() else { - continue; - }; - let scope_kind = columns - .get(1) - .and_then(tracking_scope_kind_to_string) - .unwrap_or_default(); - let scope_id = columns.get(2).and_then(value_to_string).unwrap_or_default(); - let day_key = columns.get(3).and_then(value_to_i64).unwrap_or_default(); - let count = columns.get(4).and_then(value_to_u64).unwrap_or_default(); - if scope_kind == "site" { - stats.total_visit_count = stats.total_visit_count.saturating_add(count); - } - if scope_kind == "user" && !scope_id.trim().is_empty() && scope_id != "anonymous" { - total_users.insert(scope_id.clone()); - } - if range.contains_day_key(day_key) { - if scope_kind == "site" { - stats.visit_count.add(day_key, count); - } - if scope_kind == "user" && !scope_id.trim().is_empty() && scope_id != "anonymous" { - period_users.insert(scope_id); - } - } - } - stats.visit_users = period_users.len() as u64; - stats.total_visit_users = total_users.len() as u64; - - let event_sql = format!( - "SELECT user_id, module_key, day_key, occurred_at FROM tracking_event WHERE day_key >= {} AND day_key <= {} LIMIT {}", - range.start_date_key, range.end_date_key, ADMIN_DASHBOARD_ROW_LIMIT - ); - let event_rows = match fetch_admin_dashboard_rows(state, &event_sql).await { - Ok(rows) => rows, - Err(message) => { - stats - .warnings - .push(format!("访问明细统计读取失败:{message}")); - Vec::new() - } - }; - warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "访问明细", event_rows.len()); - for row in event_rows { - let Some(columns) = row.as_array() else { - continue; - }; - let module_key = columns - .get(1) - .and_then(value_to_string) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "unknown".to_string()); - *stats.module_visit_breakdown.entry(module_key).or_insert(0) += 1; - } - - let active_since_micros = - current_admin_dashboard_utc_micros().saturating_sub(ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS); - let active_start_day_key = module_runtime::runtime_profile_beijing_day_key(active_since_micros); - let active_end_day_key = - module_runtime::runtime_profile_beijing_day_key(current_admin_dashboard_utc_micros()); - let current_sql = format!( - "SELECT user_id, occurred_at FROM tracking_event WHERE day_key >= {} AND day_key <= {} LIMIT {}", - active_start_day_key, active_end_day_key, ADMIN_DASHBOARD_ROW_LIMIT - ); - let current_rows = match fetch_admin_dashboard_rows(state, ¤t_sql).await { - Ok(rows) => rows, - Err(message) => { - stats - .warnings - .push(format!("当前使用人数读取失败:{message}")); - Vec::new() - } - }; - warn_if_dashboard_rows_may_be_truncated( - &mut stats.warnings, - "当前使用人数", - current_rows.len(), - ); - let mut current_users = BTreeSet::new(); - for row in current_rows { - let Some(columns) = row.as_array() else { - continue; - }; - let Some(occurred_at_micros) = columns.get(1).and_then(timestamp_value_to_micros) else { - continue; - }; - if occurred_at_micros >= active_since_micros { - if let Some(user_id) = columns - .first() - .and_then(value_to_string) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty() && value != "anonymous") - { - current_users.insert(user_id); - } - } - } - stats.current_users = current_users.len() as u64; - stats -} - -async fn fetch_admin_dashboard_count(state: &AppState, sql: &str) -> Result { - let client = Client::new(); - let server_root = state.config.spacetime_server_url.trim_end_matches('/'); - let database = state.config.spacetime_database.trim(); - let token = resolve_admin_spacetime_sql_token(state); - fetch_spacetime_sql_count(&client, server_root, database, token.as_deref(), sql).await -} - async fn fetch_admin_dashboard_rows(state: &AppState, sql: &str) -> Result, String> { let client = Client::new(); let server_root = state.config.spacetime_server_url.trim_end_matches('/'); @@ -2170,13 +2064,14 @@ fn build_admin_dashboard_chart( title: &str, unit: &str, series: &AdminDashboardSeries, + total: u64, range: &AdminDashboardRangeResolved, ) -> AdminDashboardChartPayload { AdminDashboardChartPayload { id: id.to_string(), title: title.to_string(), unit: unit.to_string(), - total: series.total(), + total, buckets: range .day_keys() .into_iter() @@ -2335,12 +2230,6 @@ impl AdminDashboardSeries { fn total(&self) -> u64 { self.buckets.values().copied().sum() } - - fn single_total(range: &AdminDashboardRangeResolved, value: u64) -> Self { - let mut series = Self::default(); - series.add(range.anchor_date_key, value); - series - } } fn build_spacetime_schema_url(server_root: &str, database: &str) -> String { @@ -3485,26 +3374,6 @@ fn value_to_i64(value: &Value) -> Option { } } -fn value_to_u64(value: &Value) -> Option { - match value { - Value::Number(number) => number - .as_u64() - .or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())), - Value::String(text) => text.trim().parse::().ok(), - Value::Object(object) => object.get("some").and_then(value_to_u64), - Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_u64), - Value::Array(items) if items.len() == 2 => { - let tag = items.first().and_then(Value::as_u64)?; - if tag == 0 { - items.get(1).and_then(value_to_u64) - } else { - None - } - } - _ => None, - } -} - fn timestamp_value_to_micros(value: &Value) -> Option { value_to_i64(value).or_else(|| { let text = value_to_string(value)?; @@ -3854,11 +3723,11 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - AdminDashboardGranularity, AdminDashboardRegistrationStats, AdminDashboardSeries, - AdminDisplayNameDirectory, EditorShowcaseAssetRecord, + AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, + admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, - apply_admin_dashboard_registration_rows, apply_admin_database_table_filters, - build_admin_asset_read_url_audit, build_admin_database_table_row, + apply_admin_database_table_filters, build_admin_asset_read_url_audit, + build_admin_dashboard_chart, build_admin_database_table_row, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, @@ -3867,9 +3736,9 @@ mod tests { matches_admin_editor_asset_keyword, normalize_debug_path, normalize_table_count_error, parse_admin_database_table_rows_sql_response, parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, - resolve_admin_dashboard_range, resolve_admin_database_table_sql_limit, - timestamp_value_to_micros, trim_preview, verify_admin_password, - wallet_ledger_source_type_to_string, + resolve_admin_dashboard_range, resolve_admin_dashboard_range_at, + resolve_admin_database_table_sql_limit, timestamp_value_to_micros, trim_preview, + verify_admin_password, wallet_ledger_source_type_to_string, }; use axum::{ http::{Method, StatusCode}, @@ -3881,7 +3750,11 @@ mod tests { AdminTrackingEventListQuery, }; use shared_contracts::assets::GetReadUrlQuery; - use spacetime_client::{SpacetimeClientError, SpacetimeClientStage}; + use spacetime_client::{ + AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, + AdminDashboardRetentionRecord, AdminDashboardStatsRecord, SpacetimeClientError, + SpacetimeClientStage, + }; use crate::{config::AppConfig, state::AppState}; @@ -4240,29 +4113,123 @@ mod tests { } #[test] - fn apply_admin_dashboard_registration_rows_counts_created_accounts_in_range() { - let range = resolve_admin_dashboard_range(AdminDashboardQuery { - granularity: Some("period".to_string()), - start_date: Some("2026-06-10".to_string()), - end_date: Some("2026-06-18".to_string()), - ..Default::default() - }) - .expect("period range should resolve"); - let rows = vec![ - json!(["2026-06-09T15:59:59Z"]), - json!(["2026-06-10T00:30:00Z"]), - json!(["2026-06-18T15:59:59Z"]), - json!(["2026-06-18T16:00:00Z"]), - json!(["bad-timestamp"]), - ]; - let mut stats = AdminDashboardRegistrationStats { - new_registered_users: AdminDashboardSeries::default(), - warnings: Vec::new(), + fn dashboard_user_stats_maps_authoritative_projection() { + let stats = admin_dashboard_user_stats_from_record(AdminDashboardStatsRecord { + total_registered_users: 591, + new_registered_users: vec![AdminDashboardDailyCountRecord { + day_key: 100, + count: 3, + }], + visit_count: vec![AdminDashboardDailyCountRecord { + day_key: 100, + count: 50_001, + }], + visit_users_by_day: vec![AdminDashboardDailyCountRecord { + day_key: 100, + count: 335, + }], + visit_users: 335, + total_visit_count: 70_005, + total_visit_users: 410, + current_users: 2, + day1_retention: AdminDashboardRetentionRecord { + eligible_users: 100, + retained_users: 37, + rate_basis_points: 3_700, + }, + day7_retention: AdminDashboardRetentionRecord { + eligible_users: 80, + retained_users: 16, + rate_basis_points: 2_000, + }, + module_visit_breakdown: vec![AdminDashboardBreakdownRecord { + key: "profile".to_string(), + count: 50_001, + }], + }); + + assert_eq!(stats.total_registered_users, 591); + assert_eq!(stats.visit_count.total(), 50_001); + assert_eq!(stats.total_visit_count, 70_005); + assert_eq!(stats.total_visit_users, 410); + assert_eq!(stats.day1_retention.rate_basis_points, 3_700); + assert_eq!(stats.module_visit_breakdown["profile"], 50_001); + } + + #[test] + fn dashboard_user_stats_failure_is_not_converted_to_zero_values() { + let error = match admin_dashboard_user_stats_from_result(Err( + SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult), + )) { + Ok(_) => panic!("failed authoritative stats must fail the dashboard request"), + Err(error) => error, }; - apply_admin_dashboard_registration_rows(&mut stats, &range, rows.iter()); + assert_eq!(error.into_response().status(), StatusCode::BAD_GATEWAY); + } - assert_eq!(stats.new_registered_users.total(), 2); + #[test] + fn dashboard_current_ranges_stop_at_beijing_today() { + const DAY_MICROS: i64 = 86_400_000_000; + let today = module_runtime::parse_analytics_calendar_date_key("2026-07-14").unwrap(); + let now_micros = today * DAY_MICROS; + let month = resolve_admin_dashboard_range_at( + AdminDashboardQuery { + granularity: Some("month".to_string()), + anchor: Some("2026-07-14".to_string()), + ..Default::default() + }, + now_micros, + ) + .expect("current month should resolve"); + let period = resolve_admin_dashboard_range_at( + AdminDashboardQuery { + granularity: Some("period".to_string()), + start_date: Some("2026-07-01".to_string()), + end_date: Some("2026-07-31".to_string()), + ..Default::default() + }, + now_micros, + ) + .expect("current period should clamp"); + + assert_eq!(month.end_date_key, today); + assert_eq!(period.end_date_key, today); + assert_eq!(month.day_keys().len(), 14); + assert_eq!(period.day_keys().len(), 14); + } + + #[test] + fn dashboard_daily_visit_chart_keeps_period_unique_total() { + let range = resolve_admin_dashboard_range_at( + AdminDashboardQuery { + granularity: Some("period".to_string()), + start_date: Some("2026-07-01".to_string()), + end_date: Some("2026-07-02".to_string()), + ..Default::default() + }, + module_runtime::parse_analytics_calendar_date_key("2026-07-03").unwrap() + * 86_400_000_000, + ) + .expect("period should resolve"); + let mut daily_users = super::AdminDashboardSeries::default(); + daily_users.add(range.start_date_key, 3); + daily_users.add(range.end_date_key, 2); + + let chart = build_admin_dashboard_chart( + "visit-users", + "每日访问人数", + "人", + &daily_users, + 4, + &range, + ); + + assert_eq!(chart.total, 4); + assert_eq!( + chart.buckets.iter().map(|bucket| bucket.value).sum::(), + 5 + ); } #[test] diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 350cf18e1..039e527bb 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -507,6 +507,8 @@ pub struct AdminDashboardMetricsPayload { pub consumed_mud_points: u64, pub total_registered_users: u64, pub new_registered_users: u64, + pub day1_retention: AdminDashboardRetentionPayload, + pub day7_retention: AdminDashboardRetentionPayload, pub visit_users: u64, pub total_visit_users: u64, pub visit_count: u64, @@ -514,6 +516,15 @@ pub struct AdminDashboardMetricsPayload { pub current_users: u64, } +/// dashboard 新增用户留存;rate_basis_points 取值 0..=10000,1 表示 0.01%。 +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDashboardRetentionPayload { + pub eligible_users: u64, + pub retained_users: u64, + pub rate_basis_points: u32, +} + /// dashboard 图表定义,前端只渲染后端给出的 bucket。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/server-rs/crates/spacetime-client/src/admin_dashboard.rs b/server-rs/crates/spacetime-client/src/admin_dashboard.rs new file mode 100644 index 000000000..d981070ed --- /dev/null +++ b/server-rs/crates/spacetime-client/src/admin_dashboard.rs @@ -0,0 +1,27 @@ +use super::*; + +impl SpacetimeClient { + pub async fn get_admin_dashboard_stats( + &self, + input: AdminDashboardStatsRecordInput, + ) -> Result { + let procedure_input = input.into(); + self.call_after_connect( + "get_admin_dashboard_stats_and_return", + move |connection, sender| { + connection + .procedures() + .get_admin_dashboard_stats_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_admin_dashboard_stats_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index ea01c864f..b1ead3932 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -7,9 +7,11 @@ mod telemetry; use mapper::*; pub use mapper::{ AdminAccountCreateRecordInput, AdminAccountCredentialRecord, AdminAccountRecord, - AdminAccountUpdateRecordInput, AdminEditorAssetListRecordInput, AdminEditorAssetRecord, - AdminWorkVisibilityRecord, AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, - AiTaskStageRecord, AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord, + AdminAccountUpdateRecordInput, AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, + AdminDashboardRetentionRecord, AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, + AdminEditorAssetListRecordInput, AdminEditorAssetRecord, AdminWorkVisibilityRecord, + AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, + AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, @@ -131,8 +133,9 @@ pub use mapper::{ WoodenFishWorkSummaryResponse, WoodenFishWorkspaceCreateRequest, }; -pub mod ai; pub mod admin_account; +pub mod admin_dashboard; +pub mod ai; pub mod assets; pub mod auth; pub mod bark_battle; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 4b1427806..d391acd66 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1,7 +1,8 @@ use super::*; -mod ai; mod admin_account; +mod admin_dashboard; +mod ai; mod assets; mod auth; mod bark_battle; @@ -28,14 +29,18 @@ mod story; mod visual_novel; mod wooden_fish; -pub use self::ai::{ - AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, - AiTextChunkRecord, -}; pub use self::admin_account::{ AdminAccountCreateRecordInput, AdminAccountCredentialRecord, AdminAccountRecord, AdminAccountUpdateRecordInput, }; +pub use self::admin_dashboard::{ + AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, AdminDashboardRetentionRecord, + AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, +}; +pub use self::ai::{ + AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, + AiTextChunkRecord, +}; pub use self::assets::{ BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishSessionRecord, CustomWorldAgentMessageFinalizeRecordInput, @@ -194,6 +199,11 @@ pub use self::wooden_fish::{ WoodenFishWorkSummaryResponse, WoodenFishWorkspaceCreateRequest, }; +pub(crate) use self::admin_account::{ + map_admin_account_credential_procedure_result, map_admin_account_list_procedure_result, + map_admin_account_single_procedure_result, +}; +pub(crate) use self::admin_dashboard::map_admin_dashboard_stats_procedure_result; pub(crate) use self::ai::map_ai_task_procedure_result; pub(crate) use self::assets::{ map_asset_read_access_procedure_result, map_entity_binding_procedure_result, @@ -217,10 +227,6 @@ pub(crate) use self::combat::{ map_battle_mode, map_battle_mode_back, map_battle_state_procedure_result, map_battle_status, map_combat_outcome, map_resolve_combat_action_procedure_result, }; -pub(crate) use self::admin_account::{ - map_admin_account_credential_procedure_result, map_admin_account_list_procedure_result, - map_admin_account_single_procedure_result, -}; pub(crate) use self::common::{empty_string_to_none, i64_to_u64_ms, parse_optional_json_value}; pub(crate) use self::custom_world::{ map_custom_world_agent_action_execute_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/admin_dashboard.rs b/server-rs/crates/spacetime-client/src/mapper/admin_dashboard.rs new file mode 100644 index 000000000..d482d4b85 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/admin_dashboard.rs @@ -0,0 +1,185 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdminDashboardStatsRecordInput { + pub start_day_key: i64, + pub end_day_key: i64, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdminDashboardDailyCountRecord { + pub day_key: i64, + pub count: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdminDashboardBreakdownRecord { + pub key: String, + pub count: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AdminDashboardRetentionRecord { + pub eligible_users: u64, + pub retained_users: u64, + pub rate_basis_points: u32, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AdminDashboardStatsRecord { + pub total_registered_users: u64, + pub new_registered_users: Vec, + pub visit_count: Vec, + pub visit_users_by_day: Vec, + pub visit_users: u64, + pub total_visit_count: u64, + pub total_visit_users: u64, + pub current_users: u64, + pub day1_retention: AdminDashboardRetentionRecord, + pub day7_retention: AdminDashboardRetentionRecord, + pub module_visit_breakdown: Vec, +} + +impl From for AdminDashboardStatsInput { + fn from(input: AdminDashboardStatsRecordInput) -> Self { + Self { + start_day_key: input.start_day_key, + end_day_key: input.end_day_key, + now_micros: input.now_micros, + } + } +} + +pub(crate) fn map_admin_dashboard_stats_procedure_result( + result: AdminDashboardStatsProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .snapshot + .map(map_admin_dashboard_stats_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("后台 Dashboard 统计快照")) +} + +fn map_admin_dashboard_stats_snapshot( + snapshot: AdminDashboardStatsSnapshot, +) -> AdminDashboardStatsRecord { + AdminDashboardStatsRecord { + total_registered_users: snapshot.total_registered_users, + new_registered_users: snapshot + .new_registered_users + .into_iter() + .map(map_admin_dashboard_daily_count) + .collect(), + visit_count: snapshot + .visit_count + .into_iter() + .map(map_admin_dashboard_daily_count) + .collect(), + visit_users_by_day: snapshot + .visit_users_by_day + .into_iter() + .map(map_admin_dashboard_daily_count) + .collect(), + visit_users: snapshot.visit_users, + total_visit_count: snapshot.total_visit_count, + total_visit_users: snapshot.total_visit_users, + current_users: snapshot.current_users, + day1_retention: map_admin_dashboard_retention(snapshot.day_1_retention), + day7_retention: map_admin_dashboard_retention(snapshot.day_7_retention), + module_visit_breakdown: snapshot + .module_visit_breakdown + .into_iter() + .map(|entry| AdminDashboardBreakdownRecord { + key: entry.key, + count: entry.count, + }) + .collect(), + } +} + +fn map_admin_dashboard_daily_count( + entry: AdminDashboardDailyCountSnapshot, +) -> AdminDashboardDailyCountRecord { + AdminDashboardDailyCountRecord { + day_key: entry.day_key, + count: entry.count, + } +} + +fn map_admin_dashboard_retention( + retention: AdminDashboardRetentionSnapshot, +) -> AdminDashboardRetentionRecord { + AdminDashboardRetentionRecord { + eligible_users: retention.eligible_users, + retained_users: retention.retained_users, + rate_basis_points: retention.rate_basis_points, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_admin_dashboard_stats_procedure_snapshot() { + let record = + map_admin_dashboard_stats_procedure_result(AdminDashboardStatsProcedureResult { + ok: true, + snapshot: Some(AdminDashboardStatsSnapshot { + total_registered_users: 12, + new_registered_users: vec![AdminDashboardDailyCountSnapshot { + day_key: 100, + count: 3, + }], + visit_count: vec![AdminDashboardDailyCountSnapshot { + day_key: 100, + count: 20, + }], + visit_users_by_day: vec![AdminDashboardDailyCountSnapshot { + day_key: 100, + count: 8, + }], + visit_users: 8, + total_visit_count: 99, + total_visit_users: 11, + current_users: 2, + day_1_retention: AdminDashboardRetentionSnapshot { + eligible_users: 4, + retained_users: 2, + rate_basis_points: 5_000, + }, + day_7_retention: AdminDashboardRetentionSnapshot { + eligible_users: 3, + retained_users: 1, + rate_basis_points: 3_333, + }, + module_visit_breakdown: vec![AdminDashboardBreakdownSnapshot { + key: "profile".to_string(), + count: 7, + }], + }), + error_message: None, + }) + .expect("dashboard stats should map"); + + assert_eq!(record.total_visit_count, 99); + assert_eq!(record.day1_retention.rate_basis_points, 5_000); + assert_eq!(record.module_visit_breakdown[0].key, "profile"); + } + + #[test] + fn rejects_failed_admin_dashboard_stats_procedure_result() { + let error = + map_admin_dashboard_stats_procedure_result(AdminDashboardStatsProcedureResult { + ok: false, + snapshot: None, + error_message: Some("统计失败".to_string()), + }) + .expect_err("failed result should stay failed"); + + assert!(error.to_string().contains("统计失败")); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b9b975017..06b4d23f0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -20,6 +20,12 @@ pub mod admin_account_snapshot_type; pub mod admin_account_table; pub mod admin_account_type; pub mod admin_account_update_input_type; +pub mod admin_dashboard_breakdown_snapshot_type; +pub mod admin_dashboard_daily_count_snapshot_type; +pub mod admin_dashboard_retention_snapshot_type; +pub mod admin_dashboard_stats_input_type; +pub mod admin_dashboard_stats_procedure_result_type; +pub mod admin_dashboard_stats_snapshot_type; pub mod admin_disable_profile_redeem_code_procedure; pub mod admin_disable_profile_task_config_procedure; pub mod admin_editor_asset_list_input_type; @@ -535,6 +541,7 @@ pub mod finish_wooden_fish_run_procedure; pub mod generate_big_fish_asset_procedure; pub mod get_admin_account_by_id_and_return_procedure; pub mod get_admin_account_by_username_and_return_procedure; +pub mod get_admin_dashboard_stats_and_return_procedure; pub mod get_asset_object_by_id_and_return_procedure; pub mod get_asset_object_by_location_and_return_procedure; pub mod get_asset_read_access_by_location_and_return_procedure; @@ -1436,6 +1443,12 @@ pub use admin_account_snapshot_type::AdminAccountSnapshot; pub use admin_account_table::*; pub use admin_account_type::AdminAccount; pub use admin_account_update_input_type::AdminAccountUpdateInput; +pub use admin_dashboard_breakdown_snapshot_type::AdminDashboardBreakdownSnapshot; +pub use admin_dashboard_daily_count_snapshot_type::AdminDashboardDailyCountSnapshot; +pub use admin_dashboard_retention_snapshot_type::AdminDashboardRetentionSnapshot; +pub use admin_dashboard_stats_input_type::AdminDashboardStatsInput; +pub use admin_dashboard_stats_procedure_result_type::AdminDashboardStatsProcedureResult; +pub use admin_dashboard_stats_snapshot_type::AdminDashboardStatsSnapshot; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_config; pub use admin_editor_asset_list_input_type::AdminEditorAssetListInput; @@ -1951,6 +1964,7 @@ pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use get_admin_account_by_id_and_return_procedure::get_admin_account_by_id_and_return; pub use get_admin_account_by_username_and_return_procedure::get_admin_account_by_username_and_return; +pub use get_admin_dashboard_stats_and_return_procedure::get_admin_dashboard_stats_and_return; pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_return; pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return; pub use get_asset_read_access_by_location_and_return_procedure::get_asset_read_access_by_location_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_breakdown_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_breakdown_snapshot_type.rs new file mode 100644 index 000000000..14262ccf6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_breakdown_snapshot_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminDashboardBreakdownSnapshot { + pub key: String, + pub count: u64, +} + +impl __sdk::InModule for AdminDashboardBreakdownSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_daily_count_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_daily_count_snapshot_type.rs new file mode 100644 index 000000000..383ebe911 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_daily_count_snapshot_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminDashboardDailyCountSnapshot { + pub day_key: i64, + pub count: u64, +} + +impl __sdk::InModule for AdminDashboardDailyCountSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_retention_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_retention_snapshot_type.rs new file mode 100644 index 000000000..f99b2d7ca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_retention_snapshot_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminDashboardRetentionSnapshot { + pub eligible_users: u64, + pub retained_users: u64, + pub rate_basis_points: u32, +} + +impl __sdk::InModule for AdminDashboardRetentionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_input_type.rs new file mode 100644 index 000000000..52d80f576 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminDashboardStatsInput { + pub start_day_key: i64, + pub end_day_key: i64, + pub now_micros: i64, +} + +impl __sdk::InModule for AdminDashboardStatsInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_procedure_result_type.rs new file mode 100644 index 000000000..b6dd77a61 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::admin_dashboard_stats_snapshot_type::AdminDashboardStatsSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminDashboardStatsProcedureResult { + pub ok: bool, + pub snapshot: Option, + pub error_message: Option, +} + +impl __sdk::InModule for AdminDashboardStatsProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_snapshot_type.rs new file mode 100644 index 000000000..48a1ad108 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_snapshot_type.rs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::admin_dashboard_breakdown_snapshot_type::AdminDashboardBreakdownSnapshot; +use super::admin_dashboard_daily_count_snapshot_type::AdminDashboardDailyCountSnapshot; +use super::admin_dashboard_retention_snapshot_type::AdminDashboardRetentionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminDashboardStatsSnapshot { + pub total_registered_users: u64, + pub new_registered_users: Vec, + pub visit_count: Vec, + pub visit_users_by_day: Vec, + pub visit_users: u64, + pub total_visit_count: u64, + pub total_visit_users: u64, + pub current_users: u64, + pub day_1_retention: AdminDashboardRetentionSnapshot, + pub day_7_retention: AdminDashboardRetentionSnapshot, + pub module_visit_breakdown: Vec, +} + +impl __sdk::InModule for AdminDashboardStatsSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_dashboard_stats_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_dashboard_stats_and_return_procedure.rs new file mode 100644 index 000000000..ec663a6d4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_dashboard_stats_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::admin_dashboard_stats_input_type::AdminDashboardStatsInput; +use super::admin_dashboard_stats_procedure_result_type::AdminDashboardStatsProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetAdminDashboardStatsAndReturnArgs { + pub input: AdminDashboardStatsInput, +} + +impl __sdk::InModule for GetAdminDashboardStatsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_admin_dashboard_stats_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_admin_dashboard_stats_and_return { + fn get_admin_dashboard_stats_and_return(&self, input: AdminDashboardStatsInput) { + self.get_admin_dashboard_stats_and_return_then(input, |_, _| {}); + } + + fn get_admin_dashboard_stats_and_return_then( + &self, + input: AdminDashboardStatsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_admin_dashboard_stats_and_return for super::RemoteProcedures { + fn get_admin_dashboard_stats_and_return_then( + &self, + input: AdminDashboardStatsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AdminDashboardStatsProcedureResult>( + "get_admin_dashboard_stats_and_return", + GetAdminDashboardStatsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/admin_dashboard.rs b/server-rs/crates/spacetime-module/src/admin_dashboard.rs new file mode 100644 index 000000000..16bec5379 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/admin_dashboard.rs @@ -0,0 +1,475 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use crate::*; + +const ADMIN_DASHBOARD_MAX_RANGE_DAYS: i64 = 366; +const ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000; + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AdminDashboardStatsInput { + pub start_day_key: i64, + pub end_day_key: i64, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AdminDashboardDailyCountSnapshot { + pub day_key: i64, + pub count: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AdminDashboardBreakdownSnapshot { + pub key: String, + pub count: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, SpacetimeType)] +pub struct AdminDashboardRetentionSnapshot { + pub eligible_users: u64, + pub retained_users: u64, + pub rate_basis_points: u32, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, SpacetimeType)] +pub struct AdminDashboardStatsSnapshot { + pub total_registered_users: u64, + pub new_registered_users: Vec, + pub visit_count: Vec, + pub visit_users_by_day: Vec, + pub visit_users: u64, + pub total_visit_count: u64, + pub total_visit_users: u64, + pub current_users: u64, + pub day1_retention: AdminDashboardRetentionSnapshot, + pub day7_retention: AdminDashboardRetentionSnapshot, + pub module_visit_breakdown: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AdminDashboardStatsProcedureResult { + pub ok: bool, + pub snapshot: Option, + pub error_message: Option, +} + +struct AdminDashboardDailyStatsAccumulator<'a> { + start_day_key: i64, + end_day_key: i64, + selected_registrations: &'a BTreeMap, + visit_count: BTreeMap, + visit_users_by_day: BTreeMap>, + period_visit_users: BTreeSet, + total_visit_users: BTreeSet, + total_visit_count: u64, + retained_activity_days: HashSet<(String, i64)>, +} + +impl<'a> AdminDashboardDailyStatsAccumulator<'a> { + fn new( + start_day_key: i64, + end_day_key: i64, + selected_registrations: &'a BTreeMap, + ) -> Self { + Self { + start_day_key, + end_day_key, + selected_registrations, + visit_count: BTreeMap::new(), + visit_users_by_day: BTreeMap::new(), + period_visit_users: BTreeSet::new(), + total_visit_users: BTreeSet::new(), + total_visit_count: 0, + retained_activity_days: HashSet::new(), + } + } + + fn apply( + &mut self, + scope_kind: RuntimeTrackingScopeKind, + scope_id: &str, + day_key: i64, + count: u32, + ) { + if count == 0 { + return; + } + match scope_kind { + RuntimeTrackingScopeKind::Site => { + let count = u64::from(count); + self.total_visit_count = self.total_visit_count.saturating_add(count); + if day_key_is_in_range(day_key, self.start_day_key, self.end_day_key) { + increment_count(&mut self.visit_count, day_key, count); + } + } + RuntimeTrackingScopeKind::User => { + let user_id = scope_id.trim(); + if user_id.is_empty() || user_id == "anonymous" { + return; + } + self.total_visit_users.insert(user_id.to_string()); + if day_key_is_in_range(day_key, self.start_day_key, self.end_day_key) { + self.period_visit_users.insert(user_id.to_string()); + self.visit_users_by_day + .entry(day_key) + .or_default() + .insert(user_id.to_string()); + } + if self + .selected_registrations + .get(user_id) + .is_some_and(|registration_day_key| { + day_key == registration_day_key.saturating_add(1) + || day_key == registration_day_key.saturating_add(7) + }) + { + self.retained_activity_days + .insert((user_id.to_string(), day_key)); + } + } + RuntimeTrackingScopeKind::Work | RuntimeTrackingScopeKind::Module => {} + } + } +} + +struct AdminDashboardEventAccumulator { + start_day_key: i64, + end_day_key: i64, + active_since_micros: i64, + now_micros: i64, + current_users: BTreeSet, + module_visit_breakdown: BTreeMap, +} + +impl AdminDashboardEventAccumulator { + fn new(start_day_key: i64, end_day_key: i64, now_micros: i64) -> Self { + Self { + start_day_key, + end_day_key, + active_since_micros: now_micros.saturating_sub(ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS), + now_micros, + current_users: BTreeSet::new(), + module_visit_breakdown: BTreeMap::new(), + } + } + + fn apply( + &mut self, + user_id: Option<&str>, + module_key: Option<&str>, + day_key: i64, + occurred_at_micros: i64, + ) { + if day_key_is_in_range(day_key, self.start_day_key, self.end_day_key) { + let module_key = module_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"); + increment_string_count(&mut self.module_visit_breakdown, module_key, 1); + } + + if occurred_at_micros < self.active_since_micros || occurred_at_micros > self.now_micros { + return; + } + if let Some(user_id) = user_id + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "anonymous") + { + self.current_users.insert(user_id.to_string()); + } + } +} + +#[spacetimedb::procedure] +pub fn get_admin_dashboard_stats_and_return( + ctx: &mut ProcedureContext, + input: AdminDashboardStatsInput, +) -> AdminDashboardStatsProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + build_admin_dashboard_stats(tx, &input) + }) { + Ok(snapshot) => AdminDashboardStatsProcedureResult { + ok: true, + snapshot: Some(snapshot), + error_message: None, + }, + Err(message) => AdminDashboardStatsProcedureResult { + ok: false, + snapshot: None, + error_message: Some(message), + }, + } +} + +fn build_admin_dashboard_stats( + ctx: &ReducerContext, + input: &AdminDashboardStatsInput, +) -> Result { + validate_admin_dashboard_stats_input(input)?; + + let current_day_key = runtime_profile_beijing_day_key(input.now_micros); + let mut new_registered_users = BTreeMap::new(); + let mut selected_registrations = BTreeMap::new(); + let mut total_registered_users = 0u64; + + for row in ctx.db.profile_dashboard_state().iter() { + total_registered_users = total_registered_users.saturating_add(1); + let registration_day_key = + runtime_profile_beijing_day_key(row.created_at.to_micros_since_unix_epoch()); + if day_key_is_in_range(registration_day_key, input.start_day_key, input.end_day_key) { + increment_count(&mut new_registered_users, registration_day_key, 1); + selected_registrations.insert(row.user_id, registration_day_key); + } + } + + let mut daily_stats = AdminDashboardDailyStatsAccumulator::new( + input.start_day_key, + input.end_day_key, + &selected_registrations, + ); + for row in ctx.db.tracking_daily_stat().iter() { + daily_stats.apply(row.scope_kind, &row.scope_id, row.day_key, row.count); + } + + let mut event_stats = AdminDashboardEventAccumulator::new( + input.start_day_key, + input.end_day_key, + input.now_micros, + ); + for row in ctx.db.tracking_event().iter() { + event_stats.apply( + row.user_id.as_deref(), + row.module_key.as_deref(), + row.day_key, + row.occurred_at.to_micros_since_unix_epoch(), + ); + } + + Ok(AdminDashboardStatsSnapshot { + total_registered_users, + new_registered_users: daily_counts(new_registered_users), + visit_count: daily_counts(daily_stats.visit_count), + visit_users_by_day: daily_unique_counts(daily_stats.visit_users_by_day), + visit_users: daily_stats.period_visit_users.len() as u64, + total_visit_count: daily_stats.total_visit_count, + total_visit_users: daily_stats.total_visit_users.len() as u64, + current_users: event_stats.current_users.len() as u64, + day1_retention: calculate_admin_dashboard_retention( + &selected_registrations, + &daily_stats.retained_activity_days, + current_day_key, + 1, + ), + day7_retention: calculate_admin_dashboard_retention( + &selected_registrations, + &daily_stats.retained_activity_days, + current_day_key, + 7, + ), + module_visit_breakdown: event_stats + .module_visit_breakdown + .into_iter() + .map(|(key, count)| AdminDashboardBreakdownSnapshot { key, count }) + .collect(), + }) +} + +fn validate_admin_dashboard_stats_input(input: &AdminDashboardStatsInput) -> Result<(), String> { + if input.start_day_key > input.end_day_key { + return Err("dashboard start_day_key 不能晚于 end_day_key".to_string()); + } + if input.end_day_key.saturating_sub(input.start_day_key) + 1 > ADMIN_DASHBOARD_MAX_RANGE_DAYS { + return Err(format!( + "dashboard 查询范围最多支持 {ADMIN_DASHBOARD_MAX_RANGE_DAYS} 天" + )); + } + if input.now_micros <= 0 { + return Err("dashboard now_micros 必须大于 0".to_string()); + } + let current_day_key = runtime_profile_beijing_day_key(input.now_micros); + if input.end_day_key > current_day_key { + return Err("dashboard end_day_key 不能晚于今天".to_string()); + } + Ok(()) +} + +fn calculate_admin_dashboard_retention( + registrations: &BTreeMap, + active_user_days: &HashSet<(String, i64)>, + current_day_key: i64, + retention_days: i64, +) -> AdminDashboardRetentionSnapshot { + let mut eligible_users = 0u64; + let mut retained_users = 0u64; + for (user_id, registration_day_key) in registrations { + let retention_day_key = registration_day_key.saturating_add(retention_days); + // 今天仍是进行中的业务日,只把目标日已完整结束的 cohort 纳入分母。 + if retention_day_key >= current_day_key { + continue; + } + eligible_users = eligible_users.saturating_add(1); + if active_user_days.contains(&(user_id.clone(), retention_day_key)) { + retained_users = retained_users.saturating_add(1); + } + } + AdminDashboardRetentionSnapshot { + eligible_users, + retained_users, + rate_basis_points: retention_rate_basis_points(retained_users, eligible_users), + } +} + +fn retention_rate_basis_points(retained_users: u64, eligible_users: u64) -> u32 { + if eligible_users == 0 { + return 0; + } + let numerator = u128::from(retained_users) + .saturating_mul(10_000) + .saturating_add(u128::from(eligible_users) / 2); + (numerator / u128::from(eligible_users)).min(10_000) as u32 +} + +fn day_key_is_in_range(day_key: i64, start_day_key: i64, end_day_key: i64) -> bool { + day_key >= start_day_key && day_key <= end_day_key +} + +fn increment_count(counts: &mut BTreeMap, key: i64, amount: u64) { + let next = counts + .get(&key) + .copied() + .unwrap_or(0) + .saturating_add(amount); + counts.insert(key, next); +} + +fn increment_string_count(counts: &mut BTreeMap, key: &str, amount: u64) { + let next = counts.get(key).copied().unwrap_or(0).saturating_add(amount); + counts.insert(key.to_string(), next); +} + +fn daily_counts(counts: BTreeMap) -> Vec { + counts + .into_iter() + .map(|(day_key, count)| AdminDashboardDailyCountSnapshot { day_key, count }) + .collect() +} + +fn daily_unique_counts( + users_by_day: BTreeMap>, +) -> Vec { + users_by_day + .into_iter() + .map(|(day_key, users)| AdminDashboardDailyCountSnapshot { + day_key, + count: users.len() as u64, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{ + AdminDashboardDailyStatsAccumulator, AdminDashboardEventAccumulator, + AdminDashboardStatsInput, RuntimeTrackingScopeKind, calculate_admin_dashboard_retention, + daily_unique_counts, retention_rate_basis_points, validate_admin_dashboard_stats_input, + }; + use std::collections::{BTreeMap, BTreeSet, HashSet}; + + #[test] + fn retention_only_counts_mature_cohorts_and_exact_return_day() { + let registrations = BTreeMap::from([ + ("day1-returned".to_string(), 100), + ("day1-missed".to_string(), 100), + ("day7-returned".to_string(), 94), + ("not-mature".to_string(), 101), + ]); + let active_user_days = HashSet::from([ + ("day1-returned".to_string(), 101), + ("day1-missed".to_string(), 102), + ("day7-returned".to_string(), 101), + ("not-mature".to_string(), 101), + ]); + + let day1 = calculate_admin_dashboard_retention(®istrations, &active_user_days, 102, 1); + let day7 = calculate_admin_dashboard_retention(®istrations, &active_user_days, 102, 7); + + assert_eq!(day1.eligible_users, 3); + assert_eq!(day1.retained_users, 1); + assert_eq!(day1.rate_basis_points, 3_333); + assert_eq!(day7.eligible_users, 1); + assert_eq!(day7.retained_users, 1); + assert_eq!(day7.rate_basis_points, 10_000); + } + + #[test] + fn retention_rate_is_zero_without_eligible_users_and_rounds_to_basis_points() { + assert_eq!(retention_rate_basis_points(0, 0), 0); + assert_eq!(retention_rate_basis_points(2, 3), 6_667); + assert_eq!(retention_rate_basis_points(4, 3), 10_000); + } + + #[test] + fn daily_visit_users_are_deduplicated_per_day_without_cross_day_collapsing() { + let counts = daily_unique_counts(BTreeMap::from([ + ( + 100, + BTreeSet::from(["user-a".to_string(), "user-b".to_string()]), + ), + (101, BTreeSet::from(["user-a".to_string()])), + ])); + + assert_eq!(counts.len(), 2); + assert_eq!(counts[0].day_key, 100); + assert_eq!(counts[0].count, 2); + assert_eq!(counts[1].day_key, 101); + assert_eq!(counts[1].count, 1); + } + + #[test] + fn dashboard_tracking_aggregation_stays_exact_past_legacy_row_limit() { + let registrations = BTreeMap::new(); + let mut daily = AdminDashboardDailyStatsAccumulator::new(100, 100, ®istrations); + let mut events = AdminDashboardEventAccumulator::new(100, 100, 1_000_000); + + for index in 0..50_001 { + daily.apply(RuntimeTrackingScopeKind::Site, "site", 100, 1); + daily.apply( + RuntimeTrackingScopeKind::User, + &format!("user-{index}"), + 100, + 1, + ); + events.apply(Some("current-user"), Some("profile"), 100, 999_999); + } + + assert_eq!(daily.total_visit_count, 50_001); + assert_eq!(daily.total_visit_users.len(), 50_001); + assert_eq!(daily.period_visit_users.len(), 50_001); + assert_eq!(daily.visit_count[&100], 50_001); + assert_eq!(events.module_visit_breakdown["profile"], 50_001); + assert_eq!(events.current_users.len(), 1); + } + + #[test] + fn dashboard_stats_input_rejects_future_and_oversized_ranges() { + const DAY_MICROS: i64 = 86_400_000_000; + let now_micros = 200 * DAY_MICROS; + let future = validate_admin_dashboard_stats_input(&AdminDashboardStatsInput { + start_day_key: 200, + end_day_key: 201, + now_micros, + }); + let oversized = validate_admin_dashboard_stats_input(&AdminDashboardStatsInput { + start_day_key: 1, + end_day_key: 367, + now_micros: 500 * DAY_MICROS, + }); + + assert!(future.is_err()); + assert!(oversized.is_err()); + } +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 9f71c7cb0..42cbe20cf 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -23,8 +23,9 @@ pub(crate) use shared_kernel::format_timestamp_micros; pub use spacetimedb::{ Identity, ProcedureContext, ReducerContext, ScheduleAt, SpacetimeType, Table, Timestamp, }; -mod ai; mod admin_account_storage; +mod admin_dashboard; +mod ai; mod asset_metadata; mod auth; mod bark_battle; @@ -49,8 +50,9 @@ mod square_hole; mod visual_novel; mod wooden_fish; -pub use ai::*; pub use admin_account_storage::*; +pub use admin_dashboard::*; +pub use ai::*; pub use asset_metadata::*; pub use auth::*; pub use bark_battle::*; From 3f3a6c4ab2e75cf04481fe690c17cb9b7ee39a98 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 15 Jul 2026 01:36:09 +0800 Subject: [PATCH 17/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=94=9F=E5=9B=BE=E4=BA=A4=E4=BB=98=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=88=90=E6=9C=AC=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 nanobanana 标量尺寸被误判为宽高的问题。 普通生图和快速编辑在内存完成尺寸变换后仅上传一次,变换失败保留 provider 原图。 移除快速编辑重复的原始输出素材,保留角色、图集和 UI 的独立中间产物。 后台将不重复计费的中间产物显示为不单独计费。 补充单次上传、降级交付和成本展示回归测试及相关文档。 --- .../pages/AdminEditorAssetQueryPage.test.tsx | 34 ++ .../src/pages/AdminEditorAssetQueryPage.tsx | 15 +- .../shared-memory/decision-log.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../crates/api-server/src/editor_project.rs | 301 ++++++++++++------ 7 files changed, 272 insertions(+), 98 deletions(-) diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index c0cca000c..ddbc7a502 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -245,6 +245,40 @@ test('后台素材查询不展示分类筛选和分类列', async () => { expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull(); }); +test('后台素材查询将中间产物标记为不单独计费', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + assetId: 'asset-source', + label: '角色形象原图', + assetKind: 'editor_green_screen_source', + generationCostMudPoints: 0, + }, + generatedAsset, + ], + nextCursor: null, + }); + + render( + , + ); + + const sourceRow = (await screen.findByText('角色形象原图')).closest('tr'); + const finalRow = screen.getByText('角色形象 1').closest('tr'); + expect(sourceRow).not.toBeNull(); + expect(finalRow).not.toBeNull(); + expect(within(sourceRow!).getByText('不单独计费')).toBeTruthy(); + expect(within(finalRow!).getByText('12 泥点')).toBeTruthy(); + + fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' })); + expect( + within(await screen.findByRole('dialog', { name: '素材详情' })).getByText( + '不单独计费', + ), + ).toBeTruthy(); +}); + test('后台素材查询缩略图使用 objectKey 换签后展示', async () => { render( , diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index 5bb5d25fa..d8276fe8f 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -24,6 +24,10 @@ const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; +const NON_BILLED_INTERMEDIATE_ASSET_KINDS = new Set([ + 'editor_green_screen_source', + 'editor_image_edit_source', +]); const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; let adminAssetReadDispatchTail = Promise.resolve(); @@ -217,7 +221,7 @@ export function AdminEditorAssetQueryPage({ {promptText} - {entry.generationCostMudPoints} 泥点 + {generationCostLabel(entry)} - {entry.label || '-'} - - {formatDateTime(entry.createdAt)} - -
-
- {authorDisplayName(entry)} - - {entry.authorPublicUserCode?.trim() || '-'} - + + + +
+ {children.length ? ( + + ) : ( + + )} +
+ + {entry.label || '-'} +
- + {formatDateTime(entry.createdAt)} + +
+
+ {authorDisplayName(entry)} + + {entry.authorPublicUserCode?.trim() || '-'} + +
+ +
+ + + -
- - - - - {generationCostLabel(entry)} - - - - + + {entry.taskGenerator || entry.generator || '-'} + {taskGenerationCostLabel(entry)} + + + + + {expanded + ? children.map((child) => { + const childPrompt = + child.prompt || child.actualPrompt || '-'; + return ( + + +
+ +
+ + {child.label || '-'} +
+
+ + {formatDateTime(child.createdAt)} + {authorDisplayName(child)} + + + + {child.generator || '-'} + {stageGenerationCostLabel(child)} + + + + + ); + }) + : null} + ); })} @@ -310,6 +389,33 @@ export function AdminEditorAssetQueryPage({ ); } +function AdminAssetPromptButton({ + entry, + promptText, + onOpen, +}: { + entry: AdminEditorAssetPayload; + promptText: string; + onOpen: (value: { title: string; prompt: string }) => void; +}) { + return ( + + ); +} + function AdminAssetThumbnail({ entry, token, @@ -520,8 +626,20 @@ function AdminAssetDetailDialog({ {entry.width} x {entry.height} - {generationCostLabel(entry)} + {entry.children?.length + ? taskGenerationCostLabel(entry) + : stageGenerationCostLabel(entry)} + + {entry.children?.length + ? entry.taskGenerator || entry.generator || '-' + : entry.generator || '-'} + + {entry.children?.length ? ( + + {entry.generator || '-'} + + ) : null} {entry.model || '-'} {entry.provider || '-'} @@ -562,11 +680,14 @@ function AdminAssetDetailDialog({ ); } -function generationCostLabel(entry: AdminEditorAssetPayload) { - return entry.assetKind && - NON_BILLED_INTERMEDIATE_ASSET_KINDS.has(entry.assetKind) - ? '不单独计费' - : `${entry.generationCostMudPoints} 泥点`; +function taskGenerationCostLabel(entry: AdminEditorAssetPayload) { + return `${entry.taskCostMudPoints ?? entry.generationCostMudPoints} 泥点`; +} + +function stageGenerationCostLabel(entry: AdminEditorAssetPayload) { + return entry.generationCostMudPoints > 0 + ? `${entry.generationCostMudPoints} 泥点` + : '本阶段不额外扣费'; } function AdminAssetPreviewDialog({ diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 7c10e9644..1e6e477b9 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -820,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%; @@ -1472,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), @@ -1502,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; } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4df64e06f..9ccb95532 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -36,7 +36,7 @@ - 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。 - 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 -- 画布与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。中间产物的 `generation_cost_mud_points = 0` 只表示不重复计费,后台必须显示“不单独计费”;任务成本仍只记在最终素材。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 +- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `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`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 973477242..3f2050978 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -388,8 +388,8 @@ - 现象:`nanobanana2` 已返回并成功解码图片,任务随后报“尺寸无效”;普通生图若把尺寸恢复作为硬失败,provider 已成功回图后仍可能没有素材进入素材库和画布。 - 原因:通用尺寸恢复把 `nanobanana2` 的标量清晰度档位 `512 / 1024 / 2K` 当成 `WIDTHxHEIGHT` 解析;普通图片与快速编辑还把 OSS 持久化放在尺寸恢复之后。同步 `generateContent` 返回的是内联 base64,本地兜底 task id 不能用于向 provider 回查原图。 -- 处理:`nanobanana2` 保留 provider 输出几何尺寸;其它模型仍按显式像素目标尝试恢复。普通生图和快速编辑先把 provider 回图留在内存,尺寸恢复成功后只上传变换结果,恢复失败则降级为只上传 provider 原图;每个主结果只执行一次 OSS 持久化并只创建一个素材。角色、图标图集和 UI 提取的带背景原图仍按多产物语义单独保留。扣费确认仍以 provider 成功为界,不延长到 OSS、后处理或画布回填;后台把历史中间产物的零成本显示为“不单独计费”。 -- 验证:`cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml` 覆盖 nanobanana 标量尺寸、变换失败回落 provider 原图、变换先于单次持久化;`npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx` 覆盖中间产物成本文案。 +- 处理:`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`。 ## 图片画布快速编辑元数据必须记录原图引用 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 67a4589fc..cebee78b4 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -55,7 +55,7 @@ - 图片、音频、视频和角色动画帧文件本体继续走 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` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `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。 +- 图片类、生成视频和音频结果除作为 `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 当作会话长期事实。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 2f4e8d87a..7876582e1 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 直查私有表。`editor_green_screen_source`、历史 `editor_image_edit_source` 是同一付费任务的可恢复中间产物,`generation_cost_mud_points = 0` 表示不重复计费,列表和详情统一显示“不单独计费”,不能显示成误导性的“0 泥点”。图片放大、视频和音频预览统一调用仅后台管理员会话可访问的 `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/*`。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 4758727a1..5621af679 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -626,7 +626,7 @@ pub async fn admin_list_editor_assets( .clamp(1, ADMIN_EDITOR_ASSET_MAX_LIMIT); let date_range = resolve_admin_editor_asset_date_range(&query) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?; - let mut records = state + let records = state .spacetime_client() .admin_list_editor_assets(AdminEditorAssetListRecordInput { limit: page_size + 1, @@ -639,20 +639,22 @@ pub async fn admin_list_editor_assets( .await .map_err(map_admin_spacetime_error)?; - let next_cursor = if records.len() > page_size as usize { - records + let mut groups = group_admin_editor_asset_records(records); + let next_cursor = if groups.len() > page_size as usize { + groups .get(page_size.saturating_sub(1) as usize) + .and_then(|group| group.first()) .and_then(admin_editor_asset_cursor) } else { None }; - records.truncate(page_size as usize); + groups.truncate(page_size as usize); Ok(json_success_body( Some(&request_context), AdminEditorAssetListResponse { - entries: records + entries: groups .into_iter() - .map(|record| admin_editor_asset_payload_from_record(&state, record)) + .filter_map(|records| admin_editor_asset_group_payload(&state, records)) .collect(), next_cursor, }, @@ -1122,6 +1124,7 @@ fn admin_editor_asset_payload_from_record( record: AdminEditorAssetRecord, ) -> AdminEditorAssetPayload { let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None); + let generator = resolve_admin_editor_asset_generator(&record); AdminEditorAssetPayload { asset_id: record.asset_id, owner_user_id: record.owner_user_id, @@ -1147,9 +1150,160 @@ fn admin_editor_asset_payload_from_record( generation_cost_mud_points: record.generation_cost_mud_points, created_at: record.created_at, updated_at: record.updated_at, + task_generator: generator.clone(), + task_cost_mud_points: record.generation_cost_mud_points, + generator, + children: Vec::new(), } } +fn group_admin_editor_asset_records( + records: Vec, +) -> Vec> { + let mut groups = Vec::>::new(); + let mut indexes = BTreeMap::::new(); + for record in records { + let key = record + .task_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| format!("owner:{}:task:{value}", record.owner_user_id)) + .unwrap_or_else(|| format!("asset:{}", record.asset_id)); + let index = if let Some(index) = indexes.get(&key).copied() { + index + } else { + let index = groups.len(); + groups.push(Vec::new()); + indexes.insert(key, index); + index + }; + groups[index].push(record); + } + groups +} + +fn admin_editor_asset_group_payload( + state: &AppState, + mut records: Vec, +) -> Option { + if records.is_empty() { + return None; + } + let task_generator = resolve_admin_editor_asset_task_generator(records.as_slice()); + let task_cost_mud_points = records + .iter() + .map(|record| record.generation_cost_mud_points) + .max() + .unwrap_or_default(); + let primary = records.remove(0); + let mut payload = admin_editor_asset_payload_from_record(state, primary); + payload.task_generator = task_generator; + payload.task_cost_mud_points = task_cost_mud_points; + payload.children = records + .into_iter() + .map(|record| admin_editor_asset_payload_from_record(state, record)) + .collect(); + Some(payload) +} + +fn resolve_admin_editor_asset_task_generator(records: &[AdminEditorAssetRecord]) -> String { + let paths = records + .iter() + .filter_map(|record| record.object_key.as_deref()) + .map(str::to_ascii_lowercase) + .collect::>(); + if paths.iter().any(|path| path.contains("/character-images/")) { + return "角色生成".to_string(); + } + if paths.iter().any(|path| { + path.contains("/ui-design-spritesheets/") || path.contains("/ui-design-assets/") + }) { + return "UI素材提取".to_string(); + } + if paths.iter().any(|path| { + path.contains("/icon-spritesheets/") || path.contains("/icon-spritesheet-assets/") + }) { + return "图标图集生成".to_string(); + } + records + .first() + .map(resolve_admin_editor_asset_generator) + .unwrap_or_else(|| "未知生成器".to_string()) +} + +fn resolve_admin_editor_asset_generator(record: &AdminEditorAssetRecord) -> String { + let object_key = record + .object_key + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + let provider_source = object_key.contains("green-screen-source") + || object_key.contains("provider-source") + || object_key.contains("/animation/editor-character-animation/"); + if object_key.contains("/animation/editor-character-animation/") { + return "角色动作视频生成".to_string(); + } + if object_key.contains("/character-images/") { + return if provider_source { + "角色生图" + } else { + "角色抠图" + } + .to_string(); + } + if object_key.contains("/ui-design-assets/") || object_key.contains("/icon-spritesheet-assets/") + { + return "图集拆分".to_string(); + } + if object_key.contains("/ui-design-spritesheets/") { + return if provider_source { + "UI素材生成" + } else { + "UI图集抠图" + } + .to_string(); + } + if object_key.contains("/icon-spritesheets/") { + return if provider_source { + "图标图集生成" + } else { + "图标图集抠图" + } + .to_string(); + } + if object_key.contains("/edit-images/") { + return "快速编辑".to_string(); + } + if object_key.contains("/spec-images/") { + return "规范生成".to_string(); + } + if object_key.contains("/publication-materials/") { + return "宣发素材生成".to_string(); + } + if object_key.contains("/generated-images/") { + return "图片生成".to_string(); + } + match record.asset_kind.as_deref() { + Some("character-animation") => { + if provider_source { + "角色动作视频生成" + } else { + "角色动作逐帧处理" + } + } + Some("sound-effect") => "音效生成", + Some("background-music") => "背景音乐生成", + Some("video") => "视频生成", + Some("icon") => "图集拆分", + Some("icon-spritesheet") => "图标图集生成", + Some("character") => "角色生成", + Some("ui-design") => "UI设计生成", + _ => "未知生成器", + } + .to_string() +} + fn admin_editor_asset_cursor(record: &AdminEditorAssetRecord) -> Option { let created_at_micros = parse_timestamp_text_to_micros(record.created_at.as_str())?; Some(format!("{created_at_micros}:{}", record.asset_id)) @@ -3725,14 +3879,15 @@ mod tests { use super::{ AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, - admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, - apply_admin_database_table_filters, build_admin_asset_read_url_audit, - build_admin_dashboard_chart, build_admin_database_table_row, - build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, - build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, + admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record, + append_spacetime_sql_response_chunk, apply_admin_database_table_filters, + build_admin_asset_read_url_audit, build_admin_dashboard_chart, + build_admin_database_table_row, build_admin_tracking_event_keys_sql, + build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, + build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, - finalize_admin_database_table_rows_response, hash_admin_password, - is_admin_account_not_found, is_safe_spacetime_table_name, + finalize_admin_database_table_rows_response, group_admin_editor_asset_records, + hash_admin_password, is_admin_account_not_found, is_safe_spacetime_table_name, matches_admin_editor_asset_keyword, normalize_debug_path, normalize_table_count_error, parse_admin_database_table_rows_sql_response, parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, @@ -3752,8 +3907,8 @@ mod tests { use shared_contracts::assets::GetReadUrlQuery; use spacetime_client::{ AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, - AdminDashboardRetentionRecord, AdminDashboardStatsRecord, SpacetimeClientError, - SpacetimeClientStage, + AdminDashboardRetentionRecord, AdminDashboardStatsRecord, AdminEditorAssetRecord, + SpacetimeClientError, SpacetimeClientStage, }; use crate::{config::AppConfig, state::AppState}; @@ -3798,6 +3953,39 @@ mod tests { } } + fn test_admin_editor_asset_record( + asset_id: &str, + task_id: &str, + label: &str, + object_key: &str, + generation_cost_mud_points: u64, + ) -> AdminEditorAssetRecord { + AdminEditorAssetRecord { + asset_id: asset_id.to_string(), + owner_user_id: "user-1".to_string(), + folder_id: "project".to_string(), + label: label.to_string(), + asset_object_id: Some(format!("object-{asset_id}")), + image_src: format!("/{object_key}"), + object_key: Some(object_key.to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: Some(label.to_string()), + actual_prompt: None, + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some(task_id.to_string()), + asset_kind: Some("character".to_string()), + generation_inputs: None, + source_resource_id: None, + thumbnail_src: None, + generation_cost_mud_points, + created_at: "2026-07-04T10:00:00Z".to_string(), + updated_at: "2026-07-04T10:00:00Z".to_string(), + } + } + #[test] fn admin_asset_read_url_audit_keeps_actor_and_target_without_signed_url() { let draft = build_admin_asset_read_url_audit( @@ -3842,6 +4030,51 @@ mod tests { ); } + #[test] + fn admin_editor_asset_group_nests_each_intermediate_output_under_final_asset() { + let final_output = test_admin_editor_asset_record( + "asset-final", + "task-1", + "角色抠图结果", + "generated-character-drafts/editor/character-images/character-task-1.png", + 0, + ); + let provider_source = test_admin_editor_asset_record( + "asset-source", + "task-1", + "角色生成原图", + "generated-character-drafts/editor/character-images/character-provider-source-task-1.png", + 9, + ); + let mut other_task = test_admin_editor_asset_record( + "asset-other", + "task-1", + "其他素材", + "generated-images/editor/image-task-1.png", + 3, + ); + other_task.owner_user_id = "user-2".to_string(); + + let mut groups = + group_admin_editor_asset_records(vec![final_output, provider_source, other_task]); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].len(), 2); + + let state = AppState::new(AppConfig::default()).expect("state should build"); + let payload = admin_editor_asset_group_payload(&state, groups.remove(0)) + .expect("group payload should exist"); + + assert_eq!(payload.asset_id, "asset-final"); + assert_eq!(payload.task_generator, "角色生成"); + assert_eq!(payload.task_cost_mud_points, 9); + assert_eq!(payload.generation_cost_mud_points, 0); + assert_eq!(payload.children.len(), 1); + assert_eq!(payload.children[0].asset_id, "asset-source"); + assert_eq!(payload.children[0].asset_kind.as_deref(), Some("character")); + assert_eq!(payload.children[0].generator, "角色生图"); + assert_eq!(payload.children[0].generation_cost_mud_points, 9); + } + #[test] fn admin_editor_asset_keyword_matches_public_author_prompt_and_generation_inputs() { let payload = AdminEditorAssetPayload { @@ -3869,6 +4102,10 @@ mod tests { generation_cost_mud_points: 12, created_at: "2026-07-04T10:00:00Z".to_string(), updated_at: "2026-07-04T10:00:00Z".to_string(), + generator: "角色生图".to_string(), + task_generator: "角色生成".to_string(), + task_cost_mud_points: 12, + children: Vec::new(), }; assert!(matches_admin_editor_asset_keyword( diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index f4c28f170..4c0ac0140 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -112,8 +112,8 @@ const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; const SOURCE_IMAGE_RESOLVE_TIMEOUT_MS: u64 = 30_000; const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast"; const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation"; -const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; -const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source"; +const EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND: &str = "character-animation"; +const EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT: &str = "provider_source"; const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video"; const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas"; const EDITOR_VIDEO_SLOT: &str = "video_preview"; @@ -736,7 +736,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( source_data_url.as_str(), ) .await?; - persist_editor_generated_media_asset( + let source_record = persist_editor_generated_media_asset( &state, PersistEditorGeneratedAssetRequest { project_id: project_id.clone(), @@ -756,13 +756,19 @@ pub(crate) async fn generate_editor_character_animation_for_owner( provider: "Ark".to_string(), task_id: task_id.clone(), source_resource_id: source_resource_id.clone(), - asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()), + asset_kind: Some( + EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(), + ), generation_inputs: generation_inputs.clone(), thumbnail_src: None, - generation_cost_mud_points: 0, + generation_cost_mud_points: u64::from(normalized.price_mud_points), }, ) .await?; + let provider_source_resource_id = source_record + .0 + .as_ref() + .map(|resource| resource.resource_id.clone()); let persisted_frames = extract_and_persist_editor_character_animation_frames( &state, owner_user_id.as_str(), @@ -807,11 +813,13 @@ pub(crate) async fn generate_editor_character_animation_for_owner( model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), provider: "Ark".to_string(), task_id: task_id.clone(), - source_resource_id: None, - asset_kind: Some("character-animation".to_string()), + source_resource_id: provider_source_resource_id, + asset_kind: Some( + EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(), + ), generation_inputs: generation_inputs.clone(), thumbnail_src: Some(first_frame.image_src.clone()), - generation_cost_mud_points: u64::from(normalized.price_mud_points), + generation_cost_mud_points: 0, }, ) .await?; @@ -1917,11 +1925,11 @@ async fn put_generated_editor_character_animation_preview( preview_payload.mime_type, preview_payload.bytes, build_asset_metadata( - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + EDITOR_CHARACTER_ANIMATION_ASSET_KIND, owner_user_id, "editor_layer", source_layer_id, - EDITOR_GREEN_SCREEN_SOURCE_SLOT, + EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT, "editor-character-animation", ), ) @@ -2272,7 +2280,7 @@ async fn extract_and_persist_editor_character_animation_frames( Some(request.duration_seconds as f64), ) .await?; - persist_editor_character_animation_green_screen_source_frames( + persist_editor_character_animation_provider_source_frames( state, owner_user_id, source_layer_id, @@ -2352,7 +2360,7 @@ async fn extract_and_persist_editor_character_animation_frames( }) } -async fn persist_editor_character_animation_green_screen_source_frames( +async fn persist_editor_character_animation_provider_source_frames( state: &AppState, owner_user_id: &str, source_layer_id: &str, @@ -2372,11 +2380,11 @@ async fn persist_editor_character_animation_green_screen_source_frames( frame.mime_type.clone(), frame.bytes.clone(), build_asset_metadata( - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + EDITOR_CHARACTER_ANIMATION_ASSET_KIND, owner_user_id, "editor_layer", source_layer_id, - EDITOR_GREEN_SCREEN_SOURCE_SLOT, + EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT, "editor-character-animation", ), ) @@ -2819,7 +2827,7 @@ async fn confirm_editor_character_animation_source_asset_object( task_id, object_key, content_type, - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + EDITOR_CHARACTER_ANIMATION_ASSET_KIND, ) .await } @@ -6044,7 +6052,7 @@ mod tests { "async fn publish_animation_set", &[ "apply_chroma_key: false", - "persist_editor_character_animation_green_screen_source_frames", + "persist_editor_character_animation_provider_source_frames", "remove_editor_character_animation_frame_backgrounds", ], ); @@ -6053,17 +6061,17 @@ mod tests { "async fn extract_and_persist_editor_character_animation_frames", "async fn publish_animation_set", &[ - "persist_editor_character_animation_green_screen_source_frames", + "persist_editor_character_animation_provider_source_frames", "remove_editor_character_animation_frame_backgrounds", ], ); assert_function_contains( source, - "async fn persist_editor_character_animation_green_screen_source_frames", + "async fn persist_editor_character_animation_provider_source_frames", "fn remove_editor_character_animation_frame_backgrounds", &[ - "EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND", - "EDITOR_GREEN_SCREEN_SOURCE_SLOT", + "EDITOR_CHARACTER_ANIMATION_ASSET_KIND", + "EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT", "green-screen-frame", "put_character_animation_object", ], @@ -6134,14 +6142,14 @@ mod tests { "attach_editor_character_animation_result_metadata", "project_id: None", "label: asset_label.clone()", - "asset_kind: Some(\"character-animation\".to_string())", - "generation_cost_mud_points: u64::from(normalized.price_mud_points)", + "EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND", + "generation_cost_mud_points: 0", ], ); assert_function_contains( source, "async fn extract_and_persist_editor_character_animation_frames", - "async fn persist_editor_character_animation_green_screen_source_frames", + "async fn persist_editor_character_animation_provider_source_frames", &[ "confirm_editor_character_animation_frame_asset_object", "first_frame_object_key", @@ -6175,7 +6183,8 @@ mod tests { "pub async fn generate_editor_video", &[ "or_else(|| Some(\"project\".to_string()))", - "EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND", + "EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND", + "generation_cost_mud_points: u64::from(normalized.price_mud_points)", ], ); assert_function_contains_in_order( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 23885150e..ac2b2d93f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -122,8 +122,8 @@ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-con const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; -const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; -const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source"; +const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; +const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source"; const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; @@ -1612,22 +1612,28 @@ pub(crate) async fn generate_editor_image_for_owner( })) })?; let storage_profile = editor_generated_image_storage_profile(normalized_kind); + let mut output_prompt = submitted_prompt.clone(); + let mut output_actual_prompt = generated.actual_prompt.clone(); + let mut output_model = generation_options.model.to_string(); + let mut output_provider = "VectorEngine".to_string(); + let mut output_generation_inputs = generation_inputs.clone(); let source_record = if is_character_generation { - let source_persisted = persist_editor_green_screen_source_image( + let source_persisted = persist_editor_provider_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, submitted_prompt.as_str(), generated.actual_prompt.as_deref(), + storage_profile.asset_kind, "character-images", "character-image", ) .await?; - let source_record = persist_editor_green_screen_source_resource( + let source_record = persist_editor_provider_source_resource( state, source_persisted, - PersistEditorGreenScreenSourceResourceInput { + PersistEditorProviderSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload @@ -1642,7 +1648,12 @@ pub(crate) async fn generate_editor_image_for_owner( model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: payload.source_resource_id.clone(), + asset_kind: payload + .asset_kind + .clone() + .or_else(|| Some("character".to_string())), generation_inputs: generation_inputs.clone(), + generation_cost_mud_points: u64::from(configured_price_mud_points), }, ) .await?; @@ -1654,7 +1665,7 @@ pub(crate) async fn generate_editor_image_for_owner( .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), }; - image = remove_editor_generated_screen_background_with_bgfilter( + let removal = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color.expect("character generation should have screen color"), @@ -1662,6 +1673,13 @@ pub(crate) async fn generate_editor_image_for_owner( &matting_audit, ) .await?; + image = removal.image; + output_prompt = "去除纯色背景".to_string(); + output_actual_prompt = None; + output_model = removal.model; + output_provider = removal.provider.to_string(); + output_generation_inputs = + build_editor_derived_asset_generation_inputs("角色抠图", &output_model, &source_record); Some(source_record) } else { None @@ -1691,16 +1709,20 @@ pub(crate) async fn generate_editor_image_for_owner( caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, - submitted_prompt.as_str(), - generated.actual_prompt.as_deref(), + output_prompt.as_str(), + output_actual_prompt.as_deref(), storage_profile.asset_kind, storage_profile.path_kind, "image", storage_profile.slot, - "vector-engine", + output_provider.as_str(), ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let provider_source_resource_id = source_record + .as_ref() + .and_then(|record| record.resource.as_ref()) + .map(|resource| resource.resource_id.clone()); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { @@ -1713,16 +1735,20 @@ pub(crate) async fn generate_editor_image_for_owner( asset_object_id: Some(persisted.asset_object_id.clone()), width, height, - prompt: role_setting.clone(), - actual_prompt: generated.actual_prompt.clone(), - model: generation_options.model.to_string(), - provider: "VectorEngine".to_string(), + prompt: output_prompt, + actual_prompt: output_actual_prompt, + model: output_model, + provider: output_provider, task_id: generated.task_id.clone(), - source_resource_id: payload.source_resource_id, + source_resource_id: provider_source_resource_id.or(payload.source_resource_id), asset_kind: payload.asset_kind, - generation_inputs, + generation_inputs: output_generation_inputs, thumbnail_src: None, - generation_cost_mud_points: u64::from(configured_price_mud_points), + generation_cost_mud_points: if is_character_generation { + 0 + } else { + u64::from(configured_price_mud_points) + }, }, ) .await?; @@ -1798,6 +1824,45 @@ pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs( Some(value) } +fn build_editor_derived_asset_generation_inputs( + stage: &str, + model: &str, + source: &EditorGeneratedAssetRecord, +) -> Option { + let reference = source + .resource + .as_ref() + .map(|resource| { + json!({ + "title": "输入素材", + "label": source + .asset + .as_ref() + .map(|asset| asset.label.as_str()) + .unwrap_or("上一步产物"), + "refType": "project-resource", + "refId": resource.resource_id, + }) + }) + .or_else(|| { + source.asset.as_ref().map(|asset| { + json!({ + "title": "输入素材", + "label": asset.label, + "refType": "asset", + "refId": asset.asset_id, + }) + }) + }); + Some(json!({ + "fields": [ + { "title": "处理阶段", "value": stage }, + { "title": "处理模型", "value": model }, + ], + "references": reference.into_iter().collect::>(), + })) +} + fn editor_generated_image_storage_profile( normalized_kind: Option<&str>, ) -> EditorGeneratedImageStorageProfile { @@ -2682,13 +2747,19 @@ struct EditorBackgroundRemovalImage { height: u32, } +struct EditorScreenBackgroundRemovalOutput { + image: DownloadedOpenAiImage, + provider: &'static str, + model: String, +} + async fn remove_editor_generated_screen_background_with_bgfilter( state: &AppState, image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, audit: &crate::external_api_audit::ExternalApiAuditContext, -) -> Result { +) -> Result { if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) { // 熔断打开只跳过 BgFilter 调用本身,仍要走阿里云兜底;绝不能在 cooldown 内直接退化到 // 低质量本地扣色,否则整段冷却期从「双供应商降级」退化成单一本地键色扣除。 @@ -2712,7 +2783,11 @@ async fn remove_editor_generated_screen_background_with_bgfilter( { Ok(image) => { record_editor_bgfilter_success(); - Ok(image) + Ok(EditorScreenBackgroundRemovalOutput { + image, + provider: "BgFilter", + model: seg_model.to_string(), + }) } Err(error) => { record_editor_bgfilter_failure(state); @@ -2750,7 +2825,7 @@ async fn fallback_editor_screen_background_removal( image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, audit: &crate::external_api_audit::ExternalApiAuditContext, -) -> Result { +) -> Result { match crate::aliyun_matting::segment_image_with_aliyun_matting( state, image, @@ -2758,7 +2833,11 @@ async fn fallback_editor_screen_background_removal( ) .await { - Ok(image) => Ok(image), + Ok(image) => Ok(EditorScreenBackgroundRemovalOutput { + image, + provider: "Aliyun Matting", + model: "segment-common-image".to_string(), + }), Err(error) => { tracing::warn!( provider = "aliyun-matting", @@ -2783,7 +2862,13 @@ async fn fallback_editor_screen_background_removal( ) .await; } - remove_editor_generated_green_screen_background(image, screen_color) + remove_editor_generated_green_screen_background(image, screen_color).map(|image| { + EditorScreenBackgroundRemovalOutput { + image, + provider: "Genarrative Local", + model: "screen-color-keying".to_string(), + } + }) } } } @@ -2954,9 +3039,13 @@ async fn request_editor_generated_screen_background_with_bgfilter( .get("x-bgfilter-screen-color") .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned); - let bytes = - read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter", request_started_at) - .await?; + let bytes = read_editor_image_removal_response_bytes( + response, + "bgfilter", + "BgFilter", + request_started_at, + ) + .await?; if bytes.is_empty() { // HTTP 已成功但 body 为空属于上游内容缺陷(5xx 归类正确),仍补 latencyMs 与 Aliyun 兜底口径对齐。 return Err( @@ -3597,21 +3686,22 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); - let source_persisted = persist_editor_green_screen_source_image( + let source_persisted = persist_editor_provider_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), + EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", "spritesheet", ) .await?; - let source_record = persist_editor_green_screen_source_resource( + let source_record = persist_editor_provider_source_resource( state, source_persisted, - PersistEditorGreenScreenSourceResourceInput { + PersistEditorProviderSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), @@ -3623,7 +3713,9 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: None, + asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), + generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .await?; @@ -3635,7 +3727,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), }; - let image = remove_editor_generated_screen_background_with_bgfilter( + let removal = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color, @@ -3643,6 +3735,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &matting_audit, ) .await?; + let image = removal.image; + let matting_generation_inputs = build_editor_derived_asset_generation_inputs( + "图标图集抠图", + &removal.model, + &source_record, + ); let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); @@ -3652,13 +3750,13 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( owner_user_id.as_str(), generated.task_id.as_str(), &image, - prompt.as_str(), - generated.actual_prompt.as_deref(), + "去除纯色背景", + None, EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", "spritesheet", "spritesheet", - "vector-engine", + removal.provider, ) .await?; let spritesheet_image_src = @@ -3675,16 +3773,19 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, - prompt: prompt.clone(), - actual_prompt: generated.actual_prompt.clone(), - model: generation_options.model.to_string(), - provider: "VectorEngine".to_string(), + prompt: "去除纯色背景".to_string(), + actual_prompt: None, + model: removal.model.clone(), + provider: removal.provider.to_string(), task_id: generated.task_id.clone(), - source_resource_id: None, + source_resource_id: source_record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), asset_kind: Some("icon-spritesheet".to_string()), - generation_inputs: generation_inputs.clone(), + generation_inputs: matting_generation_inputs, thumbnail_src: None, - generation_cost_mud_points: u64::from(expected_price_mud_points), + generation_cost_mud_points: 0, }, ) .await?; @@ -3711,11 +3812,15 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .as_ref() .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), - prompt: prompt.clone(), - actual_prompt: generated.actual_prompt.clone(), - model: generation_options.model.to_string(), - provider: "VectorEngine".to_string(), - generation_inputs: generation_inputs.clone(), + prompt: "自动拆分图集".to_string(), + actual_prompt: None, + model: "connected-components".to_string(), + provider: "Genarrative".to_string(), + generation_inputs: build_editor_derived_asset_generation_inputs( + "图集拆分", + "connected-components", + &spritesheet_record, + ), path_kind: "icon-spritesheet-assets", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, persistence_provider: "vector-engine", @@ -4190,21 +4295,22 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); - let source_persisted = persist_editor_green_screen_source_image( + let source_persisted = persist_editor_provider_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), + EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, "ui-design-spritesheets", "spritesheet", ) .await?; - let source_record = persist_editor_green_screen_source_resource( + let source_record = persist_editor_provider_source_resource( state, source_persisted, - PersistEditorGreenScreenSourceResourceInput { + PersistEditorProviderSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), @@ -4216,7 +4322,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: None, + asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), + generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .await?; @@ -4228,7 +4336,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), }; - let image = remove_editor_generated_screen_background_with_bgfilter( + let removal = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color, @@ -4236,6 +4344,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( &matting_audit, ) .await?; + let image = removal.image; + let matting_generation_inputs = + build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record); let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); @@ -4245,13 +4356,13 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( owner_user_id.as_str(), generated.task_id.as_str(), &image, - prompt.as_str(), - generated.actual_prompt.as_deref(), + "去除纯色背景", + None, EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, "ui-design-spritesheets", "spritesheet", "spritesheet", - "vector-engine", + removal.provider, ) .await?; let spritesheet_image_src = @@ -4268,16 +4379,19 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, - prompt: prompt.clone(), - actual_prompt: generated.actual_prompt.clone(), - model: generation_options.model.to_string(), - provider: "VectorEngine".to_string(), + prompt: "去除纯色背景".to_string(), + actual_prompt: None, + model: removal.model.clone(), + provider: removal.provider.to_string(), task_id: generated.task_id.clone(), - source_resource_id: None, + source_resource_id: source_record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), asset_kind: Some("icon-spritesheet".to_string()), - generation_inputs: generation_inputs.clone(), + generation_inputs: matting_generation_inputs, thumbnail_src: None, - generation_cost_mud_points: u64::from(expected_price_mud_points), + generation_cost_mud_points: 0, }, ) .await?; @@ -4301,11 +4415,15 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .as_ref() .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), - prompt: prompt.clone(), - actual_prompt: generated.actual_prompt.clone(), - model: generation_options.model.to_string(), - provider: "VectorEngine".to_string(), - generation_inputs: generation_inputs.clone(), + prompt: "自动拆分图集".to_string(), + actual_prompt: None, + model: "connected-components".to_string(), + provider: "Genarrative".to_string(), + generation_inputs: build_editor_derived_asset_generation_inputs( + "图集拆分", + "connected-components", + &spritesheet_record, + ), path_kind: "ui-design-assets", asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, persistence_provider: "vector-engine", @@ -4859,6 +4977,7 @@ struct EditorPayloadMediaReference { image_src: String, object_key: Option, asset_object_id: Option, + asset_kind: Option, } fn sanitize_editor_payload_media( @@ -4875,6 +4994,7 @@ fn sanitize_editor_payload_media( image_src: resource.image_src.clone(), object_key: resource.object_key.clone(), asset_object_id: resource.asset_object_id.clone(), + asset_kind: resource.asset_kind.clone(), }, ) }) @@ -4911,6 +5031,11 @@ fn sanitize_editor_payload_media_value( "assetObjectId", media.asset_object_id.as_ref(), ); + if object.get("assetKind").and_then(Value::as_str) + == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) + { + set_optional_json_string_field(object, "assetKind", media.asset_kind.as_ref()); + } } for child in object.values_mut() { sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media); @@ -6177,7 +6302,7 @@ pub(crate) struct PersistedEditorGeneratedImage { pub(crate) asset_object_id: String, } -struct PersistEditorGreenScreenSourceResourceInput { +struct PersistEditorProviderSourceResourceInput { project_id: Option, owner_user_id: String, folder_id: Option, @@ -6189,7 +6314,9 @@ struct PersistEditorGreenScreenSourceResourceInput { model: String, task_id: String, source_resource_id: Option, + asset_kind: Option, generation_inputs: Option, + generation_cost_mud_points: u64, } pub(crate) async fn persist_editor_generated_image( @@ -6295,13 +6422,14 @@ pub(crate) async fn persist_editor_generated_image( }) } -async fn persist_editor_green_screen_source_image( +async fn persist_editor_provider_source_image( state: &AppState, owner_user_id: &str, task_id: &str, image: &DownloadedOpenAiImage, prompt: &str, actual_prompt: Option<&str>, + asset_kind: &str, path_kind: &str, file_stem: &str, ) -> Result { @@ -6312,19 +6440,19 @@ async fn persist_editor_green_screen_source_image( image, prompt, actual_prompt, - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + asset_kind, path_kind, - &format!("{file_stem}-green-screen-source"), - EDITOR_GREEN_SCREEN_SOURCE_SLOT, + &format!("{file_stem}-provider-source"), + EDITOR_PROVIDER_SOURCE_SLOT, "vector-engine", ) .await } -async fn persist_editor_green_screen_source_resource( +async fn persist_editor_provider_source_resource( state: &AppState, persisted: PersistedEditorGeneratedImage, - input: PersistEditorGreenScreenSourceResourceInput, + input: PersistEditorProviderSourceResourceInput, ) -> Result { persist_editor_generated_asset( state, @@ -6344,10 +6472,10 @@ async fn persist_editor_green_screen_source_resource( provider: "VectorEngine".to_string(), task_id: input.task_id, source_resource_id: input.source_resource_id, - asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()), + asset_kind: input.asset_kind, generation_inputs: input.generation_inputs, thumbnail_src: None, - generation_cost_mud_points: 0, + generation_cost_mud_points: input.generation_cost_mud_points, }, ) .await @@ -6881,7 +7009,9 @@ mod tests { None, "body-read 传输故障不应带 HTTP statusCode,否则会落成 5xx" ); - assert!(!crate::external_api_audit::matting_failure_audit_timeout(&error)); + assert!(!crate::external_api_audit::matting_failure_audit_timeout( + &error + )); assert_eq!( crate::external_api_audit::matting_failure_audit_latency_ms(&error), Some(137) @@ -6894,15 +7024,22 @@ mod tests { #[test] fn bgfilter_body_read_timeout_maps_to_gateway_timeout_and_transport() { - let error = - editor_image_removal_body_read_error("bgfilter", "BgFilter", true, 42, "operation timed out"); + let error = editor_image_removal_body_read_error( + "bgfilter", + "BgFilter", + true, + 42, + "operation timed out", + ); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert_eq!( crate::external_api_audit::matting_failure_audit_status_code(&error), None ); - assert!(crate::external_api_audit::matting_failure_audit_timeout(&error)); + assert!(crate::external_api_audit::matting_failure_audit_timeout( + &error + )); assert_eq!( crate::external_api_audit::matting_failure_audit_latency_ms(&error), Some(42) @@ -6913,7 +7050,8 @@ mod tests { EditorScreenBackgroundDecision { color: parse_editor_screen_background_color(Some(hex)) .expect("hex should be a known background color candidate"), - mode: crate::editor_screen_background_decision::EditorScreenBackgroundDecisionMode::Manual, + mode: + crate::editor_screen_background_decision::EditorScreenBackgroundDecisionMode::Manual, attempts: 0, fallback: false, } @@ -8288,7 +8426,7 @@ mod tests { ); let source = editor_project_resource_for_canvas_test( "resource-character-source", - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + "character", 400, 600, ); @@ -8306,6 +8444,7 @@ mod tests { assert_eq!(items[0]["y"], json!(-70.0)); assert_eq!(items[1]["resourceId"], json!("resource-character-source")); assert_eq!(items[1]["title"], json!("角色形象 1(原图)")); + assert_eq!(items[1]["assetKind"], json!("character")); assert_eq!(items[1]["x"], json!(432.0)); assert_eq!(items[1]["y"], json!(-70.0)); @@ -8373,7 +8512,7 @@ mod tests { ); let source = editor_project_resource_for_canvas_test( "resource-ui-source", - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + "icon-spritesheet", 512, 512, ); @@ -8401,9 +8540,9 @@ mod tests { ); assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet")); assert_eq!(items[1]["resourceId"], json!("resource-ui-source")); + assert_eq!(items[1]["assetKind"], json!("icon-spritesheet")); assert_eq!(items[2]["resourceId"], json!("resource-ui-slice")); - let source_right = - items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap(); + let source_right = items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap(); assert!(items[2]["x"].as_f64().unwrap() > source_right); } @@ -8429,7 +8568,7 @@ mod tests { ); let source = editor_project_resource_for_canvas_test( "resource-icon-source", - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + "icon-spritesheet", 512, 512, ); @@ -8444,6 +8583,7 @@ mod tests { assert_eq!(items.len(), 2); assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet")); assert_eq!(items[1]["resourceId"], json!("resource-icon-source")); + assert_eq!(items[1]["assetKind"], json!("icon-spritesheet")); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-icon-sheet") @@ -9178,9 +9318,9 @@ mod tests { "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ - "persist_editor_green_screen_source_image", + "persist_editor_provider_source_image", "remove_editor_generated_screen_background_with_bgfilter", - "persist_editor_green_screen_source_resource", + "persist_editor_provider_source_resource", "if is_character_generation", ], ); @@ -9189,8 +9329,8 @@ mod tests { "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ - "persist_editor_green_screen_source_image", - "persist_editor_green_screen_source_resource", + "persist_editor_provider_source_image", + "persist_editor_provider_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); @@ -9199,9 +9339,9 @@ mod tests { "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ - "persist_editor_green_screen_source_image", + "persist_editor_provider_source_image", "remove_editor_generated_screen_background_with_bgfilter", - "persist_editor_green_screen_source_resource", + "persist_editor_provider_source_resource", "slice_generated_icon_spritesheet_by_connected_components", "persist_editor_spritesheet_slices", ], @@ -9211,8 +9351,8 @@ mod tests { "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ - "persist_editor_green_screen_source_image", - "persist_editor_green_screen_source_resource", + "persist_editor_provider_source_image", + "persist_editor_provider_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); @@ -9221,9 +9361,9 @@ mod tests { "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ - "persist_editor_green_screen_source_image", + "persist_editor_provider_source_image", "remove_editor_generated_screen_background_with_bgfilter", - "persist_editor_green_screen_source_resource", + "persist_editor_provider_source_resource", "slice_generated_icon_spritesheet_all_by_connected_components", ], ); @@ -9232,8 +9372,8 @@ mod tests { "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ - "persist_editor_green_screen_source_image", - "persist_editor_green_screen_source_resource", + "persist_editor_provider_source_image", + "persist_editor_provider_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); @@ -9390,7 +9530,7 @@ mod tests { "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ - "persist_editor_green_screen_source_resource", + "persist_editor_provider_source_resource", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND", "persist_editor_generated_asset", diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 039e527bb..00ade157d 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -226,6 +226,10 @@ pub struct AdminEditorAssetPayload { pub generation_cost_mud_points: u64, pub created_at: String, pub updated_at: String, + pub generator: String, + pub task_generator: String, + pub task_cost_mud_points: u64, + pub children: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index d54fde3af..2ff915a06 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -17,6 +17,7 @@ const EDITOR_GENERATION_PRICING_CONFIG_ID: &str = "global"; const EDITOR_SHOWCASE_CATEGORIES: [&str; 4] = ["characters", "ui", "music", "marketing"]; const EDITOR_GENERATION_PRICING_UNIT_PER_GENERATION: &str = "perGeneration"; const EDITOR_GENERATION_PRICING_UNIT_PER_SECOND: &str = "perSecond"; +const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; const EDITOR_GENERATION_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_GENERATION_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; const EDITOR_GENERATION_SOUND_EFFECT_MODEL: &str = "audio1.0"; @@ -1662,16 +1663,28 @@ fn admin_list_editor_assets( let cursor = parse_public_showcase_cursor(input.cursor.as_deref()); let owner_user_id = normalize_optional(input.owner_user_id); let keyword = normalize_optional(input.keyword).map(|value| value.to_lowercase()); - let mut assets = ctx + let mut grouped = BTreeMap::>::new(); + for asset in ctx .db .editor_asset() .iter() - .filter(|asset| { - let created_at_micros = asset.created_at.to_micros_since_unix_epoch(); - asset.source_type == "generated" - && owner_user_id - .as_ref() - .is_none_or(|value| asset.owner_user_id == *value) + .filter(|asset| asset.source_type == "generated") + { + grouped + .entry(editor_asset_task_group_key(&asset)) + .or_default() + .push(asset); + } + + let mut groups = grouped + .into_values() + .filter_map(|mut assets| { + let primary_index = editor_asset_primary_index(&assets)?; + let primary = assets.get(primary_index)?; + let created_at_micros = primary.created_at.to_micros_since_unix_epoch(); + let matches = owner_user_id + .as_ref() + .is_none_or(|value| primary.owner_user_id == *value) && input .created_after_micros .is_none_or(|value| created_at_micros >= value) @@ -1680,22 +1693,42 @@ fn admin_list_editor_assets( .is_none_or(|value| created_at_micros <= value) && is_after_public_showcase_cursor( created_at_micros, - asset.asset_id.as_str(), + primary.asset_id.as_str(), cursor.as_ref(), ) - && matches_admin_editor_asset_keyword(asset, keyword.as_deref()) + && keyword.as_deref().is_none_or(|keyword| { + assets + .iter() + .any(|asset| matches_admin_editor_asset_keyword(asset, Some(keyword))) + }); + if !matches { + return None; + } + normalize_legacy_editor_source_costs(&mut assets); + let primary = assets.swap_remove(primary_index.min(assets.len().saturating_sub(1))); + assets.sort_by(|left, right| { + editor_asset_child_rank(left) + .cmp(&editor_asset_child_rank(right)) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.asset_id.cmp(&right.asset_id)) + }); + let sort_key = ( + primary.created_at.to_micros_since_unix_epoch(), + primary.asset_id.clone(), + ); + let mut ordered = Vec::with_capacity(assets.len() + 1); + ordered.push(primary); + ordered.extend(assets); + Some((sort_key, ordered)) }) - .map(admin_asset_snapshot_from_row) .collect::>(); - assets.sort_by(|left, right| { - right - .created_at_micros - .cmp(&left.created_at_micros) - .then_with(|| right.asset_id.cmp(&left.asset_id)) - }); - assets.truncate(limit); - Ok(assets) + groups.sort_by(|left, right| right.0.cmp(&left.0)); + groups.truncate(limit); + Ok(groups + .into_iter() + .flat_map(|(_, assets)| assets.into_iter().map(admin_asset_snapshot_from_row)) + .collect()) } fn create_editor_asset_folder( @@ -2891,11 +2924,15 @@ fn build_asset_library_snapshot( .then_with(|| left.created_at_micros.cmp(&right.created_at_micros)) .then_with(|| left.folder_id.cmp(&right.folder_id)) }); - let mut assets = ctx + let mut asset_rows = ctx .db .editor_asset() .by_editor_asset_owner_user_id() .filter(&owner_key) + .collect::>(); + normalize_legacy_editor_source_costs_across_groups(&mut asset_rows); + let mut assets = asset_rows + .into_iter() .map(|asset| asset_snapshot_from_row(ctx, asset)) .collect::>(); assets.sort_by(|left, right| { @@ -2906,6 +2943,154 @@ fn build_asset_library_snapshot( Ok(EditorAssetLibrarySnapshot { folders, assets }) } +fn editor_asset_task_group_key(asset: &EditorAsset) -> String { + asset + .task_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| format!("owner:{}:task:{value}", asset.owner_user_id)) + .unwrap_or_else(|| format!("asset:{}", asset.asset_id)) +} + +fn editor_asset_is_provider_source(asset: &EditorAsset) -> bool { + asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) + || asset + .object_key + .as_deref() + .is_some_and(editor_object_key_is_provider_source) +} + +fn editor_object_key_is_provider_source(object_key: &str) -> bool { + let object_key = object_key.trim().to_ascii_lowercase(); + object_key.contains("green-screen-source") + || object_key.contains("provider-source") + || object_key.contains("/animation/editor-character-animation/") +} + +fn editor_asset_primary_rank(asset: &EditorAsset) -> u8 { + if editor_asset_is_provider_source(asset) { + return 0; + } + let object_key = asset + .object_key + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + if object_key.contains("/icon-spritesheet-assets/") || object_key.contains("/ui-design-assets/") + { + return 1; + } + if asset.asset_kind.as_deref() == Some("icon-spritesheet") { + return 4; + } + 3 +} + +fn editor_asset_primary_index(assets: &[EditorAsset]) -> Option { + assets + .iter() + .enumerate() + .max_by(|(_, left), (_, right)| { + editor_asset_primary_rank(left) + .cmp(&editor_asset_primary_rank(right)) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.asset_id.cmp(&right.asset_id)) + }) + .map(|(index, _)| index) +} + +fn editor_asset_child_rank(asset: &EditorAsset) -> u8 { + if editor_asset_is_provider_source(asset) { + 0 + } else { + 1 + } +} + +fn normalize_legacy_editor_source_costs(assets: &mut [EditorAsset]) { + let has_legacy_source = assets.iter().any(|asset| { + asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) + }); + let task_cost = assets + .iter() + .map(|asset| asset.generation_cost_mud_points) + .max() + .unwrap_or_default(); + for asset in assets { + let is_legacy_source = + asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND); + if is_legacy_source { + asset.asset_kind = normalize_legacy_editor_source_asset_kind( + asset.asset_kind.take(), + asset.object_key.as_deref(), + ); + asset.generation_cost_mud_points = task_cost; + } else if has_legacy_source && asset.generation_cost_mud_points > 0 { + asset.generation_cost_mud_points = 0; + } + } +} + +fn normalize_legacy_editor_source_costs_across_groups(assets: &mut [EditorAsset]) { + let mut task_indexes = BTreeMap::>::new(); + for (index, asset) in assets.iter().enumerate() { + task_indexes + .entry(editor_asset_task_group_key(asset)) + .or_default() + .push(index); + } + for indexes in task_indexes.into_values() { + let has_legacy_source = indexes.iter().any(|index| { + assets.get(*index).is_some_and(|asset| { + asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) + }) + }); + if !has_legacy_source { + continue; + } + let task_cost = indexes + .iter() + .filter_map(|index| assets.get(*index)) + .map(|asset| asset.generation_cost_mud_points) + .max() + .unwrap_or_default(); + for index in indexes { + let asset = &mut assets[index]; + if asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) { + asset.asset_kind = normalize_legacy_editor_source_asset_kind( + asset.asset_kind.take(), + asset.object_key.as_deref(), + ); + asset.generation_cost_mud_points = task_cost; + } else if asset.generation_cost_mud_points > 0 { + asset.generation_cost_mud_points = 0; + } + } + } +} + +fn normalize_legacy_editor_source_asset_kind( + asset_kind: Option, + object_key: Option<&str>, +) -> Option { + if asset_kind.as_deref() != Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) { + return asset_kind; + } + let object_key = object_key.unwrap_or_default().to_ascii_lowercase(); + if object_key.contains("/character-images/") { + return Some("character".to_string()); + } + if object_key.contains("/icon-spritesheets/") || object_key.contains("/ui-design-spritesheets/") + { + return Some("icon-spritesheet".to_string()); + } + if object_key.contains("/animation/editor-character-animation/") { + return Some("character-animation".to_string()); + } + None +} + fn find_showcase_asset_for_asset( ctx: &ReducerContext, asset_id: &str, @@ -3581,6 +3766,8 @@ fn normalize_media_ref(value: &str) -> String { } fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResourceSnapshot { + let asset_kind = + normalize_legacy_editor_source_asset_kind(row.asset_kind, row.object_key.as_deref()); EditorProjectResourceSnapshot { resource_id: row.resource_id, project_id: row.project_id, @@ -3597,7 +3784,7 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour provider: row.provider, task_id: row.task_id, source_resource_id: row.source_resource_id, - asset_kind: row.asset_kind, + asset_kind, generation_inputs_json: row.generation_inputs_json, public_showcase_enabled: row.public_showcase_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), @@ -4139,6 +4326,40 @@ mod tests { models } + fn generated_editor_asset( + asset_id: &str, + task_id: &str, + asset_kind: &str, + object_key: &str, + generation_cost_mud_points: u64, + created_at_micros: i64, + ) -> EditorAsset { + EditorAsset { + asset_id: asset_id.to_string(), + owner_user_id: "user-1".to_string(), + folder_id: "project".to_string(), + label: asset_id.to_string(), + asset_object_id: Some(format!("object-{asset_id}")), + image_src: format!("/{object_key}"), + object_key: Some(object_key.to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some(task_id.to_string()), + created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), + asset_kind: Some(asset_kind.to_string()), + generation_inputs_json: None, + source_resource_id: None, + thumbnail_src: None, + generation_cost_mud_points, + } + } + #[test] fn normalize_editor_generation_pricing_models_accepts_complete_matrix() { let normalized = @@ -4430,4 +4651,82 @@ mod tests { cursor.as_ref() )); } + + #[test] + fn editor_asset_task_group_uses_final_output_as_primary() { + let assets = vec![ + generated_editor_asset( + "source", + "task-1", + "icon-spritesheet", + "generated-icon-spritesheets/editor/provider-source-task-1.png", + 9, + 1, + ), + generated_editor_asset( + "slice", + "task-1", + "icon", + "generated-icon-spritesheet-assets/editor/slice-1.png", + 0, + 3, + ), + generated_editor_asset( + "final", + "task-1", + "icon-spritesheet", + "generated-icon-spritesheets/editor/spritesheet-task-1.png", + 0, + 2, + ), + ]; + + let primary_index = editor_asset_primary_index(&assets).expect("primary asset"); + + assert_eq!(assets[primary_index].asset_id, "final"); + assert_eq!( + editor_asset_task_group_key(&assets[0]), + "owner:user-1:task:task-1" + ); + assert!(editor_asset_is_provider_source(&assets[0])); + assert!(!editor_asset_is_provider_source(&assets[1])); + } + + #[test] + fn legacy_provider_source_uses_real_type_and_carries_task_cost() { + let mut assets = vec![ + generated_editor_asset( + "source", + "task-1", + EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND, + "generated-character-drafts/editor/character-images/character-green-screen-source.png", + 0, + 1, + ), + generated_editor_asset( + "final", + "task-1", + "character", + "generated-character-drafts/editor/character-images/character.png", + 9, + 2, + ), + ]; + + normalize_legacy_editor_source_costs_across_groups(&mut assets); + + assert_eq!(assets[0].asset_kind.as_deref(), Some("character")); + assert_eq!(assets[0].generation_cost_mud_points, 9); + assert_eq!(assets[1].generation_cost_mud_points, 0); + assert_eq!( + normalize_legacy_editor_source_asset_kind( + Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()), + Some( + "generated-character-drafts/layer/animation/editor-character-animation/task/preview.mp4", + ), + ) + .as_deref(), + Some("character-animation") + ); + } } diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 83b30e4c5..503b318d6 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1146,7 +1146,6 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { value === 'icon' || value === 'icon-spritesheet' || value === 'icon-spec' || - value === 'editor_green_screen_source' || value === 'publication-material' || value === 'ui-design' || value === 'video' || diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 683810a2e..429a7d172 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -20,7 +20,6 @@ export type CanvasAssetKind = | 'icon' | 'icon-spritesheet' | 'icon-spec' - | 'editor_green_screen_source' | 'publication-material' | 'ui-design' | 'video' diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index f73afc989..5c9977ab9 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -285,7 +285,7 @@ describe('ImageCanvasWorldView', () => { id: 'layer-source', resourceId: 'resource-source', title: '角色原图', - assetKind: 'editor_green_screen_source', + assetKind: 'character', }), ], }); @@ -298,7 +298,7 @@ describe('ImageCanvasWorldView', () => { expect(within(layerButton).getByText('未知')).toBeTruthy(); expect(within(characterButton).getByText('角色')).toBeTruthy(); - expect(within(sourceButton).getByText('原图')).toBeTruthy(); + expect(within(sourceButton).getByText('角色')).toBeTruthy(); }); it('renders snap guides, marquee and floating generation status', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 39c52b17c..a28d6401b 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -205,11 +205,6 @@ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{ { value: 'icon', label: '图标', className: 'icon' }, { value: 'icon-spritesheet', label: '图集', className: 'icon-spritesheet' }, { value: 'icon-spec', label: '图标规范', className: 'icon-spec' }, - { - value: 'editor_green_screen_source', - label: '原图', - className: 'source-image', - }, { value: 'publication-material', label: '宣发素材', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index c04940e42..119962679 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1428,7 +1428,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { width: 1024, height: 1024, sourceType: 'generated', - assetKind: 'editor_green_screen_source', + assetKind: 'character', }, ], updatedAt: '2026-06-21T12:16:22.000Z', @@ -1503,7 +1503,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }), expect.objectContaining({ resourceId: 'editor-resource-character-source', - assetKind: 'editor_green_screen_source', + assetKind: 'character', }), ]), }),