钉钉内部H5微应用整合

727 阅读6分钟

前期教程在钉钉中都有详细的教程(地址:open.dingtalk.com/document/or…) 这里只描述需要自己手动操作的。

一、创建springboot/spring项目

本项目使用的是Springboot。

1.添加依赖

其中lib中的两个jar包可以在钉钉给的示例文档中找到。

git clone https://github.com/open-dingtalk/h5app-corp-quickstart.git
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.80</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpcore</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpmime</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-collections4</artifactId>
    <version>4.4</version>
</dependency>
<dependency>
    <groupId>com.dingtalk</groupId>
    <artifactId>dingtalk-api-sdk</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <scope>system</scope>
    <systemPath>${project.basedir}/lib/taobao-sdk-java-auto_1479188381469-20210207.jar</systemPath>
</dependency>
<dependency>
    <groupId>com.taobao.top</groupId>
    <artifactId>lippi-oapi-encrpt</artifactId>
    <version>dingtalk-SNAPSHOT</version>
    <scope>system</scope>
    <systemPath>${project.basedir}/lib/lippi-oapi-encrpt.jar</systemPath>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-boot-starter</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.2</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.8</version>
</dependency>

2.编写配置文件

dingtalk:
  app_key: 你的app_key
  app_secret: 你的app_secret
  agent_id: 你的agent_id
  corp_id: 你公司的组织id
其他配置文件请自由添加

3.创建实体及常量类

3.1创建配置实体类
/**
 * 应用凭证配置
 */
@Configuration
@Data
public class AppConfig {
    @Value("${dingtalk.app_key}")
    private String appKey;

    @Value("${dingtalk.app_secret}")
    private String appSecret;

    @Value("${dingtalk.agent_id}")
    private String agentId;

    @Value("${dingtalk.corp_id}")
    private String corpId;
}
3.2项目中的常量定义类
public class Constant {
    /**
     * 数据加密密钥。用于回调数据的加密,长度固定为43个字符,从a-z, A-Z, 0-9共62个字符中选取,您可以随机生成
     */
    public static final String ENCODING_AES_KEY = "你的aes_key";

    /**
     * 加解密需要用到的token,企业可以随机填写。如 "12345"
     */
    public static final String TOKEN = "你的token"
3.3钉钉开放接口网关常量
public class UrlConstant {
    private static final String HOST = "https://oapi.dingtalk.com";

    /**
     * 获取access_token url
     */
    public static final String URL_GET_TOKEN = HOST + "/gettoken";

    /**
     * 获取jsapi_ticket url
     */
    public static final String URL_GET_JSTICKET = HOST + "/get_jsapi_ticket";

    /**
     * 通过免登授权码获取用户信息 url
     */
    public static final String URL_GET_USER_INFO = HOST + "/user/getuserinfo";

    /**
     * 根据用户id获取用户详情 url
     */
    public static final String URL_USER_GET = HOST + "/user/get";
    /**
     * 获取审批实例的接口url
     */
    public static final String URL_PROCESSINSTANCE_GET =HOST +  "/topapi/processinstance/get";
3.4创建User实体类
/**
 * 用户详情
 */
@Data
public class User implements Serializable {
    /**
     * 用户userId
     */
    private String userid;
    /**
     * 用户名称
     */
    private String name;
    /**
     * 头像URL
     */
    private String avatar;

    /**
     * 用户部门
     *
     */
    private List department;

    @Override
    public String toString() {
        return ToStringBuilder.reflectionToString(this);
    }
3.5创建token实体类

此实体可以没有,看实际情况,这里创建这个实体的目的是将从钉钉请求回来的token存储到的数据库中,然后通过计算token的时间与设定的时间差,来判读token是否过期。

@Data
@SuppressWarnings("serial")
public class Token extends Model<Token> implements Serializable{
    
    private String appkey;

    private String type;
    
    private String code;

    @TableField(fill = FieldFill.INSERT)
    private Long beginTime;

}
3.6统一接口返回实体
/**
 * service层返回对象列表封装
 */
public class ServiceResult<T> implements Serializable {

    private boolean success = false;

    private String code;

    private String message;

    private T result;

    private ServiceResult() {
    }

    public static <T> ServiceResult<T> success(T result) {
        ServiceResult<T> item = new ServiceResult<T>();
        item.success = true;
        item.result = result;
        item.code = "200";
        item.message = "success";
        return item;
    }

    public static <T> ServiceResult<T> failure(String errorCode, String errorMessage) {
        ServiceResult<T> item = new ServiceResult<T>();
        item.success = false;
        item.code = errorCode;
        item.message = errorMessage;
        return item;
    }

    public static <T> ServiceResult<T> failure(String errorCode) {
        ServiceResult<T> item = new ServiceResult<T>();
        item.success = false;
        item.code = errorCode;
        item.message = "failure";
        return item;
    }

    public boolean hasResult() {
        return result != null;
    }

    public boolean isSuccess() {
        return success;
    }

    public T getResult() {
        return result;
    }

    public String getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

4.钉钉开放平台加解密异常类

public class DingtalkEncryptException extends Exception {
    /**
     * 成功
     */
    public static final int SUCCESS = 0;
    /**
     * 加密明文文本非法
     */
    public final static int ENCRYPTION_PLAINTEXT_ILLEGAL = 900001;
    /**
     * 加密时间戳参数非法
     */
    public final static int ENCRYPTION_TIMESTAMP_ILLEGAL = 900002;
    /**
     * 加密随机字符串参数非法
     */
    public final static int ENCRYPTION_NONCE_ILLEGAL = 900003;
    /**
     * 不合法的aeskey
     */
    public final static int AES_KEY_ILLEGAL = 900004;
    /**
     * 签名不匹配
     */
    public final static int SIGNATURE_NOT_MATCH = 900005;
    /**
     * 计算签名错误
     */
    public final static int COMPUTE_SIGNATURE_ERROR = 900006;
    /**
     * 计算加密文字错误
     */
    public final static int COMPUTE_ENCRYPT_TEXT_ERROR = 900007;
    /**
     * 计算解密文字错误
     */
    public final static int COMPUTE_DECRYPT_TEXT_ERROR = 900008;
    /**
     * 计算解密文字长度不匹配
     */
    public final static int COMPUTE_DECRYPT_TEXT_LENGTH_ERROR = 900009;
    /**
     * 计算解密文字corpid不匹配
     */
    public final static int COMPUTE_DECRYPT_TEXT_CORPID_ERROR = 900010;

    private static Map<Integer, String> msgMap = new HashMap<>();

    static {
        msgMap.put(SUCCESS, "成功");
        msgMap.put(ENCRYPTION_PLAINTEXT_ILLEGAL, "加密明文文本非法");
        msgMap.put(ENCRYPTION_TIMESTAMP_ILLEGAL, "加密时间戳参数非法");
        msgMap.put(ENCRYPTION_NONCE_ILLEGAL, "加密随机字符串参数非法");
        msgMap.put(SIGNATURE_NOT_MATCH, "签名不匹配");
        msgMap.put(COMPUTE_SIGNATURE_ERROR, "签名计算失败");
        msgMap.put(AES_KEY_ILLEGAL, "不合法的aes key");
        msgMap.put(COMPUTE_ENCRYPT_TEXT_ERROR, "计算加密文字错误");
        msgMap.put(COMPUTE_DECRYPT_TEXT_ERROR, "计算解密文字错误");
        msgMap.put(COMPUTE_DECRYPT_TEXT_LENGTH_ERROR, "计算解密文字长度不匹配");
        msgMap.put(COMPUTE_DECRYPT_TEXT_CORPID_ERROR, "计算解密文字corpid或者suiteKey不匹配");
    }

    private Integer code;

    public DingtalkEncryptException(Integer exceptionCode) {
        this(exceptionCode, null);
    }

    public DingtalkEncryptException(Integer exceptionCode, Throwable e) {
        super(msgMap.get(exceptionCode), e);
        this.code = exceptionCode;
    }

    public Integer getCode() {
        return code;
    }

5.service业务方法

5.1TokenServiceImpl(token的获取以及判断是否过期)
@Service
public class TKServiceImpl {
    private static final Logger log = LoggerFactory.getLogger(TKService.class);
    /**
     * 缓存时间:一小时50分钟
     */
    private static final long CACHE_TTL = 60 * 55 * 2 * 1000;

    private AppConfig appConfig;
    private TokenDao  tokenDao;

    @Autowired
    public TKServiceImpl(AppConfig appConfig,TokenDao  tokenDao) {
        this.appConfig = appConfig;
        this.tokenDao=tokenDao;
    }

    /**
     * 在此方法中,为了避免频繁获取access_token,
     * 在距离上一次获取access_token时间在两个小时之内的情况,
     * 将直接从持久化存储中读取access_token
     *
     * 因为access_token和jsapi_ticket的过期时间都是7200秒
     * 所以在获取access_token的同时也去获取了jsapi_ticket
     * 注:jsapi_ticket是在前端页面JSAPI做权限验证配置的时候需要使用的
     * 具体信息请查看开发者文档--权限验证配置
     *
     * @return accessToken 或错误信息
     */
    public ServiceResult<String> getAccessToken() {
        final String type="accessToken";
        // 从redis中获取token
        String accessToken = getFromCache(type);
        System.out.println("accessToken:"+accessToken);
        if (accessToken != null) {
            return ServiceResult.success(accessToken);
        }

        DefaultDingTalkClient client = new DefaultDingTalkClient(UrlConstant.URL_GET_TOKEN);
        OapiGettokenRequest request = new OapiGettokenRequest();
        OapiGettokenResponse response;

        request.setAppkey(appConfig.getAppKey());
        request.setAppsecret(appConfig.getAppSecret());
        request.setHttpMethod("GET");

        try {
            response = client.execute(request);
        } catch (ApiException e) {
            log.error("getAccessToken failed", e);
            return ServiceResult.failure(e.getErrCode(), e.getErrMsg());
        }
        accessToken = response.getAccessToken();
        //放到数据库中
        putToCache(accessToken);
        return ServiceResult.success(accessToken);
    }

    /**
     * 从数据库中获取token并检查是否已过期
     *
     * @param type 类型
     * @return token值 或 null (过期或未查到)
     */
    private String getFromCache(String type) {
        QueryWrapper<Token> wrapper=new QueryWrapper<>();
        wrapper.eq("appkey", appConfig.getAppKey());
        wrapper.eq("type",type);
        Token token = tokenDao.selectOne(wrapper);
        if (token != null) {
            if (System.currentTimeMillis() - token.getBeginTime() <= CACHE_TTL){
                return token.getCode();
            }else {
                tokenDao.delete(wrapper);
            }
        }
        return null;
    }

    /**
     * 将token存储到数据库中
     *
     * @param accessToken token
     */
    private void putToCache(String accessToken) {
        Token token = new Token();
        token.setAppkey(appConfig.getAppKey());
        token.setCode(accessToken);
        token.setType("accessToken");
        tokenDao.insert(token);
    }
}
5.2UserService
public interface UserService {
    /**
     * 访问/user/get 获取用户名称
     *
     * @param accessToken access_token
     * @param userId      用户userId
     * @return 用户名称或错误信息
     */
    User getUser(String accessToken, String userId) throws ApiException;
}
@Service
public class UserServiceImpl implements UserService {
    private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class);

    /**
     * 访问/user/get 获取用户名称
     *
     * @param accessToken access_token
     * @param userId      用户userId
     * @return 用户名称或错误信息
     */
    @Override
    public User getUser(String accessToken, String userId) throws ApiException {
        DingTalkClient client = new DefaultDingTalkClient(URL_USER_GET);
        OapiUserGetRequest request = new OapiUserGetRequest();
        request.setUserid(userId);
        request.setHttpMethod("GET");
        OapiUserGetResponse response;
        response = client.execute(request, accessToken);
        User user = new User();
        user.setName(response.getName());
        user.setUserid(response.getUserid());
        user.setAvatar(response.getAvatar());
        user.setDepartment(response.getDepartment());
        return user;
    }
}

6.Controller接口

6.1 AuthController
/**
 * 身份验证(免登)接口
 */
@RestController
@CrossOrigin
@RequestMapping("user")
public class AuthController {
    private static final Logger log = LoggerFactory.getLogger(AuthController.class);

    private TKService tkService;
    private AppConfig appConfig;
    private UserService userService;

    @Autowired
    public AuthController(
            AppConfig appConfig,
            TKService tkService,
            UserService userService
    ) {
        this.appConfig = appConfig;
        this.tkService = tkService;
        this.userService = userService;
    }
    /**
     * 钉钉用户登录,显示当前登录用户的userId和名称
     *
     * @param authCode 免登临时authCode
     * @return 当前用户的信息
     */
    @PostMapping("/getUserInfo/{authCode}")
    public ServiceResult<User> login(@PathVariable("authCode") String authCode) {
        String accessToken;
        // 获取accessToken
        ServiceResult<String> accessTokenSr = tkService.getAccessToken();
        if (!accessTokenSr.isSuccess()) {
            return ServiceResult.failure(accessTokenSr.getCode(), accessTokenSr.getMessage());
        }
        accessToken = accessTokenSr.getResult();
        System.out.println(accessToken);
        // 获取用户userId
        ServiceResult<String> userIdSr = getUserInfo(accessToken, authCode);
        if (!userIdSr.isSuccess()) {
            return ServiceResult.failure(userIdSr.getCode(), userIdSr.getMessage());
        }
        // 获取用户ID
        User user;
        try {
            user = userService.getUser(accessToken, userIdSr.getResult());
        } catch (ApiException e) {
            log.error("Failed to {}", URL_USER_GET, e);
            return ServiceResult.failure(e.getErrCode(), "Failed to getUserName: " + e.getErrMsg());
        }
        return ServiceResult.success(user);
    }

    /**
     * 访问/user/getuserinfo接口获取用户userId
     *
     * @param accessToken access_token
     * @param authCode    临时授权码
     * @return 用户userId或错误信息
     */
    private ServiceResult<String> getUserInfo(String accessToken, String authCode) {

        DingTalkClient client = new DefaultDingTalkClient(URL_GET_USER_INFO);
        OapiUserGetuserinfoRequest request = new OapiUserGetuserinfoRequest();
        request.setCode(authCode);
        request.setHttpMethod("GET");

        OapiUserGetuserinfoResponse response;
        try {
            response = client.execute(request, accessToken);
        } catch (ApiException e) {
            log.error("Failed to {}", URL_GET_USER_INFO, e);
            return ServiceResult.failure(e.getErrCode(), "Failed to getUserInfo: " + e.getErrMsg());
        }
        if (!response.isSuccess()) {
            return ServiceResult.failure(response.getErrorCode(), response.getErrmsg());
        }

        return ServiceResult.success(response.getUserid());
    }
 }

二、前端(vue项目)

由于本人非专业前端。写法不规范,甚至可以说没有规范,只是项目能跑,能达到预期的效果而已。 本项目使用vue-admin-template进行修改。

1.添加钉钉的包并修改.env.development文件中的后端接口

npm install dingtalk-jsapi --save
# just a flag
ENV = 'development'

# base api
VUE_APP_BASE_API = 'http://127.0.0.1:8088'

2.删除permission.js文件中的router.beforeEach方法

在这里修改虽然可以做到我的预期效果,但是可能不符合规范。由于我不需要登录页面。所以就删除了。

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  document.title = getPageTitle(to.meta.title)
  NProgress.done()
  next()
})

3.在store.modules.user.js文件中添加getUserInfo方法用于获取用户信息。

 getUserInfo({ commit }, code) {
    return new Promise((resolve, reject) => {
      getUserInfo(code).then(response => {
        commit('SET_NAME', response.result.name)
        commit('SET_AVATAR', response.result.avatar)
        commit('SET_USERID', response.result.userid)
        commit('SET_DEPT', response.result.department)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },

4.创建common.dingding.js文件

import * as dd from 'dingtalk-jsapi'

export function getCode(callback) {
  const corpId = 'ding3b0b26da79cdc8f235c2f4657eb6378f'
  if (dd.env.platform !== 'notInDingTalk') {
    dd.ready(() => {
      // 使用SDK 获取免登授权码
      dd.runtime.permission.requestAuthCode({
        corpId: corpId,
        onSuccess: (info) => {
          // 根据钉钉提供的api 获得code后,再次调用这个callback方法
          // 由于是钉钉获取code是异步操作,不知道什么时候执行完毕
          // callback 函数会等他执行完毕后在自己调用自己
          callback(info.code)
        },
        onFail: (err) => {
          alert('fail')
          alert(JSON.stringify(err))
        }
      })
    })
  }
}

5.在项目启动时调用上面这个方法就可以了。

本项目将此方法写在了App.vue中。

<template>
  <div id="app">
    <router-view />
  </div>
</template>

<script>
import { getCode } from "@/common/dingding.js";
export default {
  name: "App",
  created() {
  //此方法就是在上面方法中定义的方法。
    getCode((code) => {
      //获取用户信息以及Token
      this.$store.dispatch("user/getUserInfo", code);
    });
  },
};
</script>

6.在api.user.js文件中添加getUserInfo方法

export function getUserInfo(code) {
  return request({
    url: `/user/getUserInfo/${code}`,
    method: 'post',
  })

此时就完成了从钉钉中获取登录用户的信息。 注:该项目需要运行在钉钉Rc中(就是钉钉的环境中,似乎是这样理解)。

三、注册钉钉的回调

回调的作用是,可以在钉钉中发生某类事件后,通知我们的项目进行一系列我们自己定义的操作。

1.在钉钉项目后台注册回调地址

此地址必须是公网可以访问的地址,但是我们在开发环境中没有时,需要使用内网穿透工具帮助我们完成。 我使用的是“花生壳”。(具体使用方法请看花生壳官网,很详细)

2.注册回调地址

/**
 * 应用回调信息处理
 */
@RestController
public class CallbackController {

    private static final Logger mainLogger = LoggerFactory.getLogger(CallbackController.class);


    private AppConfig appConfig;
    private CallBackService callBackService;

    @Autowired
    public CallbackController(
            AppConfig appConfig,
            CallBackService callBackService
    ) {
        this.appConfig = appConfig;
        this.callBackService=callBackService;
    }


    /**
     * 相应钉钉回调时的值
     */
    private static final String CALLBACK_RESPONSE_SUCCESS = "success";

    @RequestMapping(value = "/callback", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, String> callback(@RequestParam(value = "signature", required = false) String signature,
                                        @RequestParam(value = "timestamp", required = false) String timestamp,
                                        @RequestParam(value = "nonce", required = false) String nonce,
                                        @RequestBody(required = false) JSONObject json) {
        String params = " signature:" + signature + " timestamp:" + timestamp + " nonce:" + nonce + " json:" + json;
        try {
            DingTalkEncryptor dingTalkEncryptor = new DingTalkEncryptor(Constant.TOKEN, Constant.ENCODING_AES_KEY,
                    appConfig.getAppKey());
            //从post请求的body中获取回调信息的加密数据进行解密处理
            String encryptMsg = json.getString("encrypt");
            String plainText = dingTalkEncryptor.getDecryptMsg(signature, timestamp, nonce, encryptMsg);
            //处理返回的结果
            callBackService.processHandle(plainText);
            // 返回success的加密信息表示回调处理成功
            return dingTalkEncryptor.getEncryptedMap(CALLBACK_RESPONSE_SUCCESS, System.currentTimeMillis(), Utils.getRandomStr(8));
        } catch (Exception e) {
            //失败的情况,应用的开发者应该通过告警感知,并干预修复
            mainLogger.error("process callback failed!" + params, e);
            return null;
        }
    }

}

本文档为本人使用时记录,避免忘记。如有错误,望指正。