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

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

概述

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

流程详解

数据表

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

账号/Account

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

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

登录类型:

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

员工/Staff

字段名中文名类型必填默认值说明
id编号Int唯一标识
cid企业IDInt关联企业
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 应用、小程序、网页应用、酷应用和机器人功能,你需要添加应用能力

发布应用

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


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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

集成显卡

码字不易,需要您的鼓励😄

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值