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(系统级异常除外),通过 code 和 success 判断业务成败:
{
"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 拦截器自动清除并重定向。