Commit b78102a5c62eeaf1a240be585caa0091da1e5c4b

Authored by YunaiV
1 parent 128a0e3b

fix(member): 防止并发扣减积分透支并修正流水余额

扣减积分时在数据库原子校验余额,更新后重读实际积分写入流水。
补充并发增减、余额边界、缓存失效及事务回滚的 H2 单元测试。

参考:https://gitee.com/zhijiantianya/yudao-cloud/pulls/273
yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java
... ... @@ -97,7 +97,8 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
97 97 Assert.isTrue(incrCount < 0);
98 98 LambdaUpdateWrapper<MemberUserDO> lambdaUpdateWrapper = new LambdaUpdateWrapper<MemberUserDO>()
99 99 .setSql(" point = point + " + incrCount) // 负数,所以使用 + 号
100   - .eq(MemberUserDO::getId, id);
  100 + .eq(MemberUserDO::getId, id)
  101 + .ge(MemberUserDO::getPoint, -(long) incrCount); // 积分足够才扣减,避免并发扣减为负数;使用 long 避免取反溢出
101 102 return update(null, lambdaUpdateWrapper);
102 103 }
103 104  
... ...
yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java
... ... @@ -84,6 +84,7 @@ public class MemberPointRecordServiceImpl implements MemberPointRecordService {
84 84 if (!success) {
85 85 throw exception(USER_POINT_NOT_ENOUGH);
86 86 }
  87 + totalPoint = memberUserService.getUser(userId).getPoint(); // 重新读取变动后的积分,避免并发时使用旧积分
87 88  
88 89 // 3. 增加积分记录
89 90 MemberPointRecordDO record = new MemberPointRecordDO()
... ...
yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.member.service.point;
  2 +
  3 +import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
  4 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  5 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  6 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  7 +import cn.iocoder.yudao.module.member.dal.dataobject.point.MemberPointRecordDO;
  8 +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
  9 +import cn.iocoder.yudao.module.member.dal.mysql.point.MemberPointRecordMapper;
  10 +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
  11 +import cn.iocoder.yudao.module.member.mq.producer.user.MemberUserProducer;
  12 +import cn.iocoder.yudao.module.member.service.user.MemberUserService;
  13 +import cn.iocoder.yudao.module.member.service.user.MemberUserServiceImpl;
  14 +import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
  15 +import cn.iocoder.yudao.module.system.api.social.SocialClientApi;
  16 +import jakarta.annotation.Resource;
  17 +import org.junit.jupiter.api.Test;
  18 +import org.springframework.context.annotation.Import;
  19 +import org.springframework.dao.DataIntegrityViolationException;
  20 +import org.springframework.security.crypto.password.PasswordEncoder;
  21 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
  22 +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
  23 +import org.springframework.transaction.PlatformTransactionManager;
  24 +import org.springframework.transaction.support.TransactionTemplate;
  25 +
  26 +import java.util.ArrayList;
  27 +import java.util.List;
  28 +import java.util.concurrent.CopyOnWriteArrayList;
  29 +import java.util.concurrent.CountDownLatch;
  30 +import java.util.concurrent.ExecutionException;
  31 +import java.util.concurrent.ExecutorService;
  32 +import java.util.concurrent.Executors;
  33 +import java.util.concurrent.Future;
  34 +import java.util.concurrent.TimeUnit;
  35 +
  36 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  37 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  38 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
  39 +import static cn.iocoder.yudao.module.member.enums.ErrorCodeConstants.USER_POINT_NOT_ENOUGH;
  40 +import static cn.iocoder.yudao.module.member.enums.point.MemberPointBizTypeEnum.SIGN;
  41 +import static org.junit.jupiter.api.Assertions.*;
  42 +import static org.mockito.Mockito.*;
  43 +
  44 +/**
  45 + * {@link MemberPointRecordServiceImpl} 的单元测试类
  46 + *
  47 + * @author 芋道源码
  48 + */
  49 +@Import({MemberPointRecordServiceImpl.class, MemberUserServiceImpl.class})
  50 +public class MemberPointRecordServiceImplTest extends BaseDbUnitTest {
  51 +
  52 + @Resource
  53 + private MemberPointRecordServiceImpl memberPointRecordService;
  54 +
  55 + @Resource
  56 + private MemberUserMapper memberUserMapper;
  57 + @Resource
  58 + private MemberPointRecordMapper memberPointRecordMapper;
  59 + @Resource
  60 + private PlatformTransactionManager transactionManager;
  61 +
  62 + @MockitoSpyBean
  63 + private MemberUserService memberUserService;
  64 + @MockitoBean
  65 + private SmsCodeApi smsCodeApi;
  66 + @MockitoBean
  67 + private SocialClientApi socialClientApi;
  68 + @MockitoBean
  69 + private OAuth2TokenCommonApi oauth2TokenApi;
  70 + @MockitoBean
  71 + private PasswordEncoder passwordEncoder;
  72 + @MockitoBean
  73 + private MemberUserProducer memberUserProducer;
  74 +
  75 + // ========== createPointRecord ==========
  76 +
  77 + @Test
  78 + public void testCreatePointRecord_concurrentDecrNotEnough() throws Exception {
  79 + // mock 数据
  80 + MemberUserDO user = createUser(100);
  81 +
  82 + // 调用:两笔并发各扣 80 积分
  83 + List<Throwable> errors = concurrentCreatePointRecord(user.getId(), -80);
  84 + // 断言:两笔都通过了预检查,但只有一笔扣减成功
  85 + verify(memberUserService, times(2)).updateUserPoint(eq(user.getId()), eq(-80));
  86 + assertEquals(1, errors.size());
  87 + assertServiceException(() -> {
  88 + throw errors.get(0);
  89 + }, USER_POINT_NOT_ENOUGH);
  90 + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint());
  91 + // 断言:失败的一笔整体回滚,只有成功的一笔流水
  92 + List<MemberPointRecordDO> records = getPointRecordList(user.getId());
  93 + assertEquals(1, records.size());
  94 + assertEquals(-80, records.get(0).getPoint());
  95 + assertEquals(20, records.get(0).getTotalPoint());
  96 + }
  97 +
  98 + @Test
  99 + public void testCreatePointRecord_concurrentDecr() throws Exception {
  100 + // mock 数据
  101 + MemberUserDO user = createUser(100);
  102 +
  103 + // 调用:两笔并发各扣 40 积分
  104 + List<Throwable> errors = concurrentCreatePointRecord(user.getId(), -40);
  105 + // 断言:流水按实际扣减先后记录变动后的积分
  106 + assertEquals(0, errors.size());
  107 + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint());
  108 + List<MemberPointRecordDO> records = getPointRecordList(user.getId());
  109 + assertEquals(List.of(-40, -40), convertList(records, MemberPointRecordDO::getPoint));
  110 + assertEquals(List.of(60, 20), convertList(records, MemberPointRecordDO::getTotalPoint));
  111 + }
  112 +
  113 + @Test
  114 + public void testCreatePointRecord_concurrentIncr() throws Exception {
  115 + // mock 数据
  116 + MemberUserDO user = createUser(100);
  117 +
  118 + // 调用:两笔并发各加 40 积分
  119 + List<Throwable> errors = concurrentCreatePointRecord(user.getId(), 40);
  120 + // 断言:流水按实际增加先后记录变动后的积分
  121 + assertEquals(0, errors.size());
  122 + assertEquals(180, memberUserMapper.selectById(user.getId()).getPoint());
  123 + List<MemberPointRecordDO> records = getPointRecordList(user.getId());
  124 + assertEquals(List.of(40, 40), convertList(records, MemberPointRecordDO::getPoint));
  125 + assertEquals(List.of(140, 180), convertList(records, MemberPointRecordDO::getTotalPoint));
  126 + }
  127 +
  128 + @Test
  129 + public void testCreatePointRecord_notEnough() {
  130 + // mock 数据
  131 + MemberUserDO user = createUser(20);
  132 +
  133 + // 调用:预检查积分不足,打印日志后直接返回
  134 + memberPointRecordService.createPointRecord(user.getId(), -80, SIGN, randomString());
  135 + // 断言
  136 + verify(memberUserService, never()).updateUserPoint(anyLong(), anyInt());
  137 + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint());
  138 + assertEquals(0, getPointRecordList(user.getId()).size());
  139 + }
  140 +
  141 + @Test
  142 + public void testCreatePointRecord_sameSqlSession() {
  143 + // mock 数据
  144 + MemberUserDO user = createUser(100);
  145 +
  146 + // 调用:同一事务(SqlSession)内,先查询,再变动积分,再查询
  147 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
  148 + MemberUserDO cachedUser = memberUserMapper.selectById(user.getId());
  149 + assertSame(cachedUser, memberUserMapper.selectById(user.getId())); // 命中一级缓存
  150 + assertEquals(100, cachedUser.getPoint());
  151 +
  152 + memberPointRecordService.createPointRecord(user.getId(), -30, SIGN, randomString());
  153 +
  154 + // 断言:UPDATE 清空了一级缓存,查询到自身写入的积分
  155 + MemberUserDO latestUser = memberUserMapper.selectById(user.getId());
  156 + assertNotSame(cachedUser, latestUser);
  157 + assertEquals(70, latestUser.getPoint());
  158 + });
  159 + // 断言:流水使用 UPDATE 后重新读取的积分
  160 + verify(memberUserService, times(2)).getUser(eq(user.getId()));
  161 + List<MemberPointRecordDO> records = getPointRecordList(user.getId());
  162 + assertEquals(1, records.size());
  163 + assertEquals(70, records.get(0).getTotalPoint());
  164 + assertEquals(70, memberUserMapper.selectById(user.getId()).getPoint());
  165 + }
  166 +
  167 + @Test
  168 + public void testCreatePointRecord_insertFailRollback() {
  169 + // mock 数据
  170 + MemberUserDO user = createUser(100);
  171 + // mock 方法:记录每次读取到的积分
  172 + List<Integer> readPoints = new CopyOnWriteArrayList<>();
  173 + doAnswer(invocation -> {
  174 + MemberUserDO result = (MemberUserDO) invocation.callRealMethod();
  175 + readPoints.add(result.getPoint());
  176 + return result;
  177 + }).when(memberUserService).getUser(eq(user.getId()));
  178 +
  179 + // 调用:bizId 为空,违反 biz_id 非空约束,插入流水失败
  180 + assertThrows(DataIntegrityViolationException.class,
  181 + () -> memberPointRecordService.createPointRecord(user.getId(), -30, SIGN, null));
  182 + // 断言:事务内积分已扣减为 70,随插入流水失败一起回滚
  183 + assertEquals(List.of(100, 70), readPoints);
  184 + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint());
  185 + assertEquals(0, getPointRecordList(user.getId()).size());
  186 + }
  187 +
  188 + // ========== MemberUserMapper.updatePointDecr ==========
  189 +
  190 + @Test
  191 + public void testUpdatePointDecr_exact() {
  192 + // mock 数据
  193 + MemberUserDO user = createUser(100);
  194 +
  195 + // 调用,并断言
  196 + assertEquals(1, memberUserMapper.updatePointDecr(user.getId(), -100));
  197 + assertEquals(0, memberUserMapper.selectById(user.getId()).getPoint());
  198 + }
  199 +
  200 + @Test
  201 + public void testUpdatePointDecr_notEnough() {
  202 + // mock 数据
  203 + MemberUserDO user = createUser(100);
  204 +
  205 + // 调用,并断言
  206 + assertEquals(0, memberUserMapper.updatePointDecr(user.getId(), -101));
  207 + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint());
  208 + }
  209 +
  210 + @Test
  211 + public void testUpdatePointDecr_minValue() {
  212 + // mock 数据
  213 + MemberUserDO user = createUser(100);
  214 +
  215 + // 调用,并断言:Integer.MIN_VALUE 取反不溢出,按积分不足处理
  216 + assertEquals(0, memberUserMapper.updatePointDecr(user.getId(), Integer.MIN_VALUE));
  217 + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint());
  218 + }
  219 +
  220 + // ========== 私有方法 ==========
  221 +
  222 + /**
  223 + * 两个线程各自开启事务,都读取到变动前的积分后,再并发变动积分
  224 + *
  225 + * @return 失败线程的异常
  226 + */
  227 + private List<Throwable> concurrentCreatePointRecord(Long userId, Integer point) throws Exception {
  228 + // 首次读取积分后,等待另一个线程也读取完成;UPDATE 后的重新读取不再等待
  229 + CountDownLatch latch = new CountDownLatch(2);
  230 + doAnswer(invocation -> {
  231 + Object result = invocation.callRealMethod();
  232 + latch.countDown();
  233 + assertTrue(latch.await(10, TimeUnit.SECONDS));
  234 + return result;
  235 + }).when(memberUserService).getUser(eq(userId));
  236 +
  237 + ExecutorService executor = Executors.newFixedThreadPool(2);
  238 + try {
  239 + List<Future<?>> futures = new ArrayList<>();
  240 + for (int i = 0; i < 2; i++) {
  241 + futures.add(executor.submit(() -> memberPointRecordService.createPointRecord(
  242 + userId, point, SIGN, randomString())));
  243 + }
  244 + List<Throwable> errors = new ArrayList<>();
  245 + for (Future<?> future : futures) {
  246 + try {
  247 + future.get(30, TimeUnit.SECONDS);
  248 + } catch (ExecutionException ex) {
  249 + errors.add(ex.getCause());
  250 + }
  251 + }
  252 + return errors;
  253 + } finally {
  254 + executor.shutdownNow();
  255 + }
  256 + }
  257 +
  258 + private MemberUserDO createUser(Integer point) {
  259 + MemberUserDO user = MemberUserDO.builder().mobile("15601691300").status(CommonStatusEnum.ENABLE.getStatus())
  260 + .registerIp("127.0.0.1").point(point).build();
  261 + memberUserMapper.insert(user);
  262 + return user;
  263 + }
  264 +
  265 + private List<MemberPointRecordDO> getPointRecordList(Long userId) {
  266 + return memberPointRecordMapper.selectList(new LambdaQueryWrapperX<MemberPointRecordDO>()
  267 + .eq(MemberPointRecordDO::getUserId, userId)
  268 + .orderByAsc(MemberPointRecordDO::getId));
  269 + }
  270 +
  271 +}
... ...
yudao-module-member/src/test/resources/sql/clean.sql
... ... @@ -2,4 +2,5 @@ DELETE FROM &quot;member_user&quot;;
2 2 DELETE FROM "member_address";
3 3 DELETE FROM "member_tag";
4 4 DELETE FROM "member_level";
5   -DELETE FROM "member_group";
6 5 \ No newline at end of file
  6 +DELETE FROM "member_group";
  7 +DELETE FROM "member_point_record";
7 8 \ No newline at end of file
... ...
yudao-module-member/src/test/resources/sql/create_tables.sql
... ... @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS &quot;member_user&quot;
14 14 "email" varchar(50) NULL COMMENT '邮箱',
15 15 "password" varchar(100) NOT NULL DEFAULT '' COMMENT '密码',
16 16 "register_ip" varchar(32) NOT NULL COMMENT '注册 IP',
  17 + "register_terminal" tinyint NULL DEFAULT NULL COMMENT '注册终端',
17 18 "login_ip" varchar(50) NULL DEFAULT '' COMMENT '最后登录IP',
18 19 "login_date" datetime NULL DEFAULT NULL COMMENT '最后登录时间',
19 20 "tag_ids" varchar(255) NULL DEFAULT NULL COMMENT '用户标签编号列表,以逗号分隔',
... ... @@ -112,3 +113,22 @@ CREATE TABLE IF NOT EXISTS &quot;member_brokerage_record&quot;
112 113 "tenant_id" bigint not null default '0',
113 114 PRIMARY KEY ("id")
114 115 ) COMMENT '佣金记录';
  116 +
  117 +CREATE TABLE IF NOT EXISTS "member_point_record"
  118 +(
  119 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  120 + "user_id" bigint NOT NULL,
  121 + "biz_id" varchar(255) NOT NULL,
  122 + "biz_type" tinyint NOT NULL,
  123 + "title" varchar(255) NOT NULL,
  124 + "description" varchar(5000) NULL,
  125 + "point" int NOT NULL,
  126 + "total_point" int NOT NULL,
  127 + "creator" varchar(64) DEFAULT '',
  128 + "create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  129 + "updater" varchar(64) DEFAULT '',
  130 + "update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  131 + "deleted" bit NOT NULL DEFAULT FALSE,
  132 + "tenant_id" bigint NOT NULL default '0',
  133 + PRIMARY KEY ("id")
  134 +) COMMENT '用户积分记录';
... ...