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

1,501 阅读13分钟

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

背景

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

后端+客户端的整体设计

  • 客户端获取url权限列表。

  • 客户端登录后,获取当前登录用户的权限。

  • 客户端在发起请求前,判断当前用户是否有权限访问指定url,为服务端拦截无效请求。

  • 服务端收到请求后,判断当前用户是否有权限访问指定url:

    • 用户的当前url未被冻结,允许请求。

    • 用户被冻结:

      • 当前url不属于url权限列表,直接发起请求。

      • 当前url属于url权限列表,

        • 用户被冻结的权限中包含此url对应的权限,提示用户此url的访问权限被封禁。
        • 用户被冻结的权限中不包含此url对应的权限,直接发起请求。

用户发布动态流程图

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

流程图 (5).jpg 加了用户权限冻结之后,在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<URLPermissionDO> 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);

}

用户权限相关设计

页面-用户权限列表

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

image.png



 /**

 * 编辑用户权限信息

 *

 *  @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<URLUserPermissionDO> 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<Long> 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<Long> newURLGroupIdList = StringUtils.isEmpty(newUrlGroupIds) ? Lists.newArrayList() : Arrays.asList(newUrlGroupIds.split(",")).stream().map(s -> Long.parseLong(s.trim())).collect(Collectors.toList());

    List<URLGroupDO> 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<URLGroupDO> urlGroupDOList) {

    long longValue = 0L;

    for (URLGroupDO urlGroupDO : urlGroupDOList) {

        longValue = longValue | urlGroupDO.getLongValue();

    }

    return longValue;

}

表结构-用户分组关系

主键用户id分组ids权限值权限值(十六进制)状态
1143256[1]10x0000000000000001enable
2148196[1,2]30x0000000000000003enable
3151337[2]20x0000000000000002enable
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<URLGroupDO> 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;

}

表结构-分组

主键分组编码分组描述权限值权限值(十六进制)状态
1live_room直播间10x0000000000000001有效
2voice_room语音房20x0000000000000002有效
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<URLPermissionDO> 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禁止创建直播间10x0000000000000001有效

思路

权限管理系统中最普遍的是RBAC(Role-Based Access Control)模型,它有3个基础组成部分,分别是:用户、角色和权限。他们之间的关系为一个用户可以有多个角色,一个角色可以对应多个权限,用户和角色无直接关系。

如何表达权限值?计算机内部采用二进制,与十进制数相比,二进制数的运算规则简单,这里我们使用二进制来表示url的权限值。当每个url占用一个二进制位时,权限点的表示如下:

url描述权限值(十进制)二进制版本
/api/feed/add-feed发布动态10000 00011.0
/api/feed/add-comment评论动态20000 00101.0
/api/feed/delete-comment删除评论40000 01001.1
/api/room/create-live- broadcast-room创建直播间80000 10001.2
/api/room/share-live- broadcast-room禁止分享直播间160001 00001.3

分组,记录当前分组下的所有url的权限,此时分组权限如下:

分组描述权限值(十进制)二进制
feed动态70000 0111
room直播间240001 1000

假设我们存储的是用户拥有的权限的总和,并且用户张三此时处于1.1版本,且拥有发布动态、评论动态的权限,而删除评论的权限被禁止,则当前的权限值为3(即二进制0000 0011)。

用户权限值(十进制)二进制
张三30000 0011

当用户张三升级到版本1.2,此时创建直播间功能上线,默认情况下当前用户A拥有的权限值变更为11(即二进制0001 011)。后续版本发布每新增一个url时,就需要刷新所有用户对应的权限值,代表用户拥有这个url权限。

频繁的刷新,必然会带来一系列影响,从业务需求角度出发,这里改为记录用户被封禁的权限的总和。仍是上文的例子,从权限点的表示开始:

url描述权限值(十进制)二进制版本
/api/feed/add-feed禁止发布动态10000 00011.0
/api/feed/add-comment禁止评论动态20000 00101.0
/api/feed/delete-comment禁止删除评论40000 01001.1
/api/room/create-live- broadcast-room禁止创建直播间80000 10001.2
/api/room/share-live- broadcast-room禁止分享直播间160001 00001.3

分组的权限如下:

分组描述权限值(十进制)二进制
feed动态70000 0111
room直播间240001 1000

用户张三此时处于1.1版本,且拥有发布动态、评论动态的权限,而删除评论(权限值4)的权限被禁止,则当前的权限值为4(即二进制0000 0100)。

用户权限值(十进制)二进制
张三40000 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禁止发布动态10000 00011.0
/api/feed/add-comment禁止评论动态10000 00011.0
/api/feed/delete-comment禁止删除评论10000 00011.1
/api/room/create-live- broadcast-room禁止创建直播间20000 00101.2
/api/room/share-live- broadcast-room禁止分享直播间20000 00101.3

分组的权限如下:

分组描述权限值(十进制)二进制
feed动态10000 0001
room直播间20000 0010

用户张三此时处于1.1版本,且拥有发布动态、评论动态的权限,而删除评论(权限值1)的权限被禁止,则当前的权限值为1(即二进制0000 0001)。(注意:此时发布动态、评论动态不需要配置在权限点的表中,权限点的表中仅配置被禁止访问的url。)

用户权限值(十进制)二进制
张三10000 0001

当用户张三升级到版本1.2,此时创建直播间功能上线,默认情况下当前用户A被禁止的权限仍为1(即二进制0000 0001)。

这时候,用户张三在直播间发布不当言论,此时用户张三的创建直播间(权限值2)功能被封禁,则张三的权限值变更为3(即二进制0000 0011)。接着用户张三升级到1.3版本,此时用户张三的直播间功能仍处于冻结状态,即分享直播间(权限值2)功能不可用,此时张三的权限值仍保持为2(即二进制0000 0011)。

最后,关于权限值类型的选择,由于目前我们的分组包括动态、1V1、搭讪、音视频匹配、直播间、家族、交友大厅等,不超过二十个,而java中基本数据为long型的数据,由64个二进制位组成,在每个分组占用一个二进制位情况下,在未来的一段时间内,已足够使用。当业务发展需要,64位不足够时,如何做:

分组表结构增加一列,记录long数据的位置,默认第一个。

序号分组描述权限值(十进制)十六进制long数据的下标
1feed动态10000 0000 0000 00011(默认,代表第一个long型的数据)
2room直播间20000 0000 0000 00021
...
64n64n64-92233720368547758088000 0000 0000 00001
65n65n6510000 0000 0000 00012

同理,用户的权限值也要增加权限值的位置,代表第几个。判断用户是否有某个url的权限时,加上url对应的long数据的下标的判断即可。

最后

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

作者简介

木夕,来自杭州晓宇科技,技术中台后端工程师。

| 本文系晓宇科技技术团队出品,著作权归属晓宇科技技术团队。欢迎出于分享和交流等非商业目的转载或使用本文内容,敬请注明“内容转载自晓宇科技技术团队”。本文未经许可,不得进行商业性转载或者使用。