有破有立,再造升级。官网作为会员体系中最主要的营销渠道,突破原有的资源壁垒,将QQ三大会员体系官网进行再造升级,对商业目标的达成起到至关重要的作用,同时也能给QQ用户提供更为全面的会员服务。我们将从确定目标价值、构建官网架构、设计语言升级、打造通用组件这几个方面一一道来QQ VIP官网的再设计之路。
项目背景QQ原有的两大会员体系QQ会员和QQ空间黄钻,原本互相独立,产品运营和用户体验上存在很多差异。但QQ空间本身是基于QQ关系链而存在的社交平台,对用户而言QQ空间属于QQ产品下的业务分支,因此QQ会员和QQ空间黄钻两大会员业务也应该突破原本的资源壁垒,提供统一的用户体验。
从两大会员的用户数据可以看出,目前同时开通了会员和黄钻业务的双栖用户体量大,加之高付费的头部用户是提升产品ARPU值的关键,我们急需扩充会员体系来满足这两类用户的需求。于是大会员应运而生,它的特权同时覆盖了QQ和QQ空间中的使用场景,主要包括超级会员和豪华黄钻已有的特权,并拓展了线下生活特权和线上包月优惠特权。
由于三大会员体系各自业务发展存在一定瓶颈,它们之间错综复杂的关系和差异对用户来说认知门槛高,我们急需突破单个会员体系的业务局限,用更长远的视角来纵观QQ整个会员服务的全景。
官网作为会员体系中最主要的营销渠道,将三大会员官网进行有效的收归整合,对商业目标的达成起到至关重要的作用,同时也能给QQ用户提供更为全面的会员服务。
接下来将从确定目标价值、构建官网架构、设计语言升级、打造通用组件几个方面来详细阐述下我们是如何对会员体系官网进行设计整合的。
打造协同效用,确定目标价值QQ会员和QQ空间黄钻是QQ平台上早已运营多年的会员体系,在会员类型、推广渠道、特权覆盖场景、界面风格、支付体验等方面存在不少差异。
会员包括普通会员和超级会员两种会员类型,特权主要覆盖QQ中的使用场景,例如QQ聊天、身份展示、QQ好友和群管理、游戏、阅读、动漫和QQ邮箱等。黄钻包括普通黄钻和豪华黄钻两种会员类型,特权主要覆盖QQ空间中的使用场景,例如好友动态、身份展示、个人空间展示、好友互访、好友互动等。
官网承载着会员体系中的核心内容,是触达用户转化的主要平台。我们针对已有的会员官网和黄钻官网进行梳理,了解两个官网间的相同点和差异点。
通过首页对比可以看出,大部分内容模块具有相同点,主要包括:品牌曝光、用户及会员信息、特权信息、开通续费入口、活动推广等,而差异性主要体现在模块分布区域以及信息展示方式方面。
我们通过纵向分析三大会员体系的差异,横向打通共有能力,打造协同效用来创造更大的用户和产品价值。
将三大会员体系官网进行整合,对用户而言,能够覆盖到不同维度的用户需求和场景,高效触达到更多感兴趣的特权内容,简化付费路径。对产品而言,可以打造阶梯化的增值服务,进行分层运营,三大会员体系间可进行资源整合,相互交叉引流,提升付费率。
以商业目标为指导,在深入理解用户核心需求和促进营收增长的基础上,构建合理的会员体系,制定有效的设计目标,保证官网的体验统一,遵循一致的设计语言和规范的同时,求同存异,强化不同会员体系的品牌感知,从而创造更好的用户体验,提升运营效率。
深入会员体系,构建官网架构对于一个产品或平台来讲,会员体系的核心包括卖给谁、卖什么、怎么卖三个方面,分别对应用户成长、所得权益和精细运营三个部分。这三部分决定是否能有效激励用户留在体系内,在构建官网架构时需要重点考虑到这三个关键部分进行设计。
用户成长指的是用户完整生命周期的体现,细分会员用户的生命周期包括:潜在用户——新开/回流用户——在网用户——即将到期用户——到期用户。用户成长一般通过等级、积分、铭牌、排行榜等来刺激用户,取得进步,获得成就。
所得权益是用户根据所处的成长阶段和付出程度的不同,享受不同等级的权益内容,包括各种类型的特权和福利。权益内容不仅可以满足用户对产品的需求,还能有效驱动用户不断付出,实现用户成长,从而拥有更多权益。
精细运营是根据用户成长阶段不同,进行差异化的运营策略,可从渠道、活动、付费和营销手段几个角度考虑,让用户需求不断得到满足,对产品形成忠诚度,刺激用户成长。
根据用户成长、所得权益和精细运营三个核心部分,首先梳理出三大会员体系官网所包含的关键信息。
用户成长的部分主要展示用户的个人信息,当前所处的会员类型、等级和铭牌,会员的成长值和有效期,以及激励用户的勋章、任务和排行榜。
所得权益主要是三大会员体系中的各项特权,包括功能特权、个性化特权、游戏特权和一些外部合作的特权,以及不同的福利礼包。
官网中的精细运营主要是针对不同用户生命周期进行不同的付费引导和分层运营,例如年费分期、业务打包优惠、新人红包、连续包月、会员升级等,以及在不同广告位进行活动推送。
接下来从业务角度出发,分别梳理三大会员体系官网的信息架构。根据三大会员所涵盖的业务内容不同,官网中所包含的信息也有所不同。
QQ会员拥有比其他两个会员体系更丰富的所得权益,例如游戏特权以及与外部合作的联合特权等,在精细运营的部分,QQ会员除了有三大会员体系共有的包月和年费两种运营方式外,还有和腾讯视频、QQ音乐等合作的SVIP+联合会员,以及和王卡合作的王卡超级会员,分别针对不同类型用户的需求。
黄钻和大会员在信息架构上突出了成长任务和排行榜的信息层级,期望通过任务和排名的形式,刺激用户主动、频繁的在官网保持活跃,达成商业目标。
最后我们依据前面梳理的官网信息架构,来确定页面结构。
首先确定导航结构,由于QQ会员的业务结构在三大会员体系中相对复杂,存在二级业务导航,为了避免两层导航在顶部占用头部高度,让头部显得层级多,所以把一级导航放在了页面底部,二级导航放在页面顶部,对于没有二级导航的黄钻和大会员来说只存在底部的一级导航。
其次根据三大会员各自所承载的信息和商业目标不同,确定各个模块的优先级排布。
凸显品牌调性,设计语言升级会员体系中,当前所处的会员类型、等级和铭牌,是会员信息里最能让用户清晰感知到的,为了有效传递会员体系的品牌感,我们在原有铭牌的基础上结合腾讯字体进行了设计优化。
“视觉识别系统”是品牌调性的核心,决定了不同产品的延续性。将用户已认知的“普通会员”、“超级会员”、“普通黄钻”和“豪华黄钻”进行了延续,对新推出的“大会员”进行了特殊的炫彩渐变色设计,在这些会员系统的标准配色中结合不同会员体系的等级差异新增了辅助色系,提升产品的识别度。
整个会员体系的品牌色需要给到用户升级的感觉,一个比一个“屌”。QQ会员中的普通会员一直以来的形象代表为红毛QQ,整体品牌色以红色为主色调,那一撮红毛与普通用户作出差异。而超级会员,则用代表着高贵、光荣、辉煌的金色为主体。
过往超级会员最高等级为SVIP8,而今年我们推出了SVIP9,能达到这个等级的用户很少,为了满足用户升级欲望,我们将SVIP9进行了差异化设计,引入了代表高雅、庄重、神秘、权利和力量的黑色,与原超级会员品牌色金色进行结合。黑与金的结合,色彩的对比带出的力量感结合高雅,SVIP9的尊贵感更容易呈现出来。
普通黄钻以及豪华黄钻则是在QQ空间品牌色的基础上增加了辅助色系。新推出的大会员涵盖了最多种类的会员权益,结合其“包罗万象”的特权,配色在提取了会员“红”“金”和黄钻的“黄”再加入年轻化的“紫”进行渐变处理,创造出一种看起来像是“全新的颜色” ,更独特、现代,令人耳目一新。
在界面优化的过程中,针对不同会员体系我们提取了对应的辅助图形、标准配色等细节规范,在保持统一的同时又有各自会员系统的品牌延续。
为了将三大会员体系官网的设计语言统一,将曾经分散的信息聚合起来,同时将新的改版内容加入进去,卡片式的界面表达则是最合适的载体。卡片将信息以区块的形式组织到一起,使内容更为集中更适合阅读。
卡片也是承载故事的可靠工具,而且会员体系又与大众认知里的会员卡、身份证等卡片相一致,所以用户对于卡片有着极为直观的认知。当用户面对这种卡片界面的时候,会更容易接受与他们相似的卡片属性,更容易与界面进行“卡片式”交互。
三大会员体系下还有着各自不同的会员类型,例如QQ会员体系下就分为普通会员(VIP)、超级会员1-8(SVIP)、超级会员9(SVIP9),在卡片设计中,每个不同卡片对应着一个信息模块,用户一眼看去就能清晰了解该模块的内容,根据不同卡片颜色即可区分会员的等级差异。
在每个单独的会员体系下会有不同的内容模块,不同的内容模块里包含的信息也不一样,卡片的设计形式就可以将这些不同媒介形式的内容单元以统一的方式进行混合呈现,做到视觉上尽量保持一致的同时将信息划分成多个清晰的内容区域。
搭建页面结构,打造通用组件以QQ会员为例,根据不同用户生命周期,针对潜在会员用户和会员用户两种情况。
其中会员用户的个人信息、会员类型、等级、铭牌、成长值、有效期等信息在会员卡片上展示,而潜在会员用户则以突出快速付费操作为主,缩短支付路径。所得权益的部分,将会员的功能特权进行分等级梳理,将当前等级所拥有的特权和升级后可得的特权进行对比展示,刺激用户升级。内容型特权根据不同内容类型,排列方式稍有不同。最后我们将三大会员官网中涉及到的通用模块,作为通用组件复用到QQ大会员和黄钻官网中。
三大会员官网中均有涉及到特权组件的展示,由于每个会员体系的特权数量都较多,如果用传统的列表样式,显示的内容有限,要展示同样数量的特权占用的页面空间也较大,而用横向卡片式布局,能显示更多内容,用户水平滑动即可看到更多数量的特权。
在梳理支付组件时,我们优化了支付体验,将支付流程进行了优化和统一。对不同会员体系支付操作的统一,可以降低用户的学习成本,提升支付的成功率。根据各个业务不同的内容模块,提取相应的信息内容,且打通不同会员支付体系,可相互切换引流。我们在“会员”、“黄钻”和“大会员”的系统标准配色中提取了黄色与金色进行了渐变处理,让支付组件内容整体化。
在组件化建立的过程中我们对齐了手机QQ8.0版本的设计原则以及设计规范,在应用中也加入了符合各自品牌的相关元素,在制定好初稿后,与项目参与成员一起多角度出发共同梳理,针对不同场景进行讨论,确定规范优先级,进行分工优化,在设计中不断进行修改完善。
目前我们已完成了三大官网首页的新版升级,后续将会根据产品迭代逐步对各个子页面进行规范,同时也会根据产品方向变化,保持规范的更新。
小结QQ三大会员体系官网的设计整合,只是革命的第一步,也取得不错的成效。然而在增值包月业务上,我们还面临着巨大挑战,比如用户活跃场景的转移以及会员用户流失大于新增等等,我们将继续细化业务目标和设计目标,强化包月等生命周期,多维度的商业模式组合的尝试。
接下来,我们也会引入更完善的数据统计,使得看似感性的设计变得理性,有策略的设计方案为用户带来更好的体验,也为业务带来更大的商业价值。QQ拥有着丰富的会员类型,我们将致力于打造高端会员用户的尊贵感与身份象征,一步步打造一个全新的会员俱乐部。
来源:/d/file/gt/2023-09/aauptnyftc3 @腾讯ISUX 原创发布于人人都是产品经理。未经许可,禁止转载
题图来自腾讯ISUX官网
本篇文章我们讲解基于SpringBoot2.0,SpringSocial开发QQ微信登录。
我们开发QQ微信的授权登录主要就是为了完成对以下组件的实现。获取用户信息封装成Connection,如何构建Connection?必须要ConnectionFactory,ConnectionFactory需要ServiceProvider和ApiAdapter,ServiceProvider又需要我们去实现API和OAuth2Operations。
1.Api开发1.1声明获取用户信息接口
Api主要是为了获取用户信息,因此我们第一步声明一个获取用户信息的接口。
public interface QQ { QQUserInfo getUserInfo();}
1.2开发接口实现类
接口实现类继承了SpringSocial中的AbstractOAuth2ApiBinding并实现了QQ接口。
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ { private static final String URL_GET_OPENID = \"/d/file/gt/2023-09/opp1gowreyw private static final String URL_GET_USERINFO = \"/d/file/gt/2023-09/x201l2i5cix private String appId; private String openId; private ObjectMapper objectMapper = new ObjectMapper(); public QQImpl(String accessToken, String appId) { super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER); thisId = appId; String url = String.format(URL_GET_OPENID, accessToken); String result = getRestTemplate().getForObject(url, String.class); this.openId = StringUtils.substringBetween(result, \"\\"openid\\":\\"\", \"\\"}\"); } /* (non-Javadoc) * @see com.imooc.securityre.qq.api.QQ#getUserInfo() */ @Override public QQUserInfo getUserInfo() { String url = String.format(URL_GET_USERINFO, appId, openId); String result = getRestTemplate().getForObject(url, String.class); System.out.println(result); QQUserInfo userInfo = null; try { userInfo = objectMapper.readValue(result, QQUserInfo.class); userInfo.setOpenId(openId); return userInfo; } catch (Exception e) { throw new RuntimeException(\"获取用户信息失败\", e); } }}
1.2.1 AbstractOAuth2ApiBinding
再讲解这段代码之前,我们先看一下SpringSocial中AbstractOAuth2ApiBinding中的部分源码:
public abstract class AbstractOAuth2ApiBinding implements ApiBinding, InitializingBean { private final String accessToken; private RestTemplate restTemplate; /** * Constructs the API template without user authorization. This is useful for accessing operations on a provider's API that do not require user authorization. */ protected AbstractOAuth2ApiBinding() { accessToken = null; restTemplate = createRestTemplateWithCulledMessageConverters(); configureRestTemplate(restTemplate); } //省略几万字~~~
这个抽象类里面声明了一个accessToken和一个restTemplate,授权登录中,获取每一个QQ用户的openId和登录信息都需要一个token,而且每个人的token不可能都一样,因此我们的QQImpl不能够是单例的。restTemplate主要用于发起rest请求。
1.2.2 QQImpl成员变量及构造函数
回到我们的代码,我们要获取用户信息要走两个步骤,第一步首先根据token获取用户的openId,第二步,根据openId获取到用户的具体信息。因此我们声明了URL_GET_OPENID,这个是QQ提供的获取用户OPENID的URL,URL_GET_USERINFO也是QQ提供的获取用户具体信息的URL。appId是我们接入QQ时分配的ID,openId存储了获取到的用户openId。构造函数中,我们调用了父类的构造方法,最主要的是第二个参数TokenStrategy.ACCESS_TOKEN_PARAMETER,表示我们去获取openID的时候,token是放在请求参数中的,父类默认是放在请求头里面的,所以我们这里必须这么写一下。然后还使用了父类的restTemplate去获取用户的openId。完成了第一步初始化。
1.2.3 QQImpl的gerUserInfo
这个方法使用openId和appId,发起rest请求从QQ那里获取到用户的具体信息,然后封装成QQUserInfo对象。
1.3QQUserInfo
这个就是把从QQ接收到的用户信息字段映射到的一个类中,具体的get和set方法就不写了。
public class QQUserInfo { /** * 返回码 */ private String ret; /** * 如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码。 */ private String msg; /** * */ private String openId; /** * 不知道什么东西,文档上没写,但是实际api返回里有。 */ private String is_lost; /** * 省(直辖市) */ private String province; /** * 市(直辖市区) */ private String city; /** * 出生年月 */ private String year; /** * 用户在QQ空间的昵称。 */ private String nickname; /** * 大小为30×30像素的QQ空间头像URL。 */ private String figureurl; private String figureurl_type; /** * 大小为50×50像素的QQ空间头像URL。 */ private String figureurl_1; /** * 大小为100×100像素的QQ空间头像URL。 */ private String figureurl_2; private String figureurl_qq; /** * 大小为40×40像素的QQ头像URL。 */ private String figureurl_qq_1; /** * 大小为100×100像素的QQ头像URL。需要注意,不是所有的用户都拥有QQ的100×100的头像,但40×40像素则是一定会有。 */ private String figureurl_qq_2; /** * 性别。 如果获取不到则默认返回”男” */ private String gender; /** * 标识用户是否为黄钻用户(0:不是;1:是)。 */ private String is_yellow_vip; /** * 标识用户是否为黄钻用户(0:不是;1:是) */ private String vip; /** * 黄钻等级 */ private String yellow_vip_level; /** * 黄钻等级 */ private String level; /** * 标识是否为年费黄钻用户(0:不是; 1:是) */ private String is_yellow_year_vip; private String constellation;}
OK,Api模块我们就开发完成了
2 OAuthOperations开发2.1 QQOAuth2Template
为什么我们不直接使用OAuth2Template呢,而要自己去实现一个QQOAuth2Template?这是因为QQ返回回来的信息中是text/html格式的,OAuth2Template没有支持处理这种类型的请求,所以我们必须要自己手动定义一个
public class QQOAuth2Template extends OAuth2Template { private Logger logger = LoggerFactory.getLogger(getClass()); public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) { super(clientId, clientSecret, authorizeUrl, accessTokenUrl); setUseParametersForClientAuthentication(true); } @Override protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) { String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class); logger(\"获取accessToke的响应:\"+responseStr); String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, \"&\"); String accessToken = StringUtils.substringAfterLast(items[0], \"=\"); Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], \"=\")); String refreshToken = StringUtils.substringAfterLast(items[2], \"=\"); return new AccessGrant(accessToken, null, refreshToken, expiresIn); } @Override protected RestTemplate createRestTemplate() { RestTemplate restTemplate = super.createRestTemplate(); restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName(\"UTF-8\"))); return restTemplate; }}
createRestTemplate方法添加了一个StringHttpMessageConverter,这样我们就可以成功的将服务提供商返回的信息转换成对应的对象了。
那为什么我们还要重写postForAccessGrant方法呢?我们会在后面讲解。
OAuthOperations开发就算开发完成了,那么我们相当于凑齐了ServiceProvider的两大组件了,我们可以做ServiceProvider的实现了。
3 ServiceProvider开发ServiceProvider抛开API组件,主要是完成了OAuth中的步骤1-5,步骤1-5不清楚的同学可以看我之前写的文章。
3.1 QQServiceProvider
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> { private String appId; private static final String URL_AUTHORIZE = \"https://graph.qq/oauth2.0/authorize\"; private static final String URL_ACCESS_TOKEN = \"https://graph.qq/oauth2.0/token\"; public QQServiceProvider(String appId, String appSecret) { super(new QQOAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN)); thisId = appId; } @Override public QQ getApi(String accessToken) { return new QQImpl(accessToken, appId); }}
QQServiceProvider继承了SpringSocial提供的AbstractOAuth2ServiceProvider。这是一个泛型的抽象类,泛型中需要我们提供获取用户信息的接口,那这个就是我们第一步中声明的QQ。
3.1.1 构造函数
调用了父类的构造方法,并且参数是我们自己声明的QQOAuth2Template,其中appId和appSecret是QQ分配给我们的,URL_AUTHORIZE表示我们引导用户跳转的授权页面,对应步骤1。URL_ACCESS_TOKEN对应步骤4,表示去获取token。getApi是ServiceProvider用来获取用户信息需要的方法。我们刚才说了每个用户的token是不一样的,而且token是有期限的。所以我们不能够直接把QQImpl声明为单例的,这里必须要new一个出来。
我们的ServiceProvider就开发完成了。
4 ApiAdapter这个主要是用于适配作用的,试想,我们从QQ、微信、微博中获取到的UserInfo信息是五花八门的,但是我们的Connection想要的信息就那么多,如何适配呢?很简单直接继承SpringSocial提供的ApiAdapter
public class QQAdapter implements ApiAdapter<QQ> { @Override public boolean test(QQ api) { return true; } @Override public void setConnectionValues(QQ api, ConnectionValues values) { QQUserInfo userInfo = api.getUserInfo(); values.setDisplayName(userInfo.getNickname()); values.setImageUrl(userInfo.getFigureurl_qq_1()); values.setProfileUrl(null); values.setProviderUserId(userInfo.getOpenId()); } @Override public UserProfile fetchUserProfile(QQ api) { // TODO Auto-generated method stub return null; } @Override public void updateStatus(QQ api, String message) { //do noting }}
4.1 test方法
表示是否和QQ还能连接通畅,我们这里直接返回true
4.2 setConnectionValues方法
这里就是真正做适配的地方,我们把QQUserInfo,转换成了ConnectionValues。
当我们完成了适配和Serviceprovider后,我们就可以开始构造我们的ConnectionFactory了。
5 OAuth2ConnectionFactorypublic class QQConnectionFactory extends OAuth2ConnectionFactory<QQ> { public QQConnectionFactory(String providerId, String appId, String appSecret) { super(providerId, new QQServiceProvider(appId, appSecret), new QQAdapter()); }}
这里的providerId表示服务提供商的唯一标识。OAuth2ConnectionFactory的构造非常简单,然后SpringSocial就可以利用它创建Connection了,创建好后,就会使用UsersConnectionRepository来将Connection存储到DBUserConnection中去。UsersConnectionRepository已经由SpringSocail提供了一个JdbcUsersConnectionRepository了,所以我们只需要做一个配置即可。
6 配置SocialConfig@Configuration@EnableSocial@ConditionalOnProperty(prefix = \"imooc.security.qq\", name = \"app-id\")public class SocialConfig extends SocialConfigurerAdapter { @Autowired private DataSource dataSource; @Autowired private SecurityProperties securityProperties; @Autowired(required = false) private ConnectionSignUp connectionSignUp; @Override public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) { JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText()); repository.setTablePrefix(\"imooc_\"); if(connectionSignUp != null) { repository.setConnectionSignUp(connectionSignUp); } return repository; } @Bean public SpringSocialConfigurer imoocSocialSecurityConfig() { String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl(); ImoocSpringSocialConfigurer configurer = new ImoocSpringSocialConfigurer(filterProcessesUrl); configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl()); return configurer; } @Bean public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) { return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator)) { }; } @Override public void addConnectionFactories(ConnectionFactoryConfigurer connectionFactoryConfigurer, Environment environment) { super.addConnectionFactories(connectionFactoryConfigurer, environment); QQProperties qqConfig = securityProperties.getSocial().getQq(); WeixinProperties weixinConfig = securityProperties.getSocial().getWeixin(); connectionFactoryConfigurer.addConnectionFactory( new QQConnectionFactory(qqConfig.getProviderId(), qqConfig.getAppId(), qqConfig.getAppSecret())); connectionFactoryConfigurer.addConnectionFactory(new WeixinConnectionFactory(weixinConfig.getProviderId(), weixinConfig.getAppId(), weixinConfig.getAppSecret())); } @Override public UserIdSource getUserIdSource() { return new AuthenticationNameUserIdSource(); }}
6.1 成员变量解析
DataSource表示数据源,repository会使用到
connectionSignUp注册配置,详情请见下面的第9部分拓展部分
SecurityProperties,存放QQ和微信的配置,其中的成员变量包含了appId和appSecret
6.2 getUsersConnectionRepository
这里我们使用了SpringSocial默认提供的JdbcUsersConnectionRepository,第一个参数是dataSource,第二个参数connectionFactoryLocator可以帮我们选择对应的ConnectionFactory,我们这里只写了一个QQ登录,只有一个QQ的ConnectionFactory,但是下一篇文章中,我们写微信登录的时候,那么就会有多的ConnectionFactory了。Encryptors.noOpText()表示我们对数据不加密,这里我们只是为了演示使用,实际上我们的数据入库的时候,对于access_token这类敏感信息还是需要加密的。
6.3 addConnectionFactories
把我们的QQFactory和WeixinFactory加进去对于微信的实现我们下一篇文章讲,这里先卖个关子
6.4 getUserIdSource
这个也是SpringBoot2里面最恶心的地方了,在1.5的版本中是没有这个东西了,这个方法主要是被Filter调用的,我们就返回SpringSocial默认提供的实现就可以了。
6.5 建立表
create table UserConnection (userId varchar(255) not null, providerId varchar(255) not null, providerUserId varchar(255), rank int not null, displayName varchar(255), profileUrl varchar(512), imageUrl varchar(512), accessToken varchar(512) not null, secret varchar(512), refreshToken varchar(512), expireTime bigint, primary key (userId, providerId, providerUserId));create unique index UserConnectionRank on UserConnection(userId, providerId, rank);
这段SQL语句其实是Spring-social-core包提供的,直接使用就可以了。userId就是我们的业务系统userId,providerId表示服务提供商Id,providerUserId就是openId。在我们进行QQ登录的时候,我们会根据providerId和providerUserId拿到userId,然后通过userId拿到用户的具体业务信息。其实SpringSocial也提供了一个接口SocialUserDetailsService,帮助我们获取user的信息,所以我们也让MyUserDetailsService实现了SocialUserDetailsService接口:
@Componentpublic class MyUserDetailsService implements UserDetailsService, SocialUserDetailsService { private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private PasswordEncoder passwordEncoder; /* * (non-Javadoc) * * @see org.springframework.securityreerdetails.UserDetailsService# * loadUserByUsername(javang.String) */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { logger(\"表单登录用户名:\" + username); return buildUser(username); } @Override public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException { logger(\"设计登录用户Id:\" + userId); return buildUser(userId); } private SocialUserDetails buildUser(String userId) { // 根据用户名查找用户信息 //根据查找到的用户信息判断用户是否被冻结 String password = passwordEncoder.encode(\"123456\"); logger(\"数据库密码是:\"+password); return new SocialUser(userId, password, true, true, true, true, AuthorityUtilsmaSeparatedStringToAuthorityList(\"admin\")); }}
6.6 SecurityProperties
@ConfigurationProperties(prefix = \"imooc.security\")public class SecurityProperties { private BrowserProperties browser = new BrowserProperties(); private ValidateCodeProperties code = new ValidateCodeProperties(); private SocialProperties social = new SocialProperties(); public BrowserProperties getBrowser() { return browser; } public void setBrowser(BrowserProperties browser) { this.browser = browser; } public ValidateCodeProperties getCode() { return code; } public void setCode(ValidateCodeProperties code) { thisde = code; } public SocialProperties getSocial() { return social; } public void setSocial(SocialProperties social) { this = social; } }
6.7 SocialProperties
public class SocialProperties { private String filterProcessesUrl = \"/auth\"; private QQProperties qq = new QQProperties(); private WeixinProperties weixin = new WeixinProperties(); public QQProperties getQq() { return qq; } public void setQq(QQProperties qq) { this.qq = qq; } public String getFilterProcessesUrl() { return filterProcessesUrl; } public void setFilterProcessesUrl(String filterProcessesUrl) { this.filterProcessesUrl = filterProcessesUrl; } public WeixinProperties getWeixin() { return weixin; } public void setWeixin(WeixinProperties weixin) { this.weixin = weixin; }}7 过滤器配置
我们把所有的组件都已经完成好了,接下来就是配置我们的过滤器了
7.1配置SpringSocialConfigurer
我们在配置6中有这么一段代码
@Bean public SpringSocialConfigurer imoocSocialSecurityConfig() { String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl(); ImoocSpringSocialConfigurer configurer = new ImoocSpringSocialConfigurer(filterProcessesUrl); configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl()); return configurer; }
这里的ImoocSpringSocialConfigurer继承了SpringSocialConfigurer
public class ImoocSpringSocialConfigurer extends SpringSocialConfigurer { private String filterProcessesUrl; public ImoocSpringSocialConfigurer(String filterProcessesUrl) { this.filterProcessesUrl = filterProcessesUrl; } @SuppressWarnings(\"unchecked\") @Override protected <T> T postProcess(T object) { SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object); filter.setFilterProcessesUrl(filterProcessesUrl); return (T) filter; }}
继承的SpringSocialConfigurer其实生成了一个SocialAuthenticationFilter,而这个Filter默认只会拦截/auth开头的请求路径,为了满足我们的需求,所以我们这里使用了filter.setFilterProcessesUrl(filterProcessesUrl);来改变默认的配置。为什么是在setFilterProcessesUrl配置呢?因为SpringSocialConfigurer在把Filter放到过滤器链之前会调用一个postProcess方法,所以我们在调用父类的postProcess之后,我们就在这里设置processingUrl。除此之外,我们的请求跳转还涉及到providerId,这个providerId也是我们实现跳转和授权完成之后回调的关键。我们自定义Filter拦截/qqLogin/callback.do的请求会跳转到QQ登录,因此ConnectionProvider的providerId也必须配置成callback.do,filterProcessesUrl为qqLogin。
configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl());是因为如果在DB中没有获取到userId则会跳转到默认的signUp页面,这里我们需要自定义一个跳转的注册路径。
7.2 加入配置
@Configurationpublic class BrowserSecurityConfig extends AbstractChannelSecurityConfig { @Autowired private SecurityProperties securityProperties; @Autowired private DataSource dataSource; @Autowired private UserDetailsService userDetailsService; @Autowired private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig; @Autowired private ValidateCodeSecurityConfig validateCodeSecurityConfig; @Autowired private SpringSocialConfigurer imoocSocialSecurityConfig; @Override protected void configure(HttpSecurity http) throws Exception { applyPasswordAuthenticationConfig(http); httply(validateCodeSecurityConfig) .and() ly(smsCodeAuthenticationSecurityConfig) .and() ly(imoocSocialSecurityConfig) .and() .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds()) erDetailsService(userDetailsService) .and() .authorizeRequests() .antMatchers( SecurityConstants.DEFAULT_UNAUTHENTICATION_URL, SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, securityProperties.getBrowser().getLoginPage(), SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+\"/*\", securityProperties.getBrowser().getSignUpUrl(), \"/user/regist\") .permitAll() .anyRequest() .authenticated() .and() .csrf().disable(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }}
最主要的就是apply(imoocSocialSecurityConfig)配置
7.3 OAuth2AuthenticationService
其实这个类不是编写的代码,它是SocialAuthenticationFilter的关键service,在这里我们主要是简单讲讲它的原理。这个service中有一个getAuthToken方法:
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException { String code = request.getParameter(\"code\"); if (!StringUtils.hasText(code)) { OAuth2Parameters params = new OAuth2Parameters(); params.setRedirectUri(buildReturnToUrl(request)); setScope(request, params); params.add(\"state\", generateState(connectionFactory, request)); addCustomParameters(params); throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params)); } else if (StringUtils.hasText(code)) { try { String returnToUrl = buildReturnToUrl(request); AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null); // TODO avoid API call if possible (auth using token would be fine) Connection<S> connection = getConnectionFactory().createConnection(accessGrant); return new SocialAuthenticationToken(connection, null); } catch (RestClientException e) { logger.debug(\"failed to exchange for access\", e); return null; } } else { return null; } }
因为第三步中QQ会返回授权码code,如果发现没有这个code,SpringSocial会认为是第一步,也就是要去授权认证,因此会作重定向。但是如果我们发现我们得到了Code,这个时候我们就会利用code去拿token信息。在这里拿token的时候,会使用到我们第二步中自己实现的template来转换对应的信息。
细心的读者会发现,我在讲解第二步的时候说postForAccessGrant被重写了,为什么会被重写呢?这是因为默认的OAuth2Template是这样解析QQ返回回来的参数的:
private AccessGrant extractAccessGrant(Map<String, Object> result) { return createAccessGrant((String) result.get(\"access_token\"), (String) result.get(\"scope\"), (String) result.get(\"refresh_token\"), getIntegerValue(result, \"expires_in\"), result); }
但是QQ返回回来的并不是一个JSON串,而且字段也不是这样的,所以我们需要自己实现一个Template来专门解析这个返回的token信息。
为什么我们还要在QQOAuth2Template设置一个setUseParametersForClientAuthentication(true)呢?我们还是在默认的OAuth2Template中看答案:
public AccessGrant exchangeForAccess(String authorizationCode, String redirectUri, MultiValueMap<String, String> additionalParameters) { MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>(); if (useParametersForClientAuthentication) { params.set(\"client_id\", clientId); params.set(\"client_secret\", clientSecret); } params.set(\"code\", authorizationCode); params.set(\"redirect_uri\", redirectUri); params.set(\"grant_type\", \"authorization_code\"); if (additionalParameters != null) { params.putAll(additionalParameters); } return postForAccessGrant(accessTokenUrl, params); }
默认的useParametersForClientAuthentication为false,这样我们去获取token的时候,就不会带上appId和appSecret,所以我们这里必须要设置好!
在我们拿到Token之后,Filter会拿着这个Token选择一个Provider做验证,Provider会使用Repository到数据库中获取userId,如果获取不到,就会跳转到signUp页面,提示用户去注册。而且在跳转之前我们可以看到,session中已经存放了connection的信息,具体步骤如下:
第1步:根据得到的token去验证
第2步:provider中通reposy去拿对应的userId
第3步:跳转到注册页面
基于此我们还需要设置一个注册页面,请见7.4章节
7.4 注册页面<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><title>登录</title></head><body> <h2>Demo注册页</h2> <form action=\"/user/regist\" method=\"post\"> <table> <tr> <td>用户名:</td> <td><input type=\"text\" name=\"username\"></td> </tr> <tr> <td>密码:</td> <td><input type=\"password\" name=\"password\"></td> </tr> <tr> <td colspan=\"2\"> <button type=\"submit\" name=\"type\" value=\"regist\">注册</button> <button type=\"submit\" name=\"type\" value=\"binding\">绑定</button> </td> </tr> </table> </form></body></html>7.5 注册逻辑
@RestController@RequestMapping(\"/user\")public class UserController { @Autowired private ProviderSignInUtils providerSignInUtils; @PostMapping(\"/regist\") public void regist(User user, HttpServletRequest request) { //不管是注册用户还是绑定用户,都会拿到一个用户唯一标识。 String userId = user.getUsername(); providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request)); }}
ProviderSignInUtils是SpringSocial提供的一个工具类,帮助我们把用户信息绑定到对应的账户中去。
@Bean public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) { return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator)) { }; }8 前端页面
<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><title>登录</title></head><body> <h2>标准登录页面</h2> <h3>社交登录</h3> <a href=\"/qqLogin/callback.do\">QQ登录</a> <a href=\"/qqLogin/weixin\">微信登录</a></body></html>9 扩展
有时候我们发现在用第三方授权登录后,不需要用户在注册信息,那这个是怎么实现的呢?我们先看看SocialAuthenticationProvider源码
在toUserId中我们刚才找不到用户的时候直接跳转到了注册页面,原因是Repostiry在寻找userId的时候的逻辑是这样的:
如果找不到用户且connectionSignUp为空了,就会返回一个空的List,但是如果我们实现一个connectionSignUp,这里就可以根据我们的根据我们的逻辑把这个userId放到数据库中去,所以我们这里要自己实现一个connectionSignUp。
9.1 DemoConnectionSignUp
@Componentpublic class DemoConnectionSignUp implements ConnectionSignUp { /* (non-Javadoc) * @see org.springframeworknnect.ConnectionSignUp#execute(org.springframeworknnect.Connection) */ @Override public String execute(Connection<?> connection) { //根据社交用户信息默认创建用户并返回用户唯一标识 return connection.getDisplayName(); }}
这样配置后,我们即使在数据库中没有该用户也能够拿到用户信息
10 实战QQ授权登录我们就开发到这里,下一章节我们将大致讲解一下微信的登录配置流程。