likes
comments
collection
share

实现一个用户积分功能

作者站长头像
站长
· 阅读数 15

需求:

用户通过签到、活动、下单等渠道获取积分,积分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机制,会触发自动续期。

至此一个积分功能就大体实现了。除了上述提到的功能还有积分项的过期任务,积分获取规则、过期规则、抵扣规则配置等需要实现。如果有其他方案,或者文中实现有问题,欢迎讨论和指出。