# 登录与鉴权

> 官方文档：https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html

## 登录流程概览

```
┌──────────┐     ┌──────────┐     ┌──────────┐
│  小程序   │     │ 开发者服务器 │     │ 微信服务器 │
└────┬─────┘     └────┬─────┘     └────┬─────┘
     │                │                │
     │ 1. wx.login()  │                │
     │ ──────────────>│                │
     │ 返回 code      │                │
     │                │                │
     │ 2. wx.request  │                │
     │   发送 code    │                │
     │ ──────────────>│                │
     │                │ 3. code2Session│
     │                │ ──────────────>│
     │                │ 返回 openid +  │
     │                │ session_key    │
     │                │ <──────────────│
     │                │                │
     │                │ 4. 生成自定义   │
     │                │    登录态 token │
     │                │                │
     │ 5. 返回 token  │                │
     │ <──────────────│                │
     │                │                │
     │ 6. 后续请求    │                │
     │   携带 token   │                │
     │ ──────────────>│                │
     │                │ 7. 校验 token  │
     │                │    查 openid   │
```

## 前端登录实现

### 基本登录

```javascript
// app.js 或 utils/auth.js
async function login() {
  // 1. 调用 wx.login 获取 code
  const { code } = await wx.login()

  // 2. 发送 code 到后端换取 token
  const res = await wx.request({
    url: 'https://your-server.com/api/auth/login',
    method: 'POST',
    data: { code }
  })

  if (res.data.token) {
    // 3. 存储 token
    wx.setStorageSync('token', res.data.token)
    return res.data
  } else {
    throw new Error('登录失败')
  }
}
```

### 检查登录态

```javascript
// 检查 session_key 是否过期
function checkSession() {
  return new Promise((resolve, reject) => {
    wx.checkSession({
      success: () => resolve(true),   // session_key 未过期
      fail: () => resolve(false)      // session_key 已过期，需重新 login
    })
  })
}

// 启动时检查
async function checkAndLogin() {
  const token = wx.getStorageSync('token')
  if (!token) {
    return await login()
  }

  const isSessionValid = await checkSession()
  if (!isSessionValid) {
    // session_key 过期，重新登录
    return await login()
  }

  return { token }
}
```

### 封装请求（自动携带 token）

```typescript
// utils/request.ts
interface RequestOptions {
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  data?: any
  header?: Record<string, string>
}

function request(options: RequestOptions): Promise<any> {
  const token = wx.getStorageSync('token')
  return new Promise((resolve, reject) => {
    wx.request({
      ...options,
      header: {
        'Content-Type': 'application/json',
        'Authorization': token ? `Bearer ${token}` : '',
        ...options.header
      },
      success(res) {
        if (res.statusCode === 401) {
          // token 过期，重新登录
          wx.removeStorageSync('token')
          login().then(() => {
            // 重试原请求
            request(options).then(resolve).catch(reject)
          })
          return
        }
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data)
        } else {
          reject(res)
        }
      },
      fail: reject
    })
  })
}
```

## 后端登录实现（Python / FastAPI 示例）

```python
import httpx
from fastapi import APIRouter, HTTPException, Depends
from datetime import datetime, timedelta
import jwt

router = APIRouter()

APPID = "your_appid"
SECRET = "your_secret"
JWT_SECRET = "your_jwt_secret"

@router.post("/auth/login")
async def login(code: str):
    # 1. 用 code 换取 openid + session_key
    url = "https://api.weixin.qq.com/sns/jscode2session"
    params = {
        "appid": APPID,
        "secret": SECRET,
        "js_code": code,
        "grant_type": "authorization_code"
    }
    async with httpx.AsyncClient() as client:
        resp = await client.get(url, params=params)
        data = resp.json()

    if "errcode" in data and data["errcode"] != 0:
        raise HTTPException(400, f"微信登录失败: {data.get('errmsg')}")

    openid = data["openid"]
    session_key = data["session_key"]
    unionid = data.get("unionid")

    # 2. 查找或创建用户
    user = await find_or_create_user(openid, unionid)

    # 3. 生成 JWT token
    token = jwt.encode({
        "sub": str(user.id),
        "openid": openid,
        "exp": datetime.utcnow() + timedelta(days=7)
    }, JWT_SECRET, algorithm="HS256")

    # 4. 缓存 session_key（用于后续解密）
    await cache_session_key(openid, session_key)

    return {"token": token, "user": user.to_dict()}
```

## 获取手机号

### 前端

```xml
<!-- 基础库 2.21.2+ 推荐用法 -->
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber">
  授权手机号
</button>
```

```javascript
Page({
  getPhoneNumber(e) {
    if (e.detail.errMsg === 'getPhoneNumber:ok') {
      const code = e.detail.code  // 动态令牌（新版）
      // 发送 code 到后端
      wx.request({
        url: 'https://your-server.com/api/auth/phone',
        method: 'POST',
        data: { code },
        success(res) {
          console.log('手机号:', res.data.phoneNumber)
        }
      })
    } else {
      console.log('用户拒绝授权')
    }
  }
})
```

### 后端

```python
@router.post("/auth/phone")
async def get_phone(code: str, token: str = Depends(get_current_token)):
    access_token = await get_access_token()
    url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}"

    async with httpx.AsyncClient() as client:
        resp = await client.post(url, json={"code": code})
        data = resp.json()

    if data.get("errcode") != 0:
        raise HTTPException(400, f"获取手机号失败: {data.get('errmsg')}")

    phone_info = data["phone_info"]
    phone_number = phone_info["phoneNumber"]

    # 更新用户手机号
    await update_user_phone(token.openid, phone_number)

    return {"phoneNumber": phone_number}
```

## 用户信息获取（当前方案）

`wx.getUserProfile` 已于基础库 2.27.1 废弃。当前获取用户头像和昵称的方式：

### 头像昵称填写能力

```xml
<!-- 头像选择 -->
<button open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
  <image src="{{avatarUrl}}" class="avatar"/>
</button>

<!-- 昵称填写（type="nickname" 自动弹出微信昵称） -->
<input type="nickname" placeholder="请输入昵称" bindchange="onNicknameChange"/>
```

```javascript
Page({
  data: {
    avatarUrl: '/images/default-avatar.png',
    nickname: ''
  },
  onChooseAvatar(e) {
    const { avatarUrl } = e.detail
    // avatarUrl 是临时路径，需上传到自己服务器
    this.setData({ avatarUrl })
    this.uploadAvatar(avatarUrl)
  },
  onNicknameChange(e) {
    this.setData({ nickname: e.detail.value })
  },
  async uploadAvatar(tempPath) {
    wx.uploadFile({
      url: 'https://your-server.com/api/upload/avatar',
      filePath: tempPath,
      name: 'file',
      success(res) {
        const data = JSON.parse(res.data)
        // 保存头像 URL
      }
    })
  }
})
```

## 授权管理

```javascript
// 查看当前授权状态
wx.getSetting({
  success(res) {
    res.authSetting['scope.userLocation']  // true/false/undefined
    res.authSetting['scope.writePhotosAlbum']
    // undefined = 未请求过，true = 已授权，false = 已拒绝
  }
})

// 提前请求授权
wx.authorize({
  scope: 'scope.userLocation',
  success() { /* 授权成功 */ },
  fail() { /* 用户拒绝 */ }
})

// 打开设置页（用户之前拒绝后，引导重新授权）
wx.openSetting({
  success(res) {
    res.authSetting // 最新的授权状态
  }
})
```

### 常用 scope

| scope | 说明 | 对应 API |
|-------|------|----------|
| scope.userLocation | 精确地理位置 | wx.getLocation |
| scope.userFuzzyLocation | 模糊地理位置 | wx.getFuzzyLocation |
| scope.record | 麦克风 | wx.startRecord |
| scope.camera | 摄像头 | camera 组件 |
| scope.writePhotosAlbum | 保存到相册 | wx.saveImageToPhotosAlbum |
| scope.bluetooth | 蓝牙 | wx.openBluetoothAdapter |
| scope.addPhoneContact | 添加到联系人 | wx.addPhoneContact |
| scope.addPhoneCalendar | 添加到日历 | wx.addPhoneCalendar |
| scope.werun | 微信运动步数 | wx.getWeRunData |
| scope.userInfo | 已废弃 | - |

## 安全注意事项

1. **session_key 绝不能下发到前端**
2. **code 只能使用一次**，且有效期很短（约 5 分钟）
3. **不要在前端存储 openid**，通过 token 在后端关联
4. **JWT token 设置合理过期时间**（建议 7 天，配合 refresh token）
5. **HTTPS 是强制要求**，所有网络请求必须 HTTPS
6. **敏感数据解密**要在服务端进行，不要在前端解密
7. **access_token 要在服务端缓存**，不要每次请求都重新获取
8. **unionid 需要绑定开放平台**才能获取，用于跨应用用户关联

## 常见问题

### Q: wx.login 的 code 可以多次使用吗？
A: 不可以，每个 code 只能使用一次，且有效期约 5 分钟。

### Q: session_key 什么时候会过期？
A: 微信不会通知过期时间，需要通过 `wx.checkSession` 检查。用户越频繁使用小程序，session_key 有效期越长。

### Q: 如何获取 unionid？
A: 需要在微信开放平台绑定小程序。绑定后，code2Session 会返回 unionid。

### Q: getUserProfile 废弃后怎么获取用户信息？
A: 使用头像昵称填写能力（`<button open-type="chooseAvatar">` + `<input type="nickname">`），让用户主动填写。

## 在线查询

- 登录流程：https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
- 手机号快速验证：https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
- 用户信息：https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html
- 授权：https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/authorize.html
