项目快照按渠道分区,后台项目工程支持渠道筛选与游标分页
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m40s
Project CI / Repository checks (push) Successful in 5m37s
Project CI / Frontend tests (push) Successful in 6m21s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 10m57s
Project CI / AI game creator shell web tests (push) Successful in 5m29s

项目快照对象键升级为 agc/project-snapshots/v2/{channel}/{user}/{project}/,渠道取部署配置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL(缺省沿用客户端下载渠道),非法渠道在写入处失败关闭
后台新增渠道列表接口,项目工程列表与下载接受 channel,游标绑定渠道并拒绝跨渠道复用
后台项目工程用户列改为素材查询口径:昵称 + 陶泥号 + 用户详情入口,由 api-server 解析作者信息
后台项目工程改为游标分页:每页 20/50/100、上一页/下一页与当前页提示,翻页失败保留当前页
部署环境示例补充快照渠道配置,并同步运维、技术方案、里程碑与决策记录文档
This commit is contained in:
kdletters
2026-09-21 16:57:16 +08:00
parent 8520b50c81
commit 4951b71d71
20 changed files with 874 additions and 140 deletions
+17 -1
View File
@@ -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<AdminProjectSnapshotListResponse>(
`/admin/api/project-snapshots?${params.toString()}`,
@@ -221,13 +223,27 @@ export function listAdminProjectSnapshots(
);
}
export function getAdminProjectSnapshotChannels(
token: string,
signal?: AbortSignal,
) {
return request<AdminProjectSnapshotChannelsResponse>(
'/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()}`,
+9
View File
@@ -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;
@@ -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' });
});
@@ -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(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
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(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
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(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
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(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
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(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
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(
<AdminProjectSnapshotsPage
@@ -278,7 +421,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
);
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 () => {
@@ -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<AdminProjectSnapshotEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
const [channel, setChannel] = useState<string | null>(null);
const [channelOptions, setChannelOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const listController = useRef<AbortController | null>(null);
const downloadController = useRef<AbortController | null>(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 (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h2></h2>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void loadPage()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span></span>
<select
aria-label="项目工程渠道"
value={channel ?? ''}
onChange={(event) => setChannel(event.target.value)}
>
{visibleChannelOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshCurrentPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="alert">
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({
<thead>
<tr>
<th></th>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({
<strong>{entry.projectName || entry.projectId}</strong>
<small>{entry.projectId}</small>
</td>
<td data-label="用户 ID">{entry.userId}</td>
<td data-label="用户">
<div className="admin-inline-identity">
<div>
{projectOwnerDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.userId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td data-label="同步时间">
<span>
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
@@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({
{hasLoaded && items.length === 0 && !errorMessage ? (
<p className="admin-muted-text"></p>
) : null}
{nextCursor ? (
<div className="admin-action-row">
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void loadPage(nextCursor)}
>
{isLoading ? '加载中' : '加载更多'}
</button>
</div>
{hasLoaded ? (
<nav
className="admin-action-row admin-project-snapshot-pagination"
aria-label="项目工程分页"
>
<span className="admin-project-snapshot-pagination-info">
{pageIndex}
{items.length ? `,本页 ${items.length} 个项目` : ''}
</span>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span></span>
<select
aria-label="每页条数"
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
aria-label="上一页"
className="admin-secondary-button"
disabled={isLoading || pageIndex <= 1}
title="上一页"
type="button"
onClick={goToPreviousPage}
>
<ChevronLeft size={17} aria-hidden="true" />
<span></span>
</button>
<button
aria-label="下一页"
className="admin-secondary-button"
disabled={isLoading || !nextCursor}
title="下一页"
type="button"
onClick={goToNextPage}
>
<span></span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
</section>
</section>
);
}
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
return (
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function snapshotKey(entry: AdminProjectSnapshotEntry) {
return `${entry.userId}/${entry.projectId}`;
}
+18 -3
View File
@@ -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;
+4 -1
View File
@@ -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
@@ -23,7 +23,7 @@
- [ ] 正式项目窗口可被同步调度识别;周期与关闭触发保持有界,失败有项目级诊断。
- [ ] 未单独配置快照目标时仍使用 agc-dev,只复用资源存储凭据;显式快照目标保持有效,不迁移现存对象。
- [ ] 后台列表显示项目名/ID、用户 ID、同步时间、文件数、体积和完整性,支持刷新与分页
- [ ] 后台列表按部署渠道查询,显示项目名/ID、用户(昵称 + 陶泥号)、同步时间、文件数、体积和完整性,支持刷新与游标分页(每页 20/50/100 + 上一页/下一页)
- [ ] ZIP 按清单还原相对路径;不含对象存储摘要目录;空文件可上传与导出。
- [ ] 清单名称/完整性变化在无内容差异时也提交,partial 可恢复 ready,历史缺字段不冒充 ready。
- [ ] 缺失、损坏、越界路径和非完整清单失败关闭;历史未声明完整性的清单明确标记,允许导出已有文件但不称为完整工程。
@@ -1,5 +1,16 @@
# 决策记录
## 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 模板正文目录门禁:CLI 打包与后台上传同一份段名单
- 背景:模板包组织指南把 `.agent/``.git/``node_modules/`、根目录 `dist/` 等列为「不要放进 ZIP」,但两条发布路径此前只校验路径安全与 `entry` 是否存在,放进去的东西会跟着建到用户项目里(模板自带 `.agent/` 会让新项目继承一个陌生身份)。这条约定只靠作者自觉。
@@ -1783,7 +1783,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
- 本地索引是增量对比的唯一依据:`<AppData>/project-snapshots/<projectId>/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 的现存数据。
### 正常、失败、重试与幂等行为
@@ -552,7 +552,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 +561,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 +586,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`
@@ -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<Arc<Semaphore>> = OnceLock::new();
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct SnapshotCursor {
channel: String,
user: Option<String>,
project: Option<String>,
finished_user: bool,
@@ -62,9 +67,10 @@ struct SnapshotCursor {
}
impl SnapshotCursor {
fn decode(value: Option<&str>) -> Result<Self, AppError> {
fn decode(value: Option<&str>, channel: &str) -> Result<Self, AppError> {
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<SnapshotDirectoryPage, AppError> {
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<Option<AgcProjectSnapshotManifestRequest>, 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<Vec<u8>, 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<AuthenticatedAdmin>,
Query(query): Query<AdminProjectSnapshotsQuery>,
) -> Result<Json<Value>, 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<String, AppError> {
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<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<String> = 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<AppState>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Path((user, project)): Path<(String, String)>,
Query(query): Query<AdminProjectSnapshotDownloadQuery>,
) -> Result<Response, AppError> {
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!(
+46
View File
@@ -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<String>,
@@ -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
@@ -43,6 +43,10 @@ pub fn router(state: AppState) -> Router<AppState> {
"/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"],
@@ -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<String, AppError> {
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<String>) -> 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);
}
}
}
@@ -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] {
+91 -13
View File
@@ -1926,44 +1926,69 @@ fn normalize_editor_agent_messages_object_key(raw: &str) -> Result<String, OssEr
/// AGC 内部对象前缀:只允许服务端写入,客户端直传票据、公开对象键与 legacy
/// 公开路径都不覆盖这些前缀。
pub const AGC_ERROR_REPORTS_INTERNAL_PREFIX: &str = "agc/error-reports/v1/";
pub const AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX: &str = "agc/project-snapshots/v1/";
/// 项目快照当前前缀:第二层是部署渠道,读写都只落在本部署渠道下。
pub const AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX: &str = "agc/project-snapshots/v2/";
/// 无渠道的历史项目快照前缀:不再写入、不再枚举,但仍必须保持服务端私有。
pub const AGC_PROJECT_SNAPSHOT_LEGACY_INTERNAL_PREFIX: &str = "agc/project-snapshots/v1/";
const AGC_INTERNAL_OBJECT_PREFIXES: [&str; 2] = [
const AGC_INTERNAL_OBJECT_PREFIXES: [&str; 3] = [
AGC_ERROR_REPORTS_INTERNAL_PREFIX,
AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX,
AGC_PROJECT_SNAPSHOT_LEGACY_INTERNAL_PREFIX,
];
/// 项目快照渠道名:与 AGC 客户端更新渠道同形(小写字母开头,只含小写字母、
/// 数字与连字符)。渠道在对象键里是第一层目录,非法值直接拒绝而不是回落。
pub fn validate_agc_project_snapshot_channel(raw: &str) -> Result<String, OssError> {
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<String, OssError> {
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<String, OssError> {
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"),
@@ -37,7 +37,7 @@ fn directory_segment(value: &str) -> Result<String, OssError> {
}
fn list_query(
user_id: Option<&str>,
prefix: &str,
after: Option<&str>,
limit: usize,
) -> Result<BTreeMap<String, String>, 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<String, OssError> {
let channel = validate_agc_project_snapshot_channel(channel)?;
Ok(format!("{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/"))
}
fn parse_directory_page(
body: &[u8],
query: &BTreeMap<String, String>,
@@ -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<SnapshotDirectoryPage, OssError> {
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<SnapshotDirectoryPage, OssError> {
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<String, String>,
) -> Result<SnapshotDirectoryPage, OssError> {
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::<Vec<_>>();
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#"<ListBucketResult><Prefix>agc/project-snapshots/v1/user-1/</Prefix><Delimiter>/</Delimiter><IsTruncated>true</IsTruncated><NextMarker>agc/project-snapshots/v1/user-1/project-1/</NextMarker><CommonPrefixes><Prefix>agc/project-snapshots/v1/user-1/project-1/</Prefix></CommonPrefixes></ListBucketResult>"#;
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#"<ListBucketResult><Prefix>agc/project-snapshots/v2/dev/user-1/</Prefix><Delimiter>/</Delimiter><IsTruncated>true</IsTruncated><NextMarker>agc/project-snapshots/v2/dev/user-1/project-1/</NextMarker><CommonPrefixes><Prefix>agc/project-snapshots/v2/dev/user-1/project-1/</Prefix></CommonPrefixes></ListBucketResult>"#;
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"
));
}
}
@@ -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",
@@ -37,6 +37,26 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 18] = [
pub struct AdminProjectSnapshotsQuery {
pub cursor: Option<String>,
pub limit: Option<usize>,
/// 目标渠道;缺省用本部署渠道。
pub channel: Option<String>,
}
/// 后台可查看的快照渠道列表。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminProjectSnapshotChannelsResponse {
/// 本部署渠道:上传与默认查询都用它。
pub default_channel: String,
/// 本部署渠道与远端已存在渠道的并集(升序,去重)。
pub channels: Vec<String>,
}
/// 私有工程快照下载查询:只接受渠道。
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminProjectSnapshotDownloadQuery {
/// 目标渠道;缺省用本部署渠道。
pub channel: Option<String>,
}
#[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<String>,
/// 项目归属用户陶泥号,与素材查询同口径;账号不可解析时为占位账号。
#[serde(default)]
pub author_public_user_code: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]