网页 H5 微应用接入钉钉自动登录

发布于:2025-05-21 ⋅ 阅读:(17) ⋅ 点赞:(0)

ℹ️关于云审批
云审批(cloud approve) ,一款专为小微企业打造,支持多租户的在线审批神器。它简化了申请和审批流程,让您随时随地通过手机或电脑完成请款操作。员工一键提交申请,审批者即时响应,方便快捷。同时,云审批提供全面的数据记录与分析,助力企业实现财务管理透明化、智能化,安全高效,让企业的信息数字化管理变得简单轻松!最后,重要的事情说三遍📢:开源、开源、所有代码开源。
👉GITHUB开源地址 👈
👉飞书在线文档👈

概述

钉钉免登(此处专指自建H5微应用官方文档)是一种便捷的登录机制,当用户已在钉钉客户端(包括PC端和移动端)完成登录后,通过工作台访问我们的网站时,系统能够自动识别并完成用户身份验证,无需重复输入登录信息。该功能广泛应用于微信、飞书、抖音等主流平台,为用户提供无缝的跨平台使用体验。

流程详解

数据表

登录模块设计到两个表:账号表/Account、员工表/Staff。

账号/Account

此表为登录到平台的账户信息,支持传统的账密方式、钉钉免登等方式,并记录与之关联的员工ID

字段名 中文名 类型 必填 默认值 说明
id 编号 Int 唯一标识
cid 企业ID Int 关联企业
name 账号名称 String
pwd 密码 String 加密
type 类型 String 登录类型
sid 员工ID Int 关联员工
active 是否生效 Boolean false
addOn 录入日期 Int

登录类型:

  • dingding=钉钉
  • wechat=微信
  • phone=手机号(未来支持手机验证码登录)
  • other=其它(传统密码登录)

员工/Staff

字段名 中文名 类型 必填 默认值 说明
id 编号 Int 唯一标识
cid 企业ID Int 关联企业
name 员工名称 String
phone 电话号码 String
summary 描述 String

免登流程

  • 准备阶段
    • 企业管理员登录钉钉开发者后台,创建应用并配置网页功能
    • 获取应用的AppKeyAppSecrect
  • 逻辑实现
    • 新建钉钉登录专用页面(dingding.html)
    • 在页面中获取两个参数cid(企业ID)corpId(钉钉内企业ID)
    • 前端调用钉钉接口获取授权码/CODE
    • 后端拿到上述 CODE 后通过AppKeyAppSecrect获取到用户信息(包含唯一编号 unionid、姓名 name 等)
    • 构建唯一账户名:D_{unionid}_{name}
    • 检查强求账户名是否存在于 Account 表
    • 如存在则判断是否生效,若生效返回token,否则报错
    • 若不存在
      • 自动创建账户信息
      • 检索企业下同名员工,若不存在则自动创建并关联到账户对象
      • 若配置了账户自动生效,返回 token,否则前端提示账户未激活请联系管理员
  • 部署上线
    • 部署平台获取到登录页 URL(https://{域名}/dingding.html)
    • 在钉钉后台填入上述地址后发布应用版本
    • 用户在钉钉客户端工作台添加应用后即可访问

新建 dingding.html

我们在前端项目代码下新建对应页面:

并在 rsbuild.config.mjs中配置多页面:

export default defineConfig({
    source:{
        entry:{
            index: './src/index.js',
            dingding: './src/pages/dingding/index.js'
        }
    }
})

至此,我们可以通过 http://{IP}/dingding.html访问到该页面,作为钉钉免登的入口😄。

编写登录页面逻辑

登录页主要组件 App.vue 代码如下:

<template>
    <div style="width: 80%; margin: 40px auto;">
        <div class="text-center" v-if="!errMsg">
            <n-spin :show="working">
                <template #description>
                    钉钉客户端登录中,请稍候...
                </template>
            </n-spin>
        </div>
        <n-alert v-else :type show-icon title="钉钉自动登录失败" :bordered="false">
            {{ errMsg }}
        </n-alert>
    </div>
</template>

<script setup>
    import { NSpin, NAlert, useMessage, NMessageProvider } from 'naive-ui'

    import { requestAuth } from "./dingding"
    import { checkLocalToken, saveLocalToken } from "../login"

    const msg = useMessage()

    let cid = undefined
    let corpId = undefined

    const debug = import.meta.env.DEV;
    let working = ref(true)
    let errMsg = ref("")
    let type = ref("info")

    const onMsg = (msg, isError=true)=>{
        errMsg.value = msg
        type.value = isError?"error":"info"
    }

    const tryToAutoLogin = ()=>{
        requestAuth(corpId)
            .then(code=>{
                msg.info(`CODE=${code}`)
                fetch(
                    "/common/login-with-dingding",
                    {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify({ cid, code })
                    }
                )
                    .then(response => response.json())
                    .then(({ success, data, message }) => {
                        if(success==true){
                            msg.success(`登陆成功`)

                            saveLocalToken(data)
                            gotoIndex()
                        }
                        else{
                            const messages = {
                                E01 : "您的钉钉账户为首次登录,请先联系管理员完成激活",
                                E02 : "您的钉钉账户关联未激活,请联系企业管理员",
                                E03 : "找不到当前钉钉账户关联的员工"
                            }
                            onMsg(messages[message]||message, !messages[message])
                        }
                    })
            })
            .catch(e=>onMsg(typeof(e)=='string'?e:e.message))
    }

    const gotoIndex = ()=> location.href = "/"

    onMounted(() => {
        let u = new URL(location.href)
        cid = u.searchParams.get('cid')
        corpId = u.searchParams.get('corpId')

        if(checkLocalToken())
            return gotoIndex()

        tryToAutoLogin()
    })
</script>
// dingding.js
import { runtime } from 'dingtalk-jsapi'

export const requestAuth = async (corpId)=> {
    let { code } = await runtime.permission.requestAuthCode({corpId})
    return code
}

//login.js
const NAME = import.meta.env.PUBLIC_HEADER_TOKEN
const CREATED = `${NAME}_CREATED`

/**
 * 检查本地 token 是否在有效期内
 * @param {Number} expired - token 有效期,默认12小时,单位毫秒
 * @returns {Boolean} true 时为 token 有效
 */
export const checkLocalToken = (expired=12*60*60*1000)=>{
    let token = localStorage.getItem(NAME)
    if(!token)  return false

    let expire = localStorage.getItem(CREATED)||0
    if(Date.now() - expire>=expired)
        return false

    return true
}

export const saveLocalToken = token=>{
    localStorage.setItem(NAME, token)
    localStorage.setItem(CREATED, Date.now())
}

这里不得不吐槽下钉钉开发平台的官网文档,新旧版 API 文档特别容易让人混乱,引入dingtalk-jsapi的话需要查看旧版文档😔。

编写后端与钉钉服务器的交互

const { get, post } = require('axios')
const { loadWithCidAndName } = require("./ConstantService")
const logger = require('../common/logger')

/**
 * @typedef {Object} DDTokenResponse - 钉钉获取token效应值
 * @property {String} access_token - token值
 * @property {Number} expires_in - 有效期(单位秒)
 * @property {Number} errcode - 错误代码
 * @property {String} errmsg - 错误信息
 *
 * @typedef {Object} DDUser - 钉钉用户信息
 * @property {String} userid
 * @property {String} unionid - 唯一编号
 * @property {String} name - 用户名称
 *
 * @typedef {Object} DDUserResponse - 钉钉用户信息响应值
 * @property {DDUser} result
 * @property {String} request_id
 * @property {Number} errcode - 错误代码
 * @property {String} errmsg - 错误信息
 */

const DING_HOST = "https://oapi.dingtalk.com"

let localToken = {
    value: "",
    expire: 0
}

const isTokenExpired = ()=> !localToken.value || localToken.expire<=Date.now()
const log = (msg, level='info')=> logger[level](`[钉钉] ${msg}`)

exports.loginWithCode = async (cid, code)=>{
    if(isTokenExpired()){
        /**@type {CompanyConfig} */
        let cfg = await loadWithCidAndName(cid)
        if(!cfg || !(cfg.ddAppKey && cfg.ddAppSecret))
            throw `企业未配置钉钉登录`

        let url = `${DING_HOST}/gettoken?appkey=${cfg.ddAppKey}&appsecret=${cfg.ddAppSecret}`
        /**@type {{data:DDTokenResponse}} */
        let { data } = await get(url)

        if(data.errcode != 0){
            log(`获取企业 token 失败:${data.errcode}|${data.errmsg}`, 'error')
            throw data.errmsg
        }

        localToken.value = data.access_token
        localToken.expire = Date.now() + data.expires_in*1000

        log(`更新 TOKEN 为 ${localToken.value}(EXPIRED=${data.expires_in}`)
    }

    let url = `${DING_HOST}/topapi/v2/user/getuserinfo?access_token=${localToken.value}`
    /**@type {{data:DDUserResponse}} */
    let { data } = await post(url, { code })
    if(data.errcode != 0){
        log(`[钉钉] 获取用户信息失败:${data.errcode}|${data.errmsg}`, 'error')
        throw data.errmsg
    }

    global.isDebug && log(`获取用户信息 ${data.result.userid}/${data.result.name}`, 'debug')
    return data.result
}

部署及上线

创建钉钉H5微应用

  1. 登录钉钉开发者后台
  2. 单击应用开发 > 企业内部应用 > 钉钉应用 > 创建应用
  3. 填写应用信息。
配置项 是否必填 配置说明
应用名称 输入应用名称,应用名称最小长度为 2 个字符。
应用描述 简要描述应用提供的产品或服务,应用描述最小长度为 4 个字符。
应用图标 上传应用图标,图标要求 JPG/PNG 格式、240 px * 240 px 以上、1:1 、2 MB 以内的无圆角图标。
  1. 单击保存,进入应用详情页。
  2. 如果你需要开发 AI 应用、小程序、网页应用、酷应用和机器人功能,你需要添加应用能力

发布应用

创建应用后,需要发布才能看到噢


接着在钉钉客户端就能看到此应用啦🎉