feat: 开发收藏和下载功能
This commit is contained in:
+10
-11
@@ -125,16 +125,15 @@
|
||||
|
||||
对应 Prisma `User`(`@@map("users")`)。**`openid` / `unionid` 与 `_id` 的取舍、`unionid` 写入方式**见 §3.2。
|
||||
|
||||
| 字段 | BSON 类型 | 必填 | 说明 |
|
||||
| ---------------- | -------------- | ---- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `_id` | string | 是 | 主键;可与 Prisma `User.id` 一样使用 cuid,也可自定义为其它字符串,团队内与用户档案查询方式统一即可。 |
|
||||
| `openid` | string | 是 | 当前小程序下微信用户标识;与 Prisma 一致建议 ≤100 字符;**业务唯一**,索引见下表。 |
|
||||
| `unionid` | string \| null | 否 | 开放平台下跨应用用户标识;建议 ≤100 字符;未绑开放平台或未返回时为空。 |
|
||||
| `nickName` | string \| null | 否 | 用户昵称;建议 ≤50 字符。 |
|
||||
| `avatarUrl` | string \| null | 否 | 头像 URL 或云存储 fileID;建议 ≤500 字符。 |
|
||||
| `totalDownloads` | int | 是 | 累计下载次数,默认 `0`。 |
|
||||
| `createdAt` | date | 是 | 首次创建时间。 |
|
||||
| `lastActiveAt` | date | 是 | 最近一次活跃时间(登录、打开小程序、写库等策略由实现约定)。 |
|
||||
| 字段 | BSON 类型 | 必填 | 说明 |
|
||||
| -------------- | -------------- | ---- | ----------------------------------------------------------------------------------------------------- | ----- |
|
||||
| `_id` | string | 是 | 主键;可与 Prisma `User.id` 一样使用 cuid,也可自定义为其它字符串,团队内与用户档案查询方式统一即可。 |
|
||||
| `openid` | string | 是 | 当前小程序下微信用户标识;与 Prisma 一致建议 ≤100 字符;**业务唯一**,索引见下表。 |
|
||||
| `unionid` | string \| null | 否 | 开放平台下跨应用用户标识;建议 ≤100 字符;未绑开放平台或未返回时为空。 |
|
||||
| `nickName` | string \| null | 否 | 用户昵称;建议 ≤50 字符。 |
|
||||
| `avatarUrl` | string \| null | 否 | 头像 URL 或云存储 fileID;建议 ≤500 字符。 | `0`。 |
|
||||
| `createdAt` | date | 是 | 首次创建时间。 |
|
||||
| `lastActiveAt` | date | 是 | 最近一次活跃时间(登录、打开小程序、写库等策略由实现约定)。 |
|
||||
|
||||
**索引建议**
|
||||
|
||||
@@ -145,7 +144,7 @@
|
||||
|
||||
---
|
||||
|
||||
### 3.6 集合:`favorites`(收藏日志)
|
||||
### 3.6 集合:`favorites_log`(收藏日志)
|
||||
|
||||
对应 Prisma `FavoriteLog`。
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# 用户登录与信息授权方案
|
||||
|
||||
> 版本: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()` 获取用户数据,无需关心登录细节
|
||||
Reference in New Issue
Block a user