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))