You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

19 KiB

前端对接指南:钉钉扫码登录与免登

本文档面向前端开发者,详细讲解如何对接 CRM 后端的认证接口,实现钉钉扫码登录和免登(OAuth 重定向)。


1. 整体架构

                          ┌─────────────────────────────────┐
                          │       钉钉开放平台 OAuth2        │
                          └──────────┬───────────┬────────┘
                     扫码回调 authCode │           │ 重定向回带 authCode
                                     │           │
              ┌──────────────┐       │           │   ┌──────────────┐
              │  扫码登录页面 │       │           │   │  免登入口页面 │
              │  (展示二维码) │       │           │   │ (检测未登录   │
              └──────┬───────┘       │           │   │  → 重定向)   │
                     │ authCode      │           │   └──────┬───────┘
                     │               │           │          │ authCode
                     ▼               │           │          ▼
              ┌─────────────────────────────────────────────────────┐
              │         POST /api/auth/login/dingtalk               │
              │         { "authCode": "xxx" }                      │
              └──────────────────────┬──────────────────────────────┘
                                     │
                     ┌───────────────▼───────────────┐
                     │  后端:authCode → userAccessToken │
                     │  → 钉钉用户信息 → 匹配/注册本地用户 │
                     │  → 签发 JWT + Redis 登录态        │
                     └───────────────┬───────────────┘
                                     │
                                     ▼
                     { token: "xxx", userInfo: { id, username, ... } }

核心事实:扫码登录和免登在后端走同一个端点、同一套逻辑。区别仅在前端获取 authCode 的方式。详见 ADR-0009


2. 后端 API 清单

2.1 钉钉登录(扫码 / 免登通用)

POST /api/auth/login/dingtalk
Content-Type: application/x-www-form-urlencoded

请求参数(form-data):

字段 类型 必填 说明
authCode string 钉钉扫码回调或 OAuth 重定向返回的授权码,有效期 5 分钟,只能用一次

成功响应(HTTP 200):

{
  "code": 0,
  "success": true,
  "message": "success",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9.xxxx",
    "userInfo": {
      "id": 1001,
      "username": "张三",
      "mobile": "13800000001",
      "email": "zs@example.com",
      "avatar": "https://cdn/avatar.png",
      "deptId": 10,
      "deptName": "销售部",
      "menus": []
    }
  }
}
字段 类型 说明
token string JWT token,后续所有请求放入 Authorization: Bearer {token}
userInfo.id number 用户 ID
userInfo.username string 用户显示名
userInfo.mobile string 手机号
userInfo.email string 邮箱
userInfo.avatar string 头像 URL
userInfo.deptId number | null 归属部门 ID
userInfo.deptName string | null 归属部门名称
userInfo.menus array 当前用户菜单树(首次登录时填充)

2.2 获取当前用户信息

GET /api/auth/me
Authorization: Bearer {token}

成功响应:同上 userInfo 结构。

2.3 注销

POST /api/auth/logout
Authorization: Bearer {token}

注销后 token 立即失效(Redis 删除),无需传请求体。


3. Token 机制

3.1 存储

登录成功后,将 data.token 存入前端状态管理(如 localStorage / Pinia / Zustand)。

3.2 携带

后续所有需要鉴权的请求,在请求头中携带:

Authorization: Bearer {token}

3.3 有效期与续期

  • 有效期:7 天(后端配置 crm.auth.jwt.ttl-days: 7
  • 续期:剩余有效期不足一半时,后端自动续满(滑动过期),前端无感知
  • 强制下线:管理员删除 Redis 中的登录态后,token 立即失效

3.4 多端登录

同一用户可在多个设备同时登录,每个登录态有独立的 jti(JWT ID),互不影响。注销一个设备不影响其他设备的登录态。


4. 错误处理

4.1 统一响应格式

所有接口返回 HTTP 200(系统级异常除外),通过 codesuccess 判断业务成败:

{
  "code": 61002,
  "success": false,
  "message": "钉钉授权失败,请重新扫码",
  "data": null
}

4.2 登录相关错误码

HTTP 状态码 code 含义 前端处理建议
200 0 成功 存 token,跳首页
200 40001 参数缺失(authCode 为空) 提示用户重新操作
200 61002 钉钉授权失败(authCode 无效/过期) 提示"授权失败,请重新扫码",引导重试
200 61003 账号已被禁用 提示"账号已被禁用,请联系管理员"
401 40103 未登录或登录已过期 清除本地 token,跳登录页
403 40301 权限不足 提示"无权限访问"
500 50001 系统内部错误 提示"系统异常,请稍后重试"

4.3 401 拦截器(推荐实现)

在 HTTP 请求拦截器中统一处理 401:

// axios 示例
axios.interceptors.response.use(
  (response) => response.data,  // 直接返回 data 部分
  (error) => {
    if (error.response?.status === 401) {
      // 登录态失效,清除 token,跳登录页
      localStorage.removeItem('token')
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

注意:401 和 403 由 Spring Security 过滤器链直接返回(进不了 Controller),HTTP 状态码分别为 401 和 403,响应体是 JSON 格式的 Result 对象。其他业务错误 HTTP 状态码为 200。


5. 扫码登录实现

5.1 钉钉扫码二维码

钉钉提供了两种扫码登录前端方案:

方案 A:钉钉提供的扫码组件(推荐)

在登录页面引入钉钉的扫码登录 JS,渲染二维码:

<!-- 第一步:引入钉钉扫码登录 JS -->
<script src="https://login.dingtalk.com/login/qrcode.htm?appid={clientId}&goto={redirectUri}&hl=zh-CN"></script>

或使用钉钉新版 SDK:

// 第二步:初始化扫码组件
window.DTFrameLogin(
  {
    id: 'ding-login-container',  // 容器元素 ID
    width: '300',
    height: '300',
  },
  {
    redirect_uri: encodeURIComponent('http://localhost:5174/auth/callback'),
    client_id: 'dingikqv3al2koirnku7',  // 钉钉应用 Client ID
    scope: 'openid',
    prompt: 'consent',
    response_type: 'code',
  },
  (loginResult) => {
    const { redirectUrl, authCode } = loginResult
    // 拿到 authCode,调后端登录接口
    callBackendLogin(authCode)
  },
  (errorMsg) => {
    console.error('扫码失败:', errorMsg)
  }
)

方案 B:直接跳转钉钉登录页

// 如果不想在页面内嵌二维码,可以直接跳转
const clientId = 'dingikqv3al2koirnku7'
const redirectUri = encodeURIComponent('http://localhost:5174/auth/callback')
window.location.href = `https://login.dingtalk.com/oauth2/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid&prompt=consent`

5.2 回调页面

用户扫码确认后,钉钉重定向回回调地址,URL 上会带 authCode 参数:

http://localhost:5174/auth/callback?authCode=xxxxx

在回调页面提取 authCode 并调后端:

// auth-callback.vue / auth-callback.tsx
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('authCode')

if (authCode) {
  // 调后端登录接口(form-data)
  const res = await fetch('/api/auth/login/dingtalk', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ authCode })
  })
  const result = await res.json()

  if (result.code === 0) {
    // 登录成功,存 token
    localStorage.setItem('token', result.data.token)
    // 存用户信息
    localStorage.setItem('userInfo', JSON.stringify(result.data.userInfo))
    // 跳首页
    router.push('/')
  } else {
    // 登录失败
    alert(result.message)  // 如"钉钉授权失败,请重新扫码"
    router.push('/login')
  }
} else {
  // 无 authCode,可能用户取消
  router.push('/login')
}

6. 免登实现(OAuth 重定向)

6.1 场景说明

用户在浏览器中已登录钉钉后访问 CRM,前端检测到未登录态(本地无 token 或 token 已过期),自动重定向到钉钉 OAuth 授权页。钉钉检测到已登录态后,自动重定向回 CRM 并携带 authCode,前端用 authCode 调同一个登录接口。

6.2 实现步骤

// router 守卫 / axios 拦截器中
function redirectToDingTalkLogin() {
  const clientId = 'dingikqv3al2koirnku7'  // 钉钉应用 Client ID
  const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback')
  window.location.href =
    `https://login.dingtalk.com/oauth2/auth` +
    `?client_id=${clientId}` +
    `&redirect_uri=${redirectUri}` +
    `&response_type=code` +
    `&scope=openid` +
    `&prompt=consent`
}

// 路由守卫
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')

  if (to.meta.requiresAuth && !token) {
    // 未登录,重定向到钉钉 OAuth
    redirectToDingTalkLogin()
  } else if (to.path === '/auth/callback') {
    // 回调页面,放行让回调页面处理 authCode
    next()
  } else {
    next()
  }
})

6.3 回调页面(与扫码登录共用)

免登和扫码登录的回调页面完全相同——都是提取 URL 上的 authCode,调 POST /api/auth/login/dingtalk,详见 5.2 回调页面

6.4 扫码 vs 免登的前端差异

扫码登录 免登
触发时机 用户在登录页点击"扫码登录" 路由守卫检测到未登录,自动触发
前端行为 页面内嵌钉钉二维码组件 / 跳转钉钉登录页 直接跳转钉钉 OAuth 授权页
钉钉行为 显示二维码等待用户扫 检测到已登录态,立即重定向回来
后端处理 完全一致 完全一致
回调页面 同一个 /auth/callback 同一个 /auth/callback

7. 钉钉开放平台配置

7.1 应用配置

钉钉开放平台创建或打开已有应用,记录以下信息:

配置项 值(示例) 说明
Client ID (AppKey) dingikqv3al2koirnku7 前端构造 OAuth URL 用
Client Secret (AppSecret) (后端配置,前端不接触) 后端换 token 用,已配置在后端

7.2 回调域名

在应用的"安全设置"中,添加登录回调域名

  • 开发环境:http://localhost:5174
  • 生产环境:https://your-crm-domain.com

注意:回调域名需要与前端 redirect_uri 参数的域名完全一致,否则钉钉会拒绝。

7.3 权限申请

在应用的"权限管理"中,确保已开通以下权限:

权限 权限标识 用途
个人手机号信息 Contact.User.mobile 获取用户手机号(跨平台身份合一的关键字段)
通讯录个人信息读权限 Contact.User.Read 获取用户基本信息(姓名/头像等)

7.4 服务器出口 IP

在应用的"安全设置"中,添加服务器出口 IP(后端服务器 IP):

  • 开发环境:127.0.0.1 或本机公网 IP
  • 生产环境:后端服务器公网 IP

未配置 IP 白名单时,后端调钉钉 API 会被拒绝。


8. 完整前端代码示例

8.1 项目结构建议

src/
├── api/
│   └── auth.ts          # 认证相关 API 封装
├── router/
│   └── index.ts         # 路由守卫
├── stores/
│   └── auth.ts          # 登录态管理
└── views/
    ├── Login.vue         # 登录页(扫码二维码)
    └── AuthCallback.vue  # OAuth 回调页

8.2 API 封装(src/api/auth.ts

const BASE_URL = '/api/auth'

// 钉钉登录(扫码 / 免登通用)
export async function dingTalkLogin(authCode: string) {
  const res = await fetch(`${BASE_URL}/login/dingtalk`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({ authCode }),
  })
  return res.json()
}

// 获取当前用户信息
export async function getCurrentUser() {
  const token = localStorage.getItem('token')
  const res = await fetch(`${BASE_URL}/me`, {
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  })
  return res.json()
}

// 注销
export async function logout() {
  const token = localStorage.getItem('token')
  const res = await fetch(`${BASE_URL}/logout`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  })
  return res.json()
}

8.3 路由守卫(src/router/index.ts

import { redirectToDingTalkLogin } from '@/utils/dingtalk'

const routes = [
  { path: '/login', component: LoginView, meta: { public: true } },
  { path: '/auth/callback', component: AuthCallbackView, meta: { public: true } },
  { path: '/', component: HomeView, meta: { requiresAuth: true } },
]

const router = createRouter({ history: createWebHistory(), routes })

router.beforeEach((to) => {
  const token = localStorage.getItem('token')

  if (to.meta.requiresAuth && !token) {
    // 未登录 → 免登:重定向到钉钉 OAuth
    redirectToDingTalkLogin()
    return false
  }

  if (to.meta.public) {
    return true
  }
})

8.4 钉钉工具函数(src/utils/dingtalk.ts

// 钉钉应用 Client ID(公开值,非密钥)
const DINGTALK_CLIENT_ID = 'dingikqv3al2koirnku7'

// 重定向到钉钉 OAuth 授权页(免登入口)
export function redirectToDingTalkLogin() {
  const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback')
  window.location.href =
    `https://login.dingtalk.com/oauth2/auth` +
    `?client_id=${DINGTALK_CLIENT_ID}` +
    `&redirect_uri=${redirectUri}` +
    `&response_type=code` +
    `&scope=openid` +
    `&prompt=consent`
}

// 获取 URL 上的 authCode 参数
export function getAuthCodeFromUrl(): string | null {
  const params = new URLSearchParams(window.location.search)
  return params.get('authCode') || params.get('code')
}

8.5 回调页面(src/views/AuthCallback.vue

<template>
  <div class="auth-callback">
    <span v-if="loading">正在登录...</span>
    <span v-if="error">{{ error }}</span>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { dingTalkLogin } from '@/api/auth'
import { getAuthCodeFromUrl } from '@/utils/dingtalk'

const router = useRouter()
const loading = ref(true)
const error = ref('')

onMounted(async () => {
  const authCode = getAuthCodeFromUrl()

  if (!authCode) {
    error.value = '未收到钉钉授权码'
    router.push('/login')
    return
  }

  const result = await dingTalkLogin(authCode)

  if (result.code === 0) {
    // 登录成功
    localStorage.setItem('token', result.data.token)
    localStorage.setItem('userInfo', JSON.stringify(result.data.userInfo))
    router.push('/')
  } else {
    // 登录失败
    error.value = result.message
    setTimeout(() => router.push('/login'), 2000)
  }

  loading.value = false
})
</script>

8.6 Axios 拦截器(统一携带 token + 401 处理)

import axios from 'axios'

const http = axios.create({ baseURL: '/api' })

// 请求拦截:自动携带 token
http.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// 响应拦截:统一处理 401
http.interceptors.response.use(
  (response) => {
    // 业务错误:code !== 0
    if (response.data.code !== 0) {
      return Promise.reject(response.data)
    }
    return response.data.data  // 直接返回 data 部分
  },
  (error) => {
    if (error.response?.status === 401) {
      // 登录态失效,清除 token,重定向到钉钉免登
      localStorage.removeItem('token')
      redirectToDingTalkLogin()
    }
    return Promise.reject(error)
  }
)

export default http

9. 开发环境信息

配置项 开发环境值
后端地址 http://localhost:8081
前端地址(建议) http://localhost:5174
钉钉 Client ID dingikqv3al2koirnku7
API 文档(Knife4j) http://localhost:8081/doc.html

前端代理配置

开发环境需要配置 Vite 代理,将 /api 请求代理到后端:

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8081',
        changeOrigin: true,
      },
    },
  },
})

10. 常见问题

Q: authCode 的有效期?

5 分钟,且只能使用一次。如果用户在回调页面停留太久,authCode 会过期,后端返回 61002,需引导用户重新扫码或重新触发免登。

Q: 首次登录的用户会发生什么?

后端自动注册新用户(用钉钉返回的昵称、手机号、头像),并绑定钉钉身份。用户无需手动注册。后续同一钉钉账号再次登录,直接匹配已有身份,无需重新注册。

Q: 用户换了手机号怎么办?

后端优先按钉钉 unionId 匹配身份。如果 unionId 已绑定,直接取已有用户,不会因为手机号变更而注册新账号。只有当 unionId 未绑定时,才按手机号匹配。

Q: 登录后用户信息存在哪里?

POST /api/auth/login/dingtalk 的响应中直接包含 userInfo,前端登录成功后即可拿到用户信息并展示。如需刷新用户信息(如修改了部门),调用 GET /api/auth/me

Q: 免登和扫码登录可以同时支持吗?

可以,且推荐同时支持。两者的回调页面和后端接口完全相同,只是触发方式不同:

  • 扫码登录:用户在登录页看到二维码并扫码
  • 免登:路由守卫检测未登录,自动跳转钉钉 OAuth(已登录则秒回)

前端可以根据路由参数区分用户是从哪种流程回来的。

Q: 如何区分"未登录需免登"和"已登录正常访问"?

在路由守卫中检查本地是否有有效 token。如果有 token,正常放行;如果没有 token,重定向到钉钉 OAuth。token 失效(401)时由 axios 拦截器自动清除并重定向。