通用Admin管理系统设计

words: 8.7k    views:    time: 41min

一般的管理系统都会涉及一些用户部门角色权限之类的管理,比如ERP/CRM/OA这些系统早都已经很多成熟的产品以及案例。这里目标是希望设计一套足够简单,能够方便自己进行复用和扩展的系统,主要内容也就是一些表结构设计,也参考了开源管理系统若依的一些经验,尤其是菜单权限设计。

用户部门岗位

  • 从外部系统同步数据

我们的数据模型优先以自增id作为主键,但是在一些需要从外部系统接入数据的场景中,没有办法要求别人数据与我们的id保持一致。所以额外定义了一个code字段,用来记录对方数据的标识信息,这样数据接入过来之后还是统一用自增id作为表示,同时也保持了与外部数据id的对应关系。

另外可能也确实有一些需要展示编码的场景,比如单位编码、岗位编码等。

  • 数据关系

自身层级关系:对于用户、部门、岗位,数据本身可能就存在上下级关系,并且可能是多个上级,所以分别都额外定义一个Tree关系表;

部门与岗位关系:多对多关系,一个部门会有多个岗位,但一个岗位也可能存在于多个部门;

用户与部门岗位关系:用户可能存在于一个或多个部门中;也可能兼任一个或多个岗位;或者兼任一个或多个不同部门的多个不同岗位;

  • 关系缓存

为了方便Tree数据在界面查询过滤,我们会提前构建以下缓存

用户树缓存:Tree:user

单位树缓存:Tree:dept

岗位树缓存:Tree:post

单位用户树缓存:Tree:dept-user

单位岗位树缓存:Tree:dept-post

使用缓存存在另一个问题,如果数据发生了变化,应该在什么时机更新缓存?而且这可能是一个耗时操作,如果放在每次数据变化后更新,那么会增加请求耗时降低用户体验。所以我们提供了一个刷新缓存操作,交给用户自己来决定何时更新缓存,他可以在批量操作之后再刷新缓存,我们觉得以上这些并不是那种会频繁变动的业务数据,让用户自己手动刷新缓存也是能够接受的。如果因为数据量大导致刷新延时,那也是可预期的,好过那种由于刷新缓存导致其它增删改查操作出现不可预期的延时。

很多场景中,我们可能是获取一个子Tree,以下是使用广度优先进行遍历获取子树的示例

Tree:dept
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static final String KEY_DEPT = "Tree:dept";

public List<Tree<String>> tree(String deptId) {
Tree<String> root = redis.getValue(KEY_DEPT);
// 如果没有传deptId,或者传的根部门id,则返回整棵树
if(deptId == null || deptId.equals(root.getId())) {
return List.of(root);
}

// 空树
if(CollectionUtils.isEmpty(root.getChildren())) {
return List.of(new Tree<>());
}

// 广度优先,如果节点id与deptId一样则返回
Deque<Tree<String>> queue = new LinkedList<>(root.getChildren());
while (!queue.isEmpty()) {
root = queue.pop();
if(Objects.equals(deptId, root.getId())) {
return List.of(root);
}
if(CollectionUtils.isNotEmpty(root.getChildren())) {
queue.addAll(root.getChildren());
}
}
return List.of(new Tree<>());
}

角色菜单权限

目录:本身不跳转页面,一般只是控制菜单的收缩展开

菜单:具体的页面

按钮:页面中的操作

  • 菜单控制

以上是定义的三种菜单类型,我们希望能对界面的菜单进行动态控制:

对于(展示)目录和菜单,可以通过角色与用户进行关联,即给角色分配菜单,再给用户分配角色,然后只返回那些与用户关联或者本身是公开的菜单;

对于(操作)按钮,可以通过权限符来控制。我们在菜单上添加一个权限符标识,这里的菜单可以是一个具体的菜单或按钮,也可以是一个抽象的操作权限定义。相当于在按钮与角色之间添加了一层权限,这样就可以给一个权限分配多个菜单(多个菜单共用一个权限符),然后给一个角色分配多个权限,再给用户分配多个角色。

在实际操作中,我们还是统一以菜单来进行分配操作,因为菜单展示更直观一些,而且大多情况下,操作权限与菜单按钮是一一对应的(如果控制粒度够细的话)。如果对角色分配到的菜单进行权限符聚合去重,就可以得到角色的权限了(这样有个缺点,如果A和B共用权限标识,那么给角色分配了A菜单也就分配了B菜单,但用户可能不想这样,需要通过细化权限粒度解决)。

以下给普通角色分配菜单权限,这里只读控制权限就控制了所有界面中的只读按钮,当然也可以细化给每个只读按钮定义一个权限

在登录返回的Token信息中会包含用户的权限集合,那么可以根据用户的权限,以及按钮所绑定的权限符,来决定是显示还是移除按钮

hasPermi.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import store from '@/store'

export default {
inserted(el, binding, vnode) {
const { value } = binding
const permissions = store.getters && store.getters.permissions

if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value

const hasPermissions = permissions.some(permission => {
return "*:*:*" === permission || permissionFlag.includes(permission)
})

if (!hasPermissions) {
el.parentNode && el.parentNode.removeChild(el)
}
}
}
}

可以简单通过标签在目标按钮上绑定权限符

user/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini"
@click="handleAdd" v-hasPermi="['sys:user:new']">{{$t('route.system.user.new')}}</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single"
@click="handleUpdate" v-hasPermi="['sys:user:edit']">{{$t('route.system.user.edit')}}</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple"
@click="handleDelete" v-hasPermi="['sys:user:delete']">{{$t('route.system.user.delete')}}</el-button>
</el-col>
</el-row>

类似的,也可以通过角色来进行按钮元素控制

hasRole.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import store from '@/store'

export default {
inserted(el, binding, vnode) {
const { value } = binding
const roles = store.getters && store.getters.roles

if (value && value instanceof Array && value.length > 0) {
const roleFlag = value

const hasRole = roles.some(role => {
return "sysAdmin" === role || roleFlag.includes(role)
})

if (!hasRole) {
el.parentNode && el.parentNode.removeChild(el)
}
}
}
}
  • 接口权限

对应的在后端服务中,也可以根据用户权限进行判断

PermissionService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@ConditionalOnClass(WebSecurityConfigurerAdapter.class)
@Service("permit")
public class PermissionService {

public boolean isAdmin() {
List<String> roles = Access.userRoles();
if(CollectionUtils.isEmpty(roles)) {
return false;
}
return roles.contains("sysAdmin");
}

public boolean hasPermit(String permission) {
if(isAdmin()) {
return true;
}

List<String> perms = Access.userPermissions();
if(CollectionUtils.isEmpty(perms)) {
return false;
}
return perms.contains(permission);
}

public boolean hasRole(String role) {
if(isAdmin()) {
return true;
}
List<String> roles = Access.userRoles();
if(CollectionUtils.isEmpty(roles)) {
return false;
}
return roles.contains(role);
}
}

然后通过@PreAuthorize给接口绑定所需要的权限

SysUserController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@PreAuthorize("@permit.hasPermit('sys:user:new')")
@PostMapping("/add")
public Response<Void> add(@Validated @RequestBody SysUser sysUser) {
sysUserService.add(sysUser);
return Response.success();
}

@PreAuthorize("@permit.hasPermit('sys:user:edit')")
@PostMapping("/edit")
public Response<SysUser> edit(@Validated @RequestBody SysUser sysUser) {
return Response.success(sysUserService.edit(sysUser));
}

@PreAuthorize("@permit.hasPermit('sys:user:delete')")
@GetMapping(value = {"/delete"})
public Response<List<SysUser>> delete(@NotNull(message = "user.notnull.id") Long[] userId) {
return Response.success(sysUserService.delete(userId));
}

字典信息

  • 分组分类

为了方便字典管理,一般都会字典信息进行分组分类,但是又如何来定义字典的分组和分类?我们不希望要维护额外的信息,所以想法是直接用字典来定义字典的分组和分类,可以使用parent_code来给字典码添加层级关系,然后定义一个根字典dict_root,接着可以在根字典下面添加分组字典,在分组字典下面添加类型字典,最后在类型字典下面再添加具体的字典,像下面这样

其实有了层级关系,就可以构造任意多层字典关系,不过大多场景下有了分组分类两层关系也就足够了,这样也方便直接查询出所有的分组分类

options
1
2
3
4
5
6
7
8
9
10
select t.group_code,
case t.group_code when 'dict_root' then '根字典' else d1.dict_label end group_name,
case t.group_code when 'dict_root' then 'dict_root' else d1.dict_label end group_en,
t.type_code, d2.dict_label type_name, d2.dict_en type_en
from(with groups as (select parent_code, dict_code from sys_dict where parent_code = 'dict_group')
select d1.parent_code as group_code, d1.dict_code as type_code from sys_dict d1 where d1.parent_code = 'dict_root' union all
select groups.parent_code as group_code, groups.dict_code as type_code f rom groups union all
select d.parent_code as group_code, d.dict_code as type_code from sys_dict d, groups where d.parent_code = groups.dict_code) t
left join sys_dict d1 on t.group_code = d1.dict_code
left join sys_dict d2 on t.type_code = d2.dict_code
  • 字典缓存

可以抽象出一个接口,方便进行统一的缓存操作

Dict.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface Dict {

String getGroupCode(); // 字典分组

String getTypeCode(); // 字典类型

String getDictCode(); // 字典码

String getDictLabel(); // 字典名称

Object getDictValue(); // 字典值

Integer getDictOrder(); // 字典排序
}

通过关联查询,可以列出所有字典及对应的分组分类

list
1
2
3
4
5
6
7
8
9
10
11
12
select
case d.parent_code when 'dict_root' then '根字典' when 'dict_group' then '根字典' else g.dict_label end groupName,
case d.parent_code when 'dict_root' then 'dict_root' when 'dict_group' then 'dict_root' else g.dict_en end groupEn,
case d.parent_code when 'dict_root' then 'dict_root' when 'dict_group' then 'dict_root' else g.dict_code end groupCode,
case d.parent_code when 'dict_root' then '根字典' else t.dict_label end typeName,
case d.parent_code when 'dict_root' then 'dict_root' else t.dict_en end typeEn,
case d.parent_code when 'dict_root' then 'dict_root' else t.dict_code end typeCode,
d.id, d.dict_code dictCode, d.dict_label dictLabel, d.dict_en dictEn, d.dict_value dictValue,
from sys_dict d
left join sys_dict t on d.parent_code = t.dict_code
left join sys_dict g on t.parent_code = g.dict_code
order by d.dict_order, d.parent_code

然后我们构建如下三个缓存Key

DictHelper.java
1
2
3
4
5
6
7
8
9
10
11
12
13
private static final String KEY_DICT = "sys-dict:dict:";
private static final String KEY_TYPE = "sys-dict:type:";
private static final String KEY_GROUP = "sys-dict:group:";

public void put(Dict dict) {
if(!"dict_root".equals(dict.getTypeCode())){
redis.putMapValue(KEY_GROUP + dict.getGroupCode(), dict.getDictCode(), dict);
if(!"dict_root".equals(dict.getGroupCode())){
redis.putMapValue(KEY_TYPE + dict.getTypeCode(), dict.getDictCode(), dict);
}
}
redis.putValue(KEY_DICT + dict.getDictCode(), dict);
}

于是:

  • 通过sys-dict:dict:{dictCode}可以获取某个字典;
  • 通过sys-dict:type:{typeCode}可以获取某个类型下的所有字典;
  • 通过sys-dict:group:{groupCode}可以获取某个分组下面的所有字典(除了类型字典);
  • 通过sys-dict:type:{groupCode}可以获取某个分组下面的所有类型字典;
  • 通过sys-dict:type:dict_root可以获取所有的分组字典;
  • 通过sys-dict:type:dict_group可以获取所有的类型字典;
DictHelper.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public <T extends Dict> List<T> getGroup(String groupCode) {
if(StringUtils.isBlank(groupCode)){
return new ArrayList<>();
}
Map<String, T> map = redis.getMap(KEY_GROUP + groupCode);
if(map == null){
return new ArrayList<>();
}
List<T> list = new ArrayList<>(map.values());
list.sort(Comparator.comparingInt(Dict::getDictOrder));
return list;
}

public <T extends Dict> List<T> getType(String typeCode) {
if(StringUtils.isBlank(typeCode)){
return new ArrayList<>();
}
Map<String, T> map = redis.getMap(KEY_TYPE + typeCode);
if(map == null){
return new ArrayList<>();
}
List<T> list = new ArrayList<>(map.values());
list.sort(Comparator.comparingInt(Dict::getDictOrder));
return list;
}

public <T extends Dict> T getDict(String dictCode) {
if(StringUtils.isBlank(dictCode)){
return null;
}
return redis.getValue(KEY_DICT + dictCode);
}

public String getDictLabel(String dictCode) {
Dict dict = getDict(dictCode);
if(dict == null){
return null;
}
return dict.getDictLabel();
}

public <T> T getDictValue(String dictCode) {
Dict dict = getDict(dictCode);
if(dict == null){
return null;
}
return (T)dict.getDictValue();
}

方便字典获取的代价是提高了维护的成本,比如对应的删除操作

DictHelper.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public void removeDict(String dictCode) {
if(StringUtils.isBlank(dictCode)){
return;
}
Dict dict =redis.getValue(KEY_DICT + dictCode);
if(dict == null){
return;
}
redis.delete(KEY_DICT + dictCode);
redis.deleteMapValue(KEY_TYPE + dict.getTypeCode(), dictCode);
redis.deleteMapValue(KEY_GROUP + dict.getGroupCode(), dictCode);
}

public void removeType(String typeCode) {
if(StringUtils.isBlank(typeCode)){
return;
}
Map<String, Dict> dictMap = redis.getMap(KEY_TYPE + typeCode);
if(dictMap != null){
for(Dict dict : dictMap.values()){
// 删除sys-dict:dict:{dictCode}
redis.delete(KEY_DICT + dict.getDictCode());
// 从sys-dict:dict:{groupCode}中删除
redis.deleteMapValue(KEY_GROUP + dict.getGroupCode(), dict.getDictCode());
}
}
// 删除sys-dict:type:{typeCode}
redis.delete(KEY_TYPE + typeCode);
// 从sys-dict:group:dict_group中删除类型
redis.deleteMapValue(KEY_GROUP + "dict_group", typeCode);
}

public void removeGroup(String groupCode) {
if(StringUtils.isBlank(groupCode)){
return;
}
Map<String, Dict> TypeMap = redis.getMap(KEY_TYPE + groupCode);
if(TypeMap != null){
// 删除sys-dict:type:{groupCode}
redis.delete(KEY_TYPE + groupCode);
// 从sys-dict:group:dict_group中删除类型
for(Dict type : TypeMap.values()){
redis.deleteMapValue(KEY_GROUP + "dict_group", type.getDictCode());
}
}
Map<String, Dict> dictMap = redis.getMap(KEY_GROUP + groupCode);
if(dictMap != null){
for(Dict dict : dictMap.values()){
// 删除sys-dict:dict:{dictCode}
redis.delete(KEY_DICT + dict.getDictCode());
}
}
// 删除sys-dict:group:{groupCode}
redis.delete(KEY_GROUP + groupCode);
// 从sys-dict:group:dict_root中删除分组
redis.deleteMapValue(KEY_GROUP + "dict_root", groupCode);
}
  • 字典值转换

通常我们定义的字典值都是String类型,但在实际使用时可能实际需要的是一个整形数值,那么就需要类型转换,如果忘记了转换可能会出现一些意料之外的问题。所以我们定义个字典值转换器接口,在放入缓存时就进行转换。

DictValueParser.java
1
2
3
4
public interface DictValueParser {

Object parse(String value, String param);
}

比如下面时提供的一个默认转换器,可以对基本类型做一些转换

DefaultValueParser.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DefaultValueParser implements DictValueParser {

@Override
public Object parse(String value, String param) {
if(value == null){
return null;
}
return switch (param) {
case "short" -> Short.parseShort(value);
case "int" -> Integer.parseInt(value);
case "long" -> Long.parseLong(value);
case "float" -> Float.parseFloat(value);
case "double" -> Double.parseDouble(value);
case "boolean" -> Boolean.parseBoolean(value);
default -> value;
};
}
}

然后在构造字典缓存之前,会检查字典是否设置了值转换器,如果设置了就转换一下值类型

SysDictCaches.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void refresh() throws Exception {
AccessLogger.info("refresh cache of dict ...");
dict.clear();
List<SysDict> list = sysDictMapper.list(null,null);
for(SysDict sysDict : list) {
String parserClazz = sysDict.getValueParser();
if(StringUtils.isBlank(parserClazz)){
dict.put(sysDict);
}else{
DictValueParser parser = parserMap.get(parserClazz);
if(parser == null){
parser = (DictValueParser)Class.forName(parserClazz).getDeclaredConstructor().newInstance();
parserMap.put(parserClazz, parser);
}
sysDict.setDictValue(parser.parse(String.valueOf(sysDict.getDictValue()), sysDict.getValueParam()));
dict.put(sysDict);
}
}
}

附件信息

有很多这样的场景,先上传附件得到url,然后再提交表单,将附件url填到业务数据中。但是如果附件上传之后,提交表单被取消或失败了,那么已经上传的附件就会变成脏数据。

SysAttachServiceImpl.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional(rollbackFor = Exception.class)
@Override
public SysAttach upload(MultipartFile file, SysAttach sysAttach, boolean isPublic) throws Exception {
String fileName = file.getOriginalFilename();
sysAttach.setAttachName(fileName);
sysAttach.setAttachSize(file.getSize());
sysAttach.setCreateTime(new Date());
sysAttach.setIsPublic(isPublic ? 1 : 0);
String filePath = sysAttach.getAttachType()
+ "/" + DateUtils.format("yyyy-MM") + "/" + IdUtil.randomUUID() + "." + fileName;
sysAttach.setAttachPath(filePath);
sysAttachMapper.insert(sysAttach);

fileService.minioUpload(file, sysAttach.getAttachGroup(), filePath, isPublic);
return sysAttach;
}

对于这个问题,可以在上传之前先将附件信息入库(上传失败了可以回滚),然后在长传成功之后将附件信息返回给客户端,客户端可以拿到附件上传的url和附件信息的id。这样要求业务在提交数据时根据附件id更新附件记录的宿主信息,所以对于那些没有宿主信息,并且创建超过了一定时间的附件记录,有理由认为它就是脏数据,可以检查出来进行删除,并清理对应的上传文件。

有了附件记录表,就方便做一些结构化的统计处理,比如根据附件的宿主进行分组分类,也方便对上传的文件设置过期时间,可以简单通过定时任务检查并清理过期了的附件。

操作日志

SysUserController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Log(type = "admin_user", action = "add", desc = "新增用户:#{sysUser.userName}", content = Content.REQ)
@PostMapping("/add")
public Response<Void> add(@Validated @RequestBody SysUser sysUser) {
sysUserService.add(sysUser);
return Response.success();
}

@Log(type = "admin_user", action = "edit", desc = "修改用户:#{resp.userName}", content = Content.ALL)
@PostMapping("/edit")
public Response<SysUser> edit(@Validated @RequestBody SysUser sysUser) {
return Response.success(sysUserService.edit(sysUser));
}

@Log(type = "admin_user", action = "delete", desc = "删除用户", content = Content.RESP)
@GetMapping(value = {"/delete"})
public Response<List<SysUser>> delete(@NotNull(message = "user.notnull.id") Long[] userId) {
return Response.success(sysUserService.delete(userId));
}

以上是我们记录操作日志的方式,这里的type和action需要硬编码,对应的值在字典中定义,desc描述可以使用简单的SPEL表达式,content内容固定,可以选择请求参数或者响应结果。这里不去讨论如何记录日志,我们希望日志展示的可读性能友好一点,并且不同的业务日志可以区分展示。办法是通过表单来控制,将type字典的值就设置成表单key,然后在表单中再根据action动作的不同进行区分渲染。

如果将日志内容同步ES中,可以从操作内容的角度对日志进行全文检索,这里还是通过一些指定字段从关系表中进行的列表查询。查询的日志记录会关联字典信息,也就是表单key,然后我们可以在点击进入详情时选择对应的表单

log/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
<log-detail v-if="viewKey === 'log-detail'" ref="log-detail" @ok="handleQuery" />
<log-user v-if="viewKey === 'log-user'" ref="log-user" @ok="handleQuery" />
<log-dept v-if="viewKey === 'log-dept'" ref="log-dept" @ok="handleQuery" />
<log-role v-if="viewKey === 'log-role'" ref="log-role" @ok="handleQuery" />
<log-post v-if="viewKey === 'log-post'" ref="log-post" @ok="handleQuery" />

handleView(row) {
this.viewKey = row.viewKey
setTimeout(()=>{
this.$refs[row.viewKey].show(row.id);
});
}

将日志对应到表单之后,我们就可以在获取日志内容展示的时候做一些处理了。这样在日志记录时就不需要关心字段含义,或者内容组织之类的事情了,可以放到放到查询展示时处理,对应的表单必然知道字段含义,以及内容要如何展示,甚至它可以自己补充内容。

系统公告

消息公告是很常见的功能,系统用户可以创建一个消息(也可以系统后台生成消息),然后发送给目标用户或群体,并通过websocket推送给当前在线的目标用户(这里用的socketIo有个问题,由于java服务端的依赖包已经太久没有更新,前端的socket.io-client只能选择对应的老版本)。

我们希望对公告做一个已读信息展示,包括接收者的反馈。办法是添加一个read表,在公告发布时将目标全部翻译成具体的用户,并作为未读记录插入到read表,然后更新公告信息的阅读总数,后面就交给目标用户操作时更新就行了。

系统告警

类似于操作日志,每个告警类型也对应一个表单,可以对告警内容进行区分展示,另外,每个类型也对应一套告警级别判断规则,这样可以在界面针对告警类型设置级别阈值;

对于相同的告警(可以通过关键标识信息合并生成md5来判断),则进行次数累计,如果处理了则重新累计;

数据权限

对于数据权限的想法是先定义,后使用。

所以首先是如何定义数据权限,这个定义可能是与业务强相关的,可以类似前面的思路,将数据权限类型定义成字典,然后将字典值设置为表单key,这样不同类型的数据权限酒可以分开设置规则。在设置规则时有两种方式,一种是直接将数据看表结构暴露给用户设置,但这样不太友好,用户并不关心业务数据背后的表字段,所以好点的做法是通过表单设计,让用户能从业务的角度设置筛选条件。

其次是如何匹配数据权限,可以给角色分配数据权限,也可以给角色的某个菜单权限分配,直接在对应界面选择就行。然后在用户访问接口时,我们可以拦截请求,获取到当前用户的角色,以及菜单权限key,接着从缓存中获取数据权限并进行匹配。在匹配时,应该优先匹配角色菜单,如果没有再匹配角色。可能会匹配到多个权限规则,所以加了一个优先级字段,当然根据实际需求场景,也可以取并集的方式,或者在定义权限的时就进行限制。

对于匹配到的数据权限,最终只能交给业务代码自己来实现,因为数据权限本身就没法脱离开业务。我们可以统一接口定义,制定模板,或者通过Mybatis插件来做一些预处理,但是最终还是要将数据权限规则转化成对应的sql查询条件。

国际化处理

对于界面的静态信息,以及服务端的提示信息,可以直接编辑对应的国际化资源,界面可以在Http消息头中设置参数Accept-Language来告诉服务端当前使用什么语言;

对于动态菜单的国际化,可以在前端编辑好国际化资源,然后在定义菜单名称时使用国际化的key,比如上面角色菜单权限中所示;

对于字典的国际化,只能添加两种语言名称了,界面在展示时根据语言环境进行选择(但是这样只能两种语言,不过一般也够了)

1
2
3
4
5
6
<template slot-scope="{row: {rank}}">
<template v-for="item in dict.type.post_level">
<span v-if="rank === item.value && $i18n.locale==='zh'">{{ item.value }}/{{ item.label }}</span>
<span v-if="rank === item.value && $i18n.locale==='en'">{{ item.value }}/{{ item.labelEn }}</span>
</template>
</template>

对于服务一些异常的国际化,也可以将message定义成国际化的key,然后交给异常统一处理器获取出来进行转换

1
2
3
4
5
@NotBlank(message = "user.notnull.name")
private String userName;

@NotBlank(message = "user.notnull.account")
private String userAccount;

附录:表结构

具体的表结构关系简单如下:

  • 部门
1.部门信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
create table sys_dept(
dept_id bigserial primary key,
dept_code character varying(64),
dept_name character varying(128),
dept_short character varying(64),
dept_addr character varying(512),
dept_phone character varying(64),
remark character varying(200),
read_only int2 default 0,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp

);
comment on table sys_dept is '部门信息';
comment on column sys_dept.dept_id is '部门id';
comment on column sys_dept.dept_code is '部门编码';
comment on column sys_dept.dept_name is '部门名称';
comment on column sys_dept.dept_short is '部门简称';
comment on column sys_dept.dept_addr is '部门地址';
comment on column sys_dept.dept_phone is '部门电话';
comment on column sys_dept.remark is '备注';
comment on column sys_dept.read_only is '是否只读 1是 0否';
2.部门关系
1
2
3
4
5
6
7
8
9
10
create table sys_dept_tree(
dept_id int8 not null,
parent_id int8 not null,
tree_type int2 default 1,
constraint sys_dept_tree_pkey primary key (dept_id, parent_id, tree_type)
);
comment on table sys_dept_tree is '部门关系';
comment on column sys_dept_tree.dept_id is '部门id';
comment on column sys_dept_tree.parent_id is '上级部门id';
comment on column sys_dept_tree.tree_type is '关系类型';
  • 岗位
3.岗位信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
create table sys_post (
post_id bigserial primary key,
post_code varchar(64),
post_name varchar(64) not null,
post_level int2 default 1,
post_type varchar(64),
post_status int2 default 1,
remark character varying(200),
read_only int2 default 0,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp
);
comment on table sys_post is '岗位信息';
comment on column sys_post.post_id is '岗位id';
comment on column sys_post.post_code is '岗位编码';
comment on column sys_post.post_name is '岗位名称';
comment on column sys_post.post_level is '岗位级别';
comment on column sys_post.post_type is '岗位类型';
comment on column sys_post.post_status is '岗位状态';
comment on column sys_post.remark is '备注';
comment on column sys_post.read_only is '是否只读 1是 0否';
4.岗位关系
1
2
3
4
5
6
7
8
9
10
create table sys_post_tree(
post_id int8 not null,
parent_id int8 not null,
tree_type int2 default 1,
constraint sys_post_tree_pkey primary key (post_id, parent_id, tree_type)
);
comment on table sys_post_tree is '岗位关系';
comment on column sys_post_tree.post_id is '岗位id';
comment on column sys_post_tree.parent_id is '上级岗位id';
comment on column sys_post_tree.tree_type is '关系类型';
5.部门岗位
1
2
3
4
5
6
7
8
9
10
create table sys_dept_post(
dept_id int8 not null,
post_id int8 not null,
is_default int2 default 0,
constraint sys_dept_post_pkey primary key (dept_id, post_id)
);
comment on table sys_dept_post is '部门岗位';
comment on column sys_dept_post.dept_id is '部门id';
comment on column sys_dept_post.post_id is '岗位id';
comment on column sys_dept_post.is_default is '是否部门默认岗位';
  • 菜单
6.菜单信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
create table sys_menu(
menu_id bigserial primary key,
parent_id bigint default 0,
menu_name character varying(64) not null,
menu_order integer default 0,
menu_permit character varying(255),
menu_path character varying(255) default '#',
menu_param character varying(255),
menu_type char(1) not null,
menu_icon character varying(100) default '#',
component character varying(255),
menu_status int2 default 1,
is_frame int2 DEFAULT 1,
is_cache int2 DEFAULT 1,
is_visible int2 DEFAULT 1,
is_protected int2 DEFAULT 1,
remark character varying(200),
read_only int2 default 0,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp
);
comment on table sys_menu is '菜单信息';
comment on column sys_menu.menu_id is '菜单id';
comment on column sys_menu.parent_id is '父菜单id';
comment on column sys_menu.menu_name is '菜单名称';
comment on column sys_menu.menu_order is '菜单顺序';
comment on column sys_menu.menu_permit is '权限标识';
comment on column sys_menu.menu_path is '菜单路径';
comment on column sys_menu.menu_param is '路径参数';
comment on column sys_menu.menu_type is '菜单类型:M:目录 C:菜单 B:按钮';
comment on column sys_menu.menu_icon is '菜单图标';
comment on column sys_menu.component is '组件路径';
comment on column sys_menu.menu_status is '菜单状态 1启用 2停用';
comment on column sys_menu.is_frame is '是否内部链接 1是 0否';
comment on column sys_menu.is_cache is '是否缓存 1是 0否';
comment on column sys_menu.is_visible is '是否显示 1是 0否';
comment on column sys_menu.is_protected is '是否受保护的菜单 1是 0否';
comment on column sys_menu.read_only is '是否只读 1是 0否';
  • 角色
7.角色信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
create table sys_role(
role_id bigserial primary key,
role_code character varying(100) not null,
role_name character varying(64) not null,
role_type character varying(64),
remark character varying(200),
read_only int2 default 0,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp
);
create unique index sys_role_role_code on sys_role(role_code);
comment on table sys_role is '角色信息';
comment on column sys_role.role_id is '角色id';
comment on column sys_role.role_code is '角色编码';
comment on column sys_role.role_name is '角色名称';
comment on column sys_role.role_type is '角色类型';
comment on column sys_role.remark is '备注';
comment on column sys_role.read_only is '是否只读 1是 0否';
8.角色菜单
1
2
3
4
5
6
7
8
create table sys_role_menu(
role_id bigint not null,
menu_id bigint not null,
constraint sys_role_menu_pkey primary key (role_id, menu_id)
);
comment on table sys_role_menu is '角色菜单';
comment on column sys_role_menu.role_id is '角色id';
comment on column sys_role_menu.menu_id is '菜单id';
  • 用户
9.用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
create table sys_user(
user_id bigserial primary key,
user_code character varying(64),
user_name character varying(64) not null,
user_account character varying(64) not null,
user_passwd character varying(256) not null,
user_sex int2 default 0,
user_phone character varying(11),
user_email character varying(128),
user_status int2 default 1,
rank character varying(64),
remark character varying(200),
read_only int2 default 0,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp
);
create unique index sys_user_user_account on sys_user(user_account);
comment on table sys_user is '用户信息';
comment on column sys_user.user_id is '用户id';
comment on column sys_user.user_code is '用户编码';
comment on column sys_user.user_name is '用户名称';
comment on column sys_user.user_account is '用户账号';
comment on column sys_user.user_passwd is '用户密码';
comment on column sys_user.user_sex is '用户性别';
comment on column sys_user.user_phone is '用户电话';
comment on column sys_user.user_email is '用户邮箱';
comment on column sys_user.user_status is '用户状态';
comment on column sys_user.rank is '职级';
comment on column sys_user.remark is '备注';
comment on column sys_user.read_only is '是否只读 1是 0否';
10.用户关系
1
2
3
4
5
6
7
8
9
10
create table sys_user_tree(
user_id int8 not null,
parent_id int8 not null,
tree_type int2 default 1,
constraint sys_user_tree_pkey primary key (user_id, parent_id, tree_type)
);
comment on table sys_user_tree is '用户关系';
comment on column sys_user_tree.user_id is '用户id';
comment on column sys_user_tree.parent_id is '上级用户id';
comment on column sys_user_tree.tree_type is '关系类型';
11.用户部门岗位
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
create table sys_user_dept(
id bigserial primary key,
user_id int8 not null,
dept_id int8 not null,
post_id int8 default -1,
is_default int2 default 0,
is_leader int2 default 0
);
create unique index sys_user_dept_uk on sys_user_dept(user_id, dept_id, post_id);
comment on table sys_user_dept is '用户部门';
comment on column sys_user_dept.id is '主键';
comment on column sys_user_dept.user_id is '用户id';
comment on column sys_user_dept.dept_id is '部门id';
comment on column sys_user_dept.post_id is '岗位id';
comment on column sys_user_dept.is_default is '是否用户默认部门';
comment on column sys_user_dept.is_leader is '是否部门负责人';
12.用户角色
1
2
3
4
5
6
7
8
create table sys_user_role(
user_id bigint not null,
role_id bigint not null,
constraint sys_user_role_pkey primary key (user_id, role_id)
);
comment on table sys_user_role is '用户角色';
comment on column sys_user_role.user_id is '用户id';
comment on column sys_user_role.role_id is '角色id';
  • 字典
13.字典信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
create table sys_dict(
id bigserial primary key,
parent_code character varying(100),
dict_code character varying(100),
dict_label character varying(100),
dict_en character varying(100),
dict_value character varying(100),
value_parser varchar(100),
value_param varchar(100),
dict_order int2 default 0,
is_default int2 default 0,
css character varying(100),
status int2 default 1,
remark character varying(200),
read_only int2 default 0,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp
);
create unique index sys_dict_uk on sys_dict(dict_code);
comment on table sys_dict is '字典数据';
comment on column sys_dict.id is '主键';
comment on column sys_dict.parent_code is '父字典编码';
comment on column sys_dict.dict_code is '字典编码';
comment on column sys_dict.dict_label is '字典标签';
comment on column sys_dict.dict_en is '英文标签';
comment on column sys_dict.dict_value is '字典值';
comment on column sys_dict.value_parser is '值转换器';
comment on column sys_dict.value_param is '转换参数';
comment on column sys_dict.dict_order is '字典排序';
comment on column sys_dict.status is '字典状态';
comment on column sys_dict.is_default is '是否默认';
comment on column sys_dict.read_only is '是否只读 1是 0否';
comment on column sys_dict.css is '字典展示样式';
  • 公告
14.公告信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
create table sys_notice(
notice_id bigserial primary key,
notice_title character varying(255),
notice_status int2 default 0,
notice_type int2 default 0,
notice_level int2 default 0,
content text,
is_system int2 default 0,
stat_total int4 default 0,
stat_read int4 default 0,
goals_all int2 default 0,
goals_dept int8[],
goals_role int8[],
goals_user int8[],
publish_time timestamp,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp
);
comment on table sys_notice is '系统公告';
comment on column sys_notice.notice_id is '公告id';
comment on column sys_notice.notice_title is '标题';
comment on column sys_notice.notice_status is '公告状态 0草稿 1已发布 2已撤回 3已删除';
comment on column sys_notice.notice_type is '公告类型 0公告 1通知';
comment on column sys_notice.notice_level is '公告等级 0普通 1紧急';
comment on column sys_notice.content is '公告内容';
comment on column sys_notice.is_system is '是否系统公告 0否 1是';
comment on column sys_notice.stat_total is '目标数';
comment on column sys_notice.stat_read is '已读数';
comment on column sys_notice.goals_all is '是否全员 0否 1是';
comment on column sys_notice.goals_dept is '目标单位';
comment on column sys_notice.goals_role is '目标角色';
comment on column sys_notice.goals_user is '目标用户';
15.已读信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
create table sys_notice_read(
id bigserial primary key,
notice_id int8,
user_id int8,
read_status int2 default 0,
read_back character varying(512),
read_time timestamp
);
create unique index sys_notice_read_uk on sys_notice_read(user_id, notice_id);
comment on table sys_notice_read is '公告已读';
comment on column sys_notice_read.id is '主键';
comment on column sys_notice_read.notice_id is '公告id';
comment on column sys_notice_read.user_id is '用户id';
comment on column sys_notice_read.read_status is '已读状态';
comment on column sys_notice_read.read_back is '读反馈';
comment on column sys_notice_read.read_time is '读时间';
  • 系统配置
16.系统配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
CREATE TABLE sys_config(
config_id serial primary key,
config_name varchar(100) DEFAULT '',
config_key varchar(100) DEFAULT '',
config_value varchar(500) DEFAULT '',
value_parser varchar(100),
value_param varchar(100),
is_default int2 DEFAULT 0,
remark varchar(500) DEFAULT NULL,
create_user int8,
create_dept int8,
create_time timestamp,
update_user int8,
update_dept int8,
update_time timestamp
);
create unique index sys_config_config_key on sys_config(config_key);
comment on table sys_config is '系统配置';
comment on column sys_config.config_id is '参数id';
comment on column sys_config.config_name is '参数名称';
comment on column sys_config.config_key is '参数键';
comment on column sys_config.config_value is '参数值';
comment on column sys_config.value_parser is '值转换器';
comment on column sys_config.value_param is '转换参数';
comment on column sys_config.is_default is '是否默认 1是 0否';
  • 附件信息
17.附件信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
create table sys_attach(
attach_id bigserial primary key,
master_id int8,
attach_group character varying(64),
attach_type character varying(64),
attach_name character varying(1024),
attach_size int8,
attach_path character varying(1024),
attach_status int2 default 0,
is_protected int2 default 0,
create_time timestamp,
expire_set int2 default 0,
expire_time timestamp
);
create index sys_attach_master on sys_attach(master_id, attach_type, attach_group);
comment on table sys_attach is '附件信息';
comment on column sys_attach.attach_id is '附件id';
comment on column sys_attach.master_id is '宿主id';
comment on column sys_attach.attach_group is '附件分组';
comment on column sys_attach.attach_type is '附件类型';
comment on column sys_attach.attach_name is '附件名称';
comment on column sys_attach.attach_size is '附件大小';
comment on column sys_attach.attach_path is '附件路径';
comment on column sys_attach.attach_status is '附件状态 0未生效 1已生效';
comment on column sys_attach.is_protected is '是否受保护的 0否 1是';
comment on column sys_attach.create_time is '创建时间';
comment on column sys_attach.expire_set is '是否设置过期时间 0未设置 1设置';
comment on column sys_attach.expire_time is '过期时间';
  • 操作日志
18.操作日志
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
create table sys_log(
id bigserial primary key,
user_id int8,
dept_id int8,
log_group character varying(64),
log_type character varying(64),
log_action character varying(64),
ip character varying(128),
url character varying(255),
log_desc character varying(255) default '' ,
log_content json,
log_status int2 default 1,
log_time timestamp

);
comment on table sys_log is '操作日志';
comment on column sys_log.id is '日志id';
comment on column sys_log.user_id is '用户id';
comment on column sys_log.dept_id is '部门id';
comment on column sys_log.log_group is '日志模块';
comment on column sys_log.log_type is '日志类型';
comment on column sys_log.log_action is '日志动作';
comment on column sys_log.ip is '访问ip';
comment on column sys_log.url is '访问url';
comment on column sys_log.log_desc is '日志描述';
comment on column sys_log.log_content is '日志详情';
comment on column sys_log.log_status is '日志状态';
comment on column sys_log.log_time is '日志时间';
  • 系统告警
19.系统告警
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
create table sys_alarm(
id bigserial primary key,
alarm_code character varying(128) not null,
alarm_type int8,
alarm_level int2 default 1,
source_id int8,
source_name character varying(64),
source_type character varying(64),
alarm_status int2 default 0,
alarm_times int4 default 1,
first_time timestamp,
last_time timestamp,
alarm_desc character varying(255),
alarm_content json default '{}',
resolve_user int8,
resolve_msg character varying(255),
resolve_time timestamp,
resolve_type int2 default 1

);
create index sys_alarm_alarm_code on sys_alarm(alarm_code);
comment on table sys_alarm is '系统告警';
comment on column sys_alarm.id is '告警id';
comment on column sys_alarm.alarm_code is '唯一编码';
comment on column sys_alarm.alarm_type is '告警类型';
comment on column sys_alarm.source_id is '告警来源id';
comment on column sys_alarm.source_name is '告警来源名称';
comment on column sys_alarm.source_type is '告警来源类型';
comment on column sys_alarm.alarm_level is '告警级别:1提示 2普通 3重要 4严重 5灾难';
comment on column sys_alarm.alarm_status is '告警状态 0未处理 1已确认 2已解决';
comment on column sys_alarm.alarm_times is '告警次数';
comment on column sys_alarm.first_time is '首次告警时间';
comment on column sys_alarm.last_time is '最后告警时间';
comment on column sys_alarm.alarm_desc is '告警描述';
comment on column sys_alarm.alarm_content is '告警内容';
comment on column sys_alarm.resolve_user is '处理人';
comment on column sys_alarm.resolve_time is '处理时间';
comment on column sys_alarm.resolve_msg is '处理意见';
comment on column sys_alarm.resolve_type is '处理方式:1:手动 2:自动';
20.告警类型
1
2
3
4
5
6
7
8
9
10
11
create table sys_alarm_type(
id bigserial primary key,
type_name character varying(128),
type_view character varying(128),
description text
);
comment on table sys_alarm_type is '告警类型';
comment on column sys_alarm_type.id is '主键';
comment on column sys_alarm_type.type_name is '类型名称';
comment on column sys_alarm_type.type_view is '类型表单';
comment on column sys_alarm_type.description is '类型描述';
21.告警级别
1
2
3
4
5
6
7
8
9
10
11
12
13
create table sys_alarm_level(
id bigserial primary key,
alarm_type int8,
alarm_level int2,
level_rule json default '{}',
description text
);
comment on table sys_alarm_level is '告警类型';
comment on column sys_alarm_level.id is '主键';
comment on column sys_alarm_level.alarm_type is '告警类型';
comment on column sys_alarm_level.alarm_level is '告警级别';
comment on column sys_alarm_level.level_rule is '级别判断规则';
comment on column sys_alarm_level.description is '级别描述';
  • 数据权限
22.数据权限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE sys_scope(
scope_id bigserial primary key,
scope_name character varying(255),
scope_type character varying(64),
scope_status int2 DEFAULT 1,
content json default '{}',
remark varchar(200)
);
comment on table sys_scope is '数据权限';
comment on column sys_scope.scope_id is '权限id';
comment on column sys_scope.scope_name is '权限名称';
comment on column sys_scope.scope_type is '权限类型(字典值设置表单)';
comment on column sys_scope.scope_status is '权限状态';
comment on column sys_scope.content is '权限规则';
comment on column sys_scope.remark is '备注';
23.角色菜单数据权限
1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE sys_role_scope(
scope_id bigint not null,
role_id bigint not null,
menu_permit character varying(255),
priority int2 DEFAULT 1,
constraint sys_role_scope_pkey primary key (role_id, scope_id, menu_permit)
);
comment on table sys_role_scope is '角色数据权限';
comment on column sys_role_scope.scope_id is '数据权限id';
comment on column sys_role_scope.role_id is '角色id';
comment on column sys_role_scope.menu_permit is '菜单权限符';
comment on column sys_role_scope.priority is '优先级';


参考: