339 lines
9.7 KiB
Markdown
339 lines
9.7 KiB
Markdown
# 用户登录与信息授权方案
|
||
|
||
> 版本:v1.0
|
||
> 最后更新:2026-05-06
|
||
> 适用范围:微信小程序云开发环境下的用户身份管理
|
||
|
||
---
|
||
|
||
## 一、方案概述
|
||
|
||
采用**静默登录**策略:用户进入小程序后无感知地完成身份识别与用户创建,不弹出任何授权弹窗。昵称和头像等个人信息在后续需要时(如个人中心、评论等场景)再引导用户主动授权。
|
||
|
||
**核心流程**:
|
||
|
||
```
|
||
用户打开小程序
|
||
│
|
||
├─ 本地 Storage 有用户数据且未过期?
|
||
│ │
|
||
│ ├─ 是 → 直接使用本地数据,跳过登录
|
||
│ │
|
||
│ └─ 否 → 调用云函数 silentLogin
|
||
│ │
|
||
│ ├─ cloud.getWXContext() 获取 openid/unionid
|
||
│ │
|
||
│ ├─ 查询 users 集合(openid)
|
||
│ │ │
|
||
│ │ ├─ 存在 → 更新 lastActiveAt,返回用户数据
|
||
│ │ │
|
||
│ │ └─ 不存在 → 创建用户(nickName/avatarUrl 为空),返回用户数据
|
||
│ │
|
||
│ └─ 前端收到用户数据 → 写入内存 + Storage
|
||
│
|
||
└─ 完成,进入正常使用
|
||
```
|
||
|
||
---
|
||
|
||
## 二、技术细节
|
||
|
||
### 2.1 云函数:`userLogin`
|
||
|
||
**职责**:静默登录,upsert 用户记录。
|
||
|
||
```javascript
|
||
// cloudfunctions/userLogin/index.js
|
||
const cloud = require('wx-server-sdk');
|
||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||
|
||
const db = cloud.database();
|
||
|
||
exports.main = async (event, context) => {
|
||
const wxContext = cloud.getWXContext();
|
||
const openid = wxContext.OPENID;
|
||
const unionid = wxContext.UNIONID || null;
|
||
|
||
const usersCollection = db.collection('users');
|
||
|
||
// 查询是否已存在
|
||
const { data } = await usersCollection.where({ openid }).limit(1).get();
|
||
|
||
const now = db.serverDate();
|
||
|
||
if (data.length > 0) {
|
||
// 已存在:更新 lastActiveAt 和 unionid(如果有新值)
|
||
const user = data[0];
|
||
const updateData = { lastActiveAt: now };
|
||
if (unionid && !user.unionid) {
|
||
updateData.unionid = unionid;
|
||
}
|
||
await usersCollection.doc(user._id).update({ data: updateData });
|
||
|
||
return {
|
||
code: 0,
|
||
data: { ...user, ...updateData, lastActiveAt: new Date() },
|
||
};
|
||
} else {
|
||
// 不存在:创建新用户
|
||
const newUser = {
|
||
openid,
|
||
unionid,
|
||
nickName: null,
|
||
avatarUrl: null,
|
||
totalDownloads: 0,
|
||
createdAt: now,
|
||
lastActiveAt: now,
|
||
};
|
||
const { _id } = await usersCollection.add({ data: newUser });
|
||
|
||
return {
|
||
code: 0,
|
||
data: {
|
||
_id,
|
||
...newUser,
|
||
createdAt: new Date(),
|
||
lastActiveAt: new Date(),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
```
|
||
|
||
**要点**:
|
||
|
||
- `cloud.getWXContext()` 在云函数中自动获取调用者的 `OPENID`,无需前端传 code
|
||
- `UNIONID` 仅在小程序绑定了开放平台且满足条件时才有值,所以设为可选
|
||
- 使用 `db.serverDate()` 确保时间由服务端生成,避免客户端时间不准
|
||
- `_id` 由云数据库自动生成(`add()` 时不指定),为随机唯一字符串;业务上通过 `openid` 唯一索引定位用户,`_id` 作为主键供子表(favorites、download_logs)的 `userId` 外键引用
|
||
|
||
---
|
||
|
||
### 2.2 前端登录逻辑
|
||
|
||
**位置**:建议封装为 `miniprogram/utils/auth.ts`
|
||
|
||
```typescript
|
||
// miniprogram/utils/auth.ts
|
||
|
||
interface UserInfo {
|
||
_id: string;
|
||
openid: string;
|
||
unionid: string | null;
|
||
nickName: string | null;
|
||
avatarUrl: string | null;
|
||
totalDownloads: number;
|
||
createdAt: string;
|
||
lastActiveAt: string;
|
||
}
|
||
|
||
const STORAGE_KEY = 'user_info';
|
||
const STORAGE_EXPIRE_KEY = 'user_info_expire';
|
||
const EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 天
|
||
|
||
/** 内存中的用户数据(App 生命周期内有效) */
|
||
let currentUser: UserInfo | null = null;
|
||
|
||
/**
|
||
* 获取当前用户,优先内存 → Storage → 云函数登录
|
||
*/
|
||
export async function getUser(): Promise<UserInfo> {
|
||
// 1. 内存中有,直接返回
|
||
if (currentUser) return currentUser;
|
||
|
||
// 2. 尝试从 Storage 读取
|
||
const cached = loadFromStorage();
|
||
if (cached) {
|
||
currentUser = cached;
|
||
return cached;
|
||
}
|
||
|
||
// 3. 静默登录
|
||
return await silentLogin();
|
||
}
|
||
|
||
/**
|
||
* 静默登录:调用云函数
|
||
*/
|
||
async function silentLogin(): Promise<UserInfo> {
|
||
const { result } = await wx.cloud.callFunction({
|
||
name: 'userLogin',
|
||
});
|
||
|
||
if (result.code !== 0) {
|
||
throw new Error('登录失败');
|
||
}
|
||
|
||
const user = result.data as UserInfo;
|
||
currentUser = user;
|
||
saveToStorage(user);
|
||
return user;
|
||
}
|
||
|
||
/**
|
||
* 从 Storage 读取用户数据(检查过期)
|
||
*/
|
||
function loadFromStorage(): UserInfo | null {
|
||
try {
|
||
const expire = wx.getStorageSync(STORAGE_EXPIRE_KEY);
|
||
if (!expire || Date.now() > expire) {
|
||
// 已过期,清除
|
||
wx.removeStorageSync(STORAGE_KEY);
|
||
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
|
||
return null;
|
||
}
|
||
const user = wx.getStorageSync(STORAGE_KEY);
|
||
return user || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 写入 Storage
|
||
*/
|
||
function saveToStorage(user: UserInfo): void {
|
||
try {
|
||
wx.setStorageSync(STORAGE_KEY, user);
|
||
wx.setStorageSync(STORAGE_EXPIRE_KEY, Date.now() + EXPIRE_DURATION);
|
||
} catch {
|
||
// Storage 写入失败不影响主流程
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除登录状态(用于退出登录或需要强制刷新时)
|
||
*/
|
||
export function clearUser(): void {
|
||
currentUser = null;
|
||
wx.removeStorageSync(STORAGE_KEY);
|
||
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
|
||
}
|
||
|
||
/**
|
||
* 强制刷新用户数据(跳过缓存)
|
||
*/
|
||
export async function refreshUser(): Promise<UserInfo> {
|
||
clearUser();
|
||
return await silentLogin();
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 2.3 调用时机
|
||
|
||
在 `app.ts` 的 `onLaunch` 中触发登录:
|
||
|
||
```typescript
|
||
// app.ts
|
||
import { getUser } from './utils/auth';
|
||
|
||
App({
|
||
onLaunch() {
|
||
// 初始化云开发
|
||
wx.cloud.init({ env: 'your-env-id' });
|
||
|
||
// 静默登录(不阻塞页面渲染)
|
||
getUser().catch((err) => {
|
||
console.error('静默登录失败', err);
|
||
});
|
||
},
|
||
});
|
||
```
|
||
|
||
**注意**:登录是异步的,不阻塞页面渲染。页面中需要用户数据时通过 `await getUser()` 获取,如果登录已完成会立即返回内存数据。
|
||
|
||
---
|
||
|
||
### 2.4 缓存过期策略
|
||
|
||
| 策略 | 说明 |
|
||
| ---------- | ----------------------------------------- |
|
||
| 有效期 | 7 天 |
|
||
| 过期后行为 | 下次 `getUser()` 时自动触发静默登录 |
|
||
| 过期目的 | 定期刷新 `lastActiveAt`,保持活跃数据准确 |
|
||
| 强制刷新 | 调用 `refreshUser()` 可随时强制重新登录 |
|
||
|
||
---
|
||
|
||
## 三、昵称与头像授权(后续实现)
|
||
|
||
静默登录不获取用户昵称和头像。当需要展示或使用这些信息时(如个人中心页),引导用户主动填写:
|
||
|
||
```typescript
|
||
// 使用微信头像昵称填写能力(基础库 2.21.2+)
|
||
// 在 wxml 中使用 <button open-type="chooseAvatar"> 获取头像
|
||
// 使用 <input type="nickname"> 获取昵称
|
||
|
||
export async function updateProfile(
|
||
nickName: string,
|
||
avatarUrl: string,
|
||
): Promise<void> {
|
||
const { result } = await wx.cloud.callFunction({
|
||
name: 'userLogin',
|
||
data: {
|
||
action: 'updateProfile',
|
||
nickName,
|
||
avatarUrl,
|
||
},
|
||
});
|
||
|
||
if (result.code === 0) {
|
||
// 更新本地缓存
|
||
const user = await getUser();
|
||
user.nickName = nickName;
|
||
user.avatarUrl = avatarUrl;
|
||
saveToStorage(user);
|
||
}
|
||
}
|
||
```
|
||
|
||
> 注:微信已废弃 `wx.getUserProfile` 和 `wx.getUserInfo` 获取真实用户信息的能力,现在只能通过头像昵称填写组件让用户主动填写。
|
||
|
||
---
|
||
|
||
## 四、云函数目录结构
|
||
|
||
```
|
||
cloudfunctions/
|
||
└── userLogin/
|
||
├── index.js ← 入口,支持 action 路由
|
||
├── package.json
|
||
└── config.json ← 云函数配置
|
||
```
|
||
|
||
若后续 `updateProfile` 等操作较多,可在同一个云函数内通过 `event.action` 路由:
|
||
|
||
```javascript
|
||
exports.main = async (event, context) => {
|
||
const { action } = event;
|
||
|
||
switch (action) {
|
||
case 'updateProfile':
|
||
return await handleUpdateProfile(event, context);
|
||
default:
|
||
return await handleSilentLogin(event, context);
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 五、安全考虑
|
||
|
||
| 项目 | 措施 |
|
||
| ------------ | --------------------------------------------------------- |
|
||
| openid 来源 | 仅从 `cloud.getWXContext()` 获取,不信任前端传入 |
|
||
| unionid | 服务端获取,不暴露给前端 |
|
||
| Storage 数据 | 不存储敏感信息(openid 不算高敏感,但也不主动展示给用户) |
|
||
| 接口幂等 | upsert 逻辑天然幂等,重复调用不会创建多条记录 |
|
||
|
||
---
|
||
|
||
## 六、与现有架构的关系
|
||
|
||
- **数据库**:使用 `users` 集合,字段设计见 [小程序云开发方案和数据库设计.md](./小程序云开发方案和数据库设计.md) §3.5
|
||
- **其他云函数**:需要用户身份的云函数(如收藏、下载记录)通过 `cloud.getWXContext().OPENID` 查询 `users` 集合获取 `_id`,作为 `userId` 关联
|
||
- **前端**:各页面通过 `getUser()` 获取用户数据,无需关心登录细节
|