2023年3月

在前面的设计和实现中,我们的微服务开发平台通过JustAuth来实现第三方授权登录,通过集成公共组件,着实减少了很多工作量,大多数的第三方登录直接通过配置就可以实现。而在第三方授权登录中,微信小程序授权登录和APP微信授权登录是两种特殊的第三方授权登录。
JustAuth之所以能够将多种第三方授权登录服务整合在一起,抽象公共组件的原因是大多数的授权登录服务器都是遵循OAuth2.0协议开发,虽然略有不同但可通过适配器进行转换为统一接口。微信小程序授权登录和APP的微信授权登录也是OAutn2.0协议的授权登录,但在对接的流程中不是完整的OAuth2.0对接流程。
通常的第三方授权登录过程中,获取token的state和code是在回调客户端url中获取的,而微信小程序授权登录和APP的微信授权登录获取token的state和code是使用微信提供的特定方法获取到的,然后通过微信传给客户端,客户端拿到code之后到后台取获取openid等微信用户信息。然后,再进行系统登录相关操作。

一、微信小程序授权登录、注册、绑定流程设计说明

  • 微信小程序授权登录、注册、绑定流程图:

微信小程序登录、注册、绑定流程

  • 微信小程序授权登录、注册、绑定流程说明:
    1、用户进入小程序。
    2、小程序前端通过从缓存中获取是否有token来判定用户是否登录。
    3、如果未登录,那么跳转到小程序登录页。
    4、小程序前端执行微信登录方法wx.login获取微信登录的code(此时并未进行微信授权登录)。
    5、小程序前端通过code向业务后台发送请求获取用户唯一的openid。
    6、业务系统根据openid或者unionid判断该用户是否绑定了业务用户,并将是否绑定信息返回给前台。
    7、如果没有绑定过,那么前端展示微信授权登录按钮。
    8、用户点击“授权登录”按钮之后,小程序前端会获取到加密的用户信息。
    9、小程序前端将加密的用户信息传到业务后台进行解密。
    10、业务后台收到加密用户信息后,通过请求微信服务器解密用户信息,并将用户信息存储到业务系统表。
    11、后台将解密后的用户信息(非私密信息)返回到小程序前台。
    12、如果是没有绑定的,那么小程序前台弹出是否获取当前用户手机号的弹出框。
    13、用户选择是否获取微信绑定的手机号来注册或绑定到业务系统的用户。
    14、当用户点击统一获取手机号时,微信会返回加密后的手机号,然后前端将加密后的手机号发送到业务后台解密。
    15、业务后台获取到手机号码之后,会根据手机号码在系统用户表中进行匹配,如果匹配到用户,那么直接返回小程序用户信息。
    16、当用户不同意获取手机号时,那么小程序跳转到输入账号密码进行绑定页面。
    17、当绑定操作执行成功之后,微信小程序调用第三方登录获取token方式,向业务后台获取token。
    18、用户小程序授权登录、注册、绑定成功。

二、微信小程序授权登录、注册、绑定业务后台功能实现

微信通过其开放平台提供小程序登录功能接口,我们的业务服务可以通过小程序的登录接口方便地获取微信提供的用户身份标识,进而将业务自身用户体系和微信用户相结合,从而更完美地在微信小程序中实现业务功能。
微信小程序提供了对接登录的SDK,我们只需要按照其官方文档对接开发即可。同时也有很多开源组件将SDK再次进行封装,在业务开发中可以更快速的集成小程序各个接口的调用。
出于快速开发的原则,同时也少走弯路、少踩坑,我们可以选择一款实现比较完善的组件进行微信小程序的对接。weixin-java-miniapp是集成微信小程序相关SDK操作的工具包,我们在项目中集成此工具包来实现微信小程序授权登录。

1、引入weixin-java-miniapp相关maven依赖,目前发布版本为4.4.0正式版。

一般在选择开源工具包时,我们不会选择最新版,而是选择稳定版本,但是微信的开放接口经常变动,这里为了能够兼容最新的微信小程序接口,我们在引用包的时候一定要选择更新版本,否则会影响部分接口的调用。

......
    <properties>
......
        <!-- 微信小程序版本号 -->
        <weixin-java-miniapp.version>4.4.0</weixin-java-miniapp.version>
    </properties>
......
            <dependency>
                <groupId>com.github.binarywang</groupId>
                <artifactId>weixin-java-miniapp</artifactId>
                <version>${weixin-java-miniapp.version}</version>
            </dependency>
......

2、在配置文件application-dev.yml、application-test.yml、application-prod.yml中新增微信小程序需要的配置项。

关于小程序如何注册,appid和appsecret如何获取,这里不展开讲,微信开放平台有详细的说明文档。

wx:
  miniapp:
    configs:
      - appid: #微信小程序appid
        secret: #微信小程序secret
        token: #微信小程序消息服务器配置的token
        aesKey: #微信小程序消息服务器配置的EncodingAESKey
        msgDataFormat: JSON
3、将weixin-java-miniapp配置类文件WxMaConfiguration.java和WxMaProperties.java添加到我们的工程中。
  • WxMaConfiguration.java关键代码
......
@Data
@ConfigurationProperties(prefix = "wx.miniapp")
public class WxMaProperties {

    private List<Config> configs;

    @Data
    public static class Config {
        /**
         * 设置微信小程序的appid
         */
        private String appid;

        /**
         * 设置微信小程序的Secret
         */
        private String secret;

        /**
         * 设置微信小程序消息服务器配置的token
         */
        private String token;

        /**
         * 设置微信小程序消息服务器配置的EncodingAESKey
         */
        private String aesKey;

        /**
         * 消息格式,XML或者JSON
         */
        private String msgDataFormat;
    }

}
......
  • WxMaProperties.java关键代码
......
    private final WxMaProperties properties;

    @Autowired
    public WxMaConfiguration(WxMaProperties properties) {
        this.properties = properties;
    }

    @Bean
    public WxMaService wxMaService() {
        List<WxMaProperties.Config> configs = this.properties.getConfigs();
        if (configs == null) {
            throw new WxRuntimeException("配置错误!");
        }
        WxMaService maService = new WxMaServiceImpl();
        maService.setMultiConfigs(
            configs.stream()
                .map(a -> {
                    WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl();
                    config.setAppid(a.getAppid());
                    config.setSecret(a.getSecret());
                    config.setToken(a.getToken());
                    config.setAesKey(a.getAesKey());
                    config.setMsgDataFormat(a.getMsgDataFormat());
                    return config;
                }).collect(Collectors.toMap(WxMaDefaultConfigImpl::getAppid, a -> a, (o, n) -> o)));
        return maService;
    }
......
4、新建WxMaUserController.java用于实现微信小程序请求的相关接口。
  • 实现小程序登录的login接口,此接口会根据微信小程序前端传来的code进行获取用户session_key、openid/unionid,我们的业务系统会根据openid/unionid结合第三方登录表进行用户匹配,如果存在该用户则返回给小程序前台已存在的用户信息;如果不存在,说明该用户是第一次微信小程序登录,那么我们将获取到的微信唯一身份标识加密,并返回微信小程序前台进行下一步绑定账户或注册操作。
    /**
     * 登陆接口
     */
    @ApiOperation(value = "小程序登录接口")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "code", value = "小程序code", dataType="String", paramType = "query"),
    })
    @GetMapping("/login")
    public Result<?> login(@PathVariable String appid, String code) {
        
        if (StringUtils.isBlank(code)) {
            return Result.error("code 不能为空");
        }

        if (!wxMaService.switchover(appid)) {
            throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
        }
    
        WeChatMiniAppLoginDTO weChatMiniAppLoginDTO = new WeChatMiniAppLoginDTO();
        try {
            WxMaJscode2SessionResult session = wxMaService.getUserService().getSessionInfo(code);
            weChatMiniAppLoginDTO.setOpenid(session.getOpenid());
            weChatMiniAppLoginDTO.setUnionid(session.getUnionid());
            // 通过openId获取在系统中是否是已经绑定过的用户,如果没有绑定,那么返回到前台,提示需要绑定或者注册用户
            LambdaQueryWrapper<JustAuthSocial> socialLambdaQueryWrapper = new LambdaQueryWrapper<>();
            // 如果微信开通了开放平台,那么各个渠道(小程序、公众号等)都会有统一的unionid,如果没开通,就仅仅使用openId
            if (StringUtils.isBlank(session.getUnionid()))
            {
                socialLambdaQueryWrapper.eq(JustAuthSocial::getOpenId, session.getOpenid())
                        .eq(JustAuthSocial::getSource, "WECHAT_MINI_APP");
            }
            else
            {
                socialLambdaQueryWrapper.eq(JustAuthSocial::getUnionId, session.getUnionid())
                        .and(e -> e.eq(JustAuthSocial::getSource, "WECHAT_MINI_APP")
                                .or().eq(JustAuthSocial::getSource, "WECHAT_OPEN")
                                .or().eq(JustAuthSocial::getSource, "WECHAT_MP")
                                .or().eq(JustAuthSocial::getSource, "WECHAT_ENTERPRISE")
                                .or().eq(JustAuthSocial::getSource, "WECHAT_APP"));
            }
            JustAuthSocial justAuthSocial = justAuthSocialService.getOne(socialLambdaQueryWrapper, false);
            if (null == justAuthSocial)
            {
                weChatMiniAppLoginDTO.setUserInfoAlready(false);
                weChatMiniAppLoginDTO.setUserBindAlready(false);
                justAuthSocial = new JustAuthSocial();
                justAuthSocial.setAccessCode(session.getSessionKey());
                justAuthSocial.setOpenId(session.getOpenid());
                justAuthSocial.setUnionId(session.getUnionid());
                justAuthSocial.setSource("WECHAT_MINI_APP");
                justAuthSocialService.save(justAuthSocial);
            } else {
                justAuthSocial.setAccessCode(session.getSessionKey());
                justAuthSocialService.updateById(justAuthSocial);
            }
    
            // 将socialId进行加密返回,用于前端进行第三方登录,获取token
            DES des = new DES(Mode.CTS, Padding.PKCS5Padding, secretKey.getBytes(), secretKeySalt.getBytes());
            // 这里将source+uuid通过des加密作为key返回到前台
            String socialKey = "WECHAT_MINI_APP" + StrPool.UNDERLINE + (StringUtils.isBlank(session.getUnionid()) ? session.getOpenid() : session.getUnionid());
            // 将socialKey放入缓存,默认有效期2个小时,如果2个小时未完成验证,那么操作失效,重新获取,在system:socialLoginExpiration配置
            redisTemplate.opsForValue().set(AuthConstant.SOCIAL_VALIDATION_PREFIX + socialKey, String.valueOf(justAuthSocial.getId()), socialLoginExpiration,
                    TimeUnit.SECONDS);
            String desSocialKey = des.encryptHex(socialKey);
            weChatMiniAppLoginDTO.setBindKey(desSocialKey);
            
            // 查询是否绑定用户
            // 判断此第三方用户是否被绑定到系统用户
            Result<Object> bindResult = justAuthService.userBindQuery(justAuthSocial.getId());
            if(null != bindResult && null != bindResult.getData() && bindResult.isSuccess())
            {
                weChatMiniAppLoginDTO.setUserInfoAlready(true);
                weChatMiniAppLoginDTO.setUserBindAlready(true);
            } else {
                // 这里需要处理返回消息,前端需要根据返回是否已经绑定好的消息来判断
                weChatMiniAppLoginDTO.setUserInfoAlready(false);
                weChatMiniAppLoginDTO.setUserBindAlready(false);
            }
            return Result.data(weChatMiniAppLoginDTO);
        } catch (WxErrorException e) {
            log.error(e.getMessage(), e);
            return Result.error("小程序登录失败:" + e);
        } finally {
            WxMaConfigHolder.remove();//清理ThreadLocal
        }
    }
  • 实现获取/解密微信小程序用户信息的info接口
    当微信小程序前端获取到用户授权可以获取用户信息时,微信小程序前端将加密的用户信息发送到业务后台,业务后台请求微信服务器将用户信息解密并保存到我们的第三方用户登录表内。
    /**
     * 获取用户信息接口
     */
    @ApiOperation(value = "小程序获取用户信息接口")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "socialKey", value = "加密的登录key,用于绑定用户", required = true, dataType="String", paramType = "query"),
            @ApiImplicitParam(name = "signature", value = "使用 sha1( rawData + sessionkey ) 得到字符串,用于校验用户信息", required = true, dataType="String", paramType = "query"),
            @ApiImplicitParam(name = "rawData", value = "不包括敏感信息的原始数据字符串,用于计算签名", required = true, dataType="String", paramType = "query"),
            @ApiImplicitParam(name = "encryptedData", value = "包括敏感数据在内的完整用户信息的加密数据", required = true, dataType="String", paramType = "query"),
            @ApiImplicitParam(name = "iv", value = "加密算法的初始向量", required = true, dataType="String", paramType = "query")
    })
    @GetMapping("/info")
    public Result<?> info(@PathVariable String appid, String socialKey,
                       String signature, String rawData, String encryptedData, String iv) {
        if (!wxMaService.switchover(appid)) {
            throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
        }
    
        // 查询第三方用户信息
        JustAuthSocial justAuthSocial = this.getJustAuthSocial(socialKey);
        if (StringUtils.isBlank(justAuthSocial.getAccessCode()))
        {
            throw new BusinessException("登录状态失效,请尝试重新进入小程序");
        }
        // 用户信息校验
        if (!wxMaService.getUserService().checkUserInfo(justAuthSocial.getAccessCode(), rawData, signature)) {
            WxMaConfigHolder.remove();//清理ThreadLocal
            return Result.error("user check failed");
        }

        // 解密用户信息
        WxMaUserInfo userInfo = wxMaService.getUserService().getUserInfo(justAuthSocial.getAccessCode(), encryptedData, iv);
        WxMaConfigHolder.remove();//清理ThreadLocal
        justAuthSocial.setAvatar(userInfo.getAvatarUrl());
        justAuthSocial.setUnionId(userInfo.getUnionId());
        justAuthSocial.setNickname(userInfo.getNickName());
        justAuthSocialService.updateById(justAuthSocial);
        return Result.data(userInfo);
    }
  • 当在小程序前端绑定或注册账号时,可以在用户允许的前提下获取微信用户的手机号,同样,手机号和用户信息都是需要传到业务后台,然后再请求微信服务器进行解密。获取到手机号之后,业务后台根据手机号和系统用户进行匹配,如果存在,那么直接将微信账号绑定到我们业务系统的当前手机号用户。如果不存在,那么返回微信小程序不存在绑定用户的信息,由小程序前端继续进行绑定或者注册操作。
    /**
     * 获取用户绑定手机号信息
     */
    @ApiOperation(value = "小程序获取用户绑定手机号信息")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "socialKey", value = "加密的登录key,用于绑定用户", required = true, dataType="String", paramType = "query"),
            @ApiImplicitParam(name = "encryptedData", value = "包括敏感数据在内的完整用户信息的加密数据", required = true, dataType="String", paramType = "query"),
            @ApiImplicitParam(name = "iv", value = "加密算法的初始向量", required = true, dataType="String", paramType = "query")
    })
    @GetMapping("/phone")
    public Result<?> phone(@PathVariable String appid, String socialKey, String encryptedData, String iv) {
        if (!wxMaService.switchover(appid)) {
            throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
        }
        // 查询第三方用户信息
        JustAuthSocial justAuthSocial = this.getJustAuthSocial(socialKey);
        if (StringUtils.isBlank(justAuthSocial.getAccessCode()))
        {
            throw new BusinessException("登录状态失效,请尝试重新进入小程序");
        }

        // 解密
        WxMaPhoneNumberInfo phoneNoInfo = wxMaService.getUserService().getPhoneNoInfo(justAuthSocial.getAccessCode(), encryptedData, iv);
        WxMaConfigHolder.remove();//清理ThreadLocal
        
        // 不带区号的手机,国外的手机会带区号
        String phoneNumber = phoneNoInfo.getPurePhoneNumber();
        // 查询用户是否存在,如果存在,那么直接调用绑定接口
        LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.eq(User::getMobile, phoneNumber);
        User userInfo = userService.getOne(lambdaQueryWrapper);
        Long userId;
        // 判断返回信息
        if (null != userInfo && null != userInfo.getId()) {
            userId = userInfo.getId();
        }
        else {
            // 如果用户不存在,那么调用新建用户接口,并绑定
            CreateUserDTO createUserDTO = new CreateUserDTO();
            createUserDTO.setAccount(phoneNumber);
            createUserDTO.setMobile(phoneNumber);
            createUserDTO.setNickname(StringUtils.isBlank(justAuthSocial.getNickname()) ? phoneNumber : justAuthSocial.getNickname());
            createUserDTO.setPassword(StringUtils.isBlank(justAuthSocial.getUnionId()) ? justAuthSocial.getOpenId() : justAuthSocial.getUnionId());
            createUserDTO.setStatus(GitEggConstant.UserStatus.ENABLE);
            createUserDTO.setAvatar(justAuthSocial.getAvatar());
            createUserDTO.setEmail(justAuthSocial.getEmail());
            createUserDTO.setStreet(justAuthSocial.getLocation());
            createUserDTO.setComments(justAuthSocial.getRemark());
            CreateUserDTO resultUserAdd = userService.createUser(createUserDTO);
            if (null != resultUserAdd && null != resultUserAdd.getId()) {
                userId = resultUserAdd.getId();
            } else {
                // 如果添加失败,则返回失败信息
                return Result.data(resultUserAdd);
            }
        }
        // 执行绑定操作
        justAuthService.userBind(justAuthSocial.getId(), userId);
        return Result.success("账号绑定成功");
    }
  • 提供绑定当前登录账号接口,由微信小程序前端进行调用绑定
    /**
     * 绑定当前登录账号
     */
    @ApiOperation(value = "绑定当前登录账号")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "socialKey", value = "加密的登录key,用于绑定用户", required = true, dataType="String", paramType = "query")
    })
    @GetMapping("/bind")
    public Result<?> bind(@PathVariable String appid, @NotBlank String socialKey, @CurrentUser GitEggUser user) {
        if (!wxMaService.switchover(appid)) {
            throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
        }
        if (null == user || (null != user && null == user.getId())) {
            throw new BusinessException("用户未登录");
        }
        // 查询第三方用户信息
        JustAuthSocial justAuthSocial = this.getJustAuthSocial(socialKey);
        if (StringUtils.isBlank(justAuthSocial.getAccessCode()))
        {
            throw new BusinessException("账号绑定失败,请尝试重新进入小程序");
        }
        // 执行绑定操作
        justAuthService.userBind(justAuthSocial.getId(), user.getId());
        return Result.success("账号绑定成功");
    }
  • 提供解绑接口,除了绑定接口外,我们系统服务应提供微信小程序解绑功能,这里实现解绑接口
    /**
     * 解绑当前登录账号
     */
    @ApiOperation(value = "解绑当前登录账号")
    @GetMapping("/unbind")
    public Result<?> unbind(@PathVariable String appid, @CurrentUser GitEggUser user) {
        if (!wxMaService.switchover(appid)) {
            throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
        }
        if (null == user || (null != user && null == user.getId())) {
            throw new BusinessException("用户未登录");
        }
        LambdaQueryWrapper<JustAuthSocialUser> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(JustAuthSocialUser::getUserId, user.getId());
        justAuthSocialUserService.remove(queryWrapper);
        return Result.success("账号解绑成功");
    }

通过以上接口的功能,基本实现了微信小程序前端进行绑定、注册以及获取用户信息、用户手机号所需要的接口,下面来实现小程序前端具体的业务实现。

三、微信小程序授权登录、注册、绑定小程序前端功能实现。

微信小程序前端开发有多种方式,可以使用微信小程序官方开发方式,也可以使用第三方的开发方式。因为大多数前端都会使用Vue.js开发,而mpvue可以使用开发Vue.js的方式来开发微信小程序,所以这里我们选择使用mpvue来开发微信小程序。这里不详细讲解mpvue框架的搭建过程,只详细说明微信小程序授权登录相关功能,有需要的可以参考mpvue官方文档。

1、定义微信小程序授权登录相关接口文件login.js,将我们业务后台实现的接口统一管理和调用。

因为我们的开发框架是支持多租户的,同时也是支持多个小程序的,为了同一套后台可以支持多个微信小程序,这里选择在发布的微信小程序中配置appId,由微信小程序前端参数来确定具体的微信小程序。

import fly from '@/utils/requestWx'

// 获取用户信息
export function getOpenId (params) {
  return fly.get(`/wx/user/${params.appId}/login`, params)
}

// 获取用户信息
export function getUserInfo (params) {
  return fly.get(`/wx/user/${params.appId}/info`, params)
}

// 获取用户手机号
export function getUserPhone (params) {
  return fly.get(`/wx/user/${params.appId}/phone`, params)
}

// 绑定微信账号
export function bindWeChatUser (params) {
  return fly.get(`/wx/user/${params.appId}/bind`, params)
}

// 解绑微信账号
export function unbindWeChatUser (params) {
  return fly.get(`/wx/user/${params.appId}/unbind`)
}

// 登录
export function postToken (params) {
  return fly.post(`/oauth/token`, params)
}

// 退出登录
export function logout () {
  return fly.post(`/oauth/logout`)
}

// 获取登录用户信息
export function getLoginUserInfo () {
  return fly.get(`/system/account/user/info`)
}

2、新增login.vue授权登录页面,实现微信小程序具体授权登录、绑定、注册的操作界面。
  • 增加微信授权登录按钮
    <div class="login-btn" v-show="!showAccountLogin">
      <van-button color="#1aad19"  block open-type="getUserInfo" @getuserinfo="bindGetUserInfo" >微信授权登录</van-button>
    </div>
  • 增加微信小程序获取微信用户绑定手机号的弹出框,应微信官方要求,必须由用户点击授权之后,才能够获取用户绑定的手机号。
    <van-dialog 
      title="授权验证"
      @getphonenumber="bindGetUserPhone"
      confirm-button-open-type="getPhoneNumber"
      message="微信一键登录需要绑定您的手机号"
      :show="showUserPhoneVisible">
    </van-dialog>
  • 增加账号绑定弹出框,同样微信官方要求,获取微信用户信息,必须由用户点击授权允许,所以这里弹出框里的按钮也是微信用户同意授权获取用户信息的按钮。
    <van-dialog 
      title="账号绑定"
      @getuserinfo="bindUserInfo"
      confirm-button-open-type="getUserInfo"
      message="登录成功,是否将账号绑定到当前微信?"
      :show="showUserInfoVisible">
    </van-dialog>
3、在methods中新增微信绑定相关方法来具体实现微信小程序授权登录、绑定、注册等操作接口的调用。
  • 通过wx.login拿到code,在后台通过openId判断是否已经绑定用户,如果已绑定用户,则不需要再进行用户授权操作,直接登录.
wxLogin () {
      var that = this
      wx.login({
        success (res) {
          that.code = res.code
          const params = {
            appId: appId,
            code: res.code
          }
          getOpenId(params).then(res => {
            if (res.code === 200 && res.data) {
              const result = res.data
              mpvue.setStorageSync('openid', result.openid)
              mpvue.setStorageSync('unionid', result.unionid)
              mpvue.setStorageSync('bindKey', result.bindKey)
              mpvue.setStorageSync('userBindAlready', result.userBindAlready)
              // 1、如果绑定过,那么直接使用绑定用户登录
              // 2、如果没有绑定过,那弹出获取用户信息和获取手机号信息进行绑定
              if (result.userBindAlready) {
                const loginParams = {
                  grant_type: 'social',
                  social_key: mpvue.getStorageSync('bindKey')
                }
                postToken(loginParams).then(res => {
                  if (res.code === 200) {
                    console.log(res)
                    const data = res.data
                    mpvue.setStorageSync('token', data.token)
                    mpvue.setStorageSync('refreshToken', data.refreshToken)
                    // 获取用户信息
                    that.loginSuccess()
                  } else {
                    Toast(res.msg)
                  }
                })
              }
            } else {
              Toast(res.msg)
            }
          })
        }
      })
    },
  • 获取微信用户信息实现登陆,微信小程序接口,只允许点击按钮,用户同意后才能获取用户信息,不要直接使用wx.getUserInfo,此接口已过期,微信不再支持。
    bindGetUserInfo: function (res) {
      var that = this
      if (res.mp.detail.errMsg === 'getUserInfo:ok') {
        const userParams = {
          appId: appId,
          socialKey: mpvue.getStorageSync('bindKey'),
          signature: res.mp.detail.signature,
          rawData: res.mp.detail.rawData,
          encryptedData: res.mp.detail.encryptedData,
          iv: res.mp.detail.iv
        }
        getUserInfo(userParams).then(response => {
          const userBindAlready = mpvue.getStorageSync('userBindAlready')
          // 1、如果绑定过,那么直接使用绑定用户登录
          // 2、如果没有绑定过,那弹出获取用户信息和获取手机号信息进行绑定
          if (userBindAlready) {
            const loginParams = {
              grant_type: 'social',
              social_key: mpvue.getStorageSync('bindKey')
            }
            postToken(loginParams).then(res => {
              if (res.code === 200) {
                console.log(res)
                const data = res.data
                mpvue.setStorageSync('token', data.token)
                mpvue.setStorageSync('refreshToken', data.refreshToken)
                // 获取用户信息
                that.loginSuccess()
              } else {
                // 弹出获取手机号授权按钮
                that.showUserPhoneVisible = true
              }
            })
          } else {
            // 弹出获取手机号授权按钮
            that.showUserPhoneVisible = true
          }
        })
      } else {
        console.log('点击了拒绝')
      }
    },
  • 微信通过用户点击授权获取手机号来实现账号绑定操作,如果用户点击拒绝,那么提示用户无法绑定当前用户微信绑定的手机号,请用户继续授权。
    bindGetUserPhone (e) {
      const that = this
      if (e.mp.detail.errMsg === 'getPhoneNumber:ok') {
        console.log(e.mp.detail)
        // 写入store
        const params = {
          appId: appId,
          socialKey: mpvue.getStorageSync('bindKey'),
          encryptedData: e.mp.detail.encryptedData,
          iv: e.mp.detail.iv
        }
        getUserPhone(params).then(res => {
          if (res.code === 200) {
            console.log(res)
            const loginParams = {
              grant_type: 'social',
              social_key: mpvue.getStorageSync('bindKey')
            }
            postToken(loginParams).then(res => {
              if (res.code === 200) {
                console.log(res)
                const data = res.data
                mpvue.setStorageSync('token', data.token)
                mpvue.setStorageSync('refreshToken', data.refreshToken)
                // 获取用户信息
                that.loginSuccess()
              } else {

              }
            })
          } else {
            that.showUserPhoneVisible = false
            // 获取用户信息
            that.loginSuccess()
            Toast(res.msg)
          }
        })
      } else {
        that.showUserPhoneVisible = false
        Toast('当前拒绝授权手机号登陆,请使用账号密码登录')
      }
    },

通过以上开发基本实现了微信小程序授权登录第三方业务系统的功能,在此基础上,注册的功能可以根据业务需求来扩展,大多数互联网业务,都会是微信小程序授权登录之后就自动注册用户。但是有些传统行业的业务,比如只有某些公司或组织内部的用户才能登录,那么是不允许微信授权登录就自助注册成系统用户的。微信小程序前端框架也可以归根据自己的需求,及擅长的开发方式来选择,但是微信授权登录的流程是不变的,可以在此基础上根据业务需求修改优化。

源码地址:

Gitee:
https://gitee.com/wmz1930/GitEgg

GitHub:
https://github.com/wmz1930/GitEgg

Vue.js 基本上遵循 MVVM(Model–View–ViewModel)架构模式,数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。 本文讲解一下 Vue 响应式系统的底层细节。

检测变化注意事项

Vue 2.0中,是基于 Object.defineProperty 实现的响应式系统 (这个方法是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因)
vue3 中,是基于 Proxy/Reflect 来实现的

  1. 由于 JavaScript 的限制,这个 Object.defineProperty() api 没办法监听数组长度的变化,也不能检测数组和对象的新增变化。
  2. Vue 无法检测通过数组索引直接改变数组项的操作,这不是 Object.defineProperty() api 的原因,而是尤大认为性能消耗与带来的用户体验不成正比。对数组进行响应式检测会带来很大的性能消耗,因为数组项可能会大,比如1000条、10000条。

响应式原理

响应式基本原理就是,在 Vue 的构造函数中,对 options 的 data 进行处理。即在初始化vue实例的时候,对data、props等对象的每一个属性都通过 Object.defineProperty 定义一次,在数据被set的时候,做一些操作,改变相应的视图。

数据观测

让我们基于 Object.defineProperty 来实现一下对数组和对象的劫持。

import { newArrayProto } from './array'

class Observer {
  constructor(data){
    if (Array.isArray(data)) {
      // 这里我们可以重写可以修改数组本身的方法 7个方法,切片编程:需要保留数组原有的特性,并且可以重写部分方法
      data.__proto__ = newArrayProto
      this.observeArray(data) // 如果数组中放的是对象 可以监控到对象的变化
    } else {
      this.walk(data)
    }
  }
  // 循环对象"重新定义属性",对属性依次劫持,性能差
  walk(data) {
    Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
  }
  // 观测数组
  observeArray(data) {
    data.forEach(item => observe(item))
  }
}

function defineReactive(data,key,value){
  observe(value)  // 深度属性劫持,对所有的对象都进行属性劫持

  Object.defineProperty(data,key,{
    get(){
      return value
    },
    set(newValue){
      if(newValue == value) return
      observe(newValue) // 修改属性之后重新观测,目的:新值为对象或数组的话,可以劫持其数据
      value = newValue
    }
  })
}

export function observe(data) {
  // 只对对象进行劫持
  if(typeof data !== 'object' || data == null){
    return
  }
  return new Observer(data)
}

重写数组7个变异方法

7个方法是指:push、pop、shift、unshift、sort、reverse、splice。(这七个都是会改变原数组的)

实现思路:面向切片编程!!!

不是直接粗暴重写 Array.prototype 上的方法,而是通过原型链继承与函数劫持进行的移花接木。

利用 Object.create(Array.prototype) 生成一个新的对象 newArrayProto,该对象的 __proto__指向 Array.prototype,然后将我们数组的 __proto__指向拥有重写方法的新对象 newArrayProto,这样就保证了 newArrayProto 和 Array.prototype 都在数组的原型链上。

arr.__proto__ === newArrayProto;newArrayProto.__proto__ === Array.prototype

然后在重写方法的内部使用 Array.prototype.push.call 调用原来的方法,并对新增数据进行劫持观测。

let oldArrayProto = Array.prototype // 获取数组的原型

export let newArrayProto = Object.create(oldArrayProto)

// 找到所有的变异方法
let methods = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice']

methods.forEach(method => {
  // 这里重写了数组的方法
  newArrayProto[method] = function (...args) {
    // args reset参数收集,args为真正数组,arguments为伪数组
    const result = oldArrayProto[method].call(this, ...args) // 内部调用原来的方法,函数的劫持,切片编程

    // 我们需要对新增的数据再次进行劫持
    let inserted
    let ob = this.__ob__

    switch (method) {
      case 'push':
      case 'unshift': // arr.unshift(1,2,3)
        inserted = args
        break
      case 'splice': // arr.splice(0,1,{a:1},{a:1})
        inserted = args.slice(2)
      default:
        break
    }

    if (inserted) {
      // 对新增的内容再次进行观测
      ob.observeArray(inserted)
    }
    return result
  }
})

增加__ob__属性

这是一个恶心又巧妙的属性,我们在 Observer 类内部,把 this 实例添加到了响应式数据上。相当于给所有响应式数据增加了一个标识,并且可以在响应式数据上获取 Observer 实例上的方法

class Observer {
  constructor(data) {
    // data.__ob__ = this // 给数据加了一个标识 如果数据上有__ob__ 则说明这个属性被观测过了
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false, // 将__ob__ 变成不可枚举 (循环的时候无法获取到,防止栈溢出)
    })

    if (Array.isArray(data)) {
      // 这里我们可以重写可以修改数组本身的方法 7个方法,切片编程:需要保留数组原有的特性,并且可以重写部分方法
      data.__proto__ = newArrayProto
      this.observeArray(data) // 如果数组中放的是对象 可以监控到对象的变化
    } else {
      this.walk(data)
    }
  }

}

__ob__有两大用处:

  1. 如果一个对象被劫持过了,那就不需要再被劫持了,要判断一个对象是否被劫持过,可以通过__ob__来判断
// 数据观测
export function observe(data) {
  // 只对对象进行劫持
  if (typeof data !== 'object' || data == null) {
    return
  }

  // 如果一个对象被劫持过了,那就不需要再被劫持了 (要判断一个对象是否被劫持过,可以在对象上增添一个实例,用实例的原型链来判断是否被劫持过)
  if (data.__ob__ instanceof Observer) {
    return data.__ob__
  }

  return new Observer(data)
}
  1. 我们重写了数组的7个变异方法,其中 push、unshift、splice 这三个方法会给数组新增成员。此时需要对新增的成员再次进行观测,可以通过__ob__调用 Observer 实例上的 observeArray 方法

前言

现代计算机基于计算、存储和调度的体系, 于是现代架构都是围绕这三大话题不断演进。

在基础架构部, 也是主要为了解决这三个难题,为业务事业部提供透明的、高可用、可快速伸缩的 三大能力, 我们组主要负责 [流量调度] 这个话题,下面是一些宏观的技术笔记。



在单体结构, 流量调度是直观且无感的(DNS+Nginx就可以完成一次流量调度)。
演进到 微服务结构(服务粒度变得更细、服务伸缩更灵活(上下线更加频繁),团队松耦合),流量调度并没有消失,而是变得更加复杂。

关键名词解释

1.
控制平面/数据平面

控制平面: 内含路由表、负责路由控制, 引导流量的走向,注重“引导”, 属于旁路业务(核心)。  

数据平面:根据控制平面的路由逻辑,业务流量的实际走向。

控制面很多属于[核心]旁路业务:要求一致性=> raft协议。

2.
东西/南北流量

南北流量: 业务客户端--->服务器;
东西流量: 数据中心内或者数据中心之间的服务间调用;

东西/南北流量都属于数据平面流量。



服务治理的演进

服务治理的基石是动态服务发现,
阿里云服务治理的演进
大而全,这里只记录我的理解。

阶段 目标 手段
孵化期 动态服务发现 客户端/服务端服务发现,负载均衡
成长期 提高开发和迭代效率 规范API、 统一配置、 版本管理
成熟期 部署边界、链路追踪,弹性伸缩 服务预热、优雅下线、监控追踪、限流,熔断, 回退

核心的技能点

1. 为什么需要注册中心?

并不是微服务催生了 注册中心,而是微服务强化了注册中心。

早期DNS+nginx 就能完成单体的 流量调度,但是不管是DNS解析出ip, 还是nginx解析出upstream ,底层都有注册中心的影子。

这里面有一个addressability的概念,也即
服务可寻址性
, 服务必须有一个唯一的可寻址的名字,这个名字不依赖于部署环境,表征一组可以处理流量的实例资源。

对于资源,必定存在CRUD交互,这便是部署平台和服务发现,与此同时当实例出现故障时,注册中心必须能够指示服务现在在何处运行。

很多时候除了在部署层面,存在健康探测告知业务方目前的应用就绪情况; 实际在接流的时候,负载层还会有自己的主动健康检查探测。

2. 规范API接口, 统一配置

提高开发迭代效率,统一使用一种method、body payload形式的API接口(规避错误格式的传参导致的撕逼)、不用上n台实例修改配置。

各个团队是松耦合的关系,上下游的变更并不会主动通知,故需要版本管理(低版本不能直接下线)、提供给开发团队的版本调试接口。

3. 服务发现

服务发现是服务治理的基石,三板斧: 注册、心跳、寻址。

  • 部署系统做[注册]动作。
  • 微服务客户端寻址
  • 主动上报心跳/注册中心主动探活: 维护实例资源状态。

存在两种模式: 客户端服务发现、服务器服务发现, 核心区别在于 : 客户端是否保存服务列表信息。

4. 负载均衡

  • 随机
  • 轮询
  • 带权轮询
  • 根据后端实例服务质量动态调度

最近思考了一个问题,负载均衡不仅是拿到服务的可用实例列表,然后做流量均分; 其实负载均衡的存在也为我们无损切流提供了契机。

5. 飞机起飞和降落最容易出事故

  • 服务预热: 服务进程启动,等待业务配置/缓存就绪,再向注册中心通报实例资源就绪,可以接流。
  • 优雅下线: 下线时设置服务终止时间(30s),处理在途流量, 不再接收新的请求流量。

6. 限流 熔断

链路上部分服务的状态会影响整个链路(雪崩), 故需要保障微服务链路的高可用 => 服务的熔断、限流

熔断: 熔断掉对于下游的调用; 限流: 限制进入本应用的流量。

不管是客户端服务发现,还是服务器服务发现,都存在注册中心,也可以叫名字服务,是流量调度的基石,统一了流量调度的入口。



现代互联网结构,流量在打到应用之前,都会以对应的姿势接入某种负载层。

大多数时候,流量其实不care某个特定的应用实例,更在意的是服务的可用性;

服务端服务发现,
在客户端和接流应用之间形成了一个负载层,除了负载均衡外,还提供了故障转移和无损扩缩容、无损切流的契机
,这些都涉及动态上下线实例, 不同负载层有不同的接流姿势。


基于nginx7层负载的动态服务发现

我们着重聊一聊
基于nginx主机名字
的动态服务发现(服务端服务发现)。

nginx做反向代理,负载均衡的时候,我们关注nginx [http配置节]的上下文[server配置节][upstream配置节]。

上面这个配置,nginx会匹配
请求的Host头

server_name指令
,决定该请求转发给哪一个
upstream
虚拟主机。

相关知识,请关注
Host请求头在虚拟主机服务多网域服务中的关键作用

利用nginx的第三方组件
nginx_http_dyups_module
,可以做到动态修改upstream的配置。

This module can be used to update your upstream-list without reloadding Nginx.

daemon off;
error_log logs/error.log debug;

events {
}

http {
    upstream test_upstream {
         server 127.0.0.1:8088;
         server 127.0.0.1:8089;
     }

    server {
        listen   80;

        location / {
            # The upstream here must be a nginx variable
             set $up test_upstream ;
             proxy_pass http://$up;
        }
    }

    server {
        listen 8088;
        location / {
            return 200 "8088";
        }
    }

    server {
        listen 8089;
        location / {
            return 200 "8089";
        }
    }

    server {
        listen  8090;
        location / {
           return 200  "8090" ;
       }
    }

    server {
        listen 8081;
        location / {
            dyups_interface;
        }
    }
}


1. 该组件默认不被编译进tengine(淘宝开源的具备强特性的nginx分发版),请使用
--with-http_dyups_module
配置参数启用。

./configure --add-module=./modules/ngx_http_upstream_dyups_module              // 配置成静态组件
 make             // 编译
 make install     // 安装

2. 需要添加
dyups_interface
指令激活[动态修改upstream配置]能力

3. 演示利用dyups api在不重启nginx的情况下修改upstream配置。

查询upstream和server:
localhost:8081/detail

test_upstream
server 127.0.0.1:8088 weight=1 max_conns=0 max_fails=1 fail_timeout=10 backup=0 down=0
server 127.0.0.1:8089 weight=1 max_conns=0 max_fails=1 fail_timeout=10 backup=0 down=0

// 实际请求`curl 127.0.0.1`,轮询返回8088或者8089

更新upstream配置:
curl -d "server 127.0.0.1:8090;" 127.0.0.1:8081/upstream/test_upstream

// 再次调用`localhost:8081/detail`
test_upstream
server 127.0.0.1:8090 weight=1 max_conns=0 max_fails=1 fail_timeout=10 backup=0 down=0
// 实际请求`curl 127.0.0.1`,轮询返回8090

以上是对于流量调度这个大的topic的理解,限于篇幅,战术性动作没有展开,路漫漫其修远兮,文辞拙劣,如果错误或者不同见解,欢迎留言探讨。

本文分享自华为云社区《
选择KV数据库最重要的是什么?
》,作者:GaussDB 数据库 。

经常有客户提到KV数据库,但却偏偏“不要Redis”。比如有个做安全威胁分析平台的客户,他们明确表示自己对可靠性要求非常高,需要的不是开源Redis这种内存缓存库,而是KV数据库。

虽然最后我也没问清楚他们业务存啥(推测是这块业务数据比较机密),但确实业务本身对可靠性要求非常高,开源Redis自身的可靠性无法满足他们的要求,最终该用户选择使用GaussDB(for Redis)数据库,当前数据量已经是2TB规模,一直稳定运行中。

真正的KV数据库,业界有好几项开源的项目,云厂商也纷纷推出自家产品,比如华为云GaussDB(for Redis)。使用方在调研选型KV数据库的时候,由于要存储百GB甚至数十TB级的重要数据,首先关注的是数据库是否稳定可靠,同时懂行的客户一般也会聊到自建开源Redis的“不靠谱”问题,例如:

问题1:自建开源Redis有丢数据和数据不一致的风险

广告竞价和推荐等大数据业务将海量数据存放在KV数据库,开源Redis是将全量数据存在内存中,虽然有RDB和AOF的备份机制,但那只是普通的文本文件,可靠性很低,只能作为兜底手段,关键数据该丢还是会丢。

另外,开源Redis主备节点采用异步复制的机制,故障倒换的时候有可能出现明显的数据不一致,像是分布式锁这类场景就会很尴尬:

图片

问题2:“Cache+DB”的业务架构复杂

电商和游戏等互联网业务采用Redis缓存+持久化数据库的架构,通过缓存加速来提升业务的使用体验,业务需要设计缓存的淘汰机制,还需要解决缓存与持久化数据库之间的数据一致性问题,业务架构复杂。

问题3:开源Redis分片故障不能提供服务,故障场景对业务影响比较大

金融、财经和电商等业务对可靠性要求极高,开源Redis单个数据分片存放在主备两个节点上,如果两个节点都出现故障,则整个分片的数据不可访问,应对极端故障场景的能力不足。

社区开源的项目一般都引入了RocksDB存储引擎(这东西深的很),其实能把上层KV业务变得稳定许多,通过持久化存储介质替代内存的方式来弥补开源Redis的不足。但它们无法完美解决性能、兼容性和扩容的问题,更无法保证数据库的稳定可靠,还需要投入专门的人力进行搭建维护、调优等……最终综合算下来,成本并不低。

云厂商的KV数据库由于进行了技术创新与优化,一般比较给力,以GaussDB(for Redis)为例,下面从以下几个角度解释如何把一款产品做到“靠谱”:

企业级特性1:采用内存+NVMe的存储方案,全量数据三副本持久化存储

GaussDB(for Redis)在存储池中有三副本落盘到NVMe存储池中,宕机不会丢数据,也不会存在主从同步的问题。因此,客户可以直接拿GaussDB(for Redis)当做持久化数据库使用,一库顶多库,
业务架构变得简单,也减轻了多套数据库的运维成本。

图片

企业级特性2:采用存算分离的架构,支持3AZ部署和N-1故障的超高可用

GaussDB(for Redis)支持3AZ部署计算节点,均匀分布在3个不同的可用区,如果遇到1个或2个可用区出现网络、制冷、电力等突发故障,剩余可用区的节点能接管故障节点的数据分片,还能继续支撑业务访问全量数据。

同样得益于存算分离的架构,GaussDB(for Redis)将全量数据落盘到分布式存储池,最多支撑N-1故障,哪怕
只剩下一个节点正常,也能通过接管所有分片,继续支撑业务运行。

图片

企业级特性3:双活解决方案支持极致稳定可靠,灵活组网

如果遇到大规模网络故障、电力故障、火灾或自然灾害,导致整个数据库实例不可用,甚至整个Region都不可用,GaussDB(for Redis)也在这些极端场景下保证业务的连续性。

GaussDB(for Redis)支持给两个跨region的实例建立双活关系,支持数据的同步,如果其中一个实例故障,另一个实例能接管业务,持续提供可靠的数据库服务。双活解决方案已在华为内部ERP中稳定部署运行,为ERP业务的持续运行提供强有力的保障。

图片

总结

综上所述,GaussDB(for Redis)提供了全量数据三副本持久化存储,支持3AZ部署和N-1故障的超高可用,双活解决方案支持极致稳定可靠,灵活组网,稳定性和可靠性远超开源Redis,是一款真正能让企业核心业务放心上云的KV数据库。

所以,如果你的业务需要一款稳定可靠的KV数据库,可以试试GaussDB(for Redis)。

点击关注,第一时间了解华为云新鲜技术~

图像的基本操作

获取并修改图像的像素值

可以通过行和列的坐标值获取该像素点的像素值。对于BGR图像,它返回一个蓝色,绿色,红色值的数组。对于灰度图像,仅返回相应的强度值。
image

可以用同样的方法修改像素点的像素值:
image
image

更好的像素获取和编辑方法:
image

获取图像的属性

图像属性包括行数,列数和通道数,图像数据类型,像素数等。
使用img.shape可以获取图像的形状。它返回一组行,列和通道的元组(如果图像是彩色的):
image
注意:如果图像是灰度图像,则返回的元组仅包含行数和列数,因此这是检查加载的图像是灰度还是彩色的一种很好的方法。

使用img.size获取的像素总数, img.dtype获取图像数据类型:
image

图像ROI

有时你需要对一幅图像的特定区域进行操作。例如我们要检测一副图像中眼睛的位置,我们首先应该在图像中找到脸,再在脸的区域中找眼睛,而不是直接在一整幅图像中搜索。这样会提高程序的准确性(因为眼睛总在脸上)和性能(因为我们在很小的区域内搜索)。 ROI 也是使用 Numpy 索引来获得的。
1.我们先在图像上创建一个目标区域
image
image

2.移动目标区域
image

image

图像通道的拆分与合并

有时您需要分别处理图像的B、G、R通道。在这种情况下,需要将BGR图像分割为单个通道。或者在其他情况下,可能需要将这些单独的通道合并到BGR图像。

拆分有两种方式,cv.split()是一项代价高昂的操作(就消耗时间而言)。所以只有在你需要时才这样做。否则使用Numpy索引
image

合并用cv.merge()
image

假设你要将所有红色像素设置为零,则无需先拆分通道。使用Numpy索引更快:
image

image

制作图像边框(padding)

如果要在图像周围创建边框,比如相框,可以使用cv.copyMakeBorder()。它在卷积运算,零填充等方面有更多的应用。该函数需要以下参数:

  • src - 输入图像
  • top, bottom, left, right - 对应边界的像素数目。
  • borderType - 要添加那种类型的边界,类型如下:
  • cv2.BORDER_CONSTANT - 添加一个固定的彩色边框,还需要下一个参数(value)。
  • cv2.BORDER_REFLECT - 边界元素的镜像。比如: fedcba|abcdefgh|hgfedcb
  • cv2.BORDER_REFLECT_101 or cv2.BORDER_DEFAULT - 跟上面一样,但稍作改动。例如: gfedcb|abcdefgh|gfedcba
  • cv2.BORDER_REPLICATE 重复最后一个元素。例如: aaaaaa|abcdefgh|hhhhhhh
  • cv2.BORDER_WRAP - 不知道怎么说了, 就像这样: cdefgh|abcdefgh|abcdefg
  • value 边界颜色,如果边界的类型是 cv2.BORDER_CONSTANT

image

image

其他边框demo

replicate = cv.copyMakeBorder(img1,10,10,10,10,cv.BORDER_REPLICATE)
reflect = cv.copyMakeBorder(img1,10,10,10,10,cv.BORDER_REFLECT)
reflect101 = cv.copyMakeBorder(img1,10,10,10,10,cv.BORDER_REFLECT_101)
wrap = cv.copyMakeBorder(img1,10,10,10,10,cv.BORDER_WRAP)