forked from wangziqi/ruoyi-vue-pro
feat(member): make education point ledger writes idempotent via unique key
This commit is contained in:
@@ -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);
|
||||
|
||||
/**
|
||||
* 减少用户积分
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user