From 4afdb2f359f83e4f1f620ba47ff233adfde53c1b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:03:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E4=BA=A7=E5=93=81=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=A1=B9=E7=9B=AE=E5=BF=AB=E7=85=A7=E7=9A=84?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=8F=AF=E8=A7=81=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除项目快照面板组件与其前端测试,回退工作区头部入口、App 接线、前端类型与面板样式 - 两条快照命令恢复为 native-only 登记,并在注释中说明按产品口径不做客户端界面 - 主规范与里程碑同步:可观测性收敛到 AppData 诊断日志,未决问题补记用户侧不可见 --- .../scripts/check-config.mjs | 4 + apps/ai-game-creator-shell/src/App.tsx | 10 - apps/ai-game-creator-shell/src/app/types.ts | 34 --- .../ProjectSnapshotDialog.tsx | 222 ------------------ .../ProjectWorkspaceChatPane.tsx | 9 - apps/ai-game-creator-shell/src/styles.css | 46 ---- .../tests/projectSnapshotDialog.test.tsx | 117 --------- ...里程碑】AGC项目定时快照上传-2026-09-17.md | 3 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 +- 9 files changed, 9 insertions(+), 444 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ProjectSnapshotDialog.tsx delete mode 100644 apps/ai-game-creator-shell/tests/projectSnapshotDialog.test.tsx diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index e32dfb4b7..ab7a0270c 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -121,6 +121,10 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', + // 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用; + // 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。 + 'read_local_project_snapshot_state', + 'sync_local_project_snapshot', 'reset_design_agent_session', 'stop_local_game_preview_if_matches', 'start_game_creator_external_mcp', diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 85ddd7052..be33bfe93 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -275,7 +275,6 @@ import { resolveChatProjectPath, resolvePendingCommandProjectPath, } from './features/project-workspace/projectCommandPolicy'; -import { ProjectSnapshotDialog } from './features/project-workspace/ProjectSnapshotDialog'; import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands'; import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; @@ -1920,7 +1919,6 @@ export function App({ const [professionalAgentResultsById, setProfessionalAgentResultsById] = useState>({}); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); - const [projectSnapshotOpen, setProjectSnapshotOpen] = useState(false); const [llmConfigStatus, setLlmConfigStatus] = useState(null); const [workspaceStatus, setWorkspaceStatus] = useState( @@ -13261,7 +13259,6 @@ export function App({ handleRevealCurrentProjectDirectory } handleRuntimeConfigOpen={handleRuntimeConfigOpen} - handleProjectSnapshotOpen={() => setProjectSnapshotOpen(true)} hiddenConversationCount={hiddenConversationCount} hasEarlierConversationMessages={hasEarlierConversationMessages} llmConfigStatus={llmConfigStatus} @@ -13310,13 +13307,6 @@ export function App({ /> ) : null} - {projectSnapshotOpen ? ( - setProjectSnapshotOpen(false)} - /> - ) : null} - {selectedAgent ? ( ; } -/** 项目快照同步中被跳过、延后或失败的单个路径。 */ -export interface LocalProjectSnapshotFailure { - relativePath: string; - code: string; - detail: string; -} - -export interface LocalProjectSnapshotState { - projectId: string; - indexPath: string; - indexPresent: boolean; - fileCount: number; - syncRevision: number; - syncedAtMs: number; - enabled: boolean; -} - -export interface LocalProjectSnapshotSyncResult { - projectId: string; - trigger: string; - status: string; - syncRevision: number; - uploadedFiles: number; - uploadedBytes: number; - remoteSkippedFiles: number; - deletedFiles: number; - deferredFiles: number; - metadataOnlyFiles: number; - skippedFiles: LocalProjectSnapshotFailure[]; - pendingFiles: LocalProjectSnapshotFailure[]; - failedFiles: LocalProjectSnapshotFailure[]; - syncedAtMs: number; -} - export interface LocalProjectCheckpointResult { checkpointId: string; checkpointPath: string; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSnapshotDialog.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSnapshotDialog.tsx deleted file mode 100644 index d1b5a27d6..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSnapshotDialog.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { CloudUpload, Loader2, RefreshCw } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; - -import { - closeDialogOnBackdropMouseDown, - closeDialogOnEscape, - useEscapeToClose, -} from '../../app/dialogs'; -import { resolveTauriInvoke } from '../../app/tauri'; -import type { - LocalProjectSnapshotFailure, - LocalProjectSnapshotState, - LocalProjectSnapshotSyncResult, -} from '../../app/types'; - -type ProjectSnapshotDialogProps = { - projectPath: string | null; - onClose: () => void; -}; - -const SNAPSHOT_STATUS_LABELS: Record = { - synced: '已同步', - 'no-op': '无改动', - partial: '部分成功', - failed: '同步失败', -}; - -function syncStatusLabel(status: string) { - return SNAPSHOT_STATUS_LABELS[status] ?? status; -} - -function formatSyncTime(value: number) { - if (!value) return '尚无记录'; - return new Date(value).toLocaleString('zh-CN', { hour12: false }); -} - -function describePending( - label: string, - entries: LocalProjectSnapshotFailure[], -): string | null { - const first = entries.at(0); - if (!first) return null; - return entries.length === 1 - ? `${label}:${first.relativePath}(${first.detail})` - : `${label}:${entries.length} 个,例如 ${first.relativePath}(${first.detail})`; -} - -/** - * 项目快照面板:显示本机索引状态与最近一次同步结果,并提供手动触发入口。 - */ -export function ProjectSnapshotDialog({ - projectPath, - onClose, -}: ProjectSnapshotDialogProps) { - const [state, setState] = useState(null); - const [result, setResult] = useState( - null, - ); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const [loading, setLoading] = useState(false); - - const refreshState = useCallback(async () => { - const invoke = resolveTauriInvoke(); - const trimmed = projectPath?.trim(); - if (!invoke || !trimmed) { - setState(null); - return; - } - setLoading(true); - try { - const next = await invoke( - 'read_local_project_snapshot_state', - { projectPath: trimmed }, - ); - setState(next); - setError(null); - } catch (readError) { - setState(null); - setError( - readError instanceof Error ? readError.message : String(readError), - ); - } finally { - setLoading(false); - } - }, [projectPath]); - - useEffect(() => { - void refreshState(); - }, [refreshState]); - - useEscapeToClose(onClose, !busy); - - async function runSync() { - const invoke = resolveTauriInvoke(); - const trimmed = projectPath?.trim(); - if (!invoke || !trimmed) { - setError('需要在 Tauri App 内打开项目'); - return; - } - setBusy(true); - setError(null); - try { - setResult( - await invoke( - 'sync_local_project_snapshot', - { projectPath: trimmed }, - ), - ); - await refreshState(); - } catch (syncError) { - setResult(null); - setError( - syncError instanceof Error ? syncError.message : String(syncError), - ); - } finally { - setBusy(false); - } - } - - const details = result - ? [ - describePending('失败', result.failedFiles), - describePending('本次跳过', result.pendingFiles), - describePending('未纳入同步', result.skippedFiles), - ].filter((value): value is string => Boolean(value)) - : []; - - return createPortal( -
{ - if (busy) return; - closeDialogOnBackdropMouseDown(event, onClose); - }} - > -
{ - if (busy) return; - closeDialogOnEscape(event, onClose); - }} - > -

项目快照

- {error ? ( -

- {error} -

- ) : null} -
-
上传状态
-
{state?.enabled === false ? '已停用' : '已启用'}
-
上次同步
-
{formatSyncTime(state?.syncedAtMs ?? 0)}
-
同步序号
-
{state?.syncRevision ?? 0}
-
已纳管文件
-
{state?.fileCount ?? 0}
-
本地索引
-
{state?.indexPresent ? '已写入' : '尚未写入'}
- {result ? ( - <> -
最近一次
-
{syncStatusLabel(result.status)}
-
上传/字节
-
- {result.uploadedFiles} 个 / {result.uploadedBytes} 字节 -
-
远端已有
-
{result.remoteSkippedFiles} 个
-
删除/延后
-
- {result.deletedFiles} / {result.deferredFiles} -
- - ) : null} -
- {details.map((line) => ( -

- {line} -

- ))} -
- - - -
-
-
, - document.body, - ); -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index f02073226..05e7c1056 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -156,7 +156,6 @@ type ProjectWorkspaceChatPaneProps = { ) => Promise; handleRevealCurrentProjectDirectory: () => Promise; handleRuntimeConfigOpen: () => void; - handleProjectSnapshotOpen: () => void; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; llmConfigStatus: GameCreatorLlmConfigStatus | null; @@ -257,7 +256,6 @@ export function ProjectWorkspaceChatPane({ handleProjectSupervisorUserInput, handleRevealCurrentProjectDirectory, handleRuntimeConfigOpen, - handleProjectSnapshotOpen, hiddenConversationCount, hasEarlierConversationMessages = false, llmConfigStatus, @@ -324,13 +322,6 @@ export function ProjectWorkspaceChatPane({ - diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index c9a192ae0..5b0352492 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4979,52 +4979,6 @@ h2 { gap: 6px; } -.project-snapshot-dialog { - display: grid; - gap: 12px; -} - -.project-snapshot-dialog-error { - color: #b45309; -} - -.project-snapshot-dialog-facts { - display: grid; - grid-template-columns: auto 1fr; - gap: 6px 16px; - margin: 0; - font-size: 13px; -} - -.project-snapshot-dialog-facts dt { - color: #6b7280; -} - -.project-snapshot-dialog-facts dd { - margin: 0; - overflow-wrap: anywhere; -} - -.project-snapshot-dialog-detail { - margin: 0; - color: #6b7280; - font-size: 12px; - overflow-wrap: anywhere; -} - -.project-snapshot-dialog .launcher-dialog-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-end; -} - -.project-snapshot-dialog .launcher-dialog-actions button { - display: inline-flex; - align-items: center; - gap: 6px; -} - .resource-reference-picker-scopes { padding: 0 12px 4px; } diff --git a/apps/ai-game-creator-shell/tests/projectSnapshotDialog.test.tsx b/apps/ai-game-creator-shell/tests/projectSnapshotDialog.test.tsx deleted file mode 100644 index cb4b1811f..000000000 --- a/apps/ai-game-creator-shell/tests/projectSnapshotDialog.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** @vitest-environment jsdom */ - -import { cleanup, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { ProjectSnapshotDialog } from '../src/features/project-workspace/ProjectSnapshotDialog'; - -const PROJECT_PATH = 'C:\\games\\demo'; - -const state = { - projectId: 'gameagent-033b6cf397094f3e8d4e48c380ff4629', - indexPath: 'C:\\AppData\\project-snapshots\\gameagent\\index.json', - indexPresent: true, - fileCount: 6, - syncRevision: 2, - syncedAtMs: 1_789_629_339_000, - enabled: true, -}; - -const syncResult = { - projectId: state.projectId, - trigger: 'manual', - status: 'synced', - syncRevision: 3, - uploadedFiles: 1, - uploadedBytes: 2048, - remoteSkippedFiles: 0, - deletedFiles: 0, - deferredFiles: 0, - metadataOnlyFiles: 0, - skippedFiles: [], - pendingFiles: [], - failedFiles: [], - syncedAtMs: 1_789_629_400_000, -}; - -function installInvoke( - implementation: (command: string, args?: Record) => unknown, -) { - const invoke = vi.fn(implementation); - (window as unknown as { __TAURI__?: unknown }).__TAURI__ = { - core: { invoke }, - }; - return invoke; -} - -describe('ProjectSnapshotDialog', () => { - afterEach(() => { - cleanup(); - delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; - }); - - it('打开时读取本机索引状态并显示同步序号与文件数', async () => { - const invoke = installInvoke((command) => { - if (command === 'read_local_project_snapshot_state') return state; - throw new Error(`unexpected command: ${command}`); - }); - - render( - , - ); - - expect(await screen.findByText('2')).toBeTruthy(); - expect(screen.getByText('6')).toBeTruthy(); - expect(screen.getByText('已写入')).toBeTruthy(); - expect(invoke).toHaveBeenCalledWith('read_local_project_snapshot_state', { - projectPath: PROJECT_PATH, - }); - }); - - it('手动同步后显示上传计数与状态,并刷新索引状态', async () => { - let stateReads = 0; - const invoke = installInvoke((command) => { - if (command === 'read_local_project_snapshot_state') { - stateReads += 1; - return state; - } - if (command === 'sync_local_project_snapshot') return syncResult; - throw new Error(`unexpected command: ${command}`); - }); - - render( - , - ); - await waitFor(() => expect(stateReads).toBe(1)); - - await userEvent.click(screen.getByRole('button', { name: /立即同步/u })); - - expect(await screen.findByText('已同步')).toBeTruthy(); - expect(screen.getByText('1 个 / 2048 字节')).toBeTruthy(); - await waitFor(() => expect(stateReads).toBe(2)); - expect(invoke).toHaveBeenCalledWith('sync_local_project_snapshot', { - projectPath: PROJECT_PATH, - }); - }); - - it('同步失败时显示错误而不是伪造成功状态', async () => { - const invoke = installInvoke((command) => { - if (command === 'read_local_project_snapshot_state') return state; - if (command === 'sync_local_project_snapshot') { - throw new Error('authentication-required: 请先登录陶泥儿账号'); - } - throw new Error(`unexpected command: ${command}`); - }); - - render( - , - ); - await waitFor(() => expect(invoke).toHaveBeenCalled()); - - await userEvent.click(screen.getByRole('button', { name: /立即同步/u })); - - expect(await screen.findByText(/authentication-required/u)).toBeTruthy(); - expect(screen.queryByText('已同步')).toBeNull(); - }); -}); diff --git a/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md index 24310e7b3..25f0c802e 100644 --- a/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md @@ -25,7 +25,7 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev` - 不保留多版本历史:清单写入成功后回收不再被引用的旧对象,同一路径只保留当前内容。 - 不下发 bucket 生命周期策略;不做跨节点的用户级总量配额与计费口径。 - 不新增 SpacetimeDB 表或 procedure,不修改 `/api/external/v1` 与 External OpenAPI。 -- 不新增上传设置项与进度条;界面只提供状态与手动触发。 +- 不在客户端暴露上传状态、时间线或入口按钮;状态只落本机诊断日志,排障走 native-only 命令。 ## 验收标准 @@ -41,7 +41,6 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev` 10. 清单写入成功后,上一版清单里不再被引用的对象被回收;上一版清单不可读时整轮不删除任何对象。 11. 单项目超过 2 GiB 时客户端明确失败、服务端按 413 拒绝;超过服务端小时配额或 5 秒最小间隔时返回 429 且带 `Retry-After`。 12. 同步期间被改写的文件既不上传也不推进索引,沿用上一轮记录,且不会被误判成删除。 -13. 工作区「项目快照」面板能显示上传状态、上次同步时间、同步序号与已纳管文件数,并能手动触发同步与显示失败原因。 ## 依赖 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index c20eafbc4..9e182e5b6 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1529,8 +1529,8 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 ### 目标与非目标 -- 目标:AGC 在项目工作区打开期间按固定周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;重复内容不重复上传,远端占用跟随当前清单收敛,用户可在项目里查看同步状态并手动触发。 -- 非目标:不做云端下载/恢复、不做跨设备合并、不保留多版本历史、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。 +- 目标:AGC 在项目工作区打开期间按固定周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;重复内容不重复上传,远端占用跟随当前清单收敛。 +- 非目标:不做云端下载/恢复、不做跨设备合并、不保留多版本历史、不新增面向用户的上传界面、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。 - 非目标:不把 OSS AccessKey 放进客户端;客户端不直连 OSS。 ### 参与入口、状态与跨模块边界 @@ -1539,7 +1539,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 应用退出(`RunEvent::Exit`)不重复发起同步:该时刻窗口已销毁,按窗口重新枚举项目只会得到空集;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步能写完索引再退出。 - 客户端扫描、差异对比、索引持久化与上传编排都在 Tauri Rust 进程(`src-tauri/src/project_snapshot/`);WebView 只读状态,不参与差异计算。 - 本地索引是增量对比的唯一依据:`/project-snapshots//index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。 -- 界面入口:工作区聊天头部的「项目快照」按钮打开独立面板(`ProjectSnapshotDialog`),显示上传状态、上次同步时间、同步序号、已纳管文件数、本地索引是否写入,以及最近一次同步的上传/远端已有/删除/延后计数与失败明细;面板提供「刷新」与「立即同步」。面板不写功能说明文案,只用标签与数值。 +- 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 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` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。 - 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`,未配置时回退 `ALIYUN_OSS_*`;与"资源 bucket 与备份 bucket 分离"的既有口径一致。 @@ -1567,12 +1567,12 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 定向 Rust 测试:首次同步全量、仅改一个文件时只产生一个修改项、删除文件只体现在清单、`(size,mtime)` 未变时复用旧摘要、排除规则与上限跳过、同步失败不推进索引、同一项目并发触发串行化。 - 服务端测试:越界 `projectId`/相对路径/摘要被拒;相同摘要重复提交走跳过分支;超过单项目上限返回 413;超过用户小时配额返回 429;鉴权缺失返回 401;OSS 未配置返回明确的 5xx 而不是写入空对象。 -- 前端测试:面板打开即读取索引状态并显示同步序号与文件数;手动同步后显示上传计数并刷新状态;同步失败显示错误而不是伪造成功。 - 运行时 smoke:AGC 开发态打开项目、观察索引写入与同步日志、关闭工作区窗口后确认关闭触发的那次同步执行;报告为"客户端 diff 已验证 / 服务端已配置环境联调"两层,不合并成一句"已通"。 - 边界:新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。 ### 未决问题 +- 用户侧看不到同步状态与失败原因(界面按产品口径不暴露),排障只能读 AppData 诊断日志或调用 native-only 命令;如果后续要支持用户自助排查,需要先确认是否允许在客户端出现上传相关 UI。 - 历史版本:本轮只保留"当前状态镜像 + 清单",旧内容对象在清单写入成功后即被回收,没有回滚能力;要保留历史版本需要先定"保留几个 revision + 由谁回收"的策略。 - 用户级配额:跨节点的用户总量配额与计费口径未定;当前用单项目 2 GiB 上限 + 清单引用回收保证常驻占用有界,用户级总量只能靠项目数间接约束。 - 目标 bucket 的生命周期规则(例如转低频/过期删除)需要在部署环境确认后单独收口;功能本身已不再依赖它来控制增长。