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")));
+ }
+}