商城积分系统的代码实现(上)-- 积分账户及收支记录
一、背景
上一系列文章,我们说了积分的数模设计及接口设计,接下里,我们将梳理一下具体的代码实现。
使用的语言的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) {// 校验channelCodePointsChannel 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持久化框架,它有着著名的一级缓存和二级缓存;所以我们需要手动清除其一级缓存。
@Autowiredprivate 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、前者是减少账户的余额,后者是增加账户的余额。
后文,我们将梳理积分订单的实现。
相关文章:

商城积分系统的代码实现(上)-- 积分账户及收支记录
一、背景 上一系列文章,我们说了积分的数模设计及接口设计,接下里,我们将梳理一下具体的代码实现。 使用的语言的java,基本框架是spring-boot,持久化框架则是Jpa。 使用到的技术点有: 分布式锁…...

【C++进阶9】异常
一、C语言传统的处理错误的方式 终止程序,如assert 如发生内存错误,除0错误时就会终止程序返回错误码 需要程序员自己去查找对应的错误 z如系统的很多库的接口函数都是通 过把错误码放到errno中,表示错误 二、C异常概念 异常:函…...

RecyclerVIew->加速再减速的RecyclerVIew平滑对齐工具类SnapHelper
XML文件 ItemView的XML文件R.layout.shape_item_view <?xml version"1.0" encoding"utf-8"?> <FrameLayout xmlns:android"http://schemas.android.com/apk/res/android"android:layout_width"100dp"android:layout_heig…...

突破SaaS产品运营困境:多渠道运营如何集中管理?
随着数字化时代的到来,SaaS(软件即服务)产品已成为企业日常运营不可或缺的工具。然而,在竞争激烈的市场环境下,SaaS产品运营越来越重视多渠道、多平台布局,以更广泛地触及潜在用户,然而…...

智能语音热水器:置入NRK3301离线语音识别ic 迈向智能家居新时代
一、热水器语音识别芯片开发背景 在科技的今天,人们对于生活品质的追求已不仅仅满足于基本的物质需求,更渴望通过智能技术让生活变得更加便捷、舒适。热水器作为家庭生活中不可或缺的一部分,其智能化转型势在必行。 在传统热水器使用中&#…...

Redis集群部署合集
目录 一. 原理简述 二. 集群配置 2.1 环境准备 2.2 编译安装一个redis 2.3 创建集群 2.4 写入数据测试 实验一: 实验二: 实验三: 实验四: 添加节点 自动分配槽位 提升节点为master: 实验…...
【HDFS】关于Hadoop的IPC.Client类的一些整理
org.apache.hadoop.ipc.Client 类是IPC服务的一个客户端。 IPC请求把一个Writable对象当做参数,返回一个Writable对象当做结果value。 一个IPC服务运行在某个端口上,并且由参数class和value class定义。 Router里的IPC.Client对象就两个 有这样一个类:ClientCache 看名字就…...

Swoole v6 能否让 PHP 再次伟大?
现状 传统的 PHP-FPM 也是多进程模型的的运行方式,但每个进程只能处理完当前请求,才能接收下一个请求。而且对于 PHP 脚本来说,只是接收请求和响应请求,并不参与网络通信。对数据库资源的操作,也是一次请求一次有效&am…...
C++ STL Iterator Adapter
1. std::back_insert_iterator 使用 // back_insert_iterator example #include <iostream> // std::cout #include <iterator> // std::back_insert_iterator #include <vector> // std::vector #include <algorithm> // std::copy…...
android-aidl5
aidl类是实现Manager和Service通信的桥梁。 例如在修改Android Wifi功能的时候看到WifiManager管理WifiService; AIDL是一种android内部进程通信接口的描述语言,通过它我们可以定义进程间的通信接口。 比如onclick(),用oneway修…...

day01-项目介绍及初始化-登录页
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 文章目录 day01-项目介绍及初始化-登录页一、人力资源项目介绍1.1项目架构和解决方案主要模块解决的问题 二、拉取项目基础代码1.引入库2.升级core-js版本到3.25.5按照完整依…...

华为开发者大会:全场景智能操作系统HarmonyOS NEXT
文章目录 一、全场景智能操作系统 - HarmonyOS NEXT1.1 系统特性1.2 关于架构、体验和生态 二、应用案例2.1 蚂蚁mpaas平台的性能表现 三、新版本应用框架发布3.1 新语言发布3.2 新数据库发布3.3 新版本编译器的发布 四、CodeArts和DataArts4.1 CodeArts4.2 DataArts 五、总结 …...
深度学习二分类评估详细解析与代码实战
深度学习二分类的实战代码:使用 Trainer API 微调模型. https://huggingface.co/learn/nlp-course/zh-CN/chapter3/3 如果你刚接触 自然语言处理,huggingface 是你绕不过去的坎。但是目前它已经被墙了,相信读者的实力,自行解决吧。…...
c++笔记容器详细介绍
C标准库提供了多种容器来存储和管理数据。这些容器属于<vector>, <list>, <deque>, <map>, <set>, <unordered_map>, <unordered_set>等头文件中。这些容器各有优缺点,适用于不同的场景。下面详细介绍几种主要的容器及其…...

CS144 Lab3 TCPSender复盘
一.基础概念 1.TCPSender在TCPSocket中的地位与作用 Lab0中实现了基于内存模拟的流控制-字节流(ByteStream),底层使用std::deque实现,根据最大容量Capacity进行容量控制。个人理解它相当于应用层的输入输出缓存区,用户…...

建筑可视化中使用云渲染的几大理由
在建筑行业中,可视化技术已成为不可或缺的一部分。无论是设计方案的展示、施工进度的模拟,还是最终效果的呈现,建筑可视化都发挥着至关重要的作用。 建筑可视化是指通过计算机技术和图形学算法,将建筑设计、规划和施工过程中的数据…...

Python数据可视化-地图可视化
1.首先绘制实现数据可视化的思维导图 具体要实现什么功能-怎么处理,先把思路写好 数据来源: 爬取的数据 运行结果: 部分代码: 完整代码请在下方↓↓↓👇获取 转载请注明出处!...

leetcode 动态规划(基础版)单词拆分
题目: 题解: 一种可行的dp做法是基于完全背包问题,将s看成是一个背包,wordDict看作是物品,然后往s中放入物品判断最终是否可以变为给定的s即可。这道题和上一题都用到了在dp如何枚举连续子串和状态表示:枚…...

Ubuntu/Linux调试安装南京来可CAN卡
准备好USB rules文件和can driver文件备用! 必做:放置USB rules文件到对应位置处理权限问题 而后:安装内核driver并编译。需求众多依赖编译环境,视情况安装填补。如GCC,G,make等等 进入对应64bit文件夹中,添加权限,执…...
vue2+TS获取到数据后自动叫号写法
1.父组件写法 初始化: //引入子组件 <odialog ref"odialogRef" onSure"onSurea"></odialog> //子传父private onSurea() {// 初始化信息/重新叫号来的数据this.initTabelData()setTimeout(() > {// 播放声音的数据this.search…...

日语AI面试高效通关秘籍:专业解读与青柚面试智能助攻
在如今就业市场竞争日益激烈的背景下,越来越多的求职者将目光投向了日本及中日双语岗位。但是,一场日语面试往往让许多人感到步履维艰。你是否也曾因为面试官抛出的“刁钻问题”而心生畏惧?面对生疏的日语交流环境,即便提前恶补了…...

docker详细操作--未完待续
docker介绍 docker官网: Docker:加速容器应用程序开发 harbor官网:Harbor - Harbor 中文 使用docker加速器: Docker镜像极速下载服务 - 毫秒镜像 是什么 Docker 是一种开源的容器化平台,用于将应用程序及其依赖项(如库、运行时环…...

MongoDB学习和应用(高效的非关系型数据库)
一丶 MongoDB简介 对于社交类软件的功能,我们需要对它的功能特点进行分析: 数据量会随着用户数增大而增大读多写少价值较低非好友看不到其动态信息地理位置的查询… 针对以上特点进行分析各大存储工具: mysql:关系型数据库&am…...

深入理解JavaScript设计模式之单例模式
目录 什么是单例模式为什么需要单例模式常见应用场景包括 单例模式实现透明单例模式实现不透明单例模式用代理实现单例模式javaScript中的单例模式使用命名空间使用闭包封装私有变量 惰性单例通用的惰性单例 结语 什么是单例模式 单例模式(Singleton Pattern&#…...

全球首个30米分辨率湿地数据集(2000—2022)
数据简介 今天我们分享的数据是全球30米分辨率湿地数据集,包含8种湿地亚类,该数据以0.5X0.5的瓦片存储,我们整理了所有属于中国的瓦片名称与其对应省份,方便大家研究使用。 该数据集作为全球首个30米分辨率、覆盖2000–2022年时间…...

剑指offer20_链表中环的入口节点
链表中环的入口节点 给定一个链表,若其中包含环,则输出环的入口节点。 若其中不包含环,则输出null。 数据范围 节点 val 值取值范围 [ 1 , 1000 ] [1,1000] [1,1000]。 节点 val 值各不相同。 链表长度 [ 0 , 500 ] [0,500] [0,500]。 …...
代理篇12|深入理解 Vite中的Proxy接口代理配置
在前端开发中,常常会遇到 跨域请求接口 的情况。为了解决这个问题,Vite 和 Webpack 都提供了 proxy 代理功能,用于将本地开发请求转发到后端服务器。 什么是代理(proxy)? 代理是在开发过程中,前端项目通过开发服务器,将指定的请求“转发”到真实的后端服务器,从而绕…...

OPENCV形态学基础之二腐蚀
一.腐蚀的原理 (图1) 数学表达式:dst(x,y) erode(src(x,y)) min(x,y)src(xx,yy) 腐蚀也是图像形态学的基本功能之一,腐蚀跟膨胀属于反向操作,膨胀是把图像图像变大,而腐蚀就是把图像变小。腐蚀后的图像变小变暗淡。 腐蚀…...
【Nginx】使用 Nginx+Lua 实现基于 IP 的访问频率限制
使用 NginxLua 实现基于 IP 的访问频率限制 在高并发场景下,限制某个 IP 的访问频率是非常重要的,可以有效防止恶意攻击或错误配置导致的服务宕机。以下是一个详细的实现方案,使用 Nginx 和 Lua 脚本结合 Redis 来实现基于 IP 的访问频率限制…...

C++ 设计模式 《小明的奶茶加料风波》
👨🎓 模式名称:装饰器模式(Decorator Pattern) 👦 小明最近上线了校园奶茶配送功能,业务火爆,大家都在加料: 有的同学要加波霸 🟤,有的要加椰果…...