
# 关键词:单点登录、页面集成、页面跳转
# 一、需求
苍穹与第三方系统进行集成。用户登录苍穹之后,在门户首页的应用中心,点击指定应用之后,系统自动单点登录并以新的浏览器窗口打开第三方系统的指定页面,然后用户可自由进行业务操作。此外,需补充说明的是,在本案例中,因涉及到单点登录功能,故需要有一个用作身份认证服务的系统。综上,此场景下,苍穹只是第三方系统的一层外壳,所有业务、数据的操作等都是在第三方系统中进行。
# 二、思路与方案
首先,从苍穹系统单点打开第三方系统中的页面,若第三方系统不支持身份认证的相关功能,故必须存在一个可以做统一身份认证的服务系统。如此一来,就需保证苍穹、第三方系统的用户数据保持一致,且都在统一身份认证系统中已注册过。
其次,便可按统一身份认证服务系统提供的接口要求实现苍穹的单点登录功能即可。
接下来,我们就需要开发在苍穹门户首页中点击应用卡片实现跳转第三方系统页面的功能。这里,如果我们并不清楚如何干预应用卡片的点击事件,我们可现在门户首页空白处点击鼠标之后,通过快捷键 **ctrl+alt+g** 进入焦点所在页面的设计器界面。通过查看当前页面注册的插件来分析标准产品中该功能点的实现逻辑,从而找到二开实现的突破点。
然后,就是苍穹单点登录第三方系统的功能开发,此步亦只需按统一身份认证服务系统提供的接口要求实现即可。
最后,第三方系统单点登录统一身份认证服务系统。由于第三方系统在不同的项目中各不相同,这里不作统一介绍,仅以另一套苍穹系统模拟,来完成本案例的功能效果实现。
# 三、实现过程
## 3.1 准备工作
苍穹系统:http://172.20.14.30:8080/ierp
第三方系统:本案例以另一套苍穹环境进行模拟,其访问地址:http://172.20.240.99:8080/ierp
统一身份认证服务系统:https://api.kingdee.com
## 3.2 注册应用
### 登录[金蝶云平台](https://cloud.kingdee.com/)注册应用,设置回调地址为苍穹系统的IP&端口,用于苍穹系统登录认证
*备注:
如需复现案例效果,请自行前往金蝶云平台注册应用后,配置回调地址,并修改 Parameter.java & CloudPlatformSSOAuth.java 文件中 CLIENT_ID & CLIENT_SECRET 的值。*

## 3.3 在苍穹系统中开发单点登录金蝶云平台插件(CldPlatformSSOPlugin)
### 1.先判断系统是否登录,若没有,则返回;若已登录,则根据金蝶云平台返回的授权码获取access_token,再获取用户信息进行认证,认证成功则登录苍穹系统
```language
@Override
public UserAuthResult getTrdSSOAuth(HttpServletRequest request, HttpServletResponse response) {
UserAuthResult result = new UserAuthResult();
result.setSucess(false);
// 判断是否成功登录金蝶云平台, 通过code进行判断
String code = request.getParameter("code");
String state = request.getParameter("state");
if (StringUtils.isEmpty(code) || StringUtils.isEmpty(state)) {
result.setErrDesc("单点登录失败");
return result;
}
// 获取构造缓存
DistributeSessionlessCache cache = CacheFactory.getCommonCacheFactory().getDistributeSessionlessCache(KEY_CACHE_SSOLOGIN);
String infoStr = cache.get(state);
JSONObject paramObj;
if (StringUtils.isNotEmpty(infoStr)) {
paramObj = JSONObject.parseObject(infoStr);
} else {
paramObj = new JSONObject();
}
// 重定向到指定页面
String queryParam = paramObj.getString("queryparam");
if (!request.getQueryString().contains("flag=1")) {
try {
String url = String.format("%1$s/?code=%2$s&state=%3$s&flag=1", Parameter.COSMIC_HOME_URL, code, URLEncoder.encode(state, "UTF-8"));
if (StringUtils.isNotEmpty(queryParam)) {
url = String.format("%1$s&%2$s", url, queryParam);
}
response.sendRedirect(url);
} catch (Exception e) {
logger.error(String.format("重定向失败: %s", ExceptionUtils.getExceptionStackTraceMessage(e)));
}
return result;
}
// 从云平台获取access_token
JSONObject responseObj = SSOUtil.getAccessToken(code);
Integer errCode = responseObj.getInteger("errcode");
String accessToken = null;
if (errCode != null && errCode == 0) {
accessToken = responseObj.getJSONObject("data").getString("access_token");
request.setAttribute("kdcloudaccesstoken", accessToken);
} else {
String errDesc = StringUtils.isEmpty(responseObj.getString("description")) ? "未获取到access_token信息!" : responseObj.getString("description");
result.setErrDesc(errDesc);
logger.error(errDesc);
return result;
}
// 从云平台获取登录用户基本信息
JSONObject userInfoObj = SSOUtil.getUserInfo(accessToken);
errCode = userInfoObj.getInteger("errcode");
if (errCode != null && errCode == 0) {
JSONObject data = userInfoObj.getJSONObject("data");
result.setUser(data.getString("phone"));
result.setUserType(UserProperType.Mobile);
result.setSucess(true);
} else {
String errDesc = StringUtils.isEmpty(userInfoObj.getString("description")) ? "未查询到用户信息!" : userInfoObj.getString("description");
logger.error(errDesc);
throw new KDException(LoginErrorCode.loginBizException, "系统错误,请联系系统管理员。" + errDesc);
}
return result;
}
```
### 2.若用户未登录,则跳转到金蝶云平台的登录页,成功登陆之后系统重定向到苍穹系统门户首页。若用户退出系统、或者再次访问之前已登录的地址,则通过重定向到苍穹默认登录页转而重定向到金蝶云平台的登录页
```language
@Override
public void callTrdSSOLogin(HttpServletRequest request, HttpServletResponse response, String backUrl) {
// 退出处理
if (request.getRequestURI().contains("logout.do")) {
this.logout(request);
this.sendRedirect(response, Parameter.COSMIC_HOME_URL);
return;
}
if (!response.isCommitted()) {
// 用户未登录, 且访问链接包含以前请求的code & state 参数, 则重定向到本系统的homeUrl发起访问
String code = request.getParameter("code");
String state = request.getParameter("state");
if (StringUtils.isNotEmpty(code) && StringUtils.isNotEmpty(state)) {
this.sendRedirect(response, Parameter.COSMIC_HOME_URL);
return;
}
}
// sso插件重定向处理
if (request.getQueryString() != null && request.getQueryString().contains("flag=1")) {
return;
}
// 正常登录访问苍穹,构造缓存数据
String state = ID.genStringId();
DistributeSessionlessCache cache = CacheFactory.getCommonCacheFactory().getDistributeSessionlessCache(KEY_CACHE_SSOLOGIN);
JSONObject paramObj = new JSONObject();
paramObj.put("queryparam", request.getQueryString());
cache.put(state, JSONObject.toJSONString(paramObj), 60);
// 根据sso地址构造,登录地址 重定向到云平台
try {
// 成功登录之后重定向到系统门户首页
String redirectUri = String.format("%1$s%2$s", SSOUtil.getHomeUrl(Parameter.COSMIC_HOME_URL), "index.html");
String url = String.format("%1$s?client_id=%2$s&response_type=%3$s&redirect_uri=%4$s&state=%5$s",
String.format("%1$s%2$s", SSOUtil.getHomeUrl(Parameter.AUTH_CENTER_URL), "auth/oauth2/authorize"),
Parameter.CLIENT_ID,
"code",
URLEncoder.encode(redirectUri, "UTF-8"),
URLEncoder.encode(state, "UTF-8"));
response.sendRedirect(url);
} catch (IOException e) {
logger.error(String.format("重定向失败: %s", ExceptionUtils.getExceptionStackTraceMessage(e)));
}
}
```
### 3.成功登陆之后,将 access_token 放入分布式缓存中,后续在跳转第三方系统页面时使用
```language
@Override
public void processSucceedLogin(HttpServletRequest request, String globalSessionId) {
// 将从金蝶云平台获取的 access_token 存入分布式缓存, 1天有效期. 后续跳转第三方系统页面时取出使用
String accessToken = (String) request.getAttribute("kdcloudaccesstoken");
DistributeSessionlessCache cache = CacheFactory.getCommonCacheFactory().getDistributeSessionlessCache(KEY_CACHE_SSOLOGIN);
cache.put(globalSessionId, "kdcloudaccesstoken", accessToken, 1 * 24 * 60 * 60);
ThirdSSOAuthHandler.super.processSucceedLogin(request, globalSessionId);
}
```
## 3.4 二开干预新旧版本门户首页中指定的应用卡片的点击操作(CustomAppTplPlugin & CustomAppNewPlugin)
*备注:
此段逻辑涉及 4 个java插件,其中 CustomAppNewPlugin.java 和 CustomAppPlugin.java 均继承自 CustomAppTplPlugin.java ,前两个分别注册在新旧版本门户页面上,CustomBizAppHomePlugin1.java 注册在应用首页上。*
### 1.旧版门户首页,扩展页面“我的应用/tenant_myapp”,并注册插件(CustomAppPlugin)实现业务逻辑:在应用中心点击指定应用卡片后,在新的浏览器窗口打开第三方系统页面,并取消打开苍穹系统中该应用的原首页页面
```language
/**
* 登录(旧版)系统门户后点击指定应用
*/
@Override
public void beforeItemClick(BeforeItemClickEvent evt) {
String operationKey = evt.getOperationKey();
JSONObject arg = (StringUtils.isEmpty(evt.getItemKey()) ? null : JSON.parseObject(evt.getItemKey()));
switch (operationKey) {
case "gotoapp":
case "gotocommendapp":
String appNum = null;
if (!this.isNewPortal()) {
appNum = arg != null ? arg.getString("appnumber") : "";
if (StringUtils.isEmpty(appNum)) {
String appId = arg != null ? arg.getString("appid") : "";
appNum = AppMetadataCache.getAppNumberById(appId);
}
}
if (StringUtils.equalsIgnoreCase(KEY_APPNUMER, appNum)) {
// 取消响应 itemclick 事件. 否则会打开两个浏览器页签
evt.setCancel(true);
this.gotoApp(appNum);
}
break;
default:
break;
}
super.beforeItemClick(evt);
}
```
### 2.新版门户首页,扩展页面“我的应用(new)/bos_portal_myapp_new”,并注册插件(CustomAppNewPlugin)实现业务逻辑:在首页左上角上快捷菜单中点击指定应用的图标后,以新的浏览器窗口打开第三方系统页面
```language
/**
* 登录(新版)系统门户后点击指定应用
*/
@Override
public void appItemClick(AppNavigationMenuEvent evt) {
Map<String, Object> arg = evt.getArgs();
if (arg != null && !arg.isEmpty()) {
String appId = (arg != null && arg.get("appId") != null) ? arg.get("appId").toString() : "";
if (StringUtils.isNotEmpty(appId)) {
String appNum = AppMetadataCache.getAppNumberById(appId);
if (StringUtils.equalsIgnoreCase(KEY_APPNUMER, appNum)) {
// 注意: 新版门户首页在该事件中不能取消打开应用首页页面, 必须在应用首页页面上注册表单插件(在preOpenForm事件中取消页面打开), 即本例中的插件 CustomBizAppHomePlugin1
// 否则会打开 2 个浏览器页签
this.gotoApp(appNum);
this.closeSlide();
MyCurrentAppUtil.putMyCurrentAppCache(appId);
}
}
}
}
```
### 3.在该应用的首页上注册表单插件(CustomBizAppHomePlugin1),实现业务逻辑:取消打开上一节中所点击应用的首页页面
*备注:
新版苍穹门户首页,暂不支持提供接口二开取消打开原应用首页。*
```language
@Override
public void preOpenForm(PreOpenFormEventArgs e) {
// 取消打开应用首页
e.setCancel(true);
super.preOpenForm(e);
}
```
### 4.新版门户首页,在首页左上角上快捷菜单中,指定应用图标上右键点击并选择“在浏览器页签中打开”,以新的浏览器窗口打开第三方系统页面
*备注:
以此种方式打开应用,必须选择以下两种方案之一在开发平台中进行配置,此举是为了避免打开两个浏览器页签。
**1.若该应用 -【高级信息】-【首页类型】为“表单”,则【首页设置】不能为空,且【打开方式】必须为“新窗口”。
2.若该应用 -【高级信息】-【首页类型】为外部链接,则【链接地址】可不配置**。*
```language
/**
* 登录(新版)系统门户后在指定应用上右键点击"在浏览器页签中打开"
*/
@Override
public void menuClick(AppNavigationMenuEvent evt) {
Map<String, Object> arg = evt.getArgs();
if (arg == null || arg.isEmpty()) {
return;
}
String type = arg.get("type") == null ? null : arg.get("type").toString();
String appId = arg.get("appId") == null ? null : arg.get("appId").toString();
if (StringUtils.equalsIgnoreCase("openApp", type)) {
if (StringUtils.isEmpty(appId)) {
return;
}
String appNum = AppMetadataCache.getAppNumberById(appId);
if (StringUtils.equalsIgnoreCase(KEY_APPNUMER, appNum)) {
// 注意:
// 考虑到新版门户首页在该事件中不能取消打开应用首页页面, 故在开发平台中, 该应用的【高级信息】中必须进行如下其中一种配置:
// > 该应用 -【高级信息】-【首页类型】为“表单”,则【首页设置】不能为空,且【打开方式】必须为“新窗口”
// > 该应用 -【高级信息】-【首页类型】为外部链接,则【链接地址】可不配置
this.gotoApp(appNum);
this.closeSlide();
MyCurrentAppUtil.putMyCurrentAppCache(appId);
}
}
}
```
## 3.5 获取待打开页面的URL,并以新浏览器形式打开第三方系统页面(CustomAppTplPlugin)
```language
/**
* 点击指定应用卡片自动跳转第三方系统页面. 根据实际业务修改
*/
private void gotoApp(String appNum) {
try {
// 以新浏览器窗口打开第三方系统页面
String trdSysTargetUrl = OpenTrdPageUtils.getTrdSysTargetUrl(false);
if (StringUtils.isNotEmpty(trdSysTargetUrl)) {
OpenTrdPageUtils.openTrdSysPageWithNewWindow(getView(), trdSysTargetUrl);
}
} catch (KDBizException e) {
logger.error(e.getMessage());
this.getView().showMessage(e.getMessage());
}
}
```
## 3.6 单点登录第三方系统并返回待打开页面的URL(OpenTrdPageUtils)
*备注:
1.本案例以另一套苍穹系统模拟第三方系统,分别实现了跳转第三方系统(苍穹)的系统门户首页 & 第三方系统(苍穹)中【基础资料】应用首页 & 第三方系统(苍穹)中币别列表界面。
2.本案例通过配置**应用参数**实现点击不同的应用卡片跳转不同的链接,有关应用参数的开发请参阅参考资料,此处不做详述。*
```language
/**
* 获取待打开的第三方页面地址
* @param isStaticPage 待打开的第三方页面是否为静态页面
* @return
*/
public static String getTrdSysTargetUrl(boolean isStaticPage) {
String trdSysTargetUrl = null;
if (isStaticPage) {
// 跳转第三方静态页面, 亦可通过苍穹参数配置实现
trdSysTargetUrl = Parameter.TRDSYS_HOME_URL;
} else {
// 跳转第三方系统页面(带登录会话信息)
DistributeSessionlessCache cache = CacheFactory.getCommonCacheFactory().getDistributeSessionlessCache("ssoLogin");
String accessToken = cache.get(RequestContext.get().getGlobalSessionId(), "kdcloudaccesstoken");
if (StringUtils.isEmpty(accessToken)) {
logger.error("系统从金蝶云平台获取授权码(auth_code)时access_token为空!");
throw new KDBizException("请重新登录系统!");
}
// 获取第三方系统的默认访问地址
trdSysTargetUrl = getTrdSysHomeUrl("kdec_tstapp2");
if (StringUtils.isEmpty(trdSysTargetUrl)) {
logger.error("系统参数为空!");
throw new KDBizException("系统配置不完善, 请联系系统管理员操作!");
}