修复V4目录初始化和lookup空结果元数据
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user