这篇文章是对暖聊中用户权限冻结功能设计的一次复盘,读者有需要的话可以依据本文操练起来~如有不当之处,恳请指正。
背景
用户在社交产品中发布不当言论,某种程度上会给其他用户带来不良影响,应该限制其行为,将负面影响降到最小。例如在动态中发布不当言论,系统将会推送消息提示用户遵守《用户协议》,同时在指定时长内禁止使用动态功能。
后端+客户端的整体设计
用户发布动态流程图
以用户发布动态请求为例,在没有权限冻结时,流程就是从gateway(网关层)路由转发到到业务服务,在业务服务进行业务逻辑的处理即可。
加了用户权限冻结之后,在gateway层需要对当前用户的当前url权限是否被冻结做判断,如果被冻结了,则请求直接结束,提示用户“此权限被冻结”。
/**
* 校验用户是否有该接口权限
*
* @param appId 应用id
* @param path 请求路径
* @param user 用户信息
*/
private void checkUrlPermission(Long appId, String path, UserVO user) {
Long userId = Optional.ofNullable(user).map(UserVO::getId).orElse(null);
if (Objects.isNull(appId) || StringUtils.isBlank(path) || Objects.isNull(userId)) {
return;
}
// 查询当前用户的权限
UserBaseVO userBase = userManager.getUserBaseVOById(appId, userId);
Long userPermissionValue = Optional.ofNullable(userBase).map(UserBaseVO::getUserPermissionValue).orElse(null);
if (Objects.isNull(userPermissionValue) || userPermissionValue == 0) {
return;
}
// 获取当前url对应的权限配置
URLPermissionDO urlPermissionDO = getUrlPermissionDOByPath(appId, path);
if (Objects.isNull(urlPermissionDO) || Objects.isNull(urlPermissionDO.getLongValue())) {
return;
}
// 校验用户是否有该接口权限
long result = urlPermissionDO.getLongValue() & userPermissionValue;
if (result != 0) {
throw new ServiceException(ErrorCode.NO_URL_PERMISSION);
}
}
/**
* 获取所有url配置
*
* @param appId 应用id
* @param path 请求路径
* @return URLPermissionDO
*/
private URLPermissionDO getUrlPermissionDOByPath(Long appId, String path) {
if (Objects.isNull(appId) || StringUtils.isBlank(path)) {
return null;
}
String urlPermissionKey = String.format(RedisKey.URL_PERMISSION_KEY_APP_ID, appId);
// 获取所有权限集合
List urlPermissionDOList;
if (redisManager.hasKey(urlPermissionKey)) {
String json = redisManager.get(urlPermissionKey).toString();
urlPermissionDOList = JSON.parseArray(json, URLPermissionDO.class);
} else {
urlPermissionDOList = urlPermissionJpaDAO.findByAppIdAndStatusAndBan(appId, CommonStatus.enable, true);
redisManager.set(urlPermissionKey, JSON.toJSONString(urlPermissionDOList), 60 * 30);
}
if (CollectionUtils.isEmpty(urlPermissionDOList)) {
return null;
}
// 从列表中过滤出当前url
return urlPermissionDOList.stream().filter(v -> path.equalsIgnoreCase(v.getUrl())).filter(v -> CommonStatus.enable.equals(v.getStatus())).findFirst().orElse(null);
}
用户权限相关设计
页面-用户权限列表
接口-设置用户权限(冻结、取消冻结)
/**
* 编辑用户权限信息
*
* @param source 用户权限信息
* @return Boolean
*/
public Boolean modifyUserPermission(URLUserPermissionVO source) {
if (Objects.isNull(source)
|| Objects.isNull(source.getAppId())
|| Objects.isNull(source.getUserId())
|| Objects.isNull(CommonStatus.convertFrom(source.getStatus()))) {
return false;
}
Date updateTime = new Date();
Long userId = source.getUserId();
Long appId = source.getAppId();
URLUserPermissionDO target = null;
if (Objects.nonNull(source.getId())) {
Optional optional = urlUserPermissionJpaDAO.findById(source.getId());
if (optional.isPresent()) {
// id存在代表是编辑用户权限关系,否则为新增用户权限关系
target = optional.get();
}
}
if (Objects.isNull(target)) {
target = new URLUserPermissionDO();
source.setCreateTime(updateTime);
} else {
source.setCreateTime(target.getCreateTime());
}
source.setUpdateTime(updateTime);
// 记录数据库的
List oldURLGroupIdList = new ArrayList<>();
oldURLGroupIdList.addAll(StringUtils.isEmpty(target.getUrlGroupIds()) ? Lists.newArrayList() : Arrays.asList(target.getUrlGroupIds().split(",")).stream().map(s -> Long.parseLong(s.trim())).collect(Collectors.toList()));
// 更新信息
BeanUtils.copyProperties(source, target);
// status为enable代表冻结权限,disable代表取消冻结
target.setStatus(CommonStatus.convertFrom(source.getStatus()));
// 计算longValue、hexString、
String newUrlGroupIds = source.getUrlGroupIds();
List newURLGroupIdList = StringUtils.isEmpty(newUrlGroupIds) ? Lists.newArrayList() : Arrays.asList(newUrlGroupIds.split(",")).stream().map(s -> Long.parseLong(s.trim())).collect(Collectors.toList());
List urlGroupDOList;
if (CollectionUtils.isEmpty(newURLGroupIdList)) {
urlGroupDOList = Lists.newArrayList();
} else {
urlGroupDOList = urlGroupJpaDAO.findByIdIn(newURLGroupIdList);
}
Long longValue = URLPermissionManager.getUserPermissionLongValue(urlGroupDOList);
if (longValue.intValue() == 0 || CommonStatus.disable.equals(target.getStatus())) {
longValue = 0L;
target.setStatus(CommonStatus.disable);
target.setLongValue(0L);
}
// 设置longValue
target.setLongValue(longValue);
// 设置十六进制
target.setHexString(Long.toHexString(target.getLongValue()).toUpperCase());
source.setUpdateTime(updateTime);
urlUserPermissionJpaDAO.save(target);
// 发送文案,保存用户封禁关系。这里是业务功能,没有详细展开,读者略过即可,如需详细设计可联系本文作者。
if (!CollectionUtils.isEmpty(oldURLGroupIdList) || !CollectionUtils.isEmpty(newURLGroupIdList)) {
userAsyncManager.sendMessageAndSaveURLUserGroupBan(updateTime, userId, appId, oldURLGroupIdList, newURLGroupIdList, target);
}
return true;
}
/**
* 计算权限值
* @param urlGroupDOList url分组
* @return 权限值
*/
public static long getUserPermissionLongValue(List urlGroupDOList) {
long longValue = 0L;
for (URLGroupDO urlGroupDO : urlGroupDOList) {
longValue = longValue | urlGroupDO.getLongValue();
}
return longValue;
}
表结构-用户分组关系
主键 |
用户id |
分组ids |
权限值 |
权限值(十六进制) |
状态 |
1 |
143256 |
[1] |
1 |
0x0000000000000001 |
enable |
2 |
148196 |
[1,2] |
3 |
0x0000000000000003 |
enable |
3 |
151337 |
[2] |
2 |
0x0000000000000002 |
enable |
CREATE TABLE `url_user_permission` (
`id` bigint(32) NOT NULL AUTO_INCREMENT,
`app_id` bigint(32) DEFAULT NULL COMMENT '应用',
`user_id` bigint(32) DEFAULT NULL COMMENT '用户id',
`url_group_ids` varchar(255) DEFAULT NULL COMMENT 'url分组id',
`long_value` bigint(64) DEFAULT NULL COMMENT '值',
`hex_string` varchar(255) DEFAULT NULL COMMENT '十六进制',
`status` varchar(255) DEFAULT NULL COMMENT '状态',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `appId_userId` (`app_id`,`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='url用户权限';
url分组相关设计
页面-url分组列表
接口-编辑url分组信息
/**
* 编辑url分组信息
*
* @param source url分组信息
* @return Boolean
*/
public Boolean modifyGroup(URLGroupVO source) {
if (Objects.isNull(source)) {
return false;
}
if (Objects.isNull(source.getAppId())
|| Objects.isNull(source.getUrlGroupCode())
|| Objects.isNull(source.getDescription())
|| Objects.isNull(source.getLongValue())
|| Objects.isNull(CommonStatus.convertFrom(source.getStatus()))) {
throw new ServiceException(ErrorCode.INVALID_PARAM);
}
Date updateTime = new Date();
URLGroupDO target = null;
// 状态变更标识:true代表变更为无效。
boolean enableToDisable = false;
// 权限变更标识:false代表未变更,true代表变更。
boolean permissionChange = false;
// 原来的权限
Long oldLongValue = null;
// 修改后的权限
Long newLongValue = null;
if (Objects.nonNull(source.getId())) {
Optional optional = urlGroupJpaDAO.findById(source.getId());
if (optional.isPresent()) {
target = optional.get();
oldLongValue = target.getLongValue();
newLongValue = source.getLongValue();
if (!target.getLongValue().equals(source.getLongValue())) {
permissionChange = true;
}
if (!target.getStatus().getCode().equals(source.getStatus())) {
enableToDisable = CommonStatus.disable.getCode().equals(source.getStatus());
}
}
}
if (Objects.isNull(target)) {
target = new URLGroupDO();
source.setCreateTime(updateTime);
} else {
source.setCreateTime(target.getCreateTime());
}
source.setUpdateTime(updateTime);
// 更新信息
BeanUtils.copyProperties(source, target);
target.setStatus(CommonStatus.convertFrom(source.getStatus()));
target.setHexString(Long.toHexString(source.getLongValue()).toUpperCase());
urlGroupJpaDAO.save(target);
// 状态变更为无效或者权限变更时,异步更新用户对应的权限关系
if (permissionChange || enableToDisable) {
userAsyncManager.asyncUpdateUserPermission(target.getId(), permissionChange, enableToDisable, oldLongValue, newLongValue, target.getAppId());
}
return true;
}
表结构-分组
主键 |
分组编码 |
分组描述 |
权限值 |
权限值(十六进制) |
状态 |
1 |
live_room |
直播间 |
1 |
0x0000000000000001 |
有效 |
2 |
voice_room |
语音房 |
2 |
0x0000000000000002 |
有效 |
CREATE TABLE `url_group` (
`id` bigint(32) NOT NULL AUTO_INCREMENT,
`app_id` bigint(32) DEFAULT NULL COMMENT '应用',
`url_group_code` varchar(255) DEFAULT NULL COMMENT '分组编码',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`long_value` bigint(64) DEFAULT NULL COMMENT '值',
`hex_string` varchar(255) DEFAULT NULL COMMENT '十六进制',
`status` varchar(255) DEFAULT NULL COMMENT '状态',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='url分组';
url权限相关设计
页面-url权限列表
接口-编辑url权限信息
/**
* 编辑url权限信息
*
* @param source url权限信息
* @return Boolean
*/
public Boolean modifyURLPermission(URLPermissionVO source) {
if (Objects.isNull(source)
|| Objects.isNull(source.getAppId())
|| StringUtils.isBlank(source.getUrl())
|| StringUtils.isBlank(source.getDescription())
|| Objects.isNull(source.getLongValue())
|| Objects.isNull(CommonStatus.convertFrom(source.getStatus()))) {
throw new ServiceException(ErrorCode.INVALID_PARAM);
}
Date updateTime = new Date();
URLPermissionDO target = null;
if (Objects.nonNull(source.getId())) {
Optional optional = urlPermissionJpaDAO.findById(source.getId());
if (optional.isPresent()) {
target = optional.get();
}
}
if (Objects.isNull(target)) {
target = new URLPermissionDO();
source.setCreateTime(updateTime);
} else {
source.setCreateTime(target.getCreateTime());
}
source.setUpdateTime(updateTime);
// 更新信息
BeanUtils.copyProperties(source, target);
target.setStatus(CommonStatus.convertFrom(source.getStatus()));
target.setHexString(Long.toHexString(source.getLongValue()).toUpperCase());
// 禁用:提示该url代表禁用
target.setBan(true);
urlPermissionJpaDAO.save(target);
return true;
}
表结构-权限点
主键 |
url |
描述 |
权限值 |
权限值(十六进制) |
状态 |
1 |
/api/room/create-live- broadcast-room |
禁止创建直播间 |
1 |
0x0000000000000001 |
有效 |
CREATE TABLE `url_permission` (
`id` bigint(32) NOT NULL AUTO_INCREMENT,
`app_id` bigint(32) DEFAULT NULL COMMENT '应用',
`url` varchar(255) DEFAULT NULL COMMENT '请求路径',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`long_value` bigint(64) DEFAULT NULL COMMENT '值',
`hex_string` varchar(255) DEFAULT NULL COMMENT '十六进制',
`status` varchar(255) DEFAULT NULL COMMENT '状态',
`ban` tinyint(2) DEFAULT NULL COMMENT '是否禁止',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='url权限点';
思路
权限管理系统中最普遍的是RBAC(Role-Based Access Control)模型,它有3个基础组成部分,分别是:用户、角色和权限。他们之间的关系为一个用户可以有多个角色,一个角色可以对应多个权限,用户和角色无直接关系。
如何表达权限值?计算机内部采用二进制,与十进制数相比,二进制数的运算规则简单,这里我们使用二进制来表示url的权限值。当每个url占用一个二进制位时,权限点的表示如下:
url |
描述 |
权限值(十进制) |
二进制 |
版本 |
/api/feed/add-feed |
发布动态 |
1 |
0000 0001 |
1.0 |
/api/feed/add-comment |
评论动态 |
2 |
0000 0010 |
1.0 |
/api/feed/delete-comment |
删除评论 |
4 |
0000 0100 |
1.1 |
/api/room/create-live- broadcast-room |
创建直播间 |
8 |
0000 1000 |
1.2 |
/api/room/share-live- broadcast-room |
禁止分享直播间 |
16 |
0001 0000 |
1.3 |
分组,记录当前分组下的所有url的权限,此时分组权限如下:
分组 |
描述 |
权限值(十进制) |
二进制 |
feed |
动态 |
7 |
0000 0111 |
room |
直播间 |
24 |
0001 1000 |
假设我们存储的是用户拥有的权限的总和,并且用户张三此时处于1.1版本,且拥有发布动态、评论动态的权限,而删除评论的权限被禁止,则当前的权限值为3(即二进制0000 0011)。
用户 |
权限值(十进制) |
二进制 |
张三 |
3 |
0000 0011 |
当用户张三升级到版本1.2,此时创建直播间功能上线,默认情况下当前用户A拥有的权限值变更为11(即二进制0001 011)。后续版本发布每新增一个url时,就需要刷新所有用户对应的权限值,代表用户拥有这个url权限。
频繁的刷新,必然会带来一系列影响,从业务需求角度出发,这里改为记录用户被封禁的权限的总和。仍是上文的例子,从权限点的表示开始:
url |
描述 |
权限值(十进制) |
二进制 |
版本 |
/api/feed/add-feed |
禁止发布动态 |
1 |
0000 0001 |
1.0 |
/api/feed/add-comment |
禁止评论动态 |
2 |
0000 0010 |
1.0 |
/api/feed/delete-comment |
禁止删除评论 |
4 |
0000 0100 |
1.1 |
/api/room/create-live- broadcast-room |
禁止创建直播间 |
8 |
0000 1000 |
1.2 |
/api/room/share-live- broadcast-room |
禁止分享直播间 |
16 |
0001 0000 |
1.3 |
分组的权限如下:
分组 |
描述 |
权限值(十进制) |
二进制 |
feed |
动态 |
7 |
0000 0111 |
room |
直播间 |
24 |
0001 1000 |
用户张三此时处于1.1版本,且拥有发布动态、评论动态的权限,而删除评论(权限值4)的权限被禁止,则当前的权限值为4(即二进制0000 0100)。
用户 |
权限值(十进制) |
二进制 |
张三 |
4 |
0000 0100 |
当用户张三升级到版本1.2,此时创建直播间功能上线,默认情况下当前用户A被禁止的权限仍为4(即二进制0000 0100)。后续版本发布每新增一个url时,不需要刷新所有用户对应的权限值。
这时候,用户张三在直播间发布不当言论,此时用户张三的创建直播间(权限值8)功能被封禁,则张三的权限值变更为12(即二进制0000 1100)。接着用户张三升级到1.3版本,此时用户张三的直播间功能仍处于冻结状态,即分享直播间(权限值16)功能不可用,此时张三的权限值变更为28(即二进制0001 1100)。
由于每个url占用一个二进制位,每封禁一个用户的url权限,就要变更用户的权限值。如果上新一个仅少数用户可访问的url,则需要更新大多数用户的url权限值,所以这里也会涉及到大量的用户权限值的刷新问题,这个问题如何解决呢。
跟url强相关的另一个概念,分组,表示一组相关的url的集合。假设每个分组占用一个二进制位,分组下的url将对应分组的权限作为自己的权限。仍旧是上文的例子,从权限点的表示开始:
url |
描述 |
权限值(十进制) |
二进制 |
版本 |
|
|
|
|
|
|
|
|
|
|
/api/feed/delete-comment |
禁止删除评论 |
1 |
0000 0001 |
1.1 |
/api/room/create-live- broadcast-room |
禁止创建直播间 |
2 |
0000 0010 |
1.2 |
/api/room/share-live- broadcast-room |
禁止分享直播间 |
2 |
0000 0010 |
1.3 |
分组的权限如下:
分组 |
描述 |
权限值(十进制) |
二进制 |
feed |
动态 |
1 |
0000 0001 |
room |
直播间 |
2 |
0000 0010 |
用户张三此时处于1.1版本,且拥有发布动态、评论动态的权限,而删除评论(权限值1)的权限被禁止,则当前的权限值为1(即二进制0000 0001)。(注意:此时发布动态、评论动态不需要配置在权限点的表中,权限点的表中仅配置被禁止访问的url。)
用户 |
权限值(十进制) |
二进制 |
张三 |
1 |
0000 0001 |
当用户张三升级到版本1.2,此时创建直播间功能上线,默认情况下当前用户A被禁止的权限仍为1(即二进制0000 0001)。
这时候,用户张三在直播间发布不当言论,此时用户张三的创建直播间(权限值2)功能被封禁,则张三的权限值变更为3(即二进制0000 0011)。接着用户张三升级到1.3版本,此时用户张三的直播间功能仍处于冻结状态,即分享直播间(权限值2)功能不可用,此时张三的权限值仍保持为2(即二进制0000 0011)。
最后,关于权限值类型的选择,由于目前我们的分组包括动态、1V1、搭讪、音视频匹配、直播间、家族、交友大厅等,不超过二十个,而java中基本数据为long型的数据,由64个二进制位组成,在每个分组占用一个二进制位情况下,在未来的一段时间内,已足够使用。当业务发展需要,64位不足够时,如何做:
分组表结构增加一列,记录long数据的位置,默认第一个。
序号 |
分组 |
描述 |
权限值(十进制) |
十六进制 |
long数据的下标 |
1 |
feed |
动态 |
1 |
0000 0000 0000 0001 |
1(默认,代表第一个long型的数据) |
2 |
room |
直播间 |
2 |
0000 0000 0000 0002 |
1 |
... |
|||||
64 |
n64 |
n64 |
-9223372036854775808 |
8000 0000 0000 0000 |
1 |
65 |
n65 |
n65 |
1 |
0000 0000 0000 0001 |
2 |
同理,用户的权限值也要增加权限值的位置,代表第几个。判断用户是否有某个url的权限时,加上url对应的long数据的下标的判断即可。
最后
网络空间不是法外之地,发布网络信息必须遵守法律法规、恪守道德底线。