fix:登录错误尝试修改
This commit is contained in:
1 parent
f55561c98d
commit
4518856ccd
9 files changed
+381
-64
No files matched your search
@@ -0,0 +1,155 @@
|
||||
-- 已核查数据的精确修复,兼容 MySQL 5.7 / 8.0。
|
||||
-- 只读核查时间:2026-11-05 15:46:31(北京时间),数据库 chat_v1。
|
||||
-- 当时仅一组重复:member_id=64,VIP(type=4),有效记录 id=210、211。
|
||||
-- member_user.id=64 已逻辑删除;本脚本不修改、不恢复用户。
|
||||
-- 保留210,仅逻辑删除211。不清理其他标签,不执行全库去重或 ALTER。
|
||||
--
|
||||
-- 使用方法:在 chat_v1 的独立会话中执行整份脚本(会话不可有未提交事务)。
|
||||
-- 默认 @apply_tag_repair=0:锁定并核验后回滚,仅预演;不创建备份表。
|
||||
-- 预演返回 PREVIEW_OK 后,把下行改成1,重新执行整份脚本才真正修复。
|
||||
-- 正式修复自动备份两行;字段/数量/用户状态变化,或备份已存在,均报错回滚。
|
||||
-- 请在低流量时执行,并暂停该用户的标签编辑,避免长时间锁等待。
|
||||
-- 正式执行失败可能留下空备份表,须人工核查;脚本不会复用或覆盖任何同名表。
|
||||
SET NAMES utf8mb4;
|
||||
SET @apply_tag_repair = 0;
|
||||
|
||||
DELIMITER $$
|
||||
DROP PROCEDURE IF EXISTS codex_repair_member_tag_211_20261105$$
|
||||
CREATE PROCEDURE codex_repair_member_tag_211_20261105(IN p_apply TINYINT)
|
||||
BEGIN
|
||||
DECLARE v_count INT DEFAULT 0;
|
||||
DECLARE v_active_count INT DEFAULT 0;
|
||||
DECLARE v_changed INT DEFAULT 0;
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
RESIGNAL;
|
||||
END;
|
||||
|
||||
IF DATABASE() <> 'chat_v1' OR DATABASE() IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Wrong database: expected chat_v1';
|
||||
END IF;
|
||||
IF p_apply IS NULL OR p_apply NOT IN (0, 1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'p_apply must be 0 (preview) or 1 (apply)';
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ('member_user', 'member_recent_tag') AND ENGINE = 'InnoDB';
|
||||
IF v_count <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Both business tables must use InnoDB';
|
||||
END IF;
|
||||
|
||||
-- DDL必须放在修复事务之前。仅创建独立备份表,不改业务表结构。
|
||||
-- 明确列定义,避免复制业务表未来的生成列/唯一索引而妨碍备份重复记录。
|
||||
IF p_apply = 1 THEN
|
||||
CREATE TABLE member_recent_tag_backup_20261105_64 (
|
||||
id BIGINT NOT NULL,
|
||||
member_id BIGINT NOT NULL,
|
||||
tag_title VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
tag_biz_id BIGINT NULL,
|
||||
tag_biz_type CHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
|
||||
creator VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
|
||||
create_time DATETIME NOT NULL,
|
||||
updater VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted BIT(1) NOT NULL,
|
||||
tenant_id BIGINT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB COMMENT='20261105用户64重复VIP标签修复前原始记录';
|
||||
END IF;
|
||||
|
||||
-- 显式使用RR范围锁,避免会话处于READ COMMITTED时漏锁新插入的第三条记录。
|
||||
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
|
||||
START TRANSACTION;
|
||||
|
||||
-- 当前读并锁住用户及该组标签。无相关索引时可能锁住较多行,应整段及时执行。
|
||||
SELECT id, deleted + 0 AS user_deleted
|
||||
FROM member_user WHERE id = 64 FOR UPDATE;
|
||||
SELECT id, member_id, tag_biz_type, tag_title, tag_biz_id, tenant_id,
|
||||
creator, create_time, updater, update_time, deleted + 0 AS deleted
|
||||
FROM member_recent_tag
|
||||
WHERE member_id = 64 AND tag_biz_type = '4'
|
||||
ORDER BY id FOR UPDATE;
|
||||
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM member_user WHERE id = 64 AND deleted = b'1';
|
||||
IF v_count <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'User state changed; inspect again';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_active_count
|
||||
FROM member_recent_tag
|
||||
WHERE member_id = 64 AND tag_biz_type = '4' AND deleted = b'0';
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM member_recent_tag
|
||||
WHERE id IN (210, 211)
|
||||
AND member_id = 64
|
||||
AND BINARY tag_biz_type = BINARY '4'
|
||||
AND BINARY tag_title = BINARY 'VIP用户'
|
||||
AND tag_biz_id IS NULL
|
||||
AND tenant_id <=> 1
|
||||
AND BINARY creator <=> BINARY '1'
|
||||
AND BINARY updater <=> BINARY '1'
|
||||
AND create_time = '2026-07-31 18:30:28'
|
||||
AND update_time = '2026-07-31 18:30:28'
|
||||
AND deleted = b'0';
|
||||
IF v_active_count <> 2 OR v_count <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tag snapshot changed or already repaired; inspect again';
|
||||
END IF;
|
||||
|
||||
IF p_apply = 0 THEN
|
||||
ROLLBACK;
|
||||
SELECT 'PREVIEW_OK' AS result, 210 AS keep_tag_id, 211 AS delete_tag_id,
|
||||
1 AS expected_changed_rows, 'No data changed' AS message;
|
||||
ELSE
|
||||
-- 不使用 IGNORE / REPLACE:备份冲突时必须停止,不能覆盖原备份。
|
||||
INSERT INTO member_recent_tag_backup_20261105_64
|
||||
(id, member_id, tag_title, tag_biz_id, tag_biz_type, creator,
|
||||
create_time, updater, update_time, deleted, tenant_id)
|
||||
SELECT id, member_id, tag_title, tag_biz_id, tag_biz_type, creator,
|
||||
create_time, updater, update_time, deleted, tenant_id
|
||||
FROM member_recent_tag WHERE id IN (210, 211);
|
||||
SET v_count = ROW_COUNT();
|
||||
IF v_count <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Backup must contain exactly two rows';
|
||||
END IF;
|
||||
|
||||
UPDATE member_recent_tag
|
||||
SET deleted = b'1', update_time = CURRENT_TIMESTAMP
|
||||
WHERE id = 211 AND member_id = 64 AND tag_biz_type = '4' AND deleted = b'0';
|
||||
SET v_changed = ROW_COUNT();
|
||||
IF v_changed <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Repair must change exactly one row';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM member_recent_tag
|
||||
WHERE (id = 210 AND deleted = b'0') OR (id = 211 AND deleted = b'1');
|
||||
SELECT COUNT(*) INTO v_active_count
|
||||
FROM member_recent_tag
|
||||
WHERE member_id = 64 AND tag_biz_type = '4' AND deleted = b'0';
|
||||
IF v_count <> 2 OR v_active_count <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Post-repair validation failed';
|
||||
END IF;
|
||||
|
||||
COMMIT;
|
||||
SELECT 'REPAIRED' AS result, v_changed AS changed_rows,
|
||||
'member_recent_tag_backup_20261105_64' AS backup_table;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL codex_repair_member_tag_211_20261105(@apply_tag_repair);
|
||||
DROP PROCEDURE codex_repair_member_tag_211_20261105;
|
||||
|
||||
-- 全库复核:正式修复后预期0行。预演则仍返回原来的一组。
|
||||
SELECT member_id, tag_biz_type, COUNT(*) AS active_count,
|
||||
GROUP_CONCAT(id ORDER BY id) AS active_tag_ids
|
||||
FROM member_recent_tag
|
||||
WHERE deleted = b'0'
|
||||
GROUP BY member_id, tag_biz_type
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 本脚本没有安装唯一约束;防止新增重复仍需后端修复及独立的唯一索引迁移。
|
||||
-- 如需撤销,先核对备份与后续变更。唯一索引安装后,不可直接恢复成两条有效记录。
|
||||
+17
-1
@@ -3,6 +3,9 @@ package cn.iocoder.yudao.module.member.dal.mysql.user;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentCfgDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 用户朋友圈相关配置 Mapper
|
||||
@@ -10,4 +13,17 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface RecentCfgMapper extends BaseMapperX<MemberRecentCfgDO> {
|
||||
|
||||
}
|
||||
/**
|
||||
* 历史重复配置取最近修改的一条,保留用户已有的隐私设置。
|
||||
* 每次读取前清理会话缓存,并禁用二级缓存,避免锁内复查沿用锁外的空结果。
|
||||
* 调用方通过 Redis 锁及无事务自动提交防止重复创建;不使用会被 JSqlParser 4.9
|
||||
* 移到 ORDER BY / LIMIT 前面的 FOR UPDATE。
|
||||
*/
|
||||
@Select("SELECT id, member_id, is_push, home_hide, global_hide, privacy_secure, short_note, "
|
||||
+ "create_time, update_time, creator, updater, deleted FROM member_recent_cfg "
|
||||
+ "WHERE deleted = 0 AND member_id = #{memberId} "
|
||||
+ "ORDER BY update_time DESC, id DESC LIMIT 1")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
MemberRecentCfgDO selectLatestByMemberId(@Param("memberId") Long memberId);
|
||||
|
||||
}
|
||||
+6
-16
@@ -67,14 +67,14 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public MemberRecentCfgDO createOrUpdate(MemberRecentCfgReqVo dto) {
|
||||
return memberRecentRedisDAO.lock(dto.getMemberId(), UPDATE_TIMEOUT_MILLIS, () -> {
|
||||
MemberRecentCfgDO detail = selectCurrentConfig(dto.getMemberId(), true);
|
||||
MemberRecentCfgDO detail = recentCfgMapper.selectLatestByMemberId(dto.getMemberId());
|
||||
MemberRecentCfgDO entity = BeanUtil.toBean(dto, MemberRecentCfgDO.class);
|
||||
if (detail == null) {
|
||||
try {
|
||||
recentCfgMapper.insert(entity);
|
||||
return entity;
|
||||
} catch (DuplicateKeyException ex) {
|
||||
detail = selectCurrentConfig(dto.getMemberId(), true);
|
||||
detail = recentCfgMapper.selectLatestByMemberId(dto.getMemberId());
|
||||
if (detail == null) {
|
||||
throw ex;
|
||||
}
|
||||
@@ -102,13 +102,14 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
|
||||
@SneakyThrows
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public MemberRecentCfgDO getOrCreate(Long memberId) {
|
||||
MemberRecentCfgDO detail = selectCurrentConfig(memberId, false);
|
||||
MemberRecentCfgDO detail = recentCfgMapper.selectLatestByMemberId(memberId);
|
||||
if (ObjectUtil.isNotEmpty(detail)) {
|
||||
return detail;
|
||||
}
|
||||
// 单条写入自动提交后才解锁;不能让外层事务保留旧快照或在解锁后提交。
|
||||
return memberRecentRedisDAO.lock(memberId, UPDATE_TIMEOUT_MILLIS, () -> {
|
||||
MemberRecentCfgDO existing = selectCurrentConfig(memberId, true);
|
||||
// Mapper 清理查询缓存后重新查库,不能复用锁外的“无记录”结果。
|
||||
MemberRecentCfgDO existing = recentCfgMapper.selectLatestByMemberId(memberId);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
@@ -122,7 +123,7 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
|
||||
recentCfgMapper.insert(insertObj);
|
||||
return insertObj;
|
||||
} catch (DuplicateKeyException ex) {
|
||||
MemberRecentCfgDO concurrent = selectCurrentConfig(memberId, true);
|
||||
MemberRecentCfgDO concurrent = recentCfgMapper.selectLatestByMemberId(memberId);
|
||||
if (concurrent == null) {
|
||||
throw ex;
|
||||
}
|
||||
@@ -131,17 +132,6 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
|
||||
});
|
||||
}
|
||||
|
||||
private MemberRecentCfgDO selectCurrentConfig(Long memberId, boolean currentRead) {
|
||||
// 历史重复配置优先保留最近修改的隐私设置,不用默认值覆盖用户选择。
|
||||
// NOT_SUPPORTED 仍可能绑定 MyBatis SqlSession。锁内使用不同 SQL 做当前读,
|
||||
// 避免复用锁外查询缓存的“无记录”;写入仍在当前无事务连接上自动提交。
|
||||
return recentCfgMapper.selectOne(new LambdaQueryWrapperX<MemberRecentCfgDO>()
|
||||
.eq(MemberRecentCfgDO::getMemberId, memberId)
|
||||
.orderByDesc(MemberRecentCfgDO::getUpdateTime)
|
||||
.orderByDesc(MemberRecentCfgDO::getId)
|
||||
.last(currentRead ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询首页隐身的用户ids
|
||||
*/
|
||||
|
||||
+2
-1
@@ -137,8 +137,9 @@ public class MemberRecentTagServiceImp implements MemberRecentTagService {
|
||||
return memberRecentTagMapper.insert(tag) > 0;
|
||||
} catch (DuplicateKeyException ex) {
|
||||
// 当前读确认并发插入结果,避免外层 REPEATABLE READ 事务仍读到旧快照。
|
||||
// 不附加 LIMIT,避免 JSqlParser 4.9 将它重写成非法的 FOR UPDATE LIMIT 1。
|
||||
if (ObjectUtil.isNotEmpty(memberRecentTagMapper.selectList(tagQuery(memberId, tagType)
|
||||
.select(MemberRecentTagDO::getId).last("LIMIT 1 FOR UPDATE")))) {
|
||||
.select(MemberRecentTagDO::getId).last("FOR UPDATE")))) {
|
||||
return false;
|
||||
}
|
||||
throw ex;
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.member.config;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* 登录数据库测试也经过线上使用的 SQL 重写链路。
|
||||
* 即使没有数据权限条件,DataPermissionInterceptor 仍会解析并重新输出 SQL。
|
||||
*/
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
public class MemberLoginSqlTestConfiguration {
|
||||
|
||||
@Bean
|
||||
public static BeanPostProcessor memberLoginSqlRewritePostProcessor() {
|
||||
return new BeanPostProcessor() {
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) {
|
||||
if (bean instanceof MybatisPlusInterceptor) {
|
||||
MultiDataPermissionHandler handler = (table, where, mappedStatementId) -> null;
|
||||
MyBatisUtils.addInterceptor((MybatisPlusInterceptor) bean,
|
||||
new DataPermissionInterceptor(handler), 0);
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+92
-41
@@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.member.client.service.ChatApi;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatLoginReqVO;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatLoginRespVO;
|
||||
import cn.iocoder.yudao.module.member.config.MemberLoginSqlTestConfiguration;
|
||||
import cn.iocoder.yudao.module.member.controller.app.auth.vo.AppAuthLoginRespVO;
|
||||
import cn.iocoder.yudao.module.member.controller.app.auth.vo.AppAuthRegisterRespVO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.config.MemberConfigDO;
|
||||
@@ -22,9 +23,12 @@ import cn.iocoder.yudao.module.system.api.logger.LoginLogApi;
|
||||
import cn.iocoder.yudao.module.system.api.oauth2.OAuth2TokenApi;
|
||||
import cn.iocoder.yudao.module.system.api.oauth2.dto.OAuth2AccessTokenCreateReqDTO;
|
||||
import cn.iocoder.yudao.module.system.api.oauth2.dto.OAuth2AccessTokenRespDTO;
|
||||
import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -37,10 +41,13 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import javax.annotation.Resource;
|
||||
import javax.sql.DataSource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -48,13 +55,16 @@ import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Import({MemberRecentTagServiceImp.class, MemberRecentCfgServiceImpl.class})
|
||||
@Import({MemberRecentTagServiceImp.class, MemberRecentCfgServiceImpl.class, MemberLoginSqlTestConfiguration.class})
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:member_sms_register_regression;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
|
||||
"spring.sql.init.schema-locations=classpath:/sql/create_tables.sql,classpath:/sql/member_recent_tag_login_schema.sql,classpath:/sql/member_recent_cfg_schema.sql"
|
||||
})
|
||||
class MemberAuthServiceImplSmsRegisterDbTest extends BaseDbUnitTest {
|
||||
|
||||
private static final Long USER_ID = 740L;
|
||||
private static final String MOBILE = "13900000740";
|
||||
|
||||
@Resource
|
||||
private MemberRecentTagService memberRecentTagService;
|
||||
@Resource
|
||||
@@ -71,14 +81,63 @@ class MemberAuthServiceImplSmsRegisterDbTest extends BaseDbUnitTest {
|
||||
private MemberRecentRedisDAO memberRecentRedisDAO;
|
||||
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
private MemberUserDO user;
|
||||
private MemberUserService userService;
|
||||
private ChatApi chatApi;
|
||||
private SmsCodeApi smsCodeApi;
|
||||
private MemberAuthServiceImpl authService;
|
||||
private AppAuthRegisterRespVO loginRequest;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
void setUp() throws Exception {
|
||||
jdbcTemplate = new JdbcTemplate(dataSource);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
request.addHeader("terminal", "1");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
ReentrantLock lock = new ReentrantLock();
|
||||
when(memberRecentRedisDAO.lock(anyLong(), anyLong(), any())).thenAnswer(invocation -> {
|
||||
lock.lock();
|
||||
try {
|
||||
return ((Callable<?>) invocation.getArgument(2)).call();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
});
|
||||
|
||||
user = new MemberUserDO().setId(USER_ID).setMobile(MOBILE)
|
||||
.setStatus(CommonStatusEnum.ENABLE.getStatus());
|
||||
user.setCreateTime(LocalDateTime.now().minusDays(1));
|
||||
when(memberUserMapper.selectById(USER_ID)).thenReturn(user);
|
||||
MemberConfigDO config = new MemberConfigDO();
|
||||
config.setNewPersonDays(15);
|
||||
when(memberConfigService.getConfig()).thenReturn(config);
|
||||
|
||||
userService = mock(MemberUserService.class);
|
||||
OAuth2TokenApi oauth2TokenApi = mock(OAuth2TokenApi.class);
|
||||
chatApi = mock(ChatApi.class);
|
||||
smsCodeApi = mock(SmsCodeApi.class);
|
||||
when(userService.getUserByMobile(MOBILE)).thenReturn(user);
|
||||
when(userService.createUserIfAbsent(eq(MOBILE), anyString(), anyInt())).thenReturn(user);
|
||||
when(userService.syncChatUser(USER_ID)).thenReturn(true);
|
||||
when(oauth2TokenApi.createAccessToken(any(OAuth2AccessTokenCreateReqDTO.class)))
|
||||
.thenReturn(new OAuth2AccessTokenRespDTO().setUserId(USER_ID)
|
||||
.setAccessToken("access-token").setRefreshToken("refresh-token")
|
||||
.setExpiresTime(LocalDateTime.now().plusDays(1)));
|
||||
ChatLoginRespVO chatToken = new ChatLoginRespVO();
|
||||
chatToken.setAccessToken("chat-access-token");
|
||||
chatToken.setRefreshToken("chat-refresh-token");
|
||||
when(chatApi.login(any(ChatLoginReqVO.class))).thenReturn(chatToken);
|
||||
authService = new MemberAuthServiceImpl();
|
||||
ReflectionTestUtils.setField(authService, "userService", userService);
|
||||
ReflectionTestUtils.setField(authService, "memberRecentTagService", memberRecentTagService);
|
||||
ReflectionTestUtils.setField(authService, "memberRecentCfgService", memberRecentCfgService);
|
||||
ReflectionTestUtils.setField(authService, "oauth2TokenApi", oauth2TokenApi);
|
||||
ReflectionTestUtils.setField(authService, "loginLogApi", mock(LoginLogApi.class));
|
||||
ReflectionTestUtils.setField(authService, "chatApi", chatApi);
|
||||
ReflectionTestUtils.setField(authService, "smsCodeApi", smsCodeApi);
|
||||
loginRequest = AppAuthRegisterRespVO.builder().mobile(MOBILE).code("123456").build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -90,55 +149,47 @@ class MemberAuthServiceImplSmsRegisterDbTest extends BaseDbUnitTest {
|
||||
|
||||
@Test
|
||||
void smsRegisterReturnsTokensWhenBothNewPersonTagsAndConfigsContainDuplicates() {
|
||||
Long userId = 740L;
|
||||
String mobile = "18900520049";
|
||||
MemberUserDO user = new MemberUserDO().setId(userId).setMobile(mobile)
|
||||
.setStatus(CommonStatusEnum.ENABLE.getStatus());
|
||||
user.setCreateTime(LocalDateTime.now().minusDays(1));
|
||||
when(memberUserMapper.selectById(userId)).thenReturn(user);
|
||||
MemberConfigDO config = new MemberConfigDO();
|
||||
config.setNewPersonDays(15);
|
||||
when(memberConfigService.getConfig()).thenReturn(config);
|
||||
jdbcTemplate.update("INSERT INTO member_recent_tag (member_id, tag_biz_type, tag_title)"
|
||||
+ " VALUES (740, '2', '新人'), (740, '2', '新人')");
|
||||
jdbcTemplate.update("INSERT INTO member_recent_cfg (member_id, home_hide, privacy_secure, update_time)"
|
||||
+ " VALUES (740, FALSE, FALSE, '2026-11-01 10:00:00'),"
|
||||
+ " (740, TRUE, TRUE, '2026-11-02 10:00:00')");
|
||||
|
||||
MemberUserService userService = mock(MemberUserService.class);
|
||||
OAuth2TokenApi oauth2TokenApi = mock(OAuth2TokenApi.class);
|
||||
ChatApi chatApi = mock(ChatApi.class);
|
||||
when(userService.getUserByMobile(mobile)).thenReturn(user);
|
||||
when(userService.createUserIfAbsent(eq(mobile), anyString(), anyInt())).thenReturn(user);
|
||||
when(userService.syncChatUser(userId)).thenReturn(true);
|
||||
when(oauth2TokenApi.createAccessToken(any(OAuth2AccessTokenCreateReqDTO.class)))
|
||||
.thenReturn(new OAuth2AccessTokenRespDTO().setUserId(userId)
|
||||
.setAccessToken("access-token").setRefreshToken("refresh-token")
|
||||
.setExpiresTime(LocalDateTime.now().plusDays(1)));
|
||||
ChatLoginRespVO chatToken = new ChatLoginRespVO();
|
||||
chatToken.setAccessToken("chat-access-token");
|
||||
chatToken.setRefreshToken("chat-refresh-token");
|
||||
when(chatApi.login(any(ChatLoginReqVO.class))).thenReturn(chatToken);
|
||||
MemberAuthServiceImpl authService = new MemberAuthServiceImpl();
|
||||
ReflectionTestUtils.setField(authService, "userService", userService);
|
||||
ReflectionTestUtils.setField(authService, "memberRecentTagService", memberRecentTagService);
|
||||
ReflectionTestUtils.setField(authService, "memberRecentCfgService", memberRecentCfgService);
|
||||
ReflectionTestUtils.setField(authService, "oauth2TokenApi", oauth2TokenApi);
|
||||
ReflectionTestUtils.setField(authService, "loginLogApi", mock(LoginLogApi.class));
|
||||
ReflectionTestUtils.setField(authService, "chatApi", chatApi);
|
||||
AppAuthRegisterRespVO request = AppAuthRegisterRespVO.builder().mobile(mobile).code("8855").build();
|
||||
assertSuccessfulLogin(authService.smsRegister(loginRequest));
|
||||
assertSuccessfulLogin(authService.smsRegister(loginRequest));
|
||||
|
||||
AppAuthLoginRespVO result = authService.smsRegister(request);
|
||||
AppAuthLoginRespVO retried = authService.smsRegister(request);
|
||||
assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_tag", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_cfg", Integer.class)).isEqualTo(2);
|
||||
assertThat(memberRecentCfgService.getOrCreate(USER_ID).getPrivacySecure()).isTrue();
|
||||
verify(userService, times(2)).updateUserLogin(USER_ID, "127.0.0.1");
|
||||
}
|
||||
|
||||
assertThat(result.getUserId()).isEqualTo(userId);
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void smsRegisterInitializesMissingConfigForNewAndExistingUsers(boolean newUser) {
|
||||
if (newUser) {
|
||||
when(userService.getUserByMobile(MOBILE)).thenReturn(null, user);
|
||||
}
|
||||
|
||||
assertSuccessfulLogin(authService.smsRegister(loginRequest));
|
||||
assertSuccessfulLogin(authService.smsRegister(loginRequest));
|
||||
|
||||
assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_cfg WHERE member_id=740",
|
||||
Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_tag WHERE member_id=740",
|
||||
Integer.class)).isEqualTo(1);
|
||||
assertThat(memberRecentCfgService.getOrCreate(USER_ID).getPrivacySecure()).isFalse();
|
||||
verify(smsCodeApi, times(2)).useSmsCode(any());
|
||||
verify(chatApi, times(2)).addMessageFriend(USER_ID);
|
||||
verify(chatApi, times(2)).addServiceFriend(USER_ID);
|
||||
verify(chatApi, times(2)).serviceUserSendUniqueText(eq(USER_ID), anyString());
|
||||
}
|
||||
|
||||
private void assertSuccessfulLogin(AppAuthLoginRespVO result) {
|
||||
assertThat(result.getUserId()).isEqualTo(USER_ID);
|
||||
assertThat(result.getAccessToken()).isEqualTo("access-token");
|
||||
assertThat(result.getRefreshToken()).isEqualTo("refresh-token");
|
||||
assertThat(result.getChatToken().getAccessToken()).isEqualTo("chat-access-token");
|
||||
assertThat(retried.getAccessToken()).isEqualTo("access-token");
|
||||
assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_tag", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_cfg", Integer.class)).isEqualTo(2);
|
||||
assertThat(memberRecentCfgService.getOrCreate(userId).getPrivacySecure()).isTrue();
|
||||
verify(userService, times(2)).updateUserLogin(userId, "127.0.0.1");
|
||||
assertThat(result.getChatToken().getRefreshToken()).isEqualTo("chat-refresh-token");
|
||||
}
|
||||
}
|
||||
+51
-1
@@ -1,11 +1,14 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.member.config.MemberLoginSqlTestConfiguration;
|
||||
import cn.iocoder.yudao.module.member.controller.app.user.vo.recentcfg.MemberRecentCfgReqVo;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentCfgDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.RecentCfgMapper;
|
||||
import cn.iocoder.yudao.module.member.dal.redis.recent.MemberRecentRedisDAO;
|
||||
import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -37,7 +40,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Import(MemberRecentCfgServiceImpl.class)
|
||||
@Import({MemberRecentCfgServiceImpl.class, MemberLoginSqlTestConfiguration.class})
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:member_recent_cfg_regression;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
|
||||
"spring.sql.init.schema-locations=classpath:/sql/create_tables.sql,classpath:/sql/member_recent_cfg_schema.sql"
|
||||
@@ -52,6 +55,8 @@ class MemberRecentCfgServiceImplDbTest extends BaseDbUnitTest {
|
||||
private DataSource dataSource;
|
||||
@Resource
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Resource
|
||||
private SqlSessionFactory sqlSessionFactory;
|
||||
@MockBean
|
||||
private MemberRecentRedisDAO memberRecentRedisDAO;
|
||||
@MockBean
|
||||
@@ -79,6 +84,51 @@ class MemberRecentCfgServiceImplDbTest extends BaseDbUnitTest {
|
||||
jdbc.update("DELETE FROM member_recent_cfg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingConfigIsInitializedOnceAndPreservesOtherMembers() {
|
||||
jdbc.update("INSERT INTO member_recent_cfg (member_id, home_hide, privacy_secure) VALUES (741, TRUE, TRUE)");
|
||||
|
||||
MemberRecentCfgDO config = memberRecentCfgService.getOrCreate(740L);
|
||||
MemberRecentCfgDO retried = memberRecentCfgService.getOrCreate(740L);
|
||||
|
||||
assertThat(config.getId()).isNotNull();
|
||||
assertThat(retried.getId()).isEqualTo(config.getId());
|
||||
assertThat(config.getMemberId()).isEqualTo(740L);
|
||||
assertThat(config.getHomeHide()).isFalse();
|
||||
assertThat(config.getPrivacySecure()).isFalse();
|
||||
assertThat(config.getShortNote()).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM member_recent_cfg WHERE member_id=740", Integer.class))
|
||||
.isEqualTo(1);
|
||||
assertThat(memberRecentCfgService.getOrCreate(741L).getPrivacySecure()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSessionQueriesObserveCommittedInsertsUpdatesAndLogicalDeletion() {
|
||||
// 复用同一个 SqlSession,外部连接提交变更;锁内复查不能命中旧的空结果或隐私设置。
|
||||
try (SqlSession session = sqlSessionFactory.openSession(true)) {
|
||||
RecentCfgMapper mapper = session.getMapper(RecentCfgMapper.class);
|
||||
assertThat(mapper.selectLatestByMemberId(740L)).isNull();
|
||||
|
||||
insertConfig(812L, "2026-11-05 01:21:20", false, false);
|
||||
assertThat(mapper.selectLatestByMemberId(740L).getPrivacySecure()).isFalse();
|
||||
|
||||
jdbc.update("UPDATE member_recent_cfg SET privacy_secure=TRUE WHERE id=812");
|
||||
assertThat(mapper.selectLatestByMemberId(740L).getPrivacySecure()).isTrue();
|
||||
|
||||
jdbc.update("UPDATE member_recent_cfg SET deleted=TRUE WHERE id=812");
|
||||
assertThat(mapper.selectLatestByMemberId(740L)).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingConfigCanBeSavedThroughTheSqlRewriter() {
|
||||
MemberRecentCfgDO config = memberRecentCfgService.createOrUpdate(request(false));
|
||||
|
||||
assertThat(config.getId()).isNotNull();
|
||||
assertThat(memberRecentCfgService.getOrCreate(740L).getPrivacySecure()).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM member_recent_cfg", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateConfigsUseLatestPrivacySettingsAndIgnoreDeletedRows() {
|
||||
insertConfig(812L, "2026-11-05 01:21:20", false, false);
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ class MemberRecentCfgServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void rechecksAfterAcquiringInitializationLock() {
|
||||
MemberRecentCfgDO existing = existing();
|
||||
when(recentCfgMapper.selectOne(any())).thenReturn(null, existing);
|
||||
when(recentCfgMapper.selectLatestByMemberId(740L)).thenReturn(null, existing);
|
||||
|
||||
assertThat(service.getOrCreate(740L)).isSameAs(existing);
|
||||
verify(recentCfgMapper, never()).insert(any(MemberRecentCfgDO.class));
|
||||
@@ -51,7 +51,7 @@ class MemberRecentCfgServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void returnsConfigCreatedByAConcurrentWriterOnUniqueConflict() {
|
||||
MemberRecentCfgDO existing = existing();
|
||||
when(recentCfgMapper.selectOne(any())).thenReturn(null, null, existing);
|
||||
when(recentCfgMapper.selectLatestByMemberId(740L)).thenReturn(null, null, existing);
|
||||
when(recentCfgMapper.insert(any(MemberRecentCfgDO.class)))
|
||||
.thenThrow(new DuplicateKeyException("uk_member_recent_cfg_active"));
|
||||
|
||||
@@ -69,7 +69,7 @@ class MemberRecentCfgServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void updateAppliesRequestedSettingsAfterConcurrentInitialization() {
|
||||
MemberRecentCfgDO existing = existing();
|
||||
when(recentCfgMapper.selectOne(any())).thenReturn(null, existing);
|
||||
when(recentCfgMapper.selectLatestByMemberId(740L)).thenReturn(null, existing);
|
||||
when(recentCfgMapper.insert(any(MemberRecentCfgDO.class)))
|
||||
.thenThrow(new DuplicateKeyException("uk_member_recent_cfg_active"));
|
||||
MemberRecentCfgReqVo request = new MemberRecentCfgReqVo(740L, true, false, false, true, false);
|
||||
|
||||
+23
-1
@@ -1,8 +1,11 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.member.config.MemberLoginSqlTestConfiguration;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.config.MemberConfigDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentTagDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum;
|
||||
import cn.iocoder.yudao.module.member.service.config.MemberConfigService;
|
||||
@@ -11,7 +14,9 @@ import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@@ -20,9 +25,11 @@ import javax.sql.DataSource;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Import(MemberRecentTagServiceImp.class)
|
||||
@Import({MemberRecentTagServiceImp.class, MemberLoginSqlTestConfiguration.class})
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:member_recent_tag_login;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
|
||||
"spring.sql.init.schema-locations=classpath:/sql/create_tables.sql,classpath:/sql/member_recent_tag_login_schema.sql"
|
||||
@@ -41,6 +48,8 @@ class MemberRecentTagServiceImpDbTest extends BaseDbUnitTest {
|
||||
private MemberConfigService memberConfigService;
|
||||
@MockBean
|
||||
private OpenTypeService openTypeService;
|
||||
@SpyBean
|
||||
private MemberRecentTagMapper memberRecentTagMapper;
|
||||
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@@ -98,6 +107,19 @@ class MemberRecentTagServiceImpDbTest extends BaseDbUnitTest {
|
||||
.isEqualTo(MemberRecentTagEnum.NEW_PERSON.getDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentNewPersonTagInsertRecoversThroughTheSqlRewriter() {
|
||||
mockNewPersonAge(1);
|
||||
doAnswer(invocation -> {
|
||||
insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
|
||||
throw new DuplicateKeyException("active member tag already exists");
|
||||
}).when(memberRecentTagMapper).insert(any(MemberRecentTagDO.class));
|
||||
|
||||
assertThat(service.createOrUpdateNewPersonTag(MEMBER_ID)).isFalse();
|
||||
|
||||
assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredUserWithoutANewPersonTagDoesNotCreateOne() {
|
||||
mockNewPersonAge(30);
|
||||
|
||||
Reference in new issue
Block a user