Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ad2ce256 | |||
| 65f8e44a88 | |||
| ade1b7803e | |||
| 71000b6df1 | |||
| c047543825 | |||
| 34b3af1d2a | |||
| f7cba30b6b | |||
| 273f12633c | |||
| 5f5aa17152 | |||
| 3a57d9fbdf | |||
| 382b925ab9 | |||
| c1482012c6 | |||
| c3a17a6efc | |||
| 1bfdc3a760 | |||
| 1e27cd229d | |||
| 774710452f | |||
| aaecb82622 | |||
| 64ad24ac77 | |||
| 75ec3361dc | |||
| bc1dc868a1 | |||
| 50204ff7aa | |||
| 70fa160819 | |||
| 2e78cbf152 | |||
| 840baf208f | |||
| d8af0fa882 | |||
| faed0ec251 | |||
| 6e63a118e0 | |||
| 4ae5ab14fa | |||
| b73a329b2c | |||
| 06004bfeea | |||
| ecea7b4ad3 | |||
| b1e82d64cb | |||
| b7622f4a24 | |||
| cb840c6e8c | |||
| 0a9cdf6395 | |||
| e382d23892 | |||
| 37ae7508b8 | |||
| 1ae6c6cd06 | |||
| 459f8a60e5 | |||
| 63c495dab0 | |||
| b37d91882c | |||
| dd339638cc | |||
| 0caf998227 | |||
| 6eed6bd13f | |||
| b543a060b7 | |||
| 9993b2feb5 | |||
| d348cf9064 | |||
| e640219243 | |||
| da2ad83c3b | |||
| ad19c9475e | |||
| 975a2e577c | |||
| 1b40f030e7 | |||
| 098693636a | |||
| e17af3008e | |||
| 64bd006981 | |||
| 6a4cb2bc18 | |||
| 1b3a3b003f | |||
| ff8e517c8e | |||
| d874a13a92 | |||
| 5eba53a5cc | |||
| 33ddb5de27 | |||
| bf8f96c115 | |||
| e469831543 | |||
| 869abad879 | |||
| 280a2dc5d1 | |||
| 3c6e084a41 | |||
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| edfd1f9143 | |||
| 423f2d7184 | |||
| 656c89e4b2 | |||
| aa9076c95c | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 41366dd71d | |||
| 721e45f01b | |||
| f4b496caca | |||
| fc0fd846f9 | |||
| fa99d9ad62 | |||
| 64a52005f8 | |||
| 645d806fed | |||
| 0b9d16e8f1 | |||
| d5c4e0e780 | |||
| 30e27abc60 | |||
| b607501916 | |||
| 46e3cd9744 | |||
| 5fb3db662b | |||
| 87b798322e | |||
| ec286b3480 | |||
| 35733f0e33 | |||
| 3c7b02b9f8 | |||
| f2030a616f | |||
| b3b5d77990 | |||
| 1da106af7d | |||
| cd2edd6966 | |||
| 22e830ff93 | |||
| c266ae7b50 | |||
| a543b75cf7 | |||
| f213987f9a | |||
| 2c623bb577 | |||
| 3353906e6f | |||
| 17716347e2 | |||
| 9ad66a67a3 | |||
| ba3aa3ccdb | |||
| 2b38eaafba | |||
| 7d5b9071e7 | |||
| 9e83f1d88c | |||
| 7e35d7c344 | |||
| 5ec40c8b83 | |||
| 948a80fc49 | |||
| dfd6fadedf |
@@ -235,10 +235,6 @@ VITE_DEBUG_MODE=""
|
|||||||
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
||||||
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||||
|
|
||||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
|
||||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
|
||||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
|
||||||
|
|
||||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||||
# the fixed `bge-large-zh` embedding model.
|
# the fixed `bge-large-zh` embedding model.
|
||||||
|
|||||||
@@ -213,8 +213,6 @@ module.exports = {
|
|||||||
'!src/services/clipboard.test.ts',
|
'!src/services/clipboard.test.ts',
|
||||||
'!src/services/frontendRuntimeConfigService.ts',
|
'!src/services/frontendRuntimeConfigService.ts',
|
||||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||||
'!src/services/gameDistributionClient.ts',
|
|
||||||
'!src/services/gameDistributionClient.test.ts',
|
|
||||||
'!src/services/sseStream.ts',
|
'!src/services/sseStream.ts',
|
||||||
'!src/services/sseStream.test.ts',
|
'!src/services/sseStream.test.ts',
|
||||||
'src/AdventurePanel.tsx',
|
'src/AdventurePanel.tsx',
|
||||||
|
|||||||
@@ -23,6 +23,3 @@
|
|||||||
*.meta text
|
*.meta text
|
||||||
*.anim text
|
*.anim text
|
||||||
*.controller text
|
*.controller text
|
||||||
|
|
||||||
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
|
||||||
packages/shared/src/contracts/generated/** linguist-generated=true
|
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ jobs:
|
|||||||
- name: Run AI game creator shell agent-run smoke
|
- name: Run AI game creator shell agent-run smoke
|
||||||
run: npm run check:native-shells:agc-rust-smoke
|
run: npm run check:native-shells:agc-rust-smoke
|
||||||
|
|
||||||
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
|
||||||
|
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
|
||||||
ai-game-creator-shell-rust-crates:
|
ai-game-creator-shell-rust-crates:
|
||||||
name: AI game creator shell Rust crates
|
name: AI game creator shell Rust crates
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
@@ -262,15 +263,13 @@ jobs:
|
|||||||
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
||||||
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
||||||
# (见 #327 / PR #316 run 1950)。
|
# (见 #327 / PR #316 run 1950)。
|
||||||
# Cocos 插件也使用独立且未提交的锁文件,一并预热。
|
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||||
# 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
|
||||||
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||||
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||||
# 解析,不再触碰 registry index。
|
# 解析,不再触碰 registry index。
|
||||||
for manifest_path in \
|
for manifest_path in \
|
||||||
server-rs/crates/agent-runtime-core/Cargo.toml \
|
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml \
|
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
||||||
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
|
|
||||||
for attempt in $(seq 1 5); do
|
for attempt in $(seq 1 5); do
|
||||||
if cargo fetch \
|
if cargo fetch \
|
||||||
--target x86_64-unknown-linux-gnu \
|
--target x86_64-unknown-linux-gnu \
|
||||||
@@ -285,40 +284,6 @@ jobs:
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prepare Unity plugin Rust dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in $(seq 1 5); do
|
|
||||||
if cargo fetch --locked \
|
|
||||||
--target x86_64-unknown-linux-gnu \
|
|
||||||
--manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq 5 ]]; then
|
|
||||||
echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 2))
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Prepare Godot plugin Rust dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in $(seq 1 5); do
|
|
||||||
if cargo fetch --locked \
|
|
||||||
--target x86_64-unknown-linux-gnu \
|
|
||||||
--manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq 5 ]]; then
|
|
||||||
echo 'Godot plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 2))
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Run AI game creator shell shared crate gates
|
- name: Run AI game creator shell shared crate gates
|
||||||
run: npm run check:native-shells:agc-rust-crates
|
run: npm run check:native-shells:agc-rust-crates
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,6 @@ temp*build*/
|
|||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
||||||
/plugins/agc-cocos-editor/native/payload/
|
/plugins/agc-cocos-editor/native/payload/
|
||||||
/plugins/agc-unity-editor/dotnet/**/bin/
|
|
||||||
/plugins/agc-unity-editor/dotnet/**/obj/
|
|
||||||
/plugins/agc-unity-editor/dotnet/publish/
|
|
||||||
/plugins/agc-unity-editor/dotnet/native-build/
|
|
||||||
/apps/ai-game-creator-shell/logs/
|
/apps/ai-game-creator-shell/logs/
|
||||||
/apps/ai-game-creator-shell/.llm-drafts/
|
/apps/ai-game-creator-shell/.llm-drafts/
|
||||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||||
|
|||||||
+1
-10
@@ -1,14 +1,5 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all"
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "packages/shared/src/contracts/generated/**/*.ts",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 1000,
|
|
||||||
"singleQuote": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||||
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
||||||
- [`.codex/`](.codex/) 保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识写入 `docs/` 与 `docs/project-memory/`。
|
- [`.codex/`](.codex/) 只保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识不要写入 `.codex/`。
|
||||||
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
||||||
|
|
||||||
## 开始任务前
|
## 开始任务前
|
||||||
|
|
||||||
- 先写清一句话交付结果、验收判据和修改范围,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,其余记录为后续事项。
|
- 先写清一句话交付结果、验收判据和不做项,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,否则记录为后续事项;工具探测、历史整理或验证便利不能自行改变任务目标。
|
||||||
- Agent 可见内容直接描述当前任务、输入和成功条件,细节按调用需要提供。
|
|
||||||
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
||||||
1. 本文件。
|
1. 本文件。
|
||||||
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||||
@@ -24,20 +23,24 @@
|
|||||||
|
|
||||||
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
||||||
- 不要在 `.gitignore` 中新增 `.env.local`。
|
- 不要在 `.gitignore` 中新增 `.env.local`。
|
||||||
- 现有中文文案、注释、剧情和文档保持中文;看到乱码时先确认真实编码并恢复正确中文。
|
- 不要擅自把现有中文文案、注释、剧情或文档改写成英文;看到中文乱码时先确认真实编码,不要沿用乱码或用英文替换。
|
||||||
- 修改包含中文的文件时优先局部补丁;修改后优先运行仓库编码检查。
|
- 修改包含中文的文件时优先局部补丁,避免整文件重写;修改后优先运行仓库编码检查。
|
||||||
- 新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。
|
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
|
||||||
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
||||||
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件。
|
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
|
||||||
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用。共享组件承载通用表现与交互,领域规则、后端副作用和正式业务状态由后端负责。
|
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
|
||||||
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。
|
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
|
||||||
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移按实际需求保留最小、白名单化的兼容,并配套契约或迁移测试;迁移完成后同步删除兼容实现与对应测试。
|
1. 不写历史兼容代码。
|
||||||
- UI 面板优先呈现任务内容与操作,说明按当前操作需要提供;移动端优先,同时保证网页端可正常显示和操作。
|
2. 不写用于维持退役行为的防御性兼容测试。
|
||||||
- 点击按钮弹出的独立面板使用弹窗、抽屉、popover 或页面级 portal。
|
3. 不写仅说明其曾存在或已删除的墓碑注释。
|
||||||
|
4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。
|
||||||
|
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。
|
||||||
|
- UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。
|
||||||
|
- 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。
|
||||||
|
|
||||||
## 任务路由
|
## 任务路由
|
||||||
|
|
||||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||||
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
||||||
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||||
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
||||||
@@ -47,10 +50,10 @@
|
|||||||
## 后端红线
|
## 后端红线
|
||||||
|
|
||||||
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
||||||
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`。
|
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`;旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud` / `Maincloud` / `MAINCLOUD` 只作为历史残留,不作为兼容目标。
|
||||||
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
||||||
- 前端负责表现、交互和临时 UI 状态;正式业务状态与规则以后端投影和后端 API 为准。
|
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准。
|
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。
|
||||||
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
||||||
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
||||||
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
||||||
|
|||||||
@@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
|||||||
|
|
||||||
## 项目开发对话(DirectProject)
|
## 项目开发对话(DirectProject)
|
||||||
|
|
||||||
|
**DirectProject 专属聊天模块**:
|
||||||
|
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||||
|
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
||||||
|
|
||||||
|
**项目工作台布局**:
|
||||||
|
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
||||||
|
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
||||||
|
|
||||||
**项目对话历史**:
|
**项目对话历史**:
|
||||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ import {
|
|||||||
executeAdminRechargeRefund,
|
executeAdminRechargeRefund,
|
||||||
getAdminFeatureGateConfig,
|
getAdminFeatureGateConfig,
|
||||||
getAdminUserDetail,
|
getAdminUserDetail,
|
||||||
listAdminGameDistributionReviews,
|
|
||||||
listAdminRechargeOrders,
|
listAdminRechargeOrders,
|
||||||
reconcileAdminUserConsumption,
|
reconcileAdminUserConsumption,
|
||||||
resolveAdminRechargeRefundManualReview,
|
resolveAdminRechargeRefundManualReview,
|
||||||
reviewAdminGameDistributionVersion,
|
|
||||||
suspendAdminGameDistributionGame,
|
|
||||||
updateAdminAccount,
|
updateAdminAccount,
|
||||||
uploadAdminEditorShowcaseCampaignImage,
|
uploadAdminEditorShowcaseCampaignImage,
|
||||||
upsertAdminFeatureGateConfig,
|
upsertAdminFeatureGateConfig,
|
||||||
@@ -367,134 +364,3 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
|
|
||||||
const fetchMock = vi.fn().mockImplementation(() =>
|
|
||||||
Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await listAdminGameDistributionReviews('admin-token');
|
|
||||||
await reviewAdminGameDistributionVersion(
|
|
||||||
'admin-token',
|
|
||||||
'gamever/1',
|
|
||||||
'game-review-key-1',
|
|
||||||
{
|
|
||||||
decision: 'approve',
|
|
||||||
expectedPublicationRevision: 3,
|
|
||||||
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
||||||
'/admin/api/game-distribution/reviews?limit=48',
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
||||||
'/admin/api/game-distribution/versions/gamever%2F1/review',
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
'Idempotency-Key': 'game-review-key-1',
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({
|
|
||||||
decision: 'approve',
|
|
||||||
expectedPublicationRevision: 3,
|
|
||||||
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
|
||||||
const fetchMock = vi.fn().mockImplementation(() =>
|
|
||||||
Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ game: {}, replayed: false }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await suspendAdminGameDistributionGame(
|
|
||||||
'admin-token',
|
|
||||||
'game/1',
|
|
||||||
'game-suspend-key-1',
|
|
||||||
{ expectedPublicationRevision: 7, reason: '版权投诉' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
||||||
'/admin/api/game-distribution/games/game%2F1/suspend',
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
'Idempotency-Key': 'game-suspend-key-1',
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({
|
|
||||||
expectedPublicationRevision: 7,
|
|
||||||
reason: '版权投诉',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
|
|
||||||
expectedPublicationRevision: 1,
|
|
||||||
}),
|
|
||||||
).toThrow('缺少游戏 ID');
|
|
||||||
expect(() =>
|
|
||||||
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
|
|
||||||
expectedPublicationRevision: 1,
|
|
||||||
}),
|
|
||||||
).toThrow('下架幂等键必须是 1 到 128 个字符');
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
|
|
||||||
const fetchMock = vi.fn().mockImplementation(() =>
|
|
||||||
Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ version: {}, replayed: false }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await reviewAdminGameDistributionVersion(
|
|
||||||
'admin-token',
|
|
||||||
'version-1',
|
|
||||||
'game-review-key-2',
|
|
||||||
{
|
|
||||||
decision: 'reject',
|
|
||||||
expectedPublicationRevision: 0,
|
|
||||||
reviewReason: '运行时报错',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
body: JSON.stringify({
|
|
||||||
decision: 'reject',
|
|
||||||
expectedPublicationRevision: 0,
|
|
||||||
reviewReason: '运行时报错',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
|
|
||||||
decision: 'reject',
|
|
||||||
expectedPublicationRevision: 0,
|
|
||||||
reviewReason: 'x',
|
|
||||||
}),
|
|
||||||
).toThrow('审核幂等键必须是 1 到 128 个字符');
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -29,14 +29,9 @@ import type {
|
|||||||
AdminExternalApiKeyListQuery,
|
AdminExternalApiKeyListQuery,
|
||||||
AdminExternalApiKeyListResponse,
|
AdminExternalApiKeyListResponse,
|
||||||
AdminFeatureGateConfigResponse,
|
AdminFeatureGateConfigResponse,
|
||||||
AdminGameDistributionReviewListResponse,
|
|
||||||
AdminGameDistributionReviewRequest,
|
|
||||||
AdminGameDistributionReviewResponse,
|
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
AdminMeResponse,
|
AdminMeResponse,
|
||||||
AdminOverviewResponse,
|
AdminOverviewResponse,
|
||||||
AdminProjectSnapshotListQuery,
|
|
||||||
AdminProjectSnapshotListResponse,
|
|
||||||
AdminRechargeOrderListQuery,
|
AdminRechargeOrderListQuery,
|
||||||
AdminRechargeOrderListResponse,
|
AdminRechargeOrderListResponse,
|
||||||
AdminRechargeRefundActionResponse,
|
AdminRechargeRefundActionResponse,
|
||||||
@@ -203,92 +198,6 @@ export function listAdminAccounts(token: string) {
|
|||||||
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminProjectSnapshots(
|
|
||||||
token: string,
|
|
||||||
query: AdminProjectSnapshotListQuery = {},
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (query.cursor) params.set('cursor', query.cursor);
|
|
||||||
params.set('limit', String(query.limit ?? 20));
|
|
||||||
return request<AdminProjectSnapshotListResponse>(
|
|
||||||
`/admin/api/project-snapshots?${params.toString()}`,
|
|
||||||
{ token, signal },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadAdminProjectSnapshot(
|
|
||||||
token: string,
|
|
||||||
userId: string,
|
|
||||||
projectId: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
|
||||||
const response = await fetch(buildRequestUrl(path), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token.trim()}`,
|
|
||||||
Accept: 'application/zip',
|
|
||||||
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const responseText = await response.text();
|
|
||||||
throw buildAdminApiError(
|
|
||||||
response,
|
|
||||||
parseJsonResponse(responseText),
|
|
||||||
responseText,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const contentType = response.headers
|
|
||||||
.get('content-type')
|
|
||||||
?.split(';')[0]
|
|
||||||
?.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (contentType !== 'application/zip') {
|
|
||||||
await response.body?.cancel();
|
|
||||||
throw new AdminApiError({
|
|
||||||
message: '下载失败:服务端未返回 ZIP 工程文件',
|
|
||||||
status: response.status,
|
|
||||||
code: 'INVALID_PROJECT_ARCHIVE_RESPONSE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blob: await response.blob(),
|
|
||||||
filename: projectArchiveFilename(
|
|
||||||
response.headers.get('content-disposition'),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectArchiveFilename(contentDisposition: string | null): string {
|
|
||||||
const extended = contentDisposition?.match(
|
|
||||||
/(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i,
|
|
||||||
);
|
|
||||||
const ordinary = contentDisposition?.match(
|
|
||||||
/(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i,
|
|
||||||
);
|
|
||||||
let filename =
|
|
||||||
ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? '';
|
|
||||||
if (extended?.[1]) {
|
|
||||||
try {
|
|
||||||
filename = decodeURIComponent(extended[1].trim());
|
|
||||||
} catch {
|
|
||||||
// 非法扩展编码继续使用普通文件名。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const safeName = Array.from(filename, (character) => {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code < 32 || code === 127 ? '_' : character;
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
.replace(/[<>:"/\\|?*]/g, '_')
|
|
||||||
.trim()
|
|
||||||
.replace(/[. ]+$/, '');
|
|
||||||
if (!safeName || safeName.length > 240) return 'project.zip';
|
|
||||||
return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminAccount(
|
export function createAdminAccount(
|
||||||
token: string,
|
token: string,
|
||||||
payload: AdminCreateAccountRequest,
|
payload: AdminCreateAccountRequest,
|
||||||
@@ -1179,72 +1088,3 @@ export function saveAgcModelCatalog(
|
|||||||
{ token, method: 'PUT', body },
|
{ token, method: 'PUT', body },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
|
||||||
const normalizedLimit = Number.isFinite(limit)
|
|
||||||
? Math.min(Math.max(Math.trunc(limit), 1), 48)
|
|
||||||
: 48;
|
|
||||||
return request<AdminGameDistributionReviewListResponse>(
|
|
||||||
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
|
|
||||||
{ token },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
|
|
||||||
* 两条审核结论。
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
|
|
||||||
*/
|
|
||||||
export function suspendAdminGameDistributionGame(
|
|
||||||
token: string,
|
|
||||||
gameId: string,
|
|
||||||
idempotencyKey: string,
|
|
||||||
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
|
|
||||||
) {
|
|
||||||
const normalizedGameId = gameId.trim();
|
|
||||||
const normalizedKey = idempotencyKey.trim();
|
|
||||||
if (!normalizedGameId) {
|
|
||||||
throw new Error('缺少游戏 ID');
|
|
||||||
}
|
|
||||||
if (!normalizedKey || normalizedKey.length > 128) {
|
|
||||||
throw new Error('下架幂等键必须是 1 到 128 个字符');
|
|
||||||
}
|
|
||||||
return request<
|
|
||||||
import('./adminApiTypes').AdminGameDistributionSuspendResponse
|
|
||||||
>(
|
|
||||||
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
token,
|
|
||||||
headers: { 'Idempotency-Key': normalizedKey },
|
|
||||||
body: payload,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reviewAdminGameDistributionVersion(
|
|
||||||
token: string,
|
|
||||||
versionId: string,
|
|
||||||
idempotencyKey: string,
|
|
||||||
payload: AdminGameDistributionReviewRequest,
|
|
||||||
) {
|
|
||||||
const normalizedVersionId = versionId.trim();
|
|
||||||
const normalizedKey = idempotencyKey.trim();
|
|
||||||
if (!normalizedVersionId) {
|
|
||||||
throw new Error('缺少发行版本 ID');
|
|
||||||
}
|
|
||||||
if (!normalizedKey || normalizedKey.length > 128) {
|
|
||||||
throw new Error('审核幂等键必须是 1 到 128 个字符');
|
|
||||||
}
|
|
||||||
return request<AdminGameDistributionReviewResponse>(
|
|
||||||
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
token,
|
|
||||||
headers: { 'Idempotency-Key': normalizedKey },
|
|
||||||
body: payload,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,27 +96,6 @@ export interface AdminMeResponse {
|
|||||||
admin: AdminSessionPayload;
|
admin: AdminSessionPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotEntry {
|
|
||||||
userId: string;
|
|
||||||
projectId: string;
|
|
||||||
projectName: string | null;
|
|
||||||
syncRevision: number;
|
|
||||||
syncedAtMs: number;
|
|
||||||
fileCount: number;
|
|
||||||
totalBytes: number;
|
|
||||||
status: 'ready' | 'partial' | 'unverified';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListQuery {
|
|
||||||
cursor?: string | null;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListResponse {
|
|
||||||
items: AdminProjectSnapshotEntry[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminErrorReportEntry {
|
export interface AdminErrorReportEntry {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -1031,48 +1010,3 @@ export interface AdminAgcModelCatalog {
|
|||||||
defaultModelId: string;
|
defaultModelId: string;
|
||||||
models: AdminAgcModel[];
|
models: AdminAgcModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminGameDistributionReviewEntry {
|
|
||||||
versionId: string;
|
|
||||||
gameId: string;
|
|
||||||
versionNumber: number;
|
|
||||||
packageSha256: string;
|
|
||||||
packageBytes: number;
|
|
||||||
status: string;
|
|
||||||
publicationRevision: number;
|
|
||||||
reviewReason: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionReviewListResponse {
|
|
||||||
entries: AdminGameDistributionReviewEntry[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionReviewRequest {
|
|
||||||
decision: 'approve' | 'reject';
|
|
||||||
expectedPublicationRevision: number;
|
|
||||||
reviewReason?: string;
|
|
||||||
entryUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionReviewResponse {
|
|
||||||
version: AdminGameDistributionReviewEntry;
|
|
||||||
replayed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionSuspendRequest {
|
|
||||||
expectedPublicationRevision: number;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionSuspendResponse {
|
|
||||||
game: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
publicationRevision: number;
|
|
||||||
};
|
|
||||||
replayed: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
downloadAdminProjectSnapshot,
|
|
||||||
listAdminProjectSnapshots,
|
|
||||||
} from './adminApiClient';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('项目列表携带分页与后台授权,解析标准响应', async () => {
|
|
||||||
const payload = { items: [], nextCursor: 'next' };
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
expect(
|
|
||||||
await listAdminProjectSnapshots(
|
|
||||||
'admin-token',
|
|
||||||
{ cursor: 'user/a+项目', limit: 20 },
|
|
||||||
controller.signal,
|
|
||||||
),
|
|
||||||
).toEqual(payload);
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
|
||||||
new Response('PK\u0003\u0004', {
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/zip',
|
|
||||||
'content-disposition':
|
|
||||||
"attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
|
||||||
'admin-token',
|
|
||||||
'user/a',
|
|
||||||
'project/b',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
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',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
Accept: 'application/zip',
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'],
|
|
||||||
['attachment; filename="../secret.zip"', '.._secret.zip'],
|
|
||||||
["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'],
|
|
||||||
[null, 'project.zip'],
|
|
||||||
])('ZIP 附件名兼容安全回退 %s', async (header, expected) => {
|
|
||||||
const headers: Record<string, string> = { 'content-type': 'application/zip' };
|
|
||||||
// Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。
|
|
||||||
if (header)
|
|
||||||
headers['content-disposition'] = header.replace('工程', 'project');
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
|
||||||
).toBe(expected.replace('工程', 'project'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([401, 403, 409, 500])(
|
|
||||||
'下载 HTTP %s 保留后台错误,不返回 ZIP',
|
|
||||||
async (status) => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' },
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
status,
|
|
||||||
code: 'SNAPSHOT_FAILURE',
|
|
||||||
message: '工程尚未同步完成',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(
|
|
||||||
new Response('{"ok":false}', {
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
|
||||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
|
||||||
});
|
|
||||||
@@ -26,13 +26,11 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
|
|||||||
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
|
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
|
||||||
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
||||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
|
||||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||||
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
|
|
||||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||||
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
||||||
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
||||||
@@ -301,12 +299,6 @@ export function AdminApp() {
|
|||||||
onUnauthorized={handleUnauthorized}
|
onUnauthorized={handleUnauthorized}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'game-distribution' ? (
|
|
||||||
<AdminGameDistributionReviewPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activeRouteId === 'editor-assets' ? (
|
{activeRouteId === 'editor-assets' ? (
|
||||||
<AdminEditorAssetQueryPage
|
<AdminEditorAssetQueryPage
|
||||||
token={token}
|
token={token}
|
||||||
@@ -316,12 +308,6 @@ export function AdminApp() {
|
|||||||
{activeRouteId === 'accounts' ? (
|
{activeRouteId === 'accounts' ? (
|
||||||
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'project-snapshots' ? (
|
|
||||||
<AdminProjectSnapshotsPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
Coins,
|
Coins,
|
||||||
Database,
|
Database,
|
||||||
FolderArchive,
|
|
||||||
Gamepad2,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Images,
|
Images,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -50,9 +48,7 @@ const routeIcons = {
|
|||||||
'recharge-orders': ReceiptText,
|
'recharge-orders': ReceiptText,
|
||||||
'editor-generation-pricing': Coins,
|
'editor-generation-pricing': Coins,
|
||||||
'editor-showcase': Star,
|
'editor-showcase': Star,
|
||||||
'game-distribution': Gamepad2,
|
|
||||||
'editor-assets': Images,
|
'editor-assets': Images,
|
||||||
'project-snapshots': FolderArchive,
|
|
||||||
accounts: Users,
|
accounts: Users,
|
||||||
'agc-models': ListChecks,
|
'agc-models': ListChecks,
|
||||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||||
|
|||||||
@@ -122,49 +122,3 @@ test('零权限 member 不回落到 Dashboard', () => {
|
|||||||
expect(routes).toEqual([]);
|
expect(routes).toEqual([]);
|
||||||
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => {
|
|
||||||
const route = {
|
|
||||||
id: 'project-snapshots',
|
|
||||||
label: '项目工程',
|
|
||||||
hash: '#project-snapshots',
|
|
||||||
};
|
|
||||||
expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route);
|
|
||||||
expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots');
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }),
|
|
||||||
).toContainEqual(route);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['project-snapshots'],
|
|
||||||
}),
|
|
||||||
).toEqual([route]);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['tracking'],
|
|
||||||
}),
|
|
||||||
).not.toContainEqual(route);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('后台游戏审核路由可通过导航和 hash 访问', () => {
|
|
||||||
expect(adminRoutes).toContainEqual({
|
|
||||||
id: 'game-distribution',
|
|
||||||
label: '游戏审核',
|
|
||||||
hash: '#game-distribution',
|
|
||||||
});
|
|
||||||
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
|
|
||||||
expect(routeHash('game-distribution')).toBe('#game-distribution');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('member 可单独获得游戏审核 Tab 权限', () => {
|
|
||||||
const routes = getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['game-distribution'],
|
|
||||||
});
|
|
||||||
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
|
|
||||||
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
|
|
||||||
'game-distribution',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ export type AdminRouteId =
|
|||||||
| 'recharge-orders'
|
| 'recharge-orders'
|
||||||
| 'editor-generation-pricing'
|
| 'editor-generation-pricing'
|
||||||
| 'editor-showcase'
|
| 'editor-showcase'
|
||||||
| 'game-distribution'
|
|
||||||
| 'editor-assets'
|
| 'editor-assets'
|
||||||
| 'project-snapshots'
|
|
||||||
| 'agc-models'
|
| 'agc-models'
|
||||||
| 'accounts';
|
| 'accounts';
|
||||||
|
|
||||||
@@ -55,9 +53,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
},
|
},
|
||||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
|
||||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
|
||||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
/* @vitest-environment jsdom */
|
|
||||||
|
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { beforeEach, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
listAdminGameDistributionReviews,
|
|
||||||
reviewAdminGameDistributionVersion,
|
|
||||||
suspendAdminGameDistributionGame,
|
|
||||||
} from '../api/adminApiClient';
|
|
||||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
|
||||||
import {
|
|
||||||
AdminGameDistributionReviewPage,
|
|
||||||
resolveGameReleaseEntryUrlError,
|
|
||||||
} from './AdminGameDistributionReviewPage';
|
|
||||||
|
|
||||||
vi.mock('../api/adminApiClient', () => ({
|
|
||||||
isAdminApiError: vi.fn(
|
|
||||||
(error: unknown) =>
|
|
||||||
typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'status' in error &&
|
|
||||||
typeof error.status === 'number',
|
|
||||||
),
|
|
||||||
formatAdminApiError: vi.fn((error: unknown) =>
|
|
||||||
error instanceof Error ? error.message : '请求失败',
|
|
||||||
),
|
|
||||||
listAdminGameDistributionReviews: vi.fn(),
|
|
||||||
reviewAdminGameDistributionVersion: vi.fn(),
|
|
||||||
suspendAdminGameDistributionGame: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const entry: AdminGameDistributionReviewEntry = {
|
|
||||||
versionId: 'version-1',
|
|
||||||
gameId: 'game_1',
|
|
||||||
versionNumber: 2,
|
|
||||||
packageSha256: 'a'.repeat(64),
|
|
||||||
packageBytes: 2048,
|
|
||||||
status: 'pending_review',
|
|
||||||
publicationRevision: 4,
|
|
||||||
reviewReason: null,
|
|
||||||
createdAt: '2026-09-20T08:00:00Z',
|
|
||||||
updatedAt: '2026-09-20T08:00:00Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(listAdminGameDistributionReviews).mockReset();
|
|
||||||
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
|
|
||||||
vi.mocked(suspendAdminGameDistributionGame).mockReset();
|
|
||||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
|
||||||
entries: [entry],
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
|
|
||||||
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
|
|
||||||
expect(
|
|
||||||
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
|
|
||||||
).toBe('发行入口必须以 https:// 开头');
|
|
||||||
expect(
|
|
||||||
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
|
|
||||||
).toBe('发行入口不能包含 query 或 fragment');
|
|
||||||
expect(
|
|
||||||
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
|
|
||||||
).toBe('发行入口不能包含凭据');
|
|
||||||
expect(
|
|
||||||
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
|
|
||||||
).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
|
|
||||||
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
|
|
||||||
version: { ...entry, status: 'published' },
|
|
||||||
replayed: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<AdminGameDistributionReviewPage
|
|
||||||
token="admin-token"
|
|
||||||
onUnauthorized={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await screen.findByText('game_1');
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('发行入口'), {
|
|
||||||
target: { value: 'https://games.test/releases/game_1/index.html' },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '通过' }));
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
|
|
||||||
);
|
|
||||||
const [token, versionId, idempotencyKey, payload] =
|
|
||||||
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
|
|
||||||
expect(token).toBe('admin-token');
|
|
||||||
expect(versionId).toBe('version-1');
|
|
||||||
expect(String(idempotencyKey)).toContain('version-1');
|
|
||||||
expect(payload).toEqual({
|
|
||||||
decision: 'approve',
|
|
||||||
expectedPublicationRevision: 4,
|
|
||||||
entryUrl: 'https://games.test/releases/game_1/index.html',
|
|
||||||
});
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('缺少拒绝理由时不调用审核接口', async () => {
|
|
||||||
render(
|
|
||||||
<AdminGameDistributionReviewPage
|
|
||||||
token="admin-token"
|
|
||||||
onUnauthorized={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await screen.findByText('game_1');
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
|
||||||
|
|
||||||
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
|
|
||||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
|
|
||||||
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
|
|
||||||
game: {
|
|
||||||
id: 'game_1',
|
|
||||||
title: '测试游戏',
|
|
||||||
status: 'suspended',
|
|
||||||
publicationRevision: 5,
|
|
||||||
},
|
|
||||||
replayed: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<AdminGameDistributionReviewPage
|
|
||||||
token="admin-token"
|
|
||||||
onUnauthorized={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await screen.findByText('game_1');
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('下架原因'), {
|
|
||||||
target: { value: '盗用素材' },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
|
||||||
|
|
||||||
// 第一次点击只弹出确认面板,不直接调用后端。
|
|
||||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
|
||||||
expect(await screen.findByRole('dialog')).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
|
||||||
);
|
|
||||||
const [token, gameId, idempotencyKey, payload] =
|
|
||||||
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
|
|
||||||
expect(token).toBe('admin-token');
|
|
||||||
expect(gameId).toBe('game_1');
|
|
||||||
expect(String(idempotencyKey)).toContain('game_1');
|
|
||||||
expect(payload).toEqual({
|
|
||||||
expectedPublicationRevision: 4,
|
|
||||||
reason: '盗用素材',
|
|
||||||
});
|
|
||||||
expect(await screen.findByText(/已安全下架/u)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('取消确认时不下架', async () => {
|
|
||||||
render(
|
|
||||||
<AdminGameDistributionReviewPage
|
|
||||||
token="admin-token"
|
|
||||||
onUnauthorized={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await screen.findByText('game_1');
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
|
||||||
await screen.findByRole('dialog');
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
||||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
import { RefreshCcw } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
listAdminGameDistributionReviews,
|
|
||||||
reviewAdminGameDistributionVersion,
|
|
||||||
suspendAdminGameDistributionGame,
|
|
||||||
} from '../api/adminApiClient';
|
|
||||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
|
||||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
|
||||||
import { handlePageError } from './pageUtils';
|
|
||||||
|
|
||||||
interface AdminGameDistributionReviewPageProps {
|
|
||||||
token: string;
|
|
||||||
onUnauthorized: (message?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value: number) {
|
|
||||||
if (value >= 1024 * 1024) {
|
|
||||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
|
||||||
}
|
|
||||||
if (value >= 1024) {
|
|
||||||
return `${(value / 1024).toFixed(1)} KiB`;
|
|
||||||
}
|
|
||||||
return `${value} B`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(value: string) {
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return value;
|
|
||||||
return parsed.toLocaleString('zh-CN', { hour12: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSuspendIdempotencyKey(gameId: string) {
|
|
||||||
const random =
|
|
||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
return `game-suspend-${gameId}-${random}`.slice(0, 128);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createReviewIdempotencyKey(versionId: string) {
|
|
||||||
const random =
|
|
||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
return `game-review-${versionId}-${random}`.slice(0, 128);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGameReleaseEntryUrlError(value: string) {
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!normalized) return '请填写发行入口';
|
|
||||||
if (!normalized.startsWith('https://')) {
|
|
||||||
return '发行入口必须以 https:// 开头';
|
|
||||||
}
|
|
||||||
if (normalized.includes('?') || normalized.includes('#')) {
|
|
||||||
return '发行入口不能包含 query 或 fragment';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(normalized);
|
|
||||||
if (parsed.username || parsed.password) {
|
|
||||||
return '发行入口不能包含凭据';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return '发行入口不是合法 URL';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminGameDistributionReviewPage({
|
|
||||||
token,
|
|
||||||
onUnauthorized,
|
|
||||||
}: AdminGameDistributionReviewPageProps) {
|
|
||||||
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [busyVersionId, setBusyVersionId] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
|
||||||
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
|
|
||||||
Record<string, string>
|
|
||||||
>({});
|
|
||||||
const [reasonByVersion, setReasonByVersion] = useState<
|
|
||||||
Record<string, string>
|
|
||||||
>({});
|
|
||||||
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
|
|
||||||
Record<string, string>
|
|
||||||
>({});
|
|
||||||
const [busyGameId, setBusyGameId] = useState('');
|
|
||||||
const writeConfirm = useAdminWriteConfirm();
|
|
||||||
|
|
||||||
const loadReviews = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
try {
|
|
||||||
const response = await listAdminGameDistributionReviews(token);
|
|
||||||
setEntries(response.entries);
|
|
||||||
} catch (error) {
|
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [token, onUnauthorized]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadReviews();
|
|
||||||
}, [loadReviews]);
|
|
||||||
|
|
||||||
async function submitReview(
|
|
||||||
entry: AdminGameDistributionReviewEntry,
|
|
||||||
decision: 'approve' | 'reject',
|
|
||||||
) {
|
|
||||||
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
|
|
||||||
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
|
|
||||||
if (decision === 'approve') {
|
|
||||||
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
|
|
||||||
if (invalid) {
|
|
||||||
setErrorMessage(invalid);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (!reason) {
|
|
||||||
setErrorMessage('拒绝审核必须填写理由');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusyVersionId(entry.versionId);
|
|
||||||
setErrorMessage('');
|
|
||||||
setStatusMessage('');
|
|
||||||
try {
|
|
||||||
await reviewAdminGameDistributionVersion(
|
|
||||||
token,
|
|
||||||
entry.versionId,
|
|
||||||
createReviewIdempotencyKey(entry.versionId),
|
|
||||||
decision === 'approve'
|
|
||||||
? {
|
|
||||||
decision,
|
|
||||||
expectedPublicationRevision: entry.publicationRevision,
|
|
||||||
entryUrl,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
decision,
|
|
||||||
expectedPublicationRevision: entry.publicationRevision,
|
|
||||||
reviewReason: reason,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setStatusMessage(
|
|
||||||
decision === 'approve'
|
|
||||||
? `版本 v${entry.versionNumber} 已通过审核`
|
|
||||||
: `版本 v${entry.versionNumber} 已拒绝`,
|
|
||||||
);
|
|
||||||
await loadReviews();
|
|
||||||
} catch (error) {
|
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
||||||
} finally {
|
|
||||||
setBusyVersionId('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
|
|
||||||
* 由服务端返回冲突,前端只提示刷新,不静默重试。
|
|
||||||
*/
|
|
||||||
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
|
|
||||||
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
|
|
||||||
const confirmed = await writeConfirm.confirmWrite({
|
|
||||||
action: '安全下架游戏',
|
|
||||||
target: `${entry.gameId}(版本 v${entry.versionNumber})`,
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
setBusyGameId(entry.gameId);
|
|
||||||
setErrorMessage('');
|
|
||||||
setStatusMessage('');
|
|
||||||
try {
|
|
||||||
await suspendAdminGameDistributionGame(
|
|
||||||
token,
|
|
||||||
entry.gameId,
|
|
||||||
createSuspendIdempotencyKey(entry.gameId),
|
|
||||||
{
|
|
||||||
expectedPublicationRevision: entry.publicationRevision,
|
|
||||||
...(reason ? { reason } : {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
|
|
||||||
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
|
|
||||||
await loadReviews();
|
|
||||||
} catch (error) {
|
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
||||||
} finally {
|
|
||||||
setBusyGameId('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="admin-page admin-page-wide">
|
|
||||||
<div className="admin-page-heading">
|
|
||||||
<h1>游戏审核</h1>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-secondary-button"
|
|
||||||
onClick={() => void loadReviews()}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<RefreshCcw aria-hidden="true" />
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<div className="admin-alert admin-alert-warning" role="alert">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{statusMessage ? (
|
|
||||||
<div className="admin-alert admin-alert-success" role="status">
|
|
||||||
{statusMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="admin-panel">
|
|
||||||
<div className="admin-panel-heading">
|
|
||||||
<h2>待审版本</h2>
|
|
||||||
<span className="admin-muted-text">共 {entries.length} 条</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="admin-muted-text">正在加载待审版本…</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoading && entries.length === 0 ? (
|
|
||||||
<p className="admin-muted-text">当前没有待审核的游戏版本。</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoading && entries.length > 0 ? (
|
|
||||||
<div className="admin-table-wrap">
|
|
||||||
<table className="admin-table admin-table-wide">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>游戏</th>
|
|
||||||
<th>版本</th>
|
|
||||||
<th>发行包</th>
|
|
||||||
<th>提交时间</th>
|
|
||||||
<th>审核</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const busy = busyVersionId === entry.versionId;
|
|
||||||
return (
|
|
||||||
<tr key={entry.versionId}>
|
|
||||||
<td>
|
|
||||||
<code>{entry.gameId}</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
v{entry.versionNumber}
|
|
||||||
<div className="admin-muted-text">{entry.status}</div>
|
|
||||||
{entry.reviewReason ? (
|
|
||||||
<div className="admin-muted-text">
|
|
||||||
{entry.reviewReason}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{formatBytes(entry.packageBytes)}
|
|
||||||
<div className="admin-muted-text">
|
|
||||||
<code>{entry.packageSha256.slice(0, 12)}</code>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{formatTime(entry.createdAt)}</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-action-row">
|
|
||||||
<div className="admin-field">
|
|
||||||
<label
|
|
||||||
htmlFor={`game-release-url-${entry.versionId}`}
|
|
||||||
>
|
|
||||||
发行入口
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={`game-release-url-${entry.versionId}`}
|
|
||||||
value={entryUrlByVersion[entry.versionId] ?? ''}
|
|
||||||
placeholder="https://"
|
|
||||||
onChange={(event) =>
|
|
||||||
setEntryUrlByVersion((current) => ({
|
|
||||||
...current,
|
|
||||||
[entry.versionId]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-primary-button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void submitReview(entry, 'approve')}
|
|
||||||
>
|
|
||||||
通过
|
|
||||||
</button>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label
|
|
||||||
htmlFor={`game-reject-reason-${entry.versionId}`}
|
|
||||||
>
|
|
||||||
拒绝理由
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={`game-reject-reason-${entry.versionId}`}
|
|
||||||
value={reasonByVersion[entry.versionId] ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setReasonByVersion((current) => ({
|
|
||||||
...current,
|
|
||||||
[entry.versionId]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-ghost-button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void submitReview(entry, 'reject')}
|
|
||||||
>
|
|
||||||
拒绝
|
|
||||||
</button>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label
|
|
||||||
htmlFor={`game-suspend-reason-${entry.versionId}`}
|
|
||||||
>
|
|
||||||
下架原因
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={`game-suspend-reason-${entry.versionId}`}
|
|
||||||
value={suspendReasonByGame[entry.gameId] ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setSuspendReasonByGame((current) => ({
|
|
||||||
...current,
|
|
||||||
[entry.gameId]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-danger-button"
|
|
||||||
disabled={busy || busyGameId === entry.gameId}
|
|
||||||
onClick={() => void suspendGame(entry)}
|
|
||||||
>
|
|
||||||
{busyGameId === entry.gameId
|
|
||||||
? '正在下架…'
|
|
||||||
: '安全下架'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{writeConfirm.confirmDialog}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -154,27 +154,6 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
||||||
);
|
|
||||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
|
||||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
|
|
||||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
||||||
'agc:template-library',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
|
||||||
).toBe('template-library');
|
|
||||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
|
||||||
'0',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -27,17 +27,9 @@ interface GateTargetOption {
|
|||||||
|
|
||||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||||
'image-editor': '画布',
|
'image-editor': '画布',
|
||||||
agc: '客户端',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||||
{
|
|
||||||
prefix: 'agc',
|
|
||||||
suffix: 'template-library',
|
|
||||||
key: 'agc:template-library',
|
|
||||||
label: '模板库',
|
|
||||||
description: '客户端模板库灰度',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
prefix: 'image-editor',
|
prefix: 'image-editor',
|
||||||
suffix: 'agent-sidebar',
|
suffix: 'agent-sidebar',
|
||||||
@@ -188,7 +180,7 @@ export function AdminGrayReleaseConfigPage({
|
|||||||
setSelectedGateKey('');
|
setSelectedGateKey('');
|
||||||
setGatePrefix(option.prefix);
|
setGatePrefix(option.prefix);
|
||||||
setGateKey(option.key);
|
setGateKey(option.key);
|
||||||
setEnabled(option.key === 'agc:template-library');
|
setEnabled(false);
|
||||||
setRolloutPercent('0');
|
setRolloutPercent('0');
|
||||||
setAllowUserIds('');
|
setAllowUserIds('');
|
||||||
setAllowUserTags('');
|
setAllowUserTags('');
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AdminApiError,
|
|
||||||
downloadAdminProjectSnapshot,
|
|
||||||
listAdminProjectSnapshots,
|
|
||||||
} from '../api/adminApiClient';
|
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
|
||||||
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
|
|
||||||
|
|
||||||
vi.mock('../api/adminApiClient', async () => ({
|
|
||||||
...(await vi.importActual<typeof import('../api/adminApiClient')>(
|
|
||||||
'../api/adminApiClient',
|
|
||||||
)),
|
|
||||||
downloadAdminProjectSnapshot: vi.fn(),
|
|
||||||
listAdminProjectSnapshots: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const entry: AdminProjectSnapshotEntry = {
|
|
||||||
userId: 'user-1',
|
|
||||||
projectId: 'project-1',
|
|
||||||
projectName: '三消工程',
|
|
||||||
syncRevision: 3,
|
|
||||||
syncedAtMs: 1_700_000_000_000,
|
|
||||||
fileCount: 12,
|
|
||||||
totalBytes: 2048,
|
|
||||||
status: 'ready',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('按项目展示完整性并限制未完成工程下载', async () => {
|
|
||||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
entry,
|
|
||||||
{
|
|
||||||
...entry,
|
|
||||||
projectId: 'partial-project',
|
|
||||||
projectName: '未完成工程',
|
|
||||||
status: 'partial',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...entry,
|
|
||||||
projectId: 'legacy-project',
|
|
||||||
projectName: null,
|
|
||||||
status: 'unverified',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
const completeRow = (await screen.findByText('三消工程')).closest('tr')!;
|
|
||||||
expect(within(completeRow).getByText('2 KiB')).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
within(completeRow)
|
|
||||||
.getByRole('button', { name: '下载完整工程' })
|
|
||||||
.hasAttribute('disabled'),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'),
|
|
||||||
).toBe(true);
|
|
||||||
expect(screen.getByText('完整性未知')).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
screen
|
|
||||||
.getByRole('button', { name: '下载已存文件' })
|
|
||||||
.hasAttribute('disabled'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
fireEvent.click(await screen.findByRole('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(
|
|
||||||
'token',
|
|
||||||
{ cursor: null, limit: 20 },
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
|
||||||
const createObjectURL = vi.fn(() => 'blob:archive');
|
|
||||||
const revokeObjectURL = vi.fn();
|
|
||||||
vi.stubGlobal(
|
|
||||||
'URL',
|
|
||||||
class extends URL {
|
|
||||||
static createObjectURL = createObjectURL;
|
|
||||||
static revokeObjectURL = revokeObjectURL;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let savedFilename = '';
|
|
||||||
let savedHref = '';
|
|
||||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
||||||
this: HTMLAnchorElement,
|
|
||||||
) {
|
|
||||||
savedFilename = this.download;
|
|
||||||
savedHref = this.href;
|
|
||||||
});
|
|
||||||
const blob = new Blob(['PK'], { type: 'application/zip' });
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({
|
|
||||||
blob,
|
|
||||||
filename: '三消工程-r3.zip',
|
|
||||||
});
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
const button = await screen.findByRole('button', { name: '下载完整工程' });
|
|
||||||
vi.useFakeTimers();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(button);
|
|
||||||
});
|
|
||||||
expect(savedFilename).toBe('三消工程-r3.zip');
|
|
||||||
expect(savedHref).toBe('blob:archive');
|
|
||||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
|
||||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
'user-1',
|
|
||||||
'project-1',
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
act(() => vi.advanceTimersByTime(1000));
|
|
||||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
|
||||||
(_token, _user, _project, signal) =>
|
|
||||||
new Promise((_resolve, reject) => {
|
|
||||||
signal?.addEventListener('abort', () =>
|
|
||||||
reject(new DOMException('Aborted', 'AbortError')),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const view = render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
|
||||||
);
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
|
||||||
expect(
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
|
||||||
).toBe(true);
|
|
||||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
|
||||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
||||||
const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2];
|
|
||||||
view.unmount();
|
|
||||||
expect(signal?.aborted).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('下载登录失效走现有会话处理,403 错误保留页面', async () => {
|
|
||||||
const onUnauthorized = vi.fn();
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot)
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 403, message: '无项目工程权限' }),
|
|
||||||
)
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 401, message: '已过期' }),
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
|
||||||
);
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
||||||
expect(await screen.findByText('无项目工程权限')).toBeTruthy();
|
|
||||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下载完整工程' }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => {
|
|
||||||
const onUnauthorized = vi.fn();
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockRejectedValueOnce(new Error('清单存储不可用'))
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 401, message: '已过期' }),
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
|
||||||
);
|
|
||||||
await screen.findByText('清单存储不可用');
|
|
||||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
|
||||||
let finishOldRequest!: (value: {
|
|
||||||
items: AdminProjectSnapshotEntry[];
|
|
||||||
nextCursor: null;
|
|
||||||
}) => void;
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockReturnValueOnce(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finishOldRequest = resolve;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [{ ...entry, projectName: '新账号工程' }],
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
const onUnauthorized = vi.fn();
|
|
||||||
const view = render(
|
|
||||||
<AdminProjectSnapshotsPage
|
|
||||||
token="old-token"
|
|
||||||
onUnauthorized={onUnauthorized}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
|
||||||
view.rerender(
|
|
||||||
<AdminProjectSnapshotsPage
|
|
||||||
token="new-token"
|
|
||||||
onUnauthorized={onUnauthorized}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await screen.findByText('新账号工程');
|
|
||||||
expect(oldSignal?.aborted).toBe(true);
|
|
||||||
await act(async () => {
|
|
||||||
finishOldRequest({ items: [entry], nextCursor: null });
|
|
||||||
});
|
|
||||||
expect(screen.queryByText('三消工程')).toBeNull();
|
|
||||||
expect(screen.getByText('新账号工程')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('卸载后完成的下载不会创建浏览器文件', async () => {
|
|
||||||
let finishDownload!: (value: { blob: Blob; filename: string }) => void;
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockReturnValue(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finishDownload = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const click = vi
|
|
||||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const view = render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
|
||||||
);
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
||||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
|
||||||
view.unmount();
|
|
||||||
expect(signal?.aborted).toBe(true);
|
|
||||||
await act(async () => {
|
|
||||||
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
|
|
||||||
});
|
|
||||||
expect(click).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
downloadAdminProjectSnapshot,
|
|
||||||
listAdminProjectSnapshots,
|
|
||||||
} from '../api/adminApiClient';
|
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
|
||||||
import { handlePageError } from './pageUtils';
|
|
||||||
|
|
||||||
interface AdminProjectSnapshotsPageProps {
|
|
||||||
token: string;
|
|
||||||
onUnauthorized: (message?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotStatuses = {
|
|
||||||
ready: {
|
|
||||||
label: '已同步',
|
|
||||||
className: 'admin-status-ok',
|
|
||||||
action: '下载完整工程',
|
|
||||||
},
|
|
||||||
partial: {
|
|
||||||
label: '同步未完成',
|
|
||||||
className: 'admin-status-pending',
|
|
||||||
action: '同步未完成',
|
|
||||||
},
|
|
||||||
unverified: {
|
|
||||||
label: '完整性未知',
|
|
||||||
className: 'admin-status-pending',
|
|
||||||
action: '下载已存文件',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AdminProjectSnapshotsPage({
|
|
||||||
token,
|
|
||||||
onUnauthorized,
|
|
||||||
}: AdminProjectSnapshotsPageProps) {
|
|
||||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
||||||
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);
|
|
||||||
|
|
||||||
const loadPage = useCallback(
|
|
||||||
async (cursor: string | null = null) => {
|
|
||||||
listController.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
listController.current = controller;
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
try {
|
|
||||||
const response = await listAdminProjectSnapshots(
|
|
||||||
token,
|
|
||||||
{ cursor, limit: 20 },
|
|
||||||
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()];
|
|
||||||
});
|
|
||||||
setNextCursor(response.nextCursor);
|
|
||||||
setHasLoaded(true);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!controller.signal.aborted)
|
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
||||||
} finally {
|
|
||||||
if (listController.current === controller) {
|
|
||||||
listController.current = null;
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[token, onUnauthorized],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setItems([]);
|
|
||||||
setNextCursor(null);
|
|
||||||
setHasLoaded(false);
|
|
||||||
setDownloadingKey(null);
|
|
||||||
void loadPage();
|
|
||||||
return () => {
|
|
||||||
listController.current?.abort();
|
|
||||||
listController.current = null;
|
|
||||||
downloadController.current?.abort();
|
|
||||||
downloadController.current = null;
|
|
||||||
};
|
|
||||||
}, [loadPage]);
|
|
||||||
|
|
||||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
|
||||||
if (downloadController.current || entry.status === 'partial') return;
|
|
||||||
const controller = new AbortController();
|
|
||||||
downloadController.current = controller;
|
|
||||||
setDownloadingKey(snapshotKey(entry));
|
|
||||||
setErrorMessage('');
|
|
||||||
try {
|
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
|
||||||
token,
|
|
||||||
entry.userId,
|
|
||||||
entry.projectId,
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
const objectUrl = URL.createObjectURL(archive.blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = objectUrl;
|
|
||||||
link.download = archive.filename;
|
|
||||||
document.body.append(link);
|
|
||||||
try {
|
|
||||||
link.click();
|
|
||||||
} finally {
|
|
||||||
link.remove();
|
|
||||||
// 给浏览器时间接管下载,随后释放临时 URL。
|
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!controller.signal.aborted)
|
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
||||||
} finally {
|
|
||||||
if (downloadController.current === controller) {
|
|
||||||
downloadController.current = null;
|
|
||||||
setDownloadingKey(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDownload() {
|
|
||||||
downloadController.current?.abort();
|
|
||||||
downloadController.current = null;
|
|
||||||
setDownloadingKey(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
{errorMessage ? (
|
|
||||||
<div className="admin-alert" role="alert">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<section
|
|
||||||
className="admin-panel admin-stack"
|
|
||||||
aria-label="项目工程列表"
|
|
||||||
aria-busy={isLoading}
|
|
||||||
>
|
|
||||||
<div className="admin-table-wrap">
|
|
||||||
<table className="admin-table admin-project-snapshot-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>项目</th>
|
|
||||||
<th>用户 ID</th>
|
|
||||||
<th>同步时间</th>
|
|
||||||
<th>文件数</th>
|
|
||||||
<th>体积</th>
|
|
||||||
<th>完整性</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((entry) => {
|
|
||||||
const status = snapshotStatuses[entry.status];
|
|
||||||
const isDownloading = downloadingKey === snapshotKey(entry);
|
|
||||||
return (
|
|
||||||
<tr key={snapshotKey(entry)}>
|
|
||||||
<td data-label="项目">
|
|
||||||
<strong>{entry.projectName || entry.projectId}</strong>
|
|
||||||
<small>{entry.projectId}</small>
|
|
||||||
</td>
|
|
||||||
<td data-label="用户 ID">{entry.userId}</td>
|
|
||||||
<td data-label="同步时间">
|
|
||||||
<span>
|
|
||||||
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
|
||||||
hour12: false,
|
|
||||||
})}
|
|
||||||
<small>版本 {entry.syncRevision}</small>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-label="文件数">
|
|
||||||
{entry.fileCount.toLocaleString('zh-CN')}
|
|
||||||
</td>
|
|
||||||
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
|
|
||||||
<td data-label="完整性">
|
|
||||||
<span className={`admin-status ${status.className}`}>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-label="操作">
|
|
||||||
{isDownloading ? (
|
|
||||||
<button
|
|
||||||
className="admin-secondary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={cancelDownload}
|
|
||||||
>
|
|
||||||
<X size={16} aria-hidden="true" />
|
|
||||||
<span>取消下载</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="admin-secondary-button"
|
|
||||||
disabled={
|
|
||||||
entry.status === 'partial' ||
|
|
||||||
downloadingKey !== null
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void downloadProject(entry)}
|
|
||||||
>
|
|
||||||
<Download size={16} aria-hidden="true" />
|
|
||||||
<span>{status.action}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{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>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
|
||||||
return `${entry.userId}/${entry.projectId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
|
||||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
|
||||||
const unit = Math.min(
|
|
||||||
Math.floor(Math.log2(Math.max(1, bytes)) / 10),
|
|
||||||
units.length - 1,
|
|
||||||
);
|
|
||||||
return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`;
|
|
||||||
}
|
|
||||||
@@ -1452,112 +1452,6 @@ button:disabled {
|
|||||||
min-width: 1180px;
|
min-width: 1180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-project-snapshot-table {
|
|
||||||
min-width: 0;
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th,
|
|
||||||
.admin-project-snapshot-table td {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th:first-child {
|
|
||||||
width: 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(2) {
|
|
||||||
width: 14%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(3) {
|
|
||||||
width: 18%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(4) {
|
|
||||||
width: 7%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(5) {
|
|
||||||
width: 9%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(6) {
|
|
||||||
width: 12%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table th:last-child {
|
|
||||||
width: 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.admin-project-snapshot-table,
|
|
||||||
.admin-project-snapshot-table tbody {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table thead {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table tr {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
gap: 10px 16px;
|
|
||||||
border-bottom: 1px solid #eaded2;
|
|
||||||
padding: 18px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table tr:first-child {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table tr:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table td {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table td::before {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #8f7868;
|
|
||||||
font-size: 12px;
|
|
||||||
content: attr(data-label);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table td:first-child,
|
|
||||||
.admin-project-snapshot-table td:nth-child(2),
|
|
||||||
.admin-project-snapshot-table td:nth-child(3),
|
|
||||||
.admin-project-snapshot-table td:nth-child(6),
|
|
||||||
.admin-project-snapshot-table td:last-child {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table td:first-child {
|
|
||||||
display: block;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table td:first-child::before,
|
|
||||||
.admin-project-snapshot-table td:last-child::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-project-snapshot-table td:last-child button {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-recharge-table {
|
.admin-recharge-table {
|
||||||
min-width: 1080px;
|
min-width: 1080px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
/**
|
|
||||||
* AGC 总版本号(发号源)。
|
|
||||||
*
|
|
||||||
* 唯一事实源是 OSS 对象 `agc/global-version.json`;渠道清单只写各自本次拿到的号。
|
|
||||||
* 仓库里的 5 个版本文件仍由构建改写,但只作构建输入参考,不作为事实源。
|
|
||||||
*
|
|
||||||
* 发号顺序固定为「先写总号 → 再构建 → 再发渠道清单」:任何一步失败都不回滚,
|
|
||||||
* 只烧号。这样渠道之间不会复用同一个号,代价是可能出现空洞。
|
|
||||||
*/
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
export const AGC_GLOBAL_VERSION_OBJECT_KEY = 'agc/global-version.json';
|
|
||||||
const defaultOssBaseUrl =
|
|
||||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
|
||||||
const versionPattern = /^\d+\.\d+\.\d+$/u;
|
|
||||||
|
|
||||||
function trimTrailingSlashes(value) {
|
|
||||||
return value.replace(/\/+$/u, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ossBaseUrl(env = process.env) {
|
|
||||||
return trimTrailingSlashes(
|
|
||||||
env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function globalVersionUrl(env = process.env) {
|
|
||||||
return `${ossBaseUrl(env)}/global-version.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseVersion(value, label) {
|
|
||||||
if (typeof value !== 'string' || !versionPattern.test(value.trim())) {
|
|
||||||
throw new Error(`${label} 不是有效的三段版本号:${String(value)}`);
|
|
||||||
}
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareVersions(left, right) {
|
|
||||||
const leftParts = parseVersion(left, '左版本').split('.').map(Number);
|
|
||||||
const rightParts = parseVersion(right, '右版本').split('.').map(Number);
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
if (leftParts[index] !== rightParts[index]) {
|
|
||||||
return leftParts[index] > rightParts[index] ? 1 : -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取较大版本;任一为空时返回另一个。 */
|
|
||||||
export function maxVersion(...versions) {
|
|
||||||
return versions
|
|
||||||
.filter((value) => typeof value === 'string' && value.trim())
|
|
||||||
.map((value) => parseVersion(value, '候选版本'))
|
|
||||||
.reduce(
|
|
||||||
(best, current) =>
|
|
||||||
best == null || compareVersions(current, best) > 0 ? current : best,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextVersion(current) {
|
|
||||||
const [major, minor, patch] = parseVersion(current, '总版本')
|
|
||||||
.split('.')
|
|
||||||
.map(Number);
|
|
||||||
if (patch === Number.MAX_SAFE_INTEGER) {
|
|
||||||
throw new Error(`版本号 patch 已达到上限:${current}`);
|
|
||||||
}
|
|
||||||
return `${major}.${minor}.${patch + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readReleaseDryRun(env = process.env) {
|
|
||||||
const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase();
|
|
||||||
return value === '1' || value === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(url, label, { fetchImpl = fetch } = {}) {
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await fetchImpl(url, {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`读取 ${label} 失败:${error.message}`);
|
|
||||||
}
|
|
||||||
if (response.status === 404) return null;
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`${label} 不是有效 JSON:${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 渠道清单版本;缺失或 404 时返回 null(首次启用渠道)。 */
|
|
||||||
export async function readChannelVersion(channel, options = {}) {
|
|
||||||
const payload = await fetchJson(
|
|
||||||
`${ossBaseUrl(options.env)}/${channel}/latest.json`,
|
|
||||||
`${channel} 渠道清单`,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
if (payload == null) return null;
|
|
||||||
const version = typeof payload.version === 'string' ? payload.version : '';
|
|
||||||
if (!version) {
|
|
||||||
throw new Error(`${channel} 渠道清单缺少 version 字段`);
|
|
||||||
}
|
|
||||||
return parseVersion(version, `${channel} 渠道清单 version`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 旧协议迁移指针 `agc/latest.json`;只在迁移窗口内存在,仅参与播种。 */
|
|
||||||
export async function readLegacyPointerVersion(options = {}) {
|
|
||||||
const payload = await fetchJson(
|
|
||||||
`${ossBaseUrl(options.env)}/latest.json`,
|
|
||||||
'OSS 迁移指针',
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
if (payload == null) return null;
|
|
||||||
const version = typeof payload.version === 'string' ? payload.version : '';
|
|
||||||
return version ? parseVersion(version, 'OSS 迁移指针 version') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readGlobalVersion(options = {}) {
|
|
||||||
const payload = await fetchJson(
|
|
||||||
globalVersionUrl(options.env),
|
|
||||||
'AGC 总版本号',
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
if (payload == null) return null;
|
|
||||||
const version = typeof payload.version === 'string' ? payload.version : '';
|
|
||||||
if (!version) {
|
|
||||||
throw new Error('AGC 总版本号对象缺少 version 字段');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
version: parseVersion(version, 'AGC 总版本号 version'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 一次性播种基线:仓库当前版本、渠道清单与旧迁移指针里的最大值。
|
|
||||||
* 基线本身不发给客户端,首个发放号是 baseline + 1。
|
|
||||||
*/
|
|
||||||
export async function resolveSeedBaseline({
|
|
||||||
channels = ['dev-win', 'dev-mac'],
|
|
||||||
repoVersion = null,
|
|
||||||
env = process.env,
|
|
||||||
fetchImpl = fetch,
|
|
||||||
} = {}) {
|
|
||||||
const candidates = [];
|
|
||||||
if (repoVersion) candidates.push(parseVersion(repoVersion, '仓库当前版本'));
|
|
||||||
for (const channel of channels) {
|
|
||||||
const version = await readChannelVersion(channel, { env, fetchImpl });
|
|
||||||
if (version) candidates.push(version);
|
|
||||||
}
|
|
||||||
const legacy = await readLegacyPointerVersion({ env, fetchImpl });
|
|
||||||
if (legacy) candidates.push(legacy);
|
|
||||||
const baseline = maxVersion(...candidates);
|
|
||||||
if (!baseline) {
|
|
||||||
throw new Error('无法确定总版本号播种基线:仓库版本与渠道清单都不可用');
|
|
||||||
}
|
|
||||||
return baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组装 ossutil 参数。
|
|
||||||
*
|
|
||||||
* 该桶与凭据按 v1 签名使用(ossutil v2 默认 v4,缺 region 会直接失败),
|
|
||||||
* 因此默认显式传 `--sign-version v1`;需要 v4 时用 `AGC_OSS_SIGN_VERSION=v4`
|
|
||||||
* 并同时给 `AGC_OSS_REGION`。
|
|
||||||
*/
|
|
||||||
export function buildOssutilArgs({
|
|
||||||
args,
|
|
||||||
endpoint,
|
|
||||||
accessKeyId,
|
|
||||||
accessKeySecret,
|
|
||||||
env = process.env,
|
|
||||||
}) {
|
|
||||||
const finalArgs = [...args, '--endpoint', endpoint];
|
|
||||||
const region = env.AGC_OSS_REGION?.trim();
|
|
||||||
if (region) finalArgs.push('--region', region);
|
|
||||||
finalArgs.push('--sign-version', env.AGC_OSS_SIGN_VERSION?.trim() || 'v1');
|
|
||||||
if (accessKeyId) {
|
|
||||||
finalArgs.push(
|
|
||||||
'--access-key-id',
|
|
||||||
accessKeyId,
|
|
||||||
'--access-key-secret',
|
|
||||||
accessKeySecret,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return finalArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runOssutil(args, { env = process.env } = {}) {
|
|
||||||
const binary = env.OSSUTIL_BIN?.trim() || 'ossutil';
|
|
||||||
const endpoint =
|
|
||||||
env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
|
||||||
const accessKeyId = env.AGC_OSS_ACCESS_KEY_ID?.trim();
|
|
||||||
const accessKeySecret = env.AGC_OSS_ACCESS_KEY_SECRET;
|
|
||||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
|
||||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
|
||||||
}
|
|
||||||
const result = spawnSync(
|
|
||||||
binary,
|
|
||||||
buildOssutilArgs({
|
|
||||||
args,
|
|
||||||
endpoint,
|
|
||||||
accessKeyId,
|
|
||||||
accessKeySecret,
|
|
||||||
env,
|
|
||||||
}),
|
|
||||||
{ stdio: 'inherit', shell: false, env },
|
|
||||||
);
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
|
|
||||||
}
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(`${binary} 执行失败,退出码 ${result.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 写入总版本号对象;dry-run 下只打印将要执行的上传。 */
|
|
||||||
export function writeGlobalVersion(payload, options = {}) {
|
|
||||||
const { env = process.env, dryRun = readReleaseDryRun(env) } = options;
|
|
||||||
const bucket = env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
|
||||||
const body = Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
||||||
const tempDirectory = fs.mkdtempSync(
|
|
||||||
path.join(os.tmpdir(), 'agc-global-version-'),
|
|
||||||
);
|
|
||||||
const tempPath = path.join(tempDirectory, 'global-version.json');
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(tempPath, body);
|
|
||||||
const ossUrl = `oss://${bucket}/${AGC_GLOBAL_VERSION_OBJECT_KEY}`;
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(
|
|
||||||
`[dry-run] 不写总版本号:${ossUrl} <- ${JSON.stringify(payload)}`,
|
|
||||||
);
|
|
||||||
return { written: false, objectUrl: ossUrl, payload };
|
|
||||||
}
|
|
||||||
runOssutil(['cp', '--force', tempPath, ossUrl], { env });
|
|
||||||
return { written: true, objectUrl: ossUrl, payload };
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(tempDirectory, { force: true, recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发一次号并写回总版本号对象。
|
|
||||||
*
|
|
||||||
* 写后回读校验:若远端值与自己写下的不一致,说明有并发发号,按失败关闭处理
|
|
||||||
* (号已烧,不重试、不回滚),由人工确认后再发。
|
|
||||||
*/
|
|
||||||
export async function issueGlobalVersion({
|
|
||||||
channel,
|
|
||||||
commit = null,
|
|
||||||
buildId = null,
|
|
||||||
repoVersion = null,
|
|
||||||
env = process.env,
|
|
||||||
fetchImpl = fetch,
|
|
||||||
now = () => new Date().toISOString(),
|
|
||||||
writeImpl = writeGlobalVersion,
|
|
||||||
} = {}) {
|
|
||||||
if (!channel) throw new Error('发号必须显式指定 channel');
|
|
||||||
const dryRun = readReleaseDryRun(env);
|
|
||||||
const current = await readGlobalVersion({ env, fetchImpl });
|
|
||||||
let baseline = current?.version ?? null;
|
|
||||||
let seeded = false;
|
|
||||||
if (!baseline) {
|
|
||||||
baseline = await resolveSeedBaseline({ repoVersion, env, fetchImpl });
|
|
||||||
seeded = true;
|
|
||||||
}
|
|
||||||
const issued = nextVersion(baseline);
|
|
||||||
const payload = {
|
|
||||||
version: issued,
|
|
||||||
updatedAt: now(),
|
|
||||||
channel,
|
|
||||||
commit,
|
|
||||||
buildId,
|
|
||||||
};
|
|
||||||
console.log(
|
|
||||||
`[agc-global-version] ${
|
|
||||||
seeded ? `按播种基线 ${baseline} 首发` : `总号 ${baseline}`
|
|
||||||
} -> ${issued}(channel=${channel} dry-run=${dryRun})`,
|
|
||||||
);
|
|
||||||
writeImpl(payload, { env, dryRun });
|
|
||||||
if (!dryRun) {
|
|
||||||
const stored = await readGlobalVersion({ env, fetchImpl });
|
|
||||||
if (!stored || stored.version !== issued) {
|
|
||||||
throw new Error(
|
|
||||||
`总版本号写后回读不一致:期望 ${issued},远端 ${
|
|
||||||
stored?.version ?? '不存在'
|
|
||||||
};可能存在并发发号,本次构建失败关闭`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return issued;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 渠道高水位断言:请求号低于本渠道清单版本即失败关闭。 */
|
|
||||||
export function assertRequestedVersionNotBelowChannel({
|
|
||||||
requested,
|
|
||||||
channelVersion,
|
|
||||||
channel,
|
|
||||||
}) {
|
|
||||||
const requestedVersion = parseVersion(requested, '请求版本');
|
|
||||||
if (channelVersion == null) return requestedVersion;
|
|
||||||
const current = parseVersion(channelVersion, `${channel} 渠道版本`);
|
|
||||||
if (compareVersions(requestedVersion, current) < 0) {
|
|
||||||
throw new Error(
|
|
||||||
`请求版本 ${requestedVersion} 低于 ${channel} 渠道当前清单版本 ${current};拒绝回退发布`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return requestedVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只读预览:不写回、不烧号。 */
|
|
||||||
export async function previewNextGlobalVersion(options = {}) {
|
|
||||||
const current = await readGlobalVersion(options);
|
|
||||||
const baseline =
|
|
||||||
current?.version ??
|
|
||||||
(await resolveSeedBaseline({
|
|
||||||
repoVersion: options.repoVersion,
|
|
||||||
env: options.env,
|
|
||||||
fetchImpl: options.fetchImpl,
|
|
||||||
}));
|
|
||||||
return nextVersion(baseline);
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { test } from 'node:test';
|
|
||||||
|
|
||||||
import {
|
|
||||||
assertRequestedVersionNotBelowChannel,
|
|
||||||
buildOssutilArgs,
|
|
||||||
issueGlobalVersion,
|
|
||||||
maxVersion,
|
|
||||||
nextVersion,
|
|
||||||
previewNextGlobalVersion,
|
|
||||||
resolveSeedBaseline,
|
|
||||||
} from './agc-global-version.mjs';
|
|
||||||
|
|
||||||
function jsonResponse(payload, status = 200) {
|
|
||||||
return {
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
json: async () => payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 以 URL 为键的假 OSS:只读 fetch + 记录写入。 */
|
|
||||||
function createFakeOss({ objects = {} } = {}) {
|
|
||||||
const state = { ...objects };
|
|
||||||
const writes = [];
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
writes,
|
|
||||||
fetchImpl: async (url) => {
|
|
||||||
const key = String(url).replace(/^https?:\/\/[^/]+\//u, '');
|
|
||||||
if (!(key in state)) return jsonResponse(null, 404);
|
|
||||||
return jsonResponse(state[key]);
|
|
||||||
},
|
|
||||||
writeImpl: (payload, options = {}) => {
|
|
||||||
writes.push({ payload, dryRun: Boolean(options.dryRun) });
|
|
||||||
if (!options.dryRun) state['agc/global-version.json'] = payload;
|
|
||||||
return { written: !options.dryRun, payload };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('播种基线取仓库版本、各渠道清单与旧迁移指针的最大值', async () => {
|
|
||||||
const oss = createFakeOss({
|
|
||||||
objects: {
|
|
||||||
'agc/dev-win/latest.json': { version: '0.1.57' },
|
|
||||||
'agc/dev-mac/latest.json': { version: '0.1.12' },
|
|
||||||
'agc/latest.json': { version: '0.1.60' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
await resolveSeedBaseline({
|
|
||||||
repoVersion: '0.1.48',
|
|
||||||
env: {},
|
|
||||||
fetchImpl: oss.fetchImpl,
|
|
||||||
}),
|
|
||||||
'0.1.60',
|
|
||||||
);
|
|
||||||
assert.equal(maxVersion('0.1.9', '0.1.10', null), '0.1.10');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('无总号时按播种基线发首号,并把总号写回唯一事实源', async () => {
|
|
||||||
const oss = createFakeOss({
|
|
||||||
objects: {
|
|
||||||
'agc/dev-win/latest.json': { version: '0.1.57' },
|
|
||||||
'agc/dev-mac/latest.json': { version: '0.1.12' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const issued = await issueGlobalVersion({
|
|
||||||
channel: 'dev-win',
|
|
||||||
commit: 'a'.repeat(40),
|
|
||||||
buildId: '319',
|
|
||||||
repoVersion: '0.1.48',
|
|
||||||
env: {},
|
|
||||||
fetchImpl: oss.fetchImpl,
|
|
||||||
writeImpl: oss.writeImpl,
|
|
||||||
now: () => '2026-09-20T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
assert.equal(issued, '0.1.58');
|
|
||||||
assert.deepEqual(
|
|
||||||
oss.writes.map((entry) => entry.payload.version),
|
|
||||||
['0.1.58'],
|
|
||||||
);
|
|
||||||
assert.equal(oss.state['agc/global-version.json'].version, '0.1.58');
|
|
||||||
assert.equal(oss.state['agc/global-version.json'].channel, 'dev-win');
|
|
||||||
assert.equal(oss.state['agc/global-version.json'].buildId, '319');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('已有总号时只递增,不再回看渠道清单', async () => {
|
|
||||||
const oss = createFakeOss({
|
|
||||||
objects: {
|
|
||||||
'agc/global-version.json': { version: '0.2.7' },
|
|
||||||
// 渠道清单被手工改小也不能把总号拉回去。
|
|
||||||
'agc/dev-win/latest.json': { version: '0.1.10' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const issued = await issueGlobalVersion({
|
|
||||||
channel: 'dev-mac',
|
|
||||||
env: {},
|
|
||||||
fetchImpl: oss.fetchImpl,
|
|
||||||
writeImpl: oss.writeImpl,
|
|
||||||
});
|
|
||||||
assert.equal(issued, '0.2.8');
|
|
||||||
assert.equal(oss.state['agc/global-version.json'].version, '0.2.8');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dry-run 只预览下一位,不写回、不烧号', async () => {
|
|
||||||
const oss = createFakeOss({
|
|
||||||
objects: { 'agc/global-version.json': { version: '0.3.4' } },
|
|
||||||
});
|
|
||||||
const preview = await previewNextGlobalVersion({
|
|
||||||
env: {},
|
|
||||||
fetchImpl: oss.fetchImpl,
|
|
||||||
});
|
|
||||||
assert.equal(preview, '0.3.5');
|
|
||||||
assert.equal(oss.writes.length, 0);
|
|
||||||
|
|
||||||
const issued = await issueGlobalVersion({
|
|
||||||
channel: 'dev-win',
|
|
||||||
env: { AGC_RELEASE_DRY_RUN: '1' },
|
|
||||||
fetchImpl: oss.fetchImpl,
|
|
||||||
writeImpl: oss.writeImpl,
|
|
||||||
});
|
|
||||||
assert.equal(issued, '0.3.5');
|
|
||||||
assert.deepEqual(
|
|
||||||
oss.writes.map((entry) => entry.dryRun),
|
|
||||||
[true],
|
|
||||||
);
|
|
||||||
assert.equal(oss.state['agc/global-version.json'].version, '0.3.4');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('传入低于本渠道清单的号时失败关闭', () => {
|
|
||||||
assert.equal(
|
|
||||||
assertRequestedVersionNotBelowChannel({
|
|
||||||
requested: '0.1.60',
|
|
||||||
channelVersion: '0.1.60',
|
|
||||||
channel: 'dev-win',
|
|
||||||
}),
|
|
||||||
'0.1.60',
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
assertRequestedVersionNotBelowChannel({
|
|
||||||
requested: '0.1.59',
|
|
||||||
channelVersion: '0.1.60',
|
|
||||||
channel: 'dev-win',
|
|
||||||
}),
|
|
||||||
/低于 dev-win 渠道当前清单版本/u,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
assertRequestedVersionNotBelowChannel({
|
|
||||||
requested: '0.1.1',
|
|
||||||
channelVersion: null,
|
|
||||||
channel: 'dev-mac',
|
|
||||||
}),
|
|
||||||
'0.1.1',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('写后回读不一致(并发发号)时失败关闭', async () => {
|
|
||||||
const oss = createFakeOss({
|
|
||||||
objects: { 'agc/global-version.json': { version: '0.5.1' } },
|
|
||||||
});
|
|
||||||
await assert.rejects(
|
|
||||||
issueGlobalVersion({
|
|
||||||
channel: 'dev-win',
|
|
||||||
env: {},
|
|
||||||
// 模拟另一个发号进程在写入后覆盖了总号。
|
|
||||||
writeImpl: (payload, options) => {
|
|
||||||
const result = oss.writeImpl(payload, options);
|
|
||||||
// 另一个发号进程紧随其后覆盖总号。
|
|
||||||
oss.state['agc/global-version.json'] = { version: '0.5.9' };
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
fetchImpl: oss.fetchImpl,
|
|
||||||
}),
|
|
||||||
/写后回读不一致/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nextVersion 只在 patch 位递增', () => {
|
|
||||||
assert.equal(nextVersion('0.1.9'), '0.1.10');
|
|
||||||
assert.equal(nextVersion('1.0.0'), '1.0.1');
|
|
||||||
assert.throws(() => nextVersion('0.1'), /不是有效的三段版本号/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
|
||||||
const base = {
|
|
||||||
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
|
|
||||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
|
||||||
accessKeyId: 'id',
|
|
||||||
accessKeySecret: 'secret',
|
|
||||||
env: {},
|
|
||||||
};
|
|
||||||
const v1 = buildOssutilArgs(base);
|
|
||||||
assert.equal(v1[v1.indexOf('--sign-version') + 1], 'v1');
|
|
||||||
assert.ok(!v1.includes('--region'));
|
|
||||||
assert.equal(v1[v1.indexOf('--access-key-id') + 1], 'id');
|
|
||||||
assert.equal(v1[v1.indexOf('--access-key-secret') + 1], 'secret');
|
|
||||||
|
|
||||||
const v4 = buildOssutilArgs({
|
|
||||||
...base,
|
|
||||||
env: { AGC_OSS_SIGN_VERSION: 'v4', AGC_OSS_REGION: 'cn-beijing' },
|
|
||||||
});
|
|
||||||
assert.equal(v4[v4.indexOf('--region') + 1], 'cn-beijing');
|
|
||||||
assert.equal(v4[v4.indexOf('--sign-version') + 1], 'v4');
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user