修复V4目录初始化和lookup空结果元数据

This commit is contained in:
andy
2026-07-19 14:56:11 +07:00
parent 88f98a0eb6
commit 8b37232ac9
12 changed files with 342 additions and 18 deletions

View File

@@ -25,6 +25,8 @@ import java.util.List;
import java.util.Map;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -32,6 +34,7 @@ import org.springframework.transaction.annotation.Transactional;
* M003 登录权限底座启动初始化。只写入系统内置数据和首个超级管理员。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
private final AuthProperties authProperties;

View File

@@ -132,6 +132,70 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
}
/**
* 判断当前酒店是否已有 Account 目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。
*/
@Override
public boolean hasAnyAccount(String hotelId) {
String normalizedHotelId = trimToNull(hotelId);
if (normalizedHotelId == null) {
return false;
}
return accountMapper.selectCount(Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, normalizedHotelId)) > 0;
}
/**
* 判断当前酒店是否已有通用代码目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。
*/
@Override
public boolean hasAnyCatalogCode(String hotelId) {
String normalizedHotelId = trimToNull(hotelId);
if (normalizedHotelId == null) {
return false;
}
return codeMapper.selectCount(Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, normalizedHotelId)) > 0;
}
/**
* Account Code 不存在时插入目录种子,存在时跳过以保持启动幂等。
*/
@Override
public void insertAccountIfAbsent(ReservationCatalogAccountEntity entity) {
if (entity == null
|| trimToNull(entity.getHotelId()) == null
|| trimToNull(entity.getAccountCode()) == null) {
return;
}
Long existing = accountMapper.selectCount(Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, entity.getHotelId())
.eq(ReservationCatalogAccountEntity::getAccountCode, entity.getAccountCode()));
if (existing == null || existing == 0) {
accountMapper.insert(entity);
}
}
/**
* 通用目录 Code 不存在时插入目录种子,存在时跳过以保持启动幂等。
*/
@Override
public void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity) {
if (entity == null
|| trimToNull(entity.getHotelId()) == null
|| trimToNull(entity.getCatalogType()) == null
|| trimToNull(entity.getCode()) == null) {
return;
}
Long existing = codeMapper.selectCount(Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, entity.getHotelId())
.eq(ReservationCatalogCodeEntity::getCatalogType, entity.getCatalogType())
.eq(ReservationCatalogCodeEntity::getCode, entity.getCode()));
if (existing == null || existing == 0) {
codeMapper.insert(entity);
}
}
private ReservationV4CatalogAccountSnapshot toAccountSnapshot(ReservationCatalogAccountEntity entity) {
return new ReservationV4CatalogAccountSnapshot(
entity.getId(),

View File

@@ -3,6 +3,8 @@ package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
import java.util.Optional;
/**
@@ -38,4 +40,24 @@ public interface ReservationV4CatalogRepository {
String keyword,
int pageNum,
int pageSize);
/**
* 判断当前酒店是否已有任意 Account 目录记录,用于启动初始化避免覆盖人工目录。
*/
boolean hasAnyAccount(String hotelId);
/**
* 判断当前酒店是否已有任意通用代码目录记录,用于启动初始化避免覆盖人工目录。
*/
boolean hasAnyCatalogCode(String hotelId);
/**
* Account Code 不存在时插入固定种子目录,存在时保持原数据不变。
*/
void insertAccountIfAbsent(ReservationCatalogAccountEntity entity);
/**
* 通用目录 Code 不存在时插入固定种子目录,存在时保持原数据不变。
*/
void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity);
}

View File

@@ -0,0 +1,157 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogSourceSystem;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogType;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4CatalogRepository;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Reservation V4 目录启动初始化器。补齐 Flyway 之后由平台启动流程创建的默认酒店目录种子。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
public class ReservationV4CatalogBootstrapRunner implements ApplicationRunner {
private static final String SEED_CATALOG_VERSION = "seed-20260719-v1";
private static final String MARKET_CODE = "LEISURE";
private static final String SOURCE_CODE = "TRAVEL_AGENT";
private static final List<AccountSeed> ACCOUNT_SEEDS = List.of(
new AccountSeed("QBD_TRAVEL", "Q.B.D. TRAVEL GROUP CO., LTD"),
new AccountSeed("LIAN_TAI", "LIAN TAI TRAVEL (THAILAND) CO., LTD."),
new AccountSeed("HANATOUR_TD", "HANATOUR TD CO., LTD."));
private static final List<CodeSeed> CODE_SEEDS = List.of(
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "TWN", "TWN", 10, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "KING", "KING", 20, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "DBL", "DBL", 30, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "SGL", "SGL", 40, "{\"adult_capacity\":1}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "TRP", "TRP", 50, "{\"adult_capacity\":3}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM1", "RM1", 60, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM2", "RM2", 70, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM3", "RM3", 80, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "BAR", "BAR", 10, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "RACK", "RACK", 20, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "PACKAGE", "PACKAGE", 30, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "GROUP", "GROUP", 40, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "FIT", "FIT", 50, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.MARKET.name(), MARKET_CODE, MARKET_CODE, 10, null),
new CodeSeed(ReservationV4CatalogType.SOURCE.name(), SOURCE_CODE, SOURCE_CODE, 10, null));
private final PlatformHotelRepository hotelRepository;
private final ReservationV4CatalogRepository catalogRepository;
/**
* 注入平台酒店 Repository 和 Reservation 目录 Repository保持平台模块不反向依赖业务目录。
*/
public ReservationV4CatalogBootstrapRunner(
PlatformHotelRepository hotelRepository,
ReservationV4CatalogRepository catalogRepository) {
this.hotelRepository = hotelRepository;
this.catalogRepository = catalogRepository;
}
/**
* 应用启动时为当前 ACTIVE 酒店补齐初始化目录;已有目录的酒店不会被覆盖。
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
List<PlatformHotelEntity> activeHotels = hotelRepository.listActiveHotels();
if (activeHotels == null || activeHotels.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
for (PlatformHotelEntity hotel : activeHotels) {
seedHotelCatalogIfEmpty(hotel == null ? null : hotel.getHotelId(), now);
}
}
/**
* 当前酒店目录为空时插入固定初始化目录,避免 Flyway 之后新建的默认酒店没有可用 lookup。
*/
private void seedHotelCatalogIfEmpty(String hotelId, LocalDateTime now) {
String normalizedHotelId = trimToNull(hotelId);
if (normalizedHotelId == null) {
return;
}
if (!catalogRepository.hasAnyAccount(normalizedHotelId)) {
for (AccountSeed seed : ACCOUNT_SEEDS) {
catalogRepository.insertAccountIfAbsent(accountEntity(normalizedHotelId, seed, now));
}
}
if (!catalogRepository.hasAnyCatalogCode(normalizedHotelId)) {
for (CodeSeed seed : CODE_SEEDS) {
catalogRepository.insertCatalogCodeIfAbsent(codeEntity(normalizedHotelId, seed, now));
}
}
}
/**
* 构造 Account 固定种子实体,主键交给 MyBatis-Plus 生成。
*/
private ReservationCatalogAccountEntity accountEntity(String hotelId, AccountSeed seed, LocalDateTime now) {
ReservationCatalogAccountEntity entity = new ReservationCatalogAccountEntity();
entity.setHotelId(hotelId);
entity.setAccountCode(seed.accountCode());
entity.setAccountName(seed.accountName());
entity.setMarketCode(MARKET_CODE);
entity.setSourceCode(SOURCE_CODE);
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
entity.setSourceSystem(ReservationV4CatalogSourceSystem.FIXED_SEED_IMPORT.name());
entity.setCatalogVersion(SEED_CATALOG_VERSION);
entity.setVersion(0L);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
return entity;
}
/**
* 构造通用代码固定种子实体,主键交给 MyBatis-Plus 生成。
*/
private ReservationCatalogCodeEntity codeEntity(String hotelId, CodeSeed seed, LocalDateTime now) {
ReservationCatalogCodeEntity entity = new ReservationCatalogCodeEntity();
entity.setHotelId(hotelId);
entity.setCatalogType(seed.catalogType());
entity.setCode(seed.code());
entity.setDisplayName(seed.displayName());
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
entity.setSourceSystem(ReservationV4CatalogSourceSystem.FIXED_SEED_IMPORT.name());
entity.setSortOrder(seed.sortOrder());
entity.setCatalogVersion(SEED_CATALOG_VERSION);
entity.setMetadataJson(seed.metadataJson());
entity.setVersion(0L);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
return entity;
}
/**
* 去除字符串首尾空白,空白值统一视为 null。
*/
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
private record AccountSeed(String accountCode, String accountName) {
}
private record CodeSeed(String catalogType, String code, String displayName, int sortOrder, String metadataJson) {
}
}

View File

@@ -27,6 +27,7 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_NUM = 1000;
private static final int MAX_PAGE_SIZE = 100;
private static final int METADATA_PAGE_SIZE = 1;
private static final String EMPTY_CATALOG_SOURCE = "DATABASE_EMPTY";
private static final String EMPTY_CATALOG_VERSION = "unknown";
@@ -62,15 +63,20 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
.map(this::accountItem)
.toList();
List<ReservationV4CatalogAccountSnapshot> metadataItems = catalogRepository.queryActiveAccounts(
hotelId,
null,
DEFAULT_PAGE_NUM,
METADATA_PAGE_SIZE).items();
return new ReservationV4CatalogLookupResult(
hotelId,
ReservationV4CatalogType.ACCOUNT.name(),
sourceSystem(page.items()),
catalogVersion(page.items()),
sourceSystem(metadataItems),
catalogVersion(metadataItems),
false,
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
warnings(page.items()));
warnings(metadataItems));
}
/**
@@ -107,15 +113,21 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
.map(snapshot -> codeItem(catalogType, snapshot))
.toList();
List<ReservationV4CatalogCodeSnapshot> metadataItems = catalogRepository.queryActiveCatalogCodes(
hotelId,
catalogType.name(),
null,
DEFAULT_PAGE_NUM,
METADATA_PAGE_SIZE).items();
return new ReservationV4CatalogLookupResult(
hotelId,
catalogType.name(),
sourceSystem(page.items()),
catalogVersion(page.items()),
sourceSystem(metadataItems),
catalogVersion(metadataItems),
false,
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
warnings(page.items()));
warnings(metadataItems));
}
/**

View File

@@ -0,0 +1,51 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:reservation_v4_catalog_bootstrap_runner_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=v4-catalog-fresh-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=V4目录新酒店管理员",
"auth.bootstrap.default-hotel-id=HOTEL-FRESH",
"auth.bootstrap.default-hotel-name=新酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationV4CatalogBootstrapRunnerTest {
private static final String HOTEL_ID = "HOTEL-FRESH";
@Autowired
private MockMvc mockMvc;
@Test
void shouldSeedCatalogForBootstrapDefaultHotelWhenFlywayDidNotSeeHotel() throws Exception {
String token = loginToken(mockMvc, "v4-catalog-fresh-admin", "Admin@123456");
performAuthorized(mockMvc, token, get("/api/reservation/lookups/accounts")
.param("hotel_id", HOTEL_ID)
.param("keyword", "QBD"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hotel_id").value(HOTEL_ID))
.andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT"))
.andExpect(jsonPath("$.catalog_version").value("seed-20260719-v1"))
.andExpect(jsonPath("$.items[0].code").value("QBD_TRAVEL"));
}
}

View File

@@ -118,6 +118,20 @@ class ReservationV4CatalogLookupControllerTest {
.andExpect(jsonPath("$.items[0].pricing_available").value(false));
}
@Test
void shouldKeepCatalogMetadataWhenKeywordMatchesNothing() throws Exception {
performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts")
.param("hotel_id", HOTEL_ID)
.param("keyword", "NO_SUCH_ACCOUNT"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hotel_id").value(HOTEL_ID))
.andExpect(jsonPath("$.catalog_type").value("ACCOUNT"))
.andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT"))
.andExpect(jsonPath("$.catalog_version").value("seed-20260719-v1"))
.andExpect(jsonPath("$.items").isEmpty())
.andExpect(jsonPath("$.page.total").value(0));
}
@Test
void shouldRejectCatalogLookupWithoutPermissionOrHotelAccess() throws Exception {
performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/lookups/accounts")