fix:登录错误尝试修改

This commit is contained in:
dzc committed 2026-11-05 17:54:37 +08:00
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;
-- 本脚本没有安装唯一约束;防止新增重复仍需后端修复及独立的唯一索引迁移。
-- 如需撤销,先核对备份与后续变更。唯一索引安装后,不可直接恢复成两条有效记录。
@@ -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);
}
@@ -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
*/
@@ -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;
@@ -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;
}
};
}
}
@@ -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");
}
}
@@ -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);
@@ -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);
@@ -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);