diff --git a/reports/role-permissions-20260906/api-checks.json b/reports/role-permissions-20260906/api-checks.json
new file mode 100644
index 0000000..5abf6b1
--- /dev/null
+++ b/reports/role-permissions-20260906/api-checks.json
@@ -0,0 +1,135 @@
+{
+ "time": "2026-09-06T03:48:33.309Z",
+ "checks": [
+ {
+ "name": "root 使用管理员登录入口进入内部维护角色",
+ "result": "PASS"
+ },
+ {
+ "name": "普通角色目录不显示内部维护角色",
+ "result": "PASS"
+ },
+ {
+ "name": "管理员角色采用固定的受限授权",
+ "result": "PASS"
+ },
+ {
+ "name": "管理员授权已持久化,不动态获得全权限",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 无法通过接口修改固定管理员授权",
+ "result": "PASS"
+ },
+ {
+ "name": "root 无法通过接口修改固定管理员授权",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 账号已取消 is_protected",
+ "result": "PASS"
+ },
+ {
+ "name": "用户目录不显示 root 维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "普通账号按 ID 查询 root 受保护",
+ "result": "PASS"
+ },
+ {
+ "name": "普通账号按 ID 停用 root 受保护",
+ "result": "PASS"
+ },
+ {
+ "name": "普通账号按 ID 重置密码 root 受保护",
+ "result": "PASS"
+ },
+ {
+ "name": "普通账号按 ID 删除 root 受保护",
+ "result": "PASS"
+ },
+ {
+ "name": "管理员无法访问维护审计",
+ "result": "PASS"
+ },
+ {
+ "name": "普通审计不混入 root 登录与操作记录",
+ "result": "PASS"
+ },
+ {
+ "name": "root 活动保留在可追溯的内部审计",
+ "result": "PASS"
+ },
+ {
+ "name": "目录提供模型独立动作与查看依赖",
+ "result": "PASS"
+ },
+ {
+ "name": "已移除旧的共用内容写权限",
+ "result": "PASS"
+ },
+ {
+ "name": "创建可配置的reader测试角色",
+ "result": "PASS"
+ },
+ {
+ "name": "保存reader细分授权",
+ "result": "PASS"
+ },
+ {
+ "name": "创建可配置的editor测试角色",
+ "result": "PASS"
+ },
+ {
+ "name": "保存editor细分授权",
+ "result": "PASS"
+ },
+ {
+ "name": "建立隔离的多角色测试账号",
+ "result": "PASS"
+ },
+ {
+ "name": "多角色测试账号正常登录",
+ "result": "PASS"
+ },
+ {
+ "name": "接口切换到reader角色",
+ "result": "PASS"
+ },
+ {
+ "name": "reader当前身份独立生效",
+ "result": "PASS"
+ },
+ {
+ "name": "reader拥有模型查看权限",
+ "result": "PASS"
+ },
+ {
+ "name": "只有查看权限时,直接新建接口返回403",
+ "result": "PASS"
+ },
+ {
+ "name": "接口切换到editor角色",
+ "result": "PASS"
+ },
+ {
+ "name": "editor当前身份独立生效",
+ "result": "PASS"
+ },
+ {
+ "name": "editor拥有模型查看权限",
+ "result": "PASS"
+ },
+ {
+ "name": "自定义非教员角色获得新增权限即可创建模型",
+ "result": "PASS"
+ },
+ {
+ "name": "普通管理员模型查看与创建权限相互独立",
+ "result": "PASS"
+ }
+ ],
+ "catalogCount": 138,
+ "adminPermissionCount": 106
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/artifact-checks.json b/reports/role-permissions-20260906/artifact-checks.json
new file mode 100644
index 0000000..ccca8f5
--- /dev/null
+++ b/reports/role-permissions-20260906/artifact-checks.json
@@ -0,0 +1,41 @@
+{
+ "time": "2026-09-06T12:08:36.512028",
+ "secretScan": "PASS",
+ "secretScanScope": "Changed textual artifacts; identical existing source and Playwright library substrings excluded by surrounding-context comparison.",
+ "unchangedBaselineFiles": [
+ "unreal_tran_api/database/unreal_tran_full.sql",
+ "unreal_tran_api/ut_auth/src/main/resources/application.yml",
+ "unreal_tran_api/wiki/11-内容制作模块.md",
+ "unreal_tran_web/src/views/system/UsersView.vue",
+ "ute2e/reports/role-permissions-20260906/playwright/index.html"
+ ],
+ "changedFileCounts": {
+ "unreal_tran_api": 83,
+ "unreal_tran_web": 68,
+ "ute2e": 58
+ },
+ "backendTests": {
+ "ut_auth": {
+ "tests": 112,
+ "failures": 0,
+ "errors": 0,
+ "skipped": 0
+ },
+ "ut_tran": {
+ "tests": 340,
+ "failures": 0,
+ "errors": 0,
+ "skipped": 0
+ }
+ },
+ "deployedArtifacts": {
+ "auth": {
+ "sha256": "98aaeedfe29c8e75e99ef9421a3982ae52ab357eb8592c68a30dfc54e633ff85",
+ "runningCopyMatches": true
+ },
+ "tran": {
+ "sha256": "d66a38602e32c0db0c2680905f6a6461d9cd09ab12f4ba1481c87f99f583e828",
+ "runningCopyMatches": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/authorization-design.md b/reports/role-permissions-20260906/authorization-design.md
new file mode 100644
index 0000000..212f501
--- /dev/null
+++ b/reports/role-permissions-20260906/authorization-design.md
@@ -0,0 +1,139 @@
+# 权限调整设计与验收约定
+
+日期:2026-09-06。本文按“资料 → 产品原型 → 设计 → 开发 → 测试”记录本轮授权范围、实现依据和验收边界。文中的迁移后角色矩阵是根据旧环境快照与 036 迁移规则推导的目标值;真实 API、角色切换 E2E、截图及最终构建结果由本目录的最终测试报告补充,不能把设计目标当作已通过的实测结果。
+
+## 1. 资料:先确认旧系统实际允许什么
+
+依据:
+
+- 用户本轮确认的权限方案:管理员保留固定的实际管理权限;内部维护身份独立保护;页面与操作按权限判定;教学、资源和内容动作细分;头像菜单支持切换已分配的工作身份。
+- 旧环境脱敏快照:[role-permissions-audit-20260906.json](data/role-permissions-audit-20260906.json)。采集时间为 2026-09-06 03:08:01 UTC,授权模式为 `SINGLE_ACTIVE`;交付副本仅含角色与权限,不含用户个人资料或凭据。
+- 动作合同:[granular-permissions-contract.json](data/granular-permissions-contract.json)。列出本轮新增的 87 个动作码、所属页面、旧码来源和迁移条件。
+- 迁移文件:[20260906_036_role_permissions_maintenance.sql](../../../unreal_tran_api/database/20260906_036_role_permissions_maintenance.sql)。以显式清单固定管理员授权,迁移普通角色的已有能力,增加维护审计范围。
+- 服务端授权说明:[08-角色授权模式.md](../../../unreal_tran_api/wiki/08-角色授权模式.md)。页面权限、动作权限、数据范围、会话身份及维护账号约束分别生效。
+
+旧快照中的角色状态:
+
+| 当前角色 | 旧授权记录 | 旧数据范围 | 实际含义 |
+| --- | --- | --- | --- |
+| 管理员 `admin` | 动态取得全部 65 个启用叶子权限,旧 `superAdmin=true` | `ALL` | 权限目录看似“全部”,但内容写 API 和前端另有教员身份门槛;因此模型、场景等实际仍只读。不能把“目录全选”当作其真实业务写能力。 |
+| 教员 `teacher` | 显式 33 项,含 3 个共用内容写权限、教学管理与运行权限 | `SYSTEM` | 可制作内容、组织教学;资源库和维修知识主要有页面权限,未持有对应旧 `manage` 权限。 |
+| 学员 `student` | 显式 14 项,含教学领取、提交、运行及知识页面 | `SELF` | 参与本人或本人队组被分配的教学,不具有内容制作、教学管理和系统管理权限。 |
+
+旧数据说明了两处需要修正的表达:一是管理员不能再显示成动态全权限后又在页面按角色名拦住;二是角色名不能替代一项可以独立授予或撤销的业务能力。迁移以旧实际能力为边界,不借权限拆分给教员补齐资源管理功能,也不给管理员补齐内容制作功能。
+
+## 2. 产品原型:保持已有入口,明确能看与能做
+
+登录页仍为教员、学员、系统管理员三个等宽身份选项。此选择用于请求服务端确认会话身份,不是页面授权结果。普通界面不增加“内部维护”登录选项或维护人员条目。
+
+列表、工作区和系统配置页遵守同一规则:有页面权限才进入;进入后,新建、编辑、删除、发布、审核分别显示或启用;权限不足时仍可查看已授权的数据。缺少编辑权限的三维工作区保留浏览、相机查看和已有的只读预览能力,阻止编辑字段、导入上传、拖动修改与自动保存。
+
+头像菜单和个人中心共用身份切换流程,只列出当前用户已分配且有效的角色;`SINGLE_ACTIVE` 且有效角色多于一个时可切换。`UNION` 沿用权限并集,不显示会改变授权的“切换身份”入口。
+
+OFD 默认工作台仍是已有本地原型入口。本轮将其假定教员、无条件允许操作的上下文替换为真实会话与细分权限,并阻止无权限本地写入。原型没有的审批业务不会因为新增 `content.ofd.review` 就被临时拼出来;正式 OFD 接口与保留的正式工作流组件按已有审批状态机控制批准、驳回。
+
+## 3. 设计:能力、身份与数据范围分开
+
+### 3.1 授权判定
+
+有效操作必须同时满足:已认证会话、当前有效授权集合、对应页面、具体动作、服务端数据范围,以及原有项目状态和版本保护。前端只负责可用性提示,服务端仍校验每个实际请求。
+
+`SINGLE_ACTIVE` 只使用当前活动角色的权限和数据范围;一个账号同时持有管理员、教员时,管理员身份不会自动合并教员的内容写权限。`UNION` 按服务端计算的有效角色并集处理。页面不得再通过 `roleCode === teacher/admin` 赋予通用编辑能力。
+
+学员本人、分组、队伍、岗位、正式考核投影、设备事件来源等仍属于业务约束;不能因页面改看权限而放开这些条件。版本号、固定发布依赖、数据范围和考核答案隔离继续由服务端判断。
+
+### 3.2 迁移后角色能力矩阵
+
+下表对应“旧快照 + 036 首次迁移”,不是对所有同名角色强行恢复一套默认权限。可配置角色后续被撤销的细分权限,不应因重复执行迁移而重新获得。
+
+| 能力 | 管理员固定角色 | 教员迁移结果 | 学员迁移结果 |
+| --- | --- | --- | --- |
+| 工作总览 | 可进入 | 可进入 | 可进入本人视图 |
+| 模型、场景、训练编排、OFD 内容页面 | 保留查看,默认不含内容写 | 保留四类页面与制作能力 | 默认无内容制作页面 |
+| 内容新建、复制、编辑、上传、删除、发布 | 固定清单不授予 | 由原共用内容写权限展开为四类独立动作 | 不授予 |
+| OFD 批准、驳回 | 固定清单不授予 | 从原有内容修改能力迁移为 `content.ofd.review`,仍须对应审批状态和实际正式入口 | 不授予 |
+| 虚拟、实装、对抗、考试任务管理 | 保留新建、修改、发布、撤回、归档、评审、终止等管理动作 | 保留既有管理能力,按对应页面分别拆分 | 不授予管理动作 |
+| 教学领取、提交、正式考试执行 | 不因原动态全权限继续保留学员专属动作 | 不因教员身份新增学员专属动作 | 按四类教学页面分别取得领取、提交;正式考试执行使用独立 `teaching.exams.execute` |
+| 原有虚拟、实装、对抗与沉浸运行权限 | 管理固定清单不包含学员运行能力 | 保留原有 execute 权限,实际运行还须满足任务、参与人和考核约束 | 保留原有 execute 权限,限定本人/本人队组 |
+| 对抗配置、故障与干预 | 保留配置;故障增改删、干预独立授权 | 按原有配置/管理权限展开 | 不授予管理或注入能力 |
+| 指导书教学使用 | 反馈处理和统计 | 反馈处理和统计 | 学习进度、提交反馈 |
+| 资源管理、维修知识 | 保留管理能力并拆分增改删、发布和分类动作 | 保留原页面;因未有旧 manage,不自动新增资源/知识增改删 | 保留知识页面,不新增知识写权限 |
+| 数据处理 | 保留管理、测试、控制、校验、处理等已有能力 | 保留原页面与 ingest;不自动授予管道管理能力 | 默认不授予 |
+| AI 模型管理 | 保留对应管理、测试 | 旧快照无该页及管理授权,迁移不增加 | 不授予 |
+| 辅助保障 | 保留监控、干预 | 保留原监控、干预 | 不授予 |
+| 系统用户、部门、角色、权限、配置等 | 保留明确管理动作;普通 API 不能改管理员固定角色本身 | 旧快照未授予,不自动增加 | 不授予 |
+| 数据范围 | 保留 `ALL`,不代表内容写或维护身份 | 保留 `SYSTEM` | 保留 `SELF` |
+
+管理员是固定的普通管理角色,不再依赖动态全选。其显式清单不随以后新增权限自动扩张;普通角色权限接口也不能修改该固定清单。它仍可在已有系统管理授权内管理可配置角色。一个账号被合法分配其他角色后,使用该角色需要按授权模式重新计算能力,这与修改管理员固定模板是两件事。
+
+内部维护角色与普通管理员分开:仅后端同时确认受保护维护账号标记和有效内部角色分配,才赋予内部维护能力。用户名、前端标记或单独的角色关联都不能提权。维护角色动态取得启用的页面/动作权限;分组节点不成为可授予的实际权限。
+
+### 3.3 内容四类操作与真实动作对应
+
+| 页面及服务端类型 | 新建/复制 | 编辑/保存/上传/封面/恢复草稿 | 删除工程 | 发布/撤回发布 | 批准/驳回 |
+| --- | --- | --- | --- | --- | --- |
+| 模型制作 `MODEL` | `content.model.create` | `content.model.update` | `content.model.delete` | `content.model.publish` | 无独立模型审核码 |
+| 场景制作 `SCENE` | `content.scene.create` | `content.scene.update` | `content.scene.delete` | `content.scene.publish` | 无独立场景审核码 |
+| 训练编排 `TRAINING` | `content.training.create` | `content.training.update` | `content.training.delete` | `content.training.publish` | 无独立训练审核码 |
+| 作业指导书 `GUIDE` | `content.ofd.create` | `content.ofd.update` | `content.ofd.delete` | `content.ofd.publish` | `content.ofd.review` |
+
+`GUIDE` 的页面后缀使用 `ofd`,不是另造 `content.guide.*`。提交审核、撤回送审、修订归入 `update`;批准、驳回归入 OFD `review`;撤回已发布版本归入 `publish`。这些动作仍受原状态机约束,权限不会凭空增加生命周期转移。
+
+上述删除权限指删除内容工程。编辑工程内部的模型节点、场景对象、训练步骤或文档块,属于修改工程文档,仍使用该类内容的 `update`。封面截图和模型二进制上传同属编辑,不新增独立“封面权限”。
+
+“发布已保存版本”只需要发布动作;“编辑后保存并发布”必须同时拥有编辑与发布。不能为了让发布按钮可用而暗中开放更新接口。场景、训练、模型宿主在只有发布权限时跳过保存,直接提交已加载的服务端版本号。OFD 正式发布还须存在与当前内容指纹匹配的两份交付物;只有发布权限的用户可以复用已保存的 OFD/离线包,缺失或过期时提示编辑者处理,不代为上传,也不发布旧内容的交付物。
+
+### 3.4 内部维护与审计隔离
+
+维护账号和内部角色不出现在普通用户列表、人员目录、角色列表、统计及角色分配入口;普通用户 API 不能对其修改、停用、删除、重置密码或改角色。此保护来自后端身份合同,不靠前端隐藏。
+
+系统、内容、资源智能审计增加写入时的 `visibility_scope` 快照,区分 `BUSINESS` 与 `INTERNAL`。维护账号产生的内部审计不会因后来切换活动角色而变为普通业务审计;读取内部审计仍要求当前有效内部维护角色。普通查询和导出不应通过详情 ID、筛选条件或其它目录绕过隔离。
+
+036 先做结构与授权迁移,不在 SQL 写入账号凭据,也不提前解除旧管理员保护。维护恢复能力通过一次性私密配置初始化;验证凭据、内部角色及有效授权后,才交接普通管理员的保护状态。报告、截图、源码及 DDL 均不记录密码、令牌或个人资料。
+
+### 3.5 角色切换与未保存内容
+
+切换前依次调用所有已注册工作区的离开确认,包括仍被缓存的后台编辑器。保存失败、模型/场景正在绑定或用户关闭确认框时,不发送角色切换请求。
+
+单个编辑器选择“放弃修改”不会立即删除恢复数据或清除脏标记;后续另一个编辑器取消、或角色切换接口失败时,仍保留原工作区状态。只有所有确认完成且服务端切换成功后,才标记身份提交成功并整页进入工作总览;新启动重新取得 `/auth/me` 和菜单,释放旧身份的编辑器、缓存和在途上下文。成功切换后避免 `beforeunload` 再提示或把已放弃的模型草稿写回恢复区。
+
+## 4. 开发:本轮落地范围
+
+- 内容权限映射集中在 [contentPermissions.ts](../../../unreal_tran_web/src/utils/contentPermissions.ts),`canUsePermission` 只检查服务端返回的权限集合。旧共用动作码停用,不以“新码或旧码任选其一”的方式绕过细分授权。
+- 内容列表按类型分别控制新建、复制、编辑、恢复、删除和发布。保留的正式工作台按具体生命周期动作授权。
+- 场景和训练旧编辑器接入宿主能力,增加只读字段/按钮和直接回调保护;训练高级编排不再在只读会话中写本地工程缓存。发布失败继续阻断后续动作,不清除未保存内容,不取消已有版本锁或固定场景依赖。
+- 模型 iframe 在同源宿主增加权限桥接,控制编辑面、变换控件、保存、发布、自动保存与恢复写入;不改旧仓库或已生成的静态 bundle。只读查看旧工程可做内存归一化,不触发自动迁移 PUT,不恢复未授权的本地编辑副本。
+- OFD 本地运行时使用实际会话身份;本地创建、保存、删除、恢复、生命周期与资源编辑均有能力检查。向导把初始文档随新建一起保存,避免只有新建权限的用户先创建空工程后再因无修改权限失败。
+- 头像入口与个人中心共享 [useRoleSwitch.ts](../../../unreal_tran_web/src/composables/useRoleSwitch.ts),工作区通过 [workspaceLeaveGuards.ts](../../../unreal_tran_web/src/utils/workspaceLeaveGuards.ts) 注册确认。页面标签存储按用户和活动角色隔离。
+- 教学任务以已保存的任务类型/渠道选择动作码;正式考试走 `teaching.exams.*`。资源智能将旧 manage 拆为对应页面的真实动作,保留已有专用 ingest、execute、configure 等操作合同。
+
+## 5. 测试:区分已完成检查与待实测结果
+
+本文件形成时,内容范围已执行:
+
+| 检查 | 命令或证据 | 结果 |
+| --- | --- | --- |
+| 前端类型与 SFC 检查 | `pnpm exec vue-tsc --noEmit` | 通过;不等同于完整生产构建 |
+| 内容权限、OFD 本地写隔离、模型宿主与交付物复用 | `node --test tools/content-permissions.test.mjs` | 10/10 通过 |
+| 训练封面、异步保存、绑定并发、只读/发布分权 | `node --test tools/training-cover.test.mjs` | 19/19 通过 |
+| 场景封面、版本冲突、离开确认、只读/发布分权 | `node --test tools/scene-cover-api.test.mjs` | 28/28 通过,含后补的 8 项加载/上传/保存/发布并发离开回归 |
+| 上述三个 Node 测试文件此前联合执行 | `node --test --test-reporter=spec tools/content-permissions.test.mjs tools/training-cover.test.mjs tools/scene-cover-api.test.mjs` | 当时 49/49 通过;后补的 8 项已在场景测试文件独立复跑通过 |
+
+真实联调报告还需要记录以下独立验收项的实际结果,未取得证据前不得填为“通过”:
+
+1. 036 执行及维护恢复交接后,分别读取三个普通角色的实际 `/auth/me`、页面目录与数据范围;对照矩阵检查没有跨页扩大授权。
+2. 管理员内容列表只读、教员原有内容制作可用、学员无内容制作入口;自定义角色只授予某一内容类型的某个动作时,其余类型/动作均被 UI 和 API 拦截。
+3. 新建、复制、编辑、上传封面、恢复版本、删除、发布及 OFD 审核逐项验证;发布权限不能替代更新,更新不能替代删除/发布/审核。
+4. 通过真实三维编辑器确认只读画面仍能加载模型或场景,禁止编辑、上传与自动写;不能只根据接口成功或 DOM 对象数量判断视觉正常。
+5. 在有未保存修改时,从头像菜单切换角色:取消无切换请求;保存失败保留内容;多工作区前一项放弃、后一项取消不丢草稿;成功切换后重新获取身份与菜单,不显示旧角色页面缓存。
+6. 验证维护账号/角色不可经普通目录、详情、统计、人员下拉与修改接口泄漏或被维护;普通审计查询/导出不包含内部审计。
+7. 验证教学管理动作与学员动作分离,正式考试、岗位、队组、数据范围及固定快照约束仍有效。
+8. 完整 `pnpm run build`、真实 API 自动化、E2E 截图和必要的失败 trace 由最终报告统一记录。测试产生的临时角色、权限调整、工程和审计数据应说明保留或清理情况。
+
+## 6. 明确边界
+
+本轮是权限判定和工作区适配,不新增内容制作业务、教学流程或 OFD 审批产品功能。OFD 默认原型仍使用本地存储,前端能力约束不能被描述成完整服务端隔离、跨设备协作或数据范围安全;本轮没有将该本地业务整体迁移至内容 API。
+
+普通管理员保留配置其它角色的能力,但其固定角色本身没有内容制作权限。教员原来未获授予的资源/知识管理动作,不因角色名称自动补授。学员教学执行仍须任务已分配且符合本人/队组/岗位要求。存在权限不代表可以绕过发布前置检查、已发布内容不可变约束或乐观锁。
+
+本资料不包含维护凭据、用户个人资料、访问令牌或数据库备份内容;也不把尚在执行的 API/E2E 项目记为已完成。
diff --git a/reports/role-permissions-20260906/cleanup.json b/reports/role-permissions-20260906/cleanup.json
new file mode 100644
index 0000000..1df1b95
--- /dev/null
+++ b/reports/role-permissions-20260906/cleanup.json
@@ -0,0 +1,15 @@
+{
+ "time": "2026-09-06T04:07:09.247Z",
+ "projects": [
+ "165"
+ ],
+ "users": [
+ "100"
+ ],
+ "roles": [
+ "20",
+ "21"
+ ],
+ "status": "PASS",
+ "postDeleteReads": "All 4 isolated fixture records return HTTP 404"
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/data/granular-permissions-contract.json b/reports/role-permissions-20260906/data/granular-permissions-contract.json
new file mode 100644
index 0000000..0ff30c5
--- /dev/null
+++ b/reports/role-permissions-20260906/data/granular-permissions-contract.json
@@ -0,0 +1,1485 @@
+{
+ "version": 1,
+ "scope": "ut_tran granular action permissions; no compatibility OR for deprecated broad codes",
+ "defaultMigration": "For non-root roles expand only legacy permissions already held AND corresponding page held. Existing admin dynamic-all becomes explicit actual effective abilities: business page/manage abilities retained except content writes and learner-only actions. root stays dynamic all. teacher/student defaults listed are migration guidance, not additional grants to custom roles.",
+ "selection": "FORMAL_EXAM uses teaching.exams actions; TRAINING uses assignment.channelCode. Always authorize persisted project/assignment type; editing assignment kind needs old and new kind update permissions.",
+ "unchanged": [
+ "teaching.virtual.execute",
+ "teaching.physical.execute",
+ "teaching.confrontation.execute",
+ "teaching.immersive.execute",
+ "teaching.physical.ingest",
+ "teaching.confrontation.configure",
+ "integration.knowledge.reindex",
+ "integration.data-processing.ingest",
+ "integration.ai-models.test",
+ "integration.assistance.monitor",
+ "integration.assistance.intervene"
+ ],
+ "deprecated": [
+ "content.create",
+ "content.update",
+ "content.publish",
+ "teaching.tasks.create",
+ "teaching.tasks.publish",
+ "teaching.tasks.withdraw",
+ "teaching.tasks.review",
+ "teaching.tasks.archive",
+ "teaching.tasks.accept",
+ "teaching.tasks.submit",
+ "integration.resources.manage",
+ "integration.knowledge.manage",
+ "integration.data-processing.manage",
+ "integration.ai-models.manage"
+ ],
+ "permissions": [
+ {
+ "code": "content.model.create",
+ "pageCodes": [
+ "content.model"
+ ],
+ "requires": [
+ "content.model"
+ ],
+ "legacyCodes": [
+ "content.create"
+ ],
+ "operation": "create,copy,create-from-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.model.update",
+ "pageCodes": [
+ "content.model"
+ ],
+ "requires": [
+ "content.model"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "edit,upload,cover,restore-version,revise,submit-review,withdraw-review",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.model.delete",
+ "pageCodes": [
+ "content.model"
+ ],
+ "requires": [
+ "content.model"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "delete-project",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.model.publish",
+ "pageCodes": [
+ "content.model"
+ ],
+ "requires": [
+ "content.model"
+ ],
+ "legacyCodes": [
+ "content.publish"
+ ],
+ "operation": "publish,revoke-publication,register-guide-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.scene.create",
+ "pageCodes": [
+ "content.scene"
+ ],
+ "requires": [
+ "content.scene"
+ ],
+ "legacyCodes": [
+ "content.create"
+ ],
+ "operation": "create,copy,create-from-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.scene.update",
+ "pageCodes": [
+ "content.scene"
+ ],
+ "requires": [
+ "content.scene"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "edit,upload,cover,restore-version,revise,submit-review,withdraw-review",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.scene.delete",
+ "pageCodes": [
+ "content.scene"
+ ],
+ "requires": [
+ "content.scene"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "delete-project",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.scene.publish",
+ "pageCodes": [
+ "content.scene"
+ ],
+ "requires": [
+ "content.scene"
+ ],
+ "legacyCodes": [
+ "content.publish"
+ ],
+ "operation": "publish,revoke-publication,register-guide-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.training.create",
+ "pageCodes": [
+ "content.training"
+ ],
+ "requires": [
+ "content.training"
+ ],
+ "legacyCodes": [
+ "content.create"
+ ],
+ "operation": "create,copy,create-from-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.training.update",
+ "pageCodes": [
+ "content.training"
+ ],
+ "requires": [
+ "content.training"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "edit,upload,cover,restore-version,revise,submit-review,withdraw-review",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.training.delete",
+ "pageCodes": [
+ "content.training"
+ ],
+ "requires": [
+ "content.training"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "delete-project",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.training.publish",
+ "pageCodes": [
+ "content.training"
+ ],
+ "requires": [
+ "content.training"
+ ],
+ "legacyCodes": [
+ "content.publish"
+ ],
+ "operation": "publish,revoke-publication,register-guide-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.ofd.create",
+ "pageCodes": [
+ "content.ofd"
+ ],
+ "requires": [
+ "content.ofd"
+ ],
+ "legacyCodes": [
+ "content.create"
+ ],
+ "operation": "create,copy,create-from-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.ofd.update",
+ "pageCodes": [
+ "content.ofd"
+ ],
+ "requires": [
+ "content.ofd"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "edit,upload,cover,restore-version,revise,submit-review,withdraw-review",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.ofd.delete",
+ "pageCodes": [
+ "content.ofd"
+ ],
+ "requires": [
+ "content.ofd"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "delete-project",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.ofd.publish",
+ "pageCodes": [
+ "content.ofd"
+ ],
+ "requires": [
+ "content.ofd"
+ ],
+ "legacyCodes": [
+ "content.publish"
+ ],
+ "operation": "publish,revoke-publication,register-guide-template",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "content.ofd.review",
+ "pageCodes": [
+ "content.ofd"
+ ],
+ "requires": [
+ "content.ofd"
+ ],
+ "legacyCodes": [
+ "content.update"
+ ],
+ "operation": "approve,reject",
+ "defaultRoles": [
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.create",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "create-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.update",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "edit-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.publish",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.publish"
+ ],
+ "operation": "publish-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.withdraw",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.withdraw"
+ ],
+ "operation": "withdraw-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.archive",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.archive"
+ ],
+ "operation": "archive-assignment,archive-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.review",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "review-run,timeout-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.terminate",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "terminate-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.virtual.accept",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.accept"
+ ],
+ "operation": "accept-assignment",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.virtual.submit",
+ "pageCodes": [
+ "teaching.virtual"
+ ],
+ "requires": [
+ "teaching.virtual"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.submit"
+ ],
+ "operation": "submit-run",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.physical.create",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "create-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.physical.update",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "edit-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.physical.publish",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.publish"
+ ],
+ "operation": "publish-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.physical.withdraw",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.withdraw"
+ ],
+ "operation": "withdraw-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.physical.archive",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.archive"
+ ],
+ "operation": "archive-assignment,archive-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.physical.review",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "review-run,timeout-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.physical.terminate",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "terminate-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.physical.accept",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.accept"
+ ],
+ "operation": "accept-assignment",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.physical.submit",
+ "pageCodes": [
+ "teaching.physical"
+ ],
+ "requires": [
+ "teaching.physical"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.submit"
+ ],
+ "operation": "submit-run",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.create",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "create-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.update",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "edit-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.publish",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.publish"
+ ],
+ "operation": "publish-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.withdraw",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.withdraw"
+ ],
+ "operation": "withdraw-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.archive",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.archive"
+ ],
+ "operation": "archive-assignment,archive-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.review",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "review-run,timeout-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.terminate",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "terminate-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.accept",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.accept"
+ ],
+ "operation": "accept-assignment",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.submit",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.submit"
+ ],
+ "operation": "submit-run",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.exams.create",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "create-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.exams.update",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.create"
+ ],
+ "operation": "edit-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.exams.publish",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.publish"
+ ],
+ "operation": "publish-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.exams.withdraw",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.withdraw"
+ ],
+ "operation": "withdraw-assignment",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.exams.archive",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.archive"
+ ],
+ "operation": "archive-assignment,archive-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.exams.review",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "review-run,timeout-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.exams.terminate",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.review"
+ ],
+ "operation": "terminate-run",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.exams.accept",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.accept"
+ ],
+ "operation": "accept-assignment",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.exams.submit",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.tasks.submit"
+ ],
+ "operation": "submit-run",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.exams.execute",
+ "pageCodes": [
+ "teaching.exams"
+ ],
+ "requires": [
+ "teaching.exams"
+ ],
+ "legacyCodes": [
+ "teaching.virtual.execute"
+ ],
+ "operation": "start,progress,execution for formal exams",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.fault.create",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.confrontation.configure"
+ ],
+ "operation": "create-fault",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.fault.update",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.confrontation.configure"
+ ],
+ "operation": "edit-fault",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.fault.delete",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.confrontation.configure"
+ ],
+ "operation": "delete-fault",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.confrontation.intervene",
+ "pageCodes": [
+ "teaching.confrontation"
+ ],
+ "requires": [
+ "teaching.confrontation"
+ ],
+ "legacyCodes": [
+ "teaching.confrontation.configure"
+ ],
+ "operation": "create,activate,revoke-intervention",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.guides.study",
+ "pageCodes": [
+ "teaching.guides"
+ ],
+ "requires": [
+ "teaching.guides"
+ ],
+ "legacyCodes": [
+ "teaching.guides"
+ ],
+ "operation": "start/complete own reading",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.guides.feedback",
+ "pageCodes": [
+ "teaching.guides"
+ ],
+ "requires": [
+ "teaching.guides"
+ ],
+ "legacyCodes": [
+ "teaching.guides"
+ ],
+ "operation": "submit own feedback",
+ "defaultRoles": [
+ "student"
+ ]
+ },
+ {
+ "code": "teaching.guides.feedback.review",
+ "pageCodes": [
+ "teaching.guides"
+ ],
+ "requires": [
+ "teaching.guides"
+ ],
+ "legacyCodes": [
+ "teaching.analytics"
+ ],
+ "operation": "manage feedback",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "teaching.guides.statistics",
+ "pageCodes": [
+ "teaching.guides"
+ ],
+ "requires": [
+ "teaching.guides"
+ ],
+ "legacyCodes": [
+ "teaching.analytics"
+ ],
+ "operation": "guide statistics/all feedback",
+ "defaultRoles": [
+ "admin",
+ "teacher"
+ ]
+ },
+ {
+ "code": "integration.resources.create",
+ "pageCodes": [
+ "integration.resources"
+ ],
+ "requires": [
+ "integration.resources"
+ ],
+ "legacyCodes": [
+ "integration.resources.manage"
+ ],
+ "operation": "create",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.resources.update",
+ "pageCodes": [
+ "integration.resources"
+ ],
+ "requires": [
+ "integration.resources"
+ ],
+ "legacyCodes": [
+ "integration.resources.manage"
+ ],
+ "operation": "edit,restore",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.resources.delete",
+ "pageCodes": [
+ "integration.resources"
+ ],
+ "requires": [
+ "integration.resources"
+ ],
+ "legacyCodes": [
+ "integration.resources.manage"
+ ],
+ "operation": "recycle,batch-recycle,purge",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.resources.publish",
+ "pageCodes": [
+ "integration.resources"
+ ],
+ "requires": [
+ "integration.resources"
+ ],
+ "legacyCodes": [
+ "integration.resources.manage"
+ ],
+ "operation": "publish,shelve",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.resources.category.create",
+ "pageCodes": [
+ "integration.resources"
+ ],
+ "requires": [
+ "integration.resources"
+ ],
+ "legacyCodes": [
+ "integration.resources.manage"
+ ],
+ "operation": "category.create",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.resources.category.update",
+ "pageCodes": [
+ "integration.resources"
+ ],
+ "requires": [
+ "integration.resources"
+ ],
+ "legacyCodes": [
+ "integration.resources.manage"
+ ],
+ "operation": "category.update",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.resources.category.delete",
+ "pageCodes": [
+ "integration.resources"
+ ],
+ "requires": [
+ "integration.resources"
+ ],
+ "legacyCodes": [
+ "integration.resources.manage"
+ ],
+ "operation": "category.delete",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.create",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "import-document",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.update",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "edit,replace-source",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.delete",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "delete",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.publish",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "change-status",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.category.create",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "category.create",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.category.update",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "category.update",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.category.delete",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "category.delete",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.knowledge.category.merge",
+ "pageCodes": [
+ "integration.knowledge"
+ ],
+ "requires": [
+ "integration.knowledge"
+ ],
+ "legacyCodes": [
+ "integration.knowledge.manage"
+ ],
+ "operation": "category.merge",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.data-processing.create",
+ "pageCodes": [
+ "integration.data-processing"
+ ],
+ "requires": [
+ "integration.data-processing"
+ ],
+ "legacyCodes": [
+ "integration.data-processing.manage"
+ ],
+ "operation": "create source/model/rule",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.data-processing.update",
+ "pageCodes": [
+ "integration.data-processing"
+ ],
+ "requires": [
+ "integration.data-processing"
+ ],
+ "legacyCodes": [
+ "integration.data-processing.manage"
+ ],
+ "operation": "edit source/model/rule",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.data-processing.delete",
+ "pageCodes": [
+ "integration.data-processing"
+ ],
+ "requires": [
+ "integration.data-processing"
+ ],
+ "legacyCodes": [
+ "integration.data-processing.manage"
+ ],
+ "operation": "delete source/model/rule",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.data-processing.test",
+ "pageCodes": [
+ "integration.data-processing"
+ ],
+ "requires": [
+ "integration.data-processing"
+ ],
+ "legacyCodes": [
+ "integration.data-processing.manage"
+ ],
+ "operation": "test-source",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.data-processing.pipeline.control",
+ "pageCodes": [
+ "integration.data-processing"
+ ],
+ "requires": [
+ "integration.data-processing"
+ ],
+ "legacyCodes": [
+ "integration.data-processing.manage"
+ ],
+ "operation": "start/stop pipeline",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.data-processing.validate",
+ "pageCodes": [
+ "integration.data-processing"
+ ],
+ "requires": [
+ "integration.data-processing"
+ ],
+ "legacyCodes": [
+ "integration.data-processing.manage"
+ ],
+ "operation": "validate-latest",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.data-processing.resolve",
+ "pageCodes": [
+ "integration.data-processing"
+ ],
+ "requires": [
+ "integration.data-processing"
+ ],
+ "legacyCodes": [
+ "integration.data-processing.manage"
+ ],
+ "operation": "resolve-quality-issue",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.ai-models.create",
+ "pageCodes": [
+ "integration.ai-models"
+ ],
+ "requires": [
+ "integration.ai-models"
+ ],
+ "legacyCodes": [
+ "integration.ai-models.manage"
+ ],
+ "operation": "create model/skill/agent",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.ai-models.update",
+ "pageCodes": [
+ "integration.ai-models"
+ ],
+ "requires": [
+ "integration.ai-models"
+ ],
+ "legacyCodes": [
+ "integration.ai-models.manage"
+ ],
+ "operation": "edit model/skill/agent",
+ "defaultRoles": [
+ "admin"
+ ]
+ },
+ {
+ "code": "integration.ai-models.delete",
+ "pageCodes": [
+ "integration.ai-models"
+ ],
+ "requires": [
+ "integration.ai-models"
+ ],
+ "legacyCodes": [
+ "integration.ai-models.manage"
+ ],
+ "operation": "delete model/skill/agent",
+ "defaultRoles": [
+ "admin"
+ ]
+ }
+ ],
+ "auditColumns": [
+ {
+ "tables": [
+ "ut_content_audit_log",
+ "ut_integration_audit_log"
+ ],
+ "column": "visibility_scope",
+ "sqlType": "VARCHAR(16) NOT NULL DEFAULT 'BUSINESS'",
+ "values": [
+ "BUSINESS",
+ "INTERNAL"
+ ],
+ "internalCondition": "trusted /auth/me maintenanceAccount == true (independent of active role)",
+ "normalReadFilter": "visibility_scope='BUSINESS'"
+ }
+ ],
+ "notes": [
+ "teaching.confrontation.configure remains assignment fault binding and restricted fault-catalog visibility; writes split to fault CRUD/intervene.",
+ "No teaching task DELETE endpoint exists: do not seed nonfunctional delete action.",
+ "Reading study/own feedback retain learner participation policy; task/run member/team/exam safety remains server enforced.",
+ "No new action for analytics read-only endpoints. Existing integration ingest/test/reindex/monitor/intervene remain specific operations."
+ ]
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/data/role-permissions-audit-20260906.json b/reports/role-permissions-20260906/data/role-permissions-audit-20260906.json
new file mode 100644
index 0000000..614e683
--- /dev/null
+++ b/reports/role-permissions-20260906/data/role-permissions-audit-20260906.json
@@ -0,0 +1,483 @@
+{
+ "capturedAt": "2026-09-06T03:08:01.311Z",
+ "authorizationMode": "SINGLE_ACTIVE",
+ "permissions": [
+ {
+ "code": "dashboard.view",
+ "label": "工作总览",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "content.model",
+ "label": "模型制作",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "content.scene",
+ "label": "场景制作",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "content.training",
+ "label": "训练编排",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "content.ofd",
+ "label": "作业指导",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "teaching.virtual",
+ "label": "虚拟仿真",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "teaching.physical",
+ "label": "实装实训",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "teaching.confrontation",
+ "label": "对抗训练",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "teaching.guides",
+ "label": "作业指导书",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "teaching.immersive",
+ "label": "沉浸交互",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "teaching.exams",
+ "label": "考试测评",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "teaching.analytics",
+ "label": "数据中心",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "integration.resources",
+ "label": "资源管理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "integration.knowledge",
+ "label": "维修知识",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "integration.data-processing",
+ "label": "数据处理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "integration.ai-models",
+ "label": "智能模型管理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "integration.assistance",
+ "label": "智能辅助",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.users",
+ "label": "用户管理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.departments",
+ "label": "部门管理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.roles",
+ "label": "角色管理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.menus",
+ "label": "菜单管理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.permissions",
+ "label": "权限管理",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.audit",
+ "label": "审计日志",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.interfaces",
+ "label": "接口配置",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "system.config",
+ "label": "系统配置",
+ "type": "PAGE",
+ "enabled": true
+ },
+ {
+ "code": "content.create",
+ "label": "新建内容",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "content.update",
+ "label": "编辑内容",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "content.publish",
+ "label": "发布内容",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.tasks.create",
+ "label": "创建任务",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.tasks.publish",
+ "label": "发布任务",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.tasks.withdraw",
+ "label": "撤回任务",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.tasks.review",
+ "label": "评定成绩",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.tasks.archive",
+ "label": "归档任务",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.tasks.accept",
+ "label": "接收任务",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.tasks.submit",
+ "label": "提交结果",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.virtual.execute",
+ "label": "执行虚拟仿真",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.physical.execute",
+ "label": "执行实装实训",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.confrontation.configure",
+ "label": "配置对抗故障",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.confrontation.execute",
+ "label": "执行对抗训练",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.immersive.execute",
+ "label": "执行沉浸训练",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "teaching.physical.ingest",
+ "label": "接入实装事件",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.data-processing.manage",
+ "label": "管理数据处理",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.ai-models.manage",
+ "label": "管理AI配置",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.resources.manage",
+ "label": "维护资源目录",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.knowledge.manage",
+ "label": "管理维修知识",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.knowledge.reindex",
+ "label": "重建知识索引",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.data-processing.ingest",
+ "label": "接入设备数据",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.ai-models.test",
+ "label": "测试智能编排",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.assistance.monitor",
+ "label": "处置辅助预警",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "integration.assistance.intervene",
+ "label": "实施教学干预",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.users.create",
+ "label": "新增用户",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.users.update",
+ "label": "编辑用户",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.users.delete",
+ "label": "删除用户",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.users.reset-password",
+ "label": "重置密码",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.departments.create",
+ "label": "新增部门",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.departments.update",
+ "label": "编辑部门",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.departments.delete",
+ "label": "删除部门",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.roles.create",
+ "label": "新增角色",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.roles.update",
+ "label": "编辑角色",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.roles.delete",
+ "label": "删除角色",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.permissions.update",
+ "label": "配置权限",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.audit.export",
+ "label": "导出审计日志",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.interfaces.update",
+ "label": "修改服务配置",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.menus.update",
+ "label": "修改菜单配置",
+ "type": "ACTION",
+ "enabled": true
+ },
+ {
+ "code": "system.config.update",
+ "label": "修改系统配置",
+ "type": "ACTION",
+ "enabled": true
+ }
+ ],
+ "roles": [
+ {
+ "id": "1",
+ "code": "admin",
+ "name": "管理员",
+ "enabled": true,
+ "builtIn": true,
+ "superAdmin": true,
+ "dataScope": "ALL",
+ "userCount": 2,
+ "dynamicAll": true,
+ "permissionCodes": []
+ },
+ {
+ "id": "2",
+ "code": "teacher",
+ "name": "教员",
+ "enabled": true,
+ "builtIn": true,
+ "superAdmin": false,
+ "dataScope": "SYSTEM",
+ "userCount": 8,
+ "dynamicAll": false,
+ "permissionCodes": [
+ "teaching.physical.ingest",
+ "teaching.virtual",
+ "teaching.physical",
+ "teaching.confrontation",
+ "teaching.exams",
+ "content.create",
+ "content.update",
+ "content.publish",
+ "teaching.tasks.create",
+ "teaching.tasks.publish",
+ "teaching.tasks.withdraw",
+ "teaching.tasks.review",
+ "teaching.tasks.archive",
+ "teaching.virtual.execute",
+ "teaching.physical.execute",
+ "teaching.confrontation.configure",
+ "integration.assistance",
+ "teaching.confrontation.execute",
+ "teaching.immersive.execute",
+ "dashboard.view",
+ "content.model",
+ "content.scene",
+ "content.training",
+ "content.ofd",
+ "teaching.guides",
+ "teaching.immersive",
+ "teaching.analytics",
+ "integration.resources",
+ "integration.knowledge",
+ "integration.data-processing",
+ "integration.data-processing.ingest",
+ "integration.assistance.monitor",
+ "integration.assistance.intervene"
+ ]
+ },
+ {
+ "id": "3",
+ "code": "student",
+ "name": "学员",
+ "enabled": true,
+ "builtIn": true,
+ "superAdmin": false,
+ "dataScope": "SELF",
+ "userCount": 14,
+ "dynamicAll": false,
+ "permissionCodes": [
+ "teaching.virtual",
+ "teaching.physical",
+ "teaching.confrontation",
+ "teaching.exams",
+ "teaching.tasks.accept",
+ "teaching.tasks.submit",
+ "teaching.virtual.execute",
+ "teaching.physical.execute",
+ "teaching.confrontation.execute",
+ "teaching.immersive.execute",
+ "dashboard.view",
+ "teaching.guides",
+ "teaching.immersive",
+ "integration.knowledge"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/index.html b/reports/role-permissions-20260906/index.html
new file mode 100644
index 0000000..c75a356
--- /dev/null
+++ b/reports/role-permissions-20260906/index.html
@@ -0,0 +1,165 @@
+
+
权限与角色切换验收 · 2026-09-06
+
+装备数字车间 · 权限治理与工作身份
权限与角色切换验收报告
按资料、产品原型、设计、开发、测试梳理本轮调整。页面与操作使用真实权限;管理员采用固定授权;内部维护与普通业务隔离;工作身份切换保护未保存内容。
日期 2026-09-06 · 生成时间 2026年9月6日 12:11:22(北京时间) · 证据来自当前报告目录
+
+通过本轮记录的验收项已通过真实接口、浏览器操作与构建/测试记录分别列示;测试覆盖范围和 OFD 本地入口边界仍需结合下文阅读。
+真实 API · 权限主流程32 / 32接口断言,不计作浏览器用例
真实 API · 维护隔离边界39 / 39目录、审计、角色与写入保护
浏览器 E2E · 当前结果9 / 9已标记为最终完整运行
构建 / 单测 / DDL通过独立统计,详见验证记录
+
+01 · 资料
以旧系统实际能力为迁移边界
旧环境采用 SINGLE_ACTIVE。管理员曾动态获得全部叶子权限,但内容写又被教员身份门槛阻止;本轮把这种不一致改为明确的页面权限与操作权限。
| 旧角色 | 旧授权 | 数据范围 | 迁移依据 |
|---|
| 管理员 · admin | 动态全部叶子权限 | ALL | 旧权限集合全选,但内容写仍被教员身份门槛阻止。 |
| 教员 · teacher | 33 项显式权限 | SYSTEM | 保留原有制作、教学能力;未获授予的资源管理不自动新增。 |
| 学员 · student | 14 项显式权限 | SELF | 保留本人或本人队组的学习与执行能力。 |
动作合同记录 87 个新增动作和 14 个停用共用动作码。旧角色仅按原已持有权限和所属页面展开,自定义角色不自动补齐其他业务能力。
+
+02 · 产品原型
保持入口,把“能查看”和“能操作”说明白
页面与工作区
三个登录身份入口保留。列表的新建、复制、删除、发布按各类内容权限显示;没有修改权限的三维工作区仍可查看,编辑、导入、自动保存和恢复写入受到控制。
管理员授权界面展示固定清单,不能在普通角色编辑中改动固定模板。勾选动作自动补齐所属页面;取消页面先确认依赖动作。
头像与个人中心
只列出当前账号已分配且有效的工作身份。单角色生效模式下,切换前确认各工作区未保存内容;成功后重新加载身份、菜单和页签。
切换请求失败保留原页面。切换已提交但新身份加载失败时,不继续使用旧身份业务缓存。
+
+03 · 设计
页面、动作、身份和数据范围分别约束
| 内容类型 | 新建 / 复制 | 编辑 / 上传 / 封面 | 删除工程 | 发布 / 撤回发布 | 批准 / 驳回 |
|---|
| 模型 | content.model.create | content.model.update | content.model.delete | content.model.publish | 不新增审批功能 |
| 场景 | content.scene.create | content.scene.update | content.scene.delete | content.scene.publish | 不新增审批功能 |
| 训练编排 | content.training.create | content.training.update | content.training.delete | content.training.publish | 不新增审批功能 |
| 作业指导书 | content.ofd.create | content.ofd.update | content.ofd.delete | content.ofd.publish | content.ofd.review |
保存后发布需要修改与发布两项权限。只有发布权限时,提交已保存的服务端版本;OFD 还须复用与当前内容指纹一致的已保存交付物,缺失或过期时由编辑者处理。
| 迁移后角色 | 默认保留能力 | 明确边界 |
|---|
| 管理员 | 固定普通管理授权、系统配置与既有教学/资源管理 | 不自动拥有内容写、学员执行/提交或内部维护权限。 |
| 教员 | 原有四类内容制作、教学管理与运行能力 | 原无 manage 的资源/知识管理不因拆码获得 CRUD。 |
| 学员 | 本人/本人队组的领取、执行、提交、指导书学习与反馈 | SELF 范围、参与人、队伍、岗位和正式考试投影继续生效。 |
| 内部维护 | 受保护账号与有效内部角色共同确认后获得维护能力 | 普通人员/角色目录隐藏;内部审计与业务审计分别读取。 |
任务中的教员、学员、观察员成员身份与平台角色名称解耦;成员身份不授予平台动作权限。业务参与范围、固定版本、乐观锁、发布前置条件继续由服务端校验。
+
+04 · 开发
实现范围与需要保留的边界
- 内容四类动作、教学各渠道、资源管理真实操作分别授权;停用旧共用动作码,不设置永久兼容放行。
- 模型同源宿主桥接、场景与训练运行时、正式工作台及 OFD 本地入口同步只读和操作门控;模型静态生成包未在本轮重做。
- 所有工作区参与角色切换确认。场景加载或资源上传先阻止离开;已开始的截图上传、保存、发布等待完整结束再核查未保存状态。
- 前一工作区选择放弃、后一工作区取消时仍保留草稿。角色接口成功后整页进入总览,新启动重新取身份和菜单,页签按用户与角色隔离。
- 036 迁移固定管理员模板、细分已有角色能力并增加审计范围;维护凭据只经私有初始化流程设置,报告和 DDL 不包含凭据。
OFD 本地入口的边界默认 OFD 原型仍使用本地存储。本轮接入真实会话和本地写权限保护,未将整个原型业务迁移至 API;不能将前端限制描述成完整服务端隔离、跨设备协作或新增审批功能。
+
+05 · 测试
真实接口、浏览器、构建与单测分别验收
+A. 真实 API:权限配置与多角色主流程
通过 32 / 32 项通过 · JSON 证据
+ 逐项检查结果
| 检查项目 | 结果 | 补充证据 |
|---|
| root 使用管理员登录入口进入内部维护角色 | 通过 | |
| 普通角色目录不显示内部维护角色 | 通过 | |
| 管理员角色采用固定的受限授权 | 通过 | |
| 管理员授权已持久化,不动态获得全权限 | 通过 | |
| admin 无法通过接口修改固定管理员授权 | 通过 | |
| root 无法通过接口修改固定管理员授权 | 通过 | |
| admin 账号已取消 is_protected | 通过 | |
| 用户目录不显示 root 维护账号 | 通过 | |
| 普通账号按 ID 查询 root 受保护 | 通过 | |
| 普通账号按 ID 停用 root 受保护 | 通过 | |
| 普通账号按 ID 重置密码 root 受保护 | 通过 | |
| 普通账号按 ID 删除 root 受保护 | 通过 | |
| 管理员无法访问维护审计 | 通过 | |
| 普通审计不混入 root 登录与操作记录 | 通过 | |
| root 活动保留在可追溯的内部审计 | 通过 | |
| 目录提供模型独立动作与查看依赖 | 通过 | |
| 已移除旧的共用内容写权限 | 通过 | |
| 创建可配置的reader测试角色 | 通过 | |
| 保存reader细分授权 | 通过 | |
| 创建可配置的editor测试角色 | 通过 | |
| 保存editor细分授权 | 通过 | |
| 建立隔离的多角色测试账号 | 通过 | |
| 多角色测试账号正常登录 | 通过 | |
| 接口切换到reader角色 | 通过 | |
| reader当前身份独立生效 | 通过 | |
| reader拥有模型查看权限 | 通过 | |
| 只有查看权限时,直接新建接口返回403 | 通过 | |
| 接口切换到editor角色 | 通过 | |
| editor当前身份独立生效 | 通过 | |
| editor拥有模型查看权限 | 通过 | |
| 自定义非教员角色获得新增权限即可创建模型 | 通过 | |
| 普通管理员模型查看与创建权限相互独立 | 通过 | |
+
+B. 真实 API:内部维护与审计隔离边界
通过 39 / 39 项通过 · JSON 证据
+ 逐项检查结果
| 检查项目 | 结果 | 补充证据 |
|---|
| 维护账号使用已分配的内部角色,普通管理员不带维护标记 | 通过 | |
| admin 的普通角色目录和分页都隐藏内部角色 | 通过 | |
| admin 不能按已知内部角色 ID 读取/详情 | 通过 | HTTP 404 |
| admin 不能按已知内部角色 ID 读取/users | 通过 | HTTP 404 |
| admin 不能按已知内部角色 ID 读取/permissions | 通过 | HTTP 404 |
| root 的普通角色目录和分页都隐藏内部角色 | 通过 | |
| root 不能按已知内部角色 ID 读取/详情 | 通过 | HTTP 404 |
| root 不能按已知内部角色 ID 读取/users | 通过 | HTTP 404 |
| root 不能按已知内部角色 ID 读取/permissions | 通过 | HTTP 404 |
| 角色 admin 的关联用户排除维护账号 | 通过 | |
| 角色 administrative 的关联用户排除维护账号 | 通过 | |
| 角色 teacher 的关联用户排除维护账号 | 通过 | |
| 角色 student 的关联用户排除维护账号 | 通过 | |
| 角色 group_leader 的关联用户排除维护账号 | 通过 | |
| 角色 rv_reader_1e43e04e 的关联用户排除维护账号 | 通过 | |
| 角色 rv_editor_1e43e04e 的关联用户排除维护账号 | 通过 | |
| 教学人员搜索不暴露维护账号 | 通过 | |
| 教学人员按 ID 解析拒绝维护账号 | 通过 | HTTP 400 |
| 公开登录候选列表不暴露维护账号 | 通过 | |
| admin 无法通过普通用户接口给测试账号授予内部角色 | 通过 | HTTP 400 |
| root 无法通过普通用户接口给测试账号授予内部角色 | 通过 | HTTP 400 |
| 拒绝内部角色分配后测试账号原角色和版本保持不变 | 通过 | |
| admin 无法通过普通编辑更改固定管理员数据范围 | 通过 | HTTP 403 |
| admin 无法停用固定管理员角色 | 通过 | HTTP 403 |
| root 无法通过普通编辑更改固定管理员数据范围 | 通过 | HTTP 403 |
| root 无法停用固定管理员角色 | 通过 | HTTP 403 |
| 固定管理员角色状态、范围、版本未被边界请求改变 | 通过 | |
| 普通管理员不能读取维护审计/列表 | 通过 | HTTP 403 |
| 普通管理员不能读取维护审计/stats | 通过 | HTTP 403 |
| 普通管理员不能读取维护审计/export | 通过 | HTTP 403 |
| 已知维护账号错误密码和未知账号均返回统一认证失败 | 通过 | |
| 维护登录失败在普通列表不可见且内部审计保留真实 USER 目标 | 通过 | |
| 未知身份失败仍可在普通安全审计中查到 | 通过 | |
| 普通审计导出不包含维护记录,维护导出保留该记录 | 通过 | |
| 普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL | 通过 | |
| 普通用户和角色统计排除内部账号及内部角色 | 通过 | |
| 首页人数不计维护账号,最近动态不显示维护失败日志 | 通过 | |
| 数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS | 通过 | |
| 维护再次登录后普通 admin 仍未被重新保护 | 通过 | |
+
+C. 浏览器 E2E:可见交互与异常路径
+ 通过 9 / 9 项通过,0 项失败,0 项待完成/跳过/需复核 · 完整 JSON · 开始于 2026-09-06T04:01:43.840Z
+ | 浏览器用例 | 结果 | 耗时 | 失败证据 |
|---|
| 管理员授权固定,权限树细分到页面操作 | 通过 | 4.9 秒 | — |
| 权限依赖:勾选操作自动补齐查看,取消查看需要确认 | 通过 | 11.4 秒 | — |
| 头像弹窗切换自定义角色,清理旧缓存并改变操作能力 | 通过 | 6.2 秒 | — |
| 角色切换接口失败保留原身份与页面 | 通过 | 3.7 秒 | — |
| 切换已提交但身份加载失败,不展示旧角色业务页面 | 通过 | 5.1 秒 | — |
| 训练编排未保存时,取消切换保留当前内容与身份 | 通过 | 7.9 秒 | — |
| 未保存训练的保存请求失败时,阻止提交角色切换 | 通过 | 6.6 秒 | — |
| 权限加载乱序:关闭 A 后打开 B,A 的迟到响应不能覆盖 B | 通过 | 6.2 秒 | — |
| 保存授权期间禁止恢复,PUT 成功但列表刷新失败仍保留提交快照 | 通过 | 6.5 秒 | — |
+ 角色切换/身份获取/保存失败等异常路径使用浏览器定向响应模拟。它们验证前端保留状态与阻断行为,不表示真实服务发生过这些故障。
+D. 构建、聚焦单测与 DDL
完整验证记录。下方按实际执行的命令与结果展示,不把单测数量并入 E2E 或真实 API 数量。
{
+ "time": "2026-09-06T04:08:03.624366+00:00",
+ "overallStatus": "PASS",
+ "e2eComplete": true,
+ "build": {
+ "web": "PASS: pnpm build; Vite production build and postbuild 10 iframe payload checks",
+ "auth": "PASS: Maven test/package",
+ "tran": "PASS: Maven test/package"
+ },
+ "backendTests": {
+ "ut_auth": {
+ "tests": 112,
+ "failures": 0,
+ "errors": 0,
+ "skipped": 0
+ },
+ "ut_tran": {
+ "tests": 340,
+ "failures": 0,
+ "errors": 0,
+ "skipped": 0
+ }
+ },
+ "nodeTests": {
+ "contentCoverPermissions": {
+ "tests": 57,
+ "failed": 0,
+ "command": "node --test tools/content-permissions.test.mjs tools/training-cover.test.mjs tools/scene-cover-api.test.mjs"
+ },
+ "uiPermissionContracts": {
+ "tests": 12,
+ "failed": 0,
+ "command": "node --test scripts/permission-selection.contract.test.mjs scripts/teaching-permissions.test.mjs"
+ }
+ },
+ "realApi": {
+ "permissions": 32,
+ "maintenanceBoundaries": 39,
+ "failed": 0
+ },
+ "e2e": {
+ "complete": true,
+ "passed": 9,
+ "failed": 0,
+ "flaky": 0,
+ "skipped": 0,
+ "screenshots": 12,
+ "command": "pnpm exec playwright test --config role-permissions-live.config.ts"
+ },
+ "ddl": {
+ "status": "PASS",
+ "file": "20260906_036_role_permissions_maintenance.sql",
+ "sha256": "66d6c32edef315c8fcd9264352dda3444ddcdf1ca2d50d2af6f138fc7f4d4bd1",
+ "environment": "Development: unreal_tran / public, KingBase MySQL compatibility",
+ "execution": "200 statements committed; same SHA rerun committed successfully",
+ "catalogLeafCount": 138,
+ "adminFixedLeafCount": 106,
+ "fullDdlUpdated": true,
+ "executionRecord": "unreal_tran_api/database/迁移执行记录.md"
+ },
+ "runtime": {
+ "gateway": "http://127.0.0.1:6180",
+ "authPort": 6101,
+ "tranPort": 6102,
+ "deployedArtifacts": {
+ "auth": {
+ "sha256": "98aaeedfe29c8e75e99ef9421a3982ae52ab357eb8592c68a30dfc54e633ff85",
+ "runningCopyMatches": true
+ },
+ "tran": {
+ "sha256": "d66a38602e32c0db0c2680905f6a6461d9cd09ab12f4ba1481c87f99f583e828",
+ "runningCopyMatches": true
+ }
+ },
+ "rootBootstrap": "Initialized from the local account file; one-time bootstrap disabled on restart; root login verified afterward",
+ "adminProtected": false
+ },
+ "cleanup": {
+ "time": "2026-09-06T04:07:09.247Z",
+ "projects": [
+ "165"
+ ],
+ "users": [
+ "100"
+ ],
+ "roles": [
+ "20",
+ "21"
+ ],
+ "status": "PASS",
+ "postDeleteReads": "All 4 isolated fixture records return HTTP 404"
+ },
+ "artifactChecks": {
+ "status": "PASS",
+ "file": "artifact-checks.json",
+ "diffWhitespace": {
+ "unreal_tran_api": "PASS",
+ "unreal_tran_web": "PASS",
+ "ute2e": "PASS"
+ }
+ },
+ "scopeNotes": [
+ "Root login and operation audits are retained as INTERNAL; ordinary lists, statistics and exports exclude them.",
+ "Admin has fixed persisted grants; both admin and root cannot edit that built-in role permission set through the application.",
+ "OFD legacy editor continues to store its existing local browser data; session permission checks were added, not a new server persistence workflow.",
+ "Permission and role-switch tests are the focus; this run does not claim complete regression of every teaching or 3D business scenario.",
+ "Temporary test fixtures were deleted; existing training used for dirty-state checks was not saved.",
+ "Development environment only; no production deployment, Git commit or push performed."
+ ]
+}
+E. 临时测试数据与清理
清理证据。业务模型、场景与训练的保留/恢复情况以记录为准。
{
+ "time": "2026-09-06T04:07:09.247Z",
+ "projects": [
+ "165"
+ ],
+ "users": [
+ "100"
+ ],
+ "roles": [
+ "20",
+ "21"
+ ],
+ "status": "PASS",
+ "postDeleteReads": "All 4 isolated fixture records return HTTP 404"
+}
+截图证据 · 点击放大
截图只证明相应画面。标记为“过程截图”的文件未关联当前结果,不计入当前通过数量。

当前 E2E 通过01-admin-fixed-permissions.png
管理员固定授权
验收后于 12:10:50(北京时间)等待完整加载后补拍:管理员权限面板展示固定授权,模型操作节点与保存授权被禁用。补拍仅执行读取,原始 Playwright 附件保持原记录。

当前 E2E 通过02-action-adds-page-permission.png
操作自动补齐查看权限
勾选模型新建时,同步勾选模型页面,避免产生不可达的操作权限。

当前 E2E 通过03-removing-page-confirms-dependent-actions.png
取消页面权限需确认
移除模型查看时提示其依赖操作会一起移除,取消后保留原勾选。

当前 E2E 通过04-custom-reader-model-list.png
自定义只读角色·首次加载
截图捕获了只读状态,但列表仍有加载遮罩,不能单独证明完整列表已加载。模型查看返回 200、新建返回 403 的结论见真实 API 断言。

当前 E2E 通过05-avatar-role-switch-dialog.png
头像菜单切换角色
仅列出当前账号已分配的有效工作身份,并标识当前身份。

当前 E2E 通过06-custom-editor-model-list.png
切换后按新授权显示操作
切换至获得模型新增权限的自定义角色后,新建入口可见。

当前 E2E 通过07-failed-switch-keeps-current-role.png
切换接口失败保留原身份
注入角色接口 503 响应后显示短消息,原身份和当前页面保留。

当前 E2E 通过08-role-refresh-failure-clears-old-view.png
身份重取失败不回放旧页面
角色切换已提交但身份接口失败时回到登录入口,不继续显示旧身份业务页面。

当前 E2E 通过09-unsaved-training-blocks-role-switch.png
未保存训练阻止切换
修改训练名称后切换角色触发离开确认;取消时不提交角色切换请求,修改仍保留。

当前 E2E 通过09b-save-failure-blocks-role-switch.png
保存失败阻止角色切换
模拟训练保存接口 503 后保留原身份与输入,角色切换请求未提交。

当前 E2E 通过10-stale-permission-response-is-ignored.png
忽略过期权限响应
权限编辑切换上下文后的延迟响应不应覆盖当前角色。此截图的通过状态以关联测试记录为准。

当前 E2E 通过11-permission-save-survives-list-refresh-failure.png
保存成功后列表刷新失败
权限写入成功与随后列表刷新失败分别处理,避免误报授权未保存。此截图的通过状态以关联测试记录为准。
+
+证据文件
可随 Git 交付的材料
data/ 仅包含脱敏角色/权限快照与动作合同。本生成器不读取或复制账号配置、私有测试 fixtures、数据库备份、服务日志或会话凭据。
+
+
+
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/maintenance-boundaries-initial.json b/reports/role-permissions-20260906/maintenance-boundaries-initial.json
new file mode 100644
index 0000000..64662f1
--- /dev/null
+++ b/reports/role-permissions-20260906/maintenance-boundaries-initial.json
@@ -0,0 +1,174 @@
+{
+ "startedAt": "2026-09-06T03:47:57.270Z",
+ "mode": "LIVE_API",
+ "checks": [
+ {
+ "name": "维护账号使用已分配的内部角色,普通管理员不带维护标记",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 的普通角色目录和分页都隐藏内部角色",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 不能按已知内部角色 ID 读取/详情",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "admin 不能按已知内部角色 ID 读取/users",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "admin 不能按已知内部角色 ID 读取/permissions",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "root 的普通角色目录和分页都隐藏内部角色",
+ "result": "PASS"
+ },
+ {
+ "name": "root 不能按已知内部角色 ID 读取/详情",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "root 不能按已知内部角色 ID 读取/users",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "root 不能按已知内部角色 ID 读取/permissions",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "角色 admin 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 administrative 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 teacher 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 student 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 group_leader 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "教学人员搜索不暴露维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "教学人员按 ID 解析拒绝维护账号",
+ "result": "PASS",
+ "status": 400
+ },
+ {
+ "name": "公开登录候选列表不暴露维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 无法通过普通编辑更改固定管理员数据范围",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "admin 无法停用固定管理员角色",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "root 无法通过普通编辑更改固定管理员数据范围",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "root 无法停用固定管理员角色",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "固定管理员角色状态、范围、版本未被边界请求改变",
+ "result": "PASS"
+ },
+ {
+ "name": "普通管理员不能读取维护审计/列表",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "普通管理员不能读取维护审计/stats",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "普通管理员不能读取维护审计/export",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "已知维护账号错误密码和未知账号均返回统一认证失败",
+ "result": "PASS"
+ },
+ {
+ "name": "维护登录失败在普通列表不可见且内部审计保留真实 USER 目标",
+ "result": "PASS"
+ },
+ {
+ "name": "未知身份失败仍可在普通安全审计中查到",
+ "result": "PASS"
+ },
+ {
+ "name": "普通审计导出不包含维护记录,维护导出保留该记录",
+ "result": "PASS"
+ },
+ {
+ "name": "普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL",
+ "result": "PASS"
+ },
+ {
+ "name": "普通用户和角色统计排除内部账号及内部角色",
+ "result": "PASS"
+ },
+ {
+ "name": "首页人数不计维护账号,最近动态不显示维护失败日志",
+ "result": "PASS"
+ },
+ {
+ "name": "数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS",
+ "result": "PASS"
+ },
+ {
+ "name": "维护再次登录后普通 admin 仍未被重新保护",
+ "result": "PASS"
+ }
+ ],
+ "limitations": [
+ "首阶段不依赖隔离账号,内部角色赋值拒绝及测试账号版本保持由完整运行补测。"
+ ],
+ "databaseEvidence": {
+ "ordinaryUserCount": 23,
+ "ordinaryRoleCount": 7,
+ "businessAuditCount": 254,
+ "internalAuditCount": 8,
+ "knownFailureScope": "INTERNAL",
+ "unknownFailureScope": "BUSINESS",
+ "knownFailurePreservesTarget": true
+ },
+ "bootstrapReview": {
+ "result": "PASS",
+ "note": "初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。"
+ },
+ "status": "PASS_WITH_LIMITATIONS",
+ "finishedAt": "2026-09-06T03:48:35.682Z"
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/maintenance-boundaries.json b/reports/role-permissions-20260906/maintenance-boundaries.json
new file mode 100644
index 0000000..35c4ade
--- /dev/null
+++ b/reports/role-permissions-20260906/maintenance-boundaries.json
@@ -0,0 +1,194 @@
+{
+ "startedAt": "2026-09-06T03:49:40.457Z",
+ "mode": "LIVE_API",
+ "checks": [
+ {
+ "name": "维护账号使用已分配的内部角色,普通管理员不带维护标记",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 的普通角色目录和分页都隐藏内部角色",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 不能按已知内部角色 ID 读取/详情",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "admin 不能按已知内部角色 ID 读取/users",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "admin 不能按已知内部角色 ID 读取/permissions",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "root 的普通角色目录和分页都隐藏内部角色",
+ "result": "PASS"
+ },
+ {
+ "name": "root 不能按已知内部角色 ID 读取/详情",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "root 不能按已知内部角色 ID 读取/users",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "root 不能按已知内部角色 ID 读取/permissions",
+ "result": "PASS",
+ "status": 404
+ },
+ {
+ "name": "角色 admin 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 administrative 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 teacher 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 student 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 group_leader 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 rv_reader_1e43e04e 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "角色 rv_editor_1e43e04e 的关联用户排除维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "教学人员搜索不暴露维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "教学人员按 ID 解析拒绝维护账号",
+ "result": "PASS",
+ "status": 400
+ },
+ {
+ "name": "公开登录候选列表不暴露维护账号",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 无法通过普通用户接口给测试账号授予内部角色",
+ "result": "PASS",
+ "status": 400
+ },
+ {
+ "name": "root 无法通过普通用户接口给测试账号授予内部角色",
+ "result": "PASS",
+ "status": 400
+ },
+ {
+ "name": "拒绝内部角色分配后测试账号原角色和版本保持不变",
+ "result": "PASS"
+ },
+ {
+ "name": "admin 无法通过普通编辑更改固定管理员数据范围",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "admin 无法停用固定管理员角色",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "root 无法通过普通编辑更改固定管理员数据范围",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "root 无法停用固定管理员角色",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "固定管理员角色状态、范围、版本未被边界请求改变",
+ "result": "PASS"
+ },
+ {
+ "name": "普通管理员不能读取维护审计/列表",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "普通管理员不能读取维护审计/stats",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "普通管理员不能读取维护审计/export",
+ "result": "PASS",
+ "status": 403
+ },
+ {
+ "name": "已知维护账号错误密码和未知账号均返回统一认证失败",
+ "result": "PASS"
+ },
+ {
+ "name": "维护登录失败在普通列表不可见且内部审计保留真实 USER 目标",
+ "result": "PASS"
+ },
+ {
+ "name": "未知身份失败仍可在普通安全审计中查到",
+ "result": "PASS"
+ },
+ {
+ "name": "普通审计导出不包含维护记录,维护导出保留该记录",
+ "result": "PASS"
+ },
+ {
+ "name": "普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL",
+ "result": "PASS"
+ },
+ {
+ "name": "普通用户和角色统计排除内部账号及内部角色",
+ "result": "PASS"
+ },
+ {
+ "name": "首页人数不计维护账号,最近动态不显示维护失败日志",
+ "result": "PASS"
+ },
+ {
+ "name": "数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS",
+ "result": "PASS"
+ },
+ {
+ "name": "维护再次登录后普通 admin 仍未被重新保护",
+ "result": "PASS"
+ }
+ ],
+ "limitations": [],
+ "databaseEvidence": {
+ "ordinaryUserCount": 23,
+ "ordinaryRoleCount": 7,
+ "businessAuditCount": 260,
+ "internalAuditCount": 14,
+ "knownFailureScope": "INTERNAL",
+ "unknownFailureScope": "BUSINESS",
+ "knownFailurePreservesTarget": true
+ },
+ "bootstrapReview": {
+ "result": "PASS",
+ "note": "初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。"
+ },
+ "status": "PASS",
+ "finishedAt": "2026-09-06T03:49:58.984Z"
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/playwright/data/2ef992352e3aa78812f5a27cb34a2f262aca451d.png b/reports/role-permissions-20260906/playwright/data/2ef992352e3aa78812f5a27cb34a2f262aca451d.png
new file mode 100644
index 0000000..639e087
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/2ef992352e3aa78812f5a27cb34a2f262aca451d.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/39f0f61812255574f0af8f1610ec856c525dad4f.png b/reports/role-permissions-20260906/playwright/data/39f0f61812255574f0af8f1610ec856c525dad4f.png
new file mode 100644
index 0000000..4759675
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/39f0f61812255574f0af8f1610ec856c525dad4f.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/514a8a750aec96ce93fe5128868ef9abcecd360f.png b/reports/role-permissions-20260906/playwright/data/514a8a750aec96ce93fe5128868ef9abcecd360f.png
new file mode 100644
index 0000000..bce829c
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/514a8a750aec96ce93fe5128868ef9abcecd360f.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/630ebec259b5ca8308fbe81c943aaed136723587.png b/reports/role-permissions-20260906/playwright/data/630ebec259b5ca8308fbe81c943aaed136723587.png
new file mode 100644
index 0000000..1643803
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/630ebec259b5ca8308fbe81c943aaed136723587.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/71481d3d24cce0be22b6a6d72611ca7b0bfefd92.png b/reports/role-permissions-20260906/playwright/data/71481d3d24cce0be22b6a6d72611ca7b0bfefd92.png
new file mode 100644
index 0000000..1127c3c
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/71481d3d24cce0be22b6a6d72611ca7b0bfefd92.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/76a476856a5ec79a9d87da4fe81ab68e1f2b8ca6.png b/reports/role-permissions-20260906/playwright/data/76a476856a5ec79a9d87da4fe81ab68e1f2b8ca6.png
new file mode 100644
index 0000000..58c0732
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/76a476856a5ec79a9d87da4fe81ab68e1f2b8ca6.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/a83a688061e763fa93a9f4a288a56d20beb568fd.png b/reports/role-permissions-20260906/playwright/data/a83a688061e763fa93a9f4a288a56d20beb568fd.png
new file mode 100644
index 0000000..729a263
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/a83a688061e763fa93a9f4a288a56d20beb568fd.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/c976086006107b689e3a3f89bec3f7ba470a4d66.png b/reports/role-permissions-20260906/playwright/data/c976086006107b689e3a3f89bec3f7ba470a4d66.png
new file mode 100644
index 0000000..c047b9d
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/c976086006107b689e3a3f89bec3f7ba470a4d66.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/d62c1a3eda1e64e13460ce95980408c2f5b07414.png b/reports/role-permissions-20260906/playwright/data/d62c1a3eda1e64e13460ce95980408c2f5b07414.png
new file mode 100644
index 0000000..23e7d58
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/d62c1a3eda1e64e13460ce95980408c2f5b07414.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/efd0b6b975bf3918dc57d47aa384d59f031ac91f.png b/reports/role-permissions-20260906/playwright/data/efd0b6b975bf3918dc57d47aa384d59f031ac91f.png
new file mode 100644
index 0000000..62ae318
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/efd0b6b975bf3918dc57d47aa384d59f031ac91f.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/f03ce330c9008cb777514d6ce0782c46b6eceee9.png b/reports/role-permissions-20260906/playwright/data/f03ce330c9008cb777514d6ce0782c46b6eceee9.png
new file mode 100644
index 0000000..cd3e7fb
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/f03ce330c9008cb777514d6ce0782c46b6eceee9.png differ
diff --git a/reports/role-permissions-20260906/playwright/data/f55233504542037ff62174243f9ab44ecf65ba66.png b/reports/role-permissions-20260906/playwright/data/f55233504542037ff62174243f9ab44ecf65ba66.png
new file mode 100644
index 0000000..7b4bc95
Binary files /dev/null and b/reports/role-permissions-20260906/playwright/data/f55233504542037ff62174243f9ab44ecf65ba66.png differ
diff --git a/reports/role-permissions-20260906/playwright/index.html b/reports/role-permissions-20260906/playwright/index.html
new file mode 100644
index 0000000..74fb482
--- /dev/null
+++ b/reports/role-permissions-20260906/playwright/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/results.json b/reports/role-permissions-20260906/results.json
new file mode 100644
index 0000000..a53cb86
--- /dev/null
+++ b/reports/role-permissions-20260906/results.json
@@ -0,0 +1,541 @@
+{
+ "config": {
+ "configFile": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\role-permissions-live.config.ts",
+ "rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "forbidOnly": false,
+ "fullyParallel": false,
+ "globalSetup": null,
+ "globalTeardown": null,
+ "globalTimeout": 0,
+ "grep": {},
+ "grepInvert": null,
+ "maxFailures": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "preserveOutput": "always",
+ "reporter": [
+ [
+ "list",
+ null
+ ],
+ [
+ "html",
+ {
+ "outputFolder": "reports/role-permissions-20260906/playwright",
+ "open": "never"
+ }
+ ],
+ [
+ "json",
+ {
+ "outputFile": "reports/role-permissions-20260906/results.json"
+ }
+ ]
+ ],
+ "reportSlowTests": {
+ "max": 5,
+ "threshold": 300000
+ },
+ "quiet": false,
+ "projects": [
+ {
+ "outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/role-permissions-20260906/test-results",
+ "repeatEach": 1,
+ "retries": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "id": "",
+ "name": "",
+ "testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "testIgnore": [],
+ "testMatch": [
+ "role-permissions-live.spec.ts",
+ "role-permissions-races.spec.ts"
+ ],
+ "timeout": 90000
+ }
+ ],
+ "shard": null,
+ "updateSnapshots": "missing",
+ "updateSourceMethod": "patch",
+ "version": "1.55.0",
+ "workers": 1,
+ "webServer": null
+ },
+ "suites": [
+ {
+ "title": "role-permissions-live.spec.ts",
+ "file": "role-permissions-live.spec.ts",
+ "column": 0,
+ "line": 0,
+ "specs": [
+ {
+ "title": "管理员授权固定,权限树细分到页面操作",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 4890,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-06T04:01:44.991Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "01-admin-fixed-permissions",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-管理员授权固定,权限树细分到页面操作\\attachments\\01-admin-fixed-permissions-a7dbf6b3e095f684c40ab84f44679efd6782130c.png"
+ },
+ {
+ "name": "sanitized-api-failures",
+ "contentType": "application/json",
+ "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifV0="
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "6358900fc7e9a59113e1-9dcc2bf9f0cc9a70dee9",
+ "file": "role-permissions-live.spec.ts",
+ "line": 61,
+ "column": 1
+ },
+ {
+ "title": "权限依赖:勾选操作自动补齐查看,取消查看需要确认",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 11397,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-06T04:01:50.388Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "02-action-adds-page-permission",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认\\attachments\\02-action-adds-page-permission-7711c2cf7fb63f1e3f92afd44a723fc1e6e47df8.png"
+ },
+ {
+ "name": "03-removing-page-confirms-dependent-actions",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认\\attachments\\03-removing-page-confirms-dependent-actions-0f1864b58272a29fc74d69df297edd82dc941471.png"
+ },
+ {
+ "name": "sanitized-api-failures",
+ "contentType": "application/json",
+ "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifV0="
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "6358900fc7e9a59113e1-4b3691030af7af361df9",
+ "file": "role-permissions-live.spec.ts",
+ "line": 75,
+ "column": 1
+ },
+ {
+ "title": "头像弹窗切换自定义角色,清理旧缓存并改变操作能力",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 6209,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-06T04:02:01.805Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-custom-reader-model-list",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力\\attachments\\04-custom-reader-model-list-f3116a1108bad705793d298a084fd84d32c45616.png"
+ },
+ {
+ "name": "05-avatar-role-switch-dialog",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力\\attachments\\05-avatar-role-switch-dialog-be8e9c76621a69cac0d2c6d56ffe05126177d602.png"
+ },
+ {
+ "name": "06-custom-editor-model-list",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力\\attachments\\06-custom-editor-model-list-537906d386cfab21188de6e97acba72dc947dd1c.png"
+ },
+ {
+ "name": "sanitized-api-failures",
+ "contentType": "application/json",
+ "body": "W3sicGF0aCI6Ii9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzLzE1Ni9jb3ZlciIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNTcvY292ZXIiLCJzdGF0dXMiOjUwM30seyJwYXRoIjoiL2FwaS92MS9maWxlcy8wZDg4NjRhNGZlOGY0NmMyYmY0ZmQ0OTAyZjFiMzY0Ni9jb250ZW50Iiwic3RhdHVzIjo0MDR9LHsicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsImVycm9yIjoibmV0OjpFUlJfQUJPUlRFRCJ9LHsicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJzdGF0dXMiOjQwNH0seyJwYXRoIjoiL2FwaS92MS9maWxlcy8wZDg4NjRhNGZlOGY0NmMyYmY0ZmQ0OTAyZjFiMzY0Ni9jb250ZW50IiwiZXJyb3IiOiJuZXQ6OkVSUl9BQk9SVEVEIn0seyJwYXRoIjoiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifV0="
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "6358900fc7e9a59113e1-0620d79fdeab76b867de",
+ "file": "role-permissions-live.spec.ts",
+ "line": 112,
+ "column": 1
+ },
+ {
+ "title": "角色切换接口失败保留原身份与页面",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 3666,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-06T04:02:08.032Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "07-failed-switch-keeps-current-role",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-角色切换接口失败保留原身份与页面\\attachments\\07-failed-switch-keeps-current-role-316057e08801988273f288a039d5591aff4f6f31.png"
+ },
+ {
+ "name": "sanitized-api-failures",
+ "contentType": "application/json",
+ "body": "W3sicGF0aCI6Ii9hcGkvYXV0aC92MS9hdXRoL2FjdGl2ZS1yb2xlIiwic3RhdHVzIjo1MDN9XQ=="
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "6358900fc7e9a59113e1-13060bac3e155c7abc43",
+ "file": "role-permissions-live.spec.ts",
+ "line": 135,
+ "column": 1
+ },
+ {
+ "title": "切换已提交但身份加载失败,不展示旧角色业务页面",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 5116,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-06T04:02:11.711Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "08-role-refresh-failure-clears-old-view",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-切换已提交但身份加载失败,不展示旧角色业务页面\\attachments\\08-role-refresh-failure-clears-old-view-849ba5b0acb05d9d8d115afcfda3644f0a4dc52b.png"
+ },
+ {
+ "name": "sanitized-api-failures",
+ "contentType": "application/json",
+ "body": "W3sicGF0aCI6Ii9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzLzE1Ni9jb3ZlciIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNTcvY292ZXIiLCJzdGF0dXMiOjUwM30seyJwYXRoIjoiL2FwaS92MS9maWxlcy8wZDg4NjRhNGZlOGY0NmMyYmY0ZmQ0OTAyZjFiMzY0Ni9jb250ZW50Iiwic3RhdHVzIjo0MDR9LHsicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsImVycm9yIjoibmV0OjpFUlJfQUJPUlRFRCJ9LHsicGF0aCI6Ii9hcGkvYXV0aC92MS9hdXRoL21lIiwic3RhdHVzIjo1MDN9XQ=="
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "6358900fc7e9a59113e1-be7827a263424b81f5ff",
+ "file": "role-permissions-live.spec.ts",
+ "line": 148,
+ "column": 1
+ },
+ {
+ "title": "训练编排未保存时,取消切换保留当前内容与身份",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 7870,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-06T04:02:16.842Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "09-unsaved-training-blocks-role-switch",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-训练编排未保存时,取消切换保留当前内容与身份\\attachments\\09-unsaved-training-blocks-role-switch-395fba2e761d73a715ad9e1bca03e75a60582a98.png"
+ },
+ {
+ "name": "sanitized-api-failures",
+ "contentType": "application/json",
+ "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfV0="
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "6358900fc7e9a59113e1-03eef81e9aecf1e22039",
+ "file": "role-permissions-live.spec.ts",
+ "line": 160,
+ "column": 1
+ },
+ {
+ "title": "未保存训练的保存请求失败时,阻止提交角色切换",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 6604,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBe\"",
+ "duration": 1
+ }
+ ],
+ "startTime": "2026-09-06T04:02:24.729Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "09b-save-failure-blocks-role-switch",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-未保存训练的保存请求失败时,阻止提交角色切换\\attachments\\09b-save-failure-blocks-role-switch-c412151ca410dece5b637c443359b2baccb8aa42.png"
+ },
+ {
+ "name": "sanitized-api-failures",
+ "contentType": "application/json",
+ "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjQiLCJzdGF0dXMiOjUwM31d"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "6358900fc7e9a59113e1-27778e25c714dcc2239e",
+ "file": "role-permissions-live.spec.ts",
+ "line": 185,
+ "column": 1
+ }
+ ]
+ },
+ {
+ "title": "role-permissions-races.spec.ts",
+ "file": "role-permissions-races.spec.ts",
+ "column": 0,
+ "line": 0,
+ "specs": [
+ {
+ "title": "权限加载乱序:关闭 A 后打开 B,A 的迟到响应不能覆盖 B",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 6210,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toEqual\"",
+ "duration": 437
+ }
+ ],
+ "startTime": "2026-09-06T04:02:31.380Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "10-stale-permission-response-is-ignored",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-races-权限加载乱序:关闭-A-后打开-B,A-的迟到响应不能覆盖-B\\attachments\\10-stale-permission-response-is-ignored-bf5697b3945ce8d19e7fb514c307274a0c21551a.png"
+ },
+ {
+ "name": "10-stale-permission-response-is-ignored-evidence",
+ "contentType": "application/json",
+ "body": "ewogICJkZWxheWVkUm9sZSI6ICJydl9yZWFkZXJfMWU0M2UwNGUiLAogICJjdXJyZW50Um9sZSI6ICJydl9lZGl0b3JfMWU0M2UwNGUiLAogICJkZWxheWVkUGVybWlzc2lvbkNvZGVzIjogWwogICAgImNvbnRlbnQubW9kZWwiLAogICAgImRhc2hib2FyZC52aWV3IgogIF0sCiAgImRpc3BsYXllZFBlcm1pc3Npb25Db2RlcyI6IFsKICAgICJjb250ZW50Lm1vZGVsIiwKICAgICJjb250ZW50Lm1vZGVsLmNyZWF0ZSIsCiAgICAiY29udGVudC5tb2RlbC5kZWxldGUiLAogICAgImNvbnRlbnQubW9kZWwucHVibGlzaCIsCiAgICAiY29udGVudC5tb2RlbC51cGRhdGUiLAogICAgImRhc2hib2FyZC52aWV3IgogIF0sCiAgImJ1c2luZXNzV3JpdGVzIjogMAp9"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "d51c522ad2ea451f8c2b-14a99b2fcefe3f44dfbf",
+ "file": "role-permissions-races.spec.ts",
+ "line": 47,
+ "column": 1
+ },
+ {
+ "title": "保存授权期间禁止恢复,PUT 成功但列表刷新失败仍保留提交快照",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 6511,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toEqual\"",
+ "duration": 960
+ },
+ {
+ "title": "Expect \"poll toEqual\"",
+ "duration": 7
+ }
+ ],
+ "startTime": "2026-09-06T04:02:37.608Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "11-permission-save-survives-list-refresh-failure",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-races-保存授权期间禁止恢复,PUT-成功但列表刷新失败仍保留提交快照\\attachments\\11-permission-save-survives-list-refresh-failure-8b98f81877c4a0dd52bd2b8bf7557f3304da6916.png"
+ },
+ {
+ "name": "11-permission-save-survives-list-refresh-failure-evidence",
+ "contentType": "application/json",
+ "body": "ewogICJ0ZW1wb3JhcnlSb2xlIjogInJ2X3JlYWRlcl8xZTQzZTA0ZSIsCiAgIm9yaWdpbmFsUGVybWlzc2lvbkNvZGVzIjogWwogICAgImNvbnRlbnQubW9kZWwiLAogICAgImRhc2hib2FyZC52aWV3IgogIF0sCiAgInN1Ym1pdHRlZFBlcm1pc3Npb25Db2RlcyI6IFsKICAgICJjb250ZW50Lm1vZGVsIiwKICAgICJjb250ZW50Lm1vZGVsLmNyZWF0ZSIsCiAgICAiZGFzaGJvYXJkLnZpZXciCiAgXSwKICAicmVhbFB1dFN0YXR1cyI6IDIwMCwKICAiaW5qZWN0ZWRMaXN0UmVmcmVzaFN0YXR1cyI6IDUwMywKICAicmVzZXREaXNhYmxlZFdoaWxlU2F2aW5nIjogdHJ1ZQp9"
+ },
+ {
+ "name": "11-temporary-reader-restored",
+ "contentType": "application/json",
+ "body": "eyJyb2xlQ29kZSI6InJ2X3JlYWRlcl8xZTQzZTA0ZSIsInJlc3RvcmVkIjp0cnVlLCJwZXJtaXNzaW9uQ29kZXMiOlsiY29udGVudC5tb2RlbCIsImRhc2hib2FyZC52aWV3Il19"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "d51c522ad2ea451f8c2b-cdb3c98b056833e4a102",
+ "file": "role-permissions-races.spec.ts",
+ "line": 102,
+ "column": 1
+ }
+ ]
+ }
+ ],
+ "errors": [],
+ "stats": {
+ "startTime": "2026-09-06T04:01:43.840Z",
+ "duration": 91148.27900000001,
+ "expected": 9,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ }
+}
\ No newline at end of file
diff --git a/reports/role-permissions-20260906/screenshots/01-admin-fixed-permissions.png b/reports/role-permissions-20260906/screenshots/01-admin-fixed-permissions.png
new file mode 100644
index 0000000..a0083c4
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/01-admin-fixed-permissions.png differ
diff --git a/reports/role-permissions-20260906/screenshots/02-action-adds-page-permission.png b/reports/role-permissions-20260906/screenshots/02-action-adds-page-permission.png
new file mode 100644
index 0000000..58c0732
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/02-action-adds-page-permission.png differ
diff --git a/reports/role-permissions-20260906/screenshots/03-removing-page-confirms-dependent-actions.png b/reports/role-permissions-20260906/screenshots/03-removing-page-confirms-dependent-actions.png
new file mode 100644
index 0000000..c047b9d
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/03-removing-page-confirms-dependent-actions.png differ
diff --git a/reports/role-permissions-20260906/screenshots/04-custom-reader-model-list.png b/reports/role-permissions-20260906/screenshots/04-custom-reader-model-list.png
new file mode 100644
index 0000000..4759675
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/04-custom-reader-model-list.png differ
diff --git a/reports/role-permissions-20260906/screenshots/05-avatar-role-switch-dialog.png b/reports/role-permissions-20260906/screenshots/05-avatar-role-switch-dialog.png
new file mode 100644
index 0000000..1127c3c
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/05-avatar-role-switch-dialog.png differ
diff --git a/reports/role-permissions-20260906/screenshots/06-custom-editor-model-list.png b/reports/role-permissions-20260906/screenshots/06-custom-editor-model-list.png
new file mode 100644
index 0000000..639e087
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/06-custom-editor-model-list.png differ
diff --git a/reports/role-permissions-20260906/screenshots/07-failed-switch-keeps-current-role.png b/reports/role-permissions-20260906/screenshots/07-failed-switch-keeps-current-role.png
new file mode 100644
index 0000000..cd3e7fb
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/07-failed-switch-keeps-current-role.png differ
diff --git a/reports/role-permissions-20260906/screenshots/08-role-refresh-failure-clears-old-view.png b/reports/role-permissions-20260906/screenshots/08-role-refresh-failure-clears-old-view.png
new file mode 100644
index 0000000..1643803
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/08-role-refresh-failure-clears-old-view.png differ
diff --git a/reports/role-permissions-20260906/screenshots/09-unsaved-training-blocks-role-switch.png b/reports/role-permissions-20260906/screenshots/09-unsaved-training-blocks-role-switch.png
new file mode 100644
index 0000000..62ae318
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/09-unsaved-training-blocks-role-switch.png differ
diff --git a/reports/role-permissions-20260906/screenshots/09b-save-failure-blocks-role-switch.png b/reports/role-permissions-20260906/screenshots/09b-save-failure-blocks-role-switch.png
new file mode 100644
index 0000000..bce829c
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/09b-save-failure-blocks-role-switch.png differ
diff --git a/reports/role-permissions-20260906/screenshots/10-stale-permission-response-is-ignored.png b/reports/role-permissions-20260906/screenshots/10-stale-permission-response-is-ignored.png
new file mode 100644
index 0000000..23e7d58
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/10-stale-permission-response-is-ignored.png differ
diff --git a/reports/role-permissions-20260906/screenshots/11-permission-save-survives-list-refresh-failure.png b/reports/role-permissions-20260906/screenshots/11-permission-save-survives-list-refresh-failure.png
new file mode 100644
index 0000000..7b4bc95
Binary files /dev/null and b/reports/role-permissions-20260906/screenshots/11-permission-save-survives-list-refresh-failure.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-切换已提交但身份加载失败,不展示旧角色业务页面/attachments/08-role-refresh-failure-clears-old-view-849ba5b0acb05d9d8d115afcfda3644f0a4dc52b.png b/reports/role-permissions-20260906/test-results/role-permissions-live-切换已提交但身份加载失败,不展示旧角色业务页面/attachments/08-role-refresh-failure-clears-old-view-849ba5b0acb05d9d8d115afcfda3644f0a4dc52b.png
new file mode 100644
index 0000000..1643803
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-切换已提交但身份加载失败,不展示旧角色业务页面/attachments/08-role-refresh-failure-clears-old-view-849ba5b0acb05d9d8d115afcfda3644f0a4dc52b.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/04-custom-reader-model-list-f3116a1108bad705793d298a084fd84d32c45616.png b/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/04-custom-reader-model-list-f3116a1108bad705793d298a084fd84d32c45616.png
new file mode 100644
index 0000000..4759675
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/04-custom-reader-model-list-f3116a1108bad705793d298a084fd84d32c45616.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/05-avatar-role-switch-dialog-be8e9c76621a69cac0d2c6d56ffe05126177d602.png b/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/05-avatar-role-switch-dialog-be8e9c76621a69cac0d2c6d56ffe05126177d602.png
new file mode 100644
index 0000000..1127c3c
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/05-avatar-role-switch-dialog-be8e9c76621a69cac0d2c6d56ffe05126177d602.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/06-custom-editor-model-list-537906d386cfab21188de6e97acba72dc947dd1c.png b/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/06-custom-editor-model-list-537906d386cfab21188de6e97acba72dc947dd1c.png
new file mode 100644
index 0000000..639e087
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力/attachments/06-custom-editor-model-list-537906d386cfab21188de6e97acba72dc947dd1c.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-未保存训练的保存请求失败时,阻止提交角色切换/attachments/09b-save-failure-blocks-role-switch-c412151ca410dece5b637c443359b2baccb8aa42.png b/reports/role-permissions-20260906/test-results/role-permissions-live-未保存训练的保存请求失败时,阻止提交角色切换/attachments/09b-save-failure-blocks-role-switch-c412151ca410dece5b637c443359b2baccb8aa42.png
new file mode 100644
index 0000000..bce829c
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-未保存训练的保存请求失败时,阻止提交角色切换/attachments/09b-save-failure-blocks-role-switch-c412151ca410dece5b637c443359b2baccb8aa42.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认/attachments/02-action-adds-page-permission-7711c2cf7fb63f1e3f92afd44a723fc1e6e47df8.png b/reports/role-permissions-20260906/test-results/role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认/attachments/02-action-adds-page-permission-7711c2cf7fb63f1e3f92afd44a723fc1e6e47df8.png
new file mode 100644
index 0000000..58c0732
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认/attachments/02-action-adds-page-permission-7711c2cf7fb63f1e3f92afd44a723fc1e6e47df8.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认/attachments/03-removing-page-confirms-dependent-actions-0f1864b58272a29fc74d69df297edd82dc941471.png b/reports/role-permissions-20260906/test-results/role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认/attachments/03-removing-page-confirms-dependent-actions-0f1864b58272a29fc74d69df297edd82dc941471.png
new file mode 100644
index 0000000..c047b9d
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认/attachments/03-removing-page-confirms-dependent-actions-0f1864b58272a29fc74d69df297edd82dc941471.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-管理员授权固定,权限树细分到页面操作/attachments/01-admin-fixed-permissions-a7dbf6b3e095f684c40ab84f44679efd6782130c.png b/reports/role-permissions-20260906/test-results/role-permissions-live-管理员授权固定,权限树细分到页面操作/attachments/01-admin-fixed-permissions-a7dbf6b3e095f684c40ab84f44679efd6782130c.png
new file mode 100644
index 0000000..729a263
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-管理员授权固定,权限树细分到页面操作/attachments/01-admin-fixed-permissions-a7dbf6b3e095f684c40ab84f44679efd6782130c.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-角色切换接口失败保留原身份与页面/attachments/07-failed-switch-keeps-current-role-316057e08801988273f288a039d5591aff4f6f31.png b/reports/role-permissions-20260906/test-results/role-permissions-live-角色切换接口失败保留原身份与页面/attachments/07-failed-switch-keeps-current-role-316057e08801988273f288a039d5591aff4f6f31.png
new file mode 100644
index 0000000..cd3e7fb
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-角色切换接口失败保留原身份与页面/attachments/07-failed-switch-keeps-current-role-316057e08801988273f288a039d5591aff4f6f31.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-live-训练编排未保存时,取消切换保留当前内容与身份/attachments/09-unsaved-training-blocks-role-switch-395fba2e761d73a715ad9e1bca03e75a60582a98.png b/reports/role-permissions-20260906/test-results/role-permissions-live-训练编排未保存时,取消切换保留当前内容与身份/attachments/09-unsaved-training-blocks-role-switch-395fba2e761d73a715ad9e1bca03e75a60582a98.png
new file mode 100644
index 0000000..62ae318
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-live-训练编排未保存时,取消切换保留当前内容与身份/attachments/09-unsaved-training-blocks-role-switch-395fba2e761d73a715ad9e1bca03e75a60582a98.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-races-保存授权期间禁止恢复,PUT-成功但列表刷新失败仍保留提交快照/attachments/11-permission-save-survives-list-refresh-failure-8b98f81877c4a0dd52bd2b8bf7557f3304da6916.png b/reports/role-permissions-20260906/test-results/role-permissions-races-保存授权期间禁止恢复,PUT-成功但列表刷新失败仍保留提交快照/attachments/11-permission-save-survives-list-refresh-failure-8b98f81877c4a0dd52bd2b8bf7557f3304da6916.png
new file mode 100644
index 0000000..7b4bc95
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-races-保存授权期间禁止恢复,PUT-成功但列表刷新失败仍保留提交快照/attachments/11-permission-save-survives-list-refresh-failure-8b98f81877c4a0dd52bd2b8bf7557f3304da6916.png differ
diff --git a/reports/role-permissions-20260906/test-results/role-permissions-races-权限加载乱序:关闭-A-后打开-B,A-的迟到响应不能覆盖-B/attachments/10-stale-permission-response-is-ignored-bf5697b3945ce8d19e7fb514c307274a0c21551a.png b/reports/role-permissions-20260906/test-results/role-permissions-races-权限加载乱序:关闭-A-后打开-B,A-的迟到响应不能覆盖-B/attachments/10-stale-permission-response-is-ignored-bf5697b3945ce8d19e7fb514c307274a0c21551a.png
new file mode 100644
index 0000000..23e7d58
Binary files /dev/null and b/reports/role-permissions-20260906/test-results/role-permissions-races-权限加载乱序:关闭-A-后打开-B,A-的迟到响应不能覆盖-B/attachments/10-stale-permission-response-is-ignored-bf5697b3945ce8d19e7fb514c307274a0c21551a.png differ
diff --git a/reports/role-permissions-20260906/validation.json b/reports/role-permissions-20260906/validation.json
new file mode 100644
index 0000000..8803539
--- /dev/null
+++ b/reports/role-permissions-20260906/validation.json
@@ -0,0 +1,110 @@
+{
+ "time": "2026-09-06T04:08:03.624366+00:00",
+ "overallStatus": "PASS",
+ "e2eComplete": true,
+ "build": {
+ "web": "PASS: pnpm build; Vite production build and postbuild 10 iframe payload checks",
+ "auth": "PASS: Maven test/package",
+ "tran": "PASS: Maven test/package"
+ },
+ "backendTests": {
+ "ut_auth": {
+ "tests": 112,
+ "failures": 0,
+ "errors": 0,
+ "skipped": 0
+ },
+ "ut_tran": {
+ "tests": 340,
+ "failures": 0,
+ "errors": 0,
+ "skipped": 0
+ }
+ },
+ "nodeTests": {
+ "contentCoverPermissions": {
+ "tests": 57,
+ "failed": 0,
+ "command": "node --test tools/content-permissions.test.mjs tools/training-cover.test.mjs tools/scene-cover-api.test.mjs"
+ },
+ "uiPermissionContracts": {
+ "tests": 12,
+ "failed": 0,
+ "command": "node --test scripts/permission-selection.contract.test.mjs scripts/teaching-permissions.test.mjs"
+ }
+ },
+ "realApi": {
+ "permissions": 32,
+ "maintenanceBoundaries": 39,
+ "failed": 0
+ },
+ "e2e": {
+ "complete": true,
+ "passed": 9,
+ "failed": 0,
+ "flaky": 0,
+ "skipped": 0,
+ "screenshots": 12,
+ "command": "pnpm exec playwright test --config role-permissions-live.config.ts"
+ },
+ "ddl": {
+ "status": "PASS",
+ "file": "20260906_036_role_permissions_maintenance.sql",
+ "sha256": "66d6c32edef315c8fcd9264352dda3444ddcdf1ca2d50d2af6f138fc7f4d4bd1",
+ "environment": "Development: unreal_tran / public, KingBase MySQL compatibility",
+ "execution": "200 statements committed; same SHA rerun committed successfully",
+ "catalogLeafCount": 138,
+ "adminFixedLeafCount": 106,
+ "fullDdlUpdated": true,
+ "executionRecord": "unreal_tran_api/database/迁移执行记录.md"
+ },
+ "runtime": {
+ "gateway": "http://127.0.0.1:6180",
+ "authPort": 6101,
+ "tranPort": 6102,
+ "deployedArtifacts": {
+ "auth": {
+ "sha256": "98aaeedfe29c8e75e99ef9421a3982ae52ab357eb8592c68a30dfc54e633ff85",
+ "runningCopyMatches": true
+ },
+ "tran": {
+ "sha256": "d66a38602e32c0db0c2680905f6a6461d9cd09ab12f4ba1481c87f99f583e828",
+ "runningCopyMatches": true
+ }
+ },
+ "rootBootstrap": "Initialized from the local account file; one-time bootstrap disabled on restart; root login verified afterward",
+ "adminProtected": false
+ },
+ "cleanup": {
+ "time": "2026-09-06T04:07:09.247Z",
+ "projects": [
+ "165"
+ ],
+ "users": [
+ "100"
+ ],
+ "roles": [
+ "20",
+ "21"
+ ],
+ "status": "PASS",
+ "postDeleteReads": "All 4 isolated fixture records return HTTP 404"
+ },
+ "artifactChecks": {
+ "status": "PASS",
+ "file": "artifact-checks.json",
+ "diffWhitespace": {
+ "unreal_tran_api": "PASS",
+ "unreal_tran_web": "PASS",
+ "ute2e": "PASS"
+ }
+ },
+ "scopeNotes": [
+ "Root login and operation audits are retained as INTERNAL; ordinary lists, statistics and exports exclude them.",
+ "Admin has fixed persisted grants; both admin and root cannot edit that built-in role permission set through the application.",
+ "OFD legacy editor continues to store its existing local browser data; session permission checks were added, not a new server persistence workflow.",
+ "Permission and role-switch tests are the focus; this run does not claim complete regression of every teaching or 3D business scenario.",
+ "Temporary test fixtures were deleted; existing training used for dirty-state checks was not saved.",
+ "Development environment only; no production deployment, Git commit or push performed."
+ ]
+}
\ No newline at end of file
diff --git a/role-permissions-live.config.ts b/role-permissions-live.config.ts
new file mode 100644
index 0000000..9cbeb56
--- /dev/null
+++ b/role-permissions-live.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './tests', testMatch: ['role-permissions-live.spec.ts', 'role-permissions-races.spec.ts'],
+ outputDir: './reports/role-permissions-20260906/test-results',
+ workers: 1, fullyParallel: false, retries: 0, timeout: 90_000,
+ expect: { timeout: 20_000 },
+ reporter: [
+ ['list'],
+ ['html', { outputFolder: 'reports/role-permissions-20260906/playwright', open: 'never' }],
+ ['json', { outputFile: 'reports/role-permissions-20260906/results.json' }],
+ ],
+ use: {
+ baseURL: 'http://127.0.0.1:6180', browserName: 'chromium', channel: 'msedge', headless: true,
+ locale: 'zh-CN', timezoneId: 'Asia/Shanghai', viewport: { width: 1600, height: 1000 },
+ navigationTimeout: 45_000, actionTimeout: 20_000,
+ screenshot: 'only-on-failure', trace: 'off', video: 'off',
+ },
+})
diff --git a/tests/role-permissions-live.spec.ts b/tests/role-permissions-live.spec.ts
new file mode 100644
index 0000000..1e252f0
--- /dev/null
+++ b/tests/role-permissions-live.spec.ts
@@ -0,0 +1,213 @@
+import { test, expect, type Page, type TestInfo } from '@playwright/test'
+import fs from 'node:fs'
+import path from 'node:path'
+// Local helper reads credentials in memory. Reports and Playwright traces never contain them.
+// @ts-ignore JS helper is also executable independently for API checks.
+import { call, login, work } from '../tools/role-permissions-live.mjs'
+
+const shots = path.resolve('reports/role-permissions-20260906/screenshots')
+fs.mkdirSync(shots, { recursive: true })
+const fixture = () => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8'))
+const networkFailures = new WeakMap>()
+test.beforeEach(async ({ page }) => {
+ const failures: Array<{ path: string; status?: number; error?: string }> = []
+ networkFailures.set(page, failures)
+ page.on('requestfailed', request => {
+ if (new URL(request.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(request.url()).pathname, error: request.failure()?.errorText })
+ })
+ page.on('response', response => {
+ if (response.status() >= 400 && new URL(response.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(response.url()).pathname, status: response.status() })
+ })
+})
+test.afterEach(async ({ page }, info) => {
+ await info.attach('sanitized-api-failures', { body: JSON.stringify(networkFailures.get(page) ?? []), contentType: 'application/json' })
+ const token = await page.evaluate(() => sessionStorage.getItem('unreal-tran:web:access-token:v1') || localStorage.getItem('unreal-tran:web:access-token:v1')).catch(() => null)
+ if (token) await call('auth/v1/auth/logout', token, {}).catch(() => undefined)
+})
+const shot = async (page: Page, info: TestInfo, name: string) => {
+ const filename = path.join(shots, `${name}.png`)
+ await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0)
+ await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' })
+ await info.attach(name, { path: filename, contentType: 'image/png' })
+}
+async function signIn(page: Page, role: 'admin' | 'reader' | 'editor' | 'teacher' = 'admin') {
+ let session
+ if (role === 'admin') session = await login()
+ else {
+ const data = fixture()
+ const result = await call('auth/v1/auth/login', null, { userId: data.user.id, password: data.user.password, roleCode: 'teacher', departmentId: data.user.departmentId, rememberMe: false })
+ expect(result.status).toBe(200)
+ const selected = await call('auth/v1/auth/active-role', result.data.accessToken, { roleId: role === 'teacher' ? data.teacherRoleId : data[role].id }, 'PUT')
+ expect(selected.status).toBe(200)
+ session = selected.data?.accessToken ? selected.data : result.data
+ }
+ await page.addInitScript(({ accessToken, refreshToken }) => {
+ // Only initialize once: a role switch may rotate tokens before a full app reload.
+ if (!sessionStorage.getItem('role-permissions-test-initialized')) {
+ sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken)
+ if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken)
+ sessionStorage.setItem('role-permissions-test-initialized', '1')
+ }
+ }, { accessToken: session.accessToken, refreshToken: session.refreshToken })
+}
+async function openSwitcher(page: Page) {
+ await page.getByRole('button', { name: /^账号菜单:/ }).click()
+ await page.getByRole('menuitem', { name: '切换角色', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: '切换角色', exact: true })
+ await expect(dialog).toBeVisible()
+ return dialog
+}
+const treeNode = (container: ReturnType, code: string) => container.locator('.el-tree-node__content').filter({ has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$$`) }) })
+
+test('管理员授权固定,权限树细分到页面操作', async ({ page }, info) => {
+ await signIn(page)
+ await page.goto('/system/roles')
+ const adminRow = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: /^admin$/ }) })
+ await adminRow.getByRole('button', { name: '配置权限', exact: true }).click()
+ const drawer = page.getByRole('dialog', { name: '配置角色权限' })
+ await expect(drawer).toContainText('该角色采用固定授权')
+ await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model')
+ await expect(drawer.locator('code').filter({ hasText: /^content\.model\.create$/ })).toBeVisible()
+ await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
+ await expect(treeNode(drawer, 'content.model.create').getByRole('checkbox')).toBeDisabled()
+ await shot(page, info, '01-admin-fixed-permissions')
+})
+
+test('权限依赖:勾选操作自动补齐查看,取消查看需要确认', async ({ page }, info) => {
+ await signIn(page)
+ const data = fixture()
+ await page.goto('/system/roles')
+ const row = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: data.reader.code }) })
+ await row.getByRole('button', { name: '配置权限', exact: true }).click()
+ const drawer = page.getByRole('dialog', { name: '配置角色权限' })
+ const editor = drawer.locator('.permission-tree-editor')
+ await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model')
+ const read = treeNode(editor, 'content.model').getByRole('checkbox')
+ const create = treeNode(editor, 'content.model.create').getByRole('checkbox')
+ await expect(read).toBeChecked()
+ await expect(create).not.toBeChecked()
+ await treeNode(editor, 'content.model').locator('.el-checkbox').click()
+ await expect(read).not.toBeChecked()
+ await treeNode(editor, 'content.model.create').locator('.el-checkbox').click()
+ await expect(read).toBeChecked()
+ await expect(create).toBeChecked()
+ await shot(page, info, '02-action-adds-page-permission')
+ await treeNode(editor, 'content.model').locator('.el-checkbox').click()
+ const confirm = page.getByRole('dialog', { name: '确认取消关联权限' })
+ await expect(confirm).toBeVisible()
+ await shot(page, info, '03-removing-page-confirms-dependent-actions')
+ await confirm.getByRole('button', { name: '保留权限' }).click()
+ await expect(read).toBeChecked()
+ await expect(create).toBeChecked()
+ await drawer.getByRole('button', { name: '保存授权' }).click()
+ await expect(drawer).not.toBeVisible()
+ await row.getByRole('button', { name: '配置权限', exact: true }).click()
+ await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model')
+ await expect(create).toBeChecked()
+ await treeNode(editor, 'content.model.create').locator('.el-checkbox').click()
+ await expect(create).not.toBeChecked()
+ await drawer.getByRole('button', { name: '保存授权' }).click()
+ await expect(drawer).not.toBeVisible()
+})
+
+test('头像弹窗切换自定义角色,清理旧缓存并改变操作能力', async ({ page }, info) => {
+ await signIn(page, 'reader')
+ await page.goto('/content/models')
+ await expect(page.getByText('只读权限', { exact: true })).toBeVisible()
+ await shot(page, info, '04-custom-reader-model-list')
+ const dialog = await openSwitcher(page)
+ await expect(dialog.getByRole('button', { name: /权限实测·模型只读/ })).toContainText('当前角色')
+ await shot(page, info, '05-avatar-role-switch-dialog')
+ await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click()
+ await expect(page).toHaveURL(/\/dashboard$/)
+ await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型编辑')
+ await page.goto('/content/models')
+ await expect(page.getByText('只读权限', { exact: true })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: /新建模型|新增模型/ }).first()).toBeVisible()
+ await shot(page, info, '06-custom-editor-model-list')
+ const scopes = await page.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('unreal-tran:web:tabs:v2') || '{}')))
+ const data = fixture()
+ expect(scopes).toContain(`${data.user.id}:${data.reader.id}`)
+ expect(scopes).toContain(`${data.user.id}:${data.editor.id}`)
+ await page.goto('/system/roles')
+ await expect(page).not.toHaveURL(/\/system\/roles$/)
+})
+
+test('角色切换接口失败保留原身份与页面', async ({ page }, info) => {
+ await signIn(page, 'reader')
+ await page.goto('/content/models')
+ await expect(page.getByText('只读权限', { exact: true })).toBeVisible()
+ await page.route('**/api/auth/v1/auth/active-role', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '角色服务临时不可用', data: null }) }))
+ const dialog = await openSwitcher(page)
+ await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click()
+ await expect(page.locator('.el-message--error')).toContainText('角色服务临时不可用')
+ await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型只读')
+ await expect(page).toHaveURL(/\/content\/models$/)
+ await shot(page, info, '07-failed-switch-keeps-current-role')
+})
+
+test('切换已提交但身份加载失败,不展示旧角色业务页面', async ({ page }, info) => {
+ await signIn(page, 'reader')
+ await page.goto('/content/models')
+ await expect(page.getByText('只读权限', { exact: true })).toBeVisible()
+ await page.route('**/api/auth/v1/auth/me', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '身份信息临时不可用', data: null }) }))
+ const dialog = await openSwitcher(page)
+ await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click()
+ await expect(page).toHaveURL(/\/login/)
+ await expect(page.getByRole('button', { name: /^账号菜单:/ })).toHaveCount(0)
+ await shot(page, info, '08-role-refresh-failure-clears-old-view')
+})
+
+test('训练编排未保存时,取消切换保留当前内容与身份', async ({ page }, info) => {
+ await signIn(page, 'teacher')
+ await page.goto('/content/training-projects/164/legacy')
+ await page.getByRole('button', { name: '项目设置', exact: true }).click()
+ const name = page.locator('[data-field="name"][data-scope="project"]')
+ await expect(name).toBeVisible()
+ await name.fill('权限切换未保存验证')
+ await name.blur()
+ await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click()
+ const switcher = await openSwitcher(page)
+ let submitted = 0
+ page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') submitted++ })
+ await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click()
+ const warning = page.getByRole('dialog', { name: '离开训练编排?', exact: true })
+ await expect(warning).toBeVisible()
+ await shot(page, info, '09-unsaved-training-blocks-role-switch')
+ await warning.getByRole('button', { name: /close|关闭/i }).click()
+ await expect(warning).not.toBeVisible()
+ await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员')
+ expect(submitted).toBe(0)
+ await switcher.getByRole('button', { name: '关闭', exact: true }).click()
+ await page.getByRole('button', { name: '项目设置', exact: true }).click()
+ await expect(name).toHaveValue('权限切换未保存验证')
+})
+
+test('未保存训练的保存请求失败时,阻止提交角色切换', async ({ page }, info) => {
+ await signIn(page, 'teacher')
+ await page.goto('/content/training-projects/164/legacy')
+ await page.getByRole('button', { name: '项目设置', exact: true }).click()
+ const name = page.locator('[data-field="name"][data-scope="project"]')
+ await name.fill('保存失败角色切换验证')
+ await name.blur()
+ await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click()
+ let roleWrites = 0
+ let failedSaves = 0
+ page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') roleWrites++ })
+ await page.route('**/api/tran/v1/content/projects/164', async route => {
+ if (route.request().method() !== 'PUT') return route.continue()
+ failedSaves++
+ return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '实测保存失败,请稍后重试', data: null }) })
+ })
+ const switcher = await openSwitcher(page)
+ await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click()
+ await page.getByRole('dialog', { name: '离开训练编排?', exact: true }).getByRole('button', { name: '保存并离开' }).click()
+ await expect.poll(() => failedSaves).toBe(1)
+ await expect(page.locator('.el-message--error')).toContainText('实测保存失败')
+ await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员')
+ expect(roleWrites).toBe(0)
+ await shot(page, info, '09b-save-failure-blocks-role-switch')
+ await switcher.getByRole('button', { name: '关闭', exact: true }).click()
+ await page.getByRole('button', { name: '项目设置', exact: true }).click()
+ await expect(name).toHaveValue('保存失败角色切换验证')
+})
diff --git a/tests/role-permissions-races.spec.ts b/tests/role-permissions-races.spec.ts
new file mode 100644
index 0000000..8fafcee
--- /dev/null
+++ b/tests/role-permissions-races.spec.ts
@@ -0,0 +1,197 @@
+import { test, expect, type Locator, type Page, type TestInfo } from '@playwright/test'
+import fs from 'node:fs'
+import path from 'node:path'
+// Credentials stay in the local helper and browser session; traces/video are disabled by the live config.
+// @ts-ignore The existing live API helper is JavaScript.
+import { call, login, work } from '../tools/role-permissions-live.mjs'
+
+type FixtureRole = { id: string | number; code: string }
+type Fixtures = { reader: FixtureRole; editor: FixtureRole; roleIds: Array }
+const fixture = (): Fixtures => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8'))
+const sorted = (codes: string[]) => [...new Set(codes)].sort()
+const permissionCodes = (value: { permissionCodes?: string[]; permissions?: string[] }) => sorted(value.permissionCodes ?? value.permissions ?? [])
+const deferred = () => {
+ let resolve!: () => void
+ const promise = new Promise(done => { resolve = done })
+ return { promise, resolve }
+}
+
+async function signIn(page: Page) {
+ const session = await login()
+ await page.addInitScript(({ accessToken, refreshToken }) => {
+ sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken)
+ if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken)
+ }, { accessToken: session.accessToken, refreshToken: session.refreshToken })
+ return session.accessToken as string
+}
+
+const roleRow = (page: Page, code: string) => page.locator('.el-table__row').filter({
+ has: page.locator('.role-identity code').filter({ hasText: new RegExp(`^${code}$`) }),
+})
+const treeNode = (container: Locator, code: string) => container.locator('.el-tree-node__content').filter({
+ has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$`) }),
+})
+const checkedCodes = (drawer: Locator) => drawer.locator('.el-tree-node__content').evaluateAll(nodes => nodes
+ .filter(node => node.querySelector('input[type="checkbox"]')?.checked)
+ .map(node => node.querySelector('code')?.textContent?.trim() ?? '')
+ .filter(Boolean).sort())
+
+async function attachEvidence(page: Page, info: TestInfo, name: string, evidence: unknown) {
+ const filename = path.resolve('reports/role-permissions-20260906/screenshots', `${name}.png`)
+ fs.mkdirSync(path.dirname(filename), { recursive: true })
+ await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' })
+ await info.attach(name, { path: filename, contentType: 'image/png' })
+ await info.attach(`${name}-evidence`, { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' })
+}
+
+test('权限加载乱序:关闭 A 后打开 B,A 的迟到响应不能覆盖 B', async ({ page }, info) => {
+ const data = fixture()
+ const token = await signIn(page)
+ const arrived = deferred()
+ const release = deferred()
+ const delivered = deferred()
+ const endpoint = `**/api/auth/v1/permissions/roles/${data.reader.id}`
+ try {
+ const reader = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
+ const editor = await call(`auth/v1/permissions/roles/${data.editor.id}`, token)
+ expect(reader.status).toBe(200)
+ expect(editor.status).toBe(200)
+ const readerCodes = permissionCodes(reader.data)
+ const editorCodes = permissionCodes(editor.data)
+ expect(readerCodes).not.toEqual(editorCodes)
+ await page.route(endpoint, async route => {
+ if (route.request().method() !== 'GET') return route.continue()
+ // Fetch the actual read-only response, then deliberately deliver it after B has loaded.
+ const response = await route.fetch()
+ arrived.resolve()
+ await release.promise
+ try { await route.fulfill({ response }) } finally { delivered.resolve() }
+ })
+ await page.goto('/system/roles')
+ await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click()
+ await arrived.promise
+ const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true })
+ await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
+ await drawer.locator('.el-drawer__close-btn').click()
+ await expect(drawer).not.toBeVisible()
+ await roleRow(page, data.editor.code).getByRole('button', { name: '配置权限', exact: true }).click()
+ await expect.poll(() => checkedCodes(drawer)).toEqual(editorCodes)
+ await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑')
+ const lateResponse = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`)
+ && response.request().method() === 'GET')
+ release.resolve()
+ await delivered.promise
+ expect((await lateResponse).status()).toBe(200)
+ await (await lateResponse).finished()
+ // Let the fulfilled response reach the store and Vue rendering before checking for stale writes.
+ await page.waitForTimeout(250)
+ expect(await checkedCodes(drawer)).toEqual(editorCodes)
+ await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑')
+ await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
+ await attachEvidence(page, info, '10-stale-permission-response-is-ignored', {
+ delayedRole: data.reader.code, currentRole: data.editor.code,
+ delayedPermissionCodes: readerCodes, displayedPermissionCodes: editorCodes, businessWrites: 0,
+ })
+ } finally {
+ release.resolve()
+ await page.unrouteAll({ behavior: 'wait' })
+ await call('auth/v1/auth/logout', token, {}).catch(() => undefined)
+ }
+})
+
+test('保存授权期间禁止恢复,PUT 成功但列表刷新失败仍保留提交快照', async ({ page }, info) => {
+ const data = fixture()
+ const token = await signIn(page)
+ const arrived = deferred()
+ const release = deferred()
+ let original: string[] | undefined
+ let writeAttempted = false
+ let posted: string[] | undefined
+ let refreshFailures = 0
+ try {
+ // Only the explicitly prepared temporary reader role may be changed by this regression.
+ expect(data.reader.code).toMatch(/^rv_reader_/)
+ expect(data.roleIds.map(String)).toContain(String(data.reader.id))
+ const role = await call(`auth/v1/roles/${data.reader.id}`, token)
+ expect(role.status).toBe(200)
+ expect(role.data.code).toBe(data.reader.code)
+ expect(role.data.permissionPolicy).toBe('CONFIGURABLE')
+ expect(Boolean(role.data.builtIn ?? role.data.isBuiltin)).toBe(false)
+ const initial = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
+ expect(initial.status).toBe(200)
+ original = permissionCodes(initial.data)
+ expect(original).toContain('content.model')
+ const action = 'content.model.create'
+ const submitted = sorted(original.includes(action) ? original.filter(code => code !== action) : [...original, action])
+ await page.goto('/system/roles')
+ await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click()
+ const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true })
+ await expect.poll(() => checkedCodes(drawer)).toEqual(original)
+ await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model')
+ await treeNode(drawer, action).locator('.el-checkbox').click()
+ await expect.poll(() => checkedCodes(drawer)).toEqual(submitted)
+ await page.route(`**/api/auth/v1/permissions/roles/${data.reader.id}`, async route => {
+ if (route.request().method() !== 'PUT') return route.continue()
+ posted = sorted(route.request().postDataJSON().permissionCodes)
+ arrived.resolve()
+ await release.promise
+ writeAttempted = true
+ // Await the real PUT here as well, so cleanup cannot race an outstanding mutation.
+ const response = await route.fetch()
+ await route.fulfill({ response })
+ })
+ // Install only after the initial real list load. The permission PUT itself reaches the real API.
+ await page.route('**/api/auth/v1/roles/all', route => {
+ refreshFailures += 1
+ return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({
+ code: 'UNAVAILABLE', message: '角色列表刷新临时不可用', data: null,
+ }) })
+ })
+ const result = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`)
+ && response.request().method() === 'PUT')
+ await drawer.getByRole('button', { name: '保存授权' }).click()
+ await arrived.promise
+ await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled()
+ await expect(treeNode(drawer, action).getByRole('checkbox')).toBeDisabled()
+ expect(posted).toEqual(submitted)
+ release.resolve()
+ const response = await result
+ expect(response.status()).toBe(200)
+ expect(permissionCodes((await response.json()).data)).toEqual(submitted)
+ await expect(page.locator('.el-message--error')).toContainText('角色列表刷新临时不可用')
+ await expect(drawer).toBeVisible()
+ expect(await checkedCodes(drawer)).toEqual(submitted)
+ await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled()
+ await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled()
+ expect(refreshFailures).toBeGreaterThan(0)
+ await attachEvidence(page, info, '11-permission-save-survives-list-refresh-failure', {
+ temporaryRole: data.reader.code, originalPermissionCodes: original, submittedPermissionCodes: submitted,
+ realPutStatus: response.status(), injectedListRefreshStatus: 503, resetDisabledWhileSaving: true,
+ })
+ const persisted = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
+ expect(persisted.status).toBe(200)
+ expect(permissionCodes(persisted.data)).toEqual(submitted)
+ } finally {
+ release.resolve()
+ try {
+ await page.unrouteAll({ behavior: 'wait' })
+ if (original && writeAttempted) {
+ const current = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
+ expect(current.status, '读取临时 reader 最新版本以恢复授权').toBe(200)
+ if (JSON.stringify(permissionCodes(current.data)) !== JSON.stringify(original)) {
+ const restored = await call(`auth/v1/permissions/roles/${data.reader.id}`, token, {
+ permissionCodes: original, version: current.data.version ?? current.data.dataVersion,
+ }, 'PUT')
+ expect(restored.status, '恢复临时 reader 原授权').toBe(200)
+ }
+ const verified = await call(`auth/v1/permissions/roles/${data.reader.id}`, token)
+ expect(verified.status).toBe(200)
+ expect(permissionCodes(verified.data), '临时 reader 授权恢复核验').toEqual(original)
+ await info.attach('11-temporary-reader-restored', {
+ body: JSON.stringify({ roleCode: data.reader.code, restored: true, permissionCodes: original }),
+ contentType: 'application/json',
+ })
+ }
+ } finally { await call('auth/v1/auth/logout', token, {}).catch(() => undefined) }
+ }
+})
diff --git a/tools/build-role-permissions-report.mjs b/tools/build-role-permissions-report.mjs
new file mode 100644
index 0000000..3492af0
--- /dev/null
+++ b/tools/build-role-permissions-report.mjs
@@ -0,0 +1,184 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+// Render existing, sanitized evidence only. Never read private account fixtures,
+// backups, logs or credentials. Missing/failing evidence cannot become a pass.
+const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/role-permissions-20260906')
+const sensitiveKey = /^(?:password|passwd|secret|accessToken|refreshToken|token|authorization|cookie|credential|uploadTicket|apiKey|privateKey)$/i
+const cleanText = value => String(value ?? '')
+ .replace(/\u001b\[[0-9;]*m/g, '')
+ .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
+ .replace(/\b((?:access|refresh)?[_-]?token|password|secret|uploadTicket|api[_-]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
+const sanitize = value => typeof value === 'string' ? cleanText(value)
+ : Array.isArray(value) ? value.map(sanitize)
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value)
+ .filter(([key]) => !sensitiveKey.test(key)).map(([key, item]) => [key, sanitize(item)])) : value
+const html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
+const uri = value => value.split('/').map(encodeURIComponent).join('/')
+const array = value => Array.isArray(value) ? value : []
+const files = new Map()
+function readJSON(name) {
+ const filename = path.join(reportDir, name)
+ if (!fs.existsSync(filename)) { files.set(name, { state: 'PENDING' }); return null }
+ try {
+ const data = sanitize(JSON.parse(fs.readFileSync(filename, 'utf8').replace(/^\uFEFF/, '')))
+ files.set(name, { state: 'PRESENT' })
+ return data
+ } catch (error) {
+ files.set(name, { state: 'ERROR', error: cleanText(error.message) })
+ return null
+ }
+}
+const api = readJSON('api-checks.json')
+const boundaries = readJSON('maintenance-boundaries.json')
+const results = readJSON('results.json')
+const validation = readJSON('validation.json')
+const cleanup = readJSON('cleanup.json')
+const before = readJSON('data/role-permissions-audit-20260906.json')
+const contract = readJSON('data/granular-permissions-contract.json')
+const states = { PASS: '通过', FAIL: '失败', PENDING: '待完成', SKIP: '跳过', WARN: '需复核', PRESENT: '已记录', ERROR: '读取失败' }
+function state(value) {
+ if (value === true || value === 0) return 'PASS'
+ if (value === false) return 'FAIL'
+ const text = String(value ?? '').toUpperCase()
+ if (['PASS', 'PASSED', 'SUCCESS', 'SUCCEEDED', 'COMPLETE', 'COMPLETED'].includes(text)) return 'PASS'
+ if (['FAIL', 'FAILED', 'ERROR', 'UNEXPECTED', 'TIMEDOUT', 'INTERRUPTED'].includes(text)) return 'FAIL'
+ if (['SKIP', 'SKIPPED'].includes(text)) return 'SKIP'
+ if (['WARN', 'WARNING', 'FLAKY'].includes(text)) return 'WARN'
+ return 'PENDING'
+}
+const badge = (value, label) => `${html(label || states[value] || value)}`
+function checkRows(data) {
+ return array(data?.checks).map(check => ({ title: check.name || check.title || '未命名检查', status: state(check.result ?? check.status), details: check.details || check.note || '', http: typeof check.status === 'number' ? check.status : null }))
+}
+function count(rows) {
+ return { total: rows.length, pass: rows.filter(row => row.status === 'PASS').length, fail: rows.filter(row => row.status === 'FAIL').length, pending: rows.filter(row => !['PASS', 'FAIL'].includes(row.status)).length }
+}
+function e2eRows(suites, parents = []) {
+ return array(suites).flatMap(suite => [
+ ...array(suite.specs).flatMap(spec => array(spec.tests).map((test, index) => {
+ const attempts = array(test.results)
+ const last = attempts.at(-1)
+ const outcome = test.status === 'unexpected' ? 'FAIL' : test.status === 'flaky' ? 'WARN' : state(last?.status)
+ return {
+ title: [...parents, spec.title, test.projectName || (array(spec.tests).length > 1 ? `运行 ${index + 1}` : '')].filter(Boolean).join(' · '),
+ status: outcome, duration: last?.duration, attempts: attempts.length,
+ errors: array(last?.errors).map(error => cleanText(error.message || error.value || error)),
+ attachments: array(last?.attachments).filter(item => item.contentType?.startsWith('image/')).map(item => item.name),
+ }
+ })),
+ ...e2eRows(suite.suites, suite.file ? parents : [...parents, suite.title].filter(Boolean)),
+ ])
+}
+const apiRows = checkRows(api)
+const boundaryRows = checkRows(boundaries)
+const browserRows = e2eRows(results?.suites)
+const apiCount = count(apiRows)
+const boundaryCount = count(boundaryRows)
+const browserCount = count(browserRows)
+const e2eFinal = validation?.e2eComplete === true || validation?.finalRun === true || validation?.e2e?.complete === true
+const explicitFailure = value => value && typeof value === 'object' && (state(value.overallStatus ?? value.status ?? value.result ?? value.exitCode) === 'FAIL'
+ || Object.values(value).some(item => Array.isArray(item) ? item.some(explicitFailure) : item && typeof item === 'object' && explicitFailure(item)))
+const hasFailure = apiCount.fail + boundaryCount.fail + browserCount.fail > 0 || array(results?.errors).length > 0
+ || Number(results?.stats?.unexpected || 0) > 0 || explicitFailure(validation) || explicitFailure(cleanup)
+ || explicitFailure(api) || explicitFailure(boundaries) || [...files.values()].some(file => file.state === 'ERROR')
+const fullyVerified = !hasFailure && apiRows.length > 0 && boundaryRows.length > 0 && browserRows.length > 0
+ && apiCount.pending + boundaryCount.pending + browserCount.pending === 0 && e2eFinal
+ && [...files.values()].every(file => file.state === 'PRESENT')
+ && validation && state(validation.overallStatus ?? validation.status) === 'PASS'
+ && cleanup && state(cleanup.overallStatus ?? cleanup.status ?? cleanup.result) === 'PASS'
+const overall = hasFailure ? 'FAIL' : fullyVerified ? 'PASS' : 'PENDING'
+const overallTitle = overall === 'FAIL' ? '存在失败,尚未完成验收' : overall === 'PASS' ? '本轮记录的验收项已通过' : '证据整理中,最终验收尚未完成'
+const overallDetail = overall === 'FAIL' ? '以下失败来自当前证据文件。历史截图、单测通过或其他接口通过均不能覆盖这次失败。'
+ : overall === 'PASS' ? '真实接口、浏览器操作与构建/测试记录分别列示;测试覆盖范围和 OFD 本地入口边界仍需结合下文阅读。'
+ : '当前结果按文件原样展示。缺失文件、未确认的最终 E2E 或尚未完成的清理,不计为通过。'
+const pretty = value => `${html(JSON.stringify(value, null, 2))}`
+const link = (name, label = name) => fs.existsSync(path.join(reportDir, name))
+ ? `${html(label)}` : `${html(label)} · 待提供`
+const pendingFile = name => `${badge(files.get(name)?.state || 'PENDING')} ${html(name)}${files.get(name)?.error ? `:${html(files.get(name).error)}` : ' 尚未提供完整结果。'}
`
+const table = (headers, rows) => `${headers.map(item => `| ${html(item)} | `).join('')}
${rows.map(row => `${row.map(item => `| ${item} | `).join('')}
`).join('')}
`
+function checksBlock(title, name, data, rows) {
+ const stats = count(rows)
+ return `${html(title)}
${data ? `${badge(stats.fail ? 'FAIL' : stats.pending || !stats.total ? 'PENDING' : 'PASS')} ${stats.pass} / ${stats.total} 项通过${stats.fail ? `,${stats.fail} 项失败` : ''} · ${link(name, 'JSON 证据')}
+ 逐项检查结果
${table(['检查项目', '结果', '补充证据'], rows.map(row => [html(row.title), badge(row.status), html([row.http ? `HTTP ${row.http}` : '', row.details].filter(Boolean).join(' · '))]))}
+ ${array(data.limitations).length ? `${array(data.limitations).map(html).join('
')}
` : ''}` : pendingFile(name)}`
+}
+
+const captions = {
+ '01-admin-fixed-permissions': ['管理员固定授权', '验收后于 12:10:50(北京时间)等待完整加载后补拍:管理员权限面板展示固定授权,模型操作节点与保存授权被禁用。补拍仅执行读取,原始 Playwright 附件保持原记录。'],
+ '02-action-adds-page-permission': ['操作自动补齐查看权限', '勾选模型新建时,同步勾选模型页面,避免产生不可达的操作权限。'],
+ '03-removing-page-confirms-dependent-actions': ['取消页面权限需确认', '移除模型查看时提示其依赖操作会一起移除,取消后保留原勾选。'],
+ '04-custom-reader-model-list': ['自定义只读角色·首次加载', '截图捕获了只读状态,但列表仍有加载遮罩,不能单独证明完整列表已加载。模型查看返回 200、新建返回 403 的结论见真实 API 断言。'],
+ '05-avatar-role-switch-dialog': ['头像菜单切换角色', '仅列出当前账号已分配的有效工作身份,并标识当前身份。'],
+ '06-custom-editor-model-list': ['切换后按新授权显示操作', '切换至获得模型新增权限的自定义角色后,新建入口可见。'],
+ '07-failed-switch-keeps-current-role': ['切换接口失败保留原身份', '注入角色接口 503 响应后显示短消息,原身份和当前页面保留。'],
+ '08-role-refresh-failure-clears-old-view': ['身份重取失败不回放旧页面', '角色切换已提交但身份接口失败时回到登录入口,不继续显示旧身份业务页面。'],
+ '09-unsaved-training-blocks-role-switch': ['未保存训练阻止切换', '修改训练名称后切换角色触发离开确认;取消时不提交角色切换请求,修改仍保留。'],
+ '09b-save-failure-blocks-role-switch': ['保存失败阻止角色切换', '模拟训练保存接口 503 后保留原身份与输入,角色切换请求未提交。'],
+ '10-stale-permission-response-is-ignored': ['忽略过期权限响应', '权限编辑切换上下文后的延迟响应不应覆盖当前角色。此截图的通过状态以关联测试记录为准。'],
+ '11-permission-save-survives-list-refresh-failure': ['保存成功后列表刷新失败', '权限写入成功与随后列表刷新失败分别处理,避免误报授权未保存。此截图的通过状态以关联测试记录为准。'],
+}
+const screenshotDir = path.join(reportDir, 'screenshots')
+const screenshots = fs.existsSync(screenshotDir) ? fs.readdirSync(screenshotDir).filter(name => /\.(?:png|jpe?g|webp)$/i.test(name)).sort((a, b) => a.localeCompare(b, 'zh-CN', { numeric: true })) : []
+function screenshotCard(name) {
+ const stem = name.replace(/\.[^.]+$/, '')
+ const match = browserRows.find(row => row.attachments.includes(stem))
+ const caption = captions[stem] || (/(?:save|saving).*(?:fail|block)|failed.*save/i.test(stem)
+ ? ['保存失败阻止角色切换', '模拟训练保存失败后确认仍保留未保存内容与原身份,角色切换请求未提交。']
+ : [stem, match ? `关联浏览器用例:${match.title}` : '过程截图;当前 results.json 尚未关联此图,不能据此认定用例通过。'])
+ return `![${html(caption[0])}](${uri(`screenshots/${name}`)})
${badge(match?.status || 'PENDING', match ? `当前 E2E ${states[match.status]}` : '过程截图 · 未关联当前结果')}${html(name)}
${html(caption[0])}
${html(caption[1])}
`
+}
+
+const generatedAt = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'medium', timeStyle: 'medium', hour12: false }).format(new Date())
+const oldRoleRows = array(before?.roles).map(role => [html(`${role.name} · ${role.code}`), html(role.dynamicAll ? '动态全部叶子权限' : `${array(role.permissionCodes).length} 项显式权限`), `${html(role.dataScope)}`, html(role.code === 'admin' ? '旧权限集合全选,但内容写仍被教员身份门槛阻止。' : role.code === 'teacher' ? '保留原有制作、教学能力;未获授予的资源管理不自动新增。' : '保留本人或本人队组的学习与执行能力。')])
+const typeRows = [['模型', 'model'], ['场景', 'scene'], ['训练编排', 'training'], ['作业指导书', 'ofd']].map(([name, type]) => [html(name), `content.${type}.create`, `content.${type}.update`, `content.${type}.delete`, `content.${type}.publish`, type === 'ofd' ? 'content.ofd.review' : '不新增审批功能'])
+const evidenceLinks = ['authorization-design.md', 'data/role-permissions-audit-20260906.json', 'data/granular-permissions-contract.json', 'api-checks.json', 'maintenance-boundaries.json', 'results.json', 'validation.json', 'cleanup.json']
+const e2eHTML = results ? `${!e2eFinal ? '这是当前 results.json 的实际结果;最终完整 E2E 尚未标记完成,可能只是一轮针对性复跑。
' : ''}
+ ${badge(browserCount.fail || array(results.errors).length ? 'FAIL' : browserCount.pending || !browserCount.total ? 'PENDING' : 'PASS')} ${browserCount.pass} / ${browserCount.total} 项通过,${browserCount.fail} 项失败,${browserCount.pending} 项待完成/跳过/需复核 · ${link('results.json', '完整 JSON')} · 开始于 ${html(results.stats?.startTime || '未记录')}
+ ${table(['浏览器用例', '结果', '耗时', '失败证据'], browserRows.map(row => [html(row.title), badge(row.status), row.duration === undefined ? '未记录' : `${(row.duration / 1000).toFixed(1)} 秒`, row.errors.length ? `错误详情
${html(row.errors.join('\n\n'))} ` : row.attempts > 1 ? `运行 ${row.attempts} 次` : '—']))}
+ ${array(results.errors).length ? `测试运行器报错${pretty(results.errors)}
` : ''}` : pendingFile('results.json')
+
+const document = `
+权限与角色切换验收 · 2026-09-06
+
+装备数字车间 · 权限治理与工作身份
权限与角色切换验收报告
按资料、产品原型、设计、开发、测试梳理本轮调整。页面与操作使用真实权限;管理员采用固定授权;内部维护与普通业务隔离;工作身份切换保护未保存内容。
日期 2026-09-06 · 生成时间 ${html(generatedAt)}(北京时间) · 证据来自当前报告目录
+
+${badge(overall)}
${overallTitle}${overallDetail}
+真实 API · 权限主流程${api ? `${apiCount.pass} / ${apiCount.total}` : '待完成'}接口断言,不计作浏览器用例
真实 API · 维护隔离边界${boundaries ? `${boundaryCount.pass} / ${boundaryCount.total}` : '待完成'}目录、审计、角色与写入保护
浏览器 E2E · 当前结果${results ? `${browserCount.pass} / ${browserCount.total}` : '待完成'}${e2eFinal ? '已标记为最终完整运行' : '最终完整运行尚未确认'}
构建 / 单测 / DDL${validation ? states[state(validation.overallStatus ?? validation.status)] : '待完成'}独立统计,详见验证记录
+
+01 · 资料
以旧系统实际能力为迁移边界
旧环境采用 ${html(before?.authorizationMode || '未记录')}。管理员曾动态获得全部叶子权限,但内容写又被教员身份门槛阻止;本轮把这种不一致改为明确的页面权限与操作权限。
${before ? table(['旧角色', '旧授权', '数据范围', '迁移依据'], oldRoleRows) : pendingFile('data/role-permissions-audit-20260906.json')}
动作合同记录 ${array(contract?.permissions).length || '待记录'} 个新增动作和 ${array(contract?.deprecated).length || '待记录'} 个停用共用动作码。旧角色仅按原已持有权限和所属页面展开,自定义角色不自动补齐其他业务能力。
${link('data/role-permissions-audit-20260906.json', '脱敏旧角色快照')}${link('data/granular-permissions-contract.json', '细分动作合同')}${link('authorization-design.md', '完整中文设计与验收约定')}
+
+02 · 产品原型
保持入口,把“能查看”和“能操作”说明白
页面与工作区
三个登录身份入口保留。列表的新建、复制、删除、发布按各类内容权限显示;没有修改权限的三维工作区仍可查看,编辑、导入、自动保存和恢复写入受到控制。
管理员授权界面展示固定清单,不能在普通角色编辑中改动固定模板。勾选动作自动补齐所属页面;取消页面先确认依赖动作。
头像与个人中心
只列出当前账号已分配且有效的工作身份。单角色生效模式下,切换前确认各工作区未保存内容;成功后重新加载身份、菜单和页签。
切换请求失败保留原页面。切换已提交但新身份加载失败时,不继续使用旧身份业务缓存。
+
+03 · 设计
页面、动作、身份和数据范围分别约束
${table(['内容类型', '新建 / 复制', '编辑 / 上传 / 封面', '删除工程', '发布 / 撤回发布', '批准 / 驳回'], typeRows)}
保存后发布需要修改与发布两项权限。只有发布权限时,提交已保存的服务端版本;OFD 还须复用与当前内容指纹一致的已保存交付物,缺失或过期时由编辑者处理。
${table(['迁移后角色', '默认保留能力', '明确边界'], [
+ ['管理员', '固定普通管理授权、系统配置与既有教学/资源管理', '不自动拥有内容写、学员执行/提交或内部维护权限。'],
+ ['教员', '原有四类内容制作、教学管理与运行能力', '原无 manage 的资源/知识管理不因拆码获得 CRUD。'],
+ ['学员', '本人/本人队组的领取、执行、提交、指导书学习与反馈', 'SELF 范围、参与人、队伍、岗位和正式考试投影继续生效。'],
+ ['内部维护', '受保护账号与有效内部角色共同确认后获得维护能力', '普通人员/角色目录隐藏;内部审计与业务审计分别读取。'],
+].map(row => row.map(html)))}
任务中的教员、学员、观察员成员身份与平台角色名称解耦;成员身份不授予平台动作权限。业务参与范围、固定版本、乐观锁、发布前置条件继续由服务端校验。
+
+04 · 开发
实现范围与需要保留的边界
- 内容四类动作、教学各渠道、资源管理真实操作分别授权;停用旧共用动作码,不设置永久兼容放行。
- 模型同源宿主桥接、场景与训练运行时、正式工作台及 OFD 本地入口同步只读和操作门控;模型静态生成包未在本轮重做。
- 所有工作区参与角色切换确认。场景加载或资源上传先阻止离开;已开始的截图上传、保存、发布等待完整结束再核查未保存状态。
- 前一工作区选择放弃、后一工作区取消时仍保留草稿。角色接口成功后整页进入总览,新启动重新取身份和菜单,页签按用户与角色隔离。
- 036 迁移固定管理员模板、细分已有角色能力并增加审计范围;维护凭据只经私有初始化流程设置,报告和 DDL 不包含凭据。
OFD 本地入口的边界默认 OFD 原型仍使用本地存储。本轮接入真实会话和本地写权限保护,未将整个原型业务迁移至 API;不能将前端限制描述成完整服务端隔离、跨设备协作或新增审批功能。
+
+05 · 测试
真实接口、浏览器、构建与单测分别验收
+${checksBlock('A. 真实 API:权限配置与多角色主流程', 'api-checks.json', api, apiRows)}
+${checksBlock('B. 真实 API:内部维护与审计隔离边界', 'maintenance-boundaries.json', boundaries, boundaryRows)}
+C. 浏览器 E2E:可见交互与异常路径
${e2eHTML}角色切换/身份获取/保存失败等异常路径使用浏览器定向响应模拟。它们验证前端保留状态与阻断行为,不表示真实服务发生过这些故障。
+D. 构建、聚焦单测与 DDL
${validation ? `${link('validation.json', '完整验证记录')}。下方按实际执行的命令与结果展示,不把单测数量并入 E2E 或真实 API 数量。
${pretty(validation)}` : pendingFile('validation.json')}
+E. 临时测试数据与清理
${cleanup ? `${link('cleanup.json', '清理证据')}。业务模型、场景与训练的保留/恢复情况以记录为准。
${pretty(cleanup)}` : pendingFile('cleanup.json')}
+截图证据 · 点击放大
截图只证明相应画面。标记为“过程截图”的文件未关联当前结果,不计入当前通过数量。
${screenshots.length ? screenshots.map(screenshotCard).join('') : '
尚未生成截图。
'}
+
+证据文件
可随 Git 交付的材料
${evidenceLinks.map(name => link(name)).join('')}
data/ 仅包含脱敏角色/权限快照与动作合同。本生成器不读取或复制账号配置、私有测试 fixtures、数据库备份、服务日志或会话凭据。
+
+
+`
+fs.writeFileSync(path.join(reportDir, 'index.html'), document, 'utf8')
+console.log(JSON.stringify({ output: path.join(reportDir, 'index.html'), overall, api: apiCount, maintenance: boundaryCount, e2e: browserCount, e2eFinal, screenshots: screenshots.length, missing: [...files].filter(([, item]) => item.state !== 'PRESENT').map(([name, item]) => ({ name, ...item })) }, null, 2))
diff --git a/tools/content-permissions.test.mjs b/tools/content-permissions.test.mjs
new file mode 100644
index 0000000..6a23ec5
--- /dev/null
+++ b/tools/content-permissions.test.mjs
@@ -0,0 +1,164 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { readFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
+const ts = requireWeb('typescript')
+const web = new URL('../../unreal_tran_web/src/', import.meta.url)
+const dataModule = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
+async function compile(file, replacements = {}) {
+ let source = await readFile(new URL(file, web), 'utf8')
+ for (const [from, to] of Object.entries(replacements)) source = source.replaceAll(from, to)
+ return dataModule(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText)
+}
+const { canUsePermission } = await import(await compile('config/roles.ts'))
+const { contentPermission, contentReviewPermission } = await import(await compile('utils/contentPermissions.ts'))
+test('content abilities use exact grants independently of teacher/admin/custom role names', () => {
+ for (const code of ['admin', 'teacher', 'custom-author', 'student']) {
+ assert.equal(canUsePermission('content.model.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'SINGLE_ACTIVE'), true)
+ assert.equal(canUsePermission('content.scene.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'UNION'), false)
+ assert.equal(canUsePermission('content.model.update', ['content.update'], [{ code, enabled: true, active: true }], 'UNION'), false)
+ }
+})
+test('copy, delete, review, revoke and submit retain separate action grants', () => {
+ assert.equal(contentPermission('GUIDE', 'create'), 'content.ofd.create')
+ assert.equal(contentPermission('TRAINING', 'delete'), 'content.training.delete')
+ for (const action of ['APPROVE', 'REJECT']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.review')
+ assert.equal(contentReviewPermission('GUIDE', 'REVOKE'), 'content.ofd.publish')
+ for (const action of ['SUBMIT', 'WITHDRAW', 'REVISE']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.update')
+})
+
+const authUrl = dataModule(`export const auth = { profile: { userId: 'reader', displayName: 'Actual user', permissions: [] }, activeRole: { code: 'admin' }, availableRoles: [], hasEffectiveRole: () => false, hasPermission(permission) { return this.profile.permissions.includes(permission) } }; export const useAuthStore = () => auth;`)
+const { auth } = await import(authUrl)
+const storageUrl = await compile('features/legacy-shared/scopedStorage.ts')
+const guideRuntime = await import(await compile('features/guide-legacy/runtime.ts', {
+ './demo-src/asset-store.js': new URL('features/guide-legacy/demo-src/asset-store.js', web).href,
+ './demo-src/content-list-store.js': new URL('features/guide-legacy/demo-src/content-list-store.js', web).href,
+ '../legacy-shared/scopedStorage': storageUrl,
+ '../../stores/auth': authUrl,
+}))
+test('OFD runtime uses the real session and rejects local create/update/delete without exact grants', async () => {
+ const { createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web))
+ const context = guideRuntime.legacyGuideRuntimeContext()
+ assert.equal(context.actor.id, 'reader')
+ assert.equal(context.readOnly, true)
+ assert.equal(context.can('content.create'), false)
+ assert.equal(context.storage.length, 0, 'read-only initialization must not seed projects')
+ assert.throws(() => context.contentStore.createOFDProject({ title: 'Denied' }), /权限/)
+ auth.profile.permissions = ['content.ofd.create']
+ const initial = createInitialOFDState()
+ initial.project.name = 'Full initial payload'
+ const created = context.contentStore.createOFDProject({ title: 'Created only', state: initial })
+ assert.ok(created.id)
+ assert.equal(created.author, 'Actual user')
+ assert.throws(() => context.contentStore.saveOFDProjectState(created.id, created.state), /权限/)
+ assert.throws(() => context.contentStore.deleteOFDProject(created.id), /权限/)
+ auth.profile.permissions = ['content.ofd.delete']
+ assert.equal(context.contentStore.deleteOFDProject(created.id), true)
+})
+const { canPerformOFDAction, createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web))
+test('OFD lifecycle does not treat admin/teacher identity as a publish grant', () => {
+ const state = createInitialOFDState()
+ for (const role of ['admin', 'teacher']) assert.equal(canPerformOFDAction(state, 'publish', { role, permissions: [] }), false)
+ assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.publish'] }), true)
+ assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.update'] }), false)
+})
+
+const { installLegacyModelPermissions } = await import(await compile('features/editors/model/legacy-iframe-permissions.ts', {
+ '../common/editor-project-io.js': new URL('features/editors/common/editor-project-io.js', web).href,
+}))
+function modelFixture({ update = false, publish = false } = {}) {
+ const calls = []
+ const editor = {
+ apiEditorHydrated: true, apiState: 'ready', apiSession: { project: { id: 'test', version: 7 }, lastSavedFingerprint: '' },
+ saveProject: async () => { calls.push('save'); return {} },
+ publishToSceneLibrary: async () => { calls.push('save-and-publish'); return {} },
+ saveRecoveryCopy: () => calls.push('recovery'),
+ serializeProject: () => ({}), toast: message => calls.push(message),
+ transform: { enabled: true, detach() {}, attach() { calls.push('transform') } },
+ }
+ const doc = { defaultView: { editorApp: editor }, body: {}, querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, removeEventListener() {} }
+ const previousObserver = globalThis.MutationObserver
+ globalThis.MutationObserver = class { observe() {} disconnect() {} }
+ const bridge = installLegacyModelPermissions(doc, {
+ canCreate: () => false, canUpdate: () => update, canPublish: () => publish,
+ publishSaved: async version => { calls.push(['publish-saved', version]); return { id: 'test', version: 8 } },
+ })
+ globalThis.MutationObserver = previousObserver
+ return { editor, calls, bridge }
+}
+test('model iframe read-only bridge blocks autosave, recovery writes and transform attach', async () => {
+ const { editor, calls, bridge } = modelFixture()
+ assert.equal(await editor.saveProject(true, { automatic: true }), null)
+ assert.equal(await editor.publishToSceneLibrary(), null)
+ editor.saveRecoveryCopy()
+ editor.transform.attach({})
+ assert.deepEqual(calls, [])
+ assert.equal(editor.transform.enabled, false)
+ bridge.dispose()
+})
+test('model iframe publisher-only uses the loaded optimistic-lock version without saving', async () => {
+ const { editor, calls, bridge } = modelFixture({ publish: true })
+ const published = await editor.publishToSceneLibrary()
+ assert.equal(published.version, 8)
+ assert.deepEqual(calls[0], ['publish-saved', 7])
+ assert.equal(calls.includes('save-and-publish'), false)
+ bridge.dispose()
+})
+test('committed role switch prevents iframe unload recovery from writing discarded changes', async () => {
+ const { editor, calls, bridge } = modelFixture({ update: true, publish: true })
+ bridge.roleSwitchCommitted()
+ editor.saveRecoveryCopy()
+ assert.equal(await editor.saveProject(), null)
+ assert.equal(await editor.publishToSceneLibrary(), null)
+ assert.deepEqual(calls, [])
+ bridge.dispose()
+})
+
+test('read-only model viewing skips automatic migration and recovery without modifying saved data', async () => {
+ const { editor, bridge } = modelFixture()
+ editor.apiDocumentNeedsMigrationSave = true
+ assert.equal(editor.apiDocumentNeedsMigrationSave, false)
+ assert.equal(await editor.saveProject(true), null)
+ bridge.dispose()
+ assert.equal(editor.apiDocumentNeedsMigrationSave, true, 'the host must restore the original runtime state on disposal')
+})
+
+const policy = await import(await compile('features/guide/publishDeliveriesPolicy.ts'))
+const publishSource = await readFile(new URL('features/guide/publishDeliveries.ts', web), 'utf8')
+const publishFunction = ts.transpileModule(publishSource.slice(publishSource.indexOf('export async function publishGuideWithDeliveries(')), {
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+}).outputText.replace(/^export /gm, '')
+const publishGuide = new Function('normalizeGuideContent', 'resolveGuideDeliveryAssets', 'commitGuideDeliveries', 'reusableGuideDelivery', 'assertCompleteGuideDeliveries', 'GUIDE_DELIVERY_ASSET_CODES', 'artifactFile', 'text', `${publishFunction};return publishGuideWithDeliveries`)(
+ value => value, async () => ({}), policy.commitGuideDeliveries, policy.reusableGuideDelivery,
+ policy.assertCompleteGuideDeliveries, policy.GUIDE_DELIVERY_ASSET_CODES,
+ () => { throw new Error('publisher must not upload') }, value => String(value ?? '').trim(),
+)
+function guidePublisherFixture(savedFingerprint) {
+ const calls = []
+ const formats = ['OFD', 'OFFLINE_HTML']
+ const assets = formats.map(format => ({ code: policy.GUIDE_DELIVERY_ASSET_CODES[format], type: 'DOCUMENT', status: 'READY', sha256: 'a'.repeat(64),
+ storageUri: `content://${format}`, metadata: { deliveryFormat: format, sourceFingerprint: savedFingerprint } }))
+ const dependencies = {
+ loadProject: async () => ({ id: 'g', version: 17, status: 'DRAFT', content: {}, assets }),
+ readAsset: async () => new Blob(), uploadAsset: async () => { calls.push('upload'); throw new Error('forbidden') },
+ buildArtifacts: async () => ({ sourceFingerprint: 'current-source', artifacts: formats.map(format => ({ format, code: policy.GUIDE_DELIVERY_ASSET_CODES[format] })) }),
+ publishProject: async (_id, _type, input) => { calls.push(input); return { id: 'g', version: 18 } },
+ }
+ return { calls, dependencies }
+}
+test('OFD publisher reuses the exact saved source artifacts and optimistic-lock version without uploading', async () => {
+ const { calls, dependencies } = guidePublisherFixture('current-source')
+ const result = await publishGuide('g', { allowUpload: false }, dependencies)
+ assert.equal(result.project.version, 18)
+ assert.deepEqual(result.uploadedFormats, [])
+ assert.deepEqual(result.reusedFormats, ['OFD', 'OFFLINE_HTML'])
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].version, 17)
+ assert.equal(calls[0].deliveries.length, 2)
+})
+test('OFD publisher cannot regenerate/upload stale artifacts or publish them against changed content', async () => {
+ const { calls, dependencies } = guidePublisherFixture('stale-source')
+ await assert.rejects(publishGuide('g', { allowUpload: false }, dependencies), /交付物/)
+ assert.deepEqual(calls, [])
+})
diff --git a/tools/maintenance-boundaries-live.mjs b/tools/maintenance-boundaries-live.mjs
new file mode 100644
index 0000000..8172613
--- /dev/null
+++ b/tools/maintenance-boundaries-live.mjs
@@ -0,0 +1,237 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { login, call, root, work, report } from './role-permissions-live.mjs';
+
+// Run only after migration, service restart and the separate prepare fixture have completed.
+// Credentials remain inside the existing login helper; no response token, password or user payload is reported.
+const checks = [];
+const sessions = [];
+const withoutFixtures = process.argv.includes('--without-fixtures');
+const outcome = { startedAt: new Date().toISOString(), mode: 'LIVE_API', checks, limitations: [] };
+let activeCheck = 'initialization';
+const rows = value => value?.records ?? value ?? [];
+const sameId = (left, right) => String(left) === String(right);
+const ids = values => values.map(String).sort();
+function check(name, condition, evidence = {}) {
+ activeCheck = name;
+ checks.push({ name, result: condition ? 'PASS' : 'FAIL', ...evidence });
+ assert.ok(condition, name);
+}
+async function read(endpoint, token) {
+ activeCheck = `读取 ${endpoint.split('?')[0]}`;
+ const result = await call(endpoint, token);
+ assert.equal(result.status, 200, activeCheck);
+ return result.data;
+}
+async function failedLogin(username, requestId) {
+ const response = await fetch('http://127.0.0.1:6180/api/auth/v1/auth/login', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId },
+ body: JSON.stringify({ username, password: `Bad!${crypto.randomBytes(7).toString('hex')}`, roleCode: 'admin', rememberMe: false }),
+ signal: AbortSignal.timeout(30000),
+ });
+ const value = await response.json();
+ return { status: response.status, requestId: value.requestId || response.headers.get('X-Request-Id') };
+}
+function databaseSnapshot(requestIds = []) {
+ const source = String.raw`
+import importlib.util,json,sys
+from pathlib import Path
+p=Path(sys.argv[1])/'ute2e'/'tools'/'review-db.py'
+spec=importlib.util.spec_from_file_location('review_db_private',p)
+module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
+connection=module.connect()
+try:
+ connection.execute('BEGIN READ ONLY')
+ result={}
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE result_code=1) AS success, COUNT(*) FILTER(WHERE result_code=0) AS failed FROM ut_sys_audit_log WHERE is_delete=0 AND visibility_scope='BUSINESS'")
+ result['businessAudit']=cursor.fetchone()
+ cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE result_code=1) AS success, COUNT(*) FILTER(WHERE result_code=0) AS failed FROM ut_sys_audit_log WHERE is_delete=0 AND visibility_scope='INTERNAL'")
+ result['internalAudit']=cursor.fetchone()
+ cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE status=1) AS enabled FROM ut_sys_user WHERE is_delete=0 AND is_protected=0")
+ result['ordinaryUsers']=cursor.fetchone()
+ cursor.execute("SELECT COUNT(*) AS total FROM ut_sys_role WHERE is_delete=0 AND role_code<>'root'")
+ result['ordinaryRoles']=cursor.fetchone()
+ cursor.execute("SELECT id, request_id, visibility_scope, result_code, actor_user_id, target_type, target_id FROM ut_sys_audit_log WHERE request_id=ANY(%s) ORDER BY id",(json.loads(sys.argv[2]),))
+ result['events']=cursor.fetchall()
+ connection.rollback()
+ print(json.dumps(result,default=str))
+finally:
+ connection.close()
+`;
+ const result = spawnSync('python', ['-c', source, root, JSON.stringify(requestIds)], { encoding: 'utf8', windowsHide: true });
+ // Neither stderr nor the private source configuration is copied into the report.
+ assert.equal(result.status, 0, '只读数据库证据查询成功');
+ return JSON.parse(result.stdout.trim());
+}
+function exportedContains(data, needle) {
+ const source = String.raw`
+import base64,io,json,sys,zipfile
+payload=json.load(sys.stdin)
+with zipfile.ZipFile(io.BytesIO(base64.b64decode(payload['contentBase64']))) as archive:
+ contents=b'\n'.join(archive.read(name) for name in archive.namelist() if name.startswith('xl/') and name.endswith('.xml'))
+print(json.dumps({'contains':payload['needle'].encode('utf-8') in contents}))
+`;
+ const result = spawnSync('python', ['-c', source], {
+ input: JSON.stringify({ contentBase64: data.contentBase64, needle }), encoding: 'utf8', windowsHide: true,
+ });
+ assert.equal(result.status, 0, '导出文件内容校验成功');
+ return JSON.parse(result.stdout).contains;
+}
+function roleCommand(role, overrides = {}) {
+ return { code: role.code, name: role.name, shortName: role.shortName, description: role.description,
+ dataScope: role.dataScope, enabled: role.enabled, sortOrder: role.sortOrder,
+ departmentScopeIds: role.departmentScopeIds, version: role.dataVersion, ...overrides };
+}
+function userCommand(user, overrides = {}) {
+ return { username: user.username, displayName: user.displayName, departmentId: user.departmentId,
+ roleIds: user.roleIds, defaultRoleId: user.defaultRoleId, enabled: user.enabled, remark: user.remark,
+ version: user.dataVersion, ...overrides };
+}
+
+async function main() {
+ const fixture = withoutFixtures ? {} : JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8'));
+ if (!withoutFixtures) assert.ok(fixture.user?.id && fixture.adminRoleId, '隔离测试账号已由 prepare 创建');
+ else outcome.limitations.push('首阶段不依赖隔离账号,内部角色赋值拒绝及测试账号版本保持由完整运行补测。');
+ const admin = await login('admin'); sessions.push(admin.accessToken);
+ const maintenance = await login('root'); sessions.push(maintenance.accessToken);
+ const token = admin.accessToken, rootToken = maintenance.accessToken;
+ const me = await read('auth/v1/auth/me', token), rootMe = await read('auth/v1/auth/me', rootToken);
+ const rootUserId = rootMe.id, internalRole = rootMe.roles.find(role => role.code === 'root');
+ check('维护账号使用已分配的内部角色,普通管理员不带维护标记',
+ !!internalRole && rootMe.maintenanceAccount === true && me.maintenanceAccount === false);
+
+ for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) {
+ const all = rows(await read('auth/v1/roles/all', actorToken));
+ const page = rows(await read('auth/v1/roles?size=200', actorToken));
+ check(`${actor} 的普通角色目录和分页都隐藏内部角色`, [...all, ...page].every(role => role.code !== 'root'));
+ for (const suffix of ['', '/users', '/permissions']) {
+ const result = await call(`auth/v1/roles/${internalRole.id}${suffix}`, actorToken);
+ check(`${actor} 不能按已知内部角色 ID 读取${suffix || '/详情'}`, result.status === 404, { status: result.status });
+ }
+ }
+ const ordinaryRoles = rows(await read('auth/v1/roles/all', token));
+ for (const role of ordinaryRoles) {
+ const linked = rows(await read(`auth/v1/roles/${role.id}/users`, token));
+ check(`角色 ${role.code} 的关联用户排除维护账号`, linked.every(user => !sameId(user.id, rootUserId)));
+ }
+ const lookup = rows(await read(`auth/v1/directory/teaching-members?keyword=${encodeURIComponent(rootMe.username)}&limit=100`, token));
+ check('教学人员搜索不暴露维护账号', lookup.every(user => !sameId(user.id, rootUserId)));
+ const resolved = await call('auth/v1/directory/teaching-members/resolve', token, { ids: [rootUserId] });
+ check('教学人员按 ID 解析拒绝维护账号', resolved.status === 400, { status: resolved.status });
+ const candidates = rows(await read(`auth/v1/auth/login-identities?roleCode=teacher&departmentId=${rootMe.departmentId}`));
+ check('公开登录候选列表不暴露维护账号', candidates.every(user => !sameId(user.id, rootUserId)));
+
+ if (fixture.user?.id) {
+ const originalUser = await read(`auth/v1/users/${fixture.user.id}`, token);
+ for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) {
+ const attempt = await call(`auth/v1/users/${fixture.user.id}`, actorToken,
+ userCommand(originalUser, { roleIds: [...originalUser.roleIds, internalRole.id] }), 'PUT');
+ if (attempt.status === 200) {
+ // The only mutable target is the isolated fixture. Restore before reporting an unexpected authorization failure.
+ const current = await read(`auth/v1/users/${fixture.user.id}`, rootToken);
+ await call(`auth/v1/users/${fixture.user.id}`, rootToken, userCommand(originalUser, { version: current.dataVersion }), 'PUT');
+ }
+ check(`${actor} 无法通过普通用户接口给测试账号授予内部角色`, [400, 403].includes(attempt.status), { status: attempt.status });
+ }
+ const afterUser = await read(`auth/v1/users/${fixture.user.id}`, token);
+ check('拒绝内部角色分配后测试账号原角色和版本保持不变',
+ JSON.stringify(ids(afterUser.roleIds)) === JSON.stringify(ids(originalUser.roleIds)) && afterUser.dataVersion === originalUser.dataVersion);
+ }
+
+ const adminRoleId = fixture.adminRoleId ?? ordinaryRoles.find(role => role.code === 'admin')?.id;
+ const fixed = await read(`auth/v1/roles/${adminRoleId}`, token);
+ for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) {
+ const scope = await call(`auth/v1/roles/${fixed.id}`, actorToken, roleCommand(fixed, { dataScope: 'SELF' }), 'PUT');
+ if (scope.status === 200) {
+ const current = await read(`auth/v1/roles/${fixed.id}`, rootToken);
+ await call(`auth/v1/roles/${fixed.id}`, rootToken, roleCommand(fixed, { version: current.dataVersion }), 'PUT');
+ }
+ check(`${actor} 无法通过普通编辑更改固定管理员数据范围`, scope.status === 403, { status: scope.status });
+ const status = await call(`auth/v1/roles/${fixed.id}/status`, actorToken, { enabled: false, version: fixed.dataVersion }, 'PUT');
+ if (status.status === 200) {
+ const current = await read(`auth/v1/roles/${fixed.id}`, rootToken);
+ await call(`auth/v1/roles/${fixed.id}/status`, rootToken, { enabled: true, version: current.dataVersion }, 'PUT');
+ }
+ check(`${actor} 无法停用固定管理员角色`, status.status === 403, { status: status.status });
+ }
+ const afterRole = await read(`auth/v1/roles/${fixed.id}`, token);
+ check('固定管理员角色状态、范围、版本未被边界请求改变',
+ afterRole.enabled === fixed.enabled && afterRole.dataScope === fixed.dataScope && afterRole.dataVersion === fixed.dataVersion);
+
+ for (const suffix of ['', '/stats', '/export']) {
+ const denied = await call(`auth/v1/maintenance/audit-logs${suffix}`, token);
+ check(`普通管理员不能读取维护审计${suffix || '/列表'}`, denied.status === 403, { status: denied.status });
+ }
+ const sequence = crypto.randomBytes(6).toString('hex');
+ const rootAttempt = await failedLogin(rootMe.username, `maint-root-failure-${sequence}`);
+ const unknownAttempt = await failedLogin(`missing_${sequence}`, `maint-unknown-failure-${sequence}`);
+ check('已知维护账号错误密码和未知账号均返回统一认证失败', rootAttempt.status === 401 && unknownAttempt.status === 401);
+ const rootFilter = encodeURIComponent(rootAttempt.requestId), unknownFilter = encodeURIComponent(unknownAttempt.requestId);
+ const ordinaryKnown = rows(await read(`auth/v1/audit-logs?keyword=${rootFilter}`, token));
+ const internalKnown = rows(await read(`auth/v1/maintenance/audit-logs?keyword=${rootFilter}`, rootToken));
+ const ordinaryUnknown = rows(await read(`auth/v1/audit-logs?keyword=${unknownFilter}`, token));
+ check('维护登录失败在普通列表不可见且内部审计保留真实 USER 目标', ordinaryKnown.length === 0
+ && internalKnown.some(event => event.targetType === 'USER' && sameId(event.targetId, rootUserId) && event.success === false));
+ check('未知身份失败仍可在普通安全审计中查到', ordinaryUnknown.some(event => event.requestId === unknownAttempt.requestId && event.success === false));
+ const ordinaryExport = await read(`auth/v1/audit-logs/export?keyword=${rootFilter}`, token);
+ const internalExport = await read(`auth/v1/maintenance/audit-logs/export?keyword=${rootFilter}`, rootToken);
+ check('普通审计导出不包含维护记录,维护导出保留该记录', ordinaryExport.recordCount === 0
+ && !exportedContains(ordinaryExport, rootAttempt.requestId) && internalExport.recordCount >= 1
+ && exportedContains(internalExport, rootAttempt.requestId));
+
+ const before = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]);
+ const auditStats = await read('auth/v1/audit-logs/stats', token);
+ const internalStats = await read('auth/v1/maintenance/audit-logs/stats', rootToken);
+ const userStats = await read('auth/v1/users/stats', token);
+ const roleStats = await read('auth/v1/roles/stats', token);
+ const dashboard = await read('auth/v1/dashboard/platform', token);
+ const after = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]);
+ const between = (value, first, last) => value >= first && value <= last;
+ check('普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL',
+ ['total', 'success', 'failed'].every(key => between(auditStats[key], before.businessAudit[key], after.businessAudit[key])
+ && between(internalStats[key], before.internalAudit[key], after.internalAudit[key])));
+ check('普通用户和角色统计排除内部账号及内部角色',
+ between(userStats.total, before.ordinaryUsers.total, after.ordinaryUsers.total)
+ && between(roleStats.total, before.ordinaryRoles.total, after.ordinaryRoles.total));
+ check('首页人数不计维护账号,最近动态不显示维护失败日志',
+ between(dashboard.metrics.userTotal, before.ordinaryUsers.total, after.ordinaryUsers.total)
+ && !dashboard.activities.some(activity => internalKnown.some(event => sameId(activity.id, event.id))));
+ const knownDb = after.events.find(event => event.request_id === rootAttempt.requestId);
+ const unknownDb = after.events.find(event => event.request_id === unknownAttempt.requestId);
+ check('数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS',
+ knownDb?.visibility_scope === 'INTERNAL' && sameId(knownDb.target_id, rootUserId)
+ && unknownDb?.visibility_scope === 'BUSINESS' && !unknownDb.target_id && !unknownDb.actor_user_id);
+ outcome.databaseEvidence = {
+ ordinaryUserCount: after.ordinaryUsers.total, ordinaryRoleCount: after.ordinaryRoles.total,
+ businessAuditCount: after.businessAudit.total, internalAuditCount: after.internalAudit.total,
+ knownFailureScope: knownDb?.visibility_scope, unknownFailureScope: unknownDb?.visibility_scope,
+ knownFailurePreservesTarget: sameId(knownDb?.target_id, rootUserId),
+ };
+
+ // Restore the failed-login counter by a normal successful authentication; no direct account or credential writes.
+ const verified = await login('root'); sessions.push(verified.accessToken);
+ const normalAdmin = await read(`auth/v1/users/${me.id}`, token);
+ check('维护再次登录后普通 admin 仍未被重新保护', normalAdmin.isProtected === false);
+ outcome.bootstrapReview = { result: 'PASS', note: '初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。' };
+ outcome.status = withoutFixtures ? 'PASS_WITH_LIMITATIONS' : 'PASS';
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ try { await main(); }
+ catch (error) {
+ outcome.status = 'FAIL'; outcome.failedStep = activeCheck;
+ outcome.error = error?.name === 'AssertionError' ? '验证断言失败,参见最后一项或 failedStep' : '请求或验证未完成,未输出原始异常以避免泄漏认证信息';
+ process.exitCode = 1;
+ } finally {
+ for (const token of sessions) await call('auth/v1/auth/logout', token, {}).catch(() => {});
+ outcome.finishedAt = new Date().toISOString();
+ fs.writeFileSync(path.join(report, withoutFixtures ? 'maintenance-boundaries-initial.json' : 'maintenance-boundaries.json'), JSON.stringify(outcome, null, 2));
+ console.log(JSON.stringify({ status: outcome.status, passed: checks.filter(item => item.result === 'PASS').length,
+ failed: checks.filter(item => item.result === 'FAIL').length, failedStep: outcome.failedStep ?? null }));
+ }
+}
diff --git a/tools/role-permissions-live.mjs b/tools/role-permissions-live.mjs
new file mode 100644
index 0000000..bc86855
--- /dev/null
+++ b/tools/role-permissions-live.mjs
@@ -0,0 +1,175 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import { fileURLToPath } from 'node:url';
+
+export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+export const work = path.resolve(root, '../.codex-tmp/permissions-20260906');
+export const report = path.resolve(root, 'ute2e/reports/role-permissions-20260906');
+fs.mkdirSync(work, { recursive: true }); fs.mkdirSync(report, { recursive: true });
+export function credentials(account) {
+ const lines = fs.readFileSync(path.join(root, '账号.MD'), 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/).map(x => x.trim()).filter(Boolean);
+ const pairs = [];
+ for (let i = 0; i + 1 < lines.length; i += 2) pairs.push({ username: lines[i].replace(/^.*?[::]\s*/, '').trim(), password: lines[i + 1].replace(/^.*?[::]\s*/, '').trim() });
+ const found = pairs.find(x => x.username === account);
+ if (!found?.password) throw new Error('Requested local credential pair is unavailable');
+ return found;
+}
+export async function call(endpoint, token, body, method = body === undefined ? 'GET' : 'POST') {
+ const response = await fetch(`http://127.0.0.1:6180/api/${endpoint}`, {
+ method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000),
+ });
+ const value = await response.json();
+ return { status: response.status, code: value.code, data: value.data, message: value.message };
+}
+export async function login(account = 'admin') {
+ const result = await call('auth/v1/auth/login', null, { ...credentials(account), roleCode: 'admin', rememberMe: false });
+ assert.equal(result.status, 200, `Local ${account} login status`);
+ assert.ok(result.data?.accessToken, 'Access token returned');
+ return result.data;
+}
+const checks = [];
+const check = (name, condition) => { assert.ok(condition, name); checks.push({ name, result: 'PASS' }); };
+const records = value => value?.records ?? value ?? [];
+const fixtureFile = path.join(work, 'fixtures.json');
+const write = fixture => fs.writeFileSync(fixtureFile, JSON.stringify(fixture, null, 2));
+
+async function prepare() {
+ const admin = await login(); const maintenance = await login('root');
+ const token = admin.accessToken; const rootToken = maintenance.accessToken;
+ try {
+ const me = (await call('auth/v1/auth/me', token)).data;
+ const rootMe = (await call('auth/v1/auth/me', rootToken)).data;
+ const rootUserId = String(rootMe.id);
+ check('root 使用管理员登录入口进入内部维护角色', rootMe.roles.some(r => r.code === 'root') && rootMe.maintenanceAccount === true);
+ const roles = records((await call('auth/v1/roles/all', token)).data);
+ const adminRole = roles.find(r => r.code === 'admin'), teacherRole = roles.find(r => r.code === 'teacher'), studentRole = roles.find(r => r.code === 'student');
+ check('普通角色目录不显示内部维护角色', !roles.some(r => r.code === 'root'));
+ check('管理员角色采用固定的受限授权', adminRole.permissionPolicy === 'FIXED' && adminRole.superAdmin === false);
+ const grants = (await call(`auth/v1/permissions/roles/${adminRole.id}`, token)).data;
+ check('管理员授权已持久化,不动态获得全权限', grants.dynamicAll === false && grants.permissionCodes.length > 0 && !grants.permissionCodes.includes('content.model.create'));
+ for (const [name, actor] of [['admin', token], ['root', rootToken]]) {
+ const forbidden = await call(`auth/v1/permissions/roles/${adminRole.id}`, actor, { permissionCodes: grants.permissionCodes, version: grants.version }, 'PUT');
+ check(`${name} 无法通过接口修改固定管理员授权`, forbidden.status === 403 || forbidden.status === 400);
+ }
+ const normalAdmin = (await call(`auth/v1/users/${me.id}`, token)).data;
+ check('admin 账号已取消 is_protected', normalAdmin.isProtected === false);
+ const users = records((await call('auth/v1/users?size=100', token)).data);
+ check('用户目录不显示 root 维护账号', !users.some(u => String(u.id) === rootUserId));
+ for (const [name, body, method, tail] of [
+ ['查询', undefined, 'GET', ''], ['停用', { enabled: false, version: 0 }, 'PUT', '/status'],
+ ['重置密码', { password: `Test!${crypto.randomBytes(7).toString('hex')}`, version: 0 }, 'PUT', '/password'],
+ ['删除', { version: 0 }, 'DELETE', ''],
+ ]) {
+ const forbidden = await call(`auth/v1/users/${rootUserId}${tail}`, token, body, method);
+ check(`普通账号按 ID ${name} root 受保护`, [400, 403, 404].includes(forbidden.status));
+ }
+ const maintenanceDenied = await call('auth/v1/maintenance/audit-logs', token);
+ check('管理员无法访问维护审计', maintenanceDenied.status === 403);
+ const normalAudit = records((await call('auth/v1/audit-logs?size=100', token)).data);
+ check('普通审计不混入 root 登录与操作记录', !normalAudit.some(x => String(x.userId) === rootUserId || x.username === 'root'));
+ const internalAudit = (await call('auth/v1/maintenance/audit-logs?size=100', rootToken)).data;
+ check('root 活动保留在可追溯的内部审计', records(internalAudit).some(x => x.username === 'root'));
+ const catalog = (await call('auth/v1/permissions/catalog', token)).data;
+ const permissionItems = catalog.flatMap(s => s.items);
+ check('目录提供模型独立动作与查看依赖', ['create', 'update', 'delete', 'publish'].every(action => permissionItems.some(p => p.code === `content.model.${action}` && p.requires.includes('content.model'))));
+ check('已移除旧的共用内容写权限', !permissionItems.some(p => ['content.create', 'content.update', 'content.publish'].includes(p.code)));
+ const suffix = crypto.randomBytes(4).toString('hex');
+ const fixture = { createdAt: new Date().toISOString(), roleIds: [], projectIds: [], user: null, adminRoleId: adminRole.id, teacherRoleId: teacherRole.id, studentRoleId: studentRole.id };
+ if (fs.existsSync(fixtureFile) && !JSON.parse(fs.readFileSync(fixtureFile, 'utf8')).cleanedAt) {
+ throw new Error('Active fixture file exists; cleanup or reuse it first');
+ }
+ write(fixture);
+ for (const [kind, name, permissions] of [
+ ['reader', '权限实测·模型只读', ['dashboard.view', 'content.model']],
+ ['editor', '权限实测·模型编辑', ['dashboard.view', 'content.model', 'content.model.create', 'content.model.update', 'content.model.delete', 'content.model.publish']],
+ ]) {
+ const made = await call('auth/v1/roles', token, { code: `rv_${kind}_${suffix}`, name, shortName: '测', description: 'UTE2E-PERMISSIONS-20260906', dataScope: 'ALL', enabled: true, sortOrder: 90, departmentScopeIds: [] });
+ check(`创建可配置的${kind}测试角色`, made.status === 200);
+ fixture.roleIds.push(made.data.id); fixture[kind] = made.data; write(fixture);
+ const saved = await call(`auth/v1/permissions/roles/${made.data.id}`, token, { permissionCodes: permissions, version: made.data.dataVersion }, 'PUT');
+ check(`保存${kind}细分授权`, saved.status === 200);
+ }
+ const password = `Rv!${crypto.randomBytes(7).toString('hex')}`;
+ const userCommand = { username: `rv_perm_${suffix}`, displayName: '权限与角色切换实测', departmentId: me.departmentId, roleIds: [teacherRole.id, studentRole.id, ...fixture.roleIds], defaultRoleId: teacherRole.id, enabled: true, password, remark: 'UTE2E-PERMISSIONS-20260906' };
+ const madeUser = await call('auth/v1/users', token, userCommand);
+ check('建立隔离的多角色测试账号', madeUser.status === 200);
+ fixture.user = { ...madeUser.data, password }; write(fixture);
+ const session = await call('auth/v1/auth/login', null, { userId: fixture.user.id, password, roleCode: 'teacher', departmentId: me.departmentId, rememberMe: false });
+ check('多角色测试账号正常登录', session.status === 200);
+ let actor = session.data.accessToken;
+ for (const kind of ['reader', 'editor']) {
+ const switched = await call('auth/v1/auth/active-role', actor, { roleId: fixture[kind].id }, 'PUT');
+ check(`接口切换到${kind}角色`, switched.status === 200);
+ actor = switched.data?.accessToken || actor;
+ const detail = await call('auth/v1/auth/me', actor);
+ check(`${kind}当前身份独立生效`, String(detail.data.activeRoleId) === String(fixture[kind].id));
+ const list = await call('tran/v1/content/projects?type=MODEL&size=10', actor);
+ check(`${kind}拥有模型查看权限`, list.status === 200);
+ const project = await call('tran/v1/content/projects', actor, { type: 'MODEL', code: `RV-PERM-${suffix}-${kind}`, name: '权限实测临时模型', categoryCode: 'EQUIPMENT', content: {}, dependencies: [], assets: [] });
+ if (kind === 'reader') check('只有查看权限时,直接新建接口返回403', project.status === 403);
+ else {
+ check('自定义非教员角色获得新增权限即可创建模型', project.status === 200);
+ assert.ok(project.data?.project?.id, 'Created project returns a stable project ID');
+ fixture.projectIds.push(project.data.project.id); write(fixture);
+ }
+ }
+ await call('auth/v1/auth/logout', actor, {});
+ const adminWrite = await call('tran/v1/content/projects', token, { type: 'MODEL', name: '禁止生成的模型', content: {} });
+ check('普通管理员模型查看与创建权限相互独立', adminWrite.status === 403);
+ fs.writeFileSync(path.join(report, 'api-checks.json'), JSON.stringify({ time: new Date().toISOString(), checks, catalogCount: permissionItems.length, adminPermissionCount: grants.permissionCodes.length }, null, 2));
+ console.log(JSON.stringify({ passed: checks.length, fixturesReady: true }));
+ } finally {
+ await call('auth/v1/auth/logout', token, {}).catch(() => {});
+ await call('auth/v1/auth/logout', rootToken, {}).catch(() => {});
+ }
+}
+
+async function cleanup() {
+ const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf8'));
+ const session = await login('root'); const token = session.accessToken;
+ const result = { time: new Date().toISOString(), projects: [], users: [], roles: [] };
+ try {
+ for (const id of fixture.projectIds) {
+ const existing = await call(`tran/v1/content/projects/${id}`, token);
+ if (existing.status === 404) continue;
+ assert.equal(existing.status, 200, 'Read isolated project before cleanup');
+ const project = existing.data.project;
+ assert.equal(project.name, '权限实测临时模型', 'Only isolated fixture projects can be removed');
+ assert.equal(String(project.ownerUserId), String(fixture.user.id), 'Fixture project must belong to this run’s temporary account');
+ assert.ok(project.addTime >= Math.floor(Date.parse(fixture.createdAt) / 1000), 'Fixture project must have been created during this run');
+ const removed = await call(`tran/v1/content/projects/${id}?version=${project.version}`, token, undefined, 'DELETE');
+ assert.equal(removed.status, 200, 'Remove isolated project'); result.projects.push(String(id));
+ }
+ if (fixture.user?.id) {
+ const existing = await call(`auth/v1/users/${fixture.user.id}`, token);
+ if (existing.status !== 404) {
+ assert.equal(existing.status, 200);
+ assert.equal(existing.data.remark, 'UTE2E-PERMISSIONS-20260906');
+ const removed = await call(`auth/v1/users/${fixture.user.id}`, token, { version: existing.data.version ?? existing.data.dataVersion }, 'DELETE');
+ assert.equal(removed.status, 200, 'Remove isolated test account'); result.users.push(String(fixture.user.id));
+ }
+ }
+ for (const id of fixture.roleIds) {
+ const existing = await call(`auth/v1/roles/${id}`, token);
+ if (existing.status === 404) continue;
+ assert.equal(existing.status, 200);
+ assert.equal(existing.data.description, 'UTE2E-PERMISSIONS-20260906');
+ const removed = await call(`auth/v1/roles/${id}`, token, { version: existing.data.version ?? existing.data.dataVersion }, 'DELETE');
+ assert.equal(removed.status, 200, 'Remove isolated test role'); result.roles.push(String(id));
+ }
+ fixture.cleanedAt = result.time; if (fixture.user) delete fixture.user.password; write(fixture);
+ fs.writeFileSync(path.join(report, 'cleanup.json'), JSON.stringify({ ...result, status: 'PASS' }, null, 2));
+ console.log(JSON.stringify({ cleanup: 'PASS', projects: result.projects.length, users: result.users.length, roles: result.roles.length }));
+ } finally { await call('auth/v1/auth/logout', token, {}).catch(() => {}); }
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const cleanupMode = process.argv.includes('--cleanup');
+ (cleanupMode ? cleanup() : prepare()).catch(error => {
+ fs.writeFileSync(path.join(report, cleanupMode ? 'cleanup.json' : 'api-checks.json'), JSON.stringify({ time: new Date().toISOString(), status: 'FAIL', ...(cleanupMode ? {} : { checks }), error: error.message }, null, 2));
+ console.error(error.message); process.exitCode = 1;
+ });
+}
diff --git a/tools/scene-cover-api.test.mjs b/tools/scene-cover-api.test.mjs
index 732a739..b1113f6 100644
--- a/tools/scene-cover-api.test.mjs
+++ b/tools/scene-cover-api.test.mjs
@@ -166,7 +166,7 @@ const saveMethod = editorSource.slice(editorSource.indexOf(' async saveProject(
const { saveProject } = new Function(`return ({ ${saveMethod} })`)()
function editorFixture(saveDocument) {
return {
- readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, dirty: false, changeRevision: 0,
+ readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, publishing: false, dirty: false, changeRevision: 0,
context: { saveDocument }, serialize: () => ({ objects: [] }), applyAccessMode() {}, setSaveState() {},
updateHistoryControls() {}, messages: [], toast(message, type) { this.messages.push({ message, type }) },
}
@@ -221,16 +221,44 @@ function publishingEditor(saveDocument) {
}
const hostSource = readFileSync(new URL('../../unreal_tran_web/src/views/content/LegacyScenePageView.vue', import.meta.url), 'utf8')
-const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave()'), hostSource.indexOf('function resetProjectSession()'))
+const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave('), hostSource.indexOf('function resetProjectSession()'))
const leaveCompiled = ts.transpileModule(leaveSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText
-function leaveEditor(editor) {
- return new Function('editorHandle', 'ElMessageBox', 'ElMessage', `${leaveCompiled}\nreturn confirmLeave()`)(
+function leaveEditor(editor, {
+ operations = new LegacySceneOperationQueue(), hostLoading = false,
+ confirm = async () => undefined, preserveOnDiscard = false,
+} = {}) {
+ return new Function('editorHandle', 'ElMessageBox', 'ElMessage', 'operationQueue', 'loading', 'preserveOnDiscard', `${leaveCompiled}\nreturn confirmLeave({ preserveOnDiscard })`)(
editor,
- { confirm: async () => undefined },
- { error: message => editor.toast(message, 'error') },
+ { confirm },
+ { error: message => editor.toast(message, 'error'), info: message => editor.toast(message, 'info') },
+ operations, { value: hostLoading }, preserveOnDiscard,
)
}
+test('publisher-only scene publishes the saved server version without attempting save or uploads', async () => {
+ const editor = publishingEditor(() => { throw new Error('must not save') })
+ editor.readOnly = true
+ editor.assetStore.validateReferences = () => { throw new Error('must not validate local mutation state') }
+ await publishScene.call(editor)
+ assert.equal(editor.publishCalls, 1)
+ assert.equal(editor.messages.at(-1).type, 'success')
+})
+
+test('update-only scene never starts saving as a side effect of denied publication', async () => {
+ const editor = publishingEditor(() => { throw new Error('must not save') })
+ editor.canPublish = false
+ await publishScene.call(editor)
+ assert.equal(editor.publishCalls, 0)
+})
+
+test('discard while switching roles preserves dirty recovery until all guards and role PUT succeed', async () => {
+ const editor = publishingEditor(async () => ({}))
+ editor.dirty = true
+ const result = await leaveEditor(editor, { confirm: async () => { throw 'cancel' }, preserveOnDiscard: true })
+ assert.equal(result, true)
+ assert.equal(editor.dirty, true)
+})
+
for (const status of [409, 503]) {
test(`publish after PUT ${status} stays blocked and reports the save failure only once`, async () => {
const editor = publishingEditor(async () => { throw Object.assign(new Error(`HTTP ${status}`), { status }) })
@@ -265,3 +293,85 @@ for (const action of ['publish', 'leave']) {
assert.doesNotMatch(editor.messages[0].message, /保存失败|未保存成功/)
})
}
+
+for (const state of ['host loading', 'restoring models', 'uploading model']) {
+ test(`role switch blocks ${state} even before the scene becomes dirty`, async () => {
+ const editor = publishingEditor(async () => ({}))
+ if (state === 'restoring models') editor.readySettled = false
+ if (state === 'uploading model') editor.pendingLoads = 1
+ const allowed = await leaveEditor(editor, {
+ hostLoading: state === 'host loading',
+ confirm: () => assert.fail('must not offer discard during resource loading'),
+ })
+ assert.equal(allowed, false)
+ assert.equal(editor.dirty, false)
+ assert.equal(editor.messages.length, 1)
+ assert.match(editor.messages[0].message, /加载或上传/)
+ })
+}
+
+for (const outcome of ['saved', 'failed', 'new edits']) {
+ test(`role switch waits for cover upload and save before checking final dirty: ${outcome}`, async () => {
+ const operations = new LegacySceneOperationQueue()
+ const barrier = defer()
+ const editor = publishingEditor(() => operations.enqueue(() => barrier.promise))
+ // Saving may start from a clean scene (for example, replacing its cover).
+ const saving = editor.saveProject()
+ let settled = false
+ const leaving = leaveEditor(editor, {
+ operations, confirm: () => assert.fail('must not bypass the current save with a second prompt'),
+ }).then(result => { settled = true; return result })
+ await new Promise(resolve => setImmediate(resolve))
+ assert.equal(editor.saving, true)
+ assert.equal(settled, false)
+ if (outcome === 'failed') barrier.reject(new Error('HTTP 409'))
+ else {
+ if (outcome === 'new edits') editor.changeRevision += 1
+ barrier.resolve({})
+ }
+ await saving
+ assert.equal(await leaving, outcome === 'saved')
+ assert.equal(editor.dirty, outcome !== 'saved')
+ assert.equal(editor.messages.length, 1)
+ if (outcome === 'failed') assert.match(editor.messages[0].message, /HTTP 409/)
+ })
+}
+
+test('role switch waits through validation, save, and the later queued publish after dirty clears', async () => {
+ const operations = new LegacySceneOperationQueue()
+ const savingBarrier = defer()
+ const publishingBarrier = defer()
+ const editor = publishingEditor(() => operations.enqueue(() => savingBarrier.promise))
+ editor.context.publishDocument = () => operations.enqueue(async () => {
+ editor.publishCalls += 1
+ await publishingBarrier.promise
+ })
+ const publishing = publishScene.call(editor)
+ let settled = false
+ const leaving = leaveEditor(editor, { operations }).then(result => { settled = true; return result })
+ await new Promise(resolve => setImmediate(resolve))
+ assert.equal(settled, false)
+ savingBarrier.resolve({})
+ await new Promise(resolve => setImmediate(resolve))
+ assert.equal(editor.dirty, false)
+ assert.equal(editor.publishCalls, 1)
+ assert.equal(editor.publishing, true)
+ assert.equal(settled, false)
+ publishingBarrier.resolve()
+ await publishing
+ assert.equal(await leaving, true)
+})
+
+test('role switch rechecks resource uploads started while the save was finishing', async () => {
+ const operations = new LegacySceneOperationQueue()
+ const barrier = defer()
+ const editor = publishingEditor(() => operations.enqueue(() => barrier.promise))
+ const saving = editor.saveProject()
+ const leaving = leaveEditor(editor, { operations })
+ editor.pendingLoads = 1
+ barrier.resolve({})
+ await saving
+ assert.equal(await leaving, false)
+ assert.equal(editor.pendingLoads, 1)
+ assert.match(editor.messages.at(-1).message, /加载或上传/)
+})
diff --git a/tools/training-cover.test.mjs b/tools/training-cover.test.mjs
index 1cf6197..548fe06 100644
--- a/tools/training-cover.test.mjs
+++ b/tools/training-cover.test.mjs
@@ -142,6 +142,7 @@ function editorFixture() {
const pending = new Promise(done => { resolve = done })
const editor = new EditorMethods()
Object.assign(editor, {
+ can: () => true, readOnly: false,
project: { id: '161', status: 'draft', scenario: {}, steps: [] }, storage: {}, editRevision: 1,
persisting: false, dirty: true, serverPending: true, root: { querySelector: () => null, querySelectorAll: () => [] },
onPersist: () => pending, assetStore: { validateReferences: async () => ({ valid: true }) },
@@ -149,6 +150,35 @@ function editorFixture() {
for (const name of ['renderSaveState', 'renderProjects', 'renderProjectHeader', 'rememberSavedBaseline', 'replaceCurrentHistoryState', 'renderAll', 'toast', 'showValidation']) editor[name] = () => {}
return { editor, resolve }
}
+
+test('training without update permission cannot manually save or autosave its recovery buffer', async () => {
+ const { editor } = editorFixture()
+ editor.readOnly = true
+ editor.can = () => false
+ editor.onPersist = () => { throw new Error('no persistence permitted') }
+ assert.equal(await editor.saveDraft({ notify: true }), null)
+ assert.equal(await editor.saveDraft(), null)
+ assert.equal(await editor.publishProject(), null)
+})
+
+test('publisher-only training sends one publish intent without changing or saving local draft', async () => {
+ const { editor } = editorFixture()
+ editor.readOnly = true
+ editor.can = permission => permission === 'content.training.publish'
+ let calls = 0
+ editor.assetStore.validateReferences = () => { throw new Error('must use saved server version') }
+ editor.onPersist = async payload => { calls += 1; assert.equal(payload.reason, 'publish'); return { status: 'published' } }
+ assert.ok(await editor.publishProject())
+ assert.equal(calls, 1)
+ assert.equal(editor.project.status, 'published')
+})
+
+test('update-only training never invokes host persistence for a denied publish action', async () => {
+ const { editor } = editorFixture()
+ editor.can = permission => permission === 'content.training.update'
+ editor.onPersist = () => { throw new Error('no publication permitted') }
+ assert.equal(await editor.publishProject(), null)
+})
test('manual save remains pending until server acknowledgment and blocks duplicate writes', async () => {
const f = editorFixture()
const pending = f.editor.saveDraft({ notify: true })