diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ae8275e7c..d7c198f3e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -219,6 +219,7 @@ module.exports = { '!src/services/frontendRuntimeConfigService.test.ts', '!src/services/gameDistributionClient.ts', '!src/services/gameDistributionClient.test.ts', + '!src/services/gamePlayClientId.ts', '!src/services/sseStream.ts', '!src/services/sseStream.test.ts', 'src/AdventurePanel.tsx', diff --git a/apps/admin-web/src/app/AdminShell.test.tsx b/apps/admin-web/src/app/AdminShell.test.tsx new file mode 100644 index 000000000..e407bb059 --- /dev/null +++ b/apps/admin-web/src/app/AdminShell.test.tsx @@ -0,0 +1,78 @@ +/* @vitest-environment jsdom */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import type { AdminSessionPayload } from '../api/adminApiTypes'; +import { adminRoutes } from './adminRoutes'; +import { AdminShell } from './AdminShell'; + +const admin: AdminSessionPayload = { + subject: 'admin-1', + username: 'owner', + displayName: '后台管理员', + roles: ['owner'], + accountRole: 'owner', + tabPermissions: [], + actionPermissions: [], + issuedAt: '2026-10-04T00:00:00.000Z', + expiresAt: '2026-10-05T00:00:00.000Z', +}; + +test('侧边栏默认展开当前页分组并支持折叠其它分组', async () => { + const user = userEvent.setup(); + const onRouteChange = vi.fn(); + const view = render( + + 页面内容 + , + ); + + const sidebar = screen.getAllByRole('navigation', { name: '后台导航' })[0]!; + const workspaceToggle = within(sidebar).getByRole('button', { + name: /工作台/, + }); + const observabilityToggle = within(sidebar).getByRole('button', { + name: /数据与监控/, + }); + + expect(workspaceToggle.getAttribute('aria-expanded')).toBe('true'); + expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false'); + expect( + within(sidebar).getByRole('button', { name: '服务总览' }), + ).toBeTruthy(); + expect( + within(sidebar).queryByRole('button', { name: '埋点数据' }), + ).toBeNull(); + + await user.click(observabilityToggle); + expect(observabilityToggle.getAttribute('aria-expanded')).toBe('true'); + await user.click(within(sidebar).getByRole('button', { name: '埋点数据' })); + expect(onRouteChange).toHaveBeenCalledWith('tracking'); + + await user.click(observabilityToggle); + expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false'); + view.rerender( + + 页面内容 + , + ); + expect( + within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!) + .getByRole('button', { name: /数据与监控/ }) + .getAttribute('aria-expanded'), + ).toBe('true'); +}); diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index c9fe37b00..c49b36141 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -3,6 +3,7 @@ import { Activity, BadgeDollarSign, Bug, + ChevronDown, Coins, Database, FolderArchive, @@ -22,10 +23,15 @@ import { Users, WalletCards, } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import type { AdminSessionPayload } from '../api/adminApiTypes'; -import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes'; +import { + type AdminNavGroupId, + type AdminRouteDefinition, + type AdminRouteId, + getAdminNavGroups, +} from './adminRoutes'; interface AdminShellProps { admin: AdminSessionPayload; @@ -72,6 +78,67 @@ export function AdminShell({ onRouteChange, onLogout, }: AdminShellProps) { + const navGroups = getAdminNavGroups(routes); + const activeGroupId = + navGroups.find((group) => + group.routes.some((route) => route.id === routeId), + )?.id ?? navGroups[0]?.id; + const [collapsedGroupIds, setCollapsedGroupIds] = useState< + Set + >(() => { + return new Set( + navGroups + .map((group) => group.id) + .filter((groupId) => groupId !== activeGroupId), + ); + }); + + useEffect(() => { + if (!activeGroupId) { + return; + } + + setCollapsedGroupIds((current) => { + if (!current.has(activeGroupId)) { + return current; + } + + const next = new Set(current); + next.delete(activeGroupId); + return next; + }); + }, [activeGroupId, routeId]); + + const toggleGroup = (groupId: AdminNavGroupId) => { + setCollapsedGroupIds((current) => { + const next = new Set(current); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + const renderRouteButton = (route: AdminRouteDefinition) => { + const Icon = routeIcons[route.id]; + return ( + onRouteChange(route.id)} + > + + {route.label} + + ); + }; + return ( - {routes.map((route) => { - const Icon = routeIcons[route.id]; + {navGroups.map((group) => { + const isCollapsed = collapsedGroupIds.has(group.id); + const groupItemsId = `admin-nav-group-${group.id}`; return ( - onRouteChange(route.id)} + - - {route.label} - + toggleGroup(group.id)} + > + + {group.label} + {group.routes.length} + + + {group.routes.map(renderRouteButton)} + + ); })} diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index 1e5898280..c6c3f7df3 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -3,11 +3,46 @@ import { expect, test } from 'vitest'; import { adminRoutes, getAccessibleAdminRoutes, + getAdminNavGroups, resolveAccessibleAdminRoute, resolveAdminRoute, routeHash, } from './adminRoutes'; +test('后台侧边栏路由按业务分组且不丢失任何页签', () => { + const groups = getAdminNavGroups(adminRoutes); + const groupedRouteIds = groups.flatMap((group) => + group.routes.map((route) => route.id), + ); + + expect(groups.map((group) => group.label)).toEqual([ + '工作台', + '数据与监控', + '运营配置', + '充值与支付', + 'AGC 管理', + '内容运营', + '账号权限', + ]); + expect(groupedRouteIds).toEqual(adminRoutes.map((route) => route.id)); +}); + +test('后台侧边栏分组只保留当前管理员可见的页签', () => { + const groups = getAdminNavGroups( + getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['tracking', 'game-reviews', 'accounts'], + }), + ); + + expect( + groups.map((group) => [group.label, group.routes.map((route) => route.id)]), + ).toEqual([ + ['数据与监控', ['tracking']], + ['内容运营', ['game-reviews']], + ]); +}); + test('游戏评价路由独立授权并支持游戏参数', () => { expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews'); expect( diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index f85f9404a..3eba4cd69 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -71,6 +71,91 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, ]; +/** + * 桌面端侧边栏的导航分组。分组只影响展示顺序和折叠状态,不改变路由 + * 权限、hash 或移动端底栏的平铺顺序。 + */ +export type AdminNavGroupId = + | 'workspace' + | 'observability' + | 'operations' + | 'billing' + | 'content' + | 'agc' + | 'access'; + +export interface AdminNavGroupDefinition { + id: AdminNavGroupId; + label: string; + routeIds: AdminRouteId[]; +} + +export const adminNavGroups: AdminNavGroupDefinition[] = [ + { + id: 'workspace', + label: '工作台', + routeIds: ['dashboard', 'overview', 'tables', 'debug'], + }, + { + id: 'observability', + label: '数据与监控', + routeIds: ['tracking', 'agc-tracking', 'error-reports'], + }, + { + id: 'operations', + label: '运营配置', + routeIds: ['gray-release', 'redeem', 'invite', 'profile-wallet', 'tasks'], + }, + { + id: 'billing', + label: '充值与支付', + routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'], + }, + { + id: 'agc', + label: 'AGC 管理', + routeIds: ['editor-generation-pricing', 'agc-models', 'agc-templates'], + }, + { + id: 'content', + label: '内容运营', + routeIds: [ + 'editor-showcase', + 'game-distribution', + 'game-management', + 'game-reviews', + 'editor-assets', + 'project-snapshots', + ], + }, + { + id: 'access', + label: '账号权限', + routeIds: ['accounts'], + }, +]; + +export interface AdminNavGroup { + id: AdminNavGroupId; + label: string; + routes: AdminRouteDefinition[]; +} + +/** 按固定业务分组筛选当前管理员可见的路由。 */ +export function getAdminNavGroups( + routes: AdminRouteDefinition[], +): AdminNavGroup[] { + return adminNavGroups + .map((group) => ({ + id: group.id, + label: group.label, + routes: group.routeIds + .map((routeId) => routes.find((route) => route.id === routeId)) + .filter((route): route is AdminRouteDefinition => route !== undefined), + })) + .filter((group) => group.routes.length > 0); +} + export interface AdminRouteAccess { accountRole: 'owner' | 'member'; tabPermissions: string[]; diff --git a/apps/admin-web/src/pages/AdminGameReviewsPage.tsx b/apps/admin-web/src/pages/AdminGameReviewsPage.tsx index a7ad0e543..52f59dda9 100644 --- a/apps/admin-web/src/pages/AdminGameReviewsPage.tsx +++ b/apps/admin-web/src/pages/AdminGameReviewsPage.tsx @@ -290,6 +290,7 @@ export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) { button { + flex-shrink: 0; +} + @media (max-width: 700px) { .admin-review-filters { grid-template-columns: minmax(0, 1fr); diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index bfb958806..d35efd97a 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -18,6 +18,7 @@ import { import { AGC_DEFAULT_CHANNEL, resolveChannelInstallIdentity, + resolveChannelMainBinaryName, resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; @@ -38,6 +39,9 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const devChannelInstallerHooks = fileURLToPath( new URL('../src-tauri/windows/installer-hooks.nsh', import.meta.url), ); +const releaseChannelInstallerHooks = fileURLToPath( + new URL('../src-tauri/windows/release-installer-hooks.nsh', import.meta.url), +); // Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。 const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; @@ -423,6 +427,7 @@ export function createChannelConfig( const config = { productName, identifier, + mainBinaryName: resolveChannelMainBinaryName(channel), app: { windows: [{ ...baseClientWindow, title: productName }], }, @@ -432,10 +437,20 @@ export function createChannelConfig( }, }, }; - // 只有 dev 渠道改过展示名:它的包需要自带旧身份迁移;其它渠道不能拿这份旧名表去删安装。 - if (channel === AGC_DEFAULT_CHANNEL && target.includes('windows')) + // dev 渠道迁移改名前的旧身份;release 渠道迁移曾使用过「陶泥儿 Release」的安装与快捷方式。 + if ( + target.includes('windows') && + (channel === AGC_DEFAULT_CHANNEL || channel === 'release') + ) config.bundle = { - windows: { nsis: { installerHooks: devChannelInstallerHooks } }, + windows: { + nsis: { + installerHooks: + channel === AGC_DEFAULT_CHANNEL + ? devChannelInstallerHooks + : releaseChannelInstallerHooks, + }, + }, }; return config; } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 4b6fc20cc..a77c64b5b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -35,8 +35,10 @@ import { } from './build-release.mjs'; import { AGC_APP_IDENTIFIER, + AGC_MAIN_BINARY_NAME, AGC_PRODUCT_NAME, resolveChannelInstallIdentity, + resolveChannelMainBinaryName, } from './channel-identity.mjs'; import { supportedHostTarget } from './prepare-bundled-resources.mjs'; @@ -188,6 +190,7 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, + mainBinaryName: AGC_MAIN_BINARY_NAME, app: { windows: [ { @@ -236,6 +239,15 @@ test('channel install identity isolates co-installed builds and keeps the defaul productName: '陶泥儿 Beta-2', identifier: `${AGC_APP_IDENTIFIER}.beta-2`, }); + assert.equal(resolveChannelMainBinaryName('dev'), AGC_MAIN_BINARY_NAME); + assert.equal( + resolveChannelMainBinaryName('release'), + `${AGC_MAIN_BINARY_NAME}-release`, + ); + assert.equal( + resolveChannelMainBinaryName('beta-2'), + `${AGC_MAIN_BINARY_NAME}-beta-2`, + ); // release 与其它非默认渠道通过独立 identifier 隔离运行身份与数据目录; // 自定义渠道另外保留可区分的产品名。 @@ -269,6 +281,7 @@ test('channel install identity is baked into the same build-time config as the e const config = createChannelConfig('release', windowsTarget); assert.equal(config.productName, AGC_PRODUCT_NAME); assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`); + assert.equal(config.mainBinaryName, `${AGC_MAIN_BINARY_NAME}-release`); assert.match( config.plugins.updater.endpoints[0], /\/release-win\/latest\.json$/u, @@ -280,7 +293,7 @@ test('channel install identity is baked into the same build-time config as the e * dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),而更新路径 * (/UPDATE)不会重建快捷方式:改名后的包必须自带旧身份迁移,否则桌面快捷方式会一直指向旧安装。 */ -test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标不注入', () => { +test('渠道 Windows 包注入各自迁移钩子,且主进程文件名按渠道隔离', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { const devWindows = createChannelConfig('dev', windowsTarget); const hooksPath = devWindows.bundle.windows.nsis.installerHooks; @@ -296,10 +309,27 @@ test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标 `迁移钩子缺少旧身份:${legacyIdentity}`, ); } + assert.match( + hooksSource, + /AGC_RELEASE_MAIN_BINARY\s+"genarrative-ai-game-creator-shell-release"/u, + ); - // 改名只发生在 dev:其它渠道注入会拿 dev 的旧名表去删别人的安装。 + const releaseWindows = createChannelConfig('release', windowsTarget); + const releaseHooksPath = releaseWindows.bundle.windows.nsis.installerHooks; + assert.ok(releaseHooksPath); + assert.equal(existsSync(releaseHooksPath), true); + assert.match(readFileSync(releaseHooksPath, 'utf8'), /陶泥儿 Release/u); assert.equal( - createChannelConfig('release', windowsTarget).bundle, + releaseWindows.mainBinaryName, + `${AGC_MAIN_BINARY_NAME}-release`, + ); + assert.notEqual( + devWindows.mainBinaryName, + releaseWindows.mainBinaryName, + 'dev 与 release 的 NSIS 进程匹配名必须不同', + ); + assert.equal( + createChannelConfig('beta-2', windowsTarget).bundle, undefined, ); // macOS 包没有 NSIS 安装器,不能带上 Windows 钩子。 @@ -395,6 +425,44 @@ test('安装钩子的可执行逻辑必须全在 !macro 内,顶层不得引用 ); }); +test('release 安装钩子迁移旧 Release 身份且保持编译期位置纪律', () => { + const hooksPath = createChannelConfig('release', windowsTarget).bundle.windows + .nsis.installerHooks; + const hooksSource = readFileSync(hooksPath, 'utf8'); + assert.deepEqual( + readFileSync(hooksPath).subarray(0, 3), + Buffer.from([0xef, 0xbb, 0xbf]), + 'NSIS 中文钩子必须保留 UTF-8 BOM', + ); + assert.match(hooksSource, /陶泥儿 Release/u); + assert.match(hooksSource, /AGC_RELEASE_LEGACY_BINARY/u); + assert.match(hooksSource, /NSIS_HOOK_PREINSTALL/u); + assert.match(hooksSource, /NSIS_HOOK_POSTINSTALL/u); + let macroDepth = 0; + const offenders = []; + for (const [index, rawLine] of hooksSource.split('\n').entries()) { + const line = rawLine.trim(); + if (line === '' || line.startsWith(';')) continue; + if (/^!macroend\b/u.test(line)) { + macroDepth = Math.max(0, macroDepth - 1); + continue; + } + if (/^!macro\b/u.test(line)) { + macroDepth += 1; + continue; + } + if (macroDepth > 0) continue; + if ( + line.includes('::') || + /^(?:Function(?:End)?|Return|Delete|ReadRegStr|StrCpy|CreateShortCut)\b/u.test( + line, + ) + ) + offenders.push(`第 ${index + 1} 行出现顶层可执行语句:${line}`); + } + assert.deepEqual(offenders, []); +}); + /** * RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。 * 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。 diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs index 90ed39529..9af13d7e1 100644 --- a/apps/ai-game-creator-shell/scripts/channel-identity.mjs +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -16,6 +16,12 @@ export const AGC_DEFAULT_CHANNEL = 'dev'; export const AGC_PRODUCT_NAME = '陶泥儿'; export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator'; +/** + * Tauri/NSIS 默认按主可执行文件名查找并结束进程。 + * dev 保留历史文件名以维持既有更新链;其它渠道使用独立文件名, + * 这样更新一个渠道时不会把另一个渠道的进程一起结束。 + */ +export const AGC_MAIN_BINARY_NAME = 'genarrative-ai-game-creator-shell'; const reservedChannelNames = new Set([ 'win', @@ -75,3 +81,11 @@ export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) { : `${AGC_APP_IDENTIFIER}.${channel}`, }); } + +/** 渠道对应的主可执行文件名(不含平台扩展名)。 */ +export function resolveChannelMainBinaryName(channel = AGC_DEFAULT_CHANNEL) { + validateReleaseChannel(channel); + return channel === AGC_DEFAULT_CHANNEL + ? AGC_MAIN_BINARY_NAME + : `${AGC_MAIN_BINARY_NAME}-${channel}`; +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 31dd72bae..5754f5098 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -25,7 +25,9 @@ execFileSync( import { AGC_APP_IDENTIFIER, + AGC_MAIN_BINARY_NAME, resolveChannelInstallIdentity, + resolveChannelMainBinaryName, } from './channel-identity.mjs'; import { askHidden, @@ -1299,9 +1301,14 @@ if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) { throw new Error('AI game creator shell identifier drifted'); } +if (tauriConfig.mainBinaryName !== AGC_MAIN_BINARY_NAME) { + throw new Error('AI game creator shell mainBinaryName drifted'); +} + if ( tauriConfig.productName !== defaultChannelIdentity.productName || - tauriConfig.identifier !== defaultChannelIdentity.identifier + tauriConfig.identifier !== defaultChannelIdentity.identifier || + tauriConfig.mainBinaryName !== resolveChannelMainBinaryName('dev') ) { throw new Error( 'AI game creator shell baseline config must match the default channel identity', @@ -1314,6 +1321,8 @@ for (const channel of ['release', 'beta-2']) { if ( identity.productName === defaultChannelIdentity.productName || identity.identifier === defaultChannelIdentity.identifier || + resolveChannelMainBinaryName(channel) === + resolveChannelMainBinaryName('dev') || !identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`) ) { throw new Error(`channel install identity not isolated: ${channel}`); @@ -1631,13 +1640,11 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) { ); } -if ( - !( - tauriConfig.build?.beforeDevCommand?.includes( - 'run ai-game-creator-shell:dev-server', - ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') - ) -) { +if (!( + tauriConfig.build?.beforeDevCommand?.includes( + 'run ai-game-creator-shell:dev-server', + ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') +)) { throw new Error( 'AI game creator shell beforeDevCommand must start the selected Vite dev server', ); diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index cf24da0c5..63cd157e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "陶泥儿开发版", + "mainBinaryName": "genarrative-ai-game-creator-shell", "version": "0.1.67", "identifier": "world.genarrative.ai-game-creator", "build": { diff --git a/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh index a5dadc3bd..16b2c3a80 100644 --- a/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh +++ b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh @@ -30,16 +30,19 @@ ; 旧展示名表:以后再改展示名,必须在这里追加旧名,否则升级后旧快捷方式会继续指向旧安装。 !define AGC_LEGACY_IDENTITY_1 "陶泥儿" !define AGC_LEGACY_IDENTITY_2 "Genarrative AI Game Creator" +!define AGC_RELEASE_MAIN_BINARY "genarrative-ai-game-creator-shell-release" Var AgcLegacyIdentity Var AgcLegacyInstallDir Var AgcMigratedLegacyDesktopShortcut +Var AgcLegacyOwnedByRelease ; 清理一个旧身份。调用方先把旧展示名写进 $AgcLegacyIdentity,再 !insertmacro 本宏;宏不返回错误。 ; 只在「旧展示名目录下确实存在我们的主程序」时才动手,避免误删同名目录; ; 当前安装目录 $INSTDIR 永不进入删除路径。 !macro AgcMigrateLegacyIdentity StrCpy $AgcLegacyInstallDir "" + StrCpy $AgcLegacyOwnedByRelease 0 ${If} ${FileExists} "$LOCALAPPDATA\$AgcLegacyIdentity\${MAINBINARYNAME}.exe" StrCpy $AgcLegacyInstallDir "$LOCALAPPDATA\$AgcLegacyIdentity" @@ -49,6 +52,15 @@ Var AgcMigratedLegacyDesktopShortcut ${If} $AgcLegacyInstallDir != "" ${AndIf} $AgcLegacyInstallDir != $INSTDIR + ; release 新包使用独立主程序名;同一旧展示名目录若已有该文件,不能当作旧 dev 清理。 + ${If} ${FileExists} "$AgcLegacyInstallDir\${AGC_RELEASE_MAIN_BINARY}.exe" + StrCpy $AgcLegacyOwnedByRelease 1 + ${EndIf} + ${EndIf} + + ${If} $AgcLegacyInstallDir != "" + ${AndIf} $AgcLegacyInstallDir != $INSTDIR + ${AndIf} $AgcLegacyOwnedByRelease = 0 ; 桌面图标:只有目标正好指向旧目录的主程序时才删,避免误删同名的用户快捷方式。 !insertmacro IsShortcutTarget "$DESKTOP\$AgcLegacyIdentity.lnk" "$AgcLegacyInstallDir\${MAINBINARYNAME}.exe" Pop $0 diff --git a/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh b/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh new file mode 100644 index 000000000..f3b89735c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh @@ -0,0 +1,82 @@ +; AGC release 渠道的 Windows 安装迁移钩子。 +; +; release 曾使用「陶泥儿 Release」作为 productName,后来统一为「陶泥儿」。 +; Tauri 的 NSIS 更新模式不会为改名后的身份重建快捷方式,也不会自动读取旧身份的安装目录, +; 所以这里把旧安装目录、旧快捷方式迁移到当前 release 身份。 +; +; release 渠道同时使用独立的 MAINBINARYNAME(genarrative-ai-game-creator-shell-release), +; NSIS 默认的按文件名查杀逻辑因此不会碰 dev 渠道的主进程。 +; +!define AGC_RELEASE_LEGACY_IDENTITY "陶泥儿 Release" +!define AGC_RELEASE_LEGACY_BINARY "genarrative-ai-game-creator-shell" + +Var AgcReleaseLegacyInstallDir +Var AgcReleaseMigratedDesktopShortcut + +; Tauri 模板会在 Section Install 开头先执行 SetOutPath,再展开 PREINSTALL 宏。 +; 此处仍可在复制文件前改写 $INSTDIR,让旧 release 安装原地升级。 +!macro NSIS_HOOK_PREINSTALL + StrCpy $AgcReleaseLegacyInstallDir "" + ReadRegStr $R0 SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}" "InstallLocation" + ; Tauri 将 InstallLocation 写成带引号的 REG_SZ;去掉包裹引号后再交给 FileExists。 + StrCpy $R1 $R0 1 + ${If} $R1 == '$\"' + StrCpy $R0 $R0 "" 1 + StrLen $R1 $R0 + IntOp $R1 $R1 - 1 + StrCpy $R0 $R0 $R1 + ${EndIf} + ${If} $R0 != "" + ${AndIf} ${FileExists} "$R0\${AGC_RELEASE_LEGACY_BINARY}.exe" + StrCpy $AgcReleaseLegacyInstallDir $R0 + StrCpy $INSTDIR $R0 + ${EndIf} +!macroend + +!macro NSIS_HOOK_POSTINSTALL + ; 更新模式下模板不会重建快捷方式。先把旧名称和旧主程序名指向当前 release。 + !insertmacro IsShortcutTarget "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + StrCpy $AgcReleaseMigratedDesktopShortcut 1 + !insertmacro UnpinShortcut "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + Delete "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + Delete "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + ${EndIf} + + ; 用户可能早已把快捷方式改成当前名称,但仍指向旧主程序名。 + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + ${EndIf} + + ; 开始菜单图标始终存在;桌面图标只在迁移前存在时补建,保持用户选择。 + StrCpy $R0 "$INSTDIR\${MAINBINARYNAME}.exe" + ${IfNot} ${FileExists} "$SMPROGRAMS\${PRODUCTNAME}.lnk" + CreateShortCut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$R0" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + ${EndIf} + ${If} $AgcReleaseMigratedDesktopShortcut = 1 + CreateShortCut "$DESKTOP\${PRODUCTNAME}.lnk" "$R0" + !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" + ${EndIf} + + ; 新 release 已接管旧安装目录后,删除旧主程序,避免 dev 的迁移钩子把该目录误判为旧 dev。 + ${If} $AgcReleaseLegacyInstallDir != "" + Delete "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + DeleteRegKey SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}" + DeleteRegKey HKCU "Software\genarrative\${AGC_RELEASE_LEGACY_IDENTITY}" + ${EndIf} +!macroend diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1bd48562a..4aee35d18 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -71,6 +71,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from './features/project-summary/projectSummary'; +import { insertChatReferences } from './features/project-workspace/activeChatComposer'; import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { needsInitializedChatProject, @@ -78,10 +79,13 @@ import { } from './features/project-workspace/projectCommandPolicy'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; import { + type ChatReference, directCodexContentToLegacyContentDto, hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, + type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { readGamePublishAvailability } from './services/gameDistributionPublish'; @@ -1273,22 +1277,54 @@ export function App({ } useEffect(() => { + /* + 画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。 + 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 + 策划链路挂策划面,事件本身不携带这个判断。 + + 插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, + 否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西, + 也就不能把原因指到输入区上。 + */ + const insertReferences = (references: readonly ChatReference[]) => { + if (references.length === 0) return; + if (insertChatReferences(references)) return; + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)', + ); + } + }; const handleResourceReferenceInsert = (event: Event) => { const detail = (event as CustomEvent) .detail; if (!detail?.reference) return; - chatComposerRef.current?.insertReferences([detail.reference]); - chatComposerRef.current?.focus(); + insertReferences([detail.reference]); + }; + const handleResourceReferenceInsertMany = (event: Event) => { + const detail = ( + event as CustomEvent + ).detail; + insertReferences(detail?.references ?? []); }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - return () => + window.addEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + return () => { window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); + window.removeEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + }; }, []); /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts new file mode 100644 index 000000000..cc67b7a7b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -0,0 +1,66 @@ +import type { ChatReference } from './resourceReferences'; + +/** + * 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。 + * + * `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、 + * 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见 + * 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板 + * 内部用,不进这里——注册表只负责跨面板的引用插入。 + */ +export type ActiveChatComposerHandle = { + insertReferences: (references: ChatReference[]) => boolean; + focus: () => void; +}; + +/** + * 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面, + * 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。 + */ +let activeChatComposer: ActiveChatComposerHandle | null = null; + +/** + * 注册当前挂载的聊天输入区,返回注销函数。 + * + * 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换), + * 旧注销不能把新句柄一起清掉。 + * + * 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`): + * 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义 + * ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。 + */ +export function registerActiveChatComposer( + handle: ActiveChatComposerHandle, +): () => void { + if (activeChatComposer && activeChatComposer !== handle) { + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个', + ); + } + } + activeChatComposer = handle; + return () => { + if (activeChatComposer === handle) { + activeChatComposer = null; + } + }; +} + +/** + * 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。 + * + * 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去 + * (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听) + * 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。 + */ +export function insertChatReferences( + references: readonly ChatReference[], +): boolean { + if (references.length === 0) return false; + const handle = activeChatComposer; + if (!handle) return false; + if (!handle.insertReferences([...references])) return false; + handle.focus(); + return true; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index f2d345d75..a429ada6a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -4,17 +4,22 @@ import { useEffect, useImperativeHandle, useMemo, + useRef, useState, } from 'react'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { userItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; -import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer'; +import { + DirectProjectComposer, + type DirectProjectComposerHandle, +} from './components/DirectProjectComposer/DirectProjectComposer'; import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation'; import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog'; import { @@ -103,6 +108,7 @@ export function DirectProjectChatView({ }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); + const composerHandleRef = useRef(null); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); @@ -212,6 +218,28 @@ export function DirectProjectChatView({ }, })); + /* + 画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里 + **当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路 + 互斥,所以同一时刻注册表里只有一个句柄。 + + 注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面 + 在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences` + 返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。 + */ + useEffect( + () => + registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerHandleRef.current; + if (!handle) return false; + return handle.insertReferences(references); + }, + focus: () => composerHandleRef.current?.focus(), + }), + [], + ); + return ( ) : null} Promise; + /** + * 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」, + * 画布的「引用」/拖拽批量引用事件才能落进这份草稿。 + */ + ref?: Ref; }) { const composerRef = useRef(null); const composerRootRef = useRef(null); @@ -166,6 +189,24 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); + /* + 对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值, + 句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相; + `insertReferences` 回答有没有真的递到输入区——注册表拿它区分「插进去了」和「这一步落空」。 + */ + useImperativeHandle( + ref, + () => ({ + insertReferences: (references: ChatReference[]) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, + focus: () => composerRef.current?.focus(), + }), + [], + ); // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 7e35bc9a0..928d75d03 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -1,6 +1,6 @@ import { ArrowUp, FileUp, Loader2 } from 'lucide-react'; import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent'; import type { @@ -15,6 +15,7 @@ import { projectNameFromPath, projectRuntimeVisibleError, } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect'; import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider'; import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider'; @@ -133,6 +134,23 @@ export function PlanningChatView({ onDesignRetry, }: PlanningChatViewProps) { const designFileInputRef = useRef(null); + /* + 策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处, + 它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份 + 可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。 + */ + useEffect(() => { + if (!composerRef) return; + return registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, + focus: () => composerRef.current?.focus(), + }); + }, [composerRef]); /* 策划输入盒的引用来源:**只注入资源与两个静默 provider**。 diff --git a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts new file mode 100644 index 000000000..759a376e3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type ActiveChatComposerHandle, + insertChatReferences, + registerActiveChatComposer, +} from '../src/features/project-workspace/activeChatComposer'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; + +function resourceReference(resourceId: string): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'character', + mediaType: 'image/png', + label: resourceId, + category: 'character', + tags: [], + source: 'resource-card', + }; +} + +/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */ +function composerHandle(inserted = true): { + handle: ActiveChatComposerHandle; + insertReferences: ReturnType; + focus: ReturnType; +} { + const insertReferences = vi.fn(() => inserted); + const focus = vi.fn(); + return { handle: { insertReferences, focus }, insertReferences, focus }; +} + +/** + * 「活跃聊天输入区」注册表的合同。 + * + * 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据, + * 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与 + * `appSurface/*.suite.ts`。 + */ +describe('活跃聊天输入区注册表', () => { + // 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。 + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('没有任何输入区挂载时插入返回 false', () => { + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('空批次不算成功,也不打扰已挂载的输入区', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([])).toBe(false); + expect(insertReferences).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect( + insertChatReferences([ + resourceReference('hero'), + resourceReference('npc'), + ]), + ).toBe(true); + expect(insertReferences).toHaveBeenCalledTimes(1); + expect(insertReferences).toHaveBeenCalledWith([ + expect.objectContaining({ resourceId: 'hero' }), + expect.objectContaining({ resourceId: 'npc' }), + ]); + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => { + const { handle, focus } = composerHandle(false); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => { + // 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。 + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const unregisterFirst = registerActiveChatComposer(first.handle); + const unregisterSecond = registerActiveChatComposer(second.handle); + + // 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。 + unregisterFirst(); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + + unregisterSecond(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const third = composerHandle(); + + const unregisterFirst = registerActiveChatComposer(first.handle); + expect(warn).not.toHaveBeenCalled(); + + // 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。 + const unregisterSecond = registerActiveChatComposer(second.handle); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + expect.stringContaining('第二个聊天输入区注册'), + ]); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + // 再注册第三个(模拟又一条链路接管):同样留线索。 + const unregisterThird = registerActiveChatComposer(third.handle); + expect(warn).toHaveBeenCalledTimes(2); + + // 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。 + unregisterFirst(); + unregisterSecond(); + expect(insertChatReferences([resourceReference('npc')])).toBe(true); + expect(third.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + unregisterThird(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + warn.mockRestore(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 4ff242c1f..f19d826ae 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -1,5 +1,6 @@ import type { Mock } from 'vitest'; +import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences'; import { act, App, @@ -935,4 +936,35 @@ export function registerDesignAgentSurfaceTests() { expect(details[1]!.open).toBe(false); await waitFor(() => expect(summaries[0]!.textContent).toBe('思考过程')); }); + + it('画布派发的「引用」落进策划输入盒草稿', async () => { + const harness = createProjectChatRuntimeHarness({ + initialSessionExists: false, + }); + renderDesignAgent(harness); + const editor = await screen.findByLabelText('项目需求'); + + // 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由 + // 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是 + // 它在 `PlanningChatView` 这一侧也真的进了草稿。 + act(() => { + dispatchResourceReferenceInsert({ + type: 'resource', + resourceId: 'planning-hero', + kind: 'character', + mediaType: 'image/png', + label: 'hero.png', + category: 'character', + tags: [], + source: 'resource-card', + }); + }); + + await waitFor(() => + expect( + editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'), + ).toHaveLength(1), + ); + expect(editor.textContent).toContain('@hero.png'); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 0304c4567..866de0c7c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -11,7 +11,6 @@ import { GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, } from '../../../../packages/shared/src/contracts/gameCreationApp'; -import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { @@ -31,8 +30,10 @@ import { } from '../resourceGenerationPromptTestUtils'; import { act, + App, cleanup, createGameCreationAppManifest, + createProjectChatRuntimeHarness, expect, findResourceSelectButton, fireEvent, @@ -53,6 +54,11 @@ import { within, } from './harness'; +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; + /** * 历史夹具沿用的旧 kind 字面量(如 `design-document` / `game-code`)已不在当前 * `GameCreationAppAssetKind` 联合里;这些用例依赖未识别 kind 落「待归类」的运行时语义, @@ -3608,7 +3614,7 @@ export function registerProjectWorkbenchFoundationTests() { ); }); - it('工具条里的「引用」用键盘也能插进聊天输入框', async () => { + it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-reference', '引用入口测试', @@ -3623,6 +3629,13 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated' }, }); let layoutRevision = 0; + const projectPath = '/tmp/workbench-resource-reference'; + // 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`): + // 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -3660,20 +3673,45 @@ export function registerProjectWorkbenchFoundationTests() { dataUrl: 'data:image/png;base64,iVBORw0KGgo=', }; } - throw new Error(`unexpected invoke ${command}`); + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - installTauriRuntime({ core: { invoke } }); + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); render( React.createElement(ProjectDevelopmentView, { projectName: '引用入口测试', - projectPath: '/tmp/workbench-resource-reference', + projectPath, manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, - chat: React.createElement('div', null, '项目总控'), + chat: React.createElement(AppComponent, { + initialProjectPath: projectPath, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -3701,49 +3739,39 @@ export function registerProjectWorkbenchFoundationTests() { '引用资源 hero.png', ); - const inserted: unknown[] = []; - const onInsert = (event: Event) => { - inserted.push( - (event as CustomEvent<{ reference: unknown }>).detail.reference, - ); - }; - window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - try { - // 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip - // (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。 - referenceButton.focus(); - expect(document.activeElement).toBe(referenceButton); - await userEvent.setup().keyboard('{Enter}'); - expect(inserted).toHaveLength(1); + // 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。 + // 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。 + const draftChipIds = () => + Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id')); + const composerEditor = () => + document.querySelector( + 'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]', + )!; + // 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。 + const firstChipLabel = () => + document.querySelector( + 'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label', + )?.textContent; - // 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。 - fireEvent.click(referenceButton); - expect(inserted).toHaveLength(2); - } finally { - window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - } - expect(inserted).toEqual([ - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - ]); + // 键盘通路:聚焦后回车确认。 + referenceButton.focus(); + expect(document.activeElement).toBe(referenceButton); + await userEvent.setup().keyboard('{Enter}'); + await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero'])); + expect(firstChipLabel()).toBe('hero'); + expect(composerEditor().textContent).toContain('@hero'); + + // 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。 + fireEvent.click(referenceButton); + await waitFor(() => + expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']), + ); + // 插入后焦点回到输入盒,用户可以接着打字。 + expect(document.activeElement).toBe(composerEditor()); }); it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 629217d3e..e62f08807 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -15,8 +15,11 @@ import { type ResourceReferenceInsertManyEventDetail, } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; +import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel'; import { + App, createGameCreationAppManifest, + createProjectChatRuntimeHarness, fireEvent, React, render, @@ -24,6 +27,11 @@ import { } from './appSurface/harness'; import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; + /** * 「拖动素材到对话 = 批量 @ 引用」的行为级验收。 * @@ -89,10 +97,21 @@ type LayoutWrite = { positions: ProjectResourceCanvasPosition[]; }; -function installTauri(): { layoutWrites: LayoutWrite[] } { +/** 画布与真实 DirectProject 聊天面共用的项目身份。 */ +const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop'; + +function installTauri(manifest: GameCreationAppManifest): { + layoutWrites: LayoutWrite[]; +} { const layoutWrites: LayoutWrite[] = []; const persisted = new Map(); const revisions = new Map(); + // 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答, + // 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath: CANVAS_PROJECT_PATH, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { @@ -142,10 +161,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } { if (command === 'list_local_project_asset_generations') { return []; } - return undefined; + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: CANVAS_PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - installTauriRuntime({ core: { invoke } }); + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); return { layoutWrites }; } @@ -187,21 +229,34 @@ function collectReferenceInserts() { }; } -async function mountCanvas() { - const tauri = installTauri(); +async function mountCanvas( + options: { + /** 项目附件(无 manifest 登记的素材来源)。 */ + attachments?: ProjectAttachmentResult[]; + /** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */ + categoryLabel?: string; + } = {}, +) { + const { attachments = [], categoryLabel = '角色与对象' } = options; const manifest = manifestFor('chat-reference-drop', [ characterAsset('drop-a', 'a.png'), characterAsset('drop-b', 'b.png'), ]); + const tauri = installTauri(manifest); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, - projectPath: '/tmp/chat-reference-drop', + projectPath: CANVAS_PROJECT_PATH, manifest, - attachments: [], + attachments, recentRunStatus: null, recentRunStopReason: null, - chat: React.createElement('div', null, '项目总控'), + // 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` → + // `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」, + // 所以这里不能再拿 `项目总控` 顶替。 + chat: React.createElement(AppComponent, { + initialProjectPath: CANVAS_PROJECT_PATH, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -222,7 +277,7 @@ async function mountCanvas() { toJSON: () => ({}), } as DOMRect); act(() => window.dispatchEvent(new Event('resize'))); - fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` })); await settle(); const chat = document.querySelector('.game-workbench-chat')!; vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT); @@ -236,6 +291,20 @@ function cardIn(manager: HTMLElement, resourceId: string) { )!; } +/** + * 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。 + * + * 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿, + * 不能再读 window 上的一次 dispatch。 + */ +function draftReferenceIds() { + return Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id') ?? ''); +} + afterEach(() => { resetTauriRuntime(); vi.restoreAllMocks(); @@ -281,6 +350,8 @@ describe('拖动素材到对话:批量 @ 引用', () => { kind: 'character', source: 'resource-card', }); + // 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。 + await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a'])); // 拖到对话不是排版:一条坐标都不写。 expect(tauri.layoutWrites).toHaveLength(writesBefore); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); @@ -327,6 +398,10 @@ describe('拖动素材到对话:批量 @ 引用', () => { .map((reference) => reference.resourceId), ), ).toEqual(new Set(['drop-a', 'drop-b'])); + // 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。 + await waitFor(() => + expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']), + ); expect(tauri.layoutWrites).toHaveLength(writesBefore); dispose(); }); @@ -404,4 +479,81 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(0); dispose(); }); + + it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => { + // 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。 + const { manager, inserts, dispose } = await mountCanvas({ + categoryLabel: '待归类', + attachments: [ + { + fileName: 'raw-shot.png', + mediaType: 'image/png', + status: 'imported', + localPath: 'assets/raw-shot.png', + }, + ], + }); + const card = cardIn(manager, 'attachment:assets/raw-shot.png'); + expect(card).not.toBeNull(); + + fireEvent.click(card); + // 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。 + expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull(); + + fireEvent.pointerDown(card, { + pointerId: 74, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 74, + buttons: 1, + clientX: 1000, + clientY: 300, + }); + // 落点提示也说清原因,不承诺一次插不进去的引用。 + expect( + screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'), + ).not.toBeNull(); + + fireEvent.pointerUp(card, { + pointerId: 74, + button: 0, + clientX: 1000, + clientY: 300, + }); + await settle(); + + expect( + screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'), + ).not.toBeNull(); + expect(inserts).toHaveLength(0); + expect(draftReferenceIds()).toEqual([]); + dispose(); + }); + + it('空批次引用事件不报「没有可用的聊天输入区」', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { dispose } = await mountCanvas(); + // 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return), + // 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。 + act(() => { + window.dispatchEvent( + new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, { + detail: { references: [] }, + }), + ); + }); + await settle(); + + expect( + warn.mock.calls + .map((call) => String(call[0])) + .filter((message) => message.includes('没有可用的聊天输入区')), + ).toEqual([]); + expect(draftReferenceIds()).toEqual([]); + warn.mockRestore(); + dispose(); + }); }); diff --git a/docs/README.md b/docs/README.md index 07185677f..c27e5d9a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ - [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。 - [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。 - [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 +- [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 diff --git a/docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md b/docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md new file mode 100644 index 000000000..4c98a94ec --- /dev/null +++ b/docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md @@ -0,0 +1,143 @@ +# 【ADR】游戏游玩次数计数 + +状态:已接受(2026-10-03) + +## 背景 + +网站游戏分发已经展示「X 次游玩」:目录卡片、详情、作者「我的游戏」与后台「游戏管理」都读 +`game_distribution_game.play_count`,契约里也有 `playCount`。但这条链路只读不写——游戏创建时置 `0`, +之后没有任何自增、reducer 或 procedure;因此所有游戏恒显示 `0` 次游玩。 + +主规范要求「不虚构评分、玩家数、排名和收藏状态」,里程碑与证据矩阵以「新游戏显示 0、目录数据来自真实 +接口」作为无假统计的证据,却从未定义「一次游玩如何累加」。相关现状: + +- **触发信号不存在**:游玩页 `/games/play` 先给封面与「开始游戏」,点击后才挂 + `sandbox="allow-scripts"` 的 iframe;`startGame()` 纯本地状态,不发任何请求。AGC 客户端 12 类埋点白名单 + 不含「开始游玩」,主站路由埋点只有查看统计。 +- **发行网关不能承担计数**:`serve_release_entry` / `serve_release_asset` 只按 `gameId` 服务当前公开版本 + 文件;一次加载会打多个资源请求,且按设计禁带平台 Cookie、拿不到会话身份。 +- **平台没有匿名身份**:没有匿名访客 cookie,web 端没有 device/client id;`x-client-instance-id` 只在登录 + 链路采集。 +- **没有现成的服务端缓冲管线可复用**:AGC 埋点管线是客户端本地 15 分钟一批、服务端同步原子入库且明确 + 不做聚合;真正的「内存缓冲 + 周期 flush + 关停 flush」范式是主站路由埋点 `tracking_outbox`。 +- **部署与限流现状**:api-server 单实例;无 CSRF/Origin 中间件;应用层只有并发背压,按 IP 令牌桶在独立 + 的 `pingora-gateway`;现有缓存惯例是 std `OnceLock`/`LazyLock` + `Mutex`(无 `dashmap`/`moka`)。 + +## 决策 + +### 1. 触发点 = 游玩页点「开始游戏」 + +以用户点击「开始游戏」后的前端上报作为一次游玩。理由:这是产品定义的启动动作,游客可用;`iframe load` +只代表文档加载(规范已明确不能当业务状态),发行网关无法区分会话且会因资源请求重复。点击后即使 iframe +超时/未真正载入也计一次(用户意图)。 + +### 2. 落点 = 复用现有 `play_count` + +`game_distribution_game.play_count` 已经是 `u64`、已进公开/后台 DTO、已进四处 UI。只新增写入路径,不改 +字段语义、不新建计数表,避免双源。计数跟随游戏身份,不随发行版本。 + +### 3. 范围 = 只做累计总次数 + +不做日粒度 / 近 7 天热度、独立玩家数、榜单、推荐。`public_work_play_daily_stat` 属已退役自定义世界口径, +不复活。 + +### 4. api-server 纯内存缓冲 + 周期 flush + +- **纯内存,不落盘**:崩溃、被杀和正常 SIGTERM/滚动重启都允许丢最后一个 flush 窗口;进程退出不做 + force flush,关停路径不为计数等待网络(2026-10-04 修订,见「修订记录」)。 +- **两张表**:增量表 `pending: HashMap`(5 秒级、flush 即清)与 30 分钟去重窗口表 `seen: + HashMap`(30 分钟级、按 TTL 清理)。两者键不同、生命周期差 360 倍,不能合并: + 合并会把 30 分钟窗口状态塞进 5 秒清空的表,或让 flush 需要按 gameId 重新聚合。 +- **限流表**:`rate: HashMap` 固定窗口。 +- **并发**:请求路径只在短锁内做 HashMap 命中 + 自增;**flush 的网络调用移出锁外**。 + +### 5. 写入形状 = 批量 procedure + +一次 flush 发一个 procedure,入参 `Vec<{gameId, delta}>`(按 500 分块),事务内逐条 +`play_count = play_count.saturating_add(delta)`;procedure 内**原子校验**游戏当前为 `published` 且 +`active_version_id` 存在,非公开跳过。**不 bump `updated_at`**:它只表示公开资料变更,且作者自有列表按它 +排序,bump 会让每次游玩重排作者列表。 + +### 6. 失败语义 = 少计优于双计 + +只重试确定未发出的 `Build`;`Timeout` / `ConnectDropped` 无法判断是否已提交,直接丢弃该批并 `warn!` 记录 +丢量。理由是:超时后重试会在"其实已提交"时造成系统性双计,而丢弃只是偶发少计——对一个非交易展示指标, +后者更可接受(perf 优先于 correctness)。 + +一次 flush 按 500 分块;任一分片失败即终止本次 flush 的后续分片,剩余增量直接丢弃(`Build` 只把当前分片 +放回)。连接不通时剩余分片只会重复同样的失败,逐个重试会把 worker 卡在多次连接超时上(2026-10-04 补充)。 + +### 7. 接口 + +`POST /api/game-distribution/games/{game_id}/plays`,公开端点(可选 bearer): + +- 不挂 `require_bearer_auth`;用 `optional_access_token_from_headers` 拿可选 `userId`。 +- **不加 `Idempotency-Key`**(与其它游戏分发写路由惯例不同):高频计数用不上幂等收据,30 分钟去重窗口就是 + 护栏。 +- 非公开 / 下架 / 封禁返回 `404` 且不计数;被限流返回 `429`;成功统一 `200 {recorded: bool}`。 +- 前端 fire-and-forget,**任何失败静默、绝不阻断游玩**;不做发行网关兜底计数。 + +### 8. 身份与去重键 + +登录用 `userId`;匿名用前端 `localStorage` 持久随机 `clientId`(随请求体带上);两者都缺失时回退 +`IP + UA`。30 分钟窗口按 `identity + gameId`。不新造匿名 cookie、不依赖登录后才有的设备指纹。`IP` +取 nginx 覆盖写入的 `X-Real-IP`(无 CDN 时即真实 TCP 对端),不取可伪造的 `X-Forwarded-For` 首段; +限流键与微信支付下单的 `payer_client_ip` 同源(2026-10-04 补充)。 + +### 9. 落位 = 纯持久化 / 读模型 + +procedure 在 `spacetime-module`,facade + mapper 在 `spacetime-client`,缓冲 / worker / 端点在 +`api-server`;`module-game-distribution` 不动(自增不是领域规则,与既有「点赞计数」直接落持久化流程同构)。 +新增 procedure 不改表,schema guard 不触发,但必须 `npm run spacetime:generate` 重生成绑定并同步文档。 + +### 10. 展示一致性 = 接受滞后 + +读路径(目录 / 详情)继续只读 DB,不叠加内存 pending;接受「flush 间隔 + 写库」的 ≤10 秒滞后。 + +## 影响与代价 + +- 计数非实时,最多一个 flush 窗口的滞后;崩溃 / kill 丢最后一个窗口;模糊传输错误少计。 +- 匿名 `clientId` 可被清除 / 伪造,指标定位为展示用次数,仅靠限流兜底。 +- 单实例前提:去重 / 限流窗口不跨实例;将来多实例时各实例自行 flush(加法幂等),窗口不共享。 +- 不 bump `updated_at`,作者自有列表排序、公开修订 CAS 均不受影响。 +- 新增 procedure 只改 ABI(绑定),不改表 schema / `migration.rs`。 + +## 备选方案与取舍 + +1. **发行网关服务端计数**:一次加载多资源请求会重复、禁 Cookie 拿不到会话、无法去重。已否决。 +2. **复用 AGC 客户端埋点管线**:服务端同步入库且明确不做聚合,客户端 15 分钟批次,白名单不含游玩。已否决。 +3. **落盘 / outbox 保可靠**:无持久性需求(崩溃丢窗口已接受),引入磁盘与独立目录要求。已否决。 +4. **单张 map / 不做去重**:会重复计数;去重状态与增量生命周期不同。已否决。 +5. **每个游戏一次 procedure**:N 次 WebSocket 往返。已否决。 +6. **模糊失败重试**:超时已提交时系统性双计。已否决。 +7. **匿名 HttpOnly cookie / 纯 IP 去重**:前者要新造 cookie 与跨端 / 沙箱处理,后者在 NAT 下把多人并成一人。 + 已否决。 + +## 明确不做 + +- 日粒度 / 近 7 天 / 独立玩家数 / 榜单 / 推荐。 +- AGC 客户端界面埋点、外部 API / External OpenAPI 扩展。 +- 服务端网关兜底计数、CSRF token、应用层全局按 IP 限流(沿用既有限流与网关能力)。 +- 幂等收据表、双计补偿、跨实例窗口共享。 + +## 落地与验收 + +- 实施边界:`spacetime-module` 新增批量自增 procedure 与 `SpacetimeType`;`spacetime-client` facade + + mapper;`api-server` 新增计数模块(增量 / 去重 / 限流 / flush worker)、AppState 接线与公开 + 端点;前端 `gameDistributionClient` 增 `recordGamePlay` 并在 `startGame()` 触发。 +- 权威文档同步:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(路由表与游戏分发合同节)、 + `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`(`game_distribution_game` 的 procedure 与 + 写入口径)。 +- 验收判据:新游戏 `0` → 游客点一次 ≤10s 内显示 `1`;同身份 30 分钟内重复点击不增、不同身份各 `+1`; + 超限流 `429` 且不写;下架 `404` 不计数但历史值保留;SIGTERM 关停允许丢最后一个未落库窗口;计数接口任何失败都 + 不影响游玩页;相关 Rust / 前端定向测试与 schema / DDD / 绑定 / DTO parity / 编码 / doc-index 门禁全绿。 + +## 修订记录 + +- 2026-10-03:初版。 +- 2026-10-04:关停不再强制 flush(原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」 + 作废)。理由:关停时最后一个窗口丢失概率极低,而强制 flush 需要把 worker 生命周期接进关停顺序并为在途 + 网络写入等待;按"perf 与简单优先"取舍,直接放弃该窗口。同日明确 flush 任一分片失败即丢弃剩余分片。 +- 2026-10-04:客户端 IP 解析改为优先 nginx 覆盖写入的 `X-Real-IP`,`X-Forwarded-For` 只作回退且取最后 + 一段(nginx 用 `$proxy_add_x_forwarded_for` 追加的真实对端),不再信任可伪造的首段——公开上报端点原来 + 用它做匿名身份与限流键,可被伪造 IP 绕过并灌水。无 CDN 前置时 `X-Real-IP` 即真实客户端。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4ea316a14..7d5a8f4c8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,6 +8,40 @@ - 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs,asset_generation_tasks.rs,asset_generation_tasks/runtime.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx},features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx},view/project-development/index.tsx}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,projectNamingGenerationTaskRow.test.tsx,appSurface/home.suite.ts}`。 - 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1925 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(22 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal` 与 `legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`)与 `conditional_project_rename_tests`(6 passed);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +## 2026-10-04 游戏游玩次数修订:关停不强制 flush、flush 失败丢弃剩余分片、客户端 IP 只信 X-Real-IP + +- 变更:ADR `docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md` 修订——原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」作废;崩溃、被杀、正常关停都允许丢最后一个未落库窗口,`api-server` 不再注册关停 flush。 +- 新增:一次 flush 按 500 分片,任一分片失败即终止本次 flush,剩余分片直接丢弃(`Build` 只把当前分片放回下一轮),避免连接不通时每个分片各等一次连接超时把 worker 卡住。 +- 理由:关停丢一个窗口概率极低,强制 flush 要为在途网络写入等待、并把 worker 生命周期接进关停顺序;按 perf 与简单优先取舍。 +- 受影响实现:`game_play_counter_worker.rs`(删 `flush_game_play_counter_for_shutdown`、失败即 break)、`main.rs`(`finalize_shutdown` 去掉计数 flush)、`game_play_counter.rs`(`take_pending` 仅测试使用)、`modules/game_distribution.rs`(上报先做内存限流预检再查公开可见性)。 +- 安全修正:`request_context::client_ip_from_headers` 改为优先 nginx 覆盖写入的 `X-Real-IP`,`X-Forwarded-For` 只作回退且取最后一段(nginx 用 `$proxy_add_x_forwarded_for` 追加的真实对端),不再信任可伪造的首段;公开上报端点的匿名身份/限流键与微信支付下单的 `payer_client_ip` 同时受益。无 CDN 前置时 `X-Real-IP` 即真实客户端。 + +## 2026-10-03 游戏游玩次数:api-server 内存去重缓冲 + 批量 procedure 落 play_count + +- 背景:`game_distribution_game.play_count` 早已存在且随公开投影展示,但没有任何写入口;浏览列表、详情或发行网关加载都不能算「游玩」。需要一个不拖慢进入游戏、崩溃时最多少计一个窗口的上报链路。完整决策与备选方案见 ADR `docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md`。 +- 触发与落点:游玩页点击「开始游戏」时网页 fire-and-forget 上报 `POST /api/game-distribution/games/{gameId}/plays`;不建新表,累加既有 `play_count`。 +- 缓冲与写入:`api-server` 纯内存聚合,`GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5s)到点批量调用新 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `Vec<{gameId, delta}>`);事务内只对 `published` 且有有效 `active_version_id` 的记录 `saturating_add`,且不更新 `updated_at`(避免重排作者列表)。读路径不叠加内存值,展示最多滞后一个 flush 间隔。~~正常关停强制 flush~~(2026-10-04 修订:关停不再强制 flush,见上条)。 +- 身份与限流:登录用 `userId`、匿名用网页 `localStorage` 的 `clientId`(不可用时退化为会话内存值)、都拿不到回退 `IP + UA`;`identity + gameId` 30 分钟去重,`IP + gameId` 每分钟 60 次固定窗口限流。非公开/下架/封禁返回 404 且不计数;无效 Bearer 按匿名处理,绝不让计数阻断游玩。 +- 失败语义:只把 `SpacetimeClientError::Build`(未发出)放回重试;`Timeout` / `ConnectDropped` / `Procedure` 直接丢弃并记录丢失量——少计优于双计,本指标不做双计补偿,也不共享跨实例去重窗口。一次 flush 按 500 分片,任一分片失败即终止本次 flush,剩余分片直接丢弃(2026-10-04 补充)。 +- 影响范围:`spacetime-module/game_distribution.rs`(输入类型 + procedure + tx)、`spacetime-client` facade 与生成绑定、`api-server` 新增 `game_play_counter.rs` / `game_play_counter_worker.rs` 及 config/state/main/handler、前端 `gamePlayClientId.ts` / `gameDistributionClient.ts` / `GamePlayPage.tsx`、`.eslintrc.cjs` 白名单。 +- 权威文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_game` 节,以及 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 的「游玩计数(已实现)」节。 +- 验证:`cargo check -p api-server` 与 `cargo test -p api-server game_play_counter`(9 passed)通过;前端定向 vitest(点击上报断言 + clientId 稳定性)与 `eslint --max-warnings 0` 通过;`npm run check:server-rs-ddd`、`npm run check:generated-bindings`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 + +## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602) + +- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数,并检测到第二个输入区注册时留一条 dev 告警——不改运行时语义;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`(空批次直接返回:没有要插的东西,不能报成「没有可用的输入区」);返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/activeChatComposer.test.ts`(新增,钉注册表合同)、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`、本文件。 +- 验证(合并 master 后的最终一轮):`npx vitest run apps/ai-game-creator-shell/tests`(197 passed / 1 skipped 文件,1918 passed / 17 skipped 用例)、`npx vitest run tests/activeChatComposer.test.ts tests/resourceCanvasChatReferenceDrop.test.tsx`(2 files / 12 passed,含注册表合同:空批次、无输入区、句柄报落空、注销身份校验、重复注册告警、乱序注销)、`npx vitest run tests/appSurface.test.ts -t 引用`(4 passed)、`npm run agc:typecheck`(含 `check:tests:types`,exit 0)、`npm run check:encoding`、`git diff --check`、eslint `--max-warnings 0`(改动文件)。反向证伪:去掉注册调用后端到端用例变红;去掉空批次短路 / 重复注册告警后对应新用例各红一处。 + +## 2026-10-04 AGC 渠道更新进程与快捷方式隔离 + +- 背景:Tauri 2.11 的 Windows NSIS 模板通过 `MAINBINARYNAME` 查找并结束进程。dev 与 release 过去共用 `genarrative-ai-game-creator-shell.exe`,更新任一渠道都会结束另一渠道;release 从「陶泥儿 Release」改为「陶泥儿」后,`/UPDATE` 又不会自动重建旧快捷方式。 +- 决策:dev 保留历史主程序文件名以维持升级链;release 使用 `genarrative-ai-game-creator-shell-release.exe`,其它非默认渠道使用带渠道后缀的主程序名。构建期把 `mainBinaryName` 与渠道端点、productName、identifier 同批注入,NSIS 因文件名隔离而只匹配自身渠道进程。 +- 迁移:release Windows 包通过独立安装钩子读取旧 `陶泥儿 Release` 卸载项的安装目录,在原目录安装新包,迁移旧桌面/开始菜单快捷方式并清理旧主程序与孤儿卸载项;dev 继续使用原有旧展示名迁移钩子。 +- 影响范围:AGC 渠道身份脚本、发布构建配置、Tauri 基线配置、Windows NSIS 钩子与渠道发布测试;不改变 OSS 分区、更新端点或客户端数据目录合同。 +- 验证方式:渠道发布脚本定向测试、`check-config.mjs`、真实 NSIS 编译与双渠道安装/更新 smoke;真机安装仍需发布环境执行。 ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ca5122260..687c5e254 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -20,6 +20,22 @@ - **边界**:条件改名的判据必须由宿主校验(项目 ID + 当前名称仍是兜底名),前端只转述 `expectedProjectId` / `expectedName`;用户已手动改名时宿主返回 `renamed: false`,回填整体跳过、绝不覆盖用户输入。 - **关联**:`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`。 +## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上 + +- **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。 +- **原因**:画布侧只 `dispatchResourceReferenceInsert` / `dispatchResourceReferenceInsertMany`,而这两个 window 事件的**唯一**消费者是 `App.tsx` 里的 `chatComposerRef.current?.insertReferences(...)`,`chatComposerRef` 又只赋给 `PlanningChatView`;2026-09-22 DirectProject 拆分引入的 `directProjectMode` 提前 return 让普通项目整段跳过后面的策划面渲染 → ref 恒为 `null`,可选链把整次调用静默吞掉。同一批合并冲突还把 2026-09-21 新加的 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 监听整段丢掉,批量引用连消费者都没有。 +- **处理(现行口径)**:`features/project-workspace/activeChatComposer.ts` 保存当前挂载的**那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数 / `insertChatReferences`),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥,同一时刻只有一个句柄);`App.tsx` 收敛为一处监听,单条与批量都走 `insertChatReferences`,返回 `false`(空批次 / 此刻没有输入区)时 dev 下 `console.warn`;`chatComposerRef` 只留给策划输入盒自己的 `getDraft` / `clear`。 +- **判据/取证**:`npm run test -- apps/ai-game-creator-shell/tests` 里的 `resourceCanvasChatReferenceDrop.test.tsx`、`appSurface/project-development.suite.ts`(工具条「引用」)、`appSurface/design-agent.suite.ts`(策划链路)都断言**真实输入盒草稿**里出现 `[data-resource-reference-id=""]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。 +- **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。 + +## 2026-10-04 AGC 渠道更新按固定主程序名互相查杀 + +- **现象**:开发版和 release 同时运行时,更新其中一个渠道会把另一个进程一起结束;release 从旧产品名升级后,旧 `陶泥儿 Release.lnk` 仍指向旧安装目录,更新后的 release 没有可用快捷方式。 +- **原因**:Tauri 2.11 的 NSIS `CheckIfAppIsRunning` / `KillProcess` 只按 `MAINBINARYNAME` 匹配。所有渠道都使用 `genarrative-ai-game-creator-shell.exe`,所以插件无法按安装目录区分进程;更新模式还会跳过快捷方式创建,产品名变化后旧图标不会自动迁移。 +- **处理**:`channel-identity.mjs` 新增 `resolveChannelMainBinaryName`:dev 保留旧文件名,release 与其它渠道使用后缀文件名;`createChannelConfig` 同批注入 Tauri `mainBinaryName`。release Windows 包使用独立 `release-installer-hooks.nsh`,从旧 `陶泥儿 Release` 卸载项恢复安装目录,迁移旧快捷方式并删除旧主程序/卸载项;dev 的历史改名钩子保持不变。 +- **不要踩的坑**:只改 `productName` 或 `identifier` 不能阻止 NSIS 互相查杀;只改安装包文件名也不能让 updater 选中正确的进程,必须把 `mainBinaryName` 写入构建期 Tauri 配置,并保证 dev 的历史文件名不变。NSIS 钩子仍只能在 `!macro` 内引用模板常量/插件,且文件必须 UTF-8 with BOM。 +- **判据/验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` 覆盖渠道主程序名、release 钩子与宏位置;`node apps/ai-game-creator-shell/scripts/check-config.mjs` 校验基线;真实 Windows NSIS 编译与 dev/release 同机更新 smoke 仍需发布环境执行。 +- **关联**:`apps/ai-game-creator-shell/scripts/channel-identity.mjs`、`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/src-tauri/windows/{installer-hooks.nsh,release-installer-hooks.nsh}`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 ## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 @@ -6334,6 +6350,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。 - **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。 + ## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台" - **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 4634ed447..0674da792 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -58,7 +58,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 - DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。 - 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。 -- AGC 安装产品名由渠道身份决定:`dev` 显示“陶泥儿开发版”,`release` 复用正式产品名“陶泥儿”,自定义渠道显示“陶泥儿 <渠道显示名>”;Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述,identifier 继续按 `<基线>.<渠道>` 派生,保证渠道数据与运行身份隔离(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名保持稳定。 +- AGC 安装产品名由渠道身份决定:`dev` 显示“陶泥儿开发版”,`release` 复用正式产品名“陶泥儿”,自定义渠道显示“陶泥儿 <渠道显示名>”;Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述,identifier 继续按 `<基线>.<渠道>` 派生,主程序文件名也按渠道隔离(dev 保留历史名,release 使用 `-release` 后缀),保证渠道数据、运行身份与更新进程隔离(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。 - 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index b26f58358..ef292bf77 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -3,6 +3,7 @@ ## 范围 - 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。 +- 桌面端后台侧边栏按“工作台 / 数据与监控 / 运营配置 / 充值与支付 / AGC 管理 / 内容运营 / 账号权限”分组,默认只展开当前路由所在组。分组标题可独立折叠;每次切换路由(包括同组内跳转和浏览器 hash 导航)自动展开目标组,折叠动作不切换页面。只展示当前账号有权访问的页签,空组不渲染,组内数量只统计可访问页签。折叠状态仅保留在当前页面会话;分组不改变路由 hash、页签权限或移动端底栏的平铺顺序。 - Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。 - 不新增持久化统计表或字段;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 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 59ef49d5b..996793ecb 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -21,6 +21,7 @@ ### 渠道与安装身份合同 - 渠道同时决定**更新端点**与**安装身份**,两者都由构建期写入产物。默认渠道 `dev` 保持基线身份 `productName = 陶泥儿开发版`、`identifier = world.genarrative.ai-game-creator`;`release` 渠道使用正式产品名 `陶泥儿` 并派生 `identifier = world.genarrative.ai-game-creator.release`;其它自定义渠道派生 `productName = 陶泥儿 <渠道显示名>`(如 `beta-2` → `陶泥儿 Beta-2`)与 `identifier = world.genarrative.ai-game-creator.<渠道>`。渠道显示名按连字符分段首字母大写,不改动渠道本身。 +- Windows 主程序文件名也是渠道安装身份的一部分:`dev` 保留 `genarrative-ai-game-creator-shell.exe` 以延续既有升级链,`release` 使用 `genarrative-ai-game-creator-shell-release.exe`,其它渠道使用 `genarrative-ai-game-creator-shell-.exe`。NSIS 更新/卸载按该文件名查找进程,保证更新一个渠道不会结束另一个渠道。 - 默认渠道身份**不可变更**:既有安装目录、卸载项、快捷方式与已发布客户端的升级链都建立在基线身份上。渠道身份由 `apps/ai-game-creator-shell/scripts/channel-identity.mjs` 单点定义,构建入口、macOS 发布入口与配置门禁共同消费;基线 `tauri.conf.json` 必须逐字等于默认渠道身份。 - 安装身份决定的持久与可见事实:Windows 安装目录 `%LOCALAPPDATA%\<产品名>`、卸载项与 `HKCU\Software\genarrative\<产品名>`、WebView2 数据目录 `%LOCALAPPDATA%\`、客户端数据目录 `%APPDATA%\`;macOS `.app` 名、bundle id、DMG 卷名与菜单栏应用名。 - 同机并存:不同渠道的包体可以在同一台设备上同时安装并同时运行,互不覆盖、互不顶掉;同一渠道的新版本仍是原地升级,因为更新端点与安装身份同属一个渠道。 @@ -124,19 +125,20 @@ - 渠道安装身份映射(`` 为 `dev`、`release` 或自定义名称;`` 为渠道显示名): -| 渠道 | productName | identifier | Windows 安装目录 | 客户端数据目录 | -| -------------------- | ------------------ | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | -| `dev`(默认) | `陶泥儿` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator` | -| `release` / 自定义 | `陶泥儿 ` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | +| 渠道 | productName | 主程序文件名 | identifier | Windows 安装目录 | 客户端数据目录 | +| -------------------- | ------------------ | ------------------------------------------------- | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | +| `dev`(默认) | `陶泥儿开发版` | `genarrative-ai-game-creator-shell.exe` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿开发版` | `%APPDATA%\world.genarrative.ai-game-creator` | +| `release` | `陶泥儿` | `genarrative-ai-game-creator-shell-release.exe` | `world.genarrative.ai-game-creator.release` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator.release` | +| 自定义渠道 | `陶泥儿 ` | `genarrative-ai-game-creator-shell-.exe` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | -- 安装身份迁移:`dev` 客户端保持原身份,升级链路连续;`release` 与自定义渠道首次以新身份安装,**不接管也不迁移**任何既有 `dev` 安装、本地项目或登录态,设备上因此可以同时存在两个渠道的客户端,由用户自行决定是否卸载其一。 +- 安装身份迁移:`dev` 客户端保持原 identifier 与主程序文件名,升级链路连续;dev Windows 包继续清理历史展示名 `陶泥儿` / `Genarrative AI Game Creator` 的旧快捷方式。release 从旧展示名 `陶泥儿 Release` 迁移安装目录与快捷方式到当前 `陶泥儿` 身份;release 与自定义渠道不接管也不迁移其它渠道的数据或登录态。 ## 构建与发布 - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 -- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier`、updater 端点与窗口标题:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier`、`mainBinaryName`、updater 端点与窗口标题:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 - 渠道配置走 Tauri 的 JSON Merge Patch 语义:对象递归合并,**数组整体替换**。因此 `app.windows` 必须按基线 `tauri.conf.json` 的完整 client 窗口对象下发、只覆盖 `title`(脚本从基线读取后展开);任何"只写 `{ title }`"的写法都会让 `label` / `decorations` / 尺寸回落成 Tauri 默认值(`label=main`、`decorations=true`、800x600),表现为打包产物重新出现系统标题栏,并按 label 连带失效承载平台 HTTP 权限等 capability。守卫用例:`build-release.test.mjs` 的渠道配置合并用例与 `check-config.mjs` 的 `decorations` 门禁。 - 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要不再自动生成:发布脚本不读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入渠道清单和旧协议清单的 `releaseNotes`。未设置时清单不携带更新说明,归档文件 `release-notes.txt` 记录“本次没有可用的更新摘要”。 @@ -184,10 +186,11 @@ | 条款 | 验收方式 | 结果 | | --- | --- | --- | -| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿开发版` / `world.genarrative.ai-game-creator`,`release` 使用 `陶泥儿` 并保持独立 identifier,`beta-2` 派生带后缀的产品名与独立 identifier,非法渠道失败关闭 | +| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿开发版` / `world.genarrative.ai-game-creator` / `genarrative-ai-game-creator-shell`,`release` 使用 `陶泥儿` 并保持独立 identifier 与 `-release` 主程序名,`beta-2` 派生带后缀的产品名、identifier 与主程序名,非法渠道失败关闭 | | 身份与端点同批注入 | 同上的渠道 `--config` 用例 | 通过;`productName` / `identifier` 与 `/-win|mac/latest.json` 来自同一次解析 | | 渠道产物首装包选择 | 同上的渠道 DMG 夹具用例 | 通过;`陶泥儿_<版本>_aarch64.dmg` 仍按 `<版本>_<架构>.dmg` 唯一匹配 | | 基线配置等于默认渠道身份 | `node apps/ai-game-creator-shell/scripts/check-config.mjs` | 通过;基线漂移与非默认渠道身份不隔离都会失败关闭 | +| Windows 更新进程与快捷方式隔离 | `build-release.test.mjs` 渠道配置/NSIS 钩子用例 | 通过;dev/release 主程序文件名不同,release 迁移旧 `陶泥儿 Release` 安装目录与快捷方式 | | 全量发布脚本回归 | `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs` | 通过(64/64,含 macOS 入口按渠道解析产品名的守卫) | | AGC 自有 AppData 提权 ACL 范围 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests` | 通过(12/12;含基线、`<基线>.release`、`<基线>.beta-2` 与相似前缀 `-backup` 的反向断言) | | 渠道身份进入真实构建产物 | `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug` | 通过;Tauri 接受 `productName=陶泥儿` / `identifier=world.genarrative.ai-game-creator.release` 并完成构建;产物字符串实测 `陶泥儿` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0 | diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 2591f9d63..6718d6e2c 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -1,9 +1,31 @@ # AGC 聊天素材引用 -更新时间:2026-09-23 +更新时间:2026-10-03 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 +## 画布引用落到哪份输入区:活跃聊天输入区注册表(2026-10-03) + +资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答: + +- `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。 +- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。注册的是**按 ref 转发**的句柄、且不在注册时读输入区是否就位,所以挂载顺序与子组件重挂载都不会让注册表漏挂或指向死句柄。 +- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`;返回 `false` 的三种情况(空批次、没有挂载中的输入区、注册表里的句柄报「这一批没插进去」)都不静默,dev 下 `console.warn` 留一行线索;只有真的插进去了才把焦点交给输入区。 +- `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。 + +这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。 + +这条链路由以下用例守住(都渲染真实聊天面,断言草稿 DOM 而不是「事件被派发」): + +| 契约 | 用例 | +| --- | --- | +| 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 | +| 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` | +| 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` | +| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 / 重复注册留线索 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | +| 空批次事件不误报成「没有可用的聊天输入区」 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` 的「空批次引用事件」用例 | +| 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | + ## 引用来源由宿主注入(2026-09-22) `ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂): @@ -60,7 +82,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。 - 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。 -- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。 +- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),由 `App.tsx` 的监听一次性交给当前挂载的输入区(见文首「活跃聊天输入区注册表」),草稿只重建一次、插入顺序即拖动集合顺序。2026-09-22 的合并冲突曾把这条监听整段丢掉(事件无消费者),2026-10-03 修复时补回。 ## 附件进入正文(2026-09-22) @@ -83,7 +105,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 支持搜索、类型筛选和多选; - 素材芯片可插入、编辑和删除; - 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节); -- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; +- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;未登记素材(`manifestAssetId: null`)不渲染这枚按钮,拖到对话栏时落点提示与提示条给出「还没登记为项目资源」的原因; +- 普通项目(DirectProject)与立项策划两条链路都由 `App.tsx` 那一处监听 + 活跃聊天输入区注册表把引用落进当前挂载的输入盒草稿(2026-10-03 修复,见文首); - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; - 提交请求携带 canonical user message item; - Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 64db5e8fe..9ace24f5a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -485,6 +485,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。 - 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。 - 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。 +- 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。 ### `game_distribution_review` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 33e7ab149..c241dd71c 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -119,6 +119,7 @@ | --- | --- | --- | | `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 | | `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 | +| `POST /games/{gameId}/plays` | 游客/登录 | **已实现**:上报一次「开始游戏」;可选 Bearer,非公开 404、超限 429,成功返回 `{recorded}`;只进 api-server 内存缓冲,失败不影响游玩 | | `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` | | `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 | | `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` | @@ -136,6 +137,14 @@ 领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`,HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts` 与 `packages/shared`。新业务必须使用当前正式表与契约,不得以未挂载源码或非正式私有快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。 +### 游玩计数(已实现) + +- **触发点**:游玩页点击「开始游戏」时网页上报一次,不做 iframe load、不在发行网关计数、不设停留阈值;点击后即使 iframe 超时也计一次。计数失败静默,绝不阻断进入游戏。 +- **落点**:复用 `game_distribution_game.play_count` 累计次数,随目录、详情、作者「我的游戏」与后台游戏管理投影读取,不另建计数表。 +- **缓冲与延迟**:`api-server` 纯内存聚合(增量表 + 30 分钟去重表 + 限流表),flush 间隔由 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS` 配置(默认 5s),经批量 procedure 落库;读路径不叠加内存值,展示最多滞后一个 flush 间隔。崩溃、被杀和正常关停都允许丢最后一个未落库窗口,关停不做强制 flush。 +- **去重与限流**:登录用 `userId`、匿名用网页持久的 `clientId`、都拿不到时回退 `IP + UA`(`IP` 取 nginx 覆盖写入的 `X-Real-IP`,不取可伪造的 `X-Forwarded-For` 首段);`identity + gameId` 30 分钟去重窗口,另按 `IP + gameId` 每分钟 60 次固定窗口限流(超限 429)。公开上报端点先做内存限流预检,超限直接 429、不再查公开可见性;非公开/下架/封禁返回 404 且不计数。 +- **写入语义**:批量 procedure 在事务内只对 `published` 且存在有效公开版本的记录做 `saturating_add`,且**不更新** `updated_at`(避免重排作者列表)。该指标定位为展示用次数,不做交易级幂等、双计补偿或跨实例窗口共享。flush 按 500 分片,任一分片失败即终止本次 flush、剩余分片直接丢弃:`Build`(确定未发出)只把当前分片放回下一轮,其余错误连本批一起丢弃。 + ### 发行路径、沙箱与网络能力 - 发行入口是平台同源路径 `https://<平台域名>/games//`,边缘 nginx 把该前缀原样映射到 `api-server` 发行网关。运行隔离不依赖独立来源,而由 iframe `sandbox="allow-scripts"` 把游戏文档固定在不透明来源:游戏拿不到主站 Cookie、`localStorage`、`IndexedDB`、DOM 与 Service Worker,离开页面即随 iframe 卸载整套游戏代码。 @@ -342,6 +351,7 @@ - 列表列为游戏名称/ID、评价用户昵称/ID、评分、评论摘要、状态、提交时间、修改时间和操作。空评论在后台显示“仅评分”;摘要最多 120 Unicode 码点,截断时加省略号。 - “查看详情”使用现有弹窗,展示完整纯文本评论、分数、游戏/用户/评价 ID、创建和修改时间,并展示该次评价的操作记录。名称与头像关联现有游戏/账号信息,不复制到评价表。 - 评价表格的评论与操作原因保留换行,清除单元格内段落的默认上下外边距,与其他列顶对齐。 +- 评价列表的状态胶囊保持单行;行内操作按钮与“操作”列头左对齐,不换行、不压缩,避免删除按钮另起一行撑高整条评价。样式仅作用于游戏评价列表,窄屏沿用表格容器横向滚动。 - 行内提供“隐藏”或“恢复”和“删除”。隐藏、删除通过原因输入及确认弹窗提交;原因 trim 后必须为 1–4000 Unicode 码点,不静默截断;恢复沿用现有后台确认交互。删除明确提示“删除后不可恢复,用户可以重新评价”。 - 请求期间禁用对应操作,失败保留原因草稿并显示错误,不先移除记录;成功刷新当前筛选。删除或状态过滤导致当前页越界时回到最后一页,零记录回第 1 页并显示空态。详情打开期间切换目标,不接受旧目标的迟到响应覆盖。 - 管理列表/详情不受游戏公开可见性限制。移动端表格横向滚动、弹窗内正文可滚动,操作按钮可达。 diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index 47d61421f..be4868bcb 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -255,7 +255,9 @@ function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) { fail(`${nginxPath} ${failure}`); } if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) { - fail(`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`); + fail( + `${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`, + ); } if ( exactLocationBody?.includes(maintenanceGuard) && diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index 82ca93cc7..49fee68bd 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -308,10 +308,14 @@ function validateRustMainSpaPrefixPaths() { const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix)); const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix)); if (missing.length > 0) { - fail(`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`); + fail( + `Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`, + ); } if (extra.length > 0) { - fail(`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`); + fail( + `Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`, + ); } } diff --git a/scripts/vite-retired-css-plugin.test.ts b/scripts/vite-retired-css-plugin.test.ts index c20f8be19..0fd215089 100644 --- a/scripts/vite-retired-css-plugin.test.ts +++ b/scripts/vite-retired-css-plugin.test.ts @@ -221,6 +221,7 @@ describe('retired creation template module boundary plugin', () => { 'src/persistence/storage.ts', 'src/routing/activeAppRoutes.tsx', 'src/services/apiClient.ts', + 'src/services/gamePlayClientId.ts', ]) { expect(isRetiredFrontendModuleId(workspacePath(path))).toBe(false); } @@ -271,6 +272,7 @@ describe('retired frontend ESLint boundary', () => { 'src/persistence/storage.ts', 'src/routing/activeAppRoutes.tsx', 'src/services/apiClient.ts', + 'src/services/gamePlayClientId.ts', ]) { await expect(eslint.isPathIgnored(path)).resolves.toBe(false); } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 1b71b2cda..9cb9334c1 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -74,6 +74,8 @@ pub struct AppConfig { pub tracking_outbox_batch_size: usize, pub tracking_outbox_flush_interval: Duration, pub tracking_outbox_max_bytes: u64, + /// 游玩计数内存缓冲的 flush 间隔;写入是批量 procedure,间隔决定展示滞后上限。 + pub game_play_counter_flush_interval: Duration, pub wallet_refund_outbox_enabled: bool, pub wallet_refund_outbox_dir: PathBuf, pub wallet_refund_outbox_batch_size: usize, @@ -374,6 +376,7 @@ impl Default for AppConfig { tracking_outbox_batch_size: 500, tracking_outbox_flush_interval: Duration::from_millis(1_000), tracking_outbox_max_bytes: 256 * 1024 * 1024, + game_play_counter_flush_interval: Duration::from_millis(5_000), wallet_refund_outbox_enabled: true, wallet_refund_outbox_dir: PathBuf::from("server-rs/.data/wallet-refund-outbox"), wallet_refund_outbox_batch_size: 100, @@ -858,6 +861,11 @@ impl AppConfig { { config.tracking_outbox_max_bytes = max_bytes; } + if let Some(flush_interval_ms) = + read_first_positive_u64_env(&["GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS"]) + { + config.game_play_counter_flush_interval = Duration::from_millis(flush_interval_ms); + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"]) { config.wallet_refund_outbox_enabled = enabled; } @@ -2527,6 +2535,7 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); @@ -2546,6 +2555,7 @@ mod tests { std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576"); + std::env::set_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS", "4000"); std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED", "false"); std::env::set_var( "GENARRATIVE_WALLET_REFUND_OUTBOX_DIR", @@ -2577,6 +2587,10 @@ mod tests { std::time::Duration::from_millis(2_000) ); assert_eq!(config.tracking_outbox_max_bytes, 1_048_576); + assert_eq!( + config.game_play_counter_flush_interval, + std::time::Duration::from_millis(4_000) + ); assert!(!config.wallet_refund_outbox_enabled); assert_eq!( config.wallet_refund_outbox_dir, @@ -2601,6 +2615,7 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); diff --git a/server-rs/crates/api-server/src/game_play_counter.rs b/server-rs/crates/api-server/src/game_play_counter.rs new file mode 100644 index 000000000..7fc5b8c46 --- /dev/null +++ b/server-rs/crates/api-server/src/game_play_counter.rs @@ -0,0 +1,473 @@ +//! 游戏游玩次数的进程内聚合缓冲。 +//! +//! 只在内存里累计「开始游戏」上报:按 `identity + game_id` 做去重窗口、按 `IP + game_id` 做固定 +//! 窗口限流,并按 flush 间隔或待落库游戏数上限决定何时把增量交给写库方。 +//! +//! 网络写入不在本模块内:`take_pending_if_due` / `requeue` 让调用方在锁外发起 procedure,锁只覆盖 +//! HashMap 操作。时间点全部由调用方传入 `now`,因此本模块不依赖运行时,可直接单测。 + +use std::{ + collections::HashMap, + sync::{Mutex, MutexGuard}, + time::{Duration, Instant}, +}; + +use tracing::warn; + +/// 默认 flush 间隔;与 `tracking_outbox` 的秒级节奏一致,够短以保证展示及时。 +const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(5); +/// 同一 `identity + game_id` 的去重窗口。 +const DEFAULT_DEDUP_WINDOW: Duration = Duration::from_secs(30 * 60); +/// `IP + game_id` 固定窗口长度。 +const DEFAULT_RATE_WINDOW: Duration = Duration::from_secs(60); +/// 单个 `IP + game_id` 在每个固定窗口内允许的上报次数。 +const DEFAULT_RATE_LIMIT: u32 = 60; +/// 待落库游戏数上限;达到后下一次检查立即 flush,而不是等满一整个间隔。 +const DEFAULT_MAX_PENDING_GAMES: usize = 4096; + +/// 一次上报携带的最小信息。`identity` 由调用方决定:登录用户是 userId,匿名是 clientId, +/// 都拿不到时才回退 `IP + UA`。 +#[derive(Clone, Copy, Debug)] +pub struct GamePlayReport<'a> { + pub game_id: &'a str, + pub identity: &'a str, + pub client_ip: &'a str, +} + +/// 单次上报的判定结果。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GamePlayOutcome { + /// 已计入待落库增量。 + Counted, + /// 落在去重窗口内,未计入。 + Deduped, + /// 超过 `IP + game_id` 固定窗口上限,未计入。 + RateLimited, +} + +/// 待落库的增量;同一 `game_id` 在一个批次内只出现一次。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GamePlayCountDelta { + pub game_id: String, + pub delta: u64, +} + +/// 计数器参数。默认值覆盖决策口径,`flush_interval` 由 `AppConfig` 覆盖。 +#[derive(Clone, Copy, Debug)] +pub struct GamePlayCounterSettings { + pub flush_interval: Duration, + pub dedup_window: Duration, + pub rate_window: Duration, + pub rate_limit: u32, + pub max_pending_games: usize, +} + +impl Default for GamePlayCounterSettings { + fn default() -> Self { + Self { + flush_interval: DEFAULT_FLUSH_INTERVAL, + dedup_window: DEFAULT_DEDUP_WINDOW, + rate_window: DEFAULT_RATE_WINDOW, + rate_limit: DEFAULT_RATE_LIMIT, + max_pending_games: DEFAULT_MAX_PENDING_GAMES, + } + } +} + +/// 游玩计数内存缓冲。 +pub struct GamePlayCounter { + settings: GamePlayCounterSettings, + inner: Mutex, +} + +struct GamePlayCounterState { + /// `game_id -> 待落库增量`;只包含通过校验的上报,键空间由公开游戏目录界定。 + pending: HashMap, + /// `(identity, game_id) -> 最近一次计数时间`。 + seen: HashMap<(String, String), Instant>, + /// `(IP, game_id) -> 当前固定窗口`。 + rate: HashMap<(String, String), RateWindow>, + /// 最近一次真正取走增量的时间,用于判断是否到达 flush 间隔。 + last_flush_at: Instant, +} + +struct RateWindow { + started_at: Instant, + count: u32, +} + +impl GamePlayCounter { + pub fn new(settings: GamePlayCounterSettings, now: Instant) -> Self { + Self { + settings, + inner: Mutex::new(GamePlayCounterState { + pending: HashMap::new(), + seen: HashMap::new(), + rate: HashMap::new(), + last_flush_at: now, + }), + } + } + + pub fn flush_interval(&self) -> Duration { + self.settings.flush_interval + } + + /// 处理一次上报:先去重,再限流,最后累加增量。 + /// + /// 被去重命中的上报不消耗限流额度;限流只挡同一 IP 对同一游戏的超额上报。 + pub fn record(&self, report: GamePlayReport<'_>, now: Instant) -> GamePlayOutcome { + // 键用元组而不是拼接字符串:clientId 来自 JSON,可能包含任意字节(含 U+001F), + // 拼接会产生本不存在的键碰撞。 + let dedup_key = (report.identity.to_string(), report.game_id.to_string()); + let rate_key = (report.client_ip.to_string(), report.game_id.to_string()); + let mut state = self.lock(); + + if let Some(seen_at) = state.seen.get(&dedup_key) + && now.saturating_duration_since(*seen_at) < self.settings.dedup_window + { + return GamePlayOutcome::Deduped; + } + + let rate = state.rate.entry(rate_key).or_insert_with(|| RateWindow { + started_at: now, + count: 0, + }); + if now.saturating_duration_since(rate.started_at) >= self.settings.rate_window { + rate.started_at = now; + rate.count = 0; + } + if rate.count >= self.settings.rate_limit { + return GamePlayOutcome::RateLimited; + } + rate.count = rate.count.saturating_add(1); + + state.seen.insert(dedup_key, now); + let pending = state.pending.entry(report.game_id.to_string()).or_insert(0); + *pending = pending.saturating_add(1); + GamePlayOutcome::Counted + } + + /// 限流预检:只读,不消耗额度、不改任何状态。 + /// + /// 公开上报接口在昂贵的公开可见性查询之前先用它挡掉明显超限的请求;真正计数时 + /// `record` 仍会重新判定一次,所以这里只用于省一次远端查询,不承担正确性。 + pub fn is_rate_limited(&self, game_id: &str, client_ip: &str, now: Instant) -> bool { + let key = (client_ip.to_string(), game_id.to_string()); + let state = self.lock(); + state.rate.get(&key).is_some_and(|window| { + now.saturating_duration_since(window.started_at) < self.settings.rate_window + && window.count >= self.settings.rate_limit + }) + } + + /// 到达 flush 间隔或待落库游戏数达到上限时取走全部增量;否则返回 `None`。 + pub fn take_pending_if_due(&self, now: Instant) -> Option> { + let mut state = self.lock(); + if state.pending.is_empty() { + return None; + } + let interval_elapsed = + now.saturating_duration_since(state.last_flush_at) >= self.settings.flush_interval; + let at_capacity = state.pending.len() >= self.settings.max_pending_games; + if !interval_elapsed && !at_capacity { + return None; + } + state.last_flush_at = now; + let deltas = drain_pending(&mut state.pending); + drop(state); + Some(sort_deltas(deltas)) + } + + /// 无条件取走全部增量;仅供测试使用,生产路径走 `take_pending_if_due`。 + #[cfg(test)] + pub fn take_pending(&self) -> Vec { + let mut state = self.lock(); + let deltas = drain_pending(&mut state.pending); + drop(state); + sort_deltas(deltas) + } + + /// 写库失败时把增量放回,等待下一次 flush。 + pub fn requeue(&self, deltas: &[GamePlayCountDelta]) { + let mut state = self.lock(); + for delta in deltas { + let pending = state.pending.entry(delta.game_id.clone()).or_insert(0); + *pending = pending.saturating_add(delta.delta); + } + } + + /// 清掉过期的去重与限流条目,避免 map 无界增长。 + pub fn prune_expired(&self, now: Instant) { + let mut state = self.lock(); + let dedup_window = self.settings.dedup_window; + let rate_window = self.settings.rate_window; + state + .seen + .retain(|_, seen_at| now.saturating_duration_since(*seen_at) < dedup_window); + state + .rate + .retain(|_, window| now.saturating_duration_since(window.started_at) < rate_window); + } + + #[cfg(test)] + pub fn pending_game_count(&self) -> usize { + self.lock().pending.len() + } + + #[cfg(test)] + pub fn pending_total(&self) -> u64 { + self.lock().pending.values().copied().sum() + } + + fn lock(&self) -> MutexGuard<'_, GamePlayCounterState> { + // 计数是尽力而为的展示指标:锁中毒时继续用内部状态,不让一次 panic 永久关闭计数。 + // 但中毒意味着上一次 panic 可能留下部分更新的状态,必须留下可关联的日志。 + self.inner.lock().unwrap_or_else(|poisoned| { + warn!("游戏游玩计数锁已中毒,继续使用内部状态"); + poisoned.into_inner() + }) + } +} + +fn drain_pending(pending: &mut HashMap) -> Vec { + pending + .drain() + .filter_map(|(game_id, delta)| (delta > 0).then_some(GamePlayCountDelta { game_id, delta })) + .collect() +} + +/// 稳定批次顺序,便于测试与日志比对;调用方已释放计数锁,排序不阻塞并发 `record`。 +fn sort_deltas(mut deltas: Vec) -> Vec { + deltas.sort_by(|left, right| left.game_id.cmp(&right.game_id)); + deltas +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> GamePlayCounterSettings { + GamePlayCounterSettings { + flush_interval: Duration::from_secs(5), + dedup_window: Duration::from_secs(30 * 60), + rate_window: Duration::from_secs(60), + rate_limit: 3, + max_pending_games: 4, + } + } + + fn report<'a>(game_id: &'a str, identity: &'a str, client_ip: &'a str) -> GamePlayReport<'a> { + GamePlayReport { + game_id, + identity, + client_ip, + } + } + + #[test] + fn repeated_report_within_dedup_window_is_deduped() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + assert_eq!( + counter.record(report("g1", "u1", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!( + counter.record( + report("g1", "u1", "1.1.1.1"), + start + Duration::from_secs(60) + ), + GamePlayOutcome::Deduped + ); + assert_eq!(counter.pending_total(), 1); + } + + #[test] + fn dedup_expires_after_window() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + assert_eq!( + counter.record( + report("g1", "u1", "1.1.1.1"), + start + Duration::from_secs(30 * 60) + ), + GamePlayOutcome::Counted + ); + assert_eq!(counter.pending_total(), 2); + } + + #[test] + fn different_identities_count_separately() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + counter.record(report("g1", "u2", "1.1.1.1"), start); + assert_eq!(counter.pending_total(), 2); + assert_eq!(counter.pending_game_count(), 1); + } + + #[test] + fn rate_limit_blocks_excess_and_resets_after_window() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + for index in 0..3 { + assert_eq!( + counter.record(report("g1", &format!("u{index}"), "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + } + assert_eq!( + counter.record(report("g1", "u9", "1.1.1.1"), start), + GamePlayOutcome::RateLimited + ); + + assert_eq!( + counter.record( + report("g1", "u9", "1.1.1.1"), + start + Duration::from_secs(60) + ), + GamePlayOutcome::Counted + ); + } + + #[test] + fn rate_limit_precheck_is_read_only_and_window_scoped() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + assert!(!counter.is_rate_limited("g1", "1.1.1.1", start)); + for index in 0..3 { + counter.record(report("g1", &format!("u{index}"), "1.1.1.1"), start); + } + + // 预检只读:连续调用不消耗额度,也不改变判定。 + assert!(counter.is_rate_limited("g1", "1.1.1.1", start)); + assert!(counter.is_rate_limited("g1", "1.1.1.1", start)); + // 另一个 IP、另一个游戏都不受影响。 + assert!(!counter.is_rate_limited("g1", "2.2.2.2", start)); + assert!(!counter.is_rate_limited("g2", "1.1.1.1", start)); + // 固定窗口结束后恢复。 + assert!(!counter.is_rate_limited("g1", "1.1.1.1", start + Duration::from_secs(60))); + } + + #[test] + fn take_pending_if_due_waits_for_interval_then_drains() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + assert!( + counter + .take_pending_if_due(start + Duration::from_secs(4)) + .is_none() + ); + + let deltas = counter + .take_pending_if_due(start + Duration::from_secs(5)) + .expect("到达间隔后应当取走增量"); + assert_eq!( + deltas, + vec![GamePlayCountDelta { + game_id: "g1".to_string(), + delta: 1, + }] + ); + assert_eq!(counter.pending_total(), 0); + } + + #[test] + fn capacity_triggers_early_flush() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + for index in 0..4u64 { + counter.record( + report(&format!("g{index}"), "u1", "1.1.1.1"), + start + Duration::from_millis(index * 10), + ); + } + let deltas = counter + .take_pending_if_due(start + Duration::from_secs(1)) + .expect("达到待落库游戏数上限应当立即 flush"); + assert_eq!(deltas.len(), 4); + } + + #[test] + fn aggregates_per_game_and_sorts() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g2", "u1", "1.1.1.1"), start); + counter.record(report("g1", "u2", "1.1.1.1"), start); + counter.record(report("g2", "u2", "1.1.1.1"), start); + + let deltas = counter.take_pending(); + assert_eq!( + deltas, + vec![ + GamePlayCountDelta { + game_id: "g1".to_string(), + delta: 1, + }, + GamePlayCountDelta { + game_id: "g2".to_string(), + delta: 2, + }, + ] + ); + } + + #[test] + fn requeue_restores_deltas() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + let deltas = counter.take_pending(); + assert_eq!(counter.pending_total(), 0); + + counter.requeue(&deltas); + assert_eq!(counter.pending_total(), 1); + } + + #[test] + fn prune_expired_drops_old_dedup_and_rate_entries() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + counter.prune_expired(start + Duration::from_secs(30 * 60 + 1)); + + // 去重条目过期后同一身份还能重新计数。 + assert_eq!( + counter.record( + report("g1", "u1", "1.1.1.1"), + start + Duration::from_secs(30 * 60 + 2) + ), + GamePlayOutcome::Counted + ); + } + + #[test] + fn separator_like_bytes_in_key_parts_do_not_collide() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + // 旧实现用 U+001F 拼接 identity/game_id,下面两个不同元组会被拼成同一个键。 + assert_eq!( + counter.record(report("b\u{1f}c", "a", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!( + counter.record(report("c", "a\u{1f}b", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!(counter.pending_total(), 2); + } +} diff --git a/server-rs/crates/api-server/src/game_play_counter_worker.rs b/server-rs/crates/api-server/src/game_play_counter_worker.rs new file mode 100644 index 000000000..44d4148c8 --- /dev/null +++ b/server-rs/crates/api-server/src/game_play_counter_worker.rs @@ -0,0 +1,112 @@ +//! 游玩计数的 flush worker:按间隔把内存增量批量写进 SpacetimeDB。 +//! +//! 失败语义与 ADR 一致:连接还没建起来(`Build`)属于确定未发出,交给计数器放回下一轮重试; +//! 其余错误(`Timeout` / `ConnectDropped` / `Procedure`)无法判断是否已提交,直接丢弃该批并 +//! 记录丢失量,避免系统性双计。 +//! +//! 任一分片失败都会终止本次 flush 的后续分片:连接不通时剩余分片只会重复同样的失败,逐个重试 +//! 会把 worker 卡在多次连接超时上;剩余增量按“直接丢弃”处理,尽快回到 tick。 +//! +//! 进程关停不做强制 flush:内存里剩下的增量随进程结束丢弃,关停路径不为它等待网络。 + +use std::time::{Duration, Instant}; + +use spacetime_client::{GameDistributionPlayCountIncrementRecordInput, SpacetimeClientError}; +use tokio::time::sleep; +use tracing::{info, warn}; + +use crate::{game_play_counter::GamePlayCountDelta, state::AppState}; + +/// 一次 procedure 最多携带多少条游戏增量;只影响帧大小,不影响累计结果。 +pub(crate) const GAME_PLAY_COUNT_FLUSH_BATCH_SIZE: usize = 500; + +/// worker 检查节拍:比默认 flush 间隔细,以便待落库游戏数达到上限时提前 flush。 +const GAME_PLAY_COUNTER_TICK: Duration = Duration::from_secs(1); + +/// 启动 flush worker;只在 HTTP 角色进程注册一次。 +pub(crate) fn spawn_game_play_counter_worker(state: AppState) { + let tick = GAME_PLAY_COUNTER_TICK.min(state.game_play_counter().flush_interval()); + tokio::spawn(async move { + loop { + sleep(tick).await; + let now = Instant::now(); + let counter = state.game_play_counter(); + counter.prune_expired(now); + if let Some(deltas) = counter.take_pending_if_due(now) { + flush_deltas(&state, deltas).await; + } + } + }); +} + +/// 把一批增量写库。 +/// +/// `Build`(确定未发出)放回等待下一轮;其余错误无法判断是否已提交,直接丢弃并记丢失量。 +async fn flush_deltas(state: &AppState, deltas: Vec) { + let mut accepted = 0usize; + let mut dropped = 0u64; + let mut start = 0usize; + while start < deltas.len() { + let end = (start + GAME_PLAY_COUNT_FLUSH_BATCH_SIZE).min(deltas.len()); + let chunk = &deltas[start..end]; + match write_batch(state, chunk).await { + Ok(()) => { + accepted += chunk.len(); + start = end; + } + // 连接没建起来(确定未发出):本批放回,但后面的分片会重复同样的失败, + // 直接丢弃剩余,避免每个分片各等一次连接超时把 worker 卡住。 + Err(SpacetimeClientError::Build(message)) => { + state.game_play_counter().requeue(chunk); + let lost = total_delta(&deltas[end..]); + dropped = dropped.saturating_add(lost); + warn!( + error = %message, + games = chunk.len(), + lost, + "游戏游玩计数写入连接未建立:本批放回等待下一轮,剩余增量直接丢弃" + ); + break; + } + // 结果未知:连本批一起丢弃剩余,避免双计,也不再把 worker 卡在逐个重连上。 + Err(error) => { + let lost = total_delta(&deltas[start..]); + dropped = dropped.saturating_add(lost); + warn!( + error = %error, + games = deltas.len() - start, + lost, + "游戏游玩计数写入失败,剩余增量直接丢弃" + ); + break; + } + } + } + if accepted > 0 { + info!(games = accepted, "游戏游玩计数已批量落库"); + } + if dropped > 0 { + warn!(dropped, "游戏游玩计数存在丢弃量"); + } +} + +fn total_delta(deltas: &[GamePlayCountDelta]) -> u64 { + deltas.iter().map(|delta| delta.delta).sum() +} + +async fn write_batch( + state: &AppState, + deltas: &[GamePlayCountDelta], +) -> Result<(), SpacetimeClientError> { + let increments = deltas + .iter() + .map(|delta| GameDistributionPlayCountIncrementRecordInput { + game_id: delta.game_id.clone(), + delta: delta.delta, + }) + .collect(); + state + .spacetime_client() + .increment_game_distribution_game_play_counts(increments) + .await +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 477b7df9c..06dc057a1 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -48,6 +48,8 @@ mod external_generation_worker_controller; mod external_mcp; mod external_skill_api; mod frontend_runtime_config; +mod game_play_counter; +mod game_play_counter_worker; mod generated_image_assets; mod health; mod http_error; @@ -700,6 +702,7 @@ fn spawn_http_app_state_background_workers(state: &AppState, process_role: Proce crate::payment_webhook::PaymentWebhookWorker::new(state.spacetime_client().clone()) .spawn_worker(); crate::error_reports::spawn_cleanup_worker(state.clone()); + crate::game_play_counter_worker::spawn_game_play_counter_worker(state.clone()); if should_start_profile_recharge_expiration_listener(process_role) { spawn_profile_recharge_expiration_listener(state.clone()); spawn_profile_recharge_refund_reconciliation_worker(state.clone()); diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index a892127b7..184afb343 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1,7 +1,7 @@ use std::{ collections::{BTreeMap, HashMap, VecDeque}, sync::{Arc, Mutex, OnceLock}, - time::{SystemTime, UNIX_EPOCH}, + time::{Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -62,10 +62,11 @@ use uuid::Uuid; use crate::{ admin::{AuthenticatedAdmin, require_admin_auth}, api_response::json_success_body, - auth::{AuthenticatedAccessToken, require_bearer_auth}, + auth::{AuthenticatedAccessToken, optional_access_token_from_headers, require_bearer_auth}, + game_play_counter::{GamePlayOutcome, GamePlayReport}, http_error::AppError, platform_errors::{map_llm_error, map_oss_error}, - request_context::RequestContext, + request_context::{RequestContext, client_ip_from_headers}, state::AppState, }; @@ -344,6 +345,10 @@ pub fn router(state: AppState) -> Router { let public_games = Router::new() .route("/api/game-distribution/games", get(list_games)) .route("/api/game-distribution/games/{game_id}", get(get_game)) + .route( + "/api/game-distribution/games/{game_id}/plays", + post(record_game_play), + ) .route_layer(middleware::from_fn(add_no_store_response_headers)); Router::new() @@ -931,6 +936,112 @@ async fn get_game( Ok(json_success_body(Some(&ctx), public_game_payload(game))) } +/// 一次游玩上报的请求体;只有匿名身份需要 `clientId`,登录身份由 bearer 决定。 +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RecordGamePlayRequest { + #[serde(default)] + client_id: Option, +} + +/// 记录一次「开始游戏」。 +/// +/// 公开端点:登录用户按 `userId` 去重,匿名按 `clientId`(缺失时回退 `IP + UA`)去重; +/// 命中 30 分钟去重窗口或超过 `IP + game` 限流时不增加计数。计数只进内存缓冲, +/// 立即返回 `recorded`,任何失败都不影响游玩本身。 +async fn record_game_play( + State(state): State, + Extension(ctx): Extension, + Path(game_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, AppError> { + let game_id = game_id.trim().to_string(); + if game_id.is_empty() { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + + let client_ip = client_ip_from_headers(&headers); + // 先在内存里挡掉明显超限的请求,避免它们也去打一次 SpacetimeDB;真正计数时 record 会再判一次。 + if state + .game_play_counter() + .is_rate_limited(&game_id, &client_ip, Instant::now()) + { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)); + } + + // 非公开 / 已下架 / 已暂停的游戏不计数,按不存在返回。 + let is_public = state + .spacetime_client() + .get_public_game_distribution_game(game_id.clone()) + .await + .map_err(map_spacetime_error)? + .is_some(); + if !is_public { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + + let user_agent = user_agent_tag(&headers); + let authenticated = optional_access_token_from_headers( + &state, + format!("/api/game-distribution/games/{game_id}/plays"), + headers, + ctx.request_id().to_string(), + ) + .await + .unwrap_or_else(|error| { + // 可选 bearer:无效 token 按匿名处理,绝不能因为它挡掉一次真实游玩。 + debug!(error = %error, "游戏游玩计数忽略无效 bearer,按匿名计数"); + None + }); + let identity = authenticated + .as_ref() + .map(|token| format!("user:{}", token.claims().user_id())) + .or_else(|| request_client_id(&body).map(|client_id| format!("client:{client_id}"))) + .unwrap_or_else(|| format!("ip:{client_ip}|ua:{user_agent}")); + + let outcome = state.game_play_counter().record( + GamePlayReport { + game_id: &game_id, + identity: &identity, + client_ip: &client_ip, + }, + Instant::now(), + ); + if outcome == GamePlayOutcome::RateLimited { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)); + } + Ok(json_success_body( + Some(&ctx), + json!({ "recorded": outcome == GamePlayOutcome::Counted }), + )) +} + +fn request_client_id(body: &Bytes) -> Option { + if body.is_empty() { + return None; + } + let request = serde_json::from_slice::(body).ok()?; + request + .client_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(128).collect()) +} + +fn user_agent_tag(headers: &HeaderMap) -> String { + headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown") + .chars() + .take(64) + .collect() +} + /// 作者自有游戏列表:只返回当前认证主体名下的游戏与最近版本状态。 async fn list_my_games( State(state): State, @@ -4076,4 +4187,53 @@ mod tests { assert_eq!(generic.summary, "一款由陶泥儿创作的益智游戏"); assert_eq!(generic.category, "益智"); } + + #[tokio::test] + async fn game_play_route_is_public_and_no_store_without_spacetime_connection() { + use axum::http::Request; + use tower::ServiceExt; + + let app = + crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/game-distribution/games/game_1/plays") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"clientId":"client-1"}"#)) + .unwrap(), + ) + .await + .unwrap(); + // 未连接 SpacetimeDB 时公开可见性读取失败,但路由可达且不需要登录;只有确认公开后才计数。 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + + #[test] + fn play_request_client_id_trims_limits_and_rejects_blank() { + assert_eq!(request_client_id(&Bytes::from_static(b"")), None); + assert_eq!(request_client_id(&Bytes::from_static(b"not json")), None); + assert_eq!(request_client_id(&Bytes::from_static(b"{}")), None); + assert_eq!( + request_client_id(&Bytes::from_static(br#"{"clientId":" abc "}"#)), + Some("abc".to_string()) + ); + assert_eq!( + request_client_id(&Bytes::from_static(br#"{"clientId":" "}"#)), + None + ); + let long = "x".repeat(200); + let body = Bytes::from(format!(r#"{{"clientId":"{long}"}}"#)); + assert_eq!(request_client_id(&body).unwrap().chars().count(), 128); + } + + #[test] + fn play_report_user_agent_is_bounded_and_falls_back() { + assert_eq!(user_agent_tag(&HeaderMap::new()), "unknown"); + let mut headers = HeaderMap::new(); + headers.insert(header::USER_AGENT, " test-agent ".parse().unwrap()); + assert_eq!(user_agent_tag(&headers), "test-agent"); + } } diff --git a/server-rs/crates/api-server/src/request_context.rs b/server-rs/crates/api-server/src/request_context.rs index 57a109d28..9a1f58743 100644 --- a/server-rs/crates/api-server/src/request_context.rs +++ b/server-rs/crates/api-server/src/request_context.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; use axum::{ extract::Request, - http::{HeaderValue, Request as HttpRequest, header::HeaderName}, + http::{HeaderMap, HeaderValue, Request as HttpRequest, header::HeaderName}, middleware::Next, response::Response, }; @@ -107,6 +107,29 @@ pub async fn attach_request_context(mut request: Request, next: Next) -> Respons .await } +/// 从代理头解析客户端 IP。 +/// +/// 优先 `x-real-ip`:nginx 用 `$remote_addr` 覆盖写入,是真实 TCP 对端,调用方无法伪造。 +/// `x-forwarded-for` 只作回退,并取**最后一段**——nginx 用 `$proxy_add_x_forwarded_for` 会把真实 +/// 对端追加在末尾,前面几段是调用方自带的、可伪造。两者都拿不到时才兜底回环地址。 +pub fn client_ip_from_headers(headers: &HeaderMap) -> String { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.rsplit(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .unwrap_or("127.0.0.1") + .to_string() +} + pub fn resolve_request_id(request: &HttpRequest) -> Option { request .extensions() @@ -148,4 +171,35 @@ mod tests { assert_eq!(context.external_call_deadline(), None); } + + #[test] + fn client_ip_prefers_real_ip_over_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("203.0.113.7, 10.0.0.1"), + ); + headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.9")); + assert_eq!(client_ip_from_headers(&headers), "198.51.100.9"); + } + + #[test] + fn client_ip_forwarded_for_fallback_uses_last_address() { + // nginx 把真实对端追加在末尾,前面是调用方可伪造的值,只能取最后一段。 + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("203.0.113.7, 10.0.0.1"), + ); + assert_eq!(client_ip_from_headers(&headers), "10.0.0.1"); + } + + #[test] + fn client_ip_ignores_blank_real_ip_and_falls_back_then_loopback() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", HeaderValue::from_static(" ")); + headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1")); + assert_eq!(client_ip_from_headers(&headers), "10.0.0.1"); + assert_eq!(client_ip_from_headers(&HeaderMap::new()), "127.0.0.1"); + } } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index db27bb20f..5f8f96d78 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1632,21 +1632,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool { } fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String { - headers - .get("x-forwarded-for") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.split(',').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) - }) - .unwrap_or("127.0.0.1") - .to_string() + crate::request_context::client_ip_from_headers(headers) } async fn resolve_wechat_identity_for_payment( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index a7b970bfc..e89cb4829 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -45,6 +45,7 @@ use crate::editor_generation_config::{ EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, EditorGenerationPricingUnit, }; +use crate::game_play_counter::{GamePlayCounter, GamePlayCounterSettings}; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; @@ -312,6 +313,8 @@ pub struct AppStateInner { tracking_outbox: Option>, wallet_refund_outbox: Option>, profile_wallet_refund_outbox_worker: Arc, + /// 游玩计数的进程内聚合缓冲;写入由 `game_play_counter_worker` 负责。 + game_play_counter: GamePlayCounter, editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, vector_engine_llm_client: Option, @@ -617,6 +620,13 @@ impl AppState { WalletRefundOutbox::from_config(&config, spacetime_client.clone()); let profile_wallet_refund_outbox_worker = ProfileWalletRefundOutboxWorker::from_config(&config, spacetime_client.clone()); + let game_play_counter = GamePlayCounter::new( + GamePlayCounterSettings { + flush_interval: config.game_play_counter_flush_interval, + ..GamePlayCounterSettings::default() + }, + std::time::Instant::now(), + ); let editor_generation_pricing_store = EditorGenerationPricingStore::load( config.editor_generation_pricing_override_path.clone(), ) @@ -713,6 +723,7 @@ impl AppState { tracking_outbox, wallet_refund_outbox, profile_wallet_refund_outbox_worker, + game_play_counter, editor_generation_pricing_store, llm_client, vector_engine_llm_client, @@ -1691,6 +1702,10 @@ impl AppState { self.profile_wallet_refund_outbox_worker.clone() } + pub fn game_play_counter(&self) -> &GamePlayCounter { + &self.game_play_counter + } + pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 7a1651ae4..db4068181 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -27,10 +27,11 @@ pub use game_distribution::{ GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput, GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, - GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, - GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, - GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, - GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, + GameDistributionPlayCountIncrementRecordInput, GameDistributionPublicGameListRecordInput, + GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, + GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionUnpublishRecordInput, }; #[path = "active/external_generation.rs"] pub mod external_generation; diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 6e45c41a1..7e293a8f1 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -51,6 +51,13 @@ pub struct GameDistributionGetGameRecordInput { pub owner_user_id: Option, } +/// 单次游玩计数增量;同一批次内每个 `game_id` 只出现一次。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionPlayCountIncrementRecordInput { + pub game_id: String, + pub delta: u64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct GameDistributionCreateGameRecordInput { pub game_id: String, @@ -560,6 +567,49 @@ impl SpacetimeClient { .await } + /// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。 + pub async fn increment_game_distribution_game_play_counts( + &self, + increments: Vec, + ) -> Result<(), SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionPlayCountIncrementInput { + increments: increments + .into_iter() + .map( + |increment| crate::module_bindings::GameDistributionPlayCountIncrement { + game_id: increment.game_id, + delta: increment.delta, + }, + ) + .collect(), + }; + self.call_after_connect( + "increment_game_distribution_game_play_counts", + move |connection, sender| { + connection + .procedures() + .increment_game_distribution_game_play_counts_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(|result| { + if result.ok { + Ok(()) + } else { + Err(SpacetimeClientError::procedure_failed( + result.error_message, + )) + } + }); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_game_distribution_game( &self, input: GameDistributionCreateGameRecordInput, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 805d286be..aa38eeb65 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -433,6 +433,8 @@ pub mod game_distribution_owner_game_list_input_type; pub mod game_distribution_owner_game_list_procedure_result_type; pub mod game_distribution_owner_game_snapshot_type; pub mod game_distribution_owner_version_input_type; +pub mod game_distribution_play_count_increment_input_type; +pub mod game_distribution_play_count_increment_type; pub mod game_distribution_procedure_result_type; pub mod game_distribution_public_game_input_type; pub mod game_distribution_public_game_list_input_type; @@ -501,6 +503,7 @@ pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_file_procedure; pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_file_procedure; +pub mod increment_game_distribution_game_play_counts_and_return_procedure; pub mod initialize_editor_generation_pricing_config_if_missing_and_return_procedure; pub mod list_admin_accounts_and_return_procedure; pub mod list_admin_game_distribution_games_and_return_procedure; @@ -1287,6 +1290,8 @@ pub use game_distribution_owner_game_list_input_type::GameDistributionOwnerGameL pub use game_distribution_owner_game_list_procedure_result_type::GameDistributionOwnerGameListProcedureResult; pub use game_distribution_owner_game_snapshot_type::GameDistributionOwnerGameSnapshot; pub use game_distribution_owner_version_input_type::GameDistributionOwnerVersionInput; +pub use game_distribution_play_count_increment_input_type::GameDistributionPlayCountIncrementInput; +pub use game_distribution_play_count_increment_type::GameDistributionPlayCountIncrement; pub use game_distribution_procedure_result_type::GameDistributionProcedureResult; pub use game_distribution_public_game_input_type::GameDistributionPublicGameInput; pub use game_distribution_public_game_list_input_type::GameDistributionPublicGameListInput; @@ -1355,6 +1360,7 @@ pub use import_database_migration_from_chunks_procedure::import_database_migrati pub use import_database_migration_from_file_procedure::import_database_migration_from_file; pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks; pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file; +pub use increment_game_distribution_game_play_counts_and_return_procedure::increment_game_distribution_game_play_counts_and_return; pub use initialize_editor_generation_pricing_config_if_missing_and_return_procedure::initialize_editor_generation_pricing_config_if_missing_and_return; pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return; pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return; @@ -4143,19 +4149,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, - > + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, +> { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, -> RemoteDbContext for Ctx + > RemoteDbContext for Ctx { } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_input_type.rs new file mode 100644 index 000000000..bad79b6bc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_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}; + +use super::game_distribution_play_count_increment_type::GameDistributionPlayCountIncrement; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionPlayCountIncrementInput { + pub increments: Vec, +} + +impl __sdk::InModule for GameDistributionPlayCountIncrementInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_type.rs new file mode 100644 index 000000000..04cc33724 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_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 GameDistributionPlayCountIncrement { + pub game_id: String, + pub delta: u64, +} + +impl __sdk::InModule for GameDistributionPlayCountIncrement { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs index fc6340e6e..644d782d8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait get_admin_game_distribution_user_review_and_return { input: GameDistributionAdminUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl get_admin_game_distribution_user_review_and_return for super::RemoteProcedu input: GameDistributionAdminUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs index bc951678a..19fe4595c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_editor_asset_folder_and_return { input: EditorAssetFolderGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_editor_asset_folder_and_return for super::RemoteProcedures { input: EditorAssetFolderGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs index 5d41df632..035b88be5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_game_distribution_my_review_and_return { input: GameDistributionMyReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_game_distribution_my_review_and_return for super::RemoteProcedures { input: GameDistributionMyReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/increment_game_distribution_game_play_counts_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/increment_game_distribution_game_play_counts_and_return_procedure.rs new file mode 100644 index 000000000..58877ba24 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/increment_game_distribution_game_play_counts_and_return_procedure.rs @@ -0,0 +1,62 @@ +// 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::game_distribution_play_count_increment_input_type::GameDistributionPlayCountIncrementInput; +use super::game_distribution_procedure_result_type::GameDistributionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct IncrementGameDistributionGamePlayCountsAndReturnArgs { + pub input: GameDistributionPlayCountIncrementInput, +} + +impl __sdk::InModule for IncrementGameDistributionGamePlayCountsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `increment_game_distribution_game_play_counts_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait increment_game_distribution_game_play_counts_and_return { + fn increment_game_distribution_game_play_counts_and_return( + &self, + input: GameDistributionPlayCountIncrementInput, + ) { + self.increment_game_distribution_game_play_counts_and_return_then(input, |_, _| {}); + } + + fn increment_game_distribution_game_play_counts_and_return_then( + &self, + input: GameDistributionPlayCountIncrementInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl increment_game_distribution_game_play_counts_and_return for super::RemoteProcedures { + fn increment_game_distribution_game_play_counts_and_return_then( + &self, + input: GameDistributionPlayCountIncrementInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionProcedureResult>( + "increment_game_distribution_game_play_counts_and_return", + IncrementGameDistributionGamePlayCountsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs index 854482048..0b1536fb3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_admin_game_distribution_user_reviews_and_return { input: GameDistributionAdminUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_admin_game_distribution_user_reviews_and_return for super::RemoteProce input: GameDistributionAdminUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs index 6244fd337..5daaa8425 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs @@ -28,10 +28,10 @@ pub trait list_agc_tracking_events { query_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ); } @@ -41,10 +41,10 @@ impl list_agc_tracking_events for super::RemoteProcedures { query_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, Result>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs index 7843651e5..2d2b893c0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_game_distribution_review_games_and_return { input: GameDistributionReviewGameListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_game_distribution_review_games_and_return for super::RemoteProcedures input: GameDistributionReviewGameListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs index 253592e7e..1abc81320 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_game_distribution_user_reviews_and_return { input: GameDistributionUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_game_distribution_user_reviews_and_return for super::RemoteProcedures input: GameDistributionUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs index 57af37e0d..071918ac1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait moderate_game_distribution_user_review_and_return { input: GameDistributionReviewModerationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl moderate_game_distribution_user_review_and_return for super::RemoteProcedur input: GameDistributionReviewModerationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs index 5e331c484..271415f65 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait save_game_distribution_my_review_and_return { input: GameDistributionUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl save_game_distribution_my_review_and_return for super::RemoteProcedures { input: GameDistributionUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs index c6fb47968..cc583dde6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs @@ -28,10 +28,10 @@ pub trait upload_agc_analytics_batch { payload_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ); } @@ -41,10 +41,10 @@ impl upload_agc_analytics_batch for super::RemoteProcedures { payload_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, Result>( diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 1d4a59cde..9c0ba70a5 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1062,6 +1062,19 @@ pub struct GameDistributionPublicGameInput { pub game_id: String, } +/// 单次游玩计数增量;api-server 已按 `game_id` 聚合,同一输入内不重复。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionPlayCountIncrement { + pub game_id: String, + pub delta: u64, +} + +/// 一次 flush 的批量游玩计数增量。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionPlayCountIncrementInput { + pub increments: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionOwnerVersionInput { pub owner_user_id: String, @@ -1494,6 +1507,34 @@ pub fn restore_game_distribution_game_and_return( } } +/// 批量累加已公开游戏的游玩次数。 +/// +/// 只对当前 `published` 且存在有效 `active_version_id` 的游戏生效,非公开游戏静默跳过; +/// 自增不修改 `updated_at`,避免每次游玩重排作者自有列表。 +#[spacetimedb::procedure] +pub fn increment_game_distribution_game_play_counts_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionPlayCountIncrementInput, +) -> GameDistributionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + increment_game_distribution_game_play_counts_tx(tx, input.clone()) + }) { + Ok(()) => GameDistributionProcedureResult { + ok: true, + replayed: false, + game: None, + games: Vec::new(), + version: None, + versions: Vec::new(), + next_cursor: None, + error_message: None, + }, + Err(error) => game_distribution_result_error(error), + } +} + /// 返回作者名下的游戏与最近版本;owner 来自 api-server 的认证主体,调用方不能指定他人。 #[spacetimedb::procedure] pub fn list_owner_game_distribution_games_and_return( @@ -2771,6 +2812,36 @@ fn restore_game_distribution_game_tx( Ok((game_distribution_game_snapshot(&game), false)) } +/// 批量累加游玩次数;只作用于当前公开且有有效公开版本的游戏。 +/// +/// 同一 `game_id` 的多次增量由 api-server 侧先聚合;这里仍逐条 `find`,对同一行的 +/// 多条增量按顺序 `saturating_add`,因此聚合与否都不影响结果。 +fn increment_game_distribution_game_play_counts_tx( + ctx: &ReducerContext, + input: GameDistributionPlayCountIncrementInput, +) -> Result<(), String> { + for increment in input.increments { + if increment.delta == 0 { + continue; + } + let game_id = match normalize_game_distribution_optional(increment.game_id) { + Some(game_id) => game_id, + None => continue, + }; + let Some(mut game) = ctx.db.game_distribution_game().game_id().find(&game_id) else { + continue; + }; + if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED + || game.active_version_id.is_none() + { + continue; + } + game.play_count = game.play_count.saturating_add(increment.delta); + ctx.db.game_distribution_game().game_id().update(game); + } + Ok(()) +} + fn list_admin_game_distribution_games_tx( ctx: &ReducerContext, input: GameDistributionAdminGameListInput, diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 879a4b223..471613f14 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -18,11 +18,13 @@ import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage'; const listGamesMock = vi.hoisted(() => vi.fn()); const getGameMock = vi.hoisted(() => vi.fn()); +const recordGamePlayMock = vi.hoisted(() => vi.fn()); const resolvedAssetUrlMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, getGame: getGameMock, + recordGamePlay: recordGamePlayMock, listGameReviews: vi.fn().mockResolvedValue({ reviews: [], page: 1, @@ -114,6 +116,7 @@ beforeEach(() => { setViewportWidth(1024); listGamesMock.mockResolvedValue([]); getGameMock.mockResolvedValue(createGame()); + recordGamePlayMock.mockResolvedValue(undefined); resolvedAssetUrlMock.mockImplementation((objectKey: string) => objectKey ? `https://assets.example.test/${objectKey}` : '', ); @@ -229,6 +232,7 @@ describe('GamePlayPage', () => { fireEvent.click(screen.getByRole('button', { name: /开始游戏/u })); const iframe = await screen.findByTitle('星轨防线 在线游玩'); expect(iframe.getAttribute('sandbox')).toBe('allow-scripts'); + expect(recordGamePlayMock).toHaveBeenCalledWith('game-1'); }); it('rejects an unsafe release entry URL before rendering an iframe', async () => { diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index ecd1df9d5..f4f1efebd 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { type GameDistributionGame, getGame, + recordGamePlay, } from '../../services/gameDistributionClient'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; @@ -105,6 +106,11 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport; const startGame = () => { if (mobilePlaybackBlocked) return; + // 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。 + const playGameId = game?.id ?? normalizeGameId(gameId); + if (playGameId) { + void recordGamePlay(playGameId).catch(() => {}); + } setHasStarted(true); setStartupState('loading'); setFrameRunId((runId) => runId + 1); diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 67318f59b..3f85160dc 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -11,6 +11,7 @@ import { listGameReviews, listGames, listMyGames, + recordGamePlay, saveMyGameReview, unpublishGame, uploadGamePackage, @@ -83,6 +84,38 @@ describe('gameDistributionClient', () => { expect(await getGame('')).toBeNull(); }); + it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ recorded: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await recordGamePlay('game-1'); + await recordGamePlay('game-1'); + + const playCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes('/plays'), + ); + expect(playCalls).toHaveLength(2); + expect(playCalls[0]?.[0]).toBe('/api/game-distribution/games/game-1/plays'); + expect(playCalls[0]?.[1]).toMatchObject({ + method: 'POST', + cache: 'no-store', + }); + expect(playCalls[0]?.[1]?.headers?.Authorization).toBeUndefined(); + const clientIds = playCalls.map( + ([, init]) => + (JSON.parse(String(init?.body)) as { clientId?: string }).clientId, + ); + expect(clientIds[0]).toBeTruthy(); + expect(clientIds).toEqual([clientIds[0], clientIds[0]]); + }); + it('版本创建携带幂等键并保留后端失败状态', async () => { setStoredAccessToken('test-access-token', { emit: false }); vi.stubGlobal( diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index a1b7567c9..dfb89898a 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -16,8 +16,10 @@ import type { } from '../../packages/shared/src/contracts/gameDistribution'; import { type ApiRequestOptions, + BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson as requestApiJson, } from './apiClient'; +import { getGamePlayClientId } from './gamePlayClientId'; export type { GameDistributionAuthor, @@ -67,6 +69,11 @@ const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = { clearAuthOnUnauthorized: false, }; +/** + * 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401 + * 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。 + */ + async function requestJson( path: string, init: RequestInit, @@ -171,6 +178,26 @@ export async function getGame(gameId: string) { ); } +/** + * 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。 + */ +export function recordGamePlay( + gameId: string, +): Promise<{ recorded?: boolean }> { + const normalizedId = gameId.trim(); + if (!normalizedId) return Promise.resolve({ recorded: false }); + return requestJson<{ recorded?: boolean }>( + `/games/${encodeURIComponent(normalizedId)}/plays`, + { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({ clientId: getGamePlayClientId() }), + }, + '游玩上报失败', + BACKGROUND_AUTH_REQUEST_OPTIONS, + ); +} + export function listGameReviews(gameId: string, page = 1) { return requestJson( `/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`, diff --git a/src/services/gamePlayClientId.ts b/src/services/gamePlayClientId.ts new file mode 100644 index 000000000..c56c4b732 --- /dev/null +++ b/src/services/gamePlayClientId.ts @@ -0,0 +1,43 @@ +/** + * 匿名游玩身份。 + * + * 登录用户由后端从 bearer 取 `userId`,匿名用户只能靠前端持久一个随机 `clientId`。这里把 + * 生成、读取与降级收在一个小模块里:`localStorage` 不可用(隐私模式、SSR、被禁用)时退化为 + * 当前会话内存值,保证一次会话内多次点击仍去重。 + */ + +const GAME_PLAY_CLIENT_ID_STORAGE_KEY = 'genarrative.game-play.client-id.v1'; + +let cachedClientId: string | null = null; + +/** 返回稳定的匿名游玩身份;同一标签页内重复调用返回同一个值。 */ +export function getGamePlayClientId(): string { + if (cachedClientId) return cachedClientId; + + try { + const stored = window.localStorage + .getItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY) + ?.trim(); + if (stored) { + cachedClientId = stored; + return stored; + } + const created = createGamePlayClientId(); + window.localStorage.setItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY, created); + cachedClientId = created; + return created; + } catch { + cachedClientId = cachedClientId ?? createGamePlayClientId(); + return cachedClientId; + } +} + +function createGamePlayClientId(): string { + if ( + typeof crypto !== 'undefined' && + typeof crypto.randomUUID === 'function' + ) { + return crypto.randomUUID(); + } + return `gp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/vite.config.ts b/vite.config.ts index 142f05851..deaae8dd9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,6 +88,7 @@ const ACTIVE_TOP_LEVEL_SOURCE_MODULES = new Map>([ 'clipboard.ts', 'frontendRuntimeConfigService.ts', 'gameDistributionClient.ts', + 'gamePlayClientId.ts', 'sseStream.ts', ]), ],