diff --git a/cloudfunctions/userDownloadLogs/index.js b/cloudfunctions/userDownloadLogs/index.js new file mode 100644 index 0000000..6586b36 --- /dev/null +++ b/cloudfunctions/userDownloadLogs/index.js @@ -0,0 +1,98 @@ +const cloud = require('wx-server-sdk'); +cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); + +const db = cloud.database(); +const command = db.command; + +exports.main = async (event) => { + const wxContext = cloud.getWXContext(); + const openid = wxContext.OPENID; + + // 获取 userId + const { data: users } = await db + .collection('users') + .where({ openid }) + .limit(1) + .get(); + + if (users.length === 0) { + return { code: -1, message: '用户未登录' }; + } + + const userId = users[0]._id; + const { action } = event; + + switch (action) { + case 'add': + return await handleAdd(userId, event); + case 'list': + return await handleList(userId, event); + default: + return { code: -1, message: '未知操作' }; + } +}; + +async function handleAdd(userId, event) { + const { worksheetId } = event; + if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' }; + + const collection = db.collection('download_logs'); + + const record = { + userId, + worksheetId, + createdAt: db.serverDate(), + }; + const { _id } = await collection.add({ data: record }); + + // worksheets.downloads +1 + try { + await db.collection('worksheets').doc(worksheetId).update({ + data: { + downloads: command.inc(1), + updatedAt: db.serverDate(), + }, + }); + } catch (e) { + console.warn('更新 worksheets.downloads 失败', e); + } + + return { code: 0, data: { _id, ...record } }; +} + +async function handleList(userId, event) { + const { page = 1, pageSize = 20 } = event; + const skip = (page - 1) * pageSize; + + const collection = db.collection('download_logs'); + + const { data: logs } = await collection + .where({ userId }) + .orderBy('createdAt', 'desc') + .skip(skip) + .limit(pageSize) + .get(); + + if (logs.length === 0) { + return { code: 0, data: [] }; + } + + // 关联查询 worksheets 信息 + const worksheetIds = [...new Set(logs.map((l) => l.worksheetId))]; + const { data: worksheets } = await db + .collection('worksheets') + .where({ _id: command.in(worksheetIds) }) + .get(); + + const worksheetMap = {}; + worksheets.forEach((w) => { + worksheetMap[w._id] = w; + }); + + const result = logs.map((l) => ({ + ...l, + worksheet: worksheetMap[l.worksheetId] || null, + })); + + return { code: 0, data: result }; +} diff --git a/cloudfunctions/userDownloadLogs/package.json b/cloudfunctions/userDownloadLogs/package.json new file mode 100644 index 0000000..25915dd --- /dev/null +++ b/cloudfunctions/userDownloadLogs/package.json @@ -0,0 +1,8 @@ +{ + "name": "user-download-logs", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "wx-server-sdk": "^3.0.4" + } +} diff --git a/cloudfunctions/userFavorites/index.js b/cloudfunctions/userFavorites/index.js new file mode 100644 index 0000000..451f54f --- /dev/null +++ b/cloudfunctions/userFavorites/index.js @@ -0,0 +1,190 @@ +const cloud = require('wx-server-sdk'); +cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); + +const db = cloud.database(); +const command = db.command; + +exports.main = async (event) => { + const wxContext = cloud.getWXContext(); + const openid = wxContext.OPENID; + + // 获取 userId + const { data: users } = await db + .collection('users') + .where({ openid }) + .limit(1) + .get(); + + if (users.length === 0) { + return { code: -1, message: '用户未登录' }; + } + + const userId = users[0]._id; + const { action } = event; + + switch (action) { + case 'add': + return await handleAdd(userId, event); + case 'remove': + return await handleRemove(userId, event); + case 'list': + return await handleList(userId, event); + case 'check': + return await handleCheck(userId, event); + case 'batchCheck': + return await handleBatchCheck(userId, event); + default: + return { code: -1, message: '未知操作' }; + } +}; + +async function handleAdd(userId, event) { + const { worksheetId } = event; + if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' }; + + const collection = db.collection('favorites'); + + // 幂等检查:已收藏则直接返回 + const { data: existing } = await collection + .where({ userId, worksheetId }) + .limit(1) + .get(); + + if (existing.length > 0) { + return { code: 0, data: existing[0], message: '已收藏' }; + } + + // 写入收藏记录 + const record = { + userId, + worksheetId, + createdAt: db.serverDate(), + }; + const { _id } = await collection.add({ data: record }); + + // worksheets.likes +1 + try { + await db + .collection('worksheets') + .doc(worksheetId) + .update({ + data: { + likes: command.inc(1), + updatedAt: db.serverDate(), + }, + }); + } catch (e) { + console.warn('更新 worksheets.likes 失败', e); + } + + return { code: 0, data: { _id, ...record } }; +} + +async function handleRemove(userId, event) { + const { worksheetId } = event; + if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' }; + + const collection = db.collection('favorites'); + + const { data: existing } = await collection + .where({ userId, worksheetId }) + .limit(1) + .get(); + + if (existing.length === 0) { + return { code: 0, message: '未收藏' }; + } + + await collection.doc(existing[0]._id).remove(); + + // worksheets.likes -1 + try { + await db + .collection('worksheets') + .doc(worksheetId) + .update({ + data: { + likes: command.inc(-1), + updatedAt: db.serverDate(), + }, + }); + } catch (e) { + console.warn('更新 worksheets.likes 失败', e); + } + + return { code: 0, message: '已取消收藏' }; +} + +async function handleList(userId, event) { + const { page = 1, pageSize = 20 } = event; + const skip = (page - 1) * pageSize; + + const collection = db.collection('favorites'); + + // 查询收藏记录 + const { data: favorites } = await collection + .where({ userId }) + .orderBy('createdAt', 'desc') + .skip(skip) + .limit(pageSize) + .get(); + + if (favorites.length === 0) { + return { code: 0, data: [] }; + } + + // 关联查询 worksheets 信息 + const worksheetIds = favorites.map((f) => f.worksheetId); + const { data: worksheets } = await db + .collection('worksheets') + .where({ _id: command.in(worksheetIds) }) + .get(); + + const worksheetMap = {}; + worksheets.forEach((w) => { + worksheetMap[w._id] = w; + }); + + const result = favorites.map((f) => ({ + ...f, + worksheet: worksheetMap[f.worksheetId] || null, + })); + + return { code: 0, data: result }; +} + +async function handleCheck(userId, event) { + const { worksheetId } = event; + if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' }; + + const { data } = await db + .collection('favorites') + .where({ userId, worksheetId }) + .limit(1) + .get(); + + return { code: 0, data: { favorited: data.length > 0 } }; +} + +async function handleBatchCheck(userId, event) { + const { worksheetIds } = event; + if ( + !worksheetIds || + !Array.isArray(worksheetIds) || + worksheetIds.length === 0 + ) { + return { code: 0, data: {} }; + } + + const { data } = await db + .collection('favorites') + .where({ userId, worksheetId: command.in(worksheetIds) }) + .get(); + + const favoritedMap = {}; + data.forEach((item) => { + favoritedMap[item.worksheetId] = true; + }); + + return { code: 0, data: favoritedMap }; +} diff --git a/cloudfunctions/userFavorites/package.json b/cloudfunctions/userFavorites/package.json new file mode 100644 index 0000000..0d436a1 --- /dev/null +++ b/cloudfunctions/userFavorites/package.json @@ -0,0 +1,8 @@ +{ + "name": "user-favorites", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "wx-server-sdk": "^3.0.4" + } +} diff --git a/cloudfunctions/userLogin/index.js b/cloudfunctions/userLogin/index.js new file mode 100644 index 0000000..d887c18 --- /dev/null +++ b/cloudfunctions/userLogin/index.js @@ -0,0 +1,84 @@ +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 { action } = event; + + switch (action) { + case 'updateProfile': + return await handleUpdateProfile(event, openid); + default: + return await handleSilentLogin(openid, unionid); + } +}; + +async function handleSilentLogin(openid, unionid) { + const usersCollection = db.collection('users'); + + const { data } = await usersCollection.where({ openid }).limit(1).get(); + + const now = db.serverDate(); + + if (data.length > 0) { + 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(), + }, + }; + } +} + +async function handleUpdateProfile(event, openid) { + const { nickName, avatarUrl } = event; + const usersCollection = db.collection('users'); + + const { data } = await usersCollection.where({ openid }).limit(1).get(); + + if (data.length === 0) { + return { code: -1, message: '用户不存在' }; + } + + const updateData = {}; + if (nickName !== undefined) updateData.nickName = nickName; + if (avatarUrl !== undefined) updateData.avatarUrl = avatarUrl; + + await usersCollection.doc(data[0]._id).update({ data: updateData }); + + return { + code: 0, + data: { ...data[0], ...updateData }, + }; +} diff --git a/cloudfunctions/userLogin/package.json b/cloudfunctions/userLogin/package.json new file mode 100644 index 0000000..8fad6ca --- /dev/null +++ b/cloudfunctions/userLogin/package.json @@ -0,0 +1,8 @@ +{ + "name": "user-login", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "wx-server-sdk": "^3.0.4" + } +} diff --git a/docs/小程序云开发方案和数据库设计.md b/docs/小程序云开发方案和数据库设计.md index 130703d..a35d14a 100644 --- a/docs/小程序云开发方案和数据库设计.md +++ b/docs/小程序云开发方案和数据库设计.md @@ -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`。 diff --git a/docs/用户登录与信息授权方案.md b/docs/用户登录与信息授权方案.md new file mode 100644 index 0000000..9f1dff6 --- /dev/null +++ b/docs/用户登录与信息授权方案.md @@ -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 { + // 1. 内存中有,直接返回 + if (currentUser) return currentUser; + + // 2. 尝试从 Storage 读取 + const cached = loadFromStorage(); + if (cached) { + currentUser = cached; + return cached; + } + + // 3. 静默登录 + return await silentLogin(); +} + +/** + * 静默登录:调用云函数 + */ +async function silentLogin(): Promise { + 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 { + 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 中使用