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.
633 lines
19 KiB
633 lines
19 KiB
|
1 month ago
|
# 前端对接指南:钉钉扫码登录与免登
|
||
|
|
|
||
|
|
> 本文档面向前端开发者,详细讲解如何对接 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
|
||
|
|
<!-- 第一步:引入钉钉扫码登录 JS -->
|
||
|
|
<script src="https://login.dingtalk.com/login/qrcode.htm?appid={clientId}&goto={redirectUri}&hl=zh-CN"></script>
|
||
|
|
```
|
||
|
|
|
||
|
|
或使用钉钉新版 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
|
||
|
|
<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 处理)
|
||
|
|
|
||
|
|
```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 拦截器自动清除并重定向。
|