diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApi.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApi.java index e9120a36..6e6616d3 100644 --- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApi.java +++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApi.java @@ -22,6 +22,16 @@ public interface MemberPointApi { void addPoint(Long userId, @Min(value = 1L, message = "积分必须是正数") Integer point, Integer bizType, String bizId); + /** + * 幂等增加用户积分。 + * + *

调用方必须提供稳定的业务编号。相同用户、业务类型和业务编号只会入账一次。

+ * + * @return {@code true} 表示本次新入账,{@code false} 表示此前已经入账 + */ + boolean addPointOnce(Long userId, @Min(value = 1L, message = "积分必须是正数") Integer point, + Integer bizType, String bizId); + /** * 减少用户积分 * diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApiImpl.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApiImpl.java index 8346b33d..b98f3221 100644 --- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApiImpl.java +++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/api/point/MemberPointApiImpl.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.member.service.point.MemberPointRecordService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.dao.DuplicateKeyException; import org.springframework.validation.annotation.Validated; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -36,6 +37,30 @@ public class MemberPointApiImpl implements MemberPointApi { memberPointRecordService.createPointRecord(userId, point, bizTypeEnum, bizId); } + @Override + public boolean addPointOnce(Long userId, Integer point, Integer bizType, String bizId) { + Assert.notBlank(bizId, "幂等积分业务编号不能为空"); + Assert.isTrue(point > 0); + MemberPointBizTypeEnum bizTypeEnum = MemberPointBizTypeEnum.getByType(bizType); + if (bizTypeEnum == null) { + log.error("[addPointOnce][userId({}) point({}) bizType({}) bizId({}) {}]", userId, point, bizType, + bizId, POINT_RECORD_BIZ_NOT_SUPPORT); + return false; + } + try { + // createPointRecord has its own transaction. A unique-key race rolls back both + // the balance update and record insert before it is treated as an idempotent hit here. + memberPointRecordService.createPointRecord(userId, point, bizTypeEnum, bizId); + return true; + } catch (DuplicateKeyException ex) { + if (!memberPointRecordService.existsPointRecord(userId, bizType, bizId)) { + throw ex; + } + log.info("[addPointOnce][教育积分已入账 userId({}) bizType({}) bizId({})]", userId, bizType, bizId); + return false; + } + } + @Override public void reducePoint(Long userId, Integer point, Integer bizType, String bizId) { Assert.isTrue(point > 0); diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/point/MemberPointRecordMapper.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/point/MemberPointRecordMapper.java index d0e2452c..fcaeb89d 100644 --- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/point/MemberPointRecordMapper.java +++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/point/MemberPointRecordMapper.java @@ -39,4 +39,11 @@ public interface MemberPointRecordMapper extends BaseMapperX() + .eq(MemberPointRecordDO::getUserId, userId) + .eq(MemberPointRecordDO::getBizType, bizType) + .eq(MemberPointRecordDO::getBizId, bizId)) > 0; + } + } diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordService.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordService.java index 7e660d5d..ab6f401f 100644 --- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordService.java +++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordService.java @@ -39,4 +39,7 @@ public interface MemberPointRecordService { * @param bizId 业务编号 */ void createPointRecord(Long userId, Integer point, MemberPointBizTypeEnum bizType, String bizId); + + /** 校验指定业务积分是否已经入账。 */ + boolean existsPointRecord(Long userId, Integer bizType, String bizId); } diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java index 8ab559f3..c1f15672 100644 --- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java +++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java @@ -93,4 +93,9 @@ public class MemberPointRecordServiceImpl implements MemberPointRecordService { memberPointRecordMapper.insert(record); } + @Override + public boolean existsPointRecord(Long userId, Integer bizType, String bizId) { + return memberPointRecordMapper.existsByBusinessKey(userId, bizType, bizId); + } + } diff --git a/yudao-module-member/src/main/resources/db/migration/member/V4250__make_education_member_points_idempotent.sql b/yudao-module-member/src/main/resources/db/migration/member/V4250__make_education_member_points_idempotent.sql new file mode 100644 index 00000000..697fdb2f --- /dev/null +++ b/yudao-module-member/src/main/resources/db/migration/member/V4250__make_education_member_points_idempotent.sql @@ -0,0 +1,47 @@ +-- Member owns the point ledger. Education callers use a stable biz_id and rely +-- on this key to make cross-module retries safe. +DO $$ +DECLARE + duplicate_count BIGINT; + existing_unique BOOLEAN; + existing_definition TEXT; + existing_predicate TEXT; +BEGIN + IF to_regclass('member_point_record') IS NULL THEN + RETURN; + END IF; + + SELECT count(*) INTO duplicate_count + FROM ( + SELECT user_id, biz_type, biz_id + FROM member_point_record + WHERE biz_type = 31 AND biz_id IS NOT NULL + GROUP BY user_id, biz_type, biz_id + HAVING count(*) > 1 + ) duplicate_keys; + IF duplicate_count > 0 THEN + RAISE EXCEPTION 'Education member point ledger contains duplicate business keys' + USING ERRCODE = '23505'; + END IF; + + IF to_regclass('uk_member_point_record_education_biz') IS NOT NULL THEN + SELECT i.indisunique, pg_get_indexdef(i.indexrelid), pg_get_expr(i.indpred, i.indrelid) + INTO existing_unique, existing_definition, existing_predicate + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.oid = to_regclass('uk_member_point_record_education_biz'); + IF existing_unique IS DISTINCT FROM TRUE + OR replace(existing_definition, ' ', '') NOT LIKE '%(user_id,biz_type,biz_id)%' + OR existing_predicate IS NULL + OR existing_predicate NOT LIKE '%biz_type = 31%' THEN + RAISE EXCEPTION 'uk_member_point_record_education_biz has incompatible definition' + USING ERRCODE = '23505'; + END IF; + RETURN; + END IF; + + CREATE UNIQUE INDEX uk_member_point_record_education_biz + ON member_point_record (user_id, biz_type, biz_id) + WHERE biz_type = 31 AND biz_id IS NOT NULL; +END; +$$; diff --git a/yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/api/point/MemberPointApiImplTest.java b/yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/api/point/MemberPointApiImplTest.java new file mode 100644 index 00000000..c6016c44 --- /dev/null +++ b/yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/api/point/MemberPointApiImplTest.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.member.api.point; + +import cn.iocoder.yudao.module.member.enums.point.MemberPointBizTypeEnum; +import cn.iocoder.yudao.module.member.service.point.MemberPointRecordService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MemberPointApiImplTest { + + @Mock + private MemberPointRecordService pointRecordService; + private MemberPointApiImpl api; + + @BeforeEach + void setUp() throws Exception { + api = new MemberPointApiImpl(); + Field field = MemberPointApiImpl.class.getDeclaredField("memberPointRecordService"); + field.setAccessible(true); + field.set(api, pointRecordService); + } + + @Test + void idempotentPointReturnsTrueForNewLedgerEntry() { + assertTrue(api.addPointOnce(8L, 10, MemberPointBizType.EDUCATION_LEARNING, "feedback:1")); + verify(pointRecordService).createPointRecord(8L, 10, MemberPointBizTypeEnum.EDUCATION_LEARNING, + "feedback:1"); + } + + @Test + void idempotentPointTreatsUniqueConflictAsAlreadyAwarded() { + doThrow(new DuplicateKeyException("duplicate")).when(pointRecordService) + .createPointRecord(8L, 10, MemberPointBizTypeEnum.EDUCATION_LEARNING, "feedback:1"); + when(pointRecordService.existsPointRecord(8L, MemberPointBizType.EDUCATION_LEARNING, "feedback:1")) + .thenReturn(true); + + assertFalse(api.addPointOnce(8L, 10, MemberPointBizType.EDUCATION_LEARNING, "feedback:1")); + } + + @Test + void unrelatedUniqueConflictIsNotMasked() { + DuplicateKeyException duplicate = new DuplicateKeyException("other constraint"); + doThrow(duplicate).when(pointRecordService) + .createPointRecord(8L, 10, MemberPointBizTypeEnum.EDUCATION_LEARNING, "feedback:1"); + when(pointRecordService.existsPointRecord(8L, MemberPointBizType.EDUCATION_LEARNING, "feedback:1")) + .thenReturn(false); + + org.junit.jupiter.api.Assertions.assertSame(duplicate, org.junit.jupiter.api.Assertions.assertThrows( + DuplicateKeyException.class, + () -> api.addPointOnce(8L, 10, MemberPointBizType.EDUCATION_LEARNING, "feedback:1"))); + } +}