商城积分系统的代码实现(上)-- 积分账户及收支记录

news2024/12/26 21:37:15

一、背景

上一系列文章,我们说了积分的数模设计及接口设计,接下里,我们将梳理一下具体的代码实现。

使用的语言的java,基本框架是spring-boot,持久化框架则是Jpa。

使用到的技术点有:

  • 分布式锁(积分发放和消耗,在分布式场景下,防止网络重试带来的重复请求问题)
  • 乐观锁(更新积分账户表和积分订单表)
  • 事件驱动机制(积分账户遇有变更,及时通知用户,发送消息提醒)

限于篇幅,我们将分文两篇来讲:

  • 积分账户及收支记录
  • 积分订单的退款和结算

二、积分的收入

用户能够通过三种途径获得积分,发放的核心逻辑是一致的,但三者的前置校验不同。

所以,我们定义三个不同的方法,各自校验完成,统一调用发放积分的方法。

因为涉及多次操作数据库,需要开启事务,并且修改事务的隔离级别为Isolation.READ_COMMITTED,见下代码。(原因见后文的乐观锁实现)

  • 购买虚拟货币/积分
@Lock(name = POINTS_DISTRIBUTE_LOCK_PRE, key = "'buy:orderNo:' + #orderNo")
    @Transactional(rollbackFor = Throwable.class, isolation = Isolation.READ_COMMITTED)
    public Long grantByBuy(Integer schoolId, Long userId, String pointsType, Integer points,
                           String orderNo, String remark, String token) {
        // 查询是否已经发放过,防止重复
        boolean syncSuccess = this.syncPointsOrder(orderNo, schoolId, userId, pointsType, points);
        if (!syncSuccess) {
            return null;
        }

        return this.grant(schoolId, userId, PointsChannelEnum.BUY.getCode(), PointsChannelEnum.BUY.getName(),
                pointsType, points,
                orderNo, null, remark, token);
    }

  • 手动发放积分
    @Lock(name = POINTS_DISTRIBUTE_LOCK_PRE, key = "'artificial:userId:' + #userId")
    @Transactional(rollbackFor = Throwable.class, isolation = Isolation.READ_COMMITTED)
    public Long grantByArtificial(Integer schoolId, Long userId,
                                  String pointsType, Integer points,
                                  String optUserId, String remark, String token) {
        return this.grant(schoolId, userId, PointsChannelEnum.GRANT_BY_HAND.getCode(),
                PointsChannelEnum.GRANT_BY_HAND.getName(), pointsType, points,
                null, optUserId, remark, token);
    }

  • 做任务,获得积分奖励
    @Lock(name = POINTS_DISTRIBUTE_LOCK_PRE, key = "'grant:userId:' + #userId + ':channelCode:' + #channelCode")
    @Transactional(rollbackFor = Throwable.class, isolation = Isolation.READ_COMMITTED)
    public Long grantByChannel(Integer schoolId, Long userId,
                               String channelCode, String pointsType,
                               String remark, String token) {
        // 校验channelCode
        PointsChannel pointsChannel = pointsChannelRepository.findByCodeAndPointsType(channelCode, pointsType);
        Precondition.notNull(pointsChannel, "积分渠道[%s]未配置", channelCode);
        Precondition.isTrue(pointsChannel.getRewardPoints() > 0, "积分渠道配置的积分数必须大于0");

        return this.grant(schoolId, userId,
                channelCode, pointsChannel.getName(), pointsType, pointsChannel.getRewardPoints(),
                null, null, remark, token);
    }

1、发放积分

除了校验token不能重复使用外,第一步是增加账户的余额,第二步是保存账户的收支记录,第三步是异步通知操作(提醒用户,积分账户有变更,因为非主流程,所以异步,这里采用事件驱动机制)。

private Long grant(Integer schoolId, Long userId,
                       String channelCode, String channelName,
                       String pointsType, Integer rewardPoints,
                       String orderNo, String optUserId, String remark, String token) {
        if (log.isInfoEnabled()) {
            log.info("开始发放积分, 入参列表:[schoolId={}, userId={}, channelCode={}, pointsType={}, rewardPoints={}, " +
                            "orderNo={}, optUserId={}, remark={}, token={}]",
                    schoolId, userId, channelCode, pointsType, rewardPoints, orderNo, optUserId, remark, token);
        }
        // 校验token不能重复使用(略)

        //1.账户增加余额
        PointsAccount pointsAccount = pointsAccountService.findPointsAccount(schoolId, userId, pointsType);
        if (null == pointsAccount) {
            // 创建账户
            pointsAccountService.save(schoolId, userId, pointsType, rewardPoints);
        } else {
            boolean updateSuccess = this.optimisticUpdateAccount(GRANT_POINTS_ACCOUNT,
                    pointsAccount.getId(), pointsAccount.getPoints(),
                    rewardPoints, pointsAccount.getVersion());

            if (!updateSuccess) {
                if (log.isWarnEnabled()) {
                    log.warn("发放积分出现错误, [schoolId={}, userId={}, channelCode={}, pointsType={}, rewardPoints={}]",
                            schoolId, userId, channelCode, pointsType, rewardPoints);
                }

                Precondition.isTrue(false, "发放积分给用户%d出现错误", userId);
            }
        }
        //2.保存账户变更记录
        PointsAccountFlow flow = pointsAccountFlowService.savePointsAccountFlow(FlowTypeEnum.INCREASE,
                schoolId, userId, pointsType,
                rewardPoints, channelCode, channelName,
                orderNo, optUserId, remark);

        // 3. 发布异步事件,提醒用户其账户有变更。(略)

        if (log.isInfoEnabled()) {
            log.info("完成发放积分, [schoolId={}, userId={}, channelCode={}, pointsType={}, rewardPoints={}, " +
                            "orderNo={}, optUserId={}, remark={}, token={}]",
                    schoolId, userId, channelCode, pointsType, rewardPoints, orderNo, optUserId, remark, token);
        }
        return flow.getId();
    }

2、乐观锁实现账户的变更

乐观更新账户, 重试N次,如果更新失败,则再次查询DB最新数据。
我们使用的是mysql数据库,其默认隔离级别是可重复读,所以上文需要指定方法的隔离级别是Isolation.READ_COMMITTED,否则在同一个事务中,读取不到其他事务提交的最新数据。

这是关于数据库的隔离级别,第二点,因为我们使用的jpa持久化框架,它有着著名的一级缓存和二级缓存;所以我们需要手动清除其一级缓存。

    @Autowired
    private EntityManager entityManager;
    
    //清除jpa一级缓存
    entityManager.clear();

第三点,我们在update行记录的时候,判断version是否一致。

  • optimisticUpdateAccount()
	private boolean optimisticUpdateAccount(int optType, long accountId, int points, int thisPoints, long version) {
        int time = 0;
        boolean success = false;
        while (time < MAX_RETRY_TIME) {
            int result = 0;

            switch (optType) {
                // 这两种情况是增加余额
                // 发放积分
                case GRANT_POINTS_ACCOUNT:
                // 回退积分
                case ROLLBACK_POINTS_ACCOUNT:
                    result = pointsAccountService.updateAccountPoints(accountId,
                            points + thisPoints,
                            version);
                    break;
                    
                // 这两种情况是减少余额
                // 使用积分
                case USE_POINTS_ACCOUNT:
                // 积分订单的退款
                case REFUND_POINTS_ACCOUNT:
                    result = pointsAccountService.updateAccountPoints(accountId,
                            points - thisPoints,
                            version);
                    break;
                default:
                    break;
            }

            if (result == 1) {
                success = true;
                break;
            }

            //清除jpa一级缓存
            entityManager.clear();

            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                log.error("乐观锁更新账户余额中的sleep出现异常", e);
            }

            PointsAccount pointsAccount = pointsAccountService.findPointsAccount(accountId);
            Precondition.notNull(pointsAccount, "积分账户不存在");

            version = pointsAccount.getVersion();

            points = pointsAccount.getPoints();

            time++;
        }
        return success;
    }
  • modifyAccountPoints()
    @Modifying
    @Query(value = "update PointsAccount set points = :points, version = version + 1, modifiedDate = now() " +
            " where id = :id and version = :oldVersion ")
    int modifyAccountPoints(@Param("id") long id,
                            @Param("points") int points,
                            @Param("oldVersion") long oldVersion);

在这里插入图片描述

三、消耗积分

分为四步:

  • 1、更新账户的余额,保证此次消耗的积分是小于等于账户的余额
  • 2、保存账户变更记录
  • 3、发布异步事件,通知用户其账户变更
  • 4、更新积分订单表:已使用积分数、可用积分数、可结算积分数

关于积分订单表的更新,见下一篇文章。

@Lock(name = POINTS_DISTRIBUTE_LOCK_PRE, key = "'use:orderNo:' + #orderNo")
    @Transactional(rollbackFor = Throwable.class, isolation = Isolation.READ_COMMITTED)
    public void use(Integer schoolId, Long userId, String orderNo, String pointsType, Integer points, String remark) {
        if (log.isInfoEnabled()) {
            log.info("开始消费积分, 入参列表:[schoolId={}, userId={}, orderNo={}, pointsType={}, points={}, remark={}]",
                    schoolId, userId, orderNo, pointsType, points, remark);
        }

        //1.更新账户的余额
        this.updateAccount(USE_POINTS_ACCOUNT, schoolId, userId, pointsType, points);

        //2.保存账户变更记录
        pointsAccountFlowService.savePointsAccountFlow(FlowTypeEnum.DECREASE,
                schoolId, userId,
                pointsType, points,
                PointsChannelEnum.USE.getCode(), PointsChannelEnum.USE.getName(),
                orderNo, null,
                remark);

         //3.发布异步事件,通知用户其账户变更(略)


        //4.更新积分订单表中的已使用积分数和可用积分数以及可结算积分数
        // 根据userId/schoolId/pointsType查询可用的的积分,按时间先后顺序扣减订单的可用积分数
        this.updatePointsOrderByUse(schoolId, userId, pointsType, points);

        if (log.isInfoEnabled()) {
            log.info("完成消费积分, [schoolId={}, userId={}, orderNo={}, pointsType={}, points={}, remark={}]",
                    schoolId, userId, orderNo, pointsType, points, remark);
        }
    }

四、积分的回退

当商品的定价是纯积分方式,或者积分+现金的组合方式,这类商品发生退款后,我们需要把用户消耗的积分回退其账户。

所谓积分的回退,相当于给用户再次发放等量的积分。

@Lock(name = POINTS_DISTRIBUTE_LOCK_PRE, key = "'rollback:orderNo:' + #orderNo")
    @Transactional(rollbackFor = Throwable.class, isolation = Isolation.READ_COMMITTED)
    public void rollback(Integer schoolId, Long userId, String orderNo, String pointsType, Integer points) {
        if (log.isInfoEnabled()) {
            log.info("开始回退积分, 入参列表:[schoolId={}, userId={}, orderNo={}, pointsType={}, points={}]",
                    schoolId, userId, orderNo, pointsType, points);
        }

        // 仅检查账户是否存在
        PointsAccount pointsAccount = pointsAccountService.findPointsAccount(schoolId, userId, pointsType);
        Precondition.isTrue(null != pointsAccount, "积分账户[%d]不存在", userId);

        //1.把扣除的积分回退到用户的账户余额里
        boolean updateSuccess = this.optimisticUpdateAccount(ROLLBACK_POINTS_ACCOUNT,
                pointsAccount.getId(), pointsAccount.getPoints(),
                points, pointsAccount.getVersion());

        if (!updateSuccess) {
            if (log.isWarnEnabled()) {
                log.warn("回退积分出现错误, [schoolId={}, userId={}, orderNo={}, pointsType={}, points={}]",
                        schoolId, userId, orderNo, pointsType, points);
            }

            Precondition.isTrue(false, "回退用户[%d]的积分出现错误", userId);
        }

        //2.保存账户变更记录
        pointsAccountFlowService.savePointsAccountFlow(FlowTypeEnum.INCREASE,
                schoolId, userId, pointsType,
                points, PointsChannelEnum.CANCEL_ORDER.getCode(), PointsChannelEnum.CANCEL_ORDER.getName(),
                orderNo, null, "订单号[" + orderNo + "]取消");

        //3.发布异步事件,通知用户其账户变更(略)

        if (log.isInfoEnabled()) {
            log.info("完成回退积分, [schoolId={}, userId={}, orderNo={}]", schoolId, userId, orderNo);
        }
    }

五、总结

本文详细介绍了积分操作的五个方法,总体的实现逻辑都是更新账户的余额、保存账户的收支记录、最后通知用户其账户余额有变更。

无非是他们的校验逻辑不一样罢了,所以逻辑实现的方法必须复用。

消耗积分和积分的回退,区别有两点:

  • 1、是否更新积分订单表
  • 2、前者是减少账户的余额,后者是增加账户的余额。

后文,我们将梳理积分订单的实现。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1874050.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

AI大模型日报#0628:谷歌开源9B 27B版Gemma2、AI首次实时生成视频、讯飞星火4.0发布

导读&#xff1a;AI大模型日报&#xff0c;爬虫LLM自动生成&#xff0c;一文览尽每日AI大模型要点资讯&#xff01;目前采用“文心一言”&#xff08;ERNIE-4.0-8K-latest&#xff09;生成了今日要点以及每条资讯的摘要。欢迎阅读&#xff01;《AI大模型日报》今日要点&#xf…

抗击.michevol勒索病毒:保障数据安全的新策略

导言&#xff1a; 在今天高度互联的数字化环境中&#xff0c;数据安全面临着越来越复杂和普遍的威胁&#xff0c;勒索病毒如.michevol已成为了用户和企业普遍面临的风险。本文91数据恢复将探讨.michevol勒索病毒的特点、感染方式以及创新的防御策略&#xff0c;旨在帮助读者更…

九、(正点原子)Linux定时器

一、Linux中断简介 1、中断号 每个中断都有一个中断号&#xff0c;通过中断号即可区分不同的中断&#xff0c;有的资料也把中断号叫做中断线。在 Linux 内核中使用一个 int 变量表示中断号。在Linux中&#xff0c;我们可以使用已经编写好的API函数来申请中断号&#xff0c;定义…

快手主播李香周助力推动 K-beauty风潮谈背后成功秘诀

近年来&#xff0c;互联网的迅速发展和SNS社交媒体的普及&#xff0c;人们通过网络可以随时随地对自己感兴趣的自由畅谈和学习。而直播带货更是作为一种依托于互联网兴起的新型营销方式&#xff0c;凭借其价格优势和新颖的介绍方式为消费者带来了十分便捷的购物体验。 本期采访…

【shell脚本速成】python安装脚本

文章目录 案例需求应用场景解决问题脚本思路案例代码 &#x1f308;你好呀&#xff01;我是 山顶风景独好 &#x1f388;欢迎踏入我的博客世界&#xff0c;能与您在此邂逅&#xff0c;真是缘分使然&#xff01;&#x1f60a; &#x1f338;愿您在此停留的每一刻&#xff0c;都沐…

①常用API----Math

public static int abs(int a) // 返回参数的绝对值 public static double ceil(double a) // 返回大于或等于参数的最小整数 public static double floor(double a) // 返回小于或等于参数的最大整数 public static int round(f…

数据库调优厂商 OtterTune 宣布停止运营

昨天刷到消息&#xff0c;得知数据库优化厂商 OtterTune 停止了运营。OtterTune 的成员主要来自 CMU Andy Pavlo 教授领导的数据库实验室。公司正式成立于 2021 年 5 月&#xff0c;融资了 1450 万美金。 按照 Andy 教授的说法&#xff0c;公司是被一个收购 offer 搞砸了。同时…

pcr实验室和P2实验室装修设计中的区别

PCR实验室和P2实验室在装修设计的区别是什么&#xff1f;PCR实验室指的是基因扩增实验室&#xff0c;而P2实验室是指生物安全实验室中的一个分类&#xff0c;是生物安全防护达到二级的实验室。那么PCR实验室和P2实验室装修设计标准是什么&#xff1f;实验室装修公司小编为您详解…

【Python自动化测试】如何才能让用例自动运行完之后,生成一张直观可看易懂的测试报告呢?

小编使用的是unittest的一个扩展HTMLTestRunner 环境准备 使用之前&#xff0c;我们需要下载HTMLTestRunner.py文件 点击HTMLTestRunner后进入的是一个写满代码的网页&#xff0c;小编推荐操作&#xff1a;右键 --> 另存为&#xff0c;文件名称千万不要改 python3使用上述…

.net 奇葩问题调试经历之2——内存暴涨,来自非托管的内存泄露

📢欢迎点赞 :👍 收藏 ⭐留言 📝 如有错误敬请指正,赐人玫瑰,手留余香!📢本文作者:由webmote 原创📢作者格言:新的征程,我们面对的不仅仅是技术还有人心,人心不可测,海水不可量,唯有技术,才是深沉黑夜中的一座闪烁的灯塔序言 这是一个序列文章,请看以往文…

数据库同步最简单的方法

数据库同步到底有咩有简单的方法&#xff0c;有肯定是有的&#xff0c;就看你有咩有缘&#xff0c;看到这篇文章&#xff0c;你就是有缘人。众所周知&#xff0c;数据库同步向来都不是一件简单的事情&#xff0c;它很繁琐&#xff0c;很费精力&#xff0c;很考验经验&#xff0…

Hadoop版本演变、分布式集群搭建

Hadoop版本演变历史 Hadoop发行版非常的多&#xff0c;有华为发行版、Intel发行版、Cloudera Hadoop(CDH)、Hortonworks Hadoop(HDP)&#xff0c;这些发行版都是基于Apache Hadoop衍生出来的。 目前Hadoop经历了三个大的版本。 hadoop1.x&#xff1a;HDFSMapReduce hadoop2.x…

ai智能语音机器人在电销里发挥怎样的作用

得益于语音识别技术的的进步&#xff0c;人工智能发展越来越成熟。相信作为企业的管理者&#xff0c;都遇到过这样的事&#xff1a;一个电销新人刚刚入行&#xff0c;需求经过一两个月的学习培训才能成为一名合格的销售人员。在这段学习的期间&#xff0c;企业投入的成本是没有…

国际数字影像产业园创业培训,全面提升创业能力!

国际数字影像产业园作为数字影像产业的创新高地&#xff0c;致力于提供全面的创业支持服务。其中&#xff0c;创业培训作为重要的组成部分&#xff0c;旨在通过系统的课程设置和专业的讲师团队&#xff0c;为创业者提供从基础到进阶的全方位指导&#xff0c;帮助他们在数字影像…

技巧类题目

目录 技巧类题目 136 只出现一次的数字 191 位1的个数 231. 2 的幂 169 多数元素 75 颜色分类 &#xff08;双指针&#xff09; 287. 寻找重复数 136 只出现一次的数字 给你一个 非空 整数数组 nums &#xff0c;除了某个元素只出现一次以外&#xff0c;其余每个元素均…

深入探索大模型的魅力:前沿技术、挑战与未来展望

目录 一、大模型的前沿技术 二、大模型面临的挑战 三、大模型的未来展望 四、总结 在当今人工智能领域&#xff0c;大模型不仅是一个热门话题&#xff0c;更是推动技术进步的重要引擎。从深度学习的浪潮中崛起&#xff0c;大模型以其卓越的性能和广泛的应用前景&#xff0c…

任务4.8.4 利用Spark SQL实现分组排行榜

文章目录 1. 任务说明2. 解决思路3. 准备成绩文件4. 采用交互式实现5. 采用Spark项目实战概述&#xff1a;使用Spark SQL实现分组排行榜任务背景任务目标技术选型实现步骤1. 准备数据2. 数据上传至HDFS3. 启动Spark Shell或创建Spark项目4. 读取数据5. 数据转换6. 创建临时视图…

CISCN--西南半决赛--pwn

1.vuln 这是主函数&#xff0c;数一下就发现可以溢出最后的0x4008d0 然后会执行到这里&#xff0c;逻辑就是在v0上写shellcode&#xff0c;不过执行写0x10&#xff0c;不够sh&#xff0c;很明显要先read。 以下是exp: from pwn import * context.archamd64 ioprocess(./vuln)…

VRRP简介

定义 虚拟路由冗余协议VRRP&#xff08;Virtual Router Redundancy Protocol&#xff09;通过把几台路由设备联合组成一台虚拟的路由设备&#xff0c;将虚拟路由设备的IP地址作为用户的默认网关实现与外部网络通信。当网关设备发生故障时&#xff0c;VRRP机制能够选举新的网关…

G8 - ACGAN

&#x1f368; 本文为&#x1f517;365天深度学习训练营 中的学习记录博客&#x1f356; 原作者&#xff1a;K同学啊 目录 模型结构 模型结构 之前几期打卡中&#xff0c;已经介绍过GAN CGAN SGAN&#xff0c;而ACGAN属于上述几种GAN的缝合怪&#xff0c;其模型的结构图如下&a…