# 前端对接指南:钉钉扫码登录与免登 > 本文档面向前端开发者,详细讲解如何对接 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](../docs/adr/0009-sso-reuses-scan-login-endpoint.md)。 --- ## 2. 后端 API 清单 ### 2.1 钉钉登录(扫码 / 免登通用) ``` POST /api/auth/login/dingtalk Content-Type: application/x-www-form-urlencoded ``` **请求参数**(form-data): | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | authCode | string | 是 | 钉钉扫码回调或 OAuth 重定向返回的授权码,有效期 5 分钟,只能用一次 | **成功响应**(HTTP 200): ```json { "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` 判断业务成败: ```json { "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: ```javascript // 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,渲染二维码: ```html ``` 或使用钉钉新版 SDK: ```javascript // 第二步:初始化扫码组件 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:直接跳转钉钉登录页 ```javascript // 如果不想在页面内嵌二维码,可以直接跳转 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` 并调后端: ```javascript // 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 实现步骤 ```javascript // 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 回调页面](#52-回调页面)。 ### 6.4 扫码 vs 免登的前端差异 | | 扫码登录 | 免登 | |---|---|---| | **触发时机** | 用户在登录页点击"扫码登录" | 路由守卫检测到未登录,自动触发 | | **前端行为** | 页面内嵌钉钉二维码组件 / 跳转钉钉登录页 | 直接跳转钉钉 OAuth 授权页 | | **钉钉行为** | 显示二维码等待用户扫 | 检测到已登录态,**立即重定向**回来 | | **后端处理** | 完全一致 | 完全一致 | | **回调页面** | 同一个 `/auth/callback` | 同一个 `/auth/callback` | --- ## 7. 钉钉开放平台配置 ### 7.1 应用配置 在[钉钉开放平台](https://open.dingtalk.com)创建或打开已有应用,记录以下信息: | 配置项 | 值(示例) | 说明 | |---|---|---| | 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`) ```typescript 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`) ```typescript 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`) ```typescript // 钉钉应用 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`) ```vue ``` ### 8.6 Axios 拦截器(统一携带 token + 401 处理) ```typescript 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` 请求代理到后端: ```javascript // 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 拦截器自动清除并重定向。