相信每一位程序员都梦想着有一天开发出自己的产品,而且有人愿意为自己的产品买单。用户买单的方式可能是一次性购买产品的使用权、购买产品会员享受高级功能等等。本文和下一篇文章将进行会员与支付功能的设计开发分享,希望可以给正在开发会员功能的开发者提供一点帮助。
本文我们先把支付的逻辑放一边,只关注会员功能的设计和开发,抽丝剥茧,为接入支付功能打好基础。
设计思考与开发
当我们开始思考设计一个会员功能的时候,它包含了用户类别的设计、数据存储的设计、后端API的设计、前端界面的设计和预防风险的设计。现在我们就一个个理清思路。
用户类别设计
我正在开发一个工具类网站,为了给用户提供更多的灵活性并满足不同的使用需求,我决定将用户分为三类:
- 免费用户:每天可以使用10次,用户角色值设为1
- 月度会员:每天可以使用500次,用户角色值设为2
- 加油包用户:每次购买可增加100次可用次数,免费用户和月度会员均可成为加油包用户,所以不用单独设置角色值
付费功能核心逻辑是这样:
- 免费用户可以升级为月度会员,一个会员周期是31天
- 会员可以提前续费,会员有效期会顺延
- 当用户的使用次数达到限制,可通过加油包获取更多次数,每次购买加油包获得100次使用次数,有效期为7天,如果多次购买,加油包有效期会顺延
我认为这种设计可以兼顾轻度用户、重度用户和临时重度用户,再通过定价策略,就可以激励潜在的重度需求的用户购买月度会员。当然这套设计未经验证,如果效果不好,到时候再调整就好了。
数据存储设计
因为会员和加油包有过期时间,如果把相关数据记录在像 MySQL 或者 Postgres 里,我还需要定时去更改用户角色,无形中给自己增加了开发量。为了让开发流程更丝滑,我选择了 Redis 作为数据存储的解决方案,我只需要给 Redis key 设置过期时间,key 过期就查不到了,等同于会员或加油包到期。
Redis 我仍然沿用 Upstash 的免费 Redis 资源,关于 Upstash 的介绍,可以看我的这篇文章:👉《用 Upstash 作为你的 Redis 服务器》。
本文默认你对 Redis 的基础类型和命令有一定的认识,所以不会对 Redis 的用法进行介绍。
对于数据存储的设计,我首先需要设计几个关键的 Redis key:
-
用户每日已使用次数:
userId:::date:::user_date_uses
- 用户每日第一次使用功能时,通过自增命令,Redis 会自动创建一个与日期相关的 key;
- 有了每日使用的次数,我就可以通过用户角色对应的日上限(普通用户10次,会员500次)算出该用户每日剩余次数
- 为什么我记录已使用次数,而不是记录剩余次数?这是因为,如果记录剩余次数,当剩余0次时会查询到0,而当天第一次使用前,查询剩余次数会因为 key 不存在而查到
null
,在区分0和null
的时候,可能会出现混淆,所以我认为记录已使用次数是更好的做法。
-
会员状态:
userId:::membership
- 用户升级会员后,服务端会在 Redis 里创建这个用户的会员状态 key,value 设置为2,表示用户是有效的会员。
- key 的过期时间是会员的剩余有效期(秒为单位)。
-
加油包余额:
userId::boost_pack_uses
- 用户购买加油包后,会创建一个初始 value 为100的加油包余额 key
- key 的过期时间是加油包的有效期(秒为单位)
- 当用户日限额用完后,开始扣加油包的余额,用
desc
进行自减
服务端设计与开发
在真实的生产环境中,我们一般是通过查询订单状态,然后内部调用升级、续费、获得加油包的方法,但本文一方面是剥离了支付功能,另一方面出于方便测试和演示,所有相关功能全部以暴露接口、接口再调用内部方法的方式来展开说明。
参数类型和常量定义
首先把用户角色类型和重要的常量定义清楚。
用户角色类型定义:
// @/types/user.ts
// ……
export interface UserSub {
sub: string;
}
export type Role = 1 | 2; // 1 普通用户; 2 会员
export interface DateRemaining {
role: number; // 用户角色
userTodayRemaining: number; // 今天剩余次数
boostPackRemaining: number; // 加油包剩余次数
userDateRemaining: number; // 上面二者总的剩余次数
}
为了更方便地维护 Redis 相关的 key 和其它设置,我们还可以创建一个文件用来记录这些信息,如:
// @/lib/membership/constants.ts
import { Role, UserSub } from "@/types/user";
export const ROLES: { [key in Role]: string } = {
1: 'Basic',
2: 'MemberShip',
}
export const ROLES_LIMIT: { [key in Role]: number } = {
1: process.env.COMMON_USER_DAILY_LIMIT_STR && Number(process.env.COMMON_USER_DAILY_LIMIT_STR) || 10,
2: process.env.MEMBERSHIP_DAILY_LIMIT_STR && Number(process.env.MEMBERSHIP_DAILY_LIMIT_STR) || 500,
}
export const DATE_USAGE_KEY_EXPIRE = 3600 * 24 * 10 // 10天,用户日用量保存时长
export const MEMBERSHIP_EXPIRE = 3600 * 24 * 31 // 31天,会员一个月的时间
export const MEMBERSHIP_ROLE_VALUE = 2 // 月度会员的值
export const BOOST_PACK_EXPIRE = 3600 * 24 * 7 // 7天,购买加油包的使用期限
export const BOOST_PACK_USES = 100 // 每次购买加油包获得的次数
// key统一管理,可以降低维护难度
export const getUserDateUsageKey = ({ sub }: UserSub) => {
const currentDate = new Date().toLocaleDateString();
return `userId:${sub}::date:${currentDate}::user_date_uses`
}
export const getMembershipStatusKey = ({ sub }: UserSub) => {
return `userId:${sub}::membership`
}
export const getBoostPackKey = ({ sub }: UserSub) => {
return `userId:${sub}::boost_pack_uses`
}
API设计开发
1、升级/续费会员:/api/mambership/fake/upgrade
// @/app/api/membership/fake/upgrade/route.ts
import { upgrade } from "@/lib/membership/upgrade";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const { sub } = await req.json();
const res = await upgrade({ sub }) // upgrade是一个内部方法,后续接入支付可直接调用
return NextResponse.json(res)
} catch (e) {
return NextResponse.json({ error: "failed to upgrade" }, { status: 500 });
}
}
upgrade
功能设计:
-
判断当前用户角色
- 如果是普通用户,则升级会员,设置
userId:::membership
的value为2,过期时间31天 - 如果是会员用户,则延长会员期,更新
userId:::membership
的过期时间。
- 如果是普通用户,则升级会员,设置
-
每次购买都清空当日已使用次数,这是出于用户体验的考虑,可以不要
代码实现如下:
// @/lib/membership/upgrade.ts
export const upgrade = async ({ sub }: UserSub) => {
// 检查用户角色
const userRoleKey = await getMembershipStatusKey({ sub })
const userRole: Role = await redis.get(userRoleKey) || 1
// 普通用户,直接设置role和过期时间
if (userRole === 1) {
const res = await redis.set(userRoleKey, MEMBERSHIP_ROLE_VALUE, { ex: MEMBERSHIP_EXPIRE })
if (res === 'OK') {
// 清空今天已用次数
clearTodayUsage({ sub })
return { sub, oldRole: ROLES[userRole], newRole: ROLES[MEMBERSHIP_ROLE_VALUE], expire: MEMBERSHIP_EXPIRE, upgrade: 'success' }
}
return { sub, oldRole: ROLES[userRole], upgrade: 'fail' }
}
// 会员用户,查询过期时间,计算新的过期时间,更新过期时间
const TTL = await redis.ttl(userRoleKey)
const newTTL = TTL + MEMBERSHIP_EXPIRE
const res = await redis.expire(userRoleKey, newTTL)
if (res === 1) {
// redis操作成功,清空今天已用次数
clearTodayUsage({ sub })
return { sub, oldRole: ROLES[MEMBERSHIP_ROLE_VALUE], newRole: ROLES[MEMBERSHIP_ROLE_VALUE], expire: newTTL, upgrade: 'success' }
}
return { sub, oldRole: ROLES[MEMBERSHIP_ROLE_VALUE], newRole: ROLES[MEMBERSHIP_ROLE_VALUE], expire: TTL, upgrade: 'fail' }
}
这样升级/续费的接口就完成了,可以通过postman进行逻辑测试。完整源码和线上演示地址放在文末。
2、购买加油包:/api/mambership/fake/bugBoostPack
// @/app/api/membership/fake/bugBoostPack/route.ts
import { boostPack } from "@/lib/membership/boostPack";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const { sub } = await req.json();
const res = await boostPack({ sub })
return NextResponse.json(res)
} catch (e) {
return NextResponse.json({ error: "failed to purchase boost pack" }, { status: 500 });
}
}
boostPack
功能设计:
-
判断当前加油包剩余次数
- 如果剩余0次,则设置
userId::boost_pack_uses
的值为100,过期时间7天 - 如果剩余大于0次,则增加100次剩余次数,增加7天过期时间
- 如果剩余0次,则设置
代码实现如下:
// @/lib/membership/boostPack.ts
export const boostPack = async ({ sub }: UserSub) => {
const userBoostPackKey = await getBoostPackKey({ sub })
const userBoostPack = await redis.get(userBoostPackKey) || 0
// 加油包余额不存在,当作新购用户
if (userBoostPack === 0) {
const res = await redis.set(userBoostPackKey, BOOST_PACK_USES, { ex: BOOST_PACK_EXPIRE })
if (res === 'OK') {
return { sub, boostPackUses: BOOST_PACK_USES, expire: BOOST_PACK_EXPIRE, boostPack: 'success' }
}
return { sub, boostPackUses: 0, expire: 0, boostPack: 'fail' }
}
// 已是加油包用户,查询过期时间,计算新的过期时间,更新过期时间
const oldBalance: number = await redis.get(userBoostPackKey) || 0
const TTL = await redis.ttl(userBoostPackKey)
const newTTL = TTL + BOOST_PACK_EXPIRE
const newBalance = oldBalance + BOOST_PACK_USES
const res = await redis.setex(userBoostPackKey, newTTL, newBalance)
return res === 'OK' ?
{ sub, oldBalance, newBalance, expire: newTTL, boostPack: 'success' } :
{ sub, oldBalance, newBalance: oldBalance, expire: TTL, boostPack: 'fail' }
}
这样购买加油包的接口也完成了,可以通过postman进行逻辑测试。完整源码和线上演示地址放在文末。
3、检查使用次数和会员状态:/api/mambership/fake/checkStatus
// @/app/api/membership/fake/checkStatus/route.ts
import { checkStatus } from "@/lib/membership/checkStatus";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const { sub } = await req.json();
const res = await checkStatus({ sub })
return NextResponse.json(res)
} catch (e) {
return NextResponse.json({ error: "failed to upgrade" }, { status: 500 });
}
}
checkStatus
功能设计:
- 获取
userId:::date:::user_date_uses
、userId::boost_pack_uses
和userId:::membership
的值,返回当前可用次数、加油包余额、加油包过期时间及会员过期时间。
核心代码实现如下:
// @/lib/membership/checkStatus.ts
export const checkStatus = async ({ sub }: UserSub) => {
const userDateRemaining: DateRemaining = await getUserDateRemaining({ sub }) // 获取用户角色、当日剩余次数、加油包剩余次数
// 如果是会员,计算会员到期时间
let membershipExpire = 0
if (userDateRemaining.role === MEMBERSHIP_ROLE_VALUE) {
const userRoleKey = await getMembershipStatusKey({ sub })
membershipExpire = await redis.ttl(userRoleKey)
}
// 如果加油包次数大于0,计算加油包到期时间
let boostPackExpire = 0
if (userDateRemaining.boostPackRemaining > 0) {
const boostPackKey = await getBoostPackKey({ sub })
boostPackExpire = await redis.ttl(boostPackKey)
}
return {
role: userDateRemaining.role,
todayRemaining: userDateRemaining.userTodayRemaining,
membershipExpire: membershipExpire,
boostPackRemaining: userDateRemaining.boostPackRemaining,
boostPackExpire: boostPackExpire,
}
}
这样购买获取会员状态和使用次数的接口也完成了,可以通过postman进行逻辑测试。完整源码和线上演示地址放在文末。
4、使用功能:/api/fake/useFunction
核心代码设计如下:
- 服务端调用工具方法前,先查询剩余次数,如果默认次数+加油包次数>0,则可以调用,否则返回错误提示
// @/app/api/fake/useFunction/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
// 忽略身份校验的代码
// ……
// 服务端调用工具方法前,先查询剩余次数
const remainingInfo: DateRemaining = await getUserDateRemaining({ sub })
if (remainingInfo.userDateRemaining 0,则自增一个日使用次数;
如果【默认使用次数 - 日使用次数】 {
// 如果【默认使用次数 - 日使用次数】> 0,则自增一个日使用次数;
if (remainingInfo.userTodayRemaining > 0) {
await incrUserDate({ sub })
return
}
// 如果没有默认次数,有加油包,判断加油包次数
if (remainingInfo.boostPackRemaining > 0) {
const boostPackKey = await getBoostPackKey({ sub })
await redis.decr(boostPackKey)
return
}
// 如果没有默认次数,也没有加油包,则不处理,只需要记录日志
console.log('0 uses remaining today.');
}
export const incrUserDate = async ({ sub }: UserSub) => {
const keyDate = await getUserDateUsageKey({ sub });
await redis.incr(keyDate);
}
完整源码和线上演示地址放在文末。
前端设计
-
用户界面:
- 显示当前用户类型、剩余使用次数、加油包余额(包括到期日期)和会员到期日期(可以通过检查Redis键的TTL得到)。
- 提供购买额外次数的选项。
- 提供续费选项。
-
提示和警告:
-
当用户达到使用限制时,提示购买加油包或者升级会员
- 普通用户:Become a member to enjoy 500 uses every day.
- 会员用户:Purchase a Boost Pack to get more uses right now.
-
当加油包即将到期或会员即将到期时,显示相应的提醒。
-
演示截图如下:
风险应对策略
涉及到金钱的功能一定要做好风险应对策略,否则出了生产事故就会对品牌产生很大影响。本文核心逻辑都是在操作 redis,所以主要考虑 redis 的连接和操作失败的问题。因为这是一块很大的专题,所以不在本文进行详细叙述,仅抛砖引玉提供一些应对策略:
- 重试策略: 由于网络波动或短暂的服务中断,Redis 操作可能会偶尔失败。这种情况下,实施一个自动重试策略是有益的。
- 错误日志:确保记录所有 Redis 相关的错误,这样你可以追踪、分析并修复它们。
- 用户反馈:如果 Redis 操作失败,并且你已尝试了所有自动重试,那么应该给用户一个明确的错误消息。这样,用户会知道发生了什么,并且可以稍后重试。
- 后备策略:考虑创建一个后端队列或延迟任务系统。当 Redis 操作失败并且重试不起作用时,你可以将操作的详细信息放入队列中,并在后台持续尝试,直到成功。
- 监控:使用 Upstash 提供的监控工具或其他第三方服务,如 Datadog 或 Sentry 来实时监控 Redis 的性能和错误率。这样,如果出现问题,你可以迅速得知并采取行动。
结语
把本文的代码块去掉,就是一份会员功能设计和代码设计的文档,希望本文对会员功能的设计思考和代码的实现都能对你有所启发。
源码与演示
源码:👉membership
在线演示:👉模拟会员功能
专栏资源
专栏介绍:以实战的角度进行Next.js生态圈的技术栈分享,内容包括但不限于:Next.js理论知识、功能模块设计思路、实战中使用到的技术栈。这是一个长期更新的专栏,我会持续把自己的思考和经验提炼分享出来,欢迎关注我的专栏👇
专栏地址:👉Next.js生态圈实战
专栏演示站:👉Next.js Demos
专栏源码:👉Source Code