feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五): - 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库 (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖 接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环 - 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis, 空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底 - V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里) 与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129 - 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外); 平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存) - RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 —— 修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红 - scripts/e2e-m10-role.py 77/77 需求收敛(交接摘要 §一·三十六,本节的数字为准): - 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、 加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列; 审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支 - V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有 别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集, 存量账号会"能登录、处处 40301",不抛异常不打日志 - 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、 小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条) - UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭 (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常) - 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、 存储自检探针内容;学生端 app 名与全局导航栏标题
This commit is contained in:
+124
-8
@@ -21,7 +21,7 @@
|
||||
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
|
||||
而不是靠下次再想一遍)。
|
||||
|
||||
## 查七件事
|
||||
## 查九件事
|
||||
|
||||
| # | 查什么 | 没接上的后果 |
|
||||
|:--:|---|---|
|
||||
@@ -32,6 +32,8 @@
|
||||
| 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 |
|
||||
| 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
|
||||
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
|
||||
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
|
||||
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
|
||||
|
||||
### 第 7 项是 2026-10-09 补的(自己踩了一次)
|
||||
|
||||
@@ -42,6 +44,20 @@
|
||||
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
|
||||
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
|
||||
|
||||
### 第 8 / 9 项是 2026-10 补的(重搭 PC 端之后)
|
||||
|
||||
第 8 项:仓库**没有 auto-import**,漏 import 的 `<n-button>` 被当成未知自定义元素,
|
||||
只丢样式不报错(实测 4 个文件 6 处)。
|
||||
|
||||
第 9 项:SVG 是 XML,**注释里出现连续两个减号**(比如顺手写下 CSS 自定义属性的
|
||||
写法)就会让整个文件 not well-formed。浏览器对 `<img src="x.svg">` 的失败完全静默 ——
|
||||
控制台不打、`vue-tsc` 不打、`eslint` 不打,页面上只是那个图标裂开。
|
||||
换品牌标时连踩两次:`logo.svg` 的注释里写了变量名,登录页与侧栏的标直接裂图,
|
||||
而 typecheck / lint / 本脚本全绿。
|
||||
|
||||
后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。
|
||||
放这里是因为它想要的就是"一条命令把这一族扫一遍"。
|
||||
|
||||
|
||||
## 它**不查**什么(免得被当成"全绿就没事")
|
||||
|
||||
@@ -61,6 +77,7 @@ import glob
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import xml.dom.minidom
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
@@ -78,8 +95,6 @@ XT_ROUTES = 'frontend-pc/src/config/xt-routes.ts'
|
||||
# ── 已知例外:**有意不接线的**,写在这里(连同理由),不要靠下次再想一遍 ──
|
||||
# 权限码:没有任何地方校验它(既没有 @RequirePerm,也没有服务层的 permissionChecker)
|
||||
PERM_NO_CHECK = {
|
||||
'ROLE_MANAGE': '「角色与授权」页是二期功能(权限码先定下来,见 08 文档 6n):'
|
||||
'一期不开放自定义角色,所以没有任何接口需要它',
|
||||
'ROSTER_MANAGE_OWN': '「本班名册」不是独立接口,而是 roster 接口里的**数据范围**分支'
|
||||
'(服务层按角色判 orgIds),接口级权限用 ROSTER_MANAGE / ROSTER_VIEW',
|
||||
'PROGRESS_VIEW_OWN': '同上:进度看板的"本班/本人"是同一接口的范围分支(见 ProgressService)',
|
||||
@@ -291,9 +306,9 @@ def strip_js_comments(src):
|
||||
def template_send_points(sources, code, tpl_file):
|
||||
"""站内信模板的"发送点" = 代码里有人把这个码当成模板码用。
|
||||
|
||||
⚠️ 判据不能用"标识符出现次数":`JOIN_APPROVE` 同时是
|
||||
`OperationLogService.ACTION_JOIN_APPROVE`(操作日志动作码)与
|
||||
`NotificationService.TPL_JOIN_APPROVE`(微信订阅消息模板码)的值 ——
|
||||
⚠️ 判据不能用"标识符出现次数":模板码常常**同时是别的常量的值** ——
|
||||
例如 `URGE` 既是站内信的模板码,又是催报业务类型(`SUBJECT_URGE`)的名字;
|
||||
早先的 `JOIN_APPROVE` 更是同时挂在操作日志动作码与微信订阅消息模板码上。
|
||||
按标识符数会得出"3 处引用",而它当时**一个发送点都没有**。
|
||||
|
||||
所以认两件事:
|
||||
@@ -488,8 +503,13 @@ def main():
|
||||
|
||||
# ── ⑦ PC 端引用的权限码 ↔ PermissionDef ─────────────────────
|
||||
#
|
||||
# 两个写法都认:`userStore.permissions.includes('org:view')` 与
|
||||
# `v-hasPerm="['audit:final']"`(后者是模板里写的指令参数)。
|
||||
# 三种写法都认:
|
||||
# · `userStore.permissions.includes('org:view')`(重搭前的写法,保留兼容)
|
||||
# · `v-hasPerm="['audit:final']"`(重搭前的指令参数,保留兼容)
|
||||
# · `user.has('task:manage')` / `user.hasAny('a','b')`
|
||||
# —— **2026-10 重搭为纯 Vue 3 + Naive 后的唯一写法**(已无 v-hasPerm 指令)。
|
||||
# ⚠️ 少了这一条,本项会永远报"PC 端引用的权限码 0 个":
|
||||
# 看起来是"通过",实际是**检查器失效**(安全网静默消失,正是本脚本要防的那类错)。
|
||||
#
|
||||
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
|
||||
# 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。
|
||||
@@ -507,7 +527,15 @@ def main():
|
||||
for i, line in enumerate(src.split('\n'), 1):
|
||||
for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line):
|
||||
fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i))
|
||||
# `user.has('码')` / `user.hasAny('码', ...)`。
|
||||
# ⚠️ 这里加了 `域:动作` 的形状约束:`.has(` 在别处也用于 Set/Map
|
||||
# (`changed.has(key)`、`set.has(r.orgId)`),不约束形状会收进一堆假阳性。
|
||||
# 本项目的权限码一律是 `域:动作` 形状(见 PermissionDef)。
|
||||
for m in re.finditer(r"\.has(?:Any)?\(([^)]*)\)", line):
|
||||
for c in re.findall(r"'([a-zA-Z]+:[a-zA-Z]+)'", m.group(1)):
|
||||
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
|
||||
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
|
||||
# (指令本身已随 2026-10 重搭移除,这里保留是为了旧分支/文档示例仍能被查出来)
|
||||
if is_vue:
|
||||
for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line):
|
||||
for c in re.findall(r"'([^']+)'", m.group(1)):
|
||||
@@ -526,6 +554,94 @@ def main():
|
||||
'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where))
|
||||
say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where))
|
||||
|
||||
# ── ⑧ PC 端 `<n-*>` 组件 ↔ import ────────────────────────────
|
||||
#
|
||||
# ⚠️ 为什么它属于「声明了但没接上」:本仓库**没有 auto-import**(见 frontend-pc/AGENTS.md
|
||||
# 第 1 条),漏 import 的后果是 Vue 把它当成**未知自定义元素**渲染 ——
|
||||
# 不报错、不崩、lint 与 vue-tsc 也全绿,只是样式整块丢掉。
|
||||
# 2026-10 重搭成纯 Vue 3 + Naive 后实测踩到 4 个文件 6 处:
|
||||
# `<n-button>` 变成无边框透明块、`<n-avatar>` 变成 18×29 的裸元素、
|
||||
# `<n-form>` 的表单排版全丢 —— 表现就是"这个页面样式坏了",但控制台只给一句 warn。
|
||||
#
|
||||
# 判定方式:在 `<template>` 段里出现的 `<n-xxx>`,其 PascalCase 名字必须在本文件
|
||||
# 的某个 `import { ... } from '...'` 里出现过。
|
||||
naive_missing = []
|
||||
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)):
|
||||
try:
|
||||
raw = read(p)
|
||||
except Exception:
|
||||
continue
|
||||
m = re.search(r'<template>(.*)</template>', raw, re.S)
|
||||
if not m:
|
||||
continue
|
||||
used = set(re.findall(r'<(n-[a-z0-9-]+)[\s/>]', m.group(1)))
|
||||
if not used:
|
||||
continue
|
||||
imported = set()
|
||||
for im in re.finditer(r'import\s+([^;]+?)\s+from\s+[\'"][^\'"]+[\'"]', raw, re.S):
|
||||
clause = im.group(1)
|
||||
brace = re.search(r'\{([^}]*)\}', clause, re.S)
|
||||
if brace:
|
||||
for item in brace.group(1).split(','):
|
||||
item = item.split(' as ')[-1].strip()
|
||||
if item:
|
||||
imported.add(item)
|
||||
head = clause.split('{')[0].strip().rstrip(',')
|
||||
if head and not head.startswith('*'):
|
||||
imported.add(head)
|
||||
for tag in sorted(used):
|
||||
comp = ''.join(part.capitalize() for part in tag.split('-'))
|
||||
if comp not in imported:
|
||||
naive_missing.append((p, tag, comp))
|
||||
|
||||
say('\n【⑧】PC 端 `<n-*>` 组件未 import 的 %d 处' % len(naive_missing))
|
||||
seen_files = set()
|
||||
for p, tag, comp in naive_missing:
|
||||
problems.append(('未 import 的组件', '<%s>' % tag,
|
||||
'%s 里没 import %s(会被当成未知元素渲染,样式整块丢失)'
|
||||
% (p, comp)))
|
||||
if p not in seen_files:
|
||||
seen_files.add(p)
|
||||
say(' %s' % p)
|
||||
say(' ✗ <%-16s 需要 import { %s } from \'naive-ui\'' % (tag + '>', comp))
|
||||
if not naive_missing:
|
||||
say(' ✓ 模板里用到的 <n-*> 组件都有对应 import')
|
||||
|
||||
# ── ⑨ SVG 资产是不是合法 XML ──────────────────────────────
|
||||
#
|
||||
# ⚠️ 为什么它属于这一族:SVG 是 XML,**注释里出现连续两个减号**
|
||||
# (比如顺手写下 CSS 自定义属性的写法)就会让整个文件 not well-formed。
|
||||
# 浏览器对 `<img src="x.svg">` 的失败**完全静默** ——
|
||||
# 控制台不打、vue-tsc 不打、eslint 不打,页面上只是那个图标裂开。
|
||||
# 2026-10 换品牌标时连踩两次:logo.svg 注释里写了变量名,
|
||||
# 登录页(34px)与侧栏(24px)的标一起变成裂图,而三样检查全绿。
|
||||
#
|
||||
# 判定方式:拿标准库把每个 .svg 当 XML 解析一遍 —— 不需要浏览器,
|
||||
# 也不依赖某个具体 SVG 特性。
|
||||
svg_paths = []
|
||||
for pattern in ('frontend-pc/src/**/*.svg', 'frontend-pc/public/**/*.svg',
|
||||
'frontend-mp/src/**/*.svg', 'backend/**/*.svg'):
|
||||
svg_paths.extend(glob.glob(pattern, recursive=True))
|
||||
svg_paths = sorted(p for p in set(svg_paths)
|
||||
if '/node_modules/' not in p and '\\node_modules\\' not in p
|
||||
and '/dist/' not in p and '\\dist\\' not in p
|
||||
and '/target/' not in p and '\\target\\' not in p)
|
||||
|
||||
svg_bad = []
|
||||
for p in svg_paths:
|
||||
try:
|
||||
xml.dom.minidom.parse(p)
|
||||
except Exception as e:
|
||||
svg_bad.append((p, str(e)))
|
||||
|
||||
say('\n【⑨】SVG 资产 %d 个 / XML 非法的 %d 个' % (len(svg_paths), len(svg_bad)))
|
||||
for p, why in svg_bad:
|
||||
problems.append(('SVG 资产', p,
|
||||
'%s(浏览器静默不渲染:图标裂开,任何检查都不报)' % why))
|
||||
say(' ✗ %s\n %s' % (p, why))
|
||||
if not svg_bad:
|
||||
say(' ✓ 全部能被解析为合法 XML')
|
||||
|
||||
# ── 汇总 ────────────────────────────────────────────────
|
||||
print('\n' + '=' * 88)
|
||||
if notes:
|
||||
|
||||
Reference in New Issue
Block a user