实现二维码(链接)分享

1,479 阅读5分钟

链接(二维码)分享需求

  • 功能模块添加分享链接(二维码)功能,通过分享出去的链接,可查看功能模块的详情。

  • 分享出去的链接在1天/3天/7天/30天/永不过期,打开过期的链接,弹出提示页面链接已过期。

  • 3某些模块分享的链接只能由系统内的指定用户打开,在其他系统外或者非指定的用户打开提示无权限。

  • 支持Android/Ios/Web端分享,在Android/Ios端内扫描二维码直接跳转至相应功能模块

程序设计方案

二维码分享 (1)

数据库脚本
CREATE TABLE `url_share` (
  `id` varchar(32) NOT NULL COMMENT '主键',
  `userSn` varchar(10) NOT NULL COMMENT '发起分享人',
  `expire` bigint(20) NOT NULL COMMENT '过期时间,-1代表永久',
  `shareParam` longtext NOT NULL COMMENT '分享参数',
  `shareModule` varchar(20) NOT NULL COMMENT '所属模块',
  `shareToken` varchar(32) NOT NULL COMMENT '分享token',
  `shareUrl` varchar(512) NOT NULL COMMENT '分享的链接',
  `shareTime` datetime NOT NULL COMMENT '分享时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `ix_shareToken` (`shareToken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='链接分享';


CREATE TABLE `url_share_userauth` (
  `id` varchar(32) NOT NULL,
  `userSn` varchar(10) NOT NULL COMMENT '用户通行证',
  `shareId` varchar(32) NOT NULL COMMENT '分享id',
  PRIMARY KEY (`id`),
  KEY `ix_shareId` (`shareId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='链接分享用户授权';
接口新开Or复用已有接口

事先与客户端约定所有用于分享链接(二维码)的接口request uri前添加/share前缀作为分享接口的标识

1.新增/share/xxx的接口

比如现有业务依赖/doBiz接口,需要实现分享功能

@PostMapping("/doBiz")
public void doBiz(@RequestParam String param) throws Exception {
    helloService.doBiz(param);
}

新增一个用于分享的接口,定义为/share/doBiz,然后复用service的方法

@PostMapping("/share/doBiz")
public void shareDoBiz(@RequestParam String param) throws Exception {
    helloService.doBiz(param);
}

每有一个新模块需要分享功能,在控制层controller要增加一个或者多个/share/xxx的接口,造成代码重复。试想在不同的版本迭代过程中,都会存在模块添加分享功能的需求,到时候再去增加一个或者多个/share/xxx的接口,这是很难接受的。

2.篡改请求复用已有接口

ServletFilter或者Spring CloudZuulFilter允许我们在收到请求真正转发给ServerletDispatcher之前修改HttpServerletRequestrequest urirequest param,下面是一个通过Spring CloudZuulFilter篡改请求的例子。

2.1与前端约定所有分享页面调用业务接口的格式为:

Get(Post)  /share/doBiz...

2.2配置可用于通过/share/xxx访问的接口uri

如果将所有接口都允许通过/share/xxx的形式暴露出去,这是非常严重的系统漏洞,对于业务数据敏感的业务可能会带来无法挽回的损失,我们可以通过配置文件给每个模块配置允许通过/share/xxx访问的接口,这样在每次需要给新模块添加分享功能时,仅仅需要添加配置文件,对于不符合配置模块请求uri的接口,跳过篡改请求参数(地址)的Filter继续执即可。

配置文件urshare.json

[
    {
        "htmlUrl":"http://172.16.1.133:9529/#/share",
            "reqUrls":[
                "/xxxx/task/findTaskType/**",
                "/xxx/task/taskDetail/**"
    ],
        "module":"taskDetail"
    },
    {
        "htmlUrl":"http://172.16.1.133:9529/#/share",
            "reqUrls":[
                "/xxx/user/info/**"
    ],
        "module":"userInfo"
    }
]

2.3匹配uri是否符合规则的方法

public static boolean pathMatchPattern(String path, List<String> patterns) {
    boolean result = false;
    for (String pattern : patterns) {
        //Spring提供的用于匹配uri正则的工具类
        AntPathMatcher matcher = new AntPathMatcher();
        if (matcher.match(pattern, path)) {
            result = true;
            break;
        }
    }
    return result;
}
篡改HttpServletRequest请求和校验/share/doBiz请求网关ZuulFilter
@Component
public class UrlShareFilter extends ZuulFilter implements ApplicationRunner {

    private Logger logger = LoggerFactory.getLogger(UrlShareFilter.class);

    @Resource
    private RedisUtil redisUtil;

    @Resource
    private UrlShareFeignService urlShareFeignService;

    @Override
    public String filterType() {
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        RequestContext ctx = RequestContext.getCurrentContext();
        String reqUri = ctx.getRequest().getRequestURI();
        if (reqUri.indexOf("/share/") != -1) {
            try {
                return PathUtils.pathMatchPattern(reqUri.replaceFirst("/share", EmptyUtils.EMPTY_STR), urlShareFeignService.shareReqUrls());
            } catch (Exception e) {
                logger.error("urlShareFeignService.shareReqUrls error", e);
                 return false;
            }
        }
        return false;
    }

    @Override
    public Object run() throws ZuulException {
        //解析并验证shareToken
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();
        String shareToken = null;
        Map<String, String[]> queryParamMap = request.getParameterMap();
        if (EmptyUtils.isNotEmpty(queryParamMap)) {
            String[] queryParam = queryParamMap.get(Constants.SHARE_TOKEN_HEADER);
            if (EmptyUtils.isNotEmpty(queryParam)) {
                shareToken = queryParam[0];
            }
        }
        String usrToken = null;
        if (EmptyUtils.isEmpty(shareToken)) {
            sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "分享链接不合法");
            return false;
        } else {
            UrlShareInfo urlShareInfo = null;
            try {
                urlShareInfo = urlShareFeignService.getUrlShareInfo(shareToken);
            } catch (Exception ex) {
                sendResp(ctx, HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务内部错误");
                return false;
            }
            if (urlShareInfo == null) {
                sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "分享链接无效");
                return false;
            }
            if (urlShareInfo.getExpire() > 0&& urlShareInfo.getExpire() <= System.currentTimeMillis()) {
                sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "分享链接已过期");
                return false;
            }
            if (EmptyUtils.isNotEmpty(ctx.getRequest().getHeader(Constants.TOKEN_HEADER))) {
                usrToken = ctx.getRequest().getHeader(Constants.TOKEN_HEADER);
            }
            if (EmptyUtils.isNotEmpty(urlShareInfo.getAuthUserSns())) {
                if (usrToken == null) {
                    sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
                    return false;
                } else {
                    //分享链接需要用户权限打开
                    if (redisUtil.get(usrToken) != null) {
                        UserSession userSession = JSONObject.parseObject(redisUtil.get(usrToken).toString(), UserSession.class);
                        if (userSession != null) {
                            if (!urlShareInfo.getAuthUserSns().contains(userSession.getUserSn())) {
                                sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
                                return false;
                            }
                        } else {
                            sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
                            return false;
                        }
                    } else {
                        sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
                        return false;
                    }
                }
            }
        }
        //share请求重定向到正常请求
        final String realToken = (usrToken == null ? Constants.QRCODE_SHARE_REDIS_KEY : usrToken);
        String url = request.getRequestURI().replaceFirst("/share", EmptyUtils.EMPTY_STR);
        ctx.setRequest(new HttpServletRequestWrapper(request) {
            @Override
            public String getRequestURI() {
                return url;
            }

            @Override
            public Map<String, String[]> getParameterMap() {
                return queryParamMap;
            }

            @Override
            //设置用于分享的Cookie(Token)参数,访问后台接口使用
            public String getHeader(String name) {
                if (name.equals(Constants.TOKEN_HEADER) || name.equals(WpsConst.HEAD_TOKEN)) {
                    return realToken;
                }
                return super.getHeader(name);
            }
        });
        Map<String, List<String>> requestQueryParams = ctx.getRequestQueryParams();
        if (requestQueryParams == null) {
            requestQueryParams = new HashMap<>();
        }
        requestQueryParams.remove(Constants.SHARE_TOKEN_HEADER);
        ctx.setRequestQueryParams(requestQueryParams);
        ctx.put(FilterConstants.REQUEST_URI_KEY, url);
        ctx.addZuulRequestHeader(Constants.TOKEN_HEADER, realToken);
        return true;
    }

    private void sendResp(RequestContext ctx, Integer code, String errorMsg) {
        ctx.setSendZuulResponse(false);
        try {
            ctx.setResponseStatusCode(HttpStatus.INTERNAL_SERVER_ERROR.value());
            ctx.getResponse().setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            ctx.getResponse().getWriter().write(JSONObject.toJSONString(ResponseResult.fail(code, errorMsg)));
        } catch (Exception e) {
            logger.info(logger.toString());
        }
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        //初始化用于分享用到的Session数据
        redisUtil.set(Constants.QRCODE_SHARE_REDIS_KEY, Constants.QRCODE_SHARE_REDIS_VAL);
    }

}
  • 由于部分接口访问需要获取用户信息,先通过ApplicationRunner.run初始化分享Cookie(Token)Session数据
  • shouldFilter方法用于判断/share/doBiz请求,是否允许经过UrlShareFilter篡改请求uri和参数,判断逻辑:匹配请求是否符合urlshare.json配置的reqUrls其中的一条uri规则,是的话就需要通过run方法篡改请求。
  • run方法根据shareToken查找此次分享的参数信息,如链接时效性 有效性 授权人并校验,其次篡改RequestUri去除/share前缀和requestParam,添加用于分享用的Cookie(Token)信息
  • 注意UrlShareFilter的优先级应该配置最高优先级
生成二维码

使用hutool工具包的QrCodeUtil类创建二维码并返回给客户端

 @PostMapping(value = "/shareUrlQrcode", produces = "application/octet-stream;charset=UTF-8")
    public void shareUrlQrcode(@RequestBody GetShareUrlParam getShareUrlParam) throws Exception   		{
        try {
            HttpServletResponse response = getResponse();
            response.setHeader("Pragma", "No-cache");
            response.setHeader("Cache-Control", "no-cache");
            response.setDateHeader("Expires", 0);
            response.setContentType("image/png");
            String shareUrl = urlShareService.shareUrl(getShareUrlParam, getUserSn());
            QrConfig qrConfig = 	QrConfig.create().setWidth(500).setHeight(500).setMargin(0).setImg(ImageIO.read(ResourceUtil.getStream("qrcodelog.png")));
            QrCodeUtil.generate(shareUrl, qrConfig,"png", response.getOutputStream());
        }catch (Exception e){
            logger.error("shareUrlQrcode error,getShareUrlParam={}", getShareUrlParam, e);
            throw e;
        }
    }

总结

生成二维码最好不要将过期时间/授权用户信息直接加密放到requestParam参数传递,因为参数大小的不确定性将会导致二维码非常密集,相机在扫描密集二维码的效果会变得很差很差。

通过shareToken,后端交由UrlShareFilter根据shareToken获取校验过期时间/授权用户;前端可以通过shareToken参数调用接口得到分享页面所需的参数信息。并且二维码链接的长度确定,二维码的扫描性能得到了保证。