diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index a6eac6b07..d85e5a8c8 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -34,6 +34,7 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotListQuery, AdminProjectSnapshotListResponse, AdminRechargeOrderListQuery, @@ -214,6 +215,7 @@ export function listAdminProjectSnapshots( ) { const params = new URLSearchParams(); if (query.cursor) params.set('cursor', query.cursor); + if (query.channel) params.set('channel', query.channel); params.set('limit', String(query.limit ?? 20)); return request( `/admin/api/project-snapshots?${params.toString()}`, @@ -221,13 +223,27 @@ export function listAdminProjectSnapshots( ); } +export function getAdminProjectSnapshotChannels( + token: string, + signal?: AbortSignal, +) { + return request( + '/admin/api/project-snapshots/channels', + { token, signal }, + ); +} + export async function downloadAdminProjectSnapshot( token: string, + channel: string, userId: string, projectId: string, signal?: AbortSignal, ) { - const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`; + const params = new URLSearchParams(); + if (channel) params.set('channel', channel); + const query = params.toString(); + const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`; const response = await fetch(buildRequestUrl(path), { headers: { Authorization: `Bearer ${token.trim()}`, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 322bc1d62..44b833586 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry { fileCount: number; totalBytes: number; status: 'ready' | 'partial' | 'unverified'; + channel: string; + authorDisplayName?: string | null; + authorPublicUserCode?: string | null; } export interface AdminProjectSnapshotListQuery { cursor?: string | null; limit?: number; + channel?: string | null; } export interface AdminProjectSnapshotListResponse { @@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse { nextCursor: string | null; } +export interface AdminProjectSnapshotChannelsResponse { + defaultChannel: string; + channels: string[]; +} + export interface AdminErrorReportEntry { batchId: string; eventCount: number; diff --git a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts index bee81d5f9..5ae88ce97 100644 --- a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts +++ b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from './adminApiClient'; @@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () => expect( await listAdminProjectSnapshots( 'admin-token', - { cursor: 'user/a+项目', limit: 20 }, + { cursor: 'user/a+项目', limit: 20, channel: 'release' }, controller.signal, ), ).toEqual(payload); expect(fetchMock).toHaveBeenCalledWith( - '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20', + '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), signal: controller.signal, @@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () => ); }); +test('渠道列表按后台授权读取,解析本部署渠道', async () => { + const payload = { defaultChannel: 'release', channels: ['dev', 'release'] }; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: payload })), + ); + vi.stubGlobal('fetch', fetchMock); + expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload); + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/project-snapshots/channels', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + }), + ); +}); + test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response('PK\u0003\u0004', { @@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () = const controller = new AbortController(); const archive = await downloadAdminProjectSnapshot( 'admin-token', + 'release', 'user/a', 'project/b', controller.signal, @@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () = expect(archive.filename).toBe('三消-r2.zip'); expect(archive.blob.type).toBe('application/zip'); expect(fetchMock).toHaveBeenCalledWith( - '/admin/api/project-snapshots/user%2Fa/project%2Fb/download', + '/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token', @@ -81,7 +100,8 @@ test.each([ vi.fn().mockResolvedValue(new Response('PK', { headers })), ); expect( - (await downloadAdminProjectSnapshot('token', 'user', 'project')).filename, + (await downloadAdminProjectSnapshot('token', 'release', 'user', 'project')) + .filename, ).toBe(expected.replace('工程', 'project')); }); @@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])( ), ); await expect( - downloadAdminProjectSnapshot('token', 'user', 'project'), + downloadAdminProjectSnapshot('token', 'release', 'user', 'project'), ).rejects.toMatchObject({ status, code: 'SNAPSHOT_FAILURE', @@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => { ), ); await expect( - downloadAdminProjectSnapshot('token', 'user', 'project'), + downloadAdminProjectSnapshot('token', 'release', 'user', 'project'), ).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' }); }); diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 30e4ba2ae..7916e60e3 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { AdminApiError, downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; @@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({ '../api/adminApiClient', )), downloadAdminProjectSnapshot: vi.fn(), + getAdminProjectSnapshotChannels: vi.fn(), listAdminProjectSnapshots: vi.fn(), })); @@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = { fileCount: 12, totalBytes: 2048, status: 'ready', + channel: 'dev', + authorDisplayName: '陶泥作者', + authorPublicUserCode: 'SY-00000007', }; beforeEach(() => { vi.mocked(listAdminProjectSnapshots) .mockReset() .mockResolvedValue({ items: [entry], nextCursor: null }); + vi.mocked(getAdminProjectSnapshotChannels) + .mockReset() + .mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] }); vi.mocked(downloadAdminProjectSnapshot).mockReset(); }); afterEach(() => { @@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => { ).toBe(false); }); -test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => { +test('按游标翻页回到上一页时复用已取得的游标', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }); + render(); + await screen.findByText('三消工程'); + const pagination = await screen.findByRole('navigation', { + name: '项目工程分页', + }); + expect(pagination.textContent).toContain('第 1 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect( + screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'), + ).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 2, + 'token', + { cursor: 'page-2', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.queryByText('三消工程')).toBeNull(); + expect(pagination.textContent).toContain('第 2 页'); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(pagination.textContent).toContain('第 1 页'); +}); + +test('切换每页条数从第一页按新条数重新加载', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); + // 重新加载时页脚会重建,必须重新取节点再断言。 + expect( + screen.getByRole('navigation', { name: '项目工程分页' }).textContent, + ).toContain('第 1 页'); +}); + +test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => { vi.mocked(listAdminProjectSnapshots) .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) .mockResolvedValueOnce({ items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], nextCursor: null, }) - .mockRejectedValueOnce(new Error('远端清单读取失败')) - .mockResolvedValueOnce({ items: [], nextCursor: null }); + .mockRejectedValueOnce(new Error('翻页读取失败')); render(); - fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); await screen.findByText('第二工程'); - expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( - 2, - 'token', - { cursor: 'page-2', limit: 20 }, - expect.any(AbortSignal), - ); - expect(screen.getByText('三消工程')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); await screen.findByRole('alert'); - expect(screen.getByText('第二工程')).toBeTruthy(); - expect(screen.queryByText('暂无已上传项目')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '刷新' })); - await screen.findByText('暂无已上传项目'); - expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith( + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, 'token', - { cursor: null, limit: 20 }, + { cursor: 'page-2', limit: 20, channel: 'dev' }, expect.any(AbortSignal), ); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + expect(screen.getByText('第二工程')).toBeTruthy(); + expect(screen.queryByText('暂无已上传项目')).toBeNull(); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('暂无已上传项目'); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => { + render(); + const row = (await screen.findByText('三消工程')).closest('tr')!; + expect(within(row).getByText('陶泥作者')).toBeTruthy(); + expect(within(row).getByText('SY-00000007')).toBeTruthy(); + expect( + within(row).getByRole('button', { name: '查看用户信息' }), + ).toBeTruthy(); + expect(within(row).queryByText('user-1')).toBeNull(); +}); + +test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'release', + channels: ['dev', 'release'], + }); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'release' }, + expect.any(AbortSignal), + ); + const channelSelect = screen.getByLabelText('项目工程渠道'); + expect((channelSelect as HTMLSelectElement).value).toBe('release'); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第 2 页,本页 1 个项目'); + fireEvent.change(channelSelect, { target: { value: 'dev' } }); + await screen.findByText('dev 工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy(); }); test('下载使用返回的中文文件名,随后释放对象 URL', async () => { @@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = expect(createObjectURL).toHaveBeenCalledWith(blob); expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith( 'token', + 'dev', 'user-1', 'project-1', expect.any(AbortSignal), @@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => { vi.mocked(downloadAdminProjectSnapshot).mockImplementation( - (_token, _user, _project, signal) => + (_token, _channel, _user, _project, signal) => new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), @@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); fireEvent.click(await screen.findByRole('button', { name: '取消下载' })); expect( - vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted, + vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted, ).toBe(true); await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {})); @@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => { onUnauthorized={onUnauthorized} />, ); + // 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。 + await waitFor(() => + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1), + ); const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2]; view.rerender( { , ); fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); - const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]; + const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]; view.unmount(); expect(signal?.aborted).toBe(true); await act(async () => { diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 489956117..30621d554 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -1,11 +1,19 @@ -import { Download, RefreshCcw, X } from 'lucide-react'; +import { + ChevronLeft, + ChevronRight, + Download, + RefreshCcw, + X, +} from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; interface AdminProjectSnapshotsPageProps { @@ -31,21 +39,32 @@ const snapshotStatuses = { }, }; +const DEFAULT_PAGE_SIZE = 20; +const PAGE_SIZE_OPTIONS = [20, 50, 100]; + export function AdminProjectSnapshotsPage({ token, onUnauthorized, }: AdminProjectSnapshotsPageProps) { const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + // null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。 + const [channel, setChannel] = useState(null); + const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); const downloadController = useRef(null); + // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, + // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 + const pageCursors = useRef<(string | null)[]>([null]); const loadPage = useCallback( - async (cursor: string | null = null) => { + async (cursor: string | null, limit: number, page: number) => { listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({ try { const response = await listAdminProjectSnapshots( token, - { cursor, limit: 20 }, + { cursor, limit, channel }, controller.signal, ); if (controller.signal.aborted) return; - setItems((current) => { - if (!cursor) return response.items; - const entries = new Map( - current.map((entry) => [snapshotKey(entry), entry]), - ); - response.items.forEach((entry) => - entries.set(snapshotKey(entry), entry), - ); - return [...entries.values()]; - }); + setItems(response.items); setNextCursor(response.nextCursor); + setPageIndex(page); setHasLoaded(true); } catch (error: unknown) { + // 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。 if (!controller.signal.aborted) handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({ } } }, - [token, onUnauthorized], + [token, onUnauthorized, channel], ); useEffect(() => { + // 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。 + const controller = new AbortController(); + void (async () => { + try { + const response = await getAdminProjectSnapshotChannels( + token, + controller.signal, + ); + if (controller.signal.aborted) return; + setChannelOptions(response.channels); + setChannel((current) => current ?? response.defaultChannel); + } catch (error: unknown) { + if (controller.signal.aborted) return; + // 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。 + handlePageError(error, onUnauthorized, setErrorMessage); + setChannel((current) => current ?? ''); + } + })(); + return () => controller.abort(); + }, [token, onUnauthorized]); + + useEffect(() => { + if (channel === null) return undefined; + pageCursors.current = [null]; setItems([]); setNextCursor(null); + setPageIndex(1); setHasLoaded(false); setDownloadingKey(null); - void loadPage(); + void loadPage(null, pageSize, 1); return () => { listController.current?.abort(); listController.current = null; downloadController.current?.abort(); downloadController.current = null; }; - }, [loadPage]); + }, [loadPage, pageSize, channel]); + + function goToNextPage() { + if (!nextCursor) return; + pageCursors.current[pageIndex] = nextCursor; + void loadPage(nextCursor, pageSize, pageIndex + 1); + } + + function goToPreviousPage() { + if (pageIndex <= 1) return; + void loadPage( + pageCursors.current[pageIndex - 2] ?? null, + pageSize, + pageIndex - 1, + ); + } + + function refreshCurrentPage() { + void loadPage( + pageCursors.current[pageIndex - 1] ?? null, + pageSize, + pageIndex, + ); + } async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; @@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({ try { const archive = await downloadAdminProjectSnapshot( token, + channel ?? '', entry.userId, entry.projectId, controller.signal, @@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({ setDownloadingKey(null); } + // 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。 + const visibleChannelOptions = channelOptions.length + ? channelOptions + : channel + ? [channel] + : []; + return (

项目工程

- +
+ + +
{errorMessage ? (
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({ 项目 - 用户 ID + 用户 同步时间 文件数 体积 @@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({ {entry.projectName || entry.projectId} {entry.projectId} - {entry.userId} + +
+
+ {projectOwnerDisplayName(entry)} + + {entry.authorPublicUserCode?.trim() || '-'} + +
+ +
+ {new Date(entry.syncedAtMs).toLocaleString('zh-CN', { @@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({ {hasLoaded && items.length === 0 && !errorMessage ? (

暂无已上传项目

) : null} - {nextCursor ? ( -
- -
+ {hasLoaded ? ( + ) : null}
); } +function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) { + return ( + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' + ); +} + function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index caa2dedaa..2198adbdf 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1594,15 +1594,15 @@ button:disabled { } .admin-project-snapshot-table th:first-child { - width: 20%; + width: 18%; } .admin-project-snapshot-table th:nth-child(2) { - width: 14%; + width: 18%; } .admin-project-snapshot-table th:nth-child(3) { - width: 18%; + width: 16%; } .admin-project-snapshot-table th:nth-child(4) { @@ -1689,6 +1689,21 @@ button:disabled { } } +.admin-project-snapshot-pagination { + justify-content: space-between; + gap: 12px; +} + +.admin-project-snapshot-pagination-info { + color: #755a49; + font-size: 13px; + font-weight: 700; +} + +.admin-project-snapshot-pagination .admin-field { + min-width: 92px; +} + .admin-recharge-table { min-width: 1080px; table-layout: fixed; diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index b1f317674..45656fc67 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { inflateSync } from 'node:zlib'; -export const appIdentifier = 'world.genarrative.ai-game-creator'; +import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'; + +// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。 +export const appIdentifier = AGC_APP_IDENTIFIER; export const configFileName = 'game-creator.config.json'; export const localConfigFileName = 'game-creator.config.local.json'; export const runnerEndpointFileName = 'agent-runner.endpoint.json'; diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index 49bf93abf..67c4ad0b8 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -14,6 +14,7 @@ import { resolveReleasePartition, runTauriBuild, } from './build-release.mjs'; +import { resolveChannelInstallIdentity } from './channel-identity.mjs'; import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; import { readUpdaterPubkey, @@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); /** - * 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。 - * 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。 + * 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置): + * 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后 + * 让入口静默找错对象(清理、打包、归档三处一起失效)。 */ -function readProductName() { - const read = (file) => - JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8')); - const base = read('tauri.conf.json'); - const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json'); - const productName = fs.existsSync(macosPath) - ? (read('tauri.macos.conf.json').productName ?? base.productName) - : base.productName; +function resolveProductName(channel) { + const { productName } = resolveChannelInstallIdentity(channel); assert.ok( typeof productName === 'string' && productName.trim().length > 0, - 'Tauri 配置缺少 productName', + '渠道安装身份缺少 productName', ); return productName; } -const productName = readProductName(); -const appBundleName = `${productName}.app`; -const updaterArtifactName = `${productName}.app.tar.gz`; assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); assert.equal( process.env.JENKINS_URL?.length > 0, @@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const macTarget = 'aarch64-apple-darwin'; const context = resolveReleaseContext([`--target=${macTarget}`]); const partition = resolveReleasePartition(context.channel, context.target); +const productName = resolveProductName(context.channel); +const appBundleName = `${productName}.app`; +const updaterArtifactName = `${productName}.app.tar.gz`; const version = await prepareReleaseVersion(context); // 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`, // 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。 diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 70cfbdd87..bd1d5907b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -13,6 +13,10 @@ import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; +import { + resolveChannelInstallIdentity, + resolveReleaseChannel, +} from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; @@ -89,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -const reservedChannelNames = new Set([ - 'win', - 'mac', - 'windows', - 'macos', - 'darwin', - 'linux', -]); +export { resolveReleaseChannel } from './channel-identity.mjs'; /** * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 @@ -165,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) { throw new Error(`不支持的发布目标:${target}`); } -export function resolveReleaseChannel(env = process.env) { - const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'; - if ( - !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || - channel.endsWith('-') || - reservedChannelNames.has(channel) || - /-(win|mac)$/u.test(channel) - ) { - throw new Error( - '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', - ); - } - return channel; -} - /** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */ export function resolveReleasePartition( channel = resolveReleaseChannel(), @@ -428,12 +410,19 @@ export function buildTauriBuildArguments( ]; } -/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ +/** + * 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道, + * 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录, + * 不同渠道必须在同一台设备上并存而不是互相顶掉。 + */ export function createChannelConfig( channel = resolveReleaseChannel(), target = defaultTarget(), ) { + const { productName, identifier } = resolveChannelInstallIdentity(channel); return { + productName, + identifier, plugins: { updater: { endpoints: [updateManifestUrl(channel, target)], 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 15bcc11aa..848b74692 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -37,6 +37,11 @@ import { selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, plugins: { updater: { endpoints: [ @@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); +test('channel install identity isolates co-installed builds and keeps the default channel stable', () => { + // 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。 + assert.deepEqual(resolveChannelInstallIdentity('dev'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + assert.deepEqual(resolveChannelInstallIdentity('release'), { + productName: '陶泥儿 Release', + identifier: `${AGC_APP_IDENTIFIER}.release`, + }); + assert.deepEqual(resolveChannelInstallIdentity('beta-2'), { + productName: '陶泥儿 Beta-2', + identifier: `${AGC_APP_IDENTIFIER}.beta-2`, + }); + + // 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。 + for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) { + const identity = resolveChannelInstallIdentity(channel); + assert.notEqual(identity.productName, AGC_PRODUCT_NAME); + assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER); + assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)); + } + + for (const channel of ['dev-win', 'Release', 'win', 'beta-']) { + assert.throws( + () => resolveChannelInstallIdentity(channel), + /发布渠道无效/u, + ); + } +}); + +test('channel install identity is baked into the same build-time config as the endpoint', () => { + withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { + const config = createChannelConfig('release', windowsTarget); + assert.equal(config.productName, '陶泥儿 Release'); + assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`); + assert.match( + config.plugins.updater.endpoints[0], + /\/release-win\/latest\.json$/u, + ); + }); +}); + +test('channel products keep first-install selection working under the channel product name', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); + try { + const { productName } = resolveChannelInstallIdentity('release'); + const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`); + writeFileSync(dmg, 'channel first installation disk image'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + assert.equal( + selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], { + target: 'aarch64-apple-darwin', + version: packageVersion, + }), + dmg, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('packaged renderer receives the same channel as the updater manifest', () => { const context = resolveReleaseContext([], { AGC_BUILD_TARGET: windowsTarget, diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs new file mode 100644 index 000000000..5de68db22 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -0,0 +1,73 @@ +/** + * AGC 渠道 → 安装身份。 + * + * 渠道同时决定两件事: + * - 更新端点:OSS 分区 `-win` / `-mac` 的清单地址; + * - 安装身份:`productName` 与 `identifier`。 + * + * 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、 + * Windows WebView2 数据目录以及 `%APPDATA%\` 客户端数据目录。 + * 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、 + * 本地项目与运行锁。 + * + * 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。 + */ + +export const AGC_DEFAULT_CHANNEL = 'dev'; +export const AGC_PRODUCT_NAME = '陶泥儿'; +export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator'; + +const reservedChannelNames = new Set([ + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', +]); + +/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */ +export function validateReleaseChannel(channel) { + if ( + typeof channel !== 'string' || + !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || + channel.endsWith('-') || + reservedChannelNames.has(channel) || + /-(win|mac)$/u.test(channel) + ) { + throw new Error( + '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', + ); + } + return channel; +} + +export function resolveReleaseChannel(env = process.env) { + return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'); +} + +/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */ +export function channelDisplaySuffix(channel) { + return validateReleaseChannel(channel) + .split('-') + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join('-'); +} + +/** + * 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀, + * 保证同一台设备上不同渠道互不覆盖。 + */ +export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) { + validateReleaseChannel(channel); + if (channel === AGC_DEFAULT_CHANNEL) { + return Object.freeze({ + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + } + return Object.freeze({ + productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`, + identifier: `${AGC_APP_IDENTIFIER}.${channel}`, + }); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index df9518a8d..57c325aef 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -27,6 +27,11 @@ import { appIdentifier, defaultRealSwarmTestTask, } from './agent-swarm-test-chat.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; import { askHidden, assertSafeGameCreatorConfigDestination, @@ -1308,7 +1313,8 @@ if ( } for (const requiredSource of [ - "export const appIdentifier = 'world.genarrative.ai-game-creator'", + "import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'", + 'export const appIdentifier = AGC_APP_IDENTIFIER', "'--swarm-chat'", "'--autonomous-game-build'", "'--preview-serve'", @@ -1319,14 +1325,38 @@ for (const requiredSource of [ } } -if (tauriConfig.productName !== '陶泥儿') { +// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端 +// 的升级链路与既有安装目录都会断开。 +const defaultChannelIdentity = resolveChannelInstallIdentity('dev'); +if (tauriConfig.productName !== AGC_PRODUCT_NAME) { throw new Error('AI game creator shell productName drifted'); } -if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') { +if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) { throw new Error('AI game creator shell identifier drifted'); } +if ( + tauriConfig.productName !== defaultChannelIdentity.productName || + tauriConfig.identifier !== defaultChannelIdentity.identifier +) { + throw new Error( + 'AI game creator shell baseline config must match the default channel identity', + ); +} + +// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。 +for (const channel of ['release', 'beta-2']) { + const identity = resolveChannelInstallIdentity(channel); + if ( + identity.productName === defaultChannelIdentity.productName || + identity.identifier === defaultChannelIdentity.identifier || + !identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`) + ) { + throw new Error(`channel install identity not isolated: ${channel}`); + } +} + const expectedBundledDesignAgentResources = { 'design-agent': 'design-agent', ...Object.fromEntries( diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index b62fe76a1..fc3546be7 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -174,8 +174,16 @@ test('macOS release entry and smoke script derive product names from config and new URL('./build-macos-ci.mjs', import.meta.url), 'utf8', ); - // 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。 - assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名'); + // 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生, + // 写死会在换渠道或改名后静默找错对象。 + assert.ok( + entry.includes('resolveChannelInstallIdentity'), + '入口必须从渠道安装身份派生产品名', + ); + assert.ok( + entry.includes('resolveProductName(context.channel)'), + '产品名必须按当前发布渠道解析', + ); assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); assert.ok( entry.includes("const macTarget = 'aarch64-apple-darwin'"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index ad24af2a1..6cf049bb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -7684,7 +7684,7 @@ done &temp.path().join("host"), &project, "turn-0001", - "fixture-request", + &format!("{:x}", Sha256::digest("请创建菜单".as_bytes())), false, &super::super::direct_validation::DirectValidationConfig::default(), ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 01f6a6868..9b410cc60 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -21,30 +21,36 @@ pub(crate) fn validate_direct_codex_user_item( return Err("DirectProject user item 缺少稳定 id".to_string()); } if message.content.is_empty() { - return Err("DirectProject user item content 不能为空".to_string()); + return Err("聊天内容不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; + let mut has_effective_content = false; for part in &message.content { match part { DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); + if !text.trim().is_empty() { + has_effective_content = true; } } DirectCodexUserContentPart::AgcResourceReference { resource_id } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; + has_effective_content = true; } DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; + has_effective_content = true; } } } if reference_count > MAX_DIRECT_CODEX_REFERENCES { return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); } + if !has_effective_content { + return Err("聊天内容不能为空".to_string()); + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c718a9ee5..b3d9e8d8d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -197,7 +197,10 @@ fn render_ui_design_code_context( #[cfg(test)] mod tests { - use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item}; + use super::{ + direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, + validate_direct_codex_user_item, + }; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; use serde_json::json; use shared_contracts::game_creation_app::{ @@ -295,7 +298,7 @@ mod tests { } #[test] - fn response_item_projection_uses_input_text_not_turn_input_text() { + fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") .expect("init project"); @@ -303,12 +306,132 @@ mod tests { "type": "message", "role": "user", "id": "turn-1:user", - "content": [{"type": "input_text", "text": "你好"}] + "content": [ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "你好"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": "第二段"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] }); let projected = direct_codex_user_item_to_response_item(root.path(), &item) .expect("user response item should project"); - assert_eq!(projected["content"][0]["type"], "input_text"); - assert_ne!(projected["content"][0]["type"], "text"); + assert_eq!(projected["content"], item["content"]); + let canonical = serde_json::from_value(item).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"), + "你好\n第二段\n " + ); + } + + #[test] + fn user_input_rejects_empty_or_whitespace_only_messages() { + let root = prompt_context_project(); + for content in [ + json!([]), + json!([{"type": "input_text", "text": ""}]), + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": " \t\r\n\u{3000}"} + ]), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err("聊天内容不能为空".to_string()) + ); + } + } + + #[test] + fn valid_references_allow_missing_text_and_whitespace_parts() { + let root = prompt_context_project(); + let asset_id = register_fixture_asset( + root.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + for (reference, expected_text) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": asset_id}), + format!("[素材引用 resourceId={asset_id};项目路径=assets/hero.png]"), + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面"}), + "[运行画面区域:名称=主画面 ]".to_string(), + ), + ] { + for (content, expected_prompt) in [ + (json!([reference.clone()]), expected_text.clone()), + ( + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "\n"}, + reference, + {"type": "input_text", "text": " "} + ]), + format!("\n{expected_text} "), + ), + ] { + let item = json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + }); + let canonical = serde_json::from_value(item.clone()).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical) + .expect("reference prompt"), + expected_prompt + ); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("reference history projection"); + assert_eq!( + projected["content"].as_array().unwrap().len(), + content.as_array().unwrap().len() + ); + } + } + } + + #[test] + fn nonempty_text_does_not_bypass_invalid_reference_validation() { + let root = prompt_context_project(); + for (reference, expected_error) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": " "}), + "引用的素材 ID 无效,请移除后重新选择", + ), + ( + json!({"type": "agc_resource_reference", "resourceId": "missing"}), + "引用的素材已不存在,请移除后重新选择", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": " "}), + "运行画面区域缺少名称", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面", "resourceIds": ["missing"]}), + "引用的素材已不存在,请移除后重新选择", + ), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "有真实文字"}, + reference, + {"type": "input_text", "text": " "} + ] + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err(expected_error.to_string()) + ); + } } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 5c5168d14..c03b02a91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -241,11 +241,72 @@ impl ProjectCommandTree { #[cfg(not(windows))] { let _ = child; + #[cfg(target_os = "linux")] + { + let Self::Group { pid, .. } = self; + // 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。 + // 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。 + if !linux_project_command_group_has_live_members(*pid)? { + return Ok(()); + } + } self.request_owned_group_termination().map(|_| ()) } } } +#[cfg(target_os = "linux")] +fn linux_project_command_group_has_live_members(group: u32) -> Result { + let inspect = || -> std::io::Result { + let process_group = i32::try_from(group) + .ok() + .filter(|group| *group > 0) + .ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?; + if unsafe { libc::kill(-process_group, 0) } != 0 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(error) + }; + } + for entry in fs::read_dir("/proc")? { + let entry = entry?; + if entry.file_name().to_string_lossy().parse::().is_err() { + continue; + } + let stat = match fs::read(entry.path().join("stat")) { + Ok(stat) => stat, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ESRCH) => + { + continue; + } + Err(error) => return Err(error), + }; + let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态"); + // comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。 + let end = stat + .windows(2) + .rposition(|pair| pair == b") ") + .ok_or_else(invalid_stat)?; + let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?; + let mut fields = tail.split_whitespace(); + let state = fields.next().ok_or_else(invalid_stat)?; + let process_group = fields + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(invalid_stat)?; + if process_group == group && state != "Z" && state != "X" { + return Ok(true); + } + } + Ok(false) + }; + inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}")) +} + #[cfg(any(unix, test))] fn owned_project_command_group_identity_matches( expected: Option<&str>, @@ -2504,6 +2565,67 @@ where mod tests { use super::*; + #[cfg(target_os = "linux")] + #[tokio::test] + async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() { + use std::os::unix::process::CommandExt; + + const TEST: &str = + "command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members"; + const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE"; + if std::env::var_os(FIXTURE).is_none() { + // subreaper 只影响隔离夹具,避免接管并行测试的子进程。 + let output = tokio::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env(FIXTURE, "1") + .output() + .await + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0); + let mut command = tokio::process::Command::new("/bin/sh"); + command + .args(["-c", "sleep 60 & echo $!; read release"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + command.as_std_mut().process_group(0); + let mut child = command.spawn().unwrap(); + let tree = ProjectCommandTree::attach(&child).unwrap(); + let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); + let mut line = String::new(); + tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line) + .await + .unwrap(); + let descendant: i32 = line.trim().parse().unwrap(); + drop(child.stdin.take()); + child.wait().await.unwrap(); + let live_result = tree.after_main_exit(&mut child).await; + assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0); + let mut info = unsafe { std::mem::zeroed::() }; + assert_eq!( + unsafe { + libc::waitid( + libc::P_PID, + descendant as u32, + &mut info, + libc::WEXITED | libc::WNOWAIT, + ) + }, + 0 + ); + let zombie_result = tree.after_main_exit(&mut child).await; + assert_eq!( + unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) }, + descendant + ); + let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理"); + assert!(error.contains("leader 身份未确认"), "{error}"); + zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对"); + tree.after_main_exit(&mut child).await.unwrap(); + } + #[test] fn owned_process_group_refuses_missing_or_reused_leader_identity() { assert!(owned_project_command_group_identity_matches( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index c6c877523..55ffaa90a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_godot_project_root(root)?.is_none() { - return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); + return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; import_local_godot_project_at(root, project_id.trim(), name.trim()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d3f31417c..839311a3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -862,6 +862,48 @@ pub(crate) fn validate_game_creator_private_path_ancestors( Ok(()) } +/// 客户端安装身份基线:`productName` / `identifier` 由构建期按渠道注入。 +/// +/// 默认渠道保持基线身份,其它渠道派生 `<基线>.<渠道>`,因此同一台设备上 +/// 不同渠道各自拥有独立的安装目录与 AppData 数据目录。 +#[cfg(windows)] +const GAME_CREATOR_APP_IDENTIFIER: &str = "world.genarrative.ai-game-creator"; + +#[cfg(windows)] +fn is_game_creator_packaged_app_data_leaf(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let Some(remainder) = name.strip_prefix(GAME_CREATOR_APP_IDENTIFIER) else { + return false; + }; + if remainder.is_empty() { + return true; + } + // 渠道名是小写字母开头的 32 位以内小写字母、数字与连字符。 + remainder + .strip_prefix('.') + .is_some_and(|channel| !channel.is_empty() && channel.len() <= 32) +} + +/// 路径是否位于 `<平台配置根>/<安装身份目录>` 之内。提权助手是独立进程, +/// 看不到父进程的配置目录覆盖,因此这里必须按目录名识别全部渠道身份。 +#[cfg(windows)] +fn path_is_inside_game_creator_packaged_app_data(root: &Path, path: &Path) -> bool { + let root = normalize_windows_policy_path(root); + let path = normalize_windows_policy_path(path); + let Ok(relative) = path.strip_prefix(&root) else { + return false; + }; + relative + .components() + .next() + .is_some_and(|component| match component { + std::path::Component::Normal(name) => is_game_creator_packaged_app_data_leaf(name), + _ => false, + }) +} + /// Automatic ACL repair for managed paths is limited to objects AGC owns. A /// separate, explicit user-selected scope below covers native picker/project /// root results, including projects stored outside the current profile. @@ -900,21 +942,20 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { // elevated helper runs in a fresh process, so the in-memory runtime // config-dir override is unavailable there; recognize the packaged // path from the user's profile as well. - let packaged_app_data = home - .join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"); - if starts_with_path(&packaged_app_data) { + #[cfg(windows)] + if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path) + { return true; } } + #[cfg(windows)] for environment_name in ["LOCALAPPDATA", "APPDATA"] { if let Some(root) = std::env::var_os(environment_name) .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if starts_with_path(&root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return true; } } @@ -1096,10 +1137,9 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .filter(|candidate| candidate.is_absolute()) { if is_builtin_root(home.join(".config").join("genarrative")) - || is_builtin_root( - home.join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"), + || path_is_inside_game_creator_packaged_app_data( + &home.join("AppData").join("Local"), + &path, ) { return WindowsAclRepairScope::Managed; @@ -1110,7 +1150,7 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if is_builtin_root(root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return WindowsAclRepairScope::Managed; } } @@ -5044,18 +5084,38 @@ mod private_path_elevation_policy_tests { #[cfg(windows)] #[test] - fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() { + fn packaged_appdata_paths_keep_managed_repair_scope_for_every_channel() { let root = std::env::var_os("LOCALAPPDATA") .or_else(|| std::env::var_os("APPDATA")) .map(PathBuf::from) .expect("local appdata"); - let packaged = root.join("world.genarrative.ai-game-creator"); - let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); - assert!(game_creator_private_path_allows_auto_elevation(&verbatim)); + // 默认渠道是基线目录,其它渠道派生 `<基线>.<渠道>`;提权助手按目录名识别, + // 两种身份都必须落在 managed 赋权范围内。 + for leaf in [ + "world.genarrative.ai-game-creator", + "world.genarrative.ai-game-creator.release", + "world.genarrative.ai-game-creator.beta-2", + ] { + let packaged = root.join(leaf).join("diagnostics"); + let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); + assert!( + game_creator_private_path_allows_auto_elevation(&verbatim), + "{leaf}" + ); + assert_eq!( + game_creator_runtime_config_repair_scope(&verbatim), + WindowsAclRepairScope::Managed, + "{leaf}" + ); + } + + // 相似前缀不是安装身份目录,不能落进 managed 赋权范围。 + let foreign = root.join("world.genarrative.ai-game-creator-backup"); + assert!(!game_creator_private_path_allows_auto_elevation(&foreign)); assert_eq!( - game_creator_runtime_config_repair_scope(&verbatim), - WindowsAclRepairScope::Managed + game_creator_runtime_config_repair_scope(&foreign), + WindowsAclRepairScope::UserSelected ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index cb3f75eb7..d123ec186 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2005,6 +2005,13 @@ fn show_startup_error_dialog(log_path: Option<&Path>) { } } +/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀 +/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与 +/// Alt-Tab 里可区分;默认渠道结果不变。 +pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { + app.package_info().name.clone() +} + /// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` /// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 /// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 @@ -2468,6 +2475,16 @@ fn main() { setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; + // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 + // 由构建期身份决定,因此必须在这里按产品名覆盖。 + match app.get_webview_window("client") { + Some(window) => { + if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { + app_log!("startup.window-title.failed: {error}"); + } + } + None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), + } spawn_project_snapshot_scheduler(app.handle().clone()); if let Err(error) = builtin_plugins::initialize(&config_dir) { app_log!("startup.builtin-plugins.initialize.failed: {error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 2094ef492..ae4ad0550 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool { metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata) } +/// Godot 工程标记:`project.godot` 解析为普通文件即命中。 +/// +/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与 +/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只 +/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。 fn inspect_godot_project_marker(root: &Path) -> Result { let project_file = root.join("project.godot"); - let metadata = match fs::symlink_metadata(&project_file) { + // `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。 + let metadata = match fs::metadata(&project_file) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => { @@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result { )); } }; - if godot_metadata_is_link(&metadata) { - return Err(format!( - "Godot 项目文件不能是符号链接或 reparse point:{}", - project_file.display() - )); - } if !metadata.is_file() { return Err(format!( - "Godot 项目文件必须是普通文件:{}", + "Godot 项目文件必须是文件:{}", project_file.display() )); } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } - #[cfg(windows)] - { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, - }; - - const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - let file = fs::File::open(&project_file).map_err(|error| { - format!( - "打开 Godot 项目文件失败:{}: {error}", - project_file.display() - ) - })?; - // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. - let mut information = unsafe { std::mem::zeroed::() }; - // SAFETY: file owns a live handle and information is a valid output pointer. - // 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。 - if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 - || information.dwFileAttributes - & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) - != 0 - { - return Err(format!( - "读取 Godot 项目文件 Windows 身份失败:{}", - std::io::Error::last_os_error() - )); - } - if information.nNumberOfLinks != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } Ok(true) } @@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result Result<(), String> { + let path = root.join("project.godot"); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Godot 工程配置失败:{}: {error}", + path.display() + )); + } + }; + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let ends_with_newline = text.ends_with('\n'); + let mut replaced = false; + let mut lines = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_start(); + if !replaced { + if let Some(rest) = trimmed.strip_prefix("config/name") { + let rest = rest.trim_start(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim(); + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + let indent = &line[..line.len() - trimmed.len()]; + lines.push(format!( + "{indent}config/name=\"{}\"", + escape_godot_project_string(name) + )); + replaced = true; + continue; + } + } + } + } + lines.push(line.to_string()); + } + if !replaced { + return Ok(()); + } + let mut updated = lines.join(newline); + if ends_with_newline { + updated.push_str(newline); + } + write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置") +} + +fn escape_godot_project_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> { let Some(value) = value else { return Ok(()); @@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root( matches.push(validate_godot_project_child_name(&entry.file_name())?); } matches.sort(); - if matches.len() > 1 { - return Err(format!( - "工作区一层子目录中发现多个 Godot 项目:{}", - matches.join("、") - )); - } - let Some(relative_root) = matches.pop() else { + // 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序 + // 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。 + let Some(relative_root) = matches.into_iter().next() else { return Ok(None); }; @@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at( if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { return Err("Godot 工作区目录不存在或不是普通文件夹".to_string()); } - let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { - "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() - })?; + let godot_project_root = discover_local_godot_project_root(root)? + .ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?; if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index 434adb727..ab0f99690 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() { } #[test] -fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() { +fn picks_the_first_direct_child_godot_project_in_name_order() { let workspace = godot_import_test_path("multiple-children"); write_godot_project(&workspace.join("alpha"), "Alpha"); write_godot_project(&workspace.join("beta"), "Beta"); - let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") - .expect_err("multiple direct child Godot projects must fail"); + let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert!(!workspace.join(".agent").exists()); - assert!(!workspace.join("alpha/.agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert_manifest_godot_root(&workspace, "alpha"); + assert!(workspace.join(".agent/manifest.json").is_file()); + // 未选中的候选工程不写任何 AGC 元数据。 assert!(!workspace.join("beta/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() { } #[test] -fn ambiguous_layout_does_not_rewrite_an_existing_manifest() { +fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() { let workspace = godot_import_test_path("ambiguous-existing"); init_local_game_project_at(&workspace, "existing-project", "Existing Project") .expect("initialize existing workspace"); write_godot_project(&workspace.join("game"), "Game"); write_godot_project(&workspace.join("other"), "Other"); - let manifest_path = workspace.join(".agent/manifest.json"); - let original = fs::read(&manifest_path).expect("read original manifest"); - let error = import_local_godot_project_at(&workspace, "ignored", "Ignored") - .expect_err("ambiguous existing workspace must fail"); + let result = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect("ambiguous existing workspace must calibrate to one candidate"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert_eq!( - fs::read(&manifest_path).expect("read unchanged manifest"), - original - ); + assert_eq!(result.manifest.project_id, "existing-project"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!workspace.join("game/.agent").exists()); + assert!(!workspace.join("other/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() { #[cfg(unix)] #[test] -fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { +fn accepts_symbolic_link_project_marker() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-marker"); @@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", workspace.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked project.godot must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked project.godot must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_hard_link_project_marker() { + let workspace = godot_import_test_path("windows-hard-link-marker"); + fs::create_dir_all(&workspace).expect("create hard link marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + fs::hard_link( + workspace.join("real.godot"), + workspace.join("project.godot"), + ) + .expect("hard link project marker"); + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("hard linked project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_reparse_project_marker() { + let workspace = godot_import_test_path("windows-reparse-marker"); + fs::create_dir_all(&workspace).expect("create reparse marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() { + // 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。 + fs::remove_dir_all(workspace).ok(); + return; + } + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("reparse point project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); fs::remove_dir_all(workspace).ok(); } @@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() { #[cfg(unix)] #[test] -fn rejects_linked_marker_inside_a_regular_child_candidate() { +fn accepts_linked_marker_inside_a_regular_child_candidate() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-child-marker"); @@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() { fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", godot_root.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked marker in a regular child must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked marker in a regular child must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!godot_root.join(".agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() { fs::remove_dir_all(root).ok(); } +#[test] +fn rewrites_only_the_godot_display_name_line() { + let root = godot_import_test_path("display-name"); + fs::create_dir_all(&root).expect("create project root"); + fs::write( + root.join("project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n", + ) + .expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n" + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn keeps_a_godot_project_without_a_display_name_line_untouched() { + let root = godot_import_test_path("no-display-name"); + fs::create_dir_all(&root).expect("create project root"); + let original = + "config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + fs::write(root.join("project.godot"), original).expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + original + ); + fs::remove_dir_all(root).ok(); +} + fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) { let manifest_path = workspace.join(".agent/manifest.json"); fs::create_dir_all(manifest_path.parent().expect("manifest parent")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index ced19bec9..fbf8fbc10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -990,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at( &project_name, ); } + // Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入 + // `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。 + if discover_local_godot_project_root(&project_root)?.is_some() { + apply_godot_project_display_name(&project_root, &project_name)?; + return import_local_godot_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ); + } init_local_game_project_at( &project_root, &format!("gameagent-{workspace_id}"), @@ -1492,6 +1502,49 @@ mod tests { fs::remove_dir_all(&projects_root).ok(); } + #[test] + fn godot_template_creates_native_project_with_relative_root_and_display_name() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + let archive = build_archive(&[ + ("project.godot", config_source.as_bytes()), + ("scenes/main.tscn", b"[gd_scene format=3]\n"), + ]); + let mut summary = sample_summary(); + summary.id = "godot-fixture-template".to_string(); + summary.runtime = "godot".to_string(); + summary.entry = "project.godot".to_string(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install Godot template"); + + let result = create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + Some("我的平台跳跃"), + false, + ) + .expect("create Godot project from template"); + + // Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。 + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_eq!(result.manifest.name, "我的平台跳跃"); + let project_root = Path::new(&result.project_path); + assert!(!project_root.join("game").exists()); + assert!(project_root.join("scenes/main.tscn").is_file()); + let config = + fs::read_to_string(project_root.join("project.godot")).expect("read project.godot"); + assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}"); + assert!( + config.contains("run/main_scene=\"res://scenes/main.tscn\""), + "只改显示名,其余行逐字保留:{config}" + ); + assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists()); + fs::remove_dir_all(&projects_root).ok(); + } + #[test] fn refuses_to_create_project_when_template_is_not_installed() { let projects_root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index b64d1a51d..42aee42ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -1827,7 +1827,7 @@ async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_ ) .await; - assert_eq!(observation.status, "ok"); + assert_observation_status(&observation, "ok"); assert!(observation.summary.contains("只作为诊断结果")); assert!(observation .detail diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index a83d6e04f..2aaf472c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2532,42 +2532,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() { } #[test] -fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() { +fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + // 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。 + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) - .expect_err("ambiguous Godot workspace must fail inspection"); + let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(status.is_godot_project); + assert_eq!(status.godot_project_root.as_deref(), Some("alpha")); fs::remove_dir_all(root).ok(); } #[test] -fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() { +fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = import_local_godot_project( + let result = import_local_godot_project( root.to_string_lossy().into_owned(), "ambiguous".to_string(), "Ambiguous".to_string(), ) - .expect_err("ambiguous Godot workspace must fail before locking"); + .expect("ambiguous Godot workspace must import the first candidate deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(!root.join("beta/.agent").exists()); assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); - assert!(!root.join(".agent").exists()); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index fc146f387..854970f6f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -164,7 +164,7 @@ pub(crate) fn open_game_creator_workspace_window( existing.close().map_err(|error| error.to_string())?; } tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(1180.0, 820.0) .min_inner_size(760.0, 560.0) @@ -183,7 +183,7 @@ pub(crate) fn open_game_creator_launcher_window( existing.set_focus().map_err(|error| error.to_string())?; } else { tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(820.0, 640.0) .min_inner_size(720.0, 520.0) diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index de412fabb..57bff26e3 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -78,7 +78,12 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }; type RuntimeSettingsSection = - 'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about'; + | 'general' + | 'workspace' + | 'agents' + | 'extensions' + | 'advanced' + | 'about'; type RuntimeConfigToast = { tone: 'success' | 'error'; @@ -1076,30 +1081,16 @@ export function RuntimeConfigDialog({ : '已启用'} {plugin.enabled && plugin.hasRuntime ? ( - <> - - - + ) : null} {plugin.status === 'running' ? plugin.panels.map((panel) => ( diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg new file mode 100644 index 000000000..f22daa939 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg @@ -0,0 +1,12 @@ + + + + + + + + + Godot 空白 2D 工程 + Godot 4.7 · GDScript + 1280×720 窗口 · GL Compatibility + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json new file mode 100644 index 000000000..c9befb571 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-empty-2d", + "title": "Godot 空白 2D 工程", + "summary": "Godot 4.7 原生二维空白工程:1280×720 窗口、GL Compatibility 渲染、单场景入口与占位说明节点已就绪。", + "tags": [ + "空白", + "起步工程", + "2d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg new file mode 100644 index 000000000..4675426b7 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot new file mode 100644 index 000000000..bc60b7c8e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 空白 2D 工程。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 空白 2D 工程" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn new file mode 100644 index 000000000..52bbc79c8 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Hint" type="Label" parent="."] +offset_left = 48.0 +offset_top = 48.0 +offset_right = 1232.0 +offset_bottom = 160.0 +text = "AGC · Godot 空白 2D 工程" +theme_override_font_sizes/font_size = 28 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd new file mode 100644 index 000000000..cadbd6801 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd @@ -0,0 +1,12 @@ +extends Node2D + +## AGC · Godot 空白 2D 工程 +## +## 只保留最小可运行骨架:一个 Node2D 根节点加一段说明文字。 +## 继续开发时把新场景放进 `scenes/`,其余节点从 `scenes/main.tscn` 挂载。 + +@onready var _hint: Label = $Hint + + +func _ready() -> void: + _hint.text = "AGC · Godot 空白 2D 工程\n把场景挂到 scenes/ 即可开始,入口在 project.godot 的 run/main_scene" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg new file mode 100644 index 000000000..c8ff1405e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg @@ -0,0 +1,11 @@ + + + + + + + + Godot 空白 3D 场景 + Godot 4.7 · GDScript + 相机 · 平行光 · 天空环境 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json new file mode 100644 index 000000000..3b3abd3ac --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-empty-3d", + "title": "Godot 空白 3D 场景", + "summary": "Godot 4.7 原生三维空白工程:相机、平行光、天空环境与一个自转立方体已就绪,可直接开始搭建场景。", + "tags": [ + "空白", + "起步工程", + "3d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg new file mode 100644 index 000000000..1f92223a4 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot new file mode 100644 index 000000000..6a8bb2ea0 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 空白 3D 场景。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 空白 3D 场景" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn new file mode 100644 index 000000000..cc3aa8ff1 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn @@ -0,0 +1,43 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[sub_resource type="Environment" id="Environment_main"] +background_mode = 1 +background_color = Color(0.09, 0.11, 0.16, 1) +ambient_light_source = 1 +ambient_light_color = Color(0.62, 0.71, 0.86, 1) +ambient_light_energy = 0.4 + +[sub_resource type="BoxMesh" id="BoxMesh_cube"] +size = Vector3(1.6, 1.6, 1.6) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_cube"] +albedo_color = Color(0.28, 0.55, 0.75, 1) +roughness = 0.4 + +[node name="Main" type="Node3D"] +script = ExtResource("1_main") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_main") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.866025, -0.25, 0.433013, 0, 0.866025, 0.5, -0.5, -0.433013, 0.75, 3, 6, 4) + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.951057, 0.309017, 0, -0.309017, 0.951057, 0, 2.4, 6) + +[node name="Cube" type="MeshInstance3D" parent="."] +material_override = SubResource("StandardMaterial3D_cube") +mesh = SubResource("BoxMesh_cube") + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Hint" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 1000.0 +offset_bottom = 96.0 +text = "AGC · Godot 空白 3D 场景" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd new file mode 100644 index 000000000..bfe52ec9a --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd @@ -0,0 +1,13 @@ +extends Node3D + +## AGC · Godot 空白 3D 场景 +## +## 只保留最小可运行骨架:相机、平行光、天空环境与一个缓慢自转的立方体。 +## 继续开发时把新场景放进 `scenes/`,或在 `Cube` 下挂模型与脚本。 + +@onready var _cube: MeshInstance3D = $Cube + + +func _process(delta: float) -> void: + _cube.rotate_y(delta * 0.6) + _cube.rotate_x(delta * 0.2) diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg new file mode 100644 index 000000000..ae5f254b7 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg @@ -0,0 +1,12 @@ + + + + + + + + + Godot Hello World + Godot 4.7 · GDScript + 移动与收集 · 最小可玩示例 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json new file mode 100644 index 000000000..ad0b84a73 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-hello-world", + "title": "Godot Hello World", + "summary": "Godot 4.7 最小可玩示例:方向键移动方块,碰到金色目标加分,空格重置;只用 GDScript 与内置输入动作。", + "tags": [ + "示例", + "起步工程", + "2d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg new file mode 100644 index 000000000..b46265b60 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot new file mode 100644 index 000000000..4804660ed --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 二维移动与收集示例。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot Hello World" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn new file mode 100644 index 000000000..47043871e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Backdrop" type="Polygon2D" parent="."] +polygon = PackedVector2Array(0, 0, 1280, 0, 1280, 720, 0, 720) +color = Color(0.11, 0.15, 0.21, 1) + +[node name="Player" type="Node2D" parent="."] +position = Vector2(320, 360) + +[node name="Body" type="Polygon2D" parent="Player"] +polygon = PackedVector2Array(-24, -24, 24, -24, 24, 24, -24, 24) +color = Color(0.28, 0.55, 0.75, 1) + +[node name="Target" type="Node2D" parent="."] +position = Vector2(900, 240) + +[node name="Body" type="Polygon2D" parent="Target"] +polygon = PackedVector2Array(-18, -18, 18, -18, 18, 18, -18, 18) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Score" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 900.0 +offset_bottom = 120.0 +text = "得分:0" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd new file mode 100644 index 000000000..76a23f51b --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd @@ -0,0 +1,53 @@ +extends Node2D + +## AGC · Godot Hello World +## +## 最小可玩示例:方向键移动方块,碰到金色目标得分并随机刷新目标; +## 空格或回车重置得分。只使用 Godot 内置的 ui_* 输入动作,不需要额外输入映射。 + +const SPEED := 420.0 +const PICKUP_RADIUS := 42.0 +const PLAYER_BOUNDS := Rect2(28.0, 28.0, 1224.0, 664.0) + +var _score := 0 + +@onready var _player: Node2D = $Player +@onready var _target: Node2D = $Target +@onready var _hud: Label = $Hud/Score + + +func _ready() -> void: + _randomize_target() + _update_hud() + + +func _process(delta: float) -> void: + var direction := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") + _player.position += direction * SPEED * delta + _player.position.x = clampf( + _player.position.x, PLAYER_BOUNDS.position.x, PLAYER_BOUNDS.end.x + ) + _player.position.y = clampf( + _player.position.y, PLAYER_BOUNDS.position.y, PLAYER_BOUNDS.end.y + ) + if _player.position.distance_to(_target.position) <= PICKUP_RADIUS: + _score += 1 + _randomize_target() + _update_hud() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_accept"): + _score = 0 + _update_hud() + + +func _randomize_target() -> void: + _target.position = Vector2( + randf_range(PLAYER_BOUNDS.position.x, PLAYER_BOUNDS.end.x), + randf_range(PLAYER_BOUNDS.position.y, PLAYER_BOUNDS.end.y), + ) + + +func _update_hud() -> void: + _hud.text = "得分:%d\n方向键移动 · 空格/回车重置" % _score diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg new file mode 100644 index 000000000..6ee82e63c --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + Godot 2D 平台跳跃 + Godot 4.7 · GDScript + 角色 · 平台 · 相机跟随 · 金币 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json new file mode 100644 index 000000000..4157bd441 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-platformer-2d", + "title": "Godot 2D 平台跳跃起步工程", + "summary": "Godot 4.7 原生二维平台跳跃骨架:CharacterBody2D 角色、地面与两级平台、相机跟随、三枚金币与重开,全部用 GDScript 与内置输入动作实现。", + "tags": [ + "起步工程", + "2d", + "平台跳跃", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg new file mode 100644 index 000000000..fd4ef4619 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot new file mode 100644 index 000000000..d6cb7b5b6 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 二维平台跳跃起步工程。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 2D 平台跳跃起步工程" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn new file mode 100644 index 000000000..4545bf2bd --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn @@ -0,0 +1,97 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] +[ext_resource type="Script" path="res://scripts/player.gd" id="2_player"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"] +size = Vector2(40, 56) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_ground"] +size = Vector2(1280, 64) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_platform"] +size = Vector2(280, 32) + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Backdrop" type="Polygon2D" parent="."] +polygon = PackedVector2Array(0, 0, 1280, 0, 1280, 720, 0, 720) +color = Color(0.11, 0.15, 0.21, 1) + +[node name="Ground" type="StaticBody2D" parent="."] +position = Vector2(640, 688) + +[node name="Shape" type="CollisionShape2D" parent="Ground"] +shape = SubResource("RectangleShape2D_ground") + +[node name="Visual" type="Polygon2D" parent="Ground"] +polygon = PackedVector2Array(-640, -32, 640, -32, 640, 32, -640, 32) +color = Color(0.2, 0.27, 0.35, 1) + +[node name="Platform" type="StaticBody2D" parent="."] +position = Vector2(360, 560) + +[node name="Shape" type="CollisionShape2D" parent="Platform"] +shape = SubResource("RectangleShape2D_platform") + +[node name="Visual" type="Polygon2D" parent="Platform"] +polygon = PackedVector2Array(-140, -16, 140, -16, 140, 16, -140, 16) +color = Color(0.24, 0.34, 0.44, 1) + +[node name="Platform2" type="StaticBody2D" parent="."] +position = Vector2(900, 470) + +[node name="Shape" type="CollisionShape2D" parent="Platform2"] +shape = SubResource("RectangleShape2D_platform") + +[node name="Visual" type="Polygon2D" parent="Platform2"] +polygon = PackedVector2Array(-140, -16, 140, -16, 140, 16, -140, 16) +color = Color(0.24, 0.34, 0.44, 1) + +[node name="Coins" type="Node2D" parent="."] + +[node name="Coin1" type="Node2D" parent="Coins"] +position = Vector2(360, 500) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin1"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Coin2" type="Node2D" parent="Coins"] +position = Vector2(900, 410) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin2"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Coin3" type="Node2D" parent="Coins"] +position = Vector2(1160, 620) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin3"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Player" type="CharacterBody2D" parent="."] +position = Vector2(160, 560) +script = ExtResource("2_player") + +[node name="Shape" type="CollisionShape2D" parent="Player"] +shape = SubResource("RectangleShape2D_player") + +[node name="Visual" type="Polygon2D" parent="Player"] +polygon = PackedVector2Array(-20, -28, 20, -28, 20, 28, -20, 28) +color = Color(0.28, 0.55, 0.75, 1) + +[node name="Camera2D" type="Camera2D" parent="Player"] +position_smoothing_enabled = true + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Status" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 1100.0 +offset_bottom = 130.0 +text = "已收集 0 / 3" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd new file mode 100644 index 000000000..60c7593e0 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd @@ -0,0 +1,51 @@ +extends Node2D + +## AGC · 平台跳跃起步工程 +## +## 收集三枚金币:踩平台跳上去即可。金币按玩家中心与目标的距离判定, +## 不依赖物理层设置,改关卡时直接挪 Coin 节点位置就行。Esc 重来。 + +const PICKUP_RADIUS := 34.0 +const SPAWN_POSITION := Vector2(160.0, 560.0) + +var _score := 0 + +@onready var _player: CharacterBody2D = $Player +@onready var _coins: Node2D = $Coins +@onready var _status: Label = $Hud/Status + + +func _ready() -> void: + _update_status() + + +func _process(_delta: float) -> void: + for coin in _coins.get_children(): + if not coin.visible: + continue + if _player.global_position.distance_to(coin.global_position) <= PICKUP_RADIUS: + coin.visible = false + _score += 1 + _update_status() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + _restart() + + +func _restart() -> void: + for coin in _coins.get_children(): + coin.visible = true + _player.position = SPAWN_POSITION + _player.velocity = Vector2.ZERO + _score = 0 + _update_status() + + +func _update_status() -> void: + var total := _coins.get_child_count() + if _score >= total: + _status.text = "已收集 %d / %d —— 全部完成!Esc 重来" % [_score, total] + else: + _status.text = "已收集 %d / %d\n方向键移动 · 空格/回车跳跃 · Esc 重来" % [_score, total] diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd new file mode 100644 index 000000000..cc01695ff --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd @@ -0,0 +1,23 @@ +extends CharacterBody2D + +## AGC · 平台跳跃玩家控制 +## +## 方向键左右移动,空格 / 回车跳跃。只使用 Godot 内置的 ui_* 输入动作; +## 需要 WASD 或手柄时在「项目设置 → 输入映射」里给这两个动作补事件即可。 + +const SPEED := 320.0 +const JUMP_VELOCITY := -700.0 +const GRAVITY := 1500.0 + + +func _physics_process(delta: float) -> void: + if not is_on_floor(): + velocity.y += GRAVITY * delta + if is_on_floor() and Input.is_action_just_pressed("ui_accept"): + velocity.y = JUMP_VELOCITY + var direction := Input.get_axis("ui_left", "ui_right") + if direction != 0.0: + velocity.x = direction * SPEED + else: + velocity.x = move_toward(velocity.x, 0.0, SPEED) + move_and_slide() diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 142dc2b26..964e9a4e3 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -639,7 +639,8 @@ describe('ResourceReferenceInput', () => { const deleteButton = screen.getByRole('button', { name: '移除引用 hero' }); expect(chip?.contains(deleteButton)).toBe(true); - // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 + // 草稿 text 投影会 trim,但提交用的 content 仍保留引用节点后的分隔空格; + // 结构化引用完整保留,后端按整条消息判断是否有有效内容。 expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( 'hero', diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index eefb1ae55..c97ad913c 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -163,14 +163,17 @@ ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520 ALIYUN_OSS_SUCCESS_ACTION_STATUS=200 # AGC 项目定时快照上传目标。对象只落在服务端私有前缀 -# agc/project-snapshots/v1/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*, +# agc/project-snapshots/v2/{channel}/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*, # 因此回退凭据必须对目标 bucket 具备该前缀的 PutObject/GetObject/DeleteObject 权限。 # bucket 未单独配置时默认 agc-dev,未配置凭据时 api-server 跳过该客户端, # 接口返回 503 且客户端失败关闭(不写空对象、不推进本地索引)。 +# channel 是部署渠道:缺省沿用 GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL,正式部署必须显式设为 +# release,否则本部署上传会落在 dev 渠道;渠道名非法时接口返回 503,不回落。 GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev # SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index c45bb7ce1..cdf6b4e73 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变) +更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变;2026-09-21 Godot 工作区发现放宽:一层多命中按目录名排序取第一个,`project.godot` 允许是链接/reparse point/硬链接,见 §3.8) ## 1. 产品定位 @@ -136,7 +136,7 @@ ### 3.8 现有 Godot 项目 -- 首页和项目组统一使用“打开项目”,不提供独立 Godot 按钮。用户选择的目录作为工作区根;系统先检查根目录,再检查一层直接子目录中的普通文件 `project.godot`。根目录命中优先;一层发现多个 Godot 工程时必须提示歧义,不猜测目标。 +- 首页和项目组统一使用“打开项目”,不提供独立 Godot 按钮。用户选择的目录作为工作区根;系统先检查根目录,再检查一层直接子目录中的 `project.godot`。根目录命中优先;一层命中多个 Godot 工程时按目录名排序取第一个,不再因为存在第二个工程而失败或要求用户改选。`project.godot` 本身是符号链接、Windows reparse point 或硬链接时按链接目标判定,解析后是文件即命中;目录与悬空链接仍不算命中。 - 工作区根始终绑定文件读取、修改、命令 cwd、对话、Runtime 和最近项目记录;`.agent/`、Agent DB 与日志也只写在这里。`project.godot` 所在目录额外以 `godotProjectRoot` 记录为相对工作区的 `.` 或单层目录名,不把项目作用域切到 Godot 子目录。 - 首次打开只在工作区根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并按当前唯一文件布局校准 `godotProjectRoot`。Godot 源码、场景、资源和项目配置继续使用原目录结构,不复制工程,也不建立 `game/`、`assets/`、`memory/`、`exports/` 平行目录。 - Godot 项目使用 `standard` Run Profile,避免套用 Web 原型的 `game/index.html`、本地 HTTP 预览和自主 Web 游戏完成门;本期不提供 Godot 内嵌运行预览。 diff --git a/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md new file mode 100644 index 000000000..0b57d90e7 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md @@ -0,0 +1,49 @@ +# 【实施计划】AGC 渠道安装身份隔离 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md` | +| Status | ready | +| Owner | 当前 Agent | + +## 修改边界 + +- 允许修改: + - `apps/ai-game-creator-shell/scripts/channel-identity.mjs`(新增,渠道身份单点定义) + - `apps/ai-game-creator-shell/scripts/build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs`、`agent-swarm-test-chat.mjs` + - `apps/ai-game-creator-shell/src-tauri/src/main.rs`、`src-tauri/src/windows.rs`、`src-tauri/src/config.rs` + - 对应测试:`build-release.test.mjs`、`prepare-macos-codex.test.mjs` + - 文档:AGC 更新主规范、共享记忆与本计划对 +- 明确不修改:OSS 分区布局、官网下载接口与页面、Jenkins Job 参数、渠道版本发行逻辑、Apple 签名/公证、移动壳。 + +## 实现顺序 + +1. 抽出 `channel-identity.mjs`:渠道校验、渠道显示名、`resolveChannelInstallIdentity()`;`build-release.mjs` 复用并再导出渠道校验。 +2. 渠道 `--config` 同时注入 `productName` 与 `identifier`;macOS 发布入口按发布渠道解析产品名(`.app`、updater 归档、DMG 卷名与文件名)。 +3. Rust:主窗口/工作区/启动器窗口标题取构建期产品名;AGC 自有 AppData 目录的 ACL managed 识别覆盖 `<基线>` 与 `<基线>.<渠道>`。 +4. 门禁与测试:`check-config.mjs` 断言基线等于默认渠道身份、非默认渠道身份隔离;`build-release.test.mjs` 增补身份与首装包用例;`prepare-macos-codex.test.mjs` 改断言为消费渠道身份。 +5. 文档:更新 AGC 更新主规范与共享记忆,登记决策与踩坑。 + +## 验证命令 + +1. `node --check apps/ai-game-creator-shell/scripts/channel-identity.mjs apps/ai-game-creator-shell/scripts/build-release.mjs apps/ai-game-creator-shell/scripts/build-macos-ci.mjs apps/ai-game-creator-shell/scripts/check-config.mjs` +2. `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/release-oss.test.mjs apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs` +3. `node apps/ai-game-creator-shell/scripts/check-config.mjs` +4. `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::tests:: --test-threads=1` +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 验证结果 + +- `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs`:64/64 通过(新增渠道身份、身份注入与渠道 DMG 首装选择三条用例)。 +- `node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm --prefix apps/ai-game-creator-shell run typecheck`:通过。 +- `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 通过。 +- `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug`:Tauri 接受派生的 `productName` / `identifier` 并完成构建;产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0。 +- `cargo fmt --check`(AGC 壳)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`:通过。 +- 未执行:真实渠道打包(需要签名私钥与发号/上传授权)、双渠道真机安装与并存、macOS 节点实跑。 + +## 风险与回滚点 + +- 风险:默认渠道身份若被改动,既有安装目录、卸载项与升级链会断。回滚点:基线 `tauri.conf.json` 与默认渠道映射不变,门禁用例钉住。 +- 风险:非默认渠道首次以新身份安装,老 `dev` 用户不会自动迁移本地数据。回滚点:渠道身份只影响非默认渠道构建,撤销该渠道的构建产物即可,仓库侧无数据迁移。 +- 风险:窗口标题改为构建期产品名后,标题不再等于配置里的字面量。回滚点:去掉 `main.rs` 的标题覆盖调用,行为回到配置标题。 +- 风险:ACL managed 识别放宽到前缀族。回滚点:`is_game_creator_packaged_app_data_leaf` 收紧回单一直线值,但非默认渠道的提权修复会重新失败关闭。 diff --git a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md new file mode 100644 index 000000000..dea7272cd --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md @@ -0,0 +1,44 @@ +# 里程碑:AGC 渠道安装身份隔离 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | 代码与源码级验收已落地,等待真机双渠道安装验收 | +| Date | 2026-09-21 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +不同渠道的 AGC 包体在同一台设备上并存:安装、运行、客户端数据与更新互不覆盖、互不顶掉。 + +## 范围 + +- 构建期按渠道产出安装身份(`productName` / `identifier`),默认渠道 `dev` 身份保持不变。 +- 渠道产物命名、首装包选择、macOS 发布入口与配置门禁都消费同一份渠道身份。 +- 同机并存的可见区分(窗口标题)与 AGC 自有 AppData 目录的提权 ACL 识别范围。 + +## 不在范围内 + +- 不迁移、不共享既有本地项目、工程快照、模板、登录态与诊断数据。 +- 不新增渠道,不改 OSS 分区布局、官网下载页与后端接口。 +- 不做 Apple 代码签名/公证,不恢复 Intel 架构。 + +## 依赖与前置条件 + +- 渠道与更新端点合同已落地(`-win` / `-mac` 分区与 Tauri updater 端点)。 +- 构建入口统一在 Tauri 构建前注入渠道 `--config`。 + +## 验收标准 + +- [x] 默认渠道的 `productName` / `identifier` 与基线配置逐字一致,既有安装与升级链不断(`check-config.mjs` + 渠道身份用例)。 +- [x] `release` 与自定义渠道派生独立 `productName` 与 `identifier`,与 `dev` 可在同一台设备并存(源码级;真机安装见下方未完成项)。 +- [x] 渠道产物(NSIS `.exe`、`.app.tar.gz`、DMG)与首装包选择跟随渠道身份且唯一匹配(发布脚本用例)。 +- [x] 非默认渠道的客户端数据目录、WebView2 目录与窗口标题跟随渠道身份(构建产物字符串实测 + 运行期标题取产品名)。 +- [x] AGC 自有 AppData 目录的 ACL managed 范围覆盖全部渠道身份,且不扩大到相似前缀目录(Rust 定向 12/12)。 +- [ ] 真机:同一台设备同时安装 `dev` 与 `release`,二者可并存、可各自原地更新。 + +## 证据要求 + +- 自动化:`build-release.test.mjs`、`release-oss.test.mjs`、`prepare-macos-codex.test.mjs`、`check-config.mjs` 与 Rust `config::tests::` 定向测试。 +- 运行时:单渠道 `--no-bundle` 构建烟测;真机双渠道安装、并存与各自更新。 +- 边界:非法渠道失败关闭;默认渠道身份不变;相似前缀目录不进入 managed 赋权范围。 diff --git a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md index ae4a90f62..04bfa55a1 100644 --- a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md +++ b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md @@ -23,7 +23,7 @@ - [ ] 正式项目窗口可被同步调度识别;周期与关闭触发保持有界,失败有项目级诊断。 - [ ] 未单独配置快照目标时仍使用 agc-dev,只复用资源存储凭据;显式快照目标保持有效,不迁移现存对象。 -- [ ] 后台列表显示项目名/ID、用户 ID、同步时间、文件数、体积和完整性,支持刷新与分页。 +- [ ] 后台列表按部署渠道查询,显示项目名/ID、用户(昵称 + 陶泥号)、同步时间、文件数、体积和完整性,支持刷新与游标分页(每页 20/50/100 + 上一页/下一页)。 - [ ] ZIP 按清单还原相对路径;不含对象存储摘要目录;空文件可上传与导出。 - [ ] 清单名称/完整性变化在无内容差异时也提交,partial 可恢复 ready,历史缺字段不冒充 ready。 - [ ] 缺失、损坏、越界路径和非完整清单失败关闭;历史未声明完整性的清单明确标记,允许导出已有文件但不称为完整工程。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index aae71f5bd..6069537b2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,42 @@ # 决策记录 +## 2026-09-21 渠道进安装身份:不同渠道的 AGC 包体在同一台设备并存 + +- 背景:渠道此前只决定更新端点(`plugins.updater.endpoints`)与渲染层平台 origin,`productName` / `identifier` 与渠道无关,于是所有渠道共用 `%LOCALAPPDATA%\陶泥儿` 安装目录、同一个卸载项(`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\陶泥儿`,另有 `HKCU\Software\genarrative\陶泥儿`)以及同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录。本机 0.1.48 安装实测:主程序二进制里只有 1 处 `agc/dev-win/latest.json`、0 处 release 端点,说明渠道在产物里只体现为端点。后果是后装的渠道静默顶掉先装的渠道,并接管更新端点、平台服务器与本地登录态/项目数据。 +- 决策:渠道同时决定**安装身份**。默认渠道 `dev` 保持基线 `productName = 陶泥儿`、`identifier = world.genarrative.ai-game-creator`(既有安装目录、卸载项与升级链不断);其它渠道派生 `陶泥儿 <渠道显示名>`(`release` → `陶泥儿 Release`)与 `world.genarrative.ai-game-creator.<渠道>`。身份与更新端点必须在同一个构建期 `--config` 里注入,禁止分别回读默认值。窗口标题、macOS 产物名(`.app` / updater 归档 / DMG 卷名)、首装包选择与 Windows 提权 ACL 的 managed 识别范围同批跟随该身份。 +- 边界:不做本地数据迁移或共享——切渠道等于换一个客户端;`release` 与自定义渠道首次以新身份安装,不接管、不迁移既有 `dev` 安装与本地项目,由用户自行决定是否卸载其一。 +- 影响范围:新增 `apps/ai-game-creator-shell/scripts/channel-identity.mjs`;`build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs`、`agent-swarm-test-chat.mjs`、`src-tauri/src/{main.rs,windows.rs,config.rs}` 与对应测试;主规范 `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 +- 验证方式:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs`(64/64,新增渠道身份与渠道 DMG 首装选择用例)、`node apps/ai-game-creator-shell/scripts/check-config.mjs`(基线等于默认渠道身份、非默认渠道身份隔离)、`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` 与相似前缀反向断言)、`AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug`(产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0)。真机双渠道安装、并存与各自更新尚未执行,按未验证项记录。 + +## 2026-09-21 项目快照按部署渠道分区,后台按渠道查看并按素材查询口径展示用户 + +- 背景:AGC 项目快照此前统一写在 `agc/project-snapshots/v1/{user}/{project}/`,而开发与正式两套部署共用同一个 bucket(都默认 `agc-dev`)。结果是渠道混在一层前缀里:正式后台会列出开发渠道上传的项目,列表上也看不出项目属于哪个渠道;同时“项目工程”列表只有裸用户 ID,且用“加载更多”逐段追加,翻页与定位都困难。 +- 决策(存储):对象键升级为 `agc/project-snapshots/v2/{channel}/{user}/{project}/`。渠道是**部署渠道**,由服务端 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL` 决定(缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`,当前缺省 `dev`),客户端不上报渠道、也不需要发新版;渠道名必须是小写字母开头的 `[a-z0-9-]{1,32}`,非法值在上传与后台查询处失败关闭(`503`/`400`),不悄悄回落。正式部署必须在 api-server 环境里显式写 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=release`。 +- 决策(后台):新增 `GET /admin/api/project-snapshots/channels` 返回本部署渠道与远端已存在渠道的并集;列表与下载接口都接受 `channel`(缺省本部署渠道),游标里带上渠道并在解码时校验一致,跨渠道复用游标一律 400。页面顶部提供“渠道”选择框,切换渠道回到第 1 页。 +- 决策(列表形态):“用户 ID”列改为与“素材查询”同口径的“用户”列——昵称 + 陶泥号 + 用户详情入口,昵称/陶泥号由 api-server 用 `resolve_work_author_by_user_id` 解析(账号不可读时回退占位作者),原始 `userId` 不再直接铺在列里;“加载更多”改为游标分页(每页 20/50/100 + 上一页/下一页 + 当前页),前端按页记录游标链,换令牌或换每页条数从第 1 页重来;翻页失败保留当前页。 +- 原因:渠道属于“这套部署服务哪个客户端渠道”,客户端正式包按构建渠道连接不同平台服务(release → 正式站,dev → 开发站),所以服务端是唯一可靠的渠道来源;把渠道放进对象键第一层,既不需要迁移历史对象就能让两套部署互不可见,也让后台的渠道维度天然对齐存储布局。 +- 影响范围:`server-rs/crates/platform-oss/src/{lib.rs,project_snapshots.rs,template_library.rs,examples/agc_project_snapshot_live_smoke.rs}`、`server-rs/crates/api-server/src/{config.rs,project_snapshots.rs,admin_project_snapshots.rs,modules/admin.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/{api/adminApiClient.ts,api/adminApiTypes.ts,pages/AdminProjectSnapshotsPage.tsx,pages/AdminProjectSnapshotsPage.test.tsx,styles/admin.css}`、`deploy/env/api-server.env.example` 与本仓运维/技术文档。 +- 未迁移的历史对象:`agc/project-snapshots/v1/` 下现存对象(只读核对过的两份历史清单)保留在 OSS,但不再写入、不再进入后台列表;需要取回时按旧前缀在 OSS 侧直接读取,确实要在后台看到时再单独开一个只读兼容视图。 +- 验证方式:`cargo test -p platform-oss snapshot` 7 passed(渠道校验、键布局、v2 根渠道枚举、v1 历史键仍必须私有);`cargo test -p api-server project_snapshot` 18 passed/1 ignored(渠道失败关闭、游标跨渠道拒绝、用户昵称/陶泥号解析、归档与配额回归);`cargo test -p api-server protected_route_matrix`、`route_contract` 通过(新路由纳入后台鉴权矩阵);`npm run admin-web:typecheck` 与 `npx vitest run apps/admin-web/src` 202 passed。 + +## 2026-09-21 Godot 模板入库与按模板建项的 Godot 分流 + +- 背景:模板库此前只有网页(html)、Cocos 与 Unity 占位,`runtime` 白名单早就接受 `godot`,但既没有 Godot 模板,也没有按模板建项的 Godot 分流——Godot 模板即使上传,建项也会落到 Web 分支,写出 `game/index.html` 占位入口并让 `godotProjectRoot` 为空。 +- 决策(模板内容):新增四个仓库内手写的 Godot 4.7 模板 `godot-empty-2d`、`godot-empty-3d`、`godot-hello-world`、`godot-platformer-2d`,`entry` 统一为 `project.godot`。模板只用内置 `ui_*` 输入动作、GL Compatibility 渲染,并用 `Polygon2D` / `BoxMesh` 搭可视骨架,不引入外部贴图或音频二进制;不打包 `.godot/` 缓存与导出产物。 +- 决策(建项分流):`create_project_from_installed_template_at` 在复制模板后按工程文件分流——Cocos 更新自身身份后走 Cocos 导入,Godot 先改写 `project.godot` 里 `[application]` 段的 `config/name`(只改这一行,其余字节逐字保留)再走既有 Godot 导入,写入 `godotProjectRoot: "."`,其余继续走 `init_local_game_project_at`。分流靠工程文件识别,不新增只读 `entry` 或 `runtime` 字段的契约。 +- 原因:Godot 工程身份是 `project.godot` 所在目录,与 Cocos 的 `package.json` 身份同一类问题;复用既有导入流程能同时拿到相对根记录、`.agent` 初始化与「不生成 Web 占位入口」这三条既有保证,并且与 Cocos 分支保持对称。 +- 影响范围:`apps/ai-game-creator-shell/template-library/v1/godot-*`(新增模板源)、`apps/ai-game-creator-shell/src-tauri/src/template_library.rs`、`.../src/project/manifest.rs`(`apply_godot_project_display_name`)、`.../src/project/manifest/import_tests.rs`、`docs/【模板规范】AGC模板包组织指南-2026-09-21.md`、`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`。 +- 验证方式:本机 Godot `4.7.2.stable` 对四个模板逐一做「主场景实例化 + 3 帧 + GDScript `--check-only`」,平台跳跃模板另做真实物理试玩(落地 y=627.99、1 秒右移 320px、跳上平台 y=515.93、三枚金币全收集);Rust 定向回归 `import_tests::rewrites_only_the_godot_display_name_line`、`import_tests::keeps_a_godot_project_without_a_display_name_line_untouched`、`template_library::tests::godot_template_creates_native_project_with_relative_root_and_display_name` 与既有 Cocos/Web 建项回归;发布走 `--only godot-*` 定向合并。 + +## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 + +- 背景:2026-08-10 “发现与歧义决策”把「一层多命中」和「`project.godot` 必须是普通文件」两条定为失败关闭。这两种布局都会让整个工作区被判成「没有 Godot 工程」,而 `PluginHost::list` 只按发现结果过滤,结果是 Godot 内置插件在设置→扩展 里直接消失,用户看不到任何可诊断入口。 +- 决策(发现):根目录命中仍然优先;根未命中时一层直接子目录多命中改为按目录名排序取第一个,`godotProjectRoot` 记录该相对目录名,结果确定且可复现。`project.godot` 允许是符号链接 / Windows reparse point / 硬链接,判据改为「链接目标解析后是文件」,目录与悬空链接仍不算命中。工作区根本身是链接、候选子目录是链接、二层及更深不递归这三条边界不变。 +- 决策(界面):设置→扩展 的内置插件行去掉手动「启动 / 停止」按钮。启动本来就由项目切换时的 `startAvailableAgcEditorPlugins` 自动完成,手动按钮只是第二个可绕过入口;停止改走该行的启用/禁用开关,`set_agc_plugin_enabled(false)` 会停止插件并断开编辑器连接。导入扩展行的启动按钮保留,因为导入扩展没有自动启动路径。 +- 原因:Godot 插件列表是按项目过滤的,发现失败等于功能静默消失;把不确定性收敛成一个确定的排序选择,比让用户面对空列表更好。链接放宽只作用于只读的工程标记文件,受管描述文件、运行缓存与项目目录的链接拒绝规则不动。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`.../src/commands.rs`(错误文案)、`.../src/project/manifest/import_tests.rs`、`.../src/tests/project.rs`、`apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` §3.8、`docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 +- 验证方式:`cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- project::manifest::import_tests::` 与 `-- tests::project::`;前端 `agc:typecheck` 与 `runtime-settings` / `pluginHost` 套件。 + ## 2026-09-21 模板正文目录门禁:CLI 打包与后台上传同一份段名单 - 背景:模板包组织指南把 `.agent/`、`.git/`、`node_modules/`、根目录 `dist/` 等列为「不要放进 ZIP」,但两条发布路径此前只校验路径安全与 `entry` 是否存在,放进去的东西会跟着建到用户项目里(模板自带 `.agent/` 会让新项目继承一个陌生身份)。这条约定只靠作者自觉。 @@ -8239,7 +8276,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-10 AGC 打开现有 Godot 项目 - 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。 -- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。 +- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。(2026-09-21 部分取代:多命中改为按目录名排序取第一个,`project.godot` 允许链接并按目标判定;候选子目录与更深层的边界不变,见本文件「2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动」。) - 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。 - Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。 - 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d8a765144..db6ea4beb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -7,6 +7,15 @@ - 处理:客户端画布图标生成只 trim 描述并以单项 `iconDescriptions` 原样提交,保留内部换行;面板与原生入口按 `200` 个 Unicode 码点校验并拒绝空白或超限输入,不静默截断、不机械拆条。服务端共用的提示词流程负责规范图、背景与排布要求;其它图片生成的 `32000` 字符上限保持不变。 - 验证:检查实际请求体与 trim 后的原文一致,并覆盖 `200/201` 码点、补充平面字符、换行和空白输入;不能只断言“请求长度未超限”。完整合同见 [画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 +## 2026-09-21 不同渠道的包体在同一台设备安装会互相顶掉 + +- **现象**:在一台已经装了某个渠道 AGC 客户端的设备上安装另一个渠道的安装包,装完后旧客户端直接消失(安装目录被覆盖、卸载项被接管),更新端点、平台服务器与本地登录态一起换成新渠道的;两个渠道的客户端无法共存。 +- **原因**:渠道此前只烘焙了 `plugins.updater.endpoints` 与 `VITE_AGC_PLATFORM_CHANNEL`(`apps/ai-game-creator-shell/scripts/build-release.mjs` 的 `createChannelConfig`),`productName` / `identifier` 用的是渠道无关的基线值。Tauri 的 Windows 安装目录与卸载项由 `productName` 决定,WebView2 数据目录与客户端数据目录由 `identifier` 决定,于是所有渠道落到 `%LOCALAPPDATA%\陶泥儿`、`HKCU\...\Uninstall\陶泥儿` 与 `%APPDATA%\world.genarrative.ai-game-creator`。 +- **处理(现行口径)**:渠道进入安装身份,默认渠道保持基线身份不变,其它渠道派生 `<产品名> <渠道显示名>` 与 `<基线>.<渠道>`;身份与端点在同一次构建期 `--config` 注入。见决策记录 2026-09-21 条目。 +- **核对方式**:装完任渠道的包后看 `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<产品名>` 的 `InstallLocation`、`%APPDATA%\` 与主程序窗口标题是否按渠道分开;同名安装目录或同名数据目录说明身份没有生效。 +- **易错点**:只改安装包文件名或快捷方式名而不改 `identifier`,两个渠道仍会抢同一份 Agent Runner / 项目锁与登录态;反过来把渠道后缀加在默认渠道上,既有安装的升级链会断(客户端认不出旧安装)。相似前缀目录(如 `world.genarrative.ai-game-creator-backup`)不得进入提权 ACL 的 managed 范围。 +- **关联**:`apps/ai-game-creator-shell/scripts/channel-identity.mjs`、`build-release.mjs`、`build-macos-ci.mjs`、`src-tauri/src/config.rs`。 + ## 发布器守卫拒绝时不要把 process.exit 用在 fetch 句柄未关闭处 - 现象:`agc-template-library-publish.mjs --dry-run` 撞上「同一 `templateVersion` 的 ZIP 不得变」门禁时,终端只剩一句 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c`,看不到任何拒绝原因,看起来像脚本崩溃而不是被拒绝。 @@ -4150,6 +4159,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:活会话下 finalization 和 `runner.shutdown_if_idle` 必须失败关闭;分别验证 graceful handler 尾部输出、宽限超时后的 force、忽略 SIGHUP 的 npm 孙进程和 Windows Job 路径,只有 child 已终态、同组残留已处理且 PTY 尾部排空才出现唯一 terminal record。另用允许程序证明代理和固定 cwd 不是文件系统 / 网络沙箱,不得把该现象误写成测试失败或安全能力。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent.rs`。 +## 一次性命令不能把孤儿僵尸误判为仍在执行的进程组 + +- 现象:Linux CI 的命令已退出,但 `command.exec` 返回 `needs-reconciliation`,后续修复计划或输出读取请求一直等不到;普通 WSL 下相同命令可通过。 +- 原因:容器 PID 1 未回收孤儿 `bwrap` 僵尸,`kill(-pgid, 0)` 仍返回成功;主进程已被 wait 回收,后续 leader 启动身份核对必然失败,掩盖了真实退出结果与原本应触发的日志审计错误。 +- 处理:确认 target 终态且回收主进程后扫描 `/proc//stat`,空组或仅含 `Z / X` 成员无需发送信号;有存活成员仍保留 leader 身份门禁,读取失败保守进入 reconciliation,不放宽未知进程组的信号权限。 +- 验证:隔离 subreaper 夹具覆盖 leader 已回收时的存活后代拒绝、孤儿僵尸接受和空组接受;原有诊断命令、审计失败、命令修复与长输出/历史读取测试在不回收孤儿的 PID namespace 下验证。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_exec.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs`。 + ## 命令环境变量、代理和进程组不能冒充 OS 沙箱 - 现象:命令看似使用隔离 HOME / TMP、离线包管理器和不可达代理,仍能直接读取宿主用户文件、用原始 socket 联网,或由 `project.verify` 的平行 npm spawn 绕开 `command.exec` 限制。 @@ -5874,3 +5891,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:① 依赖里只放数据,回调走 ref(`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。 - **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**,renderer 工作集 **254 MB**(修复前 4.2–4.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。 - **关联**:`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`。 + +## 2026-09-21 DirectProject 结构化消息不能逐个拒绝空白文本片段 + +- **现象**:多行正文、末尾空段落或合法引用前后的分隔空格会让有内容的消息报错;编辑器为了保持结构产生的空白文本片段被误判为“聊天内容为空”。 +- **原因**:校验器对每个 `input_text` 单独执行 `trim().is_empty()` 并立即拒绝,混淆了结构化片段合法性和整条消息是否有实际内容。 +- **处理(现行口径)**:`input_text` 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有合法 `agc_resource_reference` / `agc_runtime_region_reference` 时返回“聊天内容不能为空”。两类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 3cd48c7d9..2729c69de 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 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。 +- AGC 安装产品名基线为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;默认渠道 `dev` 保持基线产品名与 identifier `world.genarrative.ai-game-creator` 不变,其它渠道派生 `陶泥儿 <渠道显示名>` 与 `<基线>.<渠道>`,让不同渠道的包体在同一台设备上并存而不互相顶掉(详见《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/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index aed755b62..41778637a 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -35,7 +35,7 @@ - AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。 -- AGC 正式包的平台服务跟随构建渠道:`release` 连接 `https://www.genarrative.world`,`dev` 连接 `https://dev.genarrative.world`;本地 debug 态保留 release/dev/custom 服务器选择,会话凭据始终按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。完整约定见 AGC 客户端更新检查与下载专题。 +- AGC 正式包的平台服务跟随构建渠道:`release` 连接 `https://www.genarrative.world`,`dev` 连接 `https://dev.genarrative.world`;本地 debug 态保留 release/dev/custom 服务器选择,会话凭据始终按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。渠道同时决定安装身份:默认渠道 `dev` 必须保持基线 `productName` / `identifier` 不变,其它渠道派生独立身份,使不同渠道的包体可在同一台设备并存且本地数据不共享;身份与更新端点必须在同一次构建期注入里确定,禁止分别回读默认值。完整约定见 AGC 客户端更新检查与下载专题。 - AGC 模板库灰度复用 `agc:template-library`:未配置关闭,已配置时遵循现有灰度启停、用户 ID/标签和比例规则;服务端返回权威结论,客户端入口和原生清单/下载/建项均执行门禁,主体切换丢弃旧异步结果。公开 OSS 不是保密边界,已创建项目不受影响。 - 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 diff --git a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md index b33697696..5e3dbf114 100644 --- a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md +++ b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md @@ -3,7 +3,9 @@ > 文档状态:`current` > 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范 -更新时间:`2026-09-20` +更新时间:`2026-09-21` + +2026-09-21 更新:解除工作区发现上的两条限制——一层子目录多命中时按目录名排序取第一个(不再整体失败关闭),`project.godot` 允许是符号链接 / Windows reparse point / 硬链接(按链接目标判定);同时设置→扩展 的内置插件行去掉手动「启动 / 停止」,启动统一由前端自动完成、停止改走该行启用开关。 ## 目标与边界 @@ -15,7 +17,7 @@ 将 Godot 编辑器操控接入现有 AGC PluginHost、EditorAdapter、Runner、内置插件开关、权限审计和 Agent 工具链。Windows x64 的 Godot 4.7 及以上标准编辑器是首个实现目标,实机验收使用 4.7.2;其他平台和 .NET 编辑器不得从该结果推断支持。 -初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。链接/reparse point 继续按同一文件边界失败关闭。 +初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。受管描述文件、缓存与运行缓存继续按同一文件边界对链接/reparse point 失败关闭;唯一例外是工作区发现读取的 `project.godot`,它允许是链接并按目标判定。 DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器会在被加载文件旁生成 `~DLL`,因此宿主在 AGC 私有配置目录的运行缓存中按编辑器实例和构建身份准备临时加载副本。AGC 向项目新增一个可扫描的受管 `.gdextension` 描述文件,通过绝对路径引用该实例的加载副本;Godot 可自动生成其同名 `.uid` 伴生文件。DLL 不复制进工程。重新聚焦 Godot 后,由官方文件扫描完成首次加载;不需要用户打开或运行脚本,不创建 EditorPlugin addon,不修改 project.godot 或业务场景文件。编译工具链只属于开发与打包环境,不要求终端用户安装编译器。 @@ -24,9 +26,9 @@ DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器 ## 入口与归属 - 插件 id 为 `agc-godot-editor`,适配器为 `godot-editor`;命令 `godot.editor.execute`、连接能力 `godot.editor.connection`,DirectProject 工具为 `agc_godot_execute`。复用已有扩展列表和启用开关,不建立平行插件管理页面。 -- 项目发现沿用现有 Godot 工作区合同:工作区根保持用户选定目录;实际 Godot 根由普通 project.godot 在根或唯一一层子目录中确定。准备描述文件和读取 Godot 缓存只作用于实际 Godot 根,通用文件工具/Runtime 的工作区根不改变。 +- 项目发现沿用现有 Godot 工作区合同:工作区根保持用户选定目录;实际 Godot 根由根目录或一层直接子目录中的 project.godot 确定,一层命中多个时按目录名排序取第一个(确定性,不再报歧义),`project.godot` 本身是链接时按链接目标判定。准备描述文件和读取 Godot 缓存只作用于实际 Godot 根,通用文件工具/Runtime 的工作区根不改变。 - 平台、内置开关、项目及目标身份必须在执行入口重新检查。插件只能处理宿主传入的当前受控项目,模型不能覆盖项目路径、DLL 路径、端口、令牌或目标实例。 -- Godot 的插件列表、启动和 Agent 工具目录继续按当前 Godot 项目过滤;Cocos/Unity 沿用各自不按工程类型过滤的合同。前端统一消费宿主列表自动启动三种编辑器插件,不在界面重复推断项目类型。Godot 的项目切换仍撤销旧插件上下文并停止旧实例。 +- Godot 的插件列表、启动和 Agent 工具目录继续按当前 Godot 项目过滤;Cocos/Unity 沿用各自不按工程类型过滤的合同。前端统一消费宿主列表自动启动三种编辑器插件,不在界面重复推断项目类型;设置→扩展 的内置插件行只保留启用/禁用与重载,不再提供手动启动/停止入口。Godot 的项目切换仍撤销旧插件上下文并停止旧实例。 - 插件启动先完成项目事件订阅并接收当前受控项目快照,再注册命令和连接能力;命令可见时必须已经具备执行上下文。订阅期间收到的新项目事件优先于迟到的初始快照。 - 只连接已打开且唯一匹配真实工程路径的 Godot Editor;校验 PID、进程启动身份、Godot 版本、握手中的工程路径与会话代次。多个候选、非编辑器、路径不符或已退出的进程均拒绝,不启动或关闭用户编辑器。 - GUI、DirectProject 和 Agent Runtime 的原生操作统一由长寿命 Runner 持有。项目切换使连接失效,迟到回执不能改变新项目状态。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 69559ba2a..68388ccc4 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -1,6 +1,6 @@ # AGC 客户端更新检查与下载 -更新时间:`2026-09-20` +更新时间:`2026-09-21` 本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 @@ -18,6 +18,17 @@ - 验收必须覆盖 dev/release/自定义渠道各自端点和对象地址、独立版本、非法名称、旧 dev 地址延续、release 不写旧迁移桥、网站配置贯通、跨渠道链接拒绝和部分失败。 - 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。 +### 渠道与安装身份合同 + +- 渠道同时决定**更新端点**与**安装身份**,两者都由构建期写入产物。默认渠道 `dev` 保持基线身份 `productName = 陶泥儿`、`identifier = world.genarrative.ai-game-creator`;其它渠道(`release` 与自定义渠道)派生 `productName = 陶泥儿 <渠道显示名>`(`release` → `陶泥儿 Release`、`beta-2` → `陶泥儿 Beta-2`)与 `identifier = world.genarrative.ai-game-creator.<渠道>`。渠道显示名按连字符分段首字母大写,不改动渠道本身。 +- 默认渠道身份**不可变更**:既有安装目录、卸载项、快捷方式与已发布客户端的升级链都建立在基线身份上。渠道身份由 `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 卷名与菜单栏应用名。 +- 同机并存:不同渠道的包体可以在同一台设备上同时安装并同时运行,互不覆盖、互不顶掉;同一渠道的新版本仍是原地升级,因为更新端点与安装身份同属一个渠道。 +- 数据不跨渠道共享:本地项目、工程快照、模板、登录态、诊断日志与 Runner/项目锁按渠道身份分目录。切渠道等于换一个客户端,不迁移、不合并本地数据;渠道内的 origin 隔离规则不变。 +- 主窗口与工作区/启动器窗口标题取构建期产品名,让同机并存的渠道客户端在任务栏与 Alt-Tab 中可区分;默认渠道标题仍是「陶泥儿」。 +- 首装包与更新包的对象名包含产品名(如 `陶泥儿 Release_0.1.96_x64-setup.exe`、`陶泥儿 Release_0.1.96_aarch64.dmg`)。清单 `downloads` 地址由发布脚本按本次真实产物派生,禁止写死产品名;首装包选择按 `<版本>_<架构>.dmg` 唯一匹配,不依赖产品名字面量。 +- Windows 提权 ACL 修复助手按目录名识别安装身份:`<基线>` 与 `<基线>.<渠道>` 都在 AGC 自有的 managed 范围内;相似前缀(例如 `world.genarrative.ai-game-creator-backup`)不在范围内,落回 user-selected 范围或直接拒绝。 + ## 非目标 - 不做灰度放量、分批更新、强制更新和自动回滚;渠道只决定「取哪份清单」。 @@ -110,11 +121,21 @@ - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。 - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。 +- 渠道安装身份映射(`` 为 `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.` | + +- 安装身份迁移:`dev` 客户端保持原身份,升级链路连续;`release` 与自定义渠道首次以新身份安装,**不接管也不迁移**任何既有 `dev` 安装、本地项目或登录态,设备上因此可以同时存在两个渠道的客户端,由用户自行决定是否卸载其一。 + ## 构建与发布 - 发布入口:`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 前先按发布渠道解析产品名,产物名一律派生而不写死。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 @@ -157,6 +178,25 @@ | 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | | 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) | +渠道安装身份隔离已于 `2026-09-21` 完成源码验收: + +| 条款 | 验收方式 | 结果 | +| --- | --- | --- | +| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿` / `world.genarrative.ai-game-creator`,`release` / `beta-2` 派生独立产品名与 identifier,非法渠道失败关闭 | +| 身份与端点同批注入 | 同上的渠道 `--config` 用例 | 通过;`productName` / `identifier` 与 `/-win|mac/latest.json` 来自同一次解析 | +| 渠道产物首装包选择 | 同上的渠道 DMG 夹具用例 | 通过;`陶泥儿 Release_<版本>_aarch64.dmg` 仍按 `<版本>_<架构>.dmg` 唯一匹配 | +| 基线配置等于默认渠道身份 | `node apps/ai-game-creator-shell/scripts/check-config.mjs` | 通过;基线漂移与非默认渠道身份不隔离都会失败关闭 | +| 全量发布脚本回归 | `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` 并完成构建;产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0 | + +未验证项(不得按已通过处理): + +- 真实 Windows 双渠道安装与并存:尚未在同一台设备安装 `dev` 与 `release` 两个渠道的安装包,安装目录/卸载项/数据目录的分离与两个客户端同时运行属于发布验收,本次只到源码与脚本层级。 +- 未生成安装包:本轮构建烟测止于 `--no-bundle`,NSIS 安装目录 / 卸载项 / 快捷方式按渠道分开、以及「装完 release 后 dev 仍在」的现场证据需要一次真实渠道打包与安装。 +- macOS 侧只验证到入口派生逻辑:`.app` 名、bundle id 与 DMG 卷名的渠道派生没有在 macOS 节点实跑。 +- 客户端数据隔离的运行期事实(`%APPDATA%\` 分目录、登录态不跨渠道)未做真机对照。 + 待执行证据(首次渠道发布后回填): | 条款 | 验收方式 | 证据 | @@ -174,6 +214,7 @@ - macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 arm64 单架构构建、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)、分区清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为关(与 Windows 渠道对称,即直接发布),只有勾选后才退化为「只打印上传计划、不写 OSS」的演练。 - macOS 代码签名与公证暂缺:产物为未签名 + 未公证,构建入口剥离 `APPLE_*` 凭据跳过 Apple 签名,不传 `--no-sign`(它还会跳过 updater 的 minisign 签名,产物将没有 `.sig`);构建清单实测记录 `appleSigned` 与签名类型,`latest.json` 侧固定记录 `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 - 更新包验签门禁:构建完成、上传 OSS 之前,用产物内烘焙的 `plugins.updater.pubkey` 复核 `<更新包>.sig`(Tauri 使用 minisign 的 `ED` 预哈希模式)。keyId 不一致或校验失败立即失败关闭,禁止上传——客户端校验失败会直接拒绝安装,且公钥发布后不可更换。 +- 渠道安装身份(2026-09-21):渠道此前只决定更新端点,`productName` / `identifier` 与渠道无关,导致不同渠道的包体共用 `%LOCALAPPDATA%\陶泥儿`、同一个卸载项与同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录,后装的渠道静默顶掉先装的渠道并接管更新端点与本地登录态。现决策为「渠道进安装身份」:默认渠道保持基线身份不动,其它渠道派生 `<产品名> <渠道显示名>` 与 `<基线>.<渠道>`,渠道内仍原地升级,同机并存与数据隔离成立。窗口标题、macOS 产物名与 Windows 提权 ACL 的 managed 识别范围同批跟随该身份。 待办: diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index c320b6b0f..90aa75885 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -86,9 +86,11 @@ templates/ - 只释放本任务已明确获取且 owner 标识仍一致的锁。获取结果不明时不猜测删除。正文对象不可变,其写入失败可安全释放本任务的锁;清单 PUT 已发起后若遇到断连、超时或服务端 5xx 等不确定结果,必须保留锁并报错,防止旧在途请求晚于下一发布者写入。清单收到确定成功或确定拒绝响应后才进入正常解锁路径;不自动重发清单写入,也不凭一次 GET 猜测在途 PUT 已结束。遗留锁须在确认原请求及进程已终止或完成后由运维处理;释放失败必须报告,不伪装为发布成功。 - `--dry-run` 仅构造和读取合并计划,不读取凭据、不获取锁、不 PUT/DELETE。发布不删除历史对象,也不提供随发布清理的选项;旧客户端缓存和未完成下载可能仍引用旧键。 - 同一 ID、同一 `templateVersion` 的 ZIP 大小或摘要改变时,在上传正文前拒绝,要求更新模板版本;只把相同 ZIP 迁移到新键可保留版本。客户端现有 `/` 缓存行为不变。 -- 当前模板:`blank-web`(空白网页)、`blank-2d-canvas`(空白二维画布)、`blank-3d-scene`(空白三维场景)、`phaser-2d-starter`(Phaser 2D 起步工程)、`threejs-3d-starter`(Three.js 3D 起步工程),以及 Cocos Creator 3.8.8 的 `cocos-empty-2d`、`cocos-empty-3d`、`cocos-empty-3d-hq`、`cocos-hello-world`。 +- 当前模板:`blank-web`(空白网页)、`blank-2d-canvas`(空白二维画布)、`blank-3d-scene`(空白三维场景)、`phaser-2d-starter`(Phaser 2D 起步工程)、`threejs-3d-starter`(Three.js 3D 起步工程)、Cocos Creator 3.8.8 的 `cocos-empty-2d`、`cocos-empty-3d`、`cocos-empty-3d-hq`、`cocos-hello-world`,以及 Godot 4.7 的 `godot-empty-2d`、`godot-empty-3d`、`godot-hello-world`、`godot-platformer-2d`。 - Cocos 内容来自 Creator 3.8.8 随附的 `resources/templates/{empty-2d,empty,empty-quality,hello-3d-world}`,保留官方资源、`.meta`、设置和模板预设;补齐 `package.json.creator.version`,空模板以 `assets/.gitkeep` 保证资源目录进入 Git 和 ZIP。`entry` 为 `package.json`,不打包编辑器生成的缓存或用户项目数据。 - Cocos 建项在复制后按实际 `package.json.creator.version + assets/` 识别,复用既有 Cocos 导入流程,写入 `cocosProjectRoot: "."`;每个新项目重建 `package.json.uuid` 并写入所选项目名。只创建 `.agent` 管理目录,不生成 Web 占位入口;模板源与本机安装缓存不被改写。 +- Godot 内容为仓库内手写的 Godot 4.7 工程(`project.godot` + `scenes/` + `scripts/`,GL Compatibility 渲染,只用内置 `ui_*` 输入动作,不依赖外部贴图),`entry` 为 `project.godot`,不打包 `.godot/` 编辑器缓存与导出产物。 +- Godot 建项在复制后按 `project.godot` 识别,复用既有 Godot 导入流程,写入 `godotProjectRoot: "."`,并把工程显示名改写成用户选择的项目名(只改 `[application]` 段的 `config/name` 一行,其余字节逐字保留);不生成 `game/` 占位入口与 `assets/`、`memory/`、`exports/` 并行目录,也不改写模板源与本机安装缓存。 - 客户端可用 `AGC_TEMPLATE_LIBRARY_BASE_URL` 覆盖库地址;只接受 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com`(拒绝其他主机、路径、http)。 ## 客户端实现 @@ -99,7 +101,7 @@ templates/ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache`。远端已经答话但正文不是合法 UTF-8 或不符合 schema、以及缓存自己损坏时,一律失败关闭,不用缓存掩盖远端错误 | | `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `/templates/installed///`,最后写 `installed.json` 作为安装完成的唯一标记 | -| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) | +| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,Godot 项目改写工程显示名后走既有 Godot 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) | 安全与健壮性: @@ -135,6 +137,8 @@ Cocos 回归分别覆盖仓库模板和线上真实 ZIP 的安装、连续建项 `2026-09-21` 模板正文目录门禁:CLI 打包(`readProjectFiles`)与后台上传(`validate_import_archive`)统一拒绝正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的模板,两处用同一份段名单与同一句文案。同名目录段只在根目录受限:正文内 `game/dist/**` 与 `.gitignore` 仍合法(`.gitignore` 不等于 `.git`),Cocos 模板的 `.creator` / `.gitignore` 不受影响。证据:Node 发布回归 28 项通过(新增 1 项,13 条拒绝用例 + 2 条放行断言);Rust `admin_templates` 8 项回归通过(新增 `template_import_archive_rejects_identity_and_build_directories`);仓库现有 9 个模板源无一条命中门禁,本地重打包的 9 个 ZIP 摘要与线上 `index.json` 的 `zipSha256` 逐条一致(匿名读清单 200、9 个模板、0 个下架条目),即门禁未改变任何已发布字节。未执行真实 OSS 写入。 +`2026-09-21` Godot 模板入库:新增 `godot-empty-2d` / `godot-empty-3d` / `godot-hello-world` / `godot-platformer-2d` 四个仓库内手写模板,并给建项补 Godot 分流。模板内容用本机 Godot `4.7.2.stable` 逐项验证:四个模板的主场景都能实例化并跑满 3 帧、全部 GDScript 过 `--check-only`(同一条命令对故意写错的脚本报告 Parse Error,证明检查有效);平台跳跃模板另做真机物理试玩——角色落地 y=627.99(地面顶 656 − 半高 28)、按住右 1 秒位移 320px、连按跳跃后落在平台上 y=515.93(平台顶 544 − 28)、三枚金币全部收集后 HUD 变为「已收集 3 / 3 —— 全部完成!Esc 重来」。Rust 侧 4 项定向回归通过:`template_library::tests::godot_template_creates_native_project_with_relative_root_and_display_name`(Godot 模板建项得到 `godotProjectRoot: "."`、无 `game/` 占位入口、`config/name` 改写成所选名称且其余行逐字保留)、`import_tests::rewrites_only_the_godot_display_name_line`、`import_tests::keeps_a_godot_project_without_a_display_name_line_untouched`,以及既有 Cocos/Web 建项回归未受影响。发布保持 `--only` 定向:只新增四个 Godot 模板对象并重写清单,其余 9 个模板的键与版本不变;未在客户端「模板库」里实际建一次项目。 + ## 本地压测假数据注入(feature 控制) 模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 39a9f1760..b07266394 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -254,6 +254,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - - `cwd` 必须是项目内规范相对目录,拒绝符号链接、绝对路径、`..`、Windows 盘符 / UNC / ADS 和整个 `.agent` 控制面;超时固定在 1-300 秒,stdin 关闭,stdout / stderr 采用有界头尾保留并先做凭据清洗。 - 默认权限为 `confirm`。确认摘要包含程序、argv 摘要、cwd 和超时;精确动作继续绑定 actionId、repository fingerprint、project revision 和 execution owner。Runner 在 `executing` 阶段退出时保持 `needs-reconciliation`,不得自动重放命令。 - 子进程继承环境清空;可执行文件必须从项目外安全绝对目录解析为绝对路径,子进程 PATH 只保留这些已规范化目录,并注入隔离 HOME / TMP / cache、离线包管理配置和不可达代理。超时或读流失败时 Runtime 请求终止受控进程组并检查终止调用结果,但这不等同于完整 detached-process / 容器隔离。首版安全等级与现有 `project.verify` 相同:固定程序和参数策略加用户确认,不宣称已经具备 Codex CLI 的完整 OS sandbox;在完成平台沙箱前不得把 `command.exec` 默认改为 `auto`。 +- Linux 一次性命令确认 target 终态并回收主进程后,按 `/proc//stat` 核对同 PGID 成员;空组或只剩 `Z / X` 成员无需再发送信号,不因容器 PID 1 未回收孤儿僵尸而误报 reconciliation。存在存活成员时仍必须核对原 leader 启动身份,身份缺失或不匹配时拒绝发送信号;读取或解析进程状态失败同样进入 reconciliation。此判断不扩展为 detached-process 隔离证明,也不改变诊断命令与验证 gate 的区分。 - Cargo / npm 缓存固定写入项目私有 `.agent/runtime/command-env/cache`,不复用或改写用户宿主缓存,也不允许联网补依赖。依赖未进入项目 vendor、现有 `node_modules` 或隔离缓存时,命令应以真实失败输出回到 Agent;首版不为“跑通命令”复制宿主的 Cargo registry、凭据或用户级配置。 - `command.exec` 的执行前后源码指纹各自最多遍历 20,000 个目录项、10,000 个受保护文件和 512 MiB 正文;执行前超预算直接拒绝启动,执行后无法完成指纹则进入 `needs-reconciliation`,不得把截断扫描当成完整验证凭证。 - 命令结束后重建安全项目文件指纹。若命令改写了受保护项目文件,则保持 verification gate 未通过并要求 Agent 重新检查;每次真正启动命令前已经保守推进一次 revision。可签发验证凭证的命令仅限 `cargo check/test/clippy/fmt/build`、`npm test`、命名为 `check/typecheck/test/lint/build/verify/validate` 的 npm 验证脚本及精确 `node --test`;`git`、`rg`、`cargo metadata` 和普通 `npm run` 即使退出码为 0 也只作为诊断结果。只有验证型命令退出码为 0、未超时、未改写受保护文件,且命令日志、manifest 投影和 Agent DB 审计全部成功后,才允许绑定当前 revision 的 passed gate;任一审计失败必须先保持 failed gate,再进入 `needs-reconciliation`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 734793a4a..8dada4d86 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,16 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 + +本节覆盖下文“打开项目自动识别 Godot”中的旧口径:判定从「唯一命中」放宽为「确定性命中」,`project.godot` 从「必须是普通文件」放宽为「按链接目标判定」。 + +| 要求 | 必须成立的行为 | 完成证据 | +| --- | --- | --- | +| 确定性多命中 | 根目录未命中时,一层直接子目录中多个 `project.godot` 命中按目录名排序取第一个;不再报「多个 Godot 项目」,也不再要求用户改选具体工程 | `cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- project::manifest::import_tests::` 与 `-- tests::project::` | +| 链接标记 | `project.godot` 是符号链接 / Windows reparse point / 硬链接时按链接目标判定,目标解析为普通文件即命中;目录与悬空链接仍不算命中 | `import_tests::accepts_symbolic_link_project_marker`(unix)、`import_tests::accepts_windows_hard_link_project_marker`、`import_tests::accepts_windows_reparse_project_marker` | +| 保持不变的边界 | 工作区根本身是链接、候选子目录是链接、二层及更深目录不递归,这三条既有边界不动 | `import_tests::ignores_symbolic_link_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_windows_reparse_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_godot_projects_below_the_first_child_level` | +| 内置插件行 | 设置→扩展 的内置插件行不再渲染手动「启动 / 停止」按钮;启动由项目切换时的前端自动启动承担,停止走该行启用开关(禁用即停止并断开编辑器连接);导入扩展行的启动按钮保留 | `apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`tests/pluginHost.test.ts` | + ## 2026-09-20 DirectProject 七项效率闭环(补齐合同) 本节补齐并覆盖下节中仅靠 Skill 要求预检、收尾、批读和原生命令预算的部分。完整目标仍为:自动预检、宿主验收与收尾、分层验证、统一执行/返修预算、稳定测试基线、请求耗时与批量读取、所有工具并行。已有代码及测试不等于全部目标已完成;按下表逐项验收。 @@ -48,6 +59,7 @@ ### 宿主验收与执行许可合同 - 正式 GUI 和 CLI 的共同 Direct 回合入口建立宿主控制状态,绑定 canonical 项目路径、稳定 clientTurnId 和原始用户输入摘要;宿主私有目录保存权威账本并独占该回合,项目 `.agent` 仅允许保存展示副本。配置或项目侧文件被改写、工具切换、Provider 重试和进程重启不得刷新同一回合的预算。 +- Direct 回合集成测试也按生产入口计算原始用户输入的 SHA-256 十六进制摘要(64 字符),不能用请求名称替代。用户回显过滤回归继续覆盖实时消息去重、回合起止身份关联及历史落盘过滤。 - 普通聊天与读取不要求交付合同。首次修改、代码执行或付费扩项之前,模型通过结构化工具登记本轮必需范围与验收项;合同非空、有界且只冻结一次。模型只能声明要求,不能提交“通过”作为证据。后续扩项留到新的用户回合。 - 明确新 Web 创建由宿主可信脚手架凭证及尚未交付的宿主记录判定,CLI 同样据此判定,不从提示文本猜测;这种回合即使模型没有调用工具或没有登记合同,也不得按普通聊天宣布交付。已有项目只有未激活合同且从未产生副作用时才允许直接聊天结束。 - 验收项为明确类型的产物、构建/测试命令、双端视觉或指定固定场景的双端玩法。可信新 Web 游戏由宿主补充构建、双端视觉和玩法底线,不能由模型声明“已有项目”降低。已有项目按冻结的变更范围选择层级;平台美术只在用户目标要求时成为必需项。 @@ -579,7 +591,8 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式合同:客户端 AppData 配置新增全局 `agentMode`,只接受 `codex_cli / provider`。缺省和新安装默认使用 `codex_cli`,原有 HTTP LLM Provider 路径完整保留并可显式切回 `provider`;切换只影响下一次节点请求,不新增 Runner、任务图、会话库、配置库或业务事实源。 - 调度边界:正式 DAG、manifest、Agent task/session/run 身份、队列、锁、委派、all-join、完成门、Provider lifecycle、持久 retry/handoff 与 `needs-reconciliation` 继续由现有 AGC Runtime 掌控。每个被调度节点在 `codex_cli` 模式下直接启动一次非交互 `codex exec` 充当该节点的推理 Agent;Codex 返回当前 Runtime 广告函数的结构化调用,Runtime 仍是唯一 ToolHost,不允许 CLI 自己写项目、执行命令、调用 MCP 或形成第二套 revision / verification 真相。 - 安装包侧车:Windows x64 release 固定随 Tauri resource 打包 `@openai/codex@0.155.1` 的原生 `codex.exe`;Rust build script 从 AGC 子包锁定依赖 stage 到 resource,并写入版本与 SHA-256 清单。固定版本只在 `build_support/codex_bundle.rs` 声明一次,构建脚本、宿主补丁执行器身份、逐次审批协议允许列表和模型目录捕获共同引用它,避免多处字面量漂移。Windows 侧车映射只写入 `tauri.windows.conf.json`,通用 `tauri.conf.json` 不得让 Linux / macOS 构建依赖未生成的 Windows 二进制。运行时只在文件摘要和 `codex-cli` 版本同时匹配清单时优先选内置侧车;缺失、损坏或版本漂移时跳过它,按既有 npm 安装、PATH 顺序回退。安装包同时携带 Apache-2.0 第三方声明;API Key、`auth.json`、Cookie、Token、用户 `CODEX_HOME`、用户配置和项目数据绝不打包。 -- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述统一由 `tauri.conf.json` 的 `productName: "陶泥儿"` 生成;应用 identifier 与内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 +- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述由 `tauri.conf.json` 的 `productName` 生成:基线是 `陶泥儿`,发布构建按渠道由 `--config` 覆盖为 `陶泥儿 <渠道显示名>`,identifier 同批派生 `<基线>.<渠道>`(默认渠道保持基线值),因此不同渠道的包体可在同一台设备并存;内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 +- Windows 渠道 AppData 目录归属判断及其调用分支统一保留 Windows 条件编译;通用项目路径策略测试仍可在 Linux 运行,实际 ACL 修复保持原平台门禁与授权范围。 - macOS 安装包必须携带锁定版本的原生 Codex、`codex-code-mode-host`、`rg`、上游 zsh、`codex-package.json` 和第三方声明,保留上游相对布局;构建时按 Cargo 目标选择 npm 原生依赖,缺文件、版本或目标不匹配立即失败,不借用开发机 PATH 里的 Codex。资源只在 `tauri.macos.conf.json` 映射到 `Contents/Resources/coding-agent/mac-native/darwin-arm64/` 与 `darwin-x64/`。构建与运行共享平台文件白名单,运行时由当前 `.app/Contents/MacOS` 定位相邻 `Resources`,完整性与版本验证通过后优先使用内置组件;失败沿既有外部安装回退,不能运行未校验的内置文件。macOS 当前只构建 arm64 单架构,但资源映射仍并列携带两套锁定原生 Codex 依赖(运行切片按 Cargo 目标只选择对应目录),恢复 Intel 时无需改动资源布局;随包 Node 只有宿主架构那一份,所以不得构建 universal 包。不读取全局 Codex。 - 内置插件的清单、运行入口与面板同时在 Windows/macOS 随包分发,继续由既有 PluginHost 的应用资源目录扫描入口发现;不携带开发依赖、缓存、测试或私有配置。插件文件随包不等于原生适配器跨平台:Cocos 进程桥接仍受现有 Windows 实现和 feature 门禁约束,macOS 原生桥接另行设计与验收,不复制 Windows DLL 冒充支持。系统 Node、用户 Cocos Creator、账号登录、网络和生成工程的 npm 工具链仍是现有外部前提,不在此次 Codex 侧车补齐中隐式变更。 - macOS 安装包验收必须包括:脱离仓库位置的 `.app` 资源与架构检查、受限 PATH/隔离 HOME 下内置 Codex 启动和 app-server 握手、必需文件缺失/篡改/平台错误的拒绝测试,以及 DMG 完整性检查。真实登录、Provider 对话、GUI 和 Cocos 操作必须独立列出证据,不能用压缩包生成或 `--version` 成功替代。未配置正式签名、公证的本地测试包不得作为公开发行包。 @@ -1784,7 +1797,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 本地索引是增量对比的唯一依据:`/project-snapshots//index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。 - 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state` 与 `sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。 - 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。 -- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v1/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v1/{userId}/{projectId}/manifest.json`。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。 +- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/manifest.json`;`channel` 是本部署渠道(`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL`,缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`),同一 bucket 因此天然按渠道分区,开发与正式部署互不可见对方项目。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀(含历史无渠道的 `agc/project-snapshots/v1/`)继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。后台“项目工程”按渠道查询与下载,渠道名非法时失败关闭,历史 v1 对象不再列出。 - 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`。只允许凭据回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`;bucket 与 endpoint 不跟随资源存储的 `ALIYUN_OSS_BUCKET` / `_ENDPOINT`,避免默认写入其它 bucket。显式快照目标配置继续优先;不自动搬迁其它 bucket 的现存数据。 ### 正常、失败、重试与幂等行为 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 27a2ba1ed..cf880126f 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -15,6 +15,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 +输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个通过既有校验的 `agc_resource_reference` / `agc_runtime_region_reference`,否则返回“聊天内容不能为空”。两种引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。 + ## 拖拽引用(2026-09-21) 除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ca6d7ec82..23d4a3cd5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -147,6 +147,8 @@ revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 手工发布入口 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/` 与 `agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。 +渠道参数同时决定安装身份:默认渠道 `dev` 产出 `productName=陶泥儿` + `identifier=world.genarrative.ai-game-creator`(既有安装与升级链),其它渠道产出 `陶泥儿 <渠道显示名>` + `<基线>.<渠道>`,因此同一台设备可以同时装 `dev` 与 `release` 两个客户端,互不覆盖、数据不共享(渠道与安装身份合同见《AGC客户端更新检查与下载》)。发布后核对发布对象名(如 `agc/release-win/<版本>/陶泥儿 Release_<版本>_x64-setup.exe`)与装机后的卸载项、`%APPDATA%\` 是否按渠道分开。 + `Genarrative-Agc-Windows-Build` 的 `Tauri NSIS toolchain` 阶段必须在 Rust 编译前预置 NSIS 工具链并失败关闭:tauri-bundler 打包时现场从 GitHub 下载 `nsis-3.11.zip` 与 `nsis_tauri_utils.dll` 且不重试,构建机每次检出都会重下,响应一旦被截断就只能抛 `io: unexpected end of file`,让发布在编译数分钟后才失败。该阶段先跑 `node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs`(固定 SHA1 校验、4 次重试、解压到 `target/.tauri/NSIS`),再执行 `makensis.exe -VERSION` 验证可执行性;Checkout 的 `git clean -fdx` 必须带 `-e apps/ai-game-creator-shell/src-tauri/target/.tauri`,只保留这份工具缓存、其余 `target/` 内容照常清空,否则工作区内缓存会被每个构建删掉,退回到「每次从 GitHub 重下」(实测裸 `git clean -fdx` 会输出 `Would remove apps/ai-game-creator-shell/src-tauri/target/`);原始归档缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),因此同一节点只有冷缓存才需要联网,离线补缓存时把这两个文件放进缓存目录即可;构建机确实无法访问 GitHub 时使用 bundler 自带的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR` 指向可达镜像。升级 `@tauri-apps/cli` 时必须同步核对 `nsis-toolset.mjs` 里的归档地址、SHA1 与必需文件清单(与 tauri-bundler 的 `NSIS_REQUIRED_FILES` 逐条对齐),否则预置会被 bundler 判为不完整。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 @@ -552,7 +554,7 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null ### AGC 项目快照上传目标 -后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 +后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 @@ -561,15 +563,23 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null AGC 客户端按周期与项目关闭时机把用户项目增量上传到 `agc-dev`。客户端只持有平台登录态 Access Token,经 `POST /api/agc/project-snapshots/files`(单文件原始字节)与 `POST /api/agc/project-snapshots/manifest`(本次同步清单)交给 `api-server`,由服务端写入私有前缀 -`agc/project-snapshots/v1/{user}/{project}/`;客户端不直连 OSS,也不持有 OSS 凭据。 +`agc/project-snapshots/v2/{channel}/{user}/{project}/`;`channel` 是本部署渠道,客户端不上报渠道、也不直连 OSS, +因此同一 bucket 可以由开发与正式两套部署分别落在 `dev/` 与 `release/` 下。 ```env GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev ``` +`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL` 未配置时沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(缺省 `dev`); +正式部署必须显式设成 `release`,否则正式上传会写进 `dev/` 渠道。渠道名必须是小写字母开头的 +小写字母 / 数字 / 连字符(≤32 字符),非法时上传与后台查询都返回 `503`/`400`,不会回落到别的渠道。 +历史无渠道前缀 `agc/project-snapshots/v1/` 只保留对象、不再写入也不再进入后台列表;需要取回历史对象时 +按旧前缀直接在 OSS 侧读取。 + 专用凭据为空时回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,bucket 与 endpoint 仍默认指向 AGC 发行 bucket,因此回退凭据必须具备目标 bucket 该前缀的 `PutObject` / `GetObject` / `DeleteObject` 权限;`api-server` 启动时会打印一行 `AGC 项目快照 OSS 客户端已启用`(含 bucket、 @@ -578,7 +588,7 @@ endpoint 与凭据来源,不含密钥),凭据缺失或只配一半则跳 远端占用按当前清单收敛:每次清单写入成功后,服务端用上一版清单反推不再被引用的对象键并删除,单次最多 回收 2000 个,上一版清单不可读时整轮跳过(不会误删)。因此不需要额外配置 bucket 生命周期来防止无界 -增长;`agc/project-snapshots/v1/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、 +增长;`agc/project-snapshots/v2/{channel}/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、 单次同步上传预算 512 MiB、单项目常驻 2 GiB;超限分别表现为跳过/延后/413。服务端另有进程内小时配额 (文件 3000 次、清单 120 次)与同一项目 5 秒最小清单间隔,超限返回 `429` 并带 `Retry-After`。 @@ -736,7 +746,7 @@ Mac 单架构(arm64)构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator- Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenkinsfile,参数为 `SOURCE_BRANCH`、`COMMIT_HASH`、`AGC_UPDATE_CHANNEL`、`AGC_RELEASE_VERSION`、`AGC_RELEASE_DRY_RUN`、`AGC_UPDATE_RELEASE_NOTES`、`OSSUTIL_BIN`、`CARGO_BUILD_JOBS`、`NOTIFICATION_EMAILS`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 -该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从 Tauri `productName` 推导,校验脚本从包内 `Info.plist` 读取可执行名,改产品名不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从渠道安装身份派生的产品名推导(默认渠道即基线 `productName`,`release` 等渠道带渠道后缀),校验脚本从包内 `Info.plist` 读取可执行名,改产品名或换渠道都不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 与 Windows 渠道对称:默认关闭即真发布,勾选后才退化为演练(只打印将上传的对象、不写任何 OSS 对象)。本 Job 是正式发布入口,调度器在发号后与 Windows 一起触发它,并额外传 `SKIP_IF_SUPERSEDED=true`——Mac 节点是日常办公机,离线期间排队的旧构建在节点回来后若已被源码分支推进,直接跳过而不发布过期版本。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 diff --git a/docs/【模板规范】AGC模板包组织指南-2026-09-21.md b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md index 933e30080..f92bd0183 100644 --- a/docs/【模板规范】AGC模板包组织指南-2026-09-21.md +++ b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md @@ -39,6 +39,16 @@ assets/… # 可选 - 建项时会在复制模板文件之后补齐 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`,并创建 `.agent/` 清单;这些目录不用在模板里手工占位。 - 模板没有 `game/index.html` 时,建项会写入一份默认入口占位页;所以 html 类模板应当自带 `game/index.html`,并把 `entry` 填成 `game/index.html`。根目录的 `index.html` 不会被当成游戏入口(建项仍会补默认 `game/index.html`),不要用这种结构。 +### Godot 项目(`runtime=godot`) + +- 根目录直接放 Godot 工程:`project.godot`(此时 `entry` 用 `project.godot`)、`scenes/`、`scripts/`、`icon.svg`、`.gitignore` 等,`.import` / `.uid` 之类由引擎生成的伴生文件不必手工准备。 +- `project.godot` 要能独立打开:`config_version=5`,且 `[application]` 段的 `run/main_scene` 指向工程内真实场景。建项会把 `config/name` 改写成用户选择的项目名(只改这一行),模板里的名字只是初始值。 +- 目标版本是 Godot 4.7:`config/features=PackedStringArray("4.7", "GL Compatibility")` 配套 `renderer/rendering_method="gl_compatibility"` 兼容面最大,2D 与轻量 3D 模板都用这一套。 +- 建项按 `project.godot` 识别 Godot 模板,写入 `godotProjectRoot: "."`,不生成 `game/` 占位入口与 `assets/`、`memory/`、`exports/` 并行目录。 +- 不要打包 `.godot/` 编辑器与导入缓存、导出产物(`export/`、`build/`);模板正文保持「克隆下来直接就能打开」的源状态。 +- 输入优先只用 Godot 内置的 `ui_*` 动作;需要自定义动作时把 `[input]` 段写进 `project.godot`,不要让模板依赖开发者本机的输入映射。 +- 建议不引入外部贴图与音频,用 `Polygon2D`、`ColorRect`、`BoxMesh` 一类节点搭出可见骨架,避免模板源里出现难以复核的二进制素材。 + ### Cocos Creator 项目(`runtime=cocos`) - 根目录直接放 Creator 工程:`package.json`(此时 `entry` 用 `package.json`)、`assets/`、`settings/`、`profiles/`、`.creator/`、`tsconfig.json`、`.gitignore` 等官方结构**原样保留**,`.meta` 与导入设置必须一起带上,否则导入后资源关系会丢。 @@ -94,7 +104,7 @@ assets/… # 可选 ## 发布前自检 -1. 核对 ZIP 结构:`unzip -l your-template.zip`,确认没有外层目录、`entry` 存在、没有 `.agent/`、`.git/`、`node_modules/`、`dist/`。 +1. 核对 ZIP 结构:`unzip -l your-template.zip`,确认没有外层目录、`entry` 存在、没有 `.agent/`、`.git/`、`node_modules/`、`dist/`(Godot 模板另需确认没有 `.godot/`)。 2. 核对体积与条目数(见上表)。 3. CLI 路径:`node scripts/agc-template-library-publish.mjs --source --dry-run` 查看合并计划与摘要,确认后再去掉 `--dry-run`。 4. 后台路径:上传页逐行核对 ID / 名称 / 版本 / 运行时 / entry,确认封面已按 ID 匹配。 diff --git a/server-rs/crates/api-server/src/admin_project_snapshots.rs b/server-rs/crates/api-server/src/admin_project_snapshots.rs index 427281191..74aa9b684 100644 --- a/server-rs/crates/api-server/src/admin_project_snapshots.rs +++ b/server-rs/crates/api-server/src/admin_project_snapshots.rs @@ -1,6 +1,7 @@ //! 后台按项目读取私有快照,并在完整性验证后导出原始工程目录。 use std::{ + collections::BTreeSet, future::Future, io::Write, sync::{Arc, OnceLock}, @@ -18,11 +19,13 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use platform_oss::{ OssClient, OssError, OssGetObjectRequest, agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key, project_snapshots::SnapshotDirectoryPage, + validate_agc_project_snapshot_channel, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared_contracts::{ admin::{ + AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotDownloadQuery, AdminProjectSnapshotItem, AdminProjectSnapshotStatus, AdminProjectSnapshotsQuery, AdminProjectSnapshotsResponse, }, @@ -45,6 +48,7 @@ use crate::{ project_snapshots::{MAX_MANIFEST_REQUEST_BODY_BYTES, project_snapshot_oss, validate_manifest}, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; const MAX_DIRECTORY_REQUESTS: usize = 100; @@ -55,6 +59,7 @@ static DOWNLOAD_PERMITS: OnceLock> = OnceLock::new(); #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct SnapshotCursor { + channel: String, user: Option, project: Option, finished_user: bool, @@ -62,9 +67,10 @@ struct SnapshotCursor { } impl SnapshotCursor { - fn decode(value: Option<&str>) -> Result { + fn decode(value: Option<&str>, channel: &str) -> Result { let Some(value) = value.filter(|value| !value.is_empty()) else { return Ok(Self { + channel: channel.to_string(), finished_user: true, ..Self::default() }); @@ -77,6 +83,10 @@ impl SnapshotCursor { .map_err(|_| bad_request("项目列表游标无效"))?; let cursor: Self = serde_json::from_slice(&bytes).map_err(|_| bad_request("项目列表游标无效"))?; + // 游标只在同一渠道内有效:跨渠道复用会让目录推进落在别的渠道上。 + if cursor.channel != channel { + return Err(bad_request("项目列表游标与渠道不一致")); + } for segment in [&cursor.user, &cursor.project].into_iter().flatten() { validate_agc_project_snapshot_project_id(segment) .map_err(|_| bad_request("项目列表游标无效"))?; @@ -115,6 +125,7 @@ trait SnapshotStore: Sync { struct OssSnapshotStore<'a> { oss: &'a OssClient, client: &'a reqwest::Client, + channel: String, } impl SnapshotStore for OssSnapshotStore<'_> { @@ -125,7 +136,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { limit: usize, ) -> Result { self.oss - .list_project_snapshot_directories(self.client, user, after, limit) + .list_project_snapshot_directories(self.client, &self.channel, user, after, limit) .await .map_err(|_| upstream("读取项目工程目录失败")) } @@ -135,7 +146,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { user: &str, project: &str, ) -> Result, AppError> { - let object_key = agc_project_snapshot_manifest_object_key(user, project) + let object_key = agc_project_snapshot_manifest_object_key(&self.channel, user, project) .map_err(|_| bad_request("项目工程身份无效"))?; let bytes = match self .oss @@ -169,6 +180,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { ) -> Result, AppError> { let digest = file.checksum.strip_prefix("fnv1a64:").unwrap_or_default(); let object_key = agc_project_snapshot_file_object_key( + &self.channel, user, project, file.size_bytes, @@ -189,7 +201,11 @@ impl SnapshotStore for OssSnapshotStore<'_> { } } -fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProjectSnapshotItem { +fn item( + channel: &str, + user: String, + manifest: &AgcProjectSnapshotManifestRequest, +) -> AdminProjectSnapshotItem { AdminProjectSnapshotItem { user_id: user, project_id: manifest.project_id.clone(), @@ -206,6 +222,19 @@ fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProj Some(_) => AdminProjectSnapshotStatus::Partial, None => AdminProjectSnapshotStatus::Unverified, }, + channel: channel.to_string(), + author_display_name: None, + author_public_user_code: None, + } +} + +/// 后台列表的“用户”列与素材查询同口径:昵称 + 陶泥号,账号不可解析时沿用占位作者。 +/// 查账号只影响展示,失败不回滚已经读到的清单统计。 +fn attach_snapshot_authors(state: &AppState, items: &mut [AdminProjectSnapshotItem]) { + for entry in items.iter_mut() { + let author = resolve_work_author_by_user_id(state, &entry.user_id, None, None); + entry.author_display_name = Some(author.display_name); + entry.author_public_user_code = author.public_user_code; } } @@ -231,6 +260,7 @@ async fn list_snapshots( }); }; cursor = SnapshotCursor { + channel: cursor.channel.clone(), user: Some(user), project: None, finished_user: false, @@ -255,7 +285,7 @@ async fn list_snapshots( for project in projects.directories { scanned += 1; if let Some(manifest) = store.manifest(user, &project).await? { - items.push(item(user.to_string(), &manifest)); + items.push(item(&cursor.channel, user.to_string(), &manifest)); } cursor.project = Some(project); } @@ -284,15 +314,69 @@ pub async fn admin_list_project_snapshots( Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { - let cursor = SnapshotCursor::decode(query.cursor.as_deref())?; + let channel = requested_snapshot_channel(&state, query.channel.as_deref())?; + let cursor = SnapshotCursor::decode(query.cursor.as_deref(), &channel)?; let store = OssSnapshotStore { oss: project_snapshot_oss(&state)?, client: state.editor_oss_http_client(), + channel, }; - let response = list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?; + let mut response = + list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?; + attach_snapshot_authors(&state, &mut response.items); Ok(json_success_body(Some(&ctx), response)) } +/// 目标渠道:显式传入时必须合法(空串按未提供处理),缺省用本部署渠道。 +/// 非法渠道失败关闭,不落到别的渠道。 +fn requested_snapshot_channel( + state: &AppState, + requested: Option<&str>, +) -> Result { + let requested = requested.map(str::trim).filter(|value| !value.is_empty()); + match requested { + Some(channel) => validate_agc_project_snapshot_channel(channel) + .map_err(|_| bad_request("项目工程渠道无效")), + None => crate::project_snapshots::project_snapshot_channel(state), + } +} + +/// 后台渠道列表:本部署渠道与远端已存在渠道的并集。 +/// 远端目录里不符合渠道命名的条目直接跳过,不作为可查询渠道暴露。 +pub async fn admin_list_project_snapshot_channels( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let default_channel = crate::project_snapshots::project_snapshot_channel(&state)?; + let oss = project_snapshot_oss(&state)?; + let client = state.editor_oss_http_client(); + let mut channels = BTreeSet::from([default_channel.clone()]); + let mut after: Option = None; + for _ in 0..MAX_DIRECTORY_REQUESTS { + let page = oss + .list_project_snapshot_channels(client, after.as_deref(), 100) + .await + .map_err(|_| upstream("读取项目工程渠道失败"))?; + channels.extend( + page.directories + .into_iter() + .filter(|name| validate_agc_project_snapshot_channel(name).is_ok()), + ); + match page.next_marker { + Some(next) => after = Some(next), + None => break, + } + } + Ok(json_success_body( + Some(&ctx), + AdminProjectSnapshotChannelsResponse { + default_channel, + channels: channels.into_iter().collect(), + }, + )) +} + fn verify_file(file: &AgcProjectSnapshotManifestFile, bytes: &[u8]) -> Result<(), AppError> { if bytes.len() as u64 != file.size_bytes || !agc_project_snapshot_checksum(bytes).eq_ignore_ascii_case(&file.checksum) @@ -441,6 +525,7 @@ pub async fn admin_download_project_snapshot( State(state): State, Extension(_admin): Extension, Path((user, project)): Path<(String, String)>, + Query(query): Query, ) -> Result { validate_agc_project_snapshot_project_id(&user).map_err(bad_request)?; validate_agc_project_snapshot_project_id(&project).map_err(bad_request)?; @@ -457,6 +542,7 @@ pub async fn admin_download_project_snapshot( let store = OssSnapshotStore { oss: project_snapshot_oss(&state)?, client: state.editor_oss_http_client(), + channel: requested_snapshot_channel(&state, query.channel.as_deref())?, }; let manifest = store.manifest(&user, &project).await?.ok_or_else(|| { AppError::from_status(StatusCode::NOT_FOUND).with_message("项目工程清单不存在") @@ -581,23 +667,63 @@ mod tests { "project-1", &[("game/empty", b""), ("game/main.js", b"123")], ); - let projection = item("user-1".to_string(), &manifest); + let projection = item("dev", "user-1".to_string(), &manifest); assert_eq!(projection.status, AdminProjectSnapshotStatus::Ready); assert_eq!((projection.file_count, projection.total_bytes), (2, 3)); + assert_eq!(projection.channel, "dev"); manifest.pending_files = Some(2); assert_eq!( - item("user-1".into(), &manifest).status, + item("release", "user-1".into(), &manifest).status, AdminProjectSnapshotStatus::Partial ); let mut legacy = serde_json::to_value(manifest).unwrap(); legacy.as_object_mut().unwrap().remove("pendingFiles"); legacy.as_object_mut().unwrap().remove("projectName"); let manifest = serde_json::from_value(legacy).unwrap(); - let projection = item("user-1".into(), &manifest); + let projection = item("dev", "user-1".into(), &manifest); assert_eq!(projection.status, AdminProjectSnapshotStatus::Unverified); assert_eq!(projection.project_name, "project-1"); } + /// 后台“用户”列与素材查询同口径:能查到账号时给昵称与陶泥号,查不到时给占位作者。 + #[test] + fn project_snapshots_items_resolve_author_profile_for_admin_list() { + use crate::{ + config::AppConfig, + work_author::{ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE}, + }; + + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .auth_user_service() + .ensure_orphan_work_owner_user("user-1", "user-1", "陶泥用户", "SY-00000007") + .expect("fixture user should be inserted"); + + let mut items = vec![ + item("dev", "user-1".to_string(), &manifest("project-1", &[])), + item( + "dev", + "user-missing".to_string(), + &manifest("project-2", &[]), + ), + ]; + attach_snapshot_authors(&state, &mut items); + + assert_eq!(items[0].author_display_name.as_deref(), Some("陶泥用户")); + assert_eq!( + items[0].author_public_user_code.as_deref(), + Some("SY-00000007") + ); + assert_eq!( + items[1].author_display_name.as_deref(), + Some(ORPHAN_WORK_AUTHOR_DISPLAY_NAME) + ); + assert_eq!( + items[1].author_public_user_code.as_deref(), + Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE) + ); + } + #[tokio::test] async fn project_snapshots_pagination_continues_directories_without_rescan() { let mut store = Store::default(); @@ -610,13 +736,14 @@ mod tests { .manifests .insert((user.into(), project.into()), manifest(project, &[])); } - let first = list_snapshots(&store, SnapshotCursor::decode(None).unwrap(), 1) + let first = list_snapshots(&store, SnapshotCursor::decode(None, "dev").unwrap(), 1) .await .unwrap(); assert_eq!(first.items[0].project_id, "project-a"); + assert_eq!(first.items[0].channel, "dev"); let second = list_snapshots( &store, - SnapshotCursor::decode(first.next_cursor.as_deref()).unwrap(), + SnapshotCursor::decode(first.next_cursor.as_deref(), "dev").unwrap(), 1, ) .await @@ -624,7 +751,7 @@ mod tests { assert_eq!(second.items[0].project_id, "project-b"); let third = list_snapshots( &store, - SnapshotCursor::decode(second.next_cursor.as_deref()).unwrap(), + SnapshotCursor::decode(second.next_cursor.as_deref(), "dev").unwrap(), 1, ) .await @@ -638,13 +765,17 @@ mod tests { (Some("user-a".into()), Some("project-a".into()), 1) ); assert_eq!(calls[3], (None, Some("user-a".into()), 1)); - assert!(SnapshotCursor::decode(Some("not-json")).is_err()); + assert!(SnapshotCursor::decode(Some("not-json"), "dev").is_err()); + // 游标不能跨渠道复用,否则目录推进会落在别的渠道上。 + let next = first.next_cursor.as_deref().expect("first page cursor"); + assert!(SnapshotCursor::decode(Some(next), "release").is_err()); let escaped = SnapshotCursor { + channel: "dev".into(), user: Some("../user".into()), ..SnapshotCursor::default() } .encode(); - assert!(SnapshotCursor::decode(Some(&escaped)).is_err()); + assert!(SnapshotCursor::decode(Some(&escaped), "dev").is_err()); } #[tokio::test] @@ -887,11 +1018,14 @@ mod tests { .timeout(Duration::from_secs(60)) .build() .unwrap(); + let channel = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") + .unwrap_or_else(|_| "dev".to_string()); let store = OssSnapshotStore { oss: &oss, client: &client, + channel: channel.clone(), }; - let mut cursor = SnapshotCursor::decode(None).unwrap(); + let mut cursor = SnapshotCursor::decode(None, &channel).unwrap(); let mut projects = 0; let mut total_files = 0; let mut total_bytes = 0_u64; @@ -938,7 +1072,7 @@ mod tests { } let Some(next) = page.next_cursor else { break }; assert!(page_index < 99, "只读 smoke 已达到分页上限"); - cursor = SnapshotCursor::decode(Some(&next)).unwrap(); + cursor = SnapshotCursor::decode(Some(&next), &channel).unwrap(); } assert!(projects > 0, "真实 bucket 未发现项目清单"); eprintln!( diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b3ff4c366..6bf7edc92 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -92,6 +92,8 @@ pub struct AppConfig { pub editor_bgfilter_circuit_cooldown: Duration, pub image_editor_agent_sidebar_enabled: bool, pub client_download_channel: String, + /// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。 + pub project_snapshot_channel: String, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -402,6 +404,7 @@ impl Default for AppConfig { ), image_editor_agent_sidebar_enabled: false, client_download_channel: "dev".to_string(), + project_snapshot_channel: "dev".to_string(), log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, @@ -724,6 +727,12 @@ impl AppConfig { // 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。 config.client_download_channel = channel.trim().to_string(); } + // 快照渠道缺省沿用同一个部署渠道(本部署的客户端渠道),显式配置优先; + // 显式空值或非法值同样保留,由快照入口失败关闭。 + config.project_snapshot_channel = config.client_download_channel.clone(); + if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") { + config.project_snapshot_channel = channel.trim().to_string(); + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"]) { @@ -3092,6 +3101,43 @@ mod tests { } } + #[test] + fn project_snapshot_channel_follows_client_download_channel_unless_overridden() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock"); + let previous_snapshot = std::env::var_os("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); + let previous_download = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); + unsafe { + std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); + std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); + } + // 未显式配置快照渠道时跟随部署的客户端渠道,缺省是 dev。 + assert_eq!(AppConfig::from_env().project_snapshot_channel, "dev"); + unsafe { + std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", "release"); + } + assert_eq!(AppConfig::from_env().project_snapshot_channel, "release"); + // 显式配置优先;空值与非法值同样保留,由快照入口失败关闭。 + for (value, expected) in [(" qa-2026 ", "qa-2026"), ("", ""), ("Dev", "Dev")] { + unsafe { + std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value); + } + assert_eq!(AppConfig::from_env().project_snapshot_channel, expected); + } + unsafe { + match previous_snapshot { + Some(value) => std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value), + None => std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"), + } + match previous_download { + Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value), + None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"), + } + } + } + #[test] fn from_env_reads_prefixed_character_animation_ffmpeg_paths() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 39f6a03e8..e16b4a82b 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -43,6 +43,10 @@ pub fn router(state: AppState) -> Router { "/admin/api/project-snapshots", get(crate::admin_project_snapshots::admin_list_project_snapshots), ), + ( + "/admin/api/project-snapshots/channels", + get(crate::admin_project_snapshots::admin_list_project_snapshot_channels), + ), ( "/admin/api/project-snapshots/{user_id}/{project_id}/download", get(crate::admin_project_snapshots::admin_download_project_snapshot), @@ -230,6 +234,7 @@ mod route_contract_tests { const PROTECTED_ROUTES: &[(&str, &[&str])] = &[ ("/admin/api/project-snapshots", &["GET"]), + ("/admin/api/project-snapshots/channels", &["GET"]), ( "/admin/api/project-snapshots/{user_id}/{project_id}/download", &["GET"], diff --git a/server-rs/crates/api-server/src/project_snapshots.rs b/server-rs/crates/api-server/src/project_snapshots.rs index 32a12fa21..6125328a7 100644 --- a/server-rs/crates/api-server/src/project_snapshots.rs +++ b/server-rs/crates/api-server/src/project_snapshots.rs @@ -16,6 +16,7 @@ use axum::{ use platform_oss::{ OssDeleteObjectRequest, OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess, agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key, + validate_agc_project_snapshot_channel, }; use serde_json::Value; use shared_contracts::agc_project_snapshots::{ @@ -67,7 +68,9 @@ pub async fn upload_project_snapshot_file( .strip_prefix("fnv1a64:") .unwrap_or_default() .to_string(); + let channel = project_snapshot_channel(&state)?; let object_key = agc_project_snapshot_file_object_key( + &channel, auth.claims().user_id(), &query.project_id, size_bytes, @@ -146,8 +149,10 @@ pub async fn upload_project_snapshot_manifest( consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::Manifest)?; validate_manifest(&payload)?; let user_id = auth.claims().user_id().to_string(); - let object_key = agc_project_snapshot_manifest_object_key(&user_id, &payload.project_id) - .map_err(|error| bad_request(error.to_string()))?; + let channel = project_snapshot_channel(&state)?; + let object_key = + agc_project_snapshot_manifest_object_key(&channel, &user_id, &payload.project_id) + .map_err(|error| bad_request(error.to_string()))?; // 上一版清单同时承担两个职责:项目级写入频率闸门,以及本轮远端对象回收的引用基线。 // 读不到或解析失败时只跳过回收,绝不据此删除任何对象。 let previous = read_project_snapshot_manifest(&state, &object_key).await; @@ -181,7 +186,8 @@ pub async fn upload_project_snapshot_manifest( })?; // 清单写入成功之后再回收:任何时刻远端对象集合都是当前清单的超集, // 不会出现清单引用了刚被删掉的对象。 - reclaim_unreferenced_objects(&state, oss, &user_id, previous.as_ref(), &payload).await; + reclaim_unreferenced_objects(&state, oss, &channel, &user_id, previous.as_ref(), &payload) + .await; Ok(json_success_body( Some(&ctx), @@ -293,6 +299,7 @@ async fn read_project_snapshot_manifest( async fn reclaim_unreferenced_objects( state: &AppState, oss: &platform_oss::OssClient, + channel: &str, user_id: &str, previous: Option<&AgcProjectSnapshotManifestRequest>, next: &AgcProjectSnapshotManifestRequest, @@ -330,6 +337,7 @@ async fn reclaim_unreferenced_objects( continue; }; let Ok(object_key) = agc_project_snapshot_file_object_key( + channel, user_id, &previous.project_id, file.size_bytes, @@ -433,6 +441,15 @@ pub(crate) fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::Os }) } +/// 本部署的快照渠道:配置文件缺省跟随客户端下载渠道,显式配置优先。 +/// 渠道名非法时失败关闭(503),不静默改写到其它渠道。 +pub(crate) fn project_snapshot_channel(state: &AppState) -> Result { + validate_agc_project_snapshot_channel(&state.config.project_snapshot_channel).map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("AGC 项目快照渠道配置无效") + }) +} + fn bad_request(m: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_message(m) } @@ -567,4 +584,26 @@ mod tests { consume_user_upload_quota(user, ProjectSnapshotUploadKind::Manifest) .expect("清单配额独立计数"); } + + /// 渠道决定对象键的第一层目录:非法配置必须失败关闭,不能落到别的渠道。 + #[test] + fn project_snapshot_channel_fails_closed_on_invalid_configuration() { + use crate::{config::AppConfig, state::AppState}; + + let mut config = AppConfig::default(); + config.project_snapshot_channel = "release".to_string(); + let state = AppState::new(config).expect("state should build"); + assert_eq!( + project_snapshot_channel(&state).expect("valid channel"), + "release" + ); + + for invalid in ["", " Dev", "dev/2", "dev-"] { + let mut config = AppConfig::default(); + config.project_snapshot_channel = invalid.to_string(); + let state = AppState::new(config).expect("state should build"); + let error = project_snapshot_channel(&state).expect_err("非法渠道必须失败关闭"); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + } } diff --git a/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs index 2ebc8f144..67cf2c49d 100644 --- a/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs +++ b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs @@ -8,7 +8,7 @@ //! cargo run -p platform-oss --example agc_project_snapshot_live_smoke --manifest-path server-rs/Cargo.toml //! ``` //! -//! 冒烟只写入 `agc/project-snapshots/v1/` 下的固定探针对象,并在结束时删除; +//! 冒烟只写入 `agc/project-snapshots/v2/dev/` 下的固定探针对象,并在结束时删除; //! 任何一步失败都会打印 `[FAIL]` 并以非 0 退出码结束,方便 CI 或人工判定。 use std::{ @@ -29,6 +29,7 @@ const DEFAULT_BUCKET: &str = "agc-dev"; const DEFAULT_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com"; const SMOKE_USER_ID: &str = "smoke-user"; const SMOKE_PROJECT_ID: &str = "smoke-project"; +const SMOKE_CHANNEL: &str = "dev"; const SMOKE_RELATIVE_PATH: &str = "smoke/README.txt"; const SMOKE_BODY: &[u8] = b"agc project snapshot live smoke\n"; const SMOKE_CHECKSUM_DIGEST: &str = "0123456789abcdef"; @@ -117,6 +118,7 @@ async fn run() -> SmokeResult<()> { // 3. 项目快照文件键:写入 → 读回。 let file_key = agc_project_snapshot_file_object_key( + SMOKE_CHANNEL, SMOKE_USER_ID, SMOKE_PROJECT_ID, SMOKE_BODY.len() as u64, @@ -124,8 +126,9 @@ async fn run() -> SmokeResult<()> { SMOKE_RELATIVE_PATH, ) .map_err(|error| format!("构造项目快照文件键失败({})", oss_error_label(&error)))?; - let manifest_key = agc_project_snapshot_manifest_object_key(SMOKE_USER_ID, SMOKE_PROJECT_ID) - .map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?; + let manifest_key = + agc_project_snapshot_manifest_object_key(SMOKE_CHANNEL, SMOKE_USER_ID, SMOKE_PROJECT_ID) + .map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?; let result = write_and_verify(&client, &http, &bucket, &file_key, &manifest_key).await; for key in [&file_key, &manifest_key] { diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 8c911a531..61b6cc213 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -1926,44 +1926,69 @@ fn normalize_editor_agent_messages_object_key(raw: &str) -> Result Result { + let allowed = !raw.is_empty() + && raw.len() <= 32 + && raw.as_bytes()[0].is_ascii_lowercase() + && raw + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !raw.ends_with('-'); + if !allowed { + return Err(OssError::InvalidRequest("项目快照渠道名非法".to_string())); + } + Ok(raw.to_string()) +} + /// 项目快照文件对象键: -/// `agc/project-snapshots/v1/{user}/{project}/files/{size}-{digest}/{relativePath}`。 +/// `agc/project-snapshots/v2/{channel}/{user}/{project}/files/{size}-{digest}/{relativePath}`。 /// /// 键里同时带字节数与内容摘要,既让同一内容重复提交落在同一个对象上,也让 /// "对象已存在且长度一致" 可以作为内容一致的判据;相对路径按原始大小写保留, /// 不走 `put_object` 的低位规范化。 pub fn agc_project_snapshot_file_object_key( + channel: &str, user_id: &str, project_id: &str, size_bytes: u64, checksum_digest: &str, relative_path: &str, ) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; let user = validate_internal_key_segment(user_id, "用户标识")?; let project = validate_internal_key_segment(project_id, "项目标识")?; let digest = validate_internal_checksum_digest(checksum_digest)?; let relative_path = validate_internal_relative_path(relative_path)?; Ok(format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/files/{size_bytes}-{digest}/{relative_path}" + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/files/{size_bytes}-{digest}/{relative_path}" )) } -/// 项目快照清单对象键:`agc/project-snapshots/v1/{user}/{project}/manifest.json`。 +/// 项目快照清单对象键: +/// `agc/project-snapshots/v2/{channel}/{user}/{project}/manifest.json`。 pub fn agc_project_snapshot_manifest_object_key( + channel: &str, user_id: &str, project_id: &str, ) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; let user = validate_internal_key_segment(user_id, "用户标识")?; let project = validate_internal_key_segment(project_id, "项目标识")?; Ok(format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/manifest.json" + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/manifest.json" )) } @@ -3496,7 +3521,7 @@ mod tests { ); assert_eq!( LegacyAssetPrefix::from_object_key( - "agc/project-snapshots/v1/user-1/project-1/manifest.json" + "agc/project-snapshots/v2/dev/user-1/project-1/manifest.json" ), None, "AGC 内部前缀不能经由通用对象键解析变成客户端可写前缀" @@ -3524,6 +3549,7 @@ mod tests { #[test] fn agc_project_snapshot_object_keys_preserve_case_and_reject_traversal() { let file_key = agc_project_snapshot_file_object_key( + "dev", "user-1", "gameagent-1a2b3c4d", 1234, @@ -3533,12 +3559,12 @@ mod tests { .expect("file key"); assert_eq!( file_key, - "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML" + "agc/project-snapshots/v2/dev/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML" ); assert_eq!( - agc_project_snapshot_manifest_object_key("user-1", "gameagent-1a2b3c4d") + agc_project_snapshot_manifest_object_key("release", "user-1", "gameagent-1a2b3c4d") .expect("manifest key"), - "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/manifest.json" + "agc/project-snapshots/v2/release/user-1/gameagent-1a2b3c4d/manifest.json" ); for (user, project, path) in [ @@ -3549,20 +3575,64 @@ mod tests { ("user-1", "project-1", "game\\index.html"), ] { assert!( - agc_project_snapshot_file_object_key(user, project, 1, "abcdef", path).is_err(), + agc_project_snapshot_file_object_key("dev", user, project, 1, "abcdef", path) + .is_err(), "越界键片段必须被拒绝:{user} {project} {path}" ); } assert!( - agc_project_snapshot_file_object_key("user-1", "project-1", 1, "not-hex", "game/a.txt") - .is_err(), + agc_project_snapshot_file_object_key( + "dev", + "user-1", + "project-1", + 1, + "not-hex", + "game/a.txt" + ) + .is_err(), "摘要必须是十六进制" ); } + /// 渠道是对象键的第一层:非法渠道必须失败关闭,不能悄悄换成一个默认渠道。 + #[test] + fn agc_project_snapshot_channel_is_validated_before_it_reaches_the_key() { + assert_eq!( + validate_agc_project_snapshot_channel("release").expect("channel"), + "release" + ); + assert_eq!( + validate_agc_project_snapshot_channel("dev-internal-2").expect("channel"), + "dev-internal-2" + ); + for invalid in [ + "", + " dev", + "dev ", + "Dev", + "dev_internal", + "dev/internal", + "-dev", + "dev-", + "2dev", + "dev.", + &"d".repeat(33), + ] { + assert!( + validate_agc_project_snapshot_channel(invalid).is_err(), + "非法渠道必须被拒绝:{invalid}" + ); + assert!( + agc_project_snapshot_manifest_object_key(invalid, "user-1", "project-1").is_err(), + "非法渠道不能进入对象键:{invalid}" + ); + } + } + #[test] fn internal_object_prefixes_cover_agc_snapshots_but_reject_everything_else() { let file_key = agc_project_snapshot_file_object_key( + "dev", "user-1", "project-1", 7, @@ -3574,6 +3644,14 @@ mod tests { normalize_internal_object_key(&file_key).expect("snapshot key is internal"), file_key ); + // 无渠道的历史项目快照对象仍然必须保持服务端私有。 + assert_eq!( + normalize_internal_object_key( + "agc/project-snapshots/v1/user-1/project-1/manifest.json" + ) + .expect("legacy snapshot key stays internal"), + "agc/project-snapshots/v1/user-1/project-1/manifest.json" + ); assert_eq!( normalize_internal_object_key("agc/error-reports/v1/batch.zip") .expect("error report key stays internal"), diff --git a/server-rs/crates/platform-oss/src/project_snapshots.rs b/server-rs/crates/platform-oss/src/project_snapshots.rs index 0466591ad..1345c2c15 100644 --- a/server-rs/crates/platform-oss/src/project_snapshots.rs +++ b/server-rs/crates/platform-oss/src/project_snapshots.rs @@ -37,7 +37,7 @@ fn directory_segment(value: &str) -> Result { } fn list_query( - user_id: Option<&str>, + prefix: &str, after: Option<&str>, limit: usize, ) -> Result, OssError> { @@ -46,15 +46,8 @@ fn list_query( "项目目录分页大小必须为 1 到 100".to_string(), )); } - let prefix = match user_id { - Some(user) => format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{}/", - directory_segment(user)? - ), - None => AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX.to_string(), - }; let mut query = BTreeMap::from([ - ("prefix".to_string(), prefix.clone()), + ("prefix".to_string(), prefix.to_string()), ("delimiter".to_string(), "/".to_string()), ("max-keys".to_string(), limit.to_string()), ]); @@ -67,6 +60,12 @@ fn list_query( Ok(query) } +/// 渠道根前缀:`agc/project-snapshots/v2/{channel}/`。 +fn channel_root_prefix(channel: &str) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; + Ok(format!("{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/")) +} + fn parse_directory_page( body: &[u8], query: &BTreeMap, @@ -119,15 +118,40 @@ fn parse_directory_page( } impl OssClient { - /// 第一层只列用户目录,第二层只列项目目录,不枚举文件对象或其它 bucket 前缀。 + /// 在本部署渠道下逐层列目录:`{channel}/` 下只列用户,用户下只列项目, + /// 不枚举文件对象,也不跨渠道。 pub async fn list_project_snapshot_directories( &self, client: &reqwest::Client, + channel: &str, user_id: Option<&str>, after: Option<&str>, limit: usize, ) -> Result { - let query = list_query(user_id, after, limit)?; + let mut prefix = channel_root_prefix(channel)?; + if let Some(user) = user_id { + prefix.push_str(&format!("{}/", directory_segment(user)?)); + } + self.list_project_snapshot_prefix(client, list_query(&prefix, after, limit)?) + .await + } + + /// 列出远端已存在的渠道目录:只在 v2 根下取第一层,不进入用户或项目。 + pub async fn list_project_snapshot_channels( + &self, + client: &reqwest::Client, + after: Option<&str>, + limit: usize, + ) -> Result { + let query = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, after, limit)?; + self.list_project_snapshot_prefix(client, query).await + } + + async fn list_project_snapshot_prefix( + &self, + client: &reqwest::Client, + query: BTreeMap, + ) -> Result { let mut target = build_object_url(&self.config.bucket, &self.config.endpoint, "") .map_err(OssError::InvalidRequest)?; target.set_query(Some(&build_canonical_query_string(&query))); @@ -177,11 +201,12 @@ pub(super) fn is_snapshot_file_key(key: &str) -> bool { return false; }; let parts = path.split('/').collect::>(); - parts.len() >= 5 - && directory_segment(parts[0]).is_ok() + parts.len() >= 6 + && validate_agc_project_snapshot_channel(parts[0]).is_ok() && directory_segment(parts[1]).is_ok() - && parts[2] == "files" - && parts[3].split_once('-').is_some_and(|(size, digest)| { + && directory_segment(parts[2]).is_ok() + && parts[3] == "files" + && parts[4].split_once('-').is_some_and(|(size, digest)| { size == "0" && digest.eq_ignore_ascii_case("cbf29ce484222325") }) } @@ -192,7 +217,7 @@ mod tests { #[test] fn snapshot_object_url_preserves_literal_reserved_characters() { - let key = "agc/project-snapshots/v1/user/project/files/1-abcd/game/图像 #100%.txt"; + let key = "agc/project-snapshots/v2/dev/user/project/files/1-abcd/game/图像 #100%.txt"; let url = build_object_url("bucket", "oss-cn-shanghai.aliyuncs.com", key).unwrap(); assert_eq!(url.fragment(), None); assert_eq!(url.query(), None); @@ -202,22 +227,26 @@ mod tests { #[test] fn snapshot_directory_query_is_prefix_scoped_and_bounded() { - let query = list_query(Some("user-1"), Some("project-1"), 20).unwrap(); - assert_eq!(query["prefix"], "agc/project-snapshots/v1/user-1/"); - assert_eq!( - query["marker"], - "agc/project-snapshots/v1/user-1/project-1/" - ); + let prefix = channel_root_prefix("dev").unwrap(); + assert_eq!(prefix, "agc/project-snapshots/v2/dev/"); + let query = list_query(&prefix, Some("project-1"), 20).unwrap(); + assert_eq!(query["prefix"], "agc/project-snapshots/v2/dev/"); + assert_eq!(query["marker"], "agc/project-snapshots/v2/dev/project-1/"); assert_eq!(query["delimiter"], "/"); - assert!(list_query(Some("../user"), None, 20).is_err()); - assert!(list_query(None, Some("escape/path"), 20).is_err()); - assert!(list_query(None, None, 101).is_err()); + assert!(list_query(&prefix, Some("escape/path"), 20).is_err()); + assert!(list_query(&prefix, None, 101).is_err()); + assert!(channel_root_prefix("../escape").is_err()); + assert!(channel_root_prefix("Dev").is_err()); } #[test] fn snapshot_directory_page_decodes_only_direct_children_and_advancing_cursor() { - let query = list_query(Some("user-1"), None, 20).unwrap(); - let xml = br#"agc/project-snapshots/v1/user-1//trueagc/project-snapshots/v1/user-1/project-1/agc/project-snapshots/v1/user-1/project-1/"#; + let query = list_query("agc/project-snapshots/v2/dev/user-1/", None, 20).unwrap(); + // 渠道枚举只读 v2 根的第一层,不进入任何渠道目录内部。 + let channels = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, Some("dev"), 20).unwrap(); + assert_eq!(channels["prefix"], "agc/project-snapshots/v2/"); + assert_eq!(channels["marker"], "agc/project-snapshots/v2/dev/"); + let xml = br#"agc/project-snapshots/v2/dev/user-1//trueagc/project-snapshots/v2/dev/user-1/project-1/agc/project-snapshots/v2/dev/user-1/project-1/"#; let page = parse_directory_page(xml, &query).unwrap(); assert_eq!(page.directories, ["project-1"]); assert_eq!(page.next_marker.as_deref(), Some("project-1")); @@ -238,14 +267,18 @@ mod tests { #[test] fn empty_internal_put_is_only_allowed_for_empty_snapshot_content_key() { assert!(is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" + "agc/project-snapshots/v2/dev/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" )); assert!(!is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/manifest.json" + "agc/project-snapshots/v2/dev/user-1/project-1/manifest.json" )); assert!(!is_snapshot_file_key("agc/error-reports/v1/report.zip")); assert!(!is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/files/1-cbf29ce484222325/game/file.txt" + "agc/project-snapshots/v2/dev/user-1/project-1/files/1-cbf29ce484222325/game/file.txt" + )); + // 历史(无渠道)布局的对象键不是当前快照文件键。 + assert!(!is_snapshot_file_key( + "agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" )); } } diff --git a/server-rs/crates/platform-oss/src/template_library.rs b/server-rs/crates/platform-oss/src/template_library.rs index d85d8326b..668cddaea 100644 --- a/server-rs/crates/platform-oss/src/template_library.rs +++ b/server-rs/crates/platform-oss/src/template_library.rs @@ -1222,6 +1222,7 @@ mod tests { LOCK_KEY, INDEX_KEY, "agc/project-snapshots/v1/a/b", + "agc/project-snapshots/v2/dev/a/b", "templates/v1/a/../index.json", "templates/v1/a/%2e%2e/index.json", "templates/v1/a/file?x", diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 60d7ca511..e899fefbb 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -37,6 +37,26 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 18] = [ pub struct AdminProjectSnapshotsQuery { pub cursor: Option, pub limit: Option, + /// 目标渠道;缺省用本部署渠道。 + pub channel: Option, +} + +/// 后台可查看的快照渠道列表。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotChannelsResponse { + /// 本部署渠道:上传与默认查询都用它。 + pub default_channel: String, + /// 本部署渠道与远端已存在渠道的并集(升序,去重)。 + pub channels: Vec, +} + +/// 私有工程快照下载查询:只接受渠道。 +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotDownloadQuery { + /// 目标渠道;缺省用本部署渠道。 + pub channel: Option, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -59,6 +79,14 @@ pub struct AdminProjectSnapshotItem { pub file_count: u32, pub total_bytes: u64, pub status: AdminProjectSnapshotStatus, + /// 项目所在渠道。 + pub channel: String, + /// 项目归属用户昵称,与素材查询同口径;账号不可解析时为占位作者。 + #[serde(default)] + pub author_display_name: Option, + /// 项目归属用户陶泥号,与素材查询同口径;账号不可解析时为占位账号。 + #[serde(default)] + pub author_public_user_code: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]