From 2d875377a9a3ea4781858815c6085d6f2599867f Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:35:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E9=A1=B9=E7=9B=AE=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E7=9A=84=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E4=B8=8E=E5=90=8E=E5=8F=B0=E5=88=97=E8=A1=A8=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=81=94=E8=B0=83=E5=8F=96=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/check-agc-project-snapshot-admin-http.mjs:核对后台列表里的快照统计、下载工程 ZIP、逐条与本地项目副本比对 SHA-256,并检查排除项与 .agent 目录 - 里程碑条目 9 改为已勾选,写入真实客户端共享模块上传(首次 synced、紧接着 no-op)与后台列表/ZIP 的 HTTP 联调取证,并把未验证项单独列出 - pitfalls 记录单用户每小时 3000 次文件上传配额对真实链路冒烟的影响与选项目口径 --- ...‹碑】项目自动上传与后台工程下载-2026-09-19.md | 11 +- docs/project-memory/shared-memory/pitfalls.md | 7 +- package.json | 1 + .../check-agc-project-snapshot-admin-http.mjs | 217 ++++++++++++++++++ 4 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 scripts/check-agc-project-snapshot-admin-http.mjs diff --git a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md index 281b63a71..409a089f6 100644 --- a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md +++ b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md @@ -29,7 +29,7 @@ - [x] 清单名称/完整性变化在无内容差异时也提交,partial 可恢复 ready,历史缺字段不冒充 ready。 - [x] 缺失、损坏、越界路径和非完整清单失败关闭;历史未声明完整性的清单明确标记,允许导出已有文件但不称为完整工程。 - [x] 无后台权限不能读取项目或 ZIP;不泄漏凭据;ZIP 构建有体积、并发和临时文件清理边界。 -- [ ] 定向 Rust 测试、后台类型检查/构建、UI smoke、编码、文档索引和 diff 检查完成,运行时证据与未验证部分分别列出。 +- [x] 定向 Rust 测试、后台类型检查/构建、UI smoke、编码、文档索引和 diff 检查完成,运行时证据与未验证部分分别列出。 ## 证据要求 @@ -78,4 +78,11 @@ - 条目 6:`project_snapshot_manifest_metadata_changes_sync_without_content_changes`、`project_snapshot_legacy_index_keeps_completeness_unknown`、`project_snapshots_status_preserves_unknown_and_partial`。 - 条目 7:`admin_project_snapshots::tests::project_snapshots_archive_rejects_ambiguous_paths_and_unsafe_names`、`project_snapshots_zip_fails_closed_and_cleans_tempfiles`。 - 条目 8:`admin::agc_model_permissions_tests::project_snapshots_handlers_require_the_tab_for_authenticated_members`、`project_snapshots_response_releases_file_and_permit_on_finish_or_cancel`、`project_snapshots_cancelled_wait_keeps_permit_until_blocking_work_exits`;`platform-oss` 74 passed 覆盖凭据不下发与有界读取。 -- 未勾选的条目 9:UI smoke 与「运行时证据/未验证部分分别列出」还差本地数据库就绪后的 HTTP 联调与新客户端实机自动上传,保持未勾选。 +- 条目 9(2026-09-28 转为已勾选):`npm run check:agc-project-snapshot-admin-http` 走完本地数据库就绪后的后台列表 + ZIP HTTP 联调(见文末本轮复验);仍未验证的是新客户端安装版实机自动上传与部署,按要求单独列出。 + +## 本轮复验(2026-09-28):客户端差异引擎 → api-server → 真实 OSS → 后台列表/ZIP + +- 真实客户端模块链路:`cargo test --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot_live_sync -- --ignored --nocapture` → **1 passed**。用的是真实项目副本(`gameagent-d0c9e83f`,本地副本 1,530 个文件 / 81 MB,压到 `%TEMP%` 的一次性目录)、dev 专用账号与本地 api-server(`http://127.0.0.1:4198`):第一次同步 `synced` 且上传数 > 0,紧接着第二次 `no-op` / 上传 0 个文件(差异对比在真实服务端与真实 OSS 上确实避免了重复上传)。 +- 后台列表 + ZIP HTTP 联调:新增 `npm run check:agc-project-snapshot-admin-http`(`scripts/check-agc-project-snapshot-admin-http.mjs`,只读)→ **10 项 PASS**:管理员列表能看到该快照 `status=ready`、`files=182`、`bytes=18,992,042`;下载返回 `application/zip`(17,297,980 字节压缩包);ZIP 条目数 182 与列表一致、条目体积之和 18,992,042 与列表一致;**182 个条目逐个与本地副本同路径文件 SHA-256 一致(mismatches=0)**;ZIP 里没有 `.git/` / `node_modules/` / `target/` / `.env*`;`.agent/` 目录带了 167 个文件(状态目录整目录随快照上传的口径成立)。 +- 口径说明:本地副本 1,530 个文件里只有 182 个进了快照,差额是同步策略排除的构建产物 / 缓存 / 凭据,不是导出丢失;ZIP 侧只断言「条目都能在本地找到且逐字节一致」,不把排除项当作缺失。 +- 仍未验证(按要求单独列出):新客户端安装版下的隔离实机自动上传、真实部署环境的后台下载;本轮只到「客户端模块 + 本地 api-server + 真实 OSS + 后台 HTTP」这一层。 \ No newline at end of file diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 87709fce9..02c2fecd4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6059,4 +6059,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因**:`server-rs/crates/platform-oss/src/lib.rs` 的 `put_internal_object_bytes` 收下 `OssObjectAccess` 却只把它交给 `oss_access_label` 写日志,PUT 请求从不带 `x-oss-object-acl`;对象 ACL 因此继承 bucket 默认值(dev 的 `agc-dev` 是允许匿名读的)。 - **判据**:`npm run check:game-distribution-media-e2e` 默认把这条打印成 `WARN`(本地 dev bucket 允许匿名读是环境现状),设 `E2E_REQUIRE_PRIVATE_BUCKET=1` 时按失败处理;本轮两种模式都实测过(默认 63 PASS + WARN,强制模式该条 FAIL)。 - **处理**:上线前必须 ① 确认生产 bucket 不是 public-read;② 给 `platform-oss` 的内部 PUT、分片追加与直传策略补对象级 ACL(或干脆在 bucket 策略层对 `agc/project-snapshots/**` 收紧)。 -- **已修(2026-09-28)**:`platform-oss` 现在在内部 PUT(`build_put_object_headers` 收 `access`)、分片追加(`OssAppendInternalObjectRequest` 新增 `access`)与直传 policy / 表单(`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`,policy 里同值做条件约束)三处都显式下发对象级 ACL(`Private` → `private`,`Public` → `public-read`)。复验:`cargo test -p platform-oss` 76 passed;`E2E_REQUIRE_PRIVATE_BUCKET=1 npm run check:game-distribution-media-e2e` 下匿名直取直传封面与发行包对象都返回 **403**(65 项 PASS)。存量对象仍是旧的 bucket 默认 ACL,需要按需回填或换键。- **影响面**:所有经 `platform-oss` 写入「private」对象的链路(游戏发行包、AGC 项目快照、后台导出)在公开 bucket 上都继承公开读。 \ No newline at end of file +- **已修(2026-09-28)**:`platform-oss` 现在在内部 PUT(`build_put_object_headers` 收 `access`)、分片追加(`OssAppendInternalObjectRequest` 新增 `access`)与直传 policy / 表单(`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`,policy 里同值做条件约束)三处都显式下发对象级 ACL(`Private` → `private`,`Public` → `public-read`)。复验:`cargo test -p platform-oss` 76 passed;`E2E_REQUIRE_PRIVATE_BUCKET=1 npm run check:game-distribution-media-e2e` 下匿名直取直传封面与发行包对象都返回 **403**(65 项 PASS)。存量对象仍是旧的 bucket 默认 ACL,需要按需回填或换键。- **影响面**:所有经 `platform-oss` 写入「private」对象的链路(游戏发行包、AGC 项目快照、后台导出)在公开 bucket 上都继承公开读。 +## 2026-09-28 AGC 项目快照有单用户每小时 3000 次文件上传配额,别用超大项目跑真实链路冒烟 + +- **现象**:`server-rs/crates/api-server/src/project_snapshots.rs` 里 `MAX_FILE_UPLOADS_PER_USER_PER_HOUR = 3_000`(清单 120/小时)是进程内计数;本机 16 个真实项目里有 4 个超过 3,000 个文件,用它们跑 `project_snapshot_live_sync` 会在中途拿到 429、`failed_files` 非空,看起来像同步回归。 +- **判据/做法**:真实链路冒烟挑 1,000~2,000 个文件量级、且带 `.agent` 的项目副本(本轮用 `gameagent-d0c9e83f`,1,530 个文件里按同步策略实际入快照 182 个);要验证超大项目,先重启 api-server 清掉进程内计数或临时调高配额,不要把 429 当代码回归。 +- **关联**:`scripts/check-agc-project-snapshot-admin-http.mjs`(后台列表 + ZIP 下载核对)与 `apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs` 的 ignored live smoke。 \ No newline at end of file diff --git a/package.json b/package.json index 26eaac292..eb625ed21 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "check:game-distribution-owner-isolation": "node scripts/check-game-distribution-owner-isolation.mjs", "check:game-distribution-upload-safety": "node scripts/check-game-distribution-upload-safety.mjs", "check:game-distribution-upload-resume": "node scripts/check-game-distribution-upload-resume.mjs", + "check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-agc-project-snapshot-admin-http.mjs b/scripts/check-agc-project-snapshot-admin-http.mjs new file mode 100644 index 000000000..ce3a42128 --- /dev/null +++ b/scripts/check-agc-project-snapshot-admin-http.mjs @@ -0,0 +1,217 @@ +// AGC 项目快照「后台列表 + 工程 ZIP 下载」的真实链路检查。 +// +// 用法(需要本地 dev 栈与一份已经同步过的项目副本): +// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ +// E2E_SNAPSHOT_USER_ID=<快照归属 user id> E2E_SNAPSHOT_PROJECT_ID= \ +// E2E_SNAPSHOT_PROJECT_DIR=<本地项目副本绝对路径> \ +// npm run check:agc-project-snapshot-admin-http +// E2E_API_BASE 默认 http://127.0.0.1:4198;E2E_SNAPSHOT_CHANNEL 默认 dev。 +// +// 覆盖:管理员列表能看到该快照的统计(status=ready / fileCount / totalBytes)→ 下载 ZIP → +// 条目数与列表一致 → 每个条目与本地副本同名文件的 SHA-256 逐一致 → ZIP 里没有版本库 / +// node_modules / .env* 这类被排除的内容。整条链路只读:不写 OSS、不改后台数据。 +import { createHash } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import JSZip from 'jszip'; + +const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198'; +const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; +const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; +const SNAPSHOT_USER_ID = (process.env.E2E_SNAPSHOT_USER_ID ?? '').trim(); +const SNAPSHOT_PROJECT_ID = (process.env.E2E_SNAPSHOT_PROJECT_ID ?? '').trim(); +const PROJECT_DIR = (process.env.E2E_SNAPSHOT_PROJECT_DIR ?? '').trim(); +const CHANNEL = (process.env.E2E_SNAPSHOT_CHANNEL ?? 'dev').trim(); + +for (const [name, value] of [ + ['E2E_ADMIN_USER', ADMIN_USER], + ['E2E_ADMIN_PASSWORD', ADMIN_PASSWORD], + ['E2E_SNAPSHOT_USER_ID', SNAPSHOT_USER_ID], + ['E2E_SNAPSHOT_PROJECT_ID', SNAPSHOT_PROJECT_ID], + ['E2E_SNAPSHOT_PROJECT_DIR', PROJECT_DIR], +]) { + if (!value) { + console.error( + `缺少 ${name}:本脚本要对已同步的快照做列表与 ZIP 下载核对。`, + ); + process.exit(2); + } +} + +let failures = 0; +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); +} + +async function api(pathname, options = {}) { + const { method = 'GET', token, body, headers = {}, raw = false } = options; + const finalHeaders = { ...ENVELOPE, ...headers }; + if (token) finalHeaders.Authorization = `Bearer ${token}`; + let finalBody; + if (body !== undefined) { + finalHeaders['Content-Type'] = 'application/json'; + finalBody = JSON.stringify(body); + } + const response = await fetch(`${API}${pathname}`, { + method, + headers: finalHeaders, + body: finalBody, + }); + if (raw) { + return { + status: response.status, + headers: response.headers, + buffer: Buffer.from(await response.arrayBuffer()), + }; + } + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { + status: response.status, + json, + text, + data: json?.data, + error: json?.error, + }; +} + +function sha256(buffer) { + return createHash('sha256').update(buffer).digest('hex'); +} + +async function main() { + const login = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + const admin = login.data?.token ?? login.data?.accessToken; + check( + '管理员登录成功', + login.status === 200 && Boolean(admin), + `status=${login.status}`, + ); + if (!admin) process.exit(1); + + const list = await api( + `/admin/api/project-snapshots?channel=${encodeURIComponent(CHANNEL)}&limit=100`, + { token: admin }, + ); + const item = (list.data?.items ?? []).find( + (entry) => + entry.userId === SNAPSHOT_USER_ID && + entry.projectId === SNAPSHOT_PROJECT_ID, + ); + check( + '后台列表包含该项目的快照', + list.status === 200 && Boolean(item), + `status=${list.status} items=${list.data?.items?.length ?? 'n/a'}`, + ); + check( + '快照统计是 ready 且文件数与体积非零', + item?.status === 'ready' && item.fileCount > 0 && item.totalBytes > 0, + `status=${item?.status ?? ''} files=${item?.fileCount ?? 'n/a'} bytes=${item?.totalBytes ?? 'n/a'}`, + ); + if (!item) process.exit(1); + + const download = await api( + `/admin/api/project-snapshots/${encodeURIComponent(SNAPSHOT_USER_ID)}/${encodeURIComponent(SNAPSHOT_PROJECT_ID)}/download?channel=${encodeURIComponent(CHANNEL)}`, + { token: admin, raw: true }, + ); + check( + '后台下载返回 ZIP', + download.status === 200 && + (download.headers.get('content-type') ?? '').includes('zip'), + `status=${download.status} type=${download.headers.get('content-type') ?? ''} bytes=${download.buffer.length}`, + ); + if (download.status !== 200) process.exit(1); + + const archive = await JSZip.loadAsync(download.buffer); + const entries = Object.values(archive.files).filter((file) => !file.dir); + check( + 'ZIP 条目数与后台列表的文件数一致', + entries.length === item.fileCount, + `zip=${entries.length} list=${item.fileCount}`, + ); + const zipBytes = entries.reduce( + (total, entry) => total + (entry._data?.uncompressedSize ?? 0), + 0, + ); + check( + 'ZIP 内文件体积之和与后台列表一致', + zipBytes === item.totalBytes, + `zip=${zipBytes} list=${item.totalBytes}`, + ); + + let compared = 0; + let missingLocally = 0; + const mismatches = []; + for (const entry of entries) { + const localPath = path.join(PROJECT_DIR, ...entry.name.split('/')); + let localBytes; + try { + const info = await stat(localPath); + if (!info.isFile()) throw new Error('not a file'); + localBytes = await readFile(localPath); + } catch { + missingLocally += 1; + continue; + } + const zipBytesForEntry = await entry.async('nodebuffer'); + compared += 1; + if (sha256(zipBytesForEntry) !== sha256(localBytes)) { + mismatches.push(entry.name); + } + } + check( + 'ZIP 条目在本地副本里都能找到', + missingLocally === 0, + `missing=${missingLocally}`, + ); + check( + 'ZIP 内容与本地副本逐字节一致(SHA-256)', + compared === entries.length && mismatches.length === 0, + `compared=${compared} mismatches=${mismatches.length}${mismatches.length > 0 ? ` first=${mismatches[0]}` : ''}`, + ); + + const excluded = entries + .map((entry) => entry.name) + .filter( + (name) => + /(^|\/)\.git\//u.test(name) || + /(^|\/)node_modules\//u.test(name) || + /(^|\/)target\//u.test(name) || + /(^|\/)\.env(\.|$)/u.test(name), + ); + check( + 'ZIP 里没有版本库 / node_modules / .env* 这类排除项', + excluded.length === 0, + excluded.length > 0 ? `first=${excluded[0]}` : '', + ); + + const agentEntries = entries.filter((entry) => + entry.name.startsWith('.agent/'), + ).length; + check( + 'ZIP 里确实带上了 .agent 状态目录', + agentEntries > 0, + `.agent files=${agentEntries}`, + ); + + console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((error) => { + console.error(`[check:agc-project-snapshot-admin-http] 运行失败:${error}`); + process.exit(1); +});