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,
|
void addPoint(Long userId, @Min(value = 1L, message = "积分必须是正数") Integer point,
|
||||||
Integer bizType, String bizId);
|
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 jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
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);
|
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
|
@Override
|
||||||
public void reducePoint(Long userId, Integer point, Integer bizType, String bizId) {
|
public void reducePoint(Long userId, Integer point, Integer bizType, String bizId) {
|
||||||
Assert.isTrue(point > 0);
|
Assert.isTrue(point > 0);
|
||||||
|
|||||||
@@ -39,4 +39,11 @@ public interface MemberPointRecordMapper extends BaseMapperX<MemberPointRecordDO
|
|||||||
.orderByDesc(MemberPointRecordDO::getId));
|
.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 业务编号
|
* @param bizId 业务编号
|
||||||
*/
|
*/
|
||||||
void createPointRecord(Long userId, Integer point, MemberPointBizTypeEnum bizType, String 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);
|
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