您好, 如果喜欢我的文章或者想上岸大厂,可以关注公众号「量子前端」,将不定期关注推送前端好文、分享就业资料秘籍,也希望有机会一对一帮助你实现梦想
笔者最近接触了多个和钉钉微应用的项目,并且从接入钉钉到授权免登整个过程也是相同的,沉淀出这部分能力写一篇文章来记录一下。
首先钉钉接入和网关有什么关系?为什么要有网关呢?由于钉钉免登鉴权这一块前后端做的事情都是一样的,因此服务端可以专门建设一个“钉钉网关”来处理业务外钉钉的事务并且转发微服务;前端可以专门建设一个“钉钉调用client”来和网关鉴权、调用服务,整个流程图大概是这样的:
用户首次从接入钉钉授权进入到我们的系统,会做这几层处理:
参照的是钉钉官方推荐的oAuth协议,参照文档:
https://opensource.dingtalk.com/developerpedia/docs/develop/permission/token/browser/get_user_app_token_browser/
请求库考虑通用化,采用axios
做请求底层包,前端client
主要负责以下工作:
我们首先封装出client
的基座:
class Client implements ClientParams {
env: string;
dingLoginUrl: string;
serverHost: string;
HttpService: any;
constructor(params: any) {
const { env, dingLoginUrl } = params;
this.env = env;
this.dingLoginUrl = dingLoginUrl;
this.serverHost = getServerHost(env);
this.HttpService = axios.create();
this.HttpService.defaults.withCredentials = true;
}
}
env
代表运行环境,我司会分日常、预发、生产;dingLoginUrl
代表钉钉免登授权重定向URL,一般为应用首页,因此开放出去;serverHost
代表服务端地址;httpService
代表请求实例。
授权会在网关层token
过期或无token
的时候抛出异常给前端,因此我这里主要是在响应拦截器中去实现的:
// 响应拦截
this.HttpService.interceptors.response.use(
(res) => {
console.log('响应拦截:', res);
// 网关接口列表
const gatewayApiList = ['getLoginUserInfo', 'getJsConfig', 'login'];
// 是否是网关接口
const isGatewayApi = gatewayApiList.find((_) =>
res.request.responseURL.includes(`/gw/api/${_}`),
)
? true
: false;
if (res.data.resultCode === 'USER_NOT_LOGIN' && !isRrfreshCookie) {
isRrfreshCookie = true;
// 接口返回登录态失效
!getQueryString('code') &&
Toast.show({
content: '未检测到钉钉登录态,跳转登录中...',
icon: 'loading',
});
jsCookie.remove('HY_SESSION_ID');
storage.clear();
refreshCookie();
}
if (res.data.status === 'FAIL') {
// 网关异常
Toast.show({
content: res.data.resultMsg,
icon: 'fail',
});
return Promise.reject(res);
} else if (!isGatewayApi && !res?.data?.data?.success) {
// 接口异常
Toast.show({
content: res.data.data.resultMessage,
icon: 'fail',
});
return Promise.reject(res);
} else if (res.data.status === 'SUCCESS') {
return Promise.resolve(res.data);
} else {
return Promise.reject(res);
}
},
(err) => {
console.log('server occur error', err);
return Promise.reject(err);
},
);
当网关侧抛出USER_NOT_LOGIN
时,前端会去拿钉钉授权码,并且调一次网关接口把cookie种到前端,refreshCookie
方法代码如下:
/**
* @description: 针对缓存无session,进行请求拦截授权
* @return {*}
*/
const refreshCookie = async () => {
const authCode = getQueryString('code');
if (authCode) {
// 通过code获取cookie
const res = await login({
authCode,
appName: 'smartedgeInsight',
});
storage.set('userInfo', JSON.stringify(res?.data));
window.location.href = location.href.split('?')[0];
Toast.show({
content: '登录成功',
icon: 'success',
});
isRrfreshCookie = false;
} else {
window.location.href = this.dingLoginUrl;
}
};
这样其实钉钉登录态的层面已经OK了,并且当网关异常会catch出网关的异常;接口异常会catch出接口的异常,接下来我们把请求给封装起来:
async get<T>(data: fetchParamsType): Promise<T> {
return new Promise(async (resolve, reject) => {
const { apiKey, params } = data;
this.HttpService.get(this.serverHost + apiKey, {
params,
})
.then((res) => {
if (!res) {
reject(res);
} else {
resolve(res);
}
})
.catch((err) => {
console.log('err:', err);
reject(err.data);
});
});
}
async post<T>(data: fetchParamsType): Promise<T> {
return new Promise(async (resolve, reject) => {
const { apiKey, params } = data;
this.HttpService({
method: 'post',
url: this.serverHost + apiKey,
data: params,
headers: {
'Content-type': 'application/json',
},
})
.then((res) => {
if (!res) {
reject(res);
} else {
resolve(res);
}
})
.catch((err) => {
console.log('err:', err);
reject(err.data);
});
});
}
到这里,一个通用的钉钉client
已经实现了,我们假设这是一个npm
包,来看下如何使用:
import Client from '@dd-client';
import { getEnv, getDingLoginRedirectUrl } from '@/common';
interface fetchParamsType {
apiKey: string;
method: string;
params: any;
}
const redirectUrl = getDingLoginRedirectUrl();
const dingLoginUrl = `https://login.dingtalk.com/oauth2/auth?client_id=suitemrnelkiv2qudt6eb&redirect_uri=${redirectUrl}&state=123&response_type=code&prompt=consent&scope=openid%20corpid`;
const fetch = async <T>(params: fetchParamsType): Promise<T> => {
const instance = new Client({
env: getEnv(),
dingLoginUrl,
});
try {
if (params?.method === 'GET') {
return await instance.get<T>(params);
} else {
return await instance.post<T>(params);
}
} catch (error: any) {
return error;
}
};
export default fetch;
在这里我们动态获取了各个环境的redirectUrl
并且组装到了钉钉重定向地址中,并且判断请求类型调用对应client
的方法,接下来再看一下每一个API
如何优雅的封装出来?
export const queryUserInfo = () => {
return fetch<GatewayResult<loginDTO>>({
apiKey: '/gw/api/bbb-aaa-xxx.UserQueryFacade.queryUserInfo',
method: 'POST',
params: [{}],
});
};
export const sendVerifyCode = (params: { mobileNumber: string }) => {
return fetch<GatewayResult<loginDTO>>({
apiKey: '/gw/api/bbb-aaa-xxx.UserRegisterFacade.sendVerifyCode',
method: 'POST',
params: [params],
});
};
export const verifyMobile = (params: {
mobileNumber: string;
inputVerifyCode: string;
}) => {
return fetch<GatewayResult<loginDTO>>({
apiKey: '/gw/api/bbb-aaa-xxx.UserRegisterFacade.verifyMobile',
method: 'POST',
params: [params],
});
};
再看一下接口DTO是如何来设计的呢?首先GatewayResult
代表了网关侧的外层返回数据,对应的泛型代表了真实业务接口返回的数据,对应类型是这样的:
// 业务层DTO格式
interface ApiResult<T> {
class: string;
data: T;
extInfo: object;
resultCode: string;
resultMessage: string;
success: boolean;
}
// 钉钉网关层DTO格式
interface GatewayResult<T> {
status: 'SUCCESS' | 'FAIL'; // 业务结果返回状态
resultMsg: string; // 结果信息 业务接口失败描述,在请求失败的时候返回
resultCode: string; // 结果码 业务接口状态码,在请求失败的时候返回
data: ApiResult<T>; // 结果对象 :业务的返回数据全部放在data字段里面,网关层会全部透传给前端
extInfo: object; // 扩展信息 其他与业务数据无关的数据,各自业务方可自行定义,可以不定义
}
因此这也是一个嵌套的返回结构,就像这样:
这样设计,优先捕获网关层的异常,再捕获接口层的异常,清晰可追溯能力强,最主要的还是对接所有的钉钉微应用业务,将钉钉网关和client的能力用上,即可很快速的开发钉钉微应用,不需要调研环境相关的问题。
从客户端刚进钉钉到调通业务接口的时序图如下:
比较庆幸的是,钉钉侧业务最近不断增多,对于这套方案的复用率也很高,对于钉钉开放平台的接入前后端也踩了很多坑,有问题也可以抛出来一起讨论。
如果喜欢我的文章或者想上岸大厂,可以关注公众号「量子前端」,将不定期关注推送前端好文、分享就业资料秘籍,也希望有机会一对一帮助你实现梦想。