实现一个用户积分功能
需求:
用户通过签到、活动、下单等渠道获取积分,积分30天后过期(可配置)。用户下单可以使用积分抵扣金额,优先使用快过期的积分,但当订单取消后,积分也要原路退回。
需求分析:
积分过期:需要存储每一次用户获取积分的项目,比如签到获取10积分,过期时间是2023-04-30 12:00:00。这样每个积分项都有不同的过期时间记录,来做到对不同时间获取积分的过期操作。
积分使用:先使用最快要过期的积分,那么就需要积分项按照过期时间排序再消费。例如用户签到10天获取到100积分,属于10个积分项,用户下单使用40积分,那么需要消费最先过期的4个积分项中的40积分。
积分退还:用户下单使用100积分,是用户签到10天获取到的,属于10个积分项,拥有不同的过期时间。退还时要把这100个积分拆成10个退还到不同的过期时间的积分项中。需要存储每次使用积分的记录id和积分项id,做到积分的退还。
表设计:
create table member
(
id varchar(36) not null comment 'id',
user_id varchar(36) not null comment '用户id',
total_point int not null comment '总积分',
avail_point int not null comment '可用积分',
expire_point int not null comment '过期积分'
constraint id
unique (id),
constraint unq_user_id
unique (user_id)
)
comment '会员表';
create table member_point_item
(
id varchar(36) not null comment 'id'
primary key,
user_id varchar(36) not null comment '用户id',
point int not null comment '积分值',
expire_point int default 0 not null comment '过期积分',
source_id varchar(36) not null comment '来源id 例如活动id',
status smallint not null comment '状态 1:正常 2:用光 3:过期'
)
comment '会员积分项';
create table member_point_record
(
id varchar(36) not null comment 'id'
primary key,
user_id varchar(36) not null comment '用户id',
source_id varchar(36) null comment '来源id(例如订单id)',
point int not null comment '积分值',
type smallint not null comment '类型 1:签到 2:订单抵扣 3:过期'
)
comment '会员积分记录';
create table member_point_record_relation
(
id varchar(36) not null comment 'id'
primary key,
record_id varchar(36) not null comment '记录id',
point_id varchar(36) not null comment '用户积分项id',
use_point int not null comment '使用积分数',
status smallint not null comment '状态 1:抵扣 2:返还'
)
comment '会员积分项与记录关系表';
member:存储用户的总积分,可用积分,过期积分。
member_point_item:存储用户积分项,记录不同的过期时间、
member_point_record:存储用户获得,使用,过期积分记录。
member_point_record_relation:存储记录使用到的积分项关系,用来实现用户积分原路退回。
问题:
由于用户获取积分时间不同,用户积分项可能数据较多,且积分数较小。例如100条获取1积分的积分项记录,标识用户拥有100积分,这100积分拥有不同的过期时间。使用时需要扣除这100条记录的积分,需要修改100条记录。
解决方案:
- 产品层面:跟产品battle,让他改成按小时过期,按日过期、按月过期,增大积分项的过期粒度,同时控制积分过期时间。减少使用积分时需要操作的积分项记录数。(可能battle不过)
- 异步做:先扣减用户积分额度,异步做积分项的积分使用。
下面简单放一下获得积分、扣减积分、退还积分的代码
获得积分:
/**
* 用户获取积分
*
* @param req 积分获取类
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void create(MemberPointCreateDTO req) {
//获取到的积分
int point = req.getPoint();
//用户id
String userId = UserUtil.handleUserId();
//生成积分记录,简单的存储member_point_record表操作
memberPointRecordManager.create(userId, point, req.getType(), req.getSourceId());
//存储用户积分项,简单的存储member_point_item表操作
create(userId, point);
//更新member用户积分
memberService.addUserPoint(userId, point);
}
//更新member用户积分
public void addUserPoint(String userId, int point) {
LambdaUpdateWrapper<Member> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(Member::getUserId, userId)
.setSql("total_point = total_point + " + point)
.setSql("avail_point = avail_point + " + point);
this.update(updateWrapper);
}
扣减积分
/**
* 消费积分
*
* @param req 积分消费类
*/
@Override
@Transactional(rollbackFor = Exception.class)
@RedissonLock(name = "member:point", key = "#req.userId")
public void consume(MemberPointConsumeDTO req) {
//获取用户积分判断积分是否足够消费
Member member = memberService.getByUserId(req.getUserId());
//用户积分不足
if (member.getAvailPoint() < req.getPoint()) {
throw MemberException.buildServiceException(MemberExceptionResponseCode.POINT_NOT_ENOUGH);
}
//要花费的总积分
Integer totalPoint = req.getPoint();
//扣减用户积分
memberService.consumeUserPoint(req.getUserId(), req.getPoint());
//生成积分记录
MemberPointRecord record = memberPointRecordManager.create(req.getUserId(), req.getPoint(), req.getType(), req.getSourceId());
//扣减用户积分项
handleUserPointItemDeduct(req.getPoint(), req.getUserId(), record.getId());
}
/**
* 扣减用户积分
* @param userId 用户id
* @param point 积分
*/
@Override
public boolean consumeUserPoint(String userId, int point) {
LambdaUpdateWrapper<Member> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(Member::getUserId, userId)
.setSql("avail_point = avail_point - " + point)
.ge(Member::getAvailPoint, point);
return this.update(updateWrapper);
}
/**
* 扣减用户积分项
*
* @param totalPoint 要花费的总积分
* @param userId 用户id
* @param recordId 记录id
*/
private void handleUserPointItemDeduct(Integer totalPoint, String userId, String recordId) {
//查询用户积分项按过期时间排序
List<MemberPointItem> memberPointItems = memberPointItemService.getByUserIdOrderAscByExpireTime(userId);
//遍历用户积分项,扣减积分
List<MemberPointRecordRelation> relations = new ArrayList<>();
List<MemberPointItem> updatePoints = new ArrayList<>();
for (MemberPointItem item : memberPointItems) {
if (totalPoint <= 0) {
break;
}
//当前积分项需要使用的积分
int usePoint;
//如果需要使用的积分大于当前积分项
if (totalPoint >= item.getPoint()) {
totalPoint -= item.getPoint();
//使用到的积分为积分项的积分数
usePoint = item.getPoint();
item.setPoint(0);
//设置积分项状态为用光
item.setStatus(MemberPointItemStatusEnum.EXHAUSTED.getValue());
} else {
//剩余积分
int remainPoint = item.getPoint() - totalPoint;
//使用到的积分为剩余的花费积分数
usePoint = totalPoint;
//设置剩余积分
item.setPoint(remainPoint);
totalPoint = 0;
}
//创建关联记录实体类,为了实现积分退还功能,简单的member_point_record_relation表的入库操作
MemberPointRecordRelation relation = handleCreatePointRecordRelationDO(item.getId(), recordId, usePoint);
relations.add(relation);
updatePoints.add(item);
}
//存储
memberPointItemService.updateBatchById(updatePoints, 100);
memberPointRecordRelationService.saveBatch(relations, 100);
}
/**
* 查询用户积分项按过期时间排序
* @param userId 用户id
*/
public List<MemberPointItem> getByUserIdOrderAscByExpireTime(String userId) {
LambdaQueryWrapper<MemberPointItem> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(MemberPointItem::getUserId, userId)
//查询状态是可以使用的积分项
.eq(MemberPointItem::getStatus, MemberPointItemStatusEnum.NORMAL.getValue());
queryWrapper.orderByAsc(MemberPointItem::getExpireTime);
return memberPointItemMapper.selectList(queryWrapper);
}
退还积分
/**
* 根据订单号退还积分
*
* @param orderId 订单号
*/
@Override
@Transactional(rollbackFor = Exception.class)
@RedissonLock(name = "member:point", key = "#userId")
public void refund(String userId, String orderId) {
//根据订单id查询记录
MemberPointRecord pointRecord = memberPointRecordManager.getByOrderId(orderId);
if (Objects.isNull(pointRecord)) {
throw MemberException.buildServiceException(MemberExceptionResponseCode.ORDER_NOT_POINT_DEDUCT);
}
//生成退还记录
memberPointRecordManager.create(pointRecord.getUserId(), pointRecord.getPoint(), MemberPointTypeEnums.REFUND.getValue(), orderId);
//根据积分记录退还积分
refundPoint(pointRecord);
}
/**
* 根据积分记录退还积分
*
* @param pointRecord 积分记录
*/
public void refundPoint(MemberPointRecord pointRecord) {
//根据记录id查询该记录都花费了哪些积分,并退回
List<MemberPointRecordRelation> relations = memberPointRecordRelationService.getByRecordId(pointRecord.getId());
Map<String, MemberPointRecordRelation> relationMap = relations.stream().collect(Collectors.toMap(MemberPointRecordRelation::getPointItemId, Function.identity()));
Set<String> pointIds = relations.stream().map(MemberPointRecordRelation::getPointItemId).collect(Collectors.toSet());
//查询使用过的积分项
List<MemberPointItem> memberPointItems = memberPointItemService.listByIds(pointIds);
for (MemberPointItem item : memberPointItems) {
MemberPointRecordRelation relation = relationMap.get(item.getId());
//更新关系表状态为返还
relation.setStatus(MemberPointRecordRelationTypeEnum.REFUND.getValue());
//积分项积分加回使用掉的积分
item.setPoint(item.getPoint() + relation.getUsePoint());
//如果状态是不是正常,那么更新状态为正常
if (!Objects.equals(item.getStatus(), MemberPointItemStatusEnum.NORMAL.getValue())) {
item.setStatus(MemberPointItemStatusEnum.NORMAL.getValue());
}
//过期积分处理,状态设置为已过期,生成积分过期记录
...
}
memberPointItemService.updateBatchById(memberPointItems, 100);
memberPointRecordRelationService.updateBatchById(relations, 100);
//加回用户积分
memberService.refundUserPoint(pointRecord.getUserId(), pointRecord.getPoint());
}
@Override
public void refundUserPoint(String userId, int point) {
LambdaUpdateWrapper<Member> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(Member::getUserId, userId)
.setSql("avail_point = avail_point + " + point);
this.update(updateWrapper);
}
在功能实现时,会有并发问题,可能会造成少消费,多退还等问题,这时候就需要加锁来解决了。
@RedissonLock是自定义的一个分布式锁的切面,网上的实现也比较多。在使用时需要注意与@Transactional之间的顺序问题。
如果分布式锁比事务先一步释放,那么另一线程得到分布式锁进入方法,由于上一线程的事务还未提交,得到的数据还是未提交时的数据,读取的数据并不是最新的。分布式锁的使用也就出现了问题。
@Transactional的Order为Ordered.LOWEST_PRECEDENCE,即最低优先级,只要保证@Order的值比它小即可。
同样也要考虑一下锁的租期问题,如果在锁的租期内方法没有完成,第二个线程同样也会获取锁,进入方法。在上锁的时间上也要进行考虑。使用Redisson的话也可以不传入过期时间,Redisson有WatchDog机制,会触发自动续期。
至此一个积分功能就大体实现了。除了上述提到的功能还有积分项的过期任务,积分获取规则、过期规则、抵扣规则配置等需要实现。如果有其他方案,或者文中实现有问题,欢迎讨论和指出。
转载自:https://juejin.cn/post/7225797036041748537