在社交系统中实现用户权限冻结

这篇文章是对暖聊中用户权限冻结功能设计的一次复盘,读者有需要的话可以依据本文操练起来~如有不当之处,恳请指正。

背景

用户在社交产品中发布不当言论,某种程度上会给其他用户带来不良影响,应该限制其行为,将负面影响降到最小。例如在动态中发布不当言论,系统将会推送消息提示用户遵守《用户协议》,同时在指定时长内禁止使用动态功能。

后端+客户端的整体设计

  • 客户端获取url权限列表。
  • 客户端登录后,获取当前登录用户的权限。
  • 客户端在发起请求前,判断当前用户是否有权限访问指定url,为服务端拦截无效请求。
  • 服务端收到请求后,判断当前用户是否有权限访问指定url:
    • 用户的当前url未被冻结,允许请求。
    • 用户被冻结:
      • 当前url不属于url权限列表,直接发起请求。
      • 当前url属于url权限列表,
        • 用户被冻结的权限中包含此url对应的权限,提示用户此url的访问权限被封禁。
        • 用户被冻结的权限中不包含此url对应的权限,直接发起请求。

用户发布动态流程图

以用户发布动态请求为例,在没有权限冻结时,流程就是从gateway(网关层)路由转发到到业务服务,在业务服务进行业务逻辑的处理即可。

在社交系统中实现用户权限冻结_第1张图片

加了用户权限冻结之后,在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);
}

用户权限相关设计

页面-用户权限列表

在社交系统中实现用户权限冻结_第2张图片

 接口-设置用户权限(冻结、取消冻结)

在社交系统中实现用户权限冻结_第3张图片


/**
 * 编辑用户权限信息
 *
 * @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分组列表

在社交系统中实现用户权限冻结_第4张图片

 接口-编辑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权限列表

在社交系统中实现用户权限冻结_第5张图片

接口-编辑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/add-feed

禁止发布动态

1

0000 0001

1.0

/api/feed/add-comment

禁止评论动态

1

0000 0001

1.0

/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数据的下标的判断即可。

最后

网络空间不是法外之地,发布网络信息必须遵守法律法规、恪守道德底线。

你可能感兴趣的:(http,java,网络协议)