想把应用接入钉钉网关?其实很简单

您好, 如果喜欢我的文章或者想上岸大厂,可以关注公众号「量子前端」,将不定期关注推送前端好文、分享就业资料秘籍,也希望有机会一对一帮助你实现梦想

前言

笔者最近接触了多个和钉钉微应用的项目,并且从接入钉钉到授权免登整个过程也是相同的,沉淀出这部分能力写一篇文章来记录一下。

介绍

首先钉钉接入和网关有什么关系?为什么要有网关呢?由于钉钉免登鉴权这一块前后端做的事情都是一样的,因此服务端可以专门建设一个“钉钉网关”来处理业务外钉钉的事务并且转发微服务;前端可以专门建设一个“钉钉调用client”来和网关鉴权、调用服务,整个流程图大概是这样的:

想把应用接入钉钉网关?其实很简单_第1张图片

用户首次从接入钉钉授权进入到我们的系统,会做这几层处理:

  • 钉钉免登,获取AuthCode;
  • 服务端通过AuthCode获取调用钉钉OpenAPI的token;
  • 服务端调用业务服务,将用户钉钉openId通过手机号的形式关联到业务底表中;

参照的是钉钉官方推荐的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; // 扩展信息 其他与业务数据无关的数据,各自业务方可自行定义,可以不定义
  }

因此这也是一个嵌套的返回结构,就像这样:

想把应用接入钉钉网关?其实很简单_第2张图片

这样设计,优先捕获网关层的异常,再捕获接口层的异常,清晰可追溯能力强,最主要的还是对接所有的钉钉微应用业务,将钉钉网关和client的能力用上,即可很快速的开发钉钉微应用,不需要调研环境相关的问题。

从客户端刚进钉钉到调通业务接口的时序图如下:

想把应用接入钉钉网关?其实很简单_第3张图片

结尾

比较庆幸的是,钉钉侧业务最近不断增多,对于这套方案的复用率也很高,对于钉钉开放平台的接入前后端也踩了很多坑,有问题也可以抛出来一起讨论。

如果喜欢我的文章或者想上岸大厂,可以关注公众号「量子前端」,将不定期关注推送前端好文、分享就业资料秘籍,也希望有机会一对一帮助你实现梦想。

你可能感兴趣的:(钉钉,状态模式,javascript,后端,前端)