Compare commits

..

3 Commits

Author SHA1 Message Date
suzmii 92c05428ec 另一架构切片不得借用宿主 npm
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m17s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m48s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m28s
Project CI / Frontend tests (pull_request) Successful in 8m34s
Project CI / Native shell tests (pull_request) Successful in 12m47s
- runtimeSourceForTarget 对非宿主架构返回 npmCli: null,明令按该发行版自己的安装目录查找 npm
- 覆盖经 npm run 触发(npm_execpath 指向宿主 npm)时两份切片仍各带自己那份 npm 的用例
2026-09-21 12:06:39 +08:00
suzmii 91f1554ac7 macOS universal 管线接入双架构 Node 运行时前置
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- Jenkinsfile.ai-game-creator-shell-macos-build 增加 AGC_NODE_RUNTIME_DARWIN_X64_HOME,并在 Toolchain 阶段编译前核对 x86_64 发行版存在且与构建 Node 同版本
- Package 阶段导出 AGC_NODE_RUNTIME_DARWIN_X64_PATH / _LICENSE_PATH,供 universal staging 取另一架构来源
- 开发运维文档记录节点前置:官方 darwin-x64 发行版安装与校验命令、同版本要求、按架构并列的资源布局、Rust 侧按运行架构选择、约 260 MB 解包体积代价,以及本机跨架构诊断构建的取值方式
- 实施计划与更新检查方案文档同步 universal 携带两套架构运行时的契约
2026-09-21 11:58:22 +08:00
suzmii 6a0b75779b AGC 随包 Node 支持 universal 双架构运行时
- stage-node-runtime 新增 targetRuntimes:universal 展开为 aarch64/x86_64 两份单架构运行时
- 新增 runtimeSourceForTarget:宿主架构取当前构建 Node,其它架构必须由 AGC_NODE_RUNTIME_<PLATFORM>_<ARCH>_PATH 显式提供,缺失即失败关闭
- 把 stageNodeRuntime 拆成 inspectRuntimeSource(只读校验)+ writeRuntimeBundle(落盘),universal 在写出任何目录前完成全部来源校验与版本一致性核对
- stageNodeRuntimeForTarget 单架构仍写扁平 node-runtime/,universal 按架构写 node-runtime/<platform>-<arch>/
- build-release 默认按发布目标 stage,universal 构建不再只带宿主架构的运行时
- environment_check resolve_at 在扁平目录之后按当前运行架构查找 <platform>-<arch> 子目录,两份候选都要过清单校验,存在但损坏则失败关闭
- 覆盖双架构 staging、缺非宿主运行时、版本不一致、按运行架构解析等用例
2026-09-21 11:57:43 +08:00
1553 changed files with 139044 additions and 115395 deletions
+5
View File
@@ -30,3 +30,8 @@ server-rs/.data
server-rs/.spacetimedb
public/generated-*
scripts/loadtest/data/*.local.json
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt
-2
View File
@@ -237,8 +237,6 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.worldrelease 对应 https://www.genarrative.world。
# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
# Optional: official VikingDB credentials for regenerating build-tag similarities
+1 -2
View File
@@ -213,8 +213,6 @@ module.exports = {
'!src/services/clipboard.test.ts',
'!src/services/frontendRuntimeConfigService.ts',
'!src/services/frontendRuntimeConfigService.test.ts',
'!src/services/gameDistributionClient.ts',
'!src/services/gameDistributionClient.test.ts',
'!src/services/sseStream.ts',
'!src/services/sseStream.test.ts',
'src/AdventurePanel.tsx',
@@ -365,6 +363,7 @@ module.exports = {
'src/types.ts',
'src/types/**',
'src/uiAssets.ts',
'scripts/loadtest/**',
'packages/shared/src/contracts/jumpHop.ts',
'packages/shared/src/contracts/match3dAgent.ts',
'packages/shared/src/contracts/match3dRuntime.ts',
+1 -92
View File
@@ -82,29 +82,12 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
shell: bash
run: |
set -euo pipefail
node --test scripts/ci-rust-cache.test.mjs
bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell Rust shard 1/4
run: npm run check:native-shells:agc-rust-shard-1
- name: Run AI game creator shell Rust shard 2/4
run: npm run check:native-shells:agc-rust-shard-2
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
ai-game-creator-shell-rust-lane-2:
name: AI game creator shell Rust lane 2/2
runs-on: genarrative-ci
@@ -135,25 +118,12 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell Rust shard 3/4
run: npm run check:native-shells:agc-rust-shard-3
- name: Run AI game creator shell Rust shard 4/4
run: npm run check:native-shells:agc-rust-shard-4
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
ai-game-creator-shell-rust-smoke:
@@ -186,22 +156,9 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell agent-run smoke
run: npm run check:native-shells:agc-rust-smoke
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
ai-game-creator-shell-rust-crates:
name: AI game creator shell Rust crates
@@ -300,22 +257,9 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell shared crate gates
run: npm run check:native-shells:agc-rust-crates
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
backend-tests:
name: Backend tests
runs-on: genarrative-ci
@@ -386,9 +330,6 @@ jobs:
- name: Check server-rs boundaries
run: npm run check:server-rs-ddd
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run server-rs workspace tests
run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml
@@ -401,16 +342,6 @@ jobs:
- name: Check SpacetimeDB module
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
# 以及依赖发布产物的构建 smoke。
native-shell-tests:
@@ -453,32 +384,15 @@ jobs:
- name: Run native shell contract gates
run: npm run check:native-shells:contract
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run native shell gates
run: npm run check:native-shells:shells
- name: Run native shell release build smoke
# 发布构建有独立 profile/features 和资源 staging,不消费测试对象快照。
env:
RUSTC_WRAPPER: ''
CARGO_BUILD_RUSTC_WRAPPER: ''
run: npm run check:native-shells:release
- name: Ensure native lockfiles are unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
frontend-tests:
name: Frontend tests
runs-on: genarrative-ci
@@ -496,7 +410,7 @@ jobs:
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run frontend and script tests
run: npm run test:ci:frontend
run: npm run test
- name: Run BgFilter worker smoke harness tests
run: npm run bgfilter-worker:smoke-test
@@ -560,11 +474,6 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Validate CI cache maintenance behavior
run: |
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
node --test scripts/export-ci-npm-download-cache.test.mjs
- name: Run repository checks
run: npm run check:repository-ci
+7 -12
View File
@@ -89,15 +89,10 @@ spacetime.local.json
deploy/container/api-server.env
deploy/container/worker-smoke/
# 仓库根目录下手工下载的 3D 样例模型(Tripo 联调 / 查看器排查用),不入库;
# glTF / FBX 多文件产物会带着 .bin 缓冲与贴图落在同一目录,一并忽略
/*.[gG][lL][bB]
/*.[gG][lL][tT][fF]
/*.[fF][bB][xX]
/*.[bB][iI][nN]
/*.[pP][nN][gG]
/*.[jJ][pP][gG]
/*.[jJ][pP][eE][gG]
/*.[wW][eE][bB][pP]
/*.[kK][tT][xX]2
/*.[tT][gG][aA]
# Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json
# Local load-test run artifacts
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt
-141
View File
@@ -50,86 +50,6 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成
## Language
### 3D Model Generation
**3D 模型生成操作**:
由文本提示或平台图片引用驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。
_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址
**Provider task**:
第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。
_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server
**临时 provider URL**:
第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。
_Avoid_: 永久 OSS URL、资源 ID
**模型 artifact**:
由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。
_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件
**Tripo API facade**:
面向站内已认证调用方的全新 Tripo 3D 生成 API 上下文,负责把 API operation、用户归属、异步状态、产物持久化和正式资源引用连接起来;它独立于历史 Hyper3D adapter,不复用其路由、契约或实现。
_Avoid_: 把 Tripo API 当成 Hyper3D 的 provider 替换、让 Hyper3D 路由悄悄切换到 Tripo
**Tripo operation**:
Genarrative 为一次 Tripo 生成请求分配的内部异步操作标识;客户端只看到 operationIdTripo provider task 只作为服务端 checkpoint 保存,二者不是同一个 ID。
_Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模型穿透到 API
**模型产物持久化**:
Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。
_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec<u8>
**3D 生成底价**:
一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。
_Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价
**3D 生成加价项**:
叠加在底价之上、由请求参数判定的可选泥点加价;每个端点各自持有一份加价项价目,同一加价项在不同端点可以是不同价。
_Avoid_: 全局唯一一份加价项价目、按用途而不是按请求参数判定加价
**定价配置归一化**:
读取持久化定价时把它与当前契约对齐的过程:缺的键用受控默认值补齐,契约之外的遗留键剔除并告警;它只发生在读取方向,写入方向仍要求完整合法。
_Avoid_: 把「省略即沿用」当作保存语义、把归一化当成静默改价
**模型生成落点**:
3D 生成结果要进入的平台容器,调用方必须在工程资源与素材库资产之间显式二选一;工程分支可附带画布占位框回填,素材库分支必须给出目录与标签。
_Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求参数
**模型输入资源引用**:
image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。
_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串
**3D 模型生成工具**:
图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**
_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话
**3D 生成定价**:
3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。该段的权威事实源是 SpacetimeDB 定价表里的两段强类型列,公开读取时投影回迁移前的旧形状。
_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行、把内部两段结构直接暴露给画布
**定价版本(乐观锁)**:
后台读取完整模型定价时一并拿到的版本号(SpacetimeDB 行的更新时间微秒值),保存时必须原样回传;与服务端当前版本不一致即整笔拒绝并返回 409,提示重新读取,避免静默覆盖他人的改价。
_Avoid_: 在前端自造或缓存该版本、把版本不匹配当成可重试的普通失败、把「省略字段」当成「沿用当前值」
### 3D 模型资源预览
**3D 模型资源**:
类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。
_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型
**模型预览图**:
3D 模型资源的静态代表图,落在资源投影的 `imageSrc``thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。
_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相
**模型本体**:
3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。
_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式
**3D 查看器预览**:
在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。
_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源
### Puzzle Clear
**拼消消**:
@@ -254,14 +174,6 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
@@ -274,54 +186,6 @@ _Avoid_: 进度通知、快照轮询、第二套历史
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
**项目对话输入**:
AGC 项目对话的输入只有自然语言回合(含 `@` 素材引用与附件);需要动作时由 Runtime 工具与确认卡承接,不从输入文本解析控制词。
_Avoid_: 斜杠命令、聊天命令草稿、命令发现列表
**项目命令 id**:
AGC 运行期工具与项目权限策略使用的稳定标识(`GAME_CREATION_APP_COMMANDS``GameCreationAppPermission`),由 Rust 运行期策略校验与 App 权限审计 / 项目前置条件判定消费,不是用户输入语法。
_Avoid_: 把命令 id 当作可输入的聊天命令、为权限位补聊天入口
**引用候选**:
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
**引用 provider**:
一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。
_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方
**静默 provider**:
不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。
_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支
**引用文本语法**:
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用名**:
引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`
_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白
**引用候选枚举**:
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
**引用粘贴解析**:
把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。
_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
**引用选择器**:
宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。
_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道
**附件芯片**:
聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。
_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -337,11 +201,6 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
- **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。
- **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。
- **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
@@ -3,19 +3,12 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
@@ -25,117 +18,6 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
const payload = { entries: [], nextCursor: null };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
expect(
await listAdminAgcTrackingEvents('token', {
userId: 'user+1',
projectId: 'project-1',
cursor: 'page/2',
limit: 50,
}),
).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
}),
);
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: library }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
library,
);
const update = {
expectedRevision: 'revision-old',
title: '空白模板',
summary: '简介',
tags: ['2D'],
enabled: true,
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
};
expect(
await updateAdminAgcTemplate('admin-token', 'template/1', update),
).toEqual(library);
expect(fetchMock.mock.calls[0]).toEqual([
'/admin/api/agc-templates',
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/admin/api/agc-templates/template%2F1',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(update),
}),
]);
});
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
const imported = {
revision: 'rev-2',
writable: true,
templates: [],
imported: [
{
id: 'alpha',
templateVersion: '0.1.0',
zipSizeBytes: 4,
zipSha256: 'a'.repeat(64),
reusedObjects: false,
},
],
};
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
form.append(
'manifest',
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
);
form.append(
'zip_0',
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
);
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('/admin/api/agc-templates/import');
expect(init.method).toBe('POST');
expect(init.body).toBe(form);
expect(init.headers).not.toHaveProperty('Content-Type');
expect(init.headers.Authorization).toBe('Bearer admin-token');
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
@@ -482,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);
});
+2 -155
View File
@@ -1,8 +1,5 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -32,14 +29,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
AdminRechargeOrderListQuery,
@@ -55,9 +47,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorGenerationPricingRequest,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
@@ -95,8 +85,6 @@ interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
formData?: FormData;
headers?: Record<string, string>;
signal?: AbortSignal;
}
@@ -184,8 +172,6 @@ export async function request<T>(
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
} else if (options.formData) {
init.body = options.formData;
}
const response = await fetch(buildRequestUrl(path), init);
@@ -221,7 +207,6 @@ export function listAdminProjectSnapshots(
) {
const params = new URLSearchParams();
if (query.cursor) params.set('cursor', query.cursor);
if (query.channel) params.set('channel', query.channel);
params.set('limit', String(query.limit ?? 20));
return request<AdminProjectSnapshotListResponse>(
`/admin/api/project-snapshots?${params.toString()}`,
@@ -229,27 +214,13 @@ export function listAdminProjectSnapshots(
);
}
export function getAdminProjectSnapshotChannels(
token: string,
signal?: AbortSignal,
) {
return request<AdminProjectSnapshotChannelsResponse>(
'/admin/api/project-snapshots/channels',
{ token, signal },
);
}
export async function downloadAdminProjectSnapshot(
token: string,
channel: string,
userId: string,
projectId: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
if (channel) params.set('channel', channel);
const query = params.toString();
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
const response = await fetch(buildRequestUrl(path), {
headers: {
Authorization: `Bearer ${token.trim()}`,
@@ -412,31 +383,6 @@ export function listAdminTrackingEventKeys(token: string) {
);
}
export function listAdminAgcTrackingEvents(
token: string,
query: AdminAgcTrackingEventQuery = {},
) {
return request<AdminAgcTrackingEventListResponse>(
`/admin/api/agc/tracking-events${buildQueryString((params) => {
for (const key of [
'userId',
'projectId',
'creativeTaskId',
'agentRunId',
'eventName',
'clientVersion',
'startTime',
'endTime',
'cursor',
] as const) {
appendQueryParam(params, key, query[key]);
}
appendNumericQueryParam(params, 'limit', query.limit);
})}`,
{ token },
);
}
export function listAdminErrorReports(
token: string,
query: {
@@ -517,7 +463,7 @@ export function getAdminEditorGenerationPricing(token: string) {
export function upsertAdminEditorGenerationPricing(
token: string,
payload: AdminUpsertEditorGenerationPricingRequest,
payload: EditorGenerationPricingConfigPayload,
) {
return request<EditorGenerationPricingConfigPayload>(
'/admin/api/editor-generation-pricing',
@@ -1230,102 +1176,3 @@ export function saveAgcModelCatalog(
{ 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,
},
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
signal,
});
}
export function updateAdminAgcTemplate(
token: string,
id: string,
body: AdminUpdateAgcTemplateRequest,
) {
return request<AdminAgcTemplateLibraryResponse>(
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
{ token, method: 'PUT', body },
);
}
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
export function importAdminAgcTemplates(token: string, formData: FormData) {
return request<AdminImportAgcTemplatesResponse>(
'/admin/api/agc-templates/import',
{
token,
method: 'POST',
formData,
},
);
}
-190
View File
@@ -105,15 +105,11 @@ export interface AdminProjectSnapshotEntry {
fileCount: number;
totalBytes: number;
status: 'ready' | 'partial' | 'unverified';
channel: string;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
}
export interface AdminProjectSnapshotListQuery {
cursor?: string | null;
limit?: number;
channel?: string | null;
}
export interface AdminProjectSnapshotListResponse {
@@ -121,11 +117,6 @@ export interface AdminProjectSnapshotListResponse {
nextCursor: string | null;
}
export interface AdminProjectSnapshotChannelsResponse {
defaultChannel: string;
channels: string[];
}
export interface AdminErrorReportEntry {
batchId: string;
eventCount: number;
@@ -428,42 +419,8 @@ export interface EditorGenerationModelPricingPayload {
prices?: Record<string, number>;
}
/** 某个 3D 模型版本在「无贴图 / 带贴图」两种形态下的泥点底价。 */
export interface EditorGenerationModel3dVersionPricePayload {
noTexture: number;
texture: number;
}
/**
* 单个 3D 生成端点的整套定价:模型版本底价 + 该端点自己的加价项价目。
*
* 键集合由后端契约决定,后台只改数值:模型版本键与加价项键都直接来自接口返回,
* 不在前端硬编码清单,也不能新增、删除或停用。
*/
export interface EditorGenerationModel3dEndpointPricingPayload {
versionPrices: Record<string, EditorGenerationModel3dVersionPricePayload>;
addOnPrices: Record<string, number>;
}
/** 3D 生成定价段:两个端点并列,各持自己的设置。 */
export interface EditorGenerationModel3dPricingPayload {
textToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
imageToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
}
export interface EditorGenerationPricingConfigPayload {
models: Record<string, EditorGenerationModelPricingPayload>;
/** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */
model3d?: EditorGenerationModel3dPricingPayload | null;
/** 当前定价版本:保存时原样回传做乐观锁,冲突时后端返回 409。 */
updatedAtMicros: number;
}
/** 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,外加读取到的定价版本。 */
export interface AdminUpsertEditorGenerationPricingRequest {
models: Record<string, EditorGenerationModelPricingPayload>;
model3d: EditorGenerationModel3dPricingPayload | null;
expectedUpdatedAtMicros: number;
}
export interface AdminEditorAssetListQuery {
@@ -854,44 +811,6 @@ export interface AdminTrackingEventListResponse {
entries: AdminTrackingEventEntryPayload[];
}
export interface AdminAgcTrackingEventQuery {
userId?: string;
projectId?: string;
creativeTaskId?: string;
agentRunId?: string;
eventName?: string;
clientVersion?: string;
startTime?: string;
endTime?: string;
cursor?: string;
limit?: number;
}
export interface AdminAgcTrackingEventEntry {
eventId: string;
schemaVersion: number;
eventName: string;
eventTime: string;
userId: string;
editorSessionId: string;
projectId: string | null;
creativeTaskId: string | null;
agentRunId: string | null;
agentTurnId: string | null;
status: string | null;
errorCode: string | null;
source: string;
clientVersion: string;
properties: Record<string, unknown>;
batchId: string;
receivedAt: string;
}
export interface AdminAgcTrackingEventListResponse {
entries: AdminAgcTrackingEventEntry[];
nextCursor: string | null;
}
export interface AdminTrackingEventKeyPayload {
eventKey: string;
eventTitle: string;
@@ -1114,112 +1033,3 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
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;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
enabled: boolean;
coverUrl: string;
zipSizeBytes: number;
}
export interface AdminAgcTemplateLibraryResponse {
revision: string;
writable: boolean;
templates: AdminAgcTemplatePayload[];
}
export interface AdminUpdateAgcTemplateRequest {
expectedRevision: string;
title: string;
summary: string;
tags: string[];
enabled: boolean;
cover?: {
contentType: string;
dataBase64: string;
};
}
export interface AdminImportAgcTemplateItemPayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
zipField: string;
coverField: string;
}
export interface AdminImportAgcTemplatesManifest {
expectedRevision: string;
templates: AdminImportAgcTemplateItemPayload[];
}
export interface AdminImportAgcTemplateResult {
id: string;
templateVersion: string;
zipSizeBytes: number;
zipSha256: string;
reusedObjects: boolean;
}
export interface AdminImportAgcTemplatesResponse
extends AdminAgcTemplateLibraryResponse {
imported: AdminImportAgcTemplateResult[];
}
@@ -2,7 +2,6 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from './adminApiClient';
@@ -22,12 +21,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
expect(
await listAdminProjectSnapshots(
'admin-token',
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
{ 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&channel=release&limit=20',
'/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,
@@ -35,23 +34,6 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
);
});
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload })),
);
vi.stubGlobal('fetch', fetchMock);
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/channels',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
);
});
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('PK\u0003\u0004', {
@@ -66,7 +48,6 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
const controller = new AbortController();
const archive = await downloadAdminProjectSnapshot(
'admin-token',
'release',
'user/a',
'project/b',
controller.signal,
@@ -74,7 +55,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
expect(archive.filename).toBe('三消-r2.zip');
expect(archive.blob.type).toBe('application/zip');
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
@@ -100,8 +81,7 @@ test.each([
vi.fn().mockResolvedValue(new Response('PK', { headers })),
);
expect(
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
.filename,
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
).toBe(expected.replace('工程', 'project'));
});
@@ -124,7 +104,7 @@ test.each([401, 403, 409, 500])(
),
);
await expect(
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'user', 'project'),
).rejects.toMatchObject({
status,
code: 'SNAPSHOT_FAILURE',
@@ -143,6 +123,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
),
);
await expect(
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'user', 'project'),
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
});
-21
View File
@@ -19,8 +19,6 @@ import {
} from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -28,7 +26,6 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -234,12 +231,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'agc-tracking' ? (
<AdminAgcTrackingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'error-reports' ? (
<AdminErrorReportsPage
token={token}
@@ -303,24 +294,12 @@ export function AdminApp() {
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'agc-templates' ? (
<AdminAgcTemplatesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
-4
View File
@@ -5,7 +5,6 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -40,7 +39,6 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'agc-tracking': Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
@@ -51,12 +49,10 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
'agc-templates': Images,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
+6 -64
View File
@@ -8,22 +8,6 @@ import {
routeHash,
} from './adminRoutes';
test('客户端埋点路由遵守成员页签权限', () => {
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-tracking'],
}).map((route) => route.id),
).toEqual(['agc-tracking']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking'],
}).some((route) => route.id === 'agc-tracking'),
).toBe(false);
});
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
id: 'dashboard',
@@ -59,6 +43,12 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
expect(routeHash('gray-release')).toBe('#gray-release');
});
test('后台不再暴露旧创作模板管理路由', () => {
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
@@ -157,51 +147,3 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).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',
);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
label: '模板管理',
hash: '#agc-templates',
});
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
expect(routeHash('agc-templates')).toBe('#agc-templates');
expect(
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
(route) => route.id === 'agc-templates',
),
).toBe(true);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-templates'],
}).map((route) => route.id),
).toEqual(['agc-templates']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['editor-assets'],
}).some((route) => route.id === 'agc-templates'),
).toBe(false);
});
-6
View File
@@ -5,7 +5,6 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'agc-tracking'
| 'error-reports'
| 'gray-release'
| 'redeem'
@@ -16,11 +15,9 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
| 'agc-templates'
| 'accounts';
export type AdminTabPermission = Exclude<
@@ -42,7 +39,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tables', label: '表查询', hash: '#tables' },
{ id: 'debug', label: 'API 调试', hash: '#debug' },
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
@@ -57,9 +53,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
hash: '#editor-generation-pricing',
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
-1
View File
@@ -1,4 +1,3 @@
import '@genarrative/shared/styles.css';
import './styles/admin.css';
import { StrictMode } from 'react';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,129 +0,0 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
listAdminAgcTrackingEvents,
} from '../api/adminApiClient';
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
vi.mock('../api/adminApiClient', async (original) => ({
...(await original<typeof import('../api/adminApiClient')>()),
listAdminAgcTrackingEvents: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: () => null,
}));
const entry: AdminAgcTrackingEventEntry = {
eventId: 'event-1',
schemaVersion: 1,
eventName: 'project_save',
eventTime: '2026-09-21T12:00:00.123Z',
userId: 'user-1',
editorSessionId: 'session-1',
projectId: 'project-1',
creativeTaskId: 'project-1',
agentRunId: null,
agentTurnId: null,
status: 'success',
errorCode: null,
source: 'gui',
clientVersion: '1.0',
properties: { reason: 'manual' },
batchId: 'batch-1',
receivedAt: '2026-09-21T12:15:00.000Z',
};
beforeEach(() => {
vi.mocked(listAdminAgcTrackingEvents).mockReset();
});
afterEach(cleanup);
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
.mockResolvedValueOnce({
entries: [{ ...entry, eventId: 'event-2' }],
nextCursor: null,
})
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('保存项目', { selector: 'td' });
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: undefined,
limit: 50,
});
fireEvent.click(screen.getByText('查看详情'));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getAllByText('—').length).toBe(3);
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
fireEvent.click(screen.getByText('下一页'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: 'cursor-page-2',
limit: 50,
}),
);
await waitFor(() =>
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('上一页'));
await screen.findByText('第 1 页');
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('项目 ID'), {
target: { value: 'project-2' },
});
fireEvent.click(screen.getByText('查询'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
'admin-token',
expect.objectContaining({
projectId: 'project-2',
cursor: undefined,
limit: 50,
}),
),
);
expect(screen.getByText('第 1 页')).toBeTruthy();
await waitFor(() =>
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
);
});
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
const unauthorized = vi.fn();
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [], nextCursor: null })
.mockRejectedValueOnce(
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
)
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '登录失效' }),
);
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
await screen.findByText('暂无客户端埋点数据');
fireEvent.click(screen.getByText('刷新'));
expect((await screen.findByRole('alert')).textContent).toContain(
'无权访问客户端埋点',
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
@@ -1,379 +0,0 @@
import { Modal } from '@genarrative/shared/components';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
import type {
AdminAgcTrackingEventEntry,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
const eventLabels: Record<string, string> = {
editor_session_start: '编辑器会话开始',
editor_session_end: '编辑器会话结束',
editor_focus_start: '编辑器获得焦点',
editor_focus_end: '编辑器失去焦点',
project_create_success: '项目创建成功',
project_open: '打开项目',
creative_task_submit: '首次提交创作目标',
agent_run_completed: 'Agent 运行完成',
agent_run_failed: 'Agent 运行失败',
project_revision_created: '项目产生修改',
preview_ready: '预览就绪',
project_save: '保存项目',
};
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
eventId: '事件 ID',
schemaVersion: '事件版本',
eventName: '事件类型',
eventTime: '发生时间',
userId: '用户 ID',
editorSessionId: '编辑器会话 ID',
projectId: '项目 ID',
creativeTaskId: '创作目标 ID',
agentRunId: 'Agent run ID',
agentTurnId: 'Agent turn ID',
status: '结果',
errorCode: '错误码',
source: '来源',
clientVersion: '客户端版本',
properties: '事件属性',
batchId: '批次 ID',
receivedAt: '入库时间',
};
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
}
export function AdminAgcTrackingPage({
token,
onUnauthorized,
}: {
token: string;
onUnauthorized: (message?: string) => void;
}) {
const [filters, setFilters] = useState({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
const [cursors, setCursors] = useState<Array<string | undefined>>([
undefined,
]);
const [page, setPage] = useState(0);
const [refresh, setRefresh] = useState(0);
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
const cursor = cursors[page];
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
const firstPage = useRef<{
token: string;
query: AdminAgcTrackingEventQuery;
response: AdminAgcTrackingEventListResponse;
} | null>(null);
useEffect(() => {
const saved = firstPage.current;
if (!cursor && saved?.token === token && saved.query === query) {
setEntries(saved.response.entries);
setNextCursor(saved.response.nextCursor);
setError('');
setLoading(false);
return;
}
let active = true;
setLoading(true);
setError('');
setEntries([]);
setNextCursor(null);
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
.then((response) => {
if (!active) return;
if (!cursor) firstPage.current = { token, query, response };
setEntries(response.entries);
setNextCursor(response.nextCursor);
})
.catch((failure: unknown) => {
if (active) handlePageError(failure, onUnauthorized, setError);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [token, query, cursor, refresh, onUnauthorized]);
function resetPages() {
firstPage.current = null;
setCursors([undefined]);
setPage(0);
setDetail(null);
}
function search(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (
filters.startTime &&
filters.endTime &&
new Date(filters.startTime) >= new Date(filters.endTime)
) {
setError('结束时间必须晚于开始时间');
return;
}
resetPages();
setQuery({
userId: filters.userId.trim(),
projectId: filters.projectId.trim(),
eventName: filters.eventName,
startTime: filters.startTime
? new Date(filters.startTime).toISOString()
: undefined,
endTime: filters.endTime
? new Date(filters.endTime).toISOString()
: undefined,
});
}
function related(
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
value: string,
) {
resetPages();
setFilters({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
setQuery({ [key]: value });
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
type="button"
className="admin-secondary-button"
disabled={loading}
onClick={() => {
resetPages();
setRefresh((value) => value + 1);
}}
>
</button>
</div>
<form className="admin-panel admin-form" onSubmit={search}>
<div className="admin-filter-grid">
{(['userId', 'projectId'] as const).map((key) => (
<label key={key} className="admin-field">
<span>{fieldLabels[key]}</span>
<input
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
<label className="admin-field">
<span></span>
<select
value={filters.eventName}
onChange={(event) =>
setFilters({ ...filters, eventName: event.target.value })
}
>
<option value=""></option>
{Object.entries(eventLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{(['startTime', 'endTime'] as const).map((key) => (
<label key={key} className="admin-field">
<span>
{key === 'startTime'
? '发生时间起点(含)'
: '发生时间终点(不含)'}
</span>
<input
type="datetime-local"
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
</div>
<div className="admin-action-row">
<button
type="submit"
className="admin-primary-button"
disabled={loading}
>
</button>
</div>
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
.filter((key) => query[key])
.map((key) => (
<p key={key}>
{fieldLabels[key]}{query[key]}
</p>
))}
</form>
{error ? (
<p role="alert" className="admin-error-message">
{error}
</p>
) : null}
<div className="admin-panel">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
{[
'入库时间',
'发生时间',
'用户',
'事件名称',
'项目',
'来源',
'结果',
'客户端版本',
'详情',
].map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.eventId}>
<td>{formatTime(entry.receivedAt)}</td>
<td>{formatTime(entry.eventTime)}</td>
<td>
{entry.userId}
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
</td>
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
<td>{entry.projectId ?? '—'}</td>
<td>{entry.source}</td>
<td>{entry.status ?? '—'}</td>
<td>{entry.clientVersion}</td>
<td>
<button
type="button"
className="admin-ghost-button"
onClick={() => setDetail(entry)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading ? (
<p role="status"></p>
) : !error && entries.length === 0 ? (
<p></p>
) : null}
<div className="admin-action-row">
<button
type="button"
className="admin-secondary-button"
disabled={loading || page === 0}
onClick={() => setPage((value) => value - 1)}
>
</button>
<span> {page + 1} </span>
<button
type="button"
className="admin-secondary-button"
disabled={loading || !nextCursor}
onClick={() => {
if (!nextCursor) return;
setCursors([...cursors.slice(0, page + 1), nextCursor]);
setPage(page + 1);
}}
>
</button>
</div>
</div>
{detail ? (
<Modal
open
title="客户端埋点详情"
closeLabel="关闭详情"
onClose={() => setDetail(null)}
className="genarrative-ui"
>
<dl>
{(
Object.keys(fieldLabels) as Array<
keyof AdminAgcTrackingEventEntry
>
)
.filter((key) => key !== 'properties')
.map((key) => (
<div key={key}>
<dt>{fieldLabels[key]}</dt>
<dd style={{ overflowWrap: 'anywhere' }}>
{detail[key] == null ? '—' : String(detail[key])}
</dd>
</div>
))}
</dl>
<div className="admin-action-row">
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
(key) =>
detail[key] ? (
<button
type="button"
key={key}
className="admin-secondary-button"
onClick={() => related(key, detail[key]!)}
>
{fieldLabels[key]}
</button>
) : null,
)}
</div>
<h3></h3>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{JSON.stringify(detail.properties, null, 2)}
</pre>
</Modal>
) : null}
</section>
);
}
@@ -2641,6 +2641,48 @@ const databaseTableLabelMap: Record<string, string> = {
profile_recharge_order: '充值订单',
profile_feedback_submission: '反馈提交',
profile_save_archive: '存档记录',
story_session: '剧情会话',
story_event: '剧情事件',
npc_state: 'NPC 状态',
inventory_slot: '背包槽位',
battle_state: '战斗状态',
treasure_record: '宝藏记录',
quest_record: '任务记录',
quest_log: '任务日志',
player_progression: '玩家进度',
chapter_progression: '章节进度',
custom_world_profile: '自定义世界档案',
custom_world_session: '自定义世界会话',
custom_world_agent_session: '自定义世界 Agent 会话',
custom_world_agent_message: '自定义世界 Agent 消息',
custom_world_agent_operation: '自定义世界 Agent 操作',
custom_world_draft_card: '自定义世界草稿卡片',
custom_world_gallery_entry: '自定义世界画廊条目',
puzzle_agent_session: '拼图 Agent 会话',
puzzle_agent_message: '拼图 Agent 消息',
puzzle_work_profile: '拼图作品档案',
puzzle_event: '拼图事件',
puzzle_runtime_run: '拼图运行记录',
puzzle_leaderboard_entry: '拼图排行榜条目',
match3d_agent_session: '抓大鹅 Agent 会话',
match3d_agent_message: '抓大鹅 Agent 消息',
match3d_work_profile: '抓大鹅作品档案',
match3d_runtime_run: '抓大鹅运行记录',
square_hole_agent_session: '方洞挑战 Agent 会话',
square_hole_agent_message: '方洞挑战 Agent 消息',
square_hole_work_profile: '方洞挑战作品档案',
square_hole_runtime_run: '方洞挑战运行记录',
visual_novel_agent_session: '视觉小说 Agent 会话',
visual_novel_agent_message: '视觉小说 Agent 消息',
visual_novel_work_profile: '视觉小说作品档案',
visual_novel_runtime_run: '视觉小说运行记录',
visual_novel_runtime_history_entry: '视觉小说历史条目',
visual_novel_runtime_event: '视觉小说运行事件',
big_fish_creation_session: '大鱼吃小鱼创建会话',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
big_fish_event: '大鱼吃小鱼事件',
big_fish_runtime_run: '大鱼吃小鱼运行记录',
asset_object: '资产对象',
asset_entity_binding: '资产实体绑定',
asset_event: '资产事件',
@@ -2682,6 +2724,48 @@ const databaseTableDescriptionMap: Record<string, string> = {
profile_recharge_order: '充值订单表',
profile_feedback_submission: '反馈提交记录表',
profile_save_archive: '用户存档记录表',
story_session: '剧情会话表',
story_event: '剧情事件表',
npc_state: 'NPC 状态表',
inventory_slot: '背包槽位表',
battle_state: '战斗状态表',
treasure_record: '宝藏记录表',
quest_record: '任务记录表',
quest_log: '任务日志表',
player_progression: '玩家进度表',
chapter_progression: '章节进度表',
custom_world_profile: '自定义世界档案表',
custom_world_session: '自定义世界会话表',
custom_world_agent_session: '自定义世界 Agent 会话表',
custom_world_agent_message: '自定义世界 Agent 消息表',
custom_world_agent_operation: '自定义世界 Agent 操作表',
custom_world_draft_card: '自定义世界草稿卡片表',
custom_world_gallery_entry: '自定义世界画廊条目表',
puzzle_agent_session: '拼图 Agent 会话表',
puzzle_agent_message: '拼图 Agent 消息表',
puzzle_work_profile: '拼图作品档案表',
puzzle_event: '拼图事件表',
puzzle_runtime_run: '拼图运行记录表',
puzzle_leaderboard_entry: '拼图排行榜条目表',
match3d_agent_session: '抓大鹅 Agent 会话表',
match3d_agent_message: '抓大鹅 Agent 消息表',
match3d_work_profile: '抓大鹅作品档案表',
match3d_runtime_run: '抓大鹅运行记录表',
square_hole_agent_session: '方洞挑战 Agent 会话表',
square_hole_agent_message: '方洞挑战 Agent 消息表',
square_hole_work_profile: '方洞挑战作品档案表',
square_hole_runtime_run: '方洞挑战运行记录表',
visual_novel_agent_session: '视觉小说 Agent 会话表',
visual_novel_agent_message: '视觉小说 Agent 消息表',
visual_novel_work_profile: '视觉小说作品档案表',
visual_novel_runtime_run: '视觉小说运行记录表',
visual_novel_runtime_history_entry: '视觉小说历史条目表',
visual_novel_runtime_event: '视觉小说运行事件表',
big_fish_creation_session: '大鱼吃小鱼创建会话表',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
big_fish_event: '大鱼吃小鱼事件表',
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
asset_object: '资产对象表',
asset_entity_binding: '资产实体绑定表',
asset_event: '资产事件表',
@@ -1,136 +0,0 @@
import type { EditorGenerationModel3dPricingPayload } from '../api/adminApiTypes';
import {
EDITOR_MODEL3D_ENDPOINT_SECTIONS,
editorModel3dAddOnLabel,
parsePositiveInteger,
updateEditorModel3dEndpointPricing,
} from './adminEditorGenerationPricing';
interface AdminEditorGenerationModel3dPricingSectionProps {
model3d: EditorGenerationModel3dPricingPayload;
onChange: (next: EditorGenerationModel3dPricingPayload) => void;
}
/**
* 3D 生成定价区块:按端点分成「文生 3D」与「图生 3D」两组,每组是该端点自己的
* 「模型版本 × {无贴图, 带贴图}」底价表与加价项单值输入。
*
* 键集合全部来自接口返回,后台只能改数值:不能新增、删除键,也不能停用整段。
*/
export function AdminEditorGenerationModel3dPricingSection({
model3d,
onChange,
}: AdminEditorGenerationModel3dPricingSectionProps) {
function updateEndpoint(
section: (typeof EDITOR_MODEL3D_ENDPOINT_SECTIONS)[number]['key'],
updater: Parameters<typeof updateEditorModel3dEndpointPricing>[2],
) {
onChange(updateEditorModel3dEndpointPricing(model3d, section, updater));
}
return (
<section className="admin-panel admin-pricing-model-card">
<div className="admin-pricing-model-heading">
<strong>3D </strong>
<span className="admin-pricing-unit"></span>
</div>
<p className="admin-pricing-model3d-hint">
3D
</p>
<div className="admin-pricing-grid">
{EDITOR_MODEL3D_ENDPOINT_SECTIONS.map(({ key, label }) => (
<section className="admin-panel admin-pricing-model-card" key={key}>
<div className="admin-pricing-model-heading">
<strong>{label}</strong>
</div>
<div className="admin-pricing-model3d-list">
{Object.entries(model3d[key].versionPrices).map(
([version, price]) => (
<div className="admin-pricing-model3d-row" key={version}>
<span className="admin-pricing-model3d-row-label">
{version}
</span>
<label className="admin-field">
<span></span>
<input
aria-label={`${label} ${version} 无贴图`}
min={1}
step={1}
type="number"
value={price.noTexture}
onChange={(event) =>
updateEndpoint(key, (current) => ({
...current,
versionPrices: {
...current.versionPrices,
[version]: {
...price,
noTexture: parsePositiveInteger(
event.target.value,
),
},
},
}))
}
/>
</label>
<label className="admin-field">
<span></span>
<input
aria-label={`${label} ${version} 带贴图`}
min={1}
step={1}
type="number"
value={price.texture}
onChange={(event) =>
updateEndpoint(key, (current) => ({
...current,
versionPrices: {
...current.versionPrices,
[version]: {
...price,
texture: parsePositiveInteger(
event.target.value,
),
},
},
}))
}
/>
</label>
</div>
),
)}
</div>
<div className="admin-form-row">
{Object.entries(model3d[key].addOnPrices).map(
([addOn, price]) => (
<label className="admin-field" key={addOn}>
<span>{editorModel3dAddOnLabel(addOn)}</span>
<input
aria-label={`${label} ${editorModel3dAddOnLabel(addOn)}`}
min={1}
step={1}
type="number"
value={price}
onChange={(event) =>
updateEndpoint(key, (current) => ({
...current,
addOnPrices: {
...current.addOnPrices,
[addOn]: parsePositiveInteger(event.target.value),
},
}))
}
/>
</label>
),
)}
</div>
</section>
))}
</div>
</section>
);
}
@@ -5,23 +5,20 @@ import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
getAdminEditorGenerationPricing,
upsertAdminEditorGenerationPricing,
} from '../api/adminApiClient';
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage';
vi.mock('../api/adminApiClient', async () => {
const actual = await vi.importActual<typeof import('../api/adminApiClient')>(
'../api/adminApiClient',
);
return {
...actual,
getAdminEditorGenerationPricing: vi.fn(),
upsertAdminEditorGenerationPricing: vi.fn(),
};
});
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminEditorGenerationPricing: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertAdminEditorGenerationPricing: vi.fn(),
}));
const pricing: EditorGenerationPricingConfigPayload = {
models: {
@@ -44,80 +41,36 @@ const pricing: EditorGenerationPricingConfigPayload = {
'audio1.0': { unit: 'perGeneration', price: 5 },
'chirp-v5': { unit: 'perGeneration', price: 12 },
},
model3d: {
textToModelPricing: {
versionPrices: {
'v3.1-20260211': { noTexture: 8, texture: 16 },
'P2-20260801': { noTexture: 80, texture: 88 },
},
addOnPrices: {
hdTexture: 8,
ultraTexture: 16,
quadMesh: 4,
generateParts: 16,
},
},
imageToModelPricing: {
versionPrices: {
'v3.1-20260211': { noTexture: 16, texture: 24 },
'P2-20260801': { noTexture: 80, texture: 88 },
},
addOnPrices: {
hdTexture: 8,
ultraTexture: 16,
quadMesh: 4,
generateParts: 16,
},
},
},
updatedAtMicros: 1_700_000_000_000_000,
};
function savedPricing(
overrides: Partial<EditorGenerationPricingConfigPayload> = {},
) {
return {
...pricing,
updatedAtMicros: pricing.updatedAtMicros + 1,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing);
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue(
savedPricing({
models: {
...pricing.models,
'gpt-image-2': {
unit: 'perGeneration',
prices: { '1K': 20, '2K': 58 },
},
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({
...pricing,
models: {
...pricing.models,
'gpt-image-2': {
unit: 'perGeneration',
prices: { '1K': 20, '2K': 58 },
},
}),
);
},
});
});
async function renderPage() {
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
const user = userEvent.setup();
render(
<AdminEditorGenerationPricingPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('3D 生成定价');
}
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
const user = userEvent.setup();
await renderPage();
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), {
target: { value: '58' },
});
const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K');
fireEvent.change(gptImage2kInput, { target: { value: '58' } });
await user.click(screen.getByRole('button', { name: '保存定价' }));
await user.click(screen.getByRole('button', { name: '确认' }));
@@ -137,99 +90,3 @@ test('模型定价后台按模型展示单位并保存尺寸定价', async () =>
);
});
});
test('3D 区块按端点渲染版本底价与加价项,并随保存回传整段与定价版本', async () => {
const user = userEvent.setup();
await renderPage();
expect(screen.getByText('文生 3D')).toBeTruthy();
expect(screen.getByText('图生 3D')).toBeTruthy();
expect(screen.getAllByText('高清贴图').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('文生 3D v3.1-20260211 带贴图'), {
target: { value: '18' },
});
fireEvent.change(screen.getByLabelText('图生 3D P2-20260801 无贴图'), {
target: { value: '86' },
});
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
target: { value: '6' },
});
await user.click(screen.getByRole('button', { name: '保存定价' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
expectedUpdatedAtMicros: pricing.updatedAtMicros,
model3d: {
textToModelPricing: expect.objectContaining({
versionPrices: expect.objectContaining({
'v3.1-20260211': { noTexture: 8, texture: 18 },
}),
addOnPrices: expect.objectContaining({ quadMesh: 6 }),
}),
imageToModelPricing: expect.objectContaining({
versionPrices: expect.objectContaining({
'P2-20260801': { noTexture: 86, texture: 88 },
}),
}),
},
}),
);
});
});
test('数值小于 1 时禁用保存并给出中文提示', async () => {
const user = userEvent.setup();
await renderPage();
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
target: { value: '0' },
});
expect(await screen.findByText(/以下价格必须是不小于 1 的整数/)).toBeTruthy();
const saveButton = screen.getByRole('button', {
name: '保存定价',
}) as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
await user.click(saveButton);
expect(upsertAdminEditorGenerationPricing).not.toHaveBeenCalled();
});
test('保存遇到 409 时提示重新读取且不覆盖本地改动', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminEditorGenerationPricing).mockRejectedValue(
new AdminApiError({ message: '定价冲突', status: 409 }),
);
await renderPage();
await user.click(screen.getByRole('button', { name: '保存定价' }));
await user.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('定价已被其他人更新,请重新读取后再编辑保存'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '重新读取' })).toBeTruthy();
});
test('后端未配置 3D 段时给出提示并禁止保存', async () => {
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({
...pricing,
model3d: null,
});
render(
<AdminEditorGenerationPricingPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy();
expect(
(screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
@@ -3,22 +3,14 @@ import { FormEvent, useEffect, useState } from 'react';
import {
getAdminEditorGenerationPricing,
isAdminApiError,
upsertAdminEditorGenerationPricing,
} from '../api/adminApiClient';
import type {
EditorGenerationModel3dPricingPayload,
EditorGenerationModelPricingPayload,
EditorGenerationPricingConfigPayload,
EditorGenerationPricingUnitPayload,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { AdminEditorGenerationModel3dPricingSection } from './AdminEditorGenerationModel3dPricingSection';
import {
buildAdminEditorGenerationPricingRequest,
collectInvalidEditorGenerationPricingLabels,
parsePositiveInteger,
} from './adminEditorGenerationPricing';
import { handlePageError } from './pageUtils';
interface AdminEditorGenerationPricingPageProps {
@@ -28,8 +20,6 @@ interface AdminEditorGenerationPricingPageProps {
const emptyPricing: EditorGenerationPricingConfigPayload = {
models: {},
model3d: null,
updatedAtMicros: 0,
};
const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
@@ -37,9 +27,6 @@ const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
perSecond: '按秒',
};
const PRICING_VERSION_CONFLICT_MESSAGE =
'定价已被其他人更新,请重新读取后再编辑保存';
export function AdminEditorGenerationPricingPage({
token,
onUnauthorized,
@@ -49,7 +36,6 @@ export function AdminEditorGenerationPricingPage({
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [isPricingVersionStale, setIsPricingVersionStale] = useState(false);
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
@@ -60,7 +46,6 @@ export function AdminEditorGenerationPricingPage({
async function refreshPricing() {
setIsLoading(true);
setErrorMessage('');
setIsPricingVersionStale(false);
try {
setPricing(await getAdminEditorGenerationPricing(token));
} catch (error: unknown) {
@@ -72,11 +57,10 @@ export function AdminEditorGenerationPricingPage({
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving || !canSave) {
if (isSaving) {
return;
}
setErrorMessage('');
setIsPricingVersionStale(false);
const confirmed = await confirmWrite({
action: '保存模型定价',
target: '图片画布生成',
@@ -87,18 +71,8 @@ export function AdminEditorGenerationPricingPage({
setIsSaving(true);
try {
setPricing(
await upsertAdminEditorGenerationPricing(
token,
buildAdminEditorGenerationPricingRequest(pricing),
),
);
setPricing(await upsertAdminEditorGenerationPricing(token, pricing));
} catch (error: unknown) {
if (isAdminApiError(error) && error.status === 409) {
setErrorMessage(PRICING_VERSION_CONFLICT_MESSAGE);
setIsPricingVersionStale(true);
return;
}
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
@@ -146,15 +120,6 @@ export function AdminEditorGenerationPricingPage({
});
}
function updateModel3dPrice(next: EditorGenerationModel3dPricingPayload) {
setPricing((current) => ({ ...current, model3d: next }));
}
const invalidPriceLabels =
collectInvalidEditorGenerationPricingLabels(pricing);
const isModel3dMissing = !pricing.model3d;
const canSave = invalidPriceLabels.length === 0 && !isModel3dMissing;
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
@@ -176,31 +141,6 @@ export function AdminEditorGenerationPricingPage({
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
{isPricingVersionStale ? (
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshPricing}
>
<RefreshCcw size={17} aria-hidden="true" />
<span></span>
</button>
) : null}
</div>
) : null}
{isModel3dMissing ? (
<div className="admin-alert" role="status">
3D 3D
</div>
) : null}
{invalidPriceLabels.length > 0 ? (
<div className="admin-alert" role="status">
{`以下价格必须是不小于 1 的整数:${invalidPriceLabels.join('、')}`}
</div>
) : null}
@@ -216,16 +156,9 @@ export function AdminEditorGenerationPricingPage({
)}
</div>
{pricing.model3d ? (
<AdminEditorGenerationModel3dPricingSection
model3d={pricing.model3d}
onChange={updateModel3dPrice}
/>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !canSave}
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
@@ -290,3 +223,8 @@ function renderModelPricingCard({
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More