feat: 开发收藏和下载功能

This commit is contained in:
R524809
2026-05-06 18:32:15 +08:00
parent 9bdf850f20
commit c0b1bfddb0
17 changed files with 1262 additions and 127 deletions
@@ -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`
+338
View File
@@ -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()` 获取用户数据,无需关心登录细节