feat(member): make education point ledger writes idempotent via unique key

This commit is contained in:
2026-08-01 12:18:01 +08:00
parent 04361a5880
commit 891c461aff
7 changed files with 159 additions and 0 deletions

View File

@@ -22,6 +22,16 @@ public interface MemberPointApi {
void addPoint(Long userId, @Min(value = 1L, message = "积分必须是正数") Integer point,
Integer bizType, String bizId);
/**
* 幂等增加用户积分。
*
* <p>调用方必须提供稳定的业务编号。相同用户、业务类型和业务编号只会入账一次。</p>
*
* @return {@code true} 表示本次新入账,{@code false} 表示此前已经入账
*/
boolean addPointOnce(Long userId, @Min(value = 1L, message = "积分必须是正数") Integer point,
Integer bizType, String bizId);
/**
* 减少用户积分
*

View File

@@ -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);

View File

@@ -39,4 +39,11 @@ public interface MemberPointRecordMapper extends BaseMapperX<MemberPointRecordDO
.orderByDesc(MemberPointRecordDO::getId));
}
default boolean existsByBusinessKey(Long userId, Integer bizType, String bizId) {
return selectCount(new LambdaQueryWrapperX<MemberPointRecordDO>()
.eq(MemberPointRecordDO::getUserId, userId)
.eq(MemberPointRecordDO::getBizType, bizType)
.eq(MemberPointRecordDO::getBizId, bizId)) > 0;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
$$;

View File

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