合并 origin/master(33552d2ed):文档冲突两边条目都保留
- 冲突仅在 docs/project-memory/shared-memory/decision-log.md 与 pitfalls.md(双方都在文件顶部追加最新条目)。 - 解决方式:两边条目全部保留——本分支的 Issue 599 条目(AI 项目命名解耦 / 生成任务账本 taskType)在前,master 侧的新条目(#604 游戏游玩次数两条、#602 AGC 画布引用、AGC 渠道更新两条)原样保留,逐条核对无丢失。
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
<AdminShell
|
||||
admin={admin}
|
||||
routeId="dashboard"
|
||||
routes={adminRoutes}
|
||||
onLogout={vi.fn()}
|
||||
onRouteChange={onRouteChange}
|
||||
>
|
||||
<div>页面内容</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AdminShell
|
||||
admin={admin}
|
||||
routeId="tracking"
|
||||
routes={adminRoutes}
|
||||
onLogout={vi.fn()}
|
||||
onRouteChange={onRouteChange}
|
||||
>
|
||||
<div>页面内容</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
expect(
|
||||
within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!)
|
||||
.getByRole('button', { name: /数据与监控/ })
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
});
|
||||
@@ -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<AdminNavGroupId>
|
||||
>(() => {
|
||||
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 (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
aria-current={route.id === routeId ? 'page' : undefined}
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
@@ -86,20 +153,35 @@ export function AdminShell({
|
||||
</div>
|
||||
|
||||
<nav className="admin-nav" aria-label="后台导航">
|
||||
{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 (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
<div
|
||||
className="admin-nav-group"
|
||||
data-collapsed={isCollapsed}
|
||||
key={group.id}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls={groupItemsId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="admin-nav-group-toggle"
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
<span>{group.label}</span>
|
||||
<small aria-hidden="true">{group.routes.length}</small>
|
||||
</button>
|
||||
<div
|
||||
className="admin-nav-group-items"
|
||||
data-collapsed={isCollapsed}
|
||||
hidden={isCollapsed}
|
||||
id={groupItemsId}
|
||||
>
|
||||
{group.routes.map(renderRouteButton)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -290,6 +290,7 @@ export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) {
|
||||
</AdminPanel>
|
||||
<AdminListPanel
|
||||
title="评价列表"
|
||||
tableClassName="admin-game-reviews-table"
|
||||
busy={loading}
|
||||
busyText="正在加载评价…"
|
||||
busyVariant="line"
|
||||
|
||||
@@ -289,7 +289,66 @@ button:disabled {
|
||||
|
||||
.admin-nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-nav-group {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0 8px;
|
||||
color: #a08472;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle svg {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.admin-nav-group[data-collapsed='true'] .admin-nav-group-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle small {
|
||||
min-width: 18px;
|
||||
margin-left: auto;
|
||||
border-radius: 999px;
|
||||
padding: 2px 5px;
|
||||
color: #b18d76;
|
||||
background: #faf0e8;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle:hover,
|
||||
.admin-nav-group-toggle:focus-visible {
|
||||
color: #8f3f27;
|
||||
background: #fff8f2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-nav-group-items {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-group-items[data-collapsed='true'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-nav-button,
|
||||
@@ -305,8 +364,8 @@ button:disabled {
|
||||
.admin-nav-button {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px 0 28px;
|
||||
color: #755a49;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -787,6 +846,20 @@ button:disabled {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table .admin-status {
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table td:last-child .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table td:last-child .admin-action-row > button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-review-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` 合并,用来守住"渠道配置不得丢窗口契约"。
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<ResourceReferenceInsertEventDetail>)
|
||||
.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<ResourceReferenceInsertManyEventDetail>
|
||||
).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,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+30
-1
@@ -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<DirectProjectComposerHandle | null>(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 (
|
||||
<section
|
||||
className="project-chat-surface is-direct-codex"
|
||||
@@ -252,6 +280,7 @@ export function DirectProjectChatView({
|
||||
</div>
|
||||
) : null}
|
||||
<DirectProjectComposer
|
||||
ref={composerHandleRef}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath}
|
||||
|
||||
+43
-2
@@ -1,10 +1,18 @@
|
||||
import { ArrowUp, AtSign } from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameIterationVersion,
|
||||
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ActiveChatComposerHandle } from '../../../../../features/project-workspace/activeChatComposer';
|
||||
import {
|
||||
ConversationModelSelect,
|
||||
type ConversationModelSelectHandle,
|
||||
@@ -21,7 +29,10 @@ import {
|
||||
ResourceReferencePicker,
|
||||
type ResourceReferencePickerHandle,
|
||||
} from '../../../../../features/project-workspace/ResourceReferencePicker';
|
||||
import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences';
|
||||
import {
|
||||
assetsSignature,
|
||||
type ChatReference,
|
||||
} from '../../../../../features/project-workspace/resourceReferences';
|
||||
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
|
||||
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
|
||||
import type { UserContentPart } from '../../generated/UserContentPart';
|
||||
@@ -40,6 +51,12 @@ function draftAttachmentCount(draft: { content: UserContentPart[] }) {
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入区对外的命令式出口:就是「活跃聊天输入区」注册表要的那两件事
|
||||
* (见 `features/project-workspace/activeChatComposer.ts`)。
|
||||
*/
|
||||
export type DirectProjectComposerHandle = ActiveChatComposerHandle;
|
||||
|
||||
/**
|
||||
* DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。
|
||||
*
|
||||
@@ -62,6 +79,7 @@ export function DirectProjectComposer({
|
||||
onReferencePickerOpen,
|
||||
onSubmit,
|
||||
onUploadFiles,
|
||||
ref,
|
||||
}: {
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
versions?: GameIterationVersion[];
|
||||
@@ -107,6 +125,11 @@ export function DirectProjectComposer({
|
||||
files: readonly File[],
|
||||
draftAttachmentCount: number,
|
||||
) => Promise<DirectCodexTurnAttachment[]>;
|
||||
/**
|
||||
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
|
||||
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
|
||||
*/
|
||||
ref?: Ref<DirectProjectComposerHandle>;
|
||||
}) {
|
||||
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
|
||||
const composerRootRef = useRef<HTMLDivElement | null>(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 = (
|
||||
|
||||
+19
-1
@@ -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<HTMLInputElement>(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**。
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<React.ComponentProps<typeof App>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 历史夹具沿用的旧 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<string, unknown>) => {
|
||||
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<HTMLElement>(
|
||||
'form.project-chat-composer [data-resource-reference-id]',
|
||||
),
|
||||
).map((chip) => chip.getAttribute('data-resource-reference-id'));
|
||||
const composerEditor = () =>
|
||||
document.querySelector<HTMLElement>(
|
||||
'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]',
|
||||
)!;
|
||||
// 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。
|
||||
const firstChipLabel = () =>
|
||||
document.querySelector<HTMLElement>(
|
||||
'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 () => {
|
||||
|
||||
@@ -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<React.ComponentProps<typeof App>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 「拖动素材到对话 = 批量 @ 引用」的行为级验收。
|
||||
*
|
||||
@@ -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<string, ProjectResourceCanvasPosition[]>();
|
||||
const revisions = new Map<string, number>();
|
||||
// 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答,
|
||||
// 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath: CANVAS_PROJECT_PATH,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
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`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」,
|
||||
// 所以这里不能再拿 `<div>项目总控</div>` 顶替。
|
||||
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<HTMLElement>('.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<HTMLElement>(
|
||||
'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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 契约唯一机器可读来源。
|
||||
|
||||
@@ -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<gameId, u64>`(5 秒级、flush 即清)与 30 分钟去重窗口表 `seen:
|
||||
HashMap<identity + gameId, timestamp>`(30 分钟级、按 TTL 清理)。两者键不同、生命周期差 360 倍,不能合并:
|
||||
合并会把 30 分钟窗口状态塞进 5 秒清空的表,或让 flush 需要按 gameId 重新聚合。
|
||||
- **限流表**:`rate: HashMap<ip + gameId, (windowStart, count)>` 固定窗口。
|
||||
- **并发**:请求路径只在短锁内做 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` 即真实客户端。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user