实现V4目录管理后台CP1后端

This commit is contained in:
andy
2026-07-19 17:56:08 +07:00
parent 22767e194d
commit f2c2616ab0
23 changed files with 1958 additions and 31 deletions

View File

@@ -15,6 +15,7 @@ public enum PlatformPermissionCode {
RESERVATION_AUDIT_READ,
RESERVATION_INVOICE_GENERATE,
RESERVATION_ROOMING_LIST_GENERATE,
RESERVATION_CATALOG_MANAGE,
HOTEL_SWITCH,
SYSTEM_AUTH_READ,
SYSTEM_USER_MANAGE,

View File

@@ -324,6 +324,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
case RESERVATION_AUDIT_READ -> "读取任务审计";
case RESERVATION_INVOICE_GENERATE -> "生成预订发票";
case RESERVATION_ROOMING_LIST_GENERATE -> "生成 Rooming List";
case RESERVATION_CATALOG_MANAGE -> "管理预订目录";
case HOTEL_SWITCH -> "切换酒店";
case SYSTEM_AUTH_READ -> "读取当前登录上下文";
case SYSTEM_USER_MANAGE -> "管理用户";
@@ -345,7 +346,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
RESERVATION_TASK_CONFIRM, RESERVATION_MANUAL_REVIEW_RESOLVE,
RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE,
RESERVATION_ROOMING_LIST_GENERATE -> "RESERVATION";
RESERVATION_ROOMING_LIST_GENERATE, RESERVATION_CATALOG_MANAGE -> "RESERVATION";
case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL";
default -> "SYSTEM";
};

View File

@@ -13,9 +13,13 @@ import java.time.LocalDateTime;
* @param sourceCode 关联 Source 代码
* @param status 目录状态
* @param sourceSystem 目录来源
* @param externalAccountId 外部系统 Account ID
* @param catalogVersion 目录版本
* @param lastSyncedAt 最近同步 UTC 时间
* @param metadataJson 扩展属性 JSON
* @param version 乐观锁版本
* @param createdAt 创建 UTC 时间
* @param updatedAt 更新 UTC 时间
*/
public record ReservationV4CatalogAccountSnapshot(
Long id,
@@ -26,8 +30,12 @@ public record ReservationV4CatalogAccountSnapshot(
String sourceCode,
String status,
String sourceSystem,
String externalAccountId,
String catalogVersion,
LocalDateTime lastSyncedAt,
String metadataJson
String metadataJson,
Long version,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -12,10 +12,14 @@ import java.time.LocalDateTime;
* @param displayName 显示名称
* @param status 目录状态
* @param sourceSystem 目录来源
* @param externalId 外部系统目录 ID
* @param sortOrder 展示排序
* @param catalogVersion 目录版本
* @param lastSyncedAt 最近同步 UTC 时间
* @param metadataJson 扩展属性 JSON
* @param version 乐观锁版本
* @param createdAt 创建 UTC 时间
* @param updatedAt 更新 UTC 时间
*/
public record ReservationV4CatalogCodeSnapshot(
Long id,
@@ -25,9 +29,13 @@ public record ReservationV4CatalogCodeSnapshot(
String displayName,
String status,
String sourceSystem,
String externalId,
Integer sortOrder,
String catalogVersion,
LocalDateTime lastSyncedAt,
String metadataJson
String metadataJson,
Long version,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,35 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation V4 Account 目录新增请求。只允许后台管理入口创建系统维护目录。
*
* @param hotelId 酒店 ID
* @param accountCode Account 稳定代码
* @param accountName Account 显示名称
* @param marketCode 关联 Market 代码
* @param sourceCode 关联 Source 代码
* @param externalAccountId 外部系统 Account ID未接 PMS 时可为空
* @param catalogVersion 目录版本,为空时后端生成系统维护版本
* @param metadataJson 扩展属性 JSON第一版只保存不参与业务计算
*/
public record ReservationV4CatalogAdminCreateAccountRequest(
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("account_code")
String accountCode,
@JsonProperty("account_name")
String accountName,
@JsonProperty("market_code")
String marketCode,
@JsonProperty("source_code")
String sourceCode,
@JsonProperty("external_account_id")
String externalAccountId,
@JsonProperty("catalog_version")
String catalogVersion,
@JsonProperty("metadata_json")
String metadataJson
) {
}

View File

@@ -0,0 +1,31 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation V4 通用代码目录新增请求。Controller 固定 Room Type 或 Rate Code 类型。
*
* @param hotelId 酒店 ID
* @param code 目录稳定代码
* @param displayName 目录显示名称
* @param externalId 外部系统目录 ID未接 PMS 时可为空
* @param sortOrder 展示排序
* @param catalogVersion 目录版本,为空时后端生成系统维护版本
* @param metadataJson 扩展属性 JSON例如房型人数或 Rate Code 价格能力
*/
public record ReservationV4CatalogAdminCreateCodeRequest(
@JsonProperty("hotel_id")
String hotelId,
String code,
@JsonProperty("display_name")
String displayName,
@JsonProperty("external_id")
String externalId,
@JsonProperty("sort_order")
Integer sortOrder,
@JsonProperty("catalog_version")
String catalogVersion,
@JsonProperty("metadata_json")
String metadataJson
) {
}

View File

@@ -0,0 +1,11 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
/**
* Reservation V4 目录状态更新请求。
*
* @param status 目标目录状态ACTIVE 或 DISABLED
*/
public record ReservationV4CatalogAdminStatusUpdateRequest(
String status
) {
}

View File

@@ -0,0 +1,51 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
/**
* Reservation V4 Account 目录管理响应。
*
* @param id 目录记录 ID 字符串
* @param hotelId 酒店 ID
* @param accountCode Account 稳定代码
* @param accountName Account 显示名称
* @param marketCode 关联 Market 代码
* @param sourceCode 关联 Source 代码
* @param status 目录状态
* @param catalogSource 目录来源
* @param externalAccountId 外部系统 Account ID
* @param catalogVersion 目录版本
* @param metadataJson 扩展属性 JSON
* @param version 乐观锁版本
* @param createdAt 创建 UTC 时间
* @param updatedAt 更新 UTC 时间
*/
public record ReservationV4CatalogAdminAccountResult(
String id,
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("account_code")
String accountCode,
@JsonProperty("account_name")
String accountName,
@JsonProperty("market_code")
String marketCode,
@JsonProperty("source_code")
String sourceCode,
String status,
@JsonProperty("catalog_source")
String catalogSource,
@JsonProperty("external_account_id")
String externalAccountId,
@JsonProperty("catalog_version")
String catalogVersion,
@JsonProperty("metadata_json")
String metadataJson,
Long version,
@JsonProperty("created_at")
OffsetDateTime createdAt,
@JsonProperty("updated_at")
OffsetDateTime updatedAt
) {
}

View File

@@ -0,0 +1,50 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
/**
* Reservation V4 Room Type / Rate Code 目录管理响应。
*
* @param id 目录记录 ID 字符串
* @param hotelId 酒店 ID
* @param catalogType 目录类型
* @param code 稳定代码
* @param displayName 显示名称
* @param status 目录状态
* @param catalogSource 目录来源
* @param externalId 外部系统目录 ID
* @param sortOrder 展示排序
* @param catalogVersion 目录版本
* @param metadataJson 扩展属性 JSON
* @param version 乐观锁版本
* @param createdAt 创建 UTC 时间
* @param updatedAt 更新 UTC 时间
*/
public record ReservationV4CatalogAdminCodeResult(
String id,
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("catalog_type")
String catalogType,
String code,
@JsonProperty("display_name")
String displayName,
String status,
@JsonProperty("catalog_source")
String catalogSource,
@JsonProperty("external_id")
String externalId,
@JsonProperty("sort_order")
Integer sortOrder,
@JsonProperty("catalog_version")
String catalogVersion,
@JsonProperty("metadata_json")
String metadataJson,
Long version,
@JsonProperty("created_at")
OffsetDateTime createdAt,
@JsonProperty("updated_at")
OffsetDateTime updatedAt
) {
}

View File

@@ -0,0 +1,171 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.common.result.PlatformPageResult;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateAccountRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateCodeRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminStatusUpdateRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminAccountResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminCodeResult;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogAdminService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Reservation V4 目录管理 Controller。提供系统后台维护 Account、Room Type、Rate Code 的第一版接口。
*/
@RestController
@RequestMapping("/api/admin/reservation/catalogs")
public class ReservationV4CatalogAdminController {
private final AdminAuthorizationService authorizationService;
private final ReservationV4CatalogAdminService catalogAdminService;
/**
* 注入管理鉴权和目录管理服务Controller 只负责 HTTP 参数边界和权限入口。
*/
public ReservationV4CatalogAdminController(
AdminAuthorizationService authorizationService,
ReservationV4CatalogAdminService catalogAdminService) {
this.authorizationService = authorizationService;
this.catalogAdminService = catalogAdminService;
}
/**
* 分页查询 Account 目录管理列表,需要目录管理权限。
*/
@GetMapping(value = "/accounts", produces = MediaType.APPLICATION_JSON_VALUE)
public PlatformPageResult<ReservationV4CatalogAdminAccountResult> listAccounts(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.listAccounts(hotelId, keyword, status, pageNum, pageSize);
}
/**
* 新增 Account 目录项,需要目录管理权限并写入管理审计。
*/
@PostMapping(
value = "/accounts",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogAdminAccountResult createAccount(
@RequestBody(required = false) ReservationV4CatalogAdminCreateAccountRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.createAccount(request, actor);
}
/**
* 启用或停用 Account 目录项;状态实际变化时写入管理审计。
*/
@PutMapping(
value = "/accounts/{accountId}/status",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogAdminAccountResult updateAccountStatus(
@PathVariable String accountId,
@RequestBody(required = false) ReservationV4CatalogAdminStatusUpdateRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.updateAccountStatus(accountId, request, actor);
}
/**
* 分页查询 Room Type 目录管理列表,需要目录管理权限。
*/
@GetMapping(value = "/room-types", produces = MediaType.APPLICATION_JSON_VALUE)
public PlatformPageResult<ReservationV4CatalogAdminCodeResult> listRoomTypes(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.listRoomTypes(hotelId, keyword, status, pageNum, pageSize);
}
/**
* 新增 Room Type 目录项,需要目录管理权限并写入管理审计。
*/
@PostMapping(
value = "/room-types",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogAdminCodeResult createRoomType(
@RequestBody(required = false) ReservationV4CatalogAdminCreateCodeRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.createRoomType(request, actor);
}
/**
* 启用或停用 Room Type 目录项;状态实际变化时写入管理审计。
*/
@PutMapping(
value = "/room-types/{catalogId}/status",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogAdminCodeResult updateRoomTypeStatus(
@PathVariable String catalogId,
@RequestBody(required = false) ReservationV4CatalogAdminStatusUpdateRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.updateRoomTypeStatus(catalogId, request, actor);
}
/**
* 分页查询 Rate Code 目录管理列表,需要目录管理权限。
*/
@GetMapping(value = "/rate-codes", produces = MediaType.APPLICATION_JSON_VALUE)
public PlatformPageResult<ReservationV4CatalogAdminCodeResult> listRateCodes(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.listRateCodes(hotelId, keyword, status, pageNum, pageSize);
}
/**
* 新增 Rate Code 目录项,需要目录管理权限并写入管理审计。
*/
@PostMapping(
value = "/rate-codes",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogAdminCodeResult createRateCode(
@RequestBody(required = false) ReservationV4CatalogAdminCreateCodeRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.createRateCode(request, actor);
}
/**
* 启用或停用 Rate Code 目录项;状态实际变化时写入管理审计。
*/
@PutMapping(
value = "/rate-codes/{catalogId}/status",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogAdminCodeResult updateRateCodeStatus(
@PathVariable String catalogId,
@RequestBody(required = false) ReservationV4CatalogAdminStatusUpdateRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name());
return catalogAdminService.updateRateCodeStatus(catalogId, request, actor);
}
}

View File

@@ -0,0 +1,33 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.common.exception.AdminOperationException;
import cn.nianxx.thhotel.platform.common.result.PlatformErrorResponse;
import cn.nianxx.thhotel.platform.security.service.impl.AdminAuthorizationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Reservation V4 目录管理后台异常处理。保持平台错误响应结构,同时避免 platform 模块反向依赖 workflow。
*/
@RestControllerAdvice(assignableTypes = ReservationV4CatalogAdminController.class)
public class ReservationV4CatalogAdminControllerAdvice {
/**
* 处理目录管理后台登录和权限异常。
*/
@ExceptionHandler(AdminAuthorizationException.class)
public ResponseEntity<PlatformErrorResponse> handleAuthorization(AdminAuthorizationException exception) {
return ResponseEntity.status(exception.getStatus())
.body(new PlatformErrorResponse(exception.getErrorCode(), exception.getMessage()));
}
/**
* 处理目录管理后台受控业务异常。
*/
@ExceptionHandler(AdminOperationException.class)
public ResponseEntity<PlatformErrorResponse> handleOperation(AdminOperationException exception) {
return ResponseEntity.status(exception.getStatus())
.body(new PlatformErrorResponse(exception.getErrorCode(), exception.getMessage()));
}
}

View File

@@ -11,6 +11,7 @@ import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationCatalogCodeMapp
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@@ -132,6 +133,140 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
}
/**
* 管理后台分页查询 Account 目录,可包含 DISABLED 记录。
*/
@Override
public ReservationPageSnapshot<ReservationV4CatalogAccountSnapshot> queryAdminAccounts(
String hotelId,
String keyword,
String status,
int pageNum,
int pageSize) {
String normalizedKeyword = trimToNull(keyword);
String normalizedStatus = trimToNull(status);
LambdaQueryWrapper<ReservationCatalogAccountEntity> query = Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, hotelId)
.eq(normalizedStatus != null, ReservationCatalogAccountEntity::getStatus, normalizedStatus)
.isNull(ReservationCatalogAccountEntity::getLogicDeletedAt)
.and(normalizedKeyword != null, wrapper -> wrapper
.like(ReservationCatalogAccountEntity::getAccountCode, normalizedKeyword)
.or()
.like(ReservationCatalogAccountEntity::getAccountName, normalizedKeyword))
.orderByAsc(ReservationCatalogAccountEntity::getAccountCode)
.orderByAsc(ReservationCatalogAccountEntity::getId);
Page<ReservationCatalogAccountEntity> page = accountMapper.selectPage(Page.of(pageNum, pageSize), query);
List<ReservationV4CatalogAccountSnapshot> items = page.getRecords().stream()
.map(this::toAccountSnapshot)
.toList();
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
}
/**
* 管理后台分页查询 Room Type / Rate Code 目录,可包含 DISABLED 记录。
*/
@Override
public ReservationPageSnapshot<ReservationV4CatalogCodeSnapshot> queryAdminCatalogCodes(
String hotelId,
String catalogType,
String keyword,
String status,
int pageNum,
int pageSize) {
String normalizedKeyword = trimToNull(keyword);
String normalizedStatus = trimToNull(status);
LambdaQueryWrapper<ReservationCatalogCodeEntity> query = Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, hotelId)
.eq(ReservationCatalogCodeEntity::getCatalogType, catalogType)
.eq(normalizedStatus != null, ReservationCatalogCodeEntity::getStatus, normalizedStatus)
.isNull(ReservationCatalogCodeEntity::getLogicDeletedAt)
.and(normalizedKeyword != null, wrapper -> wrapper
.like(ReservationCatalogCodeEntity::getCode, normalizedKeyword)
.or()
.like(ReservationCatalogCodeEntity::getDisplayName, normalizedKeyword))
.orderByAsc(ReservationCatalogCodeEntity::getSortOrder)
.orderByAsc(ReservationCatalogCodeEntity::getCode)
.orderByAsc(ReservationCatalogCodeEntity::getId);
Page<ReservationCatalogCodeEntity> page = codeMapper.selectPage(Page.of(pageNum, pageSize), query);
List<ReservationV4CatalogCodeSnapshot> items = page.getRecords().stream()
.map(this::toCodeSnapshot)
.toList();
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
}
/**
* 按内部 ID 查询 Account 目录。
*/
@Override
public Optional<ReservationV4CatalogAccountSnapshot> findAccountById(Long id) {
if (id == null) {
return Optional.empty();
}
ReservationCatalogAccountEntity entity = accountMapper.selectOne(
Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getId, id)
.isNull(ReservationCatalogAccountEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toAccountSnapshot);
}
/**
* 按内部 ID 查询通用代码目录。
*/
@Override
public Optional<ReservationV4CatalogCodeSnapshot> findCatalogCodeById(Long id) {
if (id == null) {
return Optional.empty();
}
ReservationCatalogCodeEntity entity = codeMapper.selectOne(
Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getId, id)
.isNull(ReservationCatalogCodeEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toCodeSnapshot);
}
/**
* 按酒店和 Account Code 查询未逻辑删除 Account 目录。
*/
@Override
public Optional<ReservationV4CatalogAccountSnapshot> findAccountByCode(String hotelId, String accountCode) {
String normalizedCode = trimToNull(accountCode);
if (trimToNull(hotelId) == null || normalizedCode == null) {
return Optional.empty();
}
ReservationCatalogAccountEntity entity = accountMapper.selectOne(
Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, hotelId)
.eq(ReservationCatalogAccountEntity::getAccountCode, normalizedCode)
.isNull(ReservationCatalogAccountEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toAccountSnapshot);
}
/**
* 按酒店、目录类型和代码查询未逻辑删除通用目录。
*/
@Override
public Optional<ReservationV4CatalogCodeSnapshot> findCatalogCodeByCode(
String hotelId,
String catalogType,
String code) {
String normalizedType = trimToNull(catalogType);
String normalizedCode = trimToNull(code);
if (trimToNull(hotelId) == null || normalizedType == null || normalizedCode == null) {
return Optional.empty();
}
ReservationCatalogCodeEntity entity = codeMapper.selectOne(
Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, hotelId)
.eq(ReservationCatalogCodeEntity::getCatalogType, normalizedType)
.eq(ReservationCatalogCodeEntity::getCode, normalizedCode)
.isNull(ReservationCatalogCodeEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toCodeSnapshot);
}
/**
* 判断当前酒店是否已有 Account 目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。
*/
@@ -196,6 +331,64 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal
}
}
/**
* 新增 Account 目录记录。
*/
@Override
public void insertAccount(ReservationCatalogAccountEntity entity) {
accountMapper.insert(entity);
}
/**
* 新增通用代码目录记录。
*/
@Override
public void insertCatalogCode(ReservationCatalogCodeEntity entity) {
codeMapper.insert(entity);
}
/**
* 按版本更新 Account 状态,避免后台并发覆盖。
*/
@Override
public boolean updateAccountStatus(
Long id,
String status,
long expectedVersion,
long nextVersion,
LocalDateTime updatedAt) {
ReservationCatalogAccountEntity entity = new ReservationCatalogAccountEntity();
entity.setStatus(status);
entity.setVersion(nextVersion);
entity.setUpdatedAt(updatedAt);
int updated = accountMapper.update(entity, Wrappers.<ReservationCatalogAccountEntity>lambdaUpdate()
.eq(ReservationCatalogAccountEntity::getId, id)
.eq(ReservationCatalogAccountEntity::getVersion, expectedVersion)
.isNull(ReservationCatalogAccountEntity::getLogicDeletedAt));
return updated > 0;
}
/**
* 按版本更新通用代码目录状态,避免后台并发覆盖。
*/
@Override
public boolean updateCatalogCodeStatus(
Long id,
String status,
long expectedVersion,
long nextVersion,
LocalDateTime updatedAt) {
ReservationCatalogCodeEntity entity = new ReservationCatalogCodeEntity();
entity.setStatus(status);
entity.setVersion(nextVersion);
entity.setUpdatedAt(updatedAt);
int updated = codeMapper.update(entity, Wrappers.<ReservationCatalogCodeEntity>lambdaUpdate()
.eq(ReservationCatalogCodeEntity::getId, id)
.eq(ReservationCatalogCodeEntity::getVersion, expectedVersion)
.isNull(ReservationCatalogCodeEntity::getLogicDeletedAt));
return updated > 0;
}
private ReservationV4CatalogAccountSnapshot toAccountSnapshot(ReservationCatalogAccountEntity entity) {
return new ReservationV4CatalogAccountSnapshot(
entity.getId(),
@@ -206,9 +399,13 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal
entity.getSourceCode(),
entity.getStatus(),
entity.getSourceSystem(),
entity.getExternalAccountId(),
entity.getCatalogVersion(),
entity.getLastSyncedAt(),
entity.getMetadataJson());
entity.getMetadataJson(),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
private ReservationV4CatalogCodeSnapshot toCodeSnapshot(ReservationCatalogCodeEntity entity) {
@@ -220,10 +417,14 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal
entity.getDisplayName(),
entity.getStatus(),
entity.getSourceSystem(),
entity.getExternalId(),
entity.getSortOrder(),
entity.getCatalogVersion(),
entity.getLastSyncedAt(),
entity.getMetadataJson());
entity.getMetadataJson(),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
private String trimToNull(String value) {

View File

@@ -5,6 +5,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAc
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.time.LocalDateTime;
import java.util.Optional;
/**
@@ -41,6 +42,47 @@ public interface ReservationV4CatalogRepository {
int pageNum,
int pageSize);
/**
* 管理后台分页查询 Account 目录,可按 ACTIVE / DISABLED 过滤。
*/
ReservationPageSnapshot<ReservationV4CatalogAccountSnapshot> queryAdminAccounts(
String hotelId,
String keyword,
String status,
int pageNum,
int pageSize);
/**
* 管理后台分页查询 Room Type / Rate Code 目录,可按 ACTIVE / DISABLED 过滤。
*/
ReservationPageSnapshot<ReservationV4CatalogCodeSnapshot> queryAdminCatalogCodes(
String hotelId,
String catalogType,
String keyword,
String status,
int pageNum,
int pageSize);
/**
* 按内部 ID 查询 Account 目录,管理后台写操作需要包含 DISABLED 记录。
*/
Optional<ReservationV4CatalogAccountSnapshot> findAccountById(Long id);
/**
* 按内部 ID 查询通用代码目录,管理后台写操作需要包含 DISABLED 记录。
*/
Optional<ReservationV4CatalogCodeSnapshot> findCatalogCodeById(Long id);
/**
* 按酒店和 Account Code 查询未逻辑删除目录,管理后台新增时用于重复校验。
*/
Optional<ReservationV4CatalogAccountSnapshot> findAccountByCode(String hotelId, String accountCode);
/**
* 按酒店、目录类型和代码查询未逻辑删除目录,管理后台新增时用于重复校验。
*/
Optional<ReservationV4CatalogCodeSnapshot> findCatalogCodeByCode(String hotelId, String catalogType, String code);
/**
* 判断当前酒店是否已有任意 Account 目录记录,用于启动初始化避免覆盖人工目录。
*/
@@ -60,4 +102,24 @@ public interface ReservationV4CatalogRepository {
* 通用目录 Code 不存在时插入固定种子目录,存在时保持原数据不变。
*/
void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity);
/**
* 新增 Account 目录记录,调用方负责业务校验和审计。
*/
void insertAccount(ReservationCatalogAccountEntity entity);
/**
* 新增通用代码目录记录,调用方负责业务校验和审计。
*/
void insertCatalogCode(ReservationCatalogCodeEntity entity);
/**
* 更新 Account 目录状态并推进乐观锁版本。
*/
boolean updateAccountStatus(Long id, String status, long expectedVersion, long nextVersion, LocalDateTime updatedAt);
/**
* 更新通用代码目录状态并推进乐观锁版本。
*/
boolean updateCatalogCodeStatus(Long id, String status, long expectedVersion, long nextVersion, LocalDateTime updatedAt);
}

View File

@@ -0,0 +1,90 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.platform.common.result.PlatformPageResult;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateAccountRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateCodeRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminStatusUpdateRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminAccountResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminCodeResult;
/**
* Reservation V4 目录管理服务。第一版只管理 Account、Room Type 和 Rate Code。
*/
public interface ReservationV4CatalogAdminService {
/**
* 分页查询 Account 目录管理列表。
*/
PlatformPageResult<ReservationV4CatalogAdminAccountResult> listAccounts(
String hotelId,
String keyword,
String status,
Integer pageNum,
Integer pageSize);
/**
* 新增 Account 目录项。
*/
ReservationV4CatalogAdminAccountResult createAccount(
ReservationV4CatalogAdminCreateAccountRequest request,
AuthenticatedUserContext actor);
/**
* 更新 Account 目录状态。
*/
ReservationV4CatalogAdminAccountResult updateAccountStatus(
String accountId,
ReservationV4CatalogAdminStatusUpdateRequest request,
AuthenticatedUserContext actor);
/**
* 分页查询 Room Type 目录管理列表。
*/
PlatformPageResult<ReservationV4CatalogAdminCodeResult> listRoomTypes(
String hotelId,
String keyword,
String status,
Integer pageNum,
Integer pageSize);
/**
* 新增 Room Type 目录项。
*/
ReservationV4CatalogAdminCodeResult createRoomType(
ReservationV4CatalogAdminCreateCodeRequest request,
AuthenticatedUserContext actor);
/**
* 更新 Room Type 目录状态。
*/
ReservationV4CatalogAdminCodeResult updateRoomTypeStatus(
String catalogId,
ReservationV4CatalogAdminStatusUpdateRequest request,
AuthenticatedUserContext actor);
/**
* 分页查询 Rate Code 目录管理列表。
*/
PlatformPageResult<ReservationV4CatalogAdminCodeResult> listRateCodes(
String hotelId,
String keyword,
String status,
Integer pageNum,
Integer pageSize);
/**
* 新增 Rate Code 目录项。
*/
ReservationV4CatalogAdminCodeResult createRateCode(
ReservationV4CatalogAdminCreateCodeRequest request,
AuthenticatedUserContext actor);
/**
* 更新 Rate Code 目录状态。
*/
ReservationV4CatalogAdminCodeResult updateRateCodeStatus(
String catalogId,
ReservationV4CatalogAdminStatusUpdateRequest request,
AuthenticatedUserContext actor);
}

View File

@@ -0,0 +1,583 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft;
import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService;
import cn.nianxx.thhotel.platform.common.exception.AdminOperationException;
import cn.nianxx.thhotel.platform.common.result.PlatformPageResult;
import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult;
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
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.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.common.request.ReservationV4CatalogAdminCreateAccountRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateCodeRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminStatusUpdateRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminAccountResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminCodeResult;
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 cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogAdminService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Reservation V4 目录管理服务实现。写操作只修改目录表,不触碰业务任务历史 payload。
*/
@Service
public class ReservationV4CatalogAdminServiceImpl implements ReservationV4CatalogAdminService {
private static final int DEFAULT_PAGE_NUM = 1;
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 MAX_CODE_LENGTH = 64;
private static final int MAX_NAME_LENGTH = 255;
private static final int MAX_EXTERNAL_ID_LENGTH = 128;
private static final int MAX_METADATA_LENGTH = 65535;
private static final String TARGET_TYPE_ACCOUNT = "RESERVATION_V4_CATALOG_ACCOUNT";
private static final String TARGET_TYPE_CODE = "RESERVATION_V4_CATALOG_CODE";
private static final DateTimeFormatter VERSION_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final ReservationV4CatalogRepository catalogRepository;
private final HotelContextService hotelContextService;
private final PlatformAdminAuditLogService auditLogService;
private final ObjectMapper objectMapper;
/**
* 注入目录仓储、酒店上下文、管理审计和 JSON 工具。
*/
public ReservationV4CatalogAdminServiceImpl(
ReservationV4CatalogRepository catalogRepository,
HotelContextService hotelContextService,
PlatformAdminAuditLogService auditLogService,
ObjectMapper objectMapper) {
this.catalogRepository = catalogRepository;
this.hotelContextService = hotelContextService;
this.auditLogService = auditLogService;
this.objectMapper = objectMapper;
}
/**
* 分页查询 Account 目录管理列表,支持 ACTIVE / DISABLED 过滤。
*/
@Override
public PlatformPageResult<ReservationV4CatalogAdminAccountResult> listAccounts(
String hotelId,
String keyword,
String status,
Integer pageNum,
Integer pageSize) {
String accessibleHotelId = resolveHotelId(hotelId);
ReservationPageSnapshot<ReservationV4CatalogAccountSnapshot> page = catalogRepository.queryAdminAccounts(
accessibleHotelId,
trimToNull(keyword),
normalizeNullableStatus(status),
normalizePageNum(pageNum),
normalizePageSize(pageSize));
return new PlatformPageResult<>(
page.items().stream().map(this::toAccountResult).toList(),
new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total()));
}
/**
* 新增 Account 目录项,并记录管理审计。
*/
@Override
@Transactional
public ReservationV4CatalogAdminAccountResult createAccount(
ReservationV4CatalogAdminCreateAccountRequest request,
AuthenticatedUserContext actor) {
if (request == null) {
throw invalidRequest("Account 目录新增请求不能为空。");
}
String hotelId = resolveHotelId(request.hotelId());
String accountCode = requireCode(request.accountCode(), "Account Code 不能为空。");
if (catalogRepository.findAccountByCode(hotelId, accountCode).isPresent()) {
throw conflict("Account Code 已存在。");
}
String accountName = requireText(request.accountName(), "Account 名称不能为空。", MAX_NAME_LENGTH);
String marketCode = requireCode(request.marketCode(), "Market Code 不能为空。");
String sourceCode = requireCode(request.sourceCode(), "Source Code 不能为空。");
ensureActiveCatalogCode(hotelId, ReservationV4CatalogType.MARKET, marketCode, "Market Code 不存在或未启用。");
ensureActiveCatalogCode(hotelId, ReservationV4CatalogType.SOURCE, sourceCode, "Source Code 不存在或未启用。");
String metadataJson = normalizeMetadataJson(request.metadataJson());
LocalDateTime now = nowUtc();
ReservationCatalogAccountEntity entity = new ReservationCatalogAccountEntity();
entity.setHotelId(hotelId);
entity.setAccountCode(accountCode);
entity.setAccountName(accountName);
entity.setMarketCode(marketCode);
entity.setSourceCode(sourceCode);
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
entity.setSourceSystem(ReservationV4CatalogSourceSystem.SYSTEM_MANAGED.name());
entity.setExternalAccountId(optionalText(request.externalAccountId(), MAX_EXTERNAL_ID_LENGTH, "外部 Account ID 过长。"));
entity.setCatalogVersion(resolveCatalogVersion(request.catalogVersion(), now));
entity.setLastSyncedAt(null);
entity.setMetadataJson(metadataJson);
entity.setVersion(0L);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
try {
catalogRepository.insertAccount(entity);
} catch (DuplicateKeyException exception) {
throw conflict("Account Code 已存在。");
}
ReservationV4CatalogAdminAccountResult after = getAccountResult(entity.getId());
audit(actor, TARGET_TYPE_ACCOUNT, stringId(entity.getId()), "CREATE_ACCOUNT_CATALOG", null, after);
return after;
}
/**
* 更新 Account 目录状态DISABLED 后普通 lookup 不再返回该 Account。
*/
@Override
@Transactional
public ReservationV4CatalogAdminAccountResult updateAccountStatus(
String accountId,
ReservationV4CatalogAdminStatusUpdateRequest request,
AuthenticatedUserContext actor) {
Long id = parseId(accountId, "Account 目录 ID 不合法。");
String targetStatus = normalizeRequiredStatus(request);
ReservationV4CatalogAccountSnapshot beforeSnapshot = catalogRepository.findAccountById(id)
.orElseThrow(() -> notFound("Account 目录不存在。"));
resolveHotelId(beforeSnapshot.hotelId());
ReservationV4CatalogAdminAccountResult before = toAccountResult(beforeSnapshot);
if (targetStatus.equals(beforeSnapshot.status())) {
return before;
}
long expectedVersion = beforeSnapshot.version() == null ? 0L : beforeSnapshot.version();
boolean updated = catalogRepository.updateAccountStatus(
id,
targetStatus,
expectedVersion,
expectedVersion + 1,
nowUtc());
if (!updated) {
throw conflict("Account 目录已被其他操作更新,请刷新后重试。");
}
ReservationV4CatalogAdminAccountResult after = getAccountResult(id);
audit(actor, TARGET_TYPE_ACCOUNT, stringId(id), "UPDATE_ACCOUNT_CATALOG_STATUS", before, after);
return after;
}
/**
* 分页查询 Room Type 目录管理列表。
*/
@Override
public PlatformPageResult<ReservationV4CatalogAdminCodeResult> listRoomTypes(
String hotelId,
String keyword,
String status,
Integer pageNum,
Integer pageSize) {
return listCatalogCodes(hotelId, ReservationV4CatalogType.ROOM_TYPE, keyword, status, pageNum, pageSize);
}
/**
* 新增 Room Type 目录项。
*/
@Override
@Transactional
public ReservationV4CatalogAdminCodeResult createRoomType(
ReservationV4CatalogAdminCreateCodeRequest request,
AuthenticatedUserContext actor) {
return createCatalogCode(request, ReservationV4CatalogType.ROOM_TYPE, actor);
}
/**
* 更新 Room Type 目录状态。
*/
@Override
@Transactional
public ReservationV4CatalogAdminCodeResult updateRoomTypeStatus(
String catalogId,
ReservationV4CatalogAdminStatusUpdateRequest request,
AuthenticatedUserContext actor) {
return updateCatalogCodeStatus(catalogId, ReservationV4CatalogType.ROOM_TYPE, request, actor);
}
/**
* 分页查询 Rate Code 目录管理列表。
*/
@Override
public PlatformPageResult<ReservationV4CatalogAdminCodeResult> listRateCodes(
String hotelId,
String keyword,
String status,
Integer pageNum,
Integer pageSize) {
return listCatalogCodes(hotelId, ReservationV4CatalogType.RATE_CODE, keyword, status, pageNum, pageSize);
}
/**
* 新增 Rate Code 目录项。
*/
@Override
@Transactional
public ReservationV4CatalogAdminCodeResult createRateCode(
ReservationV4CatalogAdminCreateCodeRequest request,
AuthenticatedUserContext actor) {
return createCatalogCode(request, ReservationV4CatalogType.RATE_CODE, actor);
}
/**
* 更新 Rate Code 目录状态。
*/
@Override
@Transactional
public ReservationV4CatalogAdminCodeResult updateRateCodeStatus(
String catalogId,
ReservationV4CatalogAdminStatusUpdateRequest request,
AuthenticatedUserContext actor) {
return updateCatalogCodeStatus(catalogId, ReservationV4CatalogType.RATE_CODE, request, actor);
}
/**
* 分页查询通用代码目录管理列表。
*/
private PlatformPageResult<ReservationV4CatalogAdminCodeResult> listCatalogCodes(
String hotelId,
ReservationV4CatalogType catalogType,
String keyword,
String status,
Integer pageNum,
Integer pageSize) {
String accessibleHotelId = resolveHotelId(hotelId);
ReservationPageSnapshot<ReservationV4CatalogCodeSnapshot> page = catalogRepository.queryAdminCatalogCodes(
accessibleHotelId,
catalogType.name(),
trimToNull(keyword),
normalizeNullableStatus(status),
normalizePageNum(pageNum),
normalizePageSize(pageSize));
return new PlatformPageResult<>(
page.items().stream().map(this::toCodeResult).toList(),
new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total()));
}
/**
* 新增 Room Type / Rate Code 目录项。
*/
private ReservationV4CatalogAdminCodeResult createCatalogCode(
ReservationV4CatalogAdminCreateCodeRequest request,
ReservationV4CatalogType catalogType,
AuthenticatedUserContext actor) {
if (request == null) {
throw invalidRequest("目录新增请求不能为空。");
}
String hotelId = resolveHotelId(request.hotelId());
String code = requireCode(request.code(), "目录代码不能为空。");
if (catalogRepository.findCatalogCodeByCode(hotelId, catalogType.name(), code).isPresent()) {
throw conflict("目录代码已存在。");
}
String displayName = requireText(request.displayName(), "目录显示名称不能为空。", MAX_NAME_LENGTH);
String metadataJson = normalizeMetadataJson(request.metadataJson());
LocalDateTime now = nowUtc();
ReservationCatalogCodeEntity entity = new ReservationCatalogCodeEntity();
entity.setHotelId(hotelId);
entity.setCatalogType(catalogType.name());
entity.setCode(code);
entity.setDisplayName(displayName);
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
entity.setSourceSystem(ReservationV4CatalogSourceSystem.SYSTEM_MANAGED.name());
entity.setExternalId(optionalText(request.externalId(), MAX_EXTERNAL_ID_LENGTH, "外部目录 ID 过长。"));
entity.setSortOrder(request.sortOrder() == null ? 0 : request.sortOrder());
entity.setCatalogVersion(resolveCatalogVersion(request.catalogVersion(), now));
entity.setLastSyncedAt(null);
entity.setMetadataJson(metadataJson);
entity.setVersion(0L);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
try {
catalogRepository.insertCatalogCode(entity);
} catch (DuplicateKeyException exception) {
throw conflict("目录代码已存在。");
}
ReservationV4CatalogAdminCodeResult after = getCodeResult(entity.getId());
audit(actor, TARGET_TYPE_CODE, stringId(entity.getId()), "CREATE_" + catalogType.name() + "_CATALOG", null, after);
return after;
}
/**
* 更新 Room Type / Rate Code 目录状态,并校验 ID 所属目录类型。
*/
private ReservationV4CatalogAdminCodeResult updateCatalogCodeStatus(
String catalogId,
ReservationV4CatalogType catalogType,
ReservationV4CatalogAdminStatusUpdateRequest request,
AuthenticatedUserContext actor) {
Long id = parseId(catalogId, "目录 ID 不合法。");
String targetStatus = normalizeRequiredStatus(request);
ReservationV4CatalogCodeSnapshot beforeSnapshot = catalogRepository.findCatalogCodeById(id)
.orElseThrow(() -> notFound("目录不存在。"));
if (!catalogType.name().equals(beforeSnapshot.catalogType())) {
throw notFound("目录不存在。");
}
resolveHotelId(beforeSnapshot.hotelId());
ReservationV4CatalogAdminCodeResult before = toCodeResult(beforeSnapshot);
if (targetStatus.equals(beforeSnapshot.status())) {
return before;
}
long expectedVersion = beforeSnapshot.version() == null ? 0L : beforeSnapshot.version();
boolean updated = catalogRepository.updateCatalogCodeStatus(
id,
targetStatus,
expectedVersion,
expectedVersion + 1,
nowUtc());
if (!updated) {
throw conflict("目录已被其他操作更新,请刷新后重试。");
}
ReservationV4CatalogAdminCodeResult after = getCodeResult(id);
audit(actor, TARGET_TYPE_CODE, stringId(id), "UPDATE_" + catalogType.name() + "_CATALOG_STATUS", before, after);
return after;
}
private ReservationV4CatalogAdminAccountResult getAccountResult(Long id) {
return catalogRepository.findAccountById(id)
.map(this::toAccountResult)
.orElseThrow(() -> notFound("Account 目录不存在。"));
}
private ReservationV4CatalogAdminCodeResult getCodeResult(Long id) {
return catalogRepository.findCatalogCodeById(id)
.map(this::toCodeResult)
.orElseThrow(() -> notFound("目录不存在。"));
}
private ReservationV4CatalogAdminAccountResult toAccountResult(ReservationV4CatalogAccountSnapshot snapshot) {
return new ReservationV4CatalogAdminAccountResult(
stringId(snapshot.id()),
snapshot.hotelId(),
snapshot.accountCode(),
snapshot.accountName(),
snapshot.marketCode(),
snapshot.sourceCode(),
snapshot.status(),
snapshot.sourceSystem(),
snapshot.externalAccountId(),
snapshot.catalogVersion(),
snapshot.metadataJson(),
snapshot.version(),
UtcTimeFormatter.toUtcOffsetDateTime(snapshot.createdAt()),
UtcTimeFormatter.toUtcOffsetDateTime(snapshot.updatedAt()));
}
private ReservationV4CatalogAdminCodeResult toCodeResult(ReservationV4CatalogCodeSnapshot snapshot) {
return new ReservationV4CatalogAdminCodeResult(
stringId(snapshot.id()),
snapshot.hotelId(),
snapshot.catalogType(),
snapshot.code(),
snapshot.displayName(),
snapshot.status(),
snapshot.sourceSystem(),
snapshot.externalId(),
snapshot.sortOrder(),
snapshot.catalogVersion(),
snapshot.metadataJson(),
snapshot.version(),
UtcTimeFormatter.toUtcOffsetDateTime(snapshot.createdAt()),
UtcTimeFormatter.toUtcOffsetDateTime(snapshot.updatedAt()));
}
private void ensureActiveCatalogCode(
String hotelId,
ReservationV4CatalogType catalogType,
String code,
String message) {
if (catalogRepository.findActiveCatalogCode(hotelId, catalogType.name(), code).isEmpty()) {
throw invalidRequest(message);
}
}
private String resolveHotelId(String hotelId) {
try {
return hotelContextService.resolveCurrentHotelId(hotelId);
} catch (HotelContextException exception) {
throw new AdminOperationException(exception.getStatus(), exception.getErrorCode(), exception.getMessage());
}
}
private String normalizeRequiredStatus(ReservationV4CatalogAdminStatusUpdateRequest request) {
if (request == null) {
throw invalidRequest("目录状态更新请求不能为空。");
}
String status = normalizeNullableStatus(request.status());
if (status == null) {
throw invalidRequest("目录状态不能为空。");
}
return status;
}
private String normalizeNullableStatus(String status) {
String normalized = trimToNull(status);
if (normalized == null) {
return null;
}
String upper = normalized.toUpperCase(Locale.ROOT);
if (!ReservationV4CatalogStatus.ACTIVE.name().equals(upper)
&& !ReservationV4CatalogStatus.DISABLED.name().equals(upper)) {
throw invalidRequest("目录状态不合法。");
}
return upper;
}
private String requireCode(String value, String message) {
String normalized = trimToNull(value);
if (normalized == null) {
throw invalidRequest(message);
}
if (normalized.length() > MAX_CODE_LENGTH) {
throw invalidRequest("目录代码过长。");
}
return normalized.toUpperCase(Locale.ROOT);
}
private String requireText(String value, String message, int maxLength) {
String normalized = trimToNull(value);
if (normalized == null) {
throw invalidRequest(message);
}
if (normalized.length() > maxLength) {
throw invalidRequest("文本长度超过限制。");
}
return normalized;
}
private String optionalText(String value, int maxLength, String message) {
String normalized = trimToNull(value);
if (normalized == null) {
return null;
}
if (normalized.length() > maxLength) {
throw invalidRequest(message);
}
return normalized;
}
private String normalizeMetadataJson(String value) {
String normalized = trimToNull(value);
if (normalized == null) {
return null;
}
if (normalized.length() > MAX_METADATA_LENGTH) {
throw invalidRequest("目录扩展属性 JSON 过长。");
}
try {
objectMapper.readTree(normalized);
} catch (Exception exception) {
throw invalidRequest("目录扩展属性 JSON 不合法。");
}
return normalized;
}
private String resolveCatalogVersion(String requestedVersion, LocalDateTime now) {
String normalized = trimToNull(requestedVersion);
if (normalized != null) {
if (normalized.length() > MAX_CODE_LENGTH) {
throw invalidRequest("目录版本过长。");
}
return normalized;
}
return "system-managed-" + VERSION_TIME_FORMATTER.format(now);
}
private Long parseId(String value, String message) {
String normalized = trimToNull(value);
if (normalized == null) {
throw invalidRequest(message);
}
try {
return Long.parseLong(normalized);
} catch (NumberFormatException exception) {
throw invalidRequest(message);
}
}
private int normalizePageNum(Integer pageNum) {
if (pageNum == null || pageNum < 1) {
return DEFAULT_PAGE_NUM;
}
return Math.min(pageNum, MAX_PAGE_NUM);
}
private int normalizePageSize(Integer pageSize) {
if (pageSize == null || pageSize < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, MAX_PAGE_SIZE);
}
private LocalDateTime nowUtc() {
return LocalDateTime.now(ZoneOffset.UTC);
}
private String stringId(Long id) {
return id == null ? null : id.toString();
}
private AdminOperationException invalidRequest(String message) {
return new AdminOperationException(HttpStatus.BAD_REQUEST, "RESERVATION_CATALOG_INVALID_REQUEST", message);
}
private AdminOperationException notFound(String message) {
return new AdminOperationException(HttpStatus.NOT_FOUND, "RESERVATION_CATALOG_NOT_FOUND", message);
}
private AdminOperationException conflict(String message) {
return new AdminOperationException(HttpStatus.CONFLICT, "RESERVATION_CATALOG_CONFLICT", message);
}
private void audit(
AuthenticatedUserContext actor,
String targetType,
String targetId,
String action,
Object before,
Object after) {
auditLogService.record(new PlatformAdminAuditLogDraft(
actor,
targetType,
targetId,
action,
toJson(before),
toJson(after)));
}
private String toJson(Object value) {
if (value == null) {
return null;
}
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new AdminOperationException(
HttpStatus.INTERNAL_SERVER_ERROR,
"ADMIN_AUDIT_SERIALIZE_FAILED",
"系统管理审计序列化失败。");
}
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}