diff --git a/sql/mysql/recent/20261105_member_recent_tag_64_reviewed_repair.sql b/sql/mysql/recent/20261105_member_recent_tag_64_reviewed_repair.sql new file mode 100644 index 0000000..1b7f396 --- /dev/null +++ b/sql/mysql/recent/20261105_member_recent_tag_64_reviewed_repair.sql @@ -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; + +-- 本脚本没有安装唯一约束;防止新增重复仍需后端修复及独立的唯一索引迁移。 +-- 如需撤销,先核对备份与后续变更。唯一索引安装后,不可直接恢复成两条有效记录。 diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/RecentCfgMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/RecentCfgMapper.java index b955eb6..0748786 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/RecentCfgMapper.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/RecentCfgMapper.java @@ -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 { -} \ No newline at end of file + /** + * 历史重复配置取最近修改的一条,保留用户已有的隐私设置。 + * 每次读取前清理会话缓存,并禁用二级缓存,避免锁内复查沿用锁外的空结果。 + * 调用方通过 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); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java index 89d8f70..a286291 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java @@ -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() - .eq(MemberRecentCfgDO::getMemberId, memberId) - .orderByDesc(MemberRecentCfgDO::getUpdateTime) - .orderByDesc(MemberRecentCfgDO::getId) - .last(currentRead ? "LIMIT 1 FOR UPDATE" : "LIMIT 1")); - } - /** * 查询首页隐身的用户ids */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java index 276e5ef..e17b86a 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java @@ -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; diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/config/MemberLoginSqlTestConfiguration.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/config/MemberLoginSqlTestConfiguration.java new file mode 100644 index 0000000..44b5a9a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/config/MemberLoginSqlTestConfiguration.java @@ -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; + } + }; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java index a00f260..1ff9a2e 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java @@ -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"); } } diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplDbTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplDbTest.java index c1e349d..2ed70d5 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplDbTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplDbTest.java @@ -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); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplTest.java index dfd0741..c033fbb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplTest.java @@ -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); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpDbTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpDbTest.java index 2a1a133..3e235b1 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpDbTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpDbTest.java @@ -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);