From 4745e20b9a1a9d15422de857f0c599db589c2e71 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 10 Jul 2026 16:19:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=94=A8=E6=88=B7=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=85=92=E5=BA=97=E5=94=AF=E4=B8=80=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/go-live-notes.md | 4 +- .../MybatisPlatformHotelRepository.java | 43 +++++- ...V10__enforce_single_default_user_hotel.sql | 24 ++++ .../MybatisPlatformHotelRepositoryTest.java | 47 +++++++ .../PlatformHotelRepositoryTest.java | 125 ++++++++++++++++++ 5 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 server/src/main/resources/db/migration/V10__enforce_single_default_user_hotel.sql create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepositoryTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepositoryTest.java diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index e64f799..f81549e 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -79,7 +79,7 @@ - `/api/auth/me` 和 `/api/auth/logout` 需要 `Authorization: Bearer `。 - 初始管理员 bootstrap 只以“启用状态超级管理员”为阻断条件;如果测试库或生产库只剩禁用超级管理员,应通过环境变量恢复一个可登录超级管理员后再排查账号运营问题。 - 内置角色权限矩阵在启动时按代码同步,矩阵移除的旧权限关系会被清理;管理后台上线前不要手工给内置角色追加临时权限作为长期方案。 -- 普通用户默认酒店当前由后端写入逻辑保持;如果后续提交单默认唯一约束 migration,应在对应上线窗口补充历史数据清理说明。 +- 普通用户默认酒店由后端写入逻辑和数据库唯一索引共同保持单默认;V10 migration 会在建约束前把历史重复默认清理为每个用户保留 id 最大的一条。 - 单酒店阶段系统酒店由 `platform_hotel` 唯一 `ACTIVE` 酒店决定;V12 migration 会通过唯一索引阻止第二家 `ACTIVE` 酒店。上线前如果已有多家 `ACTIVE` 酒店,必须先调整数据,否则迁移或运行时解析会失败。 - 管理后台还未上线时,不要把数据库手工改用户、角色、权限作为常规运营手段。 @@ -200,10 +200,12 @@ 当前 M003 登录权限相关 migration: - `server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql` +- `server/src/main/resources/db/migration/V10__enforce_single_default_user_hotel.sql` 上线前确认: - 目标数据库为空库或 Flyway history 与当前代码一致。 +- 如果某个环境已经在缺少 V10 的临时提交上执行过 V11 / V12,不能直接用默认 Flyway 策略补跑 V10;应先重建测试库,或按运维窗口明确 out-of-order / repair 策略。 - MySQL 版本满足项目要求,默认使用 MySQL 8.0+。 - migration 在 UAT 或测试库已经跑过。 - 表和字段中文注释能正常创建。 diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java index 350a4e6..a0e33cc 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Repository; /** @@ -75,10 +76,10 @@ public class MybatisPlatformHotelRepository implements PlatformHotelRepository { @Override public void ensureUserHotel(Long userId, String hotelId, boolean defaultHotel) { - PlatformUserHotelEntity existing = userHotelMapper.selectOne(Wrappers.lambdaQuery() - .eq(PlatformUserHotelEntity::getUserId, userId) - .eq(PlatformUserHotelEntity::getHotelId, hotelId) - .last("LIMIT 1")); + if (defaultHotel) { + clearOtherDefaultHotels(userId, hotelId); + } + PlatformUserHotelEntity existing = findUserHotel(userId, hotelId).orElse(null); if (existing != null) { if (defaultHotel && !Boolean.TRUE.equals(existing.getDefaultHotel())) { existing.setDefaultHotel(true); @@ -91,7 +92,39 @@ public class MybatisPlatformHotelRepository implements PlatformHotelRepository { relation.setHotelId(hotelId); relation.setDefaultHotel(defaultHotel); relation.setCreatedAt(nowUtc()); - userHotelMapper.insert(relation); + try { + userHotelMapper.insert(relation); + } catch (DuplicateKeyException ignored) { + // 多实例并发初始化时唯一索引已经保证授权关系存在,重复插入后只需补齐默认酒店状态。 + findUserHotel(userId, hotelId).ifPresent(concurrentExisting -> { + if (defaultHotel && !Boolean.TRUE.equals(concurrentExisting.getDefaultHotel())) { + concurrentExisting.setDefaultHotel(true); + userHotelMapper.updateById(concurrentExisting); + } + }); + } + } + + /** + * 查询单个用户酒店授权关系,用于写入前检查和并发重复插入后的状态补齐。 + */ + private Optional findUserHotel(Long userId, String hotelId) { + return Optional.ofNullable(userHotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformUserHotelEntity::getUserId, userId) + .eq(PlatformUserHotelEntity::getHotelId, hotelId) + .last("LIMIT 1"))); + } + + /** + * 清理同一用户其他默认酒店标记,保证普通用户默认酒店在写入逻辑上保持唯一。 + */ + private void clearOtherDefaultHotels(Long userId, String hotelId) { + PlatformUserHotelEntity update = new PlatformUserHotelEntity(); + update.setDefaultHotel(false); + userHotelMapper.update(update, Wrappers.lambdaUpdate() + .eq(PlatformUserHotelEntity::getUserId, userId) + .ne(PlatformUserHotelEntity::getHotelId, hotelId) + .eq(PlatformUserHotelEntity::getDefaultHotel, true)); } private LocalDateTime nowUtc() { diff --git a/server/src/main/resources/db/migration/V10__enforce_single_default_user_hotel.sql b/server/src/main/resources/db/migration/V10__enforce_single_default_user_hotel.sql new file mode 100644 index 0000000..e125580 --- /dev/null +++ b/server/src/main/resources/db/migration/V10__enforce_single_default_user_hotel.sql @@ -0,0 +1,24 @@ +-- M003 登录权限底座:数据库级约束普通用户只能有一个默认酒店。 +-- 上线前如已有脏数据,同一用户多个默认酒店时保留 id 最大的一条,其余改为非默认。 +UPDATE platform_user_hotel +SET default_hotel = 0 +WHERE default_hotel = 1 + AND id NOT IN ( + SELECT keep_id + FROM ( + SELECT MAX(id) AS keep_id + FROM platform_user_hotel + WHERE default_hotel = 1 + GROUP BY user_id + ) kept_defaults + ); + +-- default_hotel_user_id 是默认酒店唯一约束辅助字段,default_hotel=1 时等于用户 ID,否则为空。 +ALTER TABLE platform_user_hotel + ADD COLUMN default_hotel_user_id BIGINT GENERATED ALWAYS AS ( + CASE WHEN default_hotel = 1 THEN user_id ELSE NULL END + ); + +-- MySQL 唯一索引允许多个 NULL,因此只会限制同一用户最多一条 default_hotel=1。 +CREATE UNIQUE INDEX uk_platform_user_hotel_one_default + ON platform_user_hotel (default_hotel_user_id); diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepositoryTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepositoryTest.java new file mode 100644 index 0000000..83d7342 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepositoryTest.java @@ -0,0 +1,47 @@ +package cn.nianxx.thhotel.platform.hotel.repository; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.platform.hotel.domain.PlatformUserHotelEntity; +import cn.nianxx.thhotel.platform.hotel.mapper.PlatformHotelMapper; +import cn.nianxx.thhotel.platform.hotel.mapper.PlatformUserHotelMapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings({"rawtypes", "unchecked"}) +class MybatisPlatformHotelRepositoryTest { + + @Mock + private PlatformHotelMapper hotelMapper; + @Mock + private PlatformUserHotelMapper userHotelMapper; + + @Test + void shouldIgnoreDuplicateKeyWhenEnsuringUserHotel() { + MybatisPlatformHotelRepository repository = new MybatisPlatformHotelRepository(hotelMapper, userHotelMapper); + when(userHotelMapper.selectOne(any(Wrapper.class))) + .thenReturn(null) + .thenReturn(existingUserHotel()); + doThrow(new DuplicateKeyException("duplicate user hotel")) + .when(userHotelMapper).insert(any(PlatformUserHotelEntity.class)); + + assertThatNoException().isThrownBy(() -> repository.ensureUserHotel(1L, "HOTEL-TEST", true)); + } + + private PlatformUserHotelEntity existingUserHotel() { + PlatformUserHotelEntity relation = new PlatformUserHotelEntity(); + relation.setId(10L); + relation.setUserId(1L); + relation.setHotelId("HOTEL-TEST"); + relation.setDefaultHotel(true); + return relation; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepositoryTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepositoryTest.java new file mode 100644 index 0000000..33c2756 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepositoryTest.java @@ -0,0 +1,125 @@ +package cn.nianxx.thhotel.platform.hotel.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:m003_hotel_repository_review;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=", + "auth.bootstrap.admin.password=", + "auth.bootstrap.default-hotel-id=" + }) +@ActiveProfiles("test") +class PlatformHotelRepositoryTest { + + @Autowired + private PlatformHotelRepository hotelRepository; + + @Autowired + private PlatformIdentityRepository identityRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @AfterEach + void cleanReviewRows() { + jdbcTemplate.update("DELETE FROM platform_user_hotel WHERE hotel_id LIKE 'M003-REVIEW-%'"); + jdbcTemplate.update("DELETE FROM platform_hotel WHERE hotel_id LIKE 'M003-REVIEW-%'"); + jdbcTemplate.update("DELETE FROM platform_user WHERE username LIKE 'm003-review-%'"); + } + + @Test + void shouldKeepOnlyOneDefaultHotelForUser() { + PlatformUserEntity user = user("m003-review-hotel-user"); + identityRepository.insertUser(user); + hotelRepository.insertHotel(hotel("M003-REVIEW-HOTEL-A", PlatformHotelStatus.DISABLED)); + hotelRepository.insertHotel(hotel("M003-REVIEW-HOTEL-B", PlatformHotelStatus.DISABLED)); + + hotelRepository.ensureUserHotel(user.getId(), "M003-REVIEW-HOTEL-A", true); + hotelRepository.ensureUserHotel(user.getId(), "M003-REVIEW-HOTEL-B", true); + + Integer defaultCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_user_hotel + WHERE user_id = ? + AND default_hotel = 1 + """, Integer.class, user.getId()); + Boolean hotelADefault = jdbcTemplate.queryForObject(""" + SELECT default_hotel + FROM platform_user_hotel + WHERE user_id = ? + AND hotel_id = 'M003-REVIEW-HOTEL-A' + """, Boolean.class, user.getId()); + Boolean hotelBDefault = jdbcTemplate.queryForObject(""" + SELECT default_hotel + FROM platform_user_hotel + WHERE user_id = ? + AND hotel_id = 'M003-REVIEW-HOTEL-B' + """, Boolean.class, user.getId()); + + assertThat(defaultCount).isEqualTo(1); + assertThat(hotelADefault).isFalse(); + assertThat(hotelBDefault).isTrue(); + } + + @Test + void shouldRejectMultipleDefaultHotelsAtDatabaseLevel() { + PlatformUserEntity user = user("m003-review-hotel-db-user"); + identityRepository.insertUser(user); + + jdbcTemplate.update(""" + INSERT INTO platform_user_hotel (id, user_id, hotel_id, default_hotel, created_at) + VALUES (?, ?, ?, ?, ?) + """, 9003003001L, user.getId(), "M003-REVIEW-HOTEL-DB-A", true, LocalDateTime.now()); + + assertThatThrownBy(() -> jdbcTemplate.update(""" + INSERT INTO platform_user_hotel (id, user_id, hotel_id, default_hotel, created_at) + VALUES (?, ?, ?, ?, ?) + """, 9003003002L, user.getId(), "M003-REVIEW-HOTEL-DB-B", true, LocalDateTime.now())) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private PlatformUserEntity user(String username) { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity user = new PlatformUserEntity(); + user.setUsername(username); + user.setPasswordHash("{bcrypt}placeholder"); + user.setDisplayName(username); + user.setUserStatus(PlatformUserStatus.ACTIVE.name()); + user.setSuperAdmin(false); + user.setPasswordChangedAt(now); + user.setCreatedAt(now); + user.setUpdatedAt(now); + return user; + } + + private PlatformHotelEntity hotel(String hotelId, PlatformHotelStatus status) { + LocalDateTime now = LocalDateTime.now(); + PlatformHotelEntity hotel = new PlatformHotelEntity(); + hotel.setHotelId(hotelId); + hotel.setHotelName(hotelId); + hotel.setHotelStatus(status.name()); + hotel.setTimeZone("Asia/Bangkok"); + hotel.setSortOrder(10); + hotel.setCreatedAt(now); + hotel.setUpdatedAt(now); + return hotel; + } +}