diff --git a/src/main/java/com/sjmresorts/opsapp/booking/audit/dtos/EsignAuditLogResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/audit/dtos/EsignAuditLogResponse.java new file mode 100644 index 0000000..be7d900 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/audit/dtos/EsignAuditLogResponse.java @@ -0,0 +1,51 @@ +package com.sjmresorts.opsapp.esign.audit.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "e-sign 審計日誌列表") +public class EsignAuditLogResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignAuditLogData data; + + @Data + @NoArgsConstructor + public static class EsignAuditLogData { + private long total; + private int page; + private int size; + private List logs; + } + + @Data + @NoArgsConstructor + public static class EsignAuditLogItem { + private String logId; + private String logCategory; + private String eventType; + private String message; + private String operatorName; + private Long userId; + private String taskId; + private String fileName; + private String agentSource; + private String hotelId; + private String httpMethod; + private String requestUrl; + private Integer responseStatus; + private Long durationMs; + private String clientIp; + private String detailJson; + private String createdAt; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/audit/models/EsignAuditLog.java b/src/main/java/com/sjmresorts/opsapp/booking/audit/models/EsignAuditLog.java new file mode 100644 index 0000000..ef4bc90 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/audit/models/EsignAuditLog.java @@ -0,0 +1,73 @@ +package com.sjmresorts.opsapp.esign.audit.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_audit_logs") +public class EsignAuditLog { + + @Id + @Column(name = "log_id", nullable = false, length = 36) + private String logId; + + @Column(name = "log_category", nullable = false, length = 32) + private String logCategory; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(name = "message", length = 512) + private String message; + + @Column(name = "operator_name", length = 128) + private String operatorName; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "task_id", length = 36) + private String taskId; + + @Column(name = "file_name", length = 512) + private String fileName; + + @Column(name = "agent_source", length = 256) + private String agentSource; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "http_method", length = 16) + private String httpMethod; + + @Column(name = "request_url", length = 1024) + private String requestUrl; + + @Column(name = "response_status") + private Integer responseStatus; + + @Column(name = "duration_ms") + private Long durationMs; + + @Column(name = "client_ip", length = 64) + private String clientIp; + + @Lob + @Column(name = "detail_json") + private String detailJson; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/audit/repositories/EsignAuditLogRepository.java b/src/main/java/com/sjmresorts/opsapp/booking/audit/repositories/EsignAuditLogRepository.java new file mode 100644 index 0000000..8a111e9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/audit/repositories/EsignAuditLogRepository.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.esign.audit.repositories; + +import com.sjmresorts.opsapp.esign.audit.models.EsignAuditLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EsignAuditLogRepository extends JpaRepository { + + Page findByLogCategoryOrderByCreatedAtDesc(String logCategory, Pageable pageable); + + Page findByLogCategoryAndEventTypeOrderByCreatedAtDesc( + String logCategory, String eventType, Pageable pageable); +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/audit/services/EsignAuditLogService.java b/src/main/java/com/sjmresorts/opsapp/booking/audit/services/EsignAuditLogService.java new file mode 100644 index 0000000..8e8cd98 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/audit/services/EsignAuditLogService.java @@ -0,0 +1,243 @@ +package com.sjmresorts.opsapp.esign.audit.services; + +import com.sjmresorts.opsapp.esign.audit.dtos.EsignAuditLogResponse; +import com.sjmresorts.opsapp.esign.audit.models.EsignAuditLog; +import com.sjmresorts.opsapp.esign.audit.repositories.EsignAuditLogRepository; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service("esignAuditLogService") +public class EsignAuditLogService { + + public static final String CATEGORY_OPERATION = "OPERATION"; + public static final String CATEGORY_THIRD_PARTY = "THIRD_PARTY"; + public static final String CATEGORY_CONNECTION = "CONNECTION"; + + public static final String EVT_WS_CONNECTED = "WS_CONNECTED"; + public static final String EVT_WS_DISCONNECTED = "WS_DISCONNECTED"; + public static final String EVT_POLL_FAILED = "POLL_FAILED"; + public static final String EVT_POLL_RECOVERED = "POLL_RECOVERED"; + public static final String EVT_WS_ERROR = "WS_ERROR"; + + /** USB 直传(PC Agent ↔ App,PDF 不经后端;仅审计元数据) */ + public static final String EVT_USB_LINK_UP = "USB_LINK_UP"; + public static final String EVT_USB_LINK_DOWN = "USB_LINK_DOWN"; + public static final String EVT_USB_PUSH_QUEUED = "USB_PUSH_QUEUED"; + public static final String EVT_USB_PUSH_SENT = "USB_PUSH_SENT"; + public static final String EVT_USB_PUSH_ACK = "USB_PUSH_ACK"; + public static final String EVT_USB_PUSH_RECEIVED = "USB_PUSH_RECEIVED"; + public static final String EVT_USB_PUSH_OPENED = "USB_PUSH_OPENED"; + public static final String EVT_USB_PUSH_FAILED = "USB_PUSH_FAILED"; + + public static final String EVT_OHIP_GET_REGISTRATION = "OHIP_GET_REGISTRATION"; + public static final String EVT_OHIP_GET_RESERVATIONS = "OHIP_GET_RESERVATIONS"; + public static final String EVT_OHIP_POST_ATTACHMENT = "OHIP_POST_ATTACHMENT"; + public static final String EVT_OHIP_POST_PROFILE_FIELDS = "OHIP_POST_PROFILE_FIELDS"; + public static final String EVT_OHIP_PUT_PROFILE = "OHIP_PUT_PROFILE"; + public static final String EVT_OHIP_POST_AUTO_ROOM_ASSIGNMENT = "OHIP_POST_AUTO_ROOM_ASSIGNMENT"; + public static final String EVT_OHIP_POST_CHECK_IN = "OHIP_POST_CHECK_IN"; + public static final String EVT_OHIP_PUT_STAY_EXTENSION = "OHIP_PUT_STAY_EXTENSION"; + + @Resource(name = "esignAuditLogRepository") + private EsignAuditLogRepository esignAuditLogRepository; + + @Transactional + public void appendOperation(String eventType, String message, String operatorName, String detailJson) { + append(CATEGORY_OPERATION, eventType, message, operatorName, null, null, null, null, null, + null, null, null, null, null, detailJson); + } + + @Transactional + public void appendCmsOperation( + String httpMethod, + String requestUri, + int responseStatus, + long durationMs, + String operatorName, + String clientIp) { + String eventType = resolveOperationEventType(httpMethod, requestUri); + String message = String.format("%s %s → HTTP %d (%dms)", httpMethod, requestUri, responseStatus, durationMs); + append(CATEGORY_OPERATION, eventType, message, operatorName, null, null, null, null, null, + httpMethod, requestUri, responseStatus, durationMs, clientIp, null); + } + + @Transactional + public void appendThirdParty( + String eventType, + String httpMethod, + String requestUrl, + Integer responseStatus, + long durationMs, + String hotelId, + String message, + String detailJson) { + append(CATEGORY_THIRD_PARTY, eventType, message, null, null, null, null, null, hotelId, + httpMethod, requestUrl, responseStatus, durationMs, null, detailJson); + } + + @Transactional + public void appendConnection( + String eventType, + Long userId, + String message, + String transport, + String clientIp, + String detailJson) { + String detail = detailJson; + if (StringUtils.isNotBlank(transport) && StringUtils.isBlank(detailJson)) { + detail = "{\"transport\":\"" + escapeJson(transport) + "\"}"; + } + append(CATEGORY_CONNECTION, eventType, message, null, userId, null, null, null, null, + null, null, null, null, clientIp, detail); + } + + private void append( + String category, + String eventType, + String message, + String operatorName, + Long userId, + String taskId, + String fileName, + String agentSource, + String hotelId, + String httpMethod, + String requestUrl, + Integer responseStatus, + Long durationMs, + String clientIp, + String detailJson) { + if (StringUtils.isBlank(category) || StringUtils.isBlank(eventType)) { + return; + } + EsignAuditLog row = new EsignAuditLog(); + row.setLogId(UUID.randomUUID().toString()); + row.setLogCategory(category.trim()); + row.setEventType(eventType.trim()); + row.setMessage(trim(message, 512)); + row.setOperatorName(trim(operatorName, 128)); + row.setUserId(userId); + row.setTaskId(trim(taskId, 36)); + row.setFileName(trim(fileName, 512)); + row.setAgentSource(trim(agentSource, 256)); + row.setHotelId(trim(hotelId, 64)); + row.setHttpMethod(trim(httpMethod, 16)); + row.setRequestUrl(trim(requestUrl, 1024)); + row.setResponseStatus(responseStatus); + row.setDurationMs(durationMs); + row.setClientIp(trim(clientIp, 64)); + row.setDetailJson(detailJson); + row.setCreatedAt(Instant.now()); + esignAuditLogRepository.save(row); + } + + @Transactional(readOnly = true) + public EsignAuditLogResponse listLogs(String category, int page, int size, String eventType) { + int safePage = Math.max(page, 0); + int safeSize = size > 0 ? Math.min(size, 200) : 50; + PageRequest pageable = PageRequest.of(safePage, safeSize); + Page result; + String cat = StringUtils.trimToEmpty(category); + if (StringUtils.isBlank(cat)) { + cat = CATEGORY_OPERATION; + } + if (StringUtils.isNotBlank(eventType)) { + result = esignAuditLogRepository.findByLogCategoryAndEventTypeOrderByCreatedAtDesc( + cat, eventType.trim(), pageable); + } else { + result = esignAuditLogRepository.findByLogCategoryOrderByCreatedAtDesc(cat, pageable); + } + + List logs = new ArrayList<>(); + for (EsignAuditLog row : result.getContent()) { + EsignAuditLogResponse.EsignAuditLogItem item = + new EsignAuditLogResponse.EsignAuditLogItem(); + item.setLogId(row.getLogId()); + item.setLogCategory(row.getLogCategory()); + item.setEventType(row.getEventType()); + item.setMessage(row.getMessage()); + item.setOperatorName(row.getOperatorName()); + item.setUserId(row.getUserId()); + item.setTaskId(row.getTaskId()); + item.setFileName(row.getFileName()); + item.setAgentSource(row.getAgentSource()); + item.setHotelId(row.getHotelId()); + item.setHttpMethod(row.getHttpMethod()); + item.setRequestUrl(row.getRequestUrl()); + item.setResponseStatus(row.getResponseStatus()); + item.setDurationMs(row.getDurationMs()); + item.setClientIp(row.getClientIp()); + item.setDetailJson(row.getDetailJson()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + logs.add(item); + } + + EsignAuditLogResponse resp = new EsignAuditLogResponse(); + EsignAuditLogResponse.EsignAuditLogData data = + new EsignAuditLogResponse.EsignAuditLogData(); + data.setTotal(result.getTotalElements()); + data.setPage(safePage); + data.setSize(safeSize); + data.setLogs(logs); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("ok"); + resp.setData(data); + return resp; + } + + private static String resolveOperationEventType(String method, String uri) { + if (uri == null) { + return "CMS_MUTATION"; + } + if (uri.contains("/printer-device-link/review")) { + return "CMS_DEVICE_LINK_REVIEW"; + } + if (uri.contains("/printer-push-rules")) { + return "CMS_PUSH_RULES"; + } + if (uri.contains("/printer-pdf-task-push") || uri.contains("/printer-pdf-task-quick-push")) { + return "CMS_PDF_PUSH"; + } + if (uri.contains("/ios-printer-pdf-task-update")) { + return "CMS_PDF_UPDATE"; + } + if (uri.contains("/hub-promotions")) { + return "CMS_HUB_PROMOTION"; + } + if (uri.contains("/hub-settings")) { + return "CMS_HUB_SETTINGS"; + } + if (uri.contains("/surveys")) { + return "CMS_SURVEY"; + } + if (uri.contains("/printer-push-groups")) { + return "CMS_PUSH_GROUP"; + } + return method + " " + uri; + } + + private static String escapeJson(String s) { + return StringUtils.defaultString(s).replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/audit/support/ThirdPartyAuditDetailBuilder.java b/src/main/java/com/sjmresorts/opsapp/booking/audit/support/ThirdPartyAuditDetailBuilder.java new file mode 100644 index 0000000..485dd34 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/audit/support/ThirdPartyAuditDetailBuilder.java @@ -0,0 +1,92 @@ +package com.sjmresorts.opsapp.esign.audit.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import com.sjmresorts.opsapp.common.utils.SimpleHttpClient; +import org.apache.commons.lang3.StringUtils; + +/** + * 第三方审计 detail_json:结构化 request / response,便于 CMS 查看。 + */ +public final class ThirdPartyAuditDetailBuilder { + + private static final int MAX_TEXT_CHARS = 16_000; + + private ThirdPartyAuditDetailBuilder() {} + + public static String build(SimpleHttpClient.RequestBuilder request, HttpResponseResult response) { + JSONObject root = new JSONObject(); + if (request != null) { + root.put("request", buildRequest(request)); + } + root.put("response", buildResponse(response)); + return root.toJSONString(); + } + + private static JSONObject buildRequest(SimpleHttpClient.RequestBuilder request) { + JSONObject obj = new JSONObject(); + obj.put("method", request.getMethod()); + obj.put("url", request.getRequestUrlWithQuery()); + obj.put("headers", SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + String body = request.getBodyContent(); + if (StringUtils.isNotBlank(body)) { + obj.put("body", sanitizeRequestBody(body)); + } + return obj; + } + + private static JSONObject buildResponse(HttpResponseResult response) { + JSONObject obj = new JSONObject(); + if (response == null) { + return obj; + } + obj.put("status", response.getStatusCode()); + if (response.isTransportError()) { + obj.put("transportError", StringUtils.defaultIfBlank(response.getErrorMessage(), "transport error")); + } + String body = response.getResponseContent(); + if (StringUtils.isNotBlank(body)) { + obj.put("body", parseJsonOrText(truncate(body))); + } + return obj; + } + + /** 请求体中的 PDF base64 不落库全文,只保留长度。 */ + private static Object sanitizeRequestBody(String body) { + try { + JSONObject json = JSON.parseObject(body); + if (json.containsKey("fileAttachment")) { + String att = json.getString("fileAttachment"); + JSONObject redacted = new JSONObject(); + redacted.put("_redacted", "base64"); + redacted.put("length", att != null ? att.length() : 0); + json.put("fileAttachment", redacted); + } + return json; + } catch (Exception e) { + return parseJsonOrText(truncate(body)); + } + } + + private static Object parseJsonOrText(String text) { + if (StringUtils.isBlank(text)) { + return null; + } + try { + return JSON.parse(text); + } catch (Exception e) { + return text; + } + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + if (value.length() <= MAX_TEXT_CHARS) { + return value; + } + return value.substring(0, MAX_TEXT_CHARS) + "...(truncated)"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/EsignApiPaths.java b/src/main/java/com/sjmresorts/opsapp/booking/common/EsignApiPaths.java new file mode 100644 index 0000000..4d1402c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/EsignApiPaths.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.common; + +import java.time.LocalDate; + +/** + * E-Sign HTTP API 路径常量;{@link #LEGACY_PREFIX} 计划于 {@link #LEGACY_SUNSET_DATE} 下线。 + */ +public final class EsignApiPaths { + + public static final String PREFIX = "/api/v1/checkin"; + public static final String LEGACY_PREFIX = "/api/v1/sinosecu"; + + /** 兼容路径计划移除日期(含当日仍可用,次日仅返回 410) */ + public static final LocalDate LEGACY_SUNSET_DATE = LocalDate.of(2026, 9, 30); + + public static final String DEPRECATION_HEADER = "Deprecation"; + public static final String SUNSET_HEADER = "Sunset"; + public static final String LINK_HEADER = "Link"; + + private EsignApiPaths() { + } + + public static boolean isLegacyPath(String uri) { + return uri != null && uri.contains(LEGACY_PREFIX); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/EsignLegacyApiProperties.java b/src/main/java/com/sjmresorts/opsapp/booking/common/EsignLegacyApiProperties.java new file mode 100644 index 0000000..94c0bf7 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/EsignLegacyApiProperties.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.esign.common; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "ops-app.esign.legacy-api") +public class EsignLegacyApiProperties { + + /** 是否响应 /api/v1/sinosecu/** 请求;false 时返回 410 Gone */ + private boolean enabled = true; + + /** 是否在响应头附加 Deprecation / Sunset 警告 */ + private boolean warnHeaders = true; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/EsignSimpleResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/EsignSimpleResponse.java new file mode 100644 index 0000000..8968627 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/EsignSimpleResponse.java @@ -0,0 +1,29 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EsignSimpleResponse { + + private boolean success; + private String code; + private String message; + + public static EsignSimpleResponse ok(String message) { + EsignSimpleResponse r = new EsignSimpleResponse(); + r.setSuccess(true); + r.setCode("200"); + r.setMessage(message != null ? message : "ok"); + return r; + } + + public static EsignSimpleResponse fail(String code, String message) { + EsignSimpleResponse r = new EsignSimpleResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/OhipProfileFieldCatalogResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/OhipProfileFieldCatalogResponse.java new file mode 100644 index 0000000..15c34ae --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/OhipProfileFieldCatalogResponse.java @@ -0,0 +1,38 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OhipProfileFieldCatalogResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private CatalogData data; + + @Data + @NoArgsConstructor + public static class CatalogData { + private List groups; + private List fields; + private List commonFields; + } + + public static OhipProfileFieldCatalogResponse ok( + List groups, List fields, List commonFields) { + OhipProfileFieldCatalogResponse r = new OhipProfileFieldCatalogResponse(); + r.setMessage("ok"); + CatalogData data = new CatalogData(); + data.setGroups(groups); + data.setFields(fields); + data.setCommonFields(commonFields); + r.setData(data); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/OhipProfileFieldDef.java b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/OhipProfileFieldDef.java new file mode 100644 index 0000000..205f349 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/OhipProfileFieldDef.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "OHIP profileDetails 固定字段定义") +public class OhipProfileFieldDef { + + @Schema(description = "稳定字段代码,存入 CMS 映射配置") + private String code; + + @Schema(description = "profileDetails 内路径") + private String path; + + @Schema(description = "分组:personNamePrimary / customer / identification …") + private String group; + + @Schema(description = "是否列入 CMS 下拉「常用字段」") + private Boolean common; + + private String labelEn; + private String labelZh; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/SignFieldOhipMappingItem.java b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/SignFieldOhipMappingItem.java new file mode 100644 index 0000000..860ee51 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/dtos/SignFieldOhipMappingItem.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SignFieldOhipMappingItem { + + /** App 签名字段名,如 field_1、field_2 */ + private String fieldName; + + /** CMS 备注 / 显示名 */ + private String label; + + /** 固定 OHIP 字段代码,见 {@link com.sjmresorts.opsapp.esign.common.support.OhipProfileFieldCatalog} */ + private String ohipFieldCode; + + /** text | signature;signature 通常仅 PDF 附件,text 可映射到 OHIP 字段 */ + private String valueType; + + private Boolean enabled; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/filters/EsignAccessLogFilter.java b/src/main/java/com/sjmresorts/opsapp/booking/common/filters/EsignAccessLogFilter.java new file mode 100644 index 0000000..99cfdb0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/filters/EsignAccessLogFilter.java @@ -0,0 +1,104 @@ +package com.sjmresorts.opsapp.esign.common.filters; + +import com.sjmresorts.opsapp.esign.common.support.EsignLogMaskUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** Esign 请求/响应摘要日志(脱敏,不含 Base64)。 */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class EsignAccessLogFilter extends OncePerRequestFilter { + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null || (!uri.contains("/esign/") && !uri.contains("/sinosecu/"))) { + return true; + } + // SSE 长连接不能被 ContentCachingResponseWrapper 缓冲 + return uri.contains("/printer-queue/stream"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + String method = request.getMethod(); + String uri = request.getRequestURI(); + String query = request.getQueryString(); + + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response); + + log.info(">>> Esign {} {}{}", method, uri, query != null ? "?" + query : ""); + + try { + filterChain.doFilter(wrappedRequest, wrappedResponse); + } finally { + logRequestBodyIfPresent(wrappedRequest); + logResponseBodyIfPresent(wrappedResponse); + log.info( + "<<< Esign {} {} status={} {}ms", + method, + uri, + wrappedResponse.getStatus(), + System.currentTimeMillis() - start); + wrappedResponse.copyBodyToResponse(); + } + } + + private void logRequestBodyIfPresent(ContentCachingRequestWrapper request) { + if (!isJsonBody(request)) { + return; + } + byte[] buf = request.getContentAsByteArray(); + if (buf == null || buf.length == 0) { + return; + } + String raw = new String(buf, StandardCharsets.UTF_8); + log.info(" request-body: {}", EsignLogMaskUtil.maskJsonForLog(raw)); + } + + private void logResponseBodyIfPresent(ContentCachingResponseWrapper response) { + if (!isJsonResponse(response)) { + return; + } + byte[] buf = response.getContentAsByteArray(); + if (buf == null || buf.length == 0) { + return; + } + String raw = new String(buf, StandardCharsets.UTF_8); + log.info(" response-body: {}", EsignLogMaskUtil.maskJsonForLog(raw)); + } + + private static boolean isJsonBody(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) && !"PUT".equalsIgnoreCase(method) && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + String ct = StringUtils.defaultString(request.getContentType()).toLowerCase(); + return ct.contains(MediaType.APPLICATION_JSON_VALUE) || ct.contains("json"); + } + + private static boolean isJsonResponse(HttpServletResponse response) { + String ct = StringUtils.defaultString(response.getContentType()).toLowerCase(); + return ct.contains(MediaType.APPLICATION_JSON_VALUE) || ct.contains("json"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/filters/EsignLegacyApiDeprecationFilter.java b/src/main/java/com/sjmresorts/opsapp/booking/common/filters/EsignLegacyApiDeprecationFilter.java new file mode 100644 index 0000000..dcdb007 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/filters/EsignLegacyApiDeprecationFilter.java @@ -0,0 +1,70 @@ +package com.sjmresorts.opsapp.esign.common.filters; + +import com.sjmresorts.opsapp.esign.common.EsignApiPaths; +import com.sjmresorts.opsapp.esign.common.EsignLegacyApiProperties; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.LocalDate; + +/** + * /api/v1/sinosecu/** 兼容层:可配置关闭、响应 Deprecation 头、Sunset 后返回 410。 + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 5) +@RequiredArgsConstructor +public class EsignLegacyApiDeprecationFilter extends OncePerRequestFilter { + + private final EsignLegacyApiProperties legacyApiProperties; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + return !EsignApiPaths.isLegacyPath(uri); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!legacyApiProperties.isEnabled()) { + response.setStatus(HttpStatus.GONE.value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"code\":\"LEGACY_API_DISABLED\",\"message\":\"/api/v1/sinosecu/ 已下线,请改用 /api/v1/checkin/\"}"); + return; + } + + LocalDate today = LocalDate.now(); + if (today.isAfter(EsignApiPaths.LEGACY_SUNSET_DATE)) { + response.setStatus(HttpStatus.GONE.value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"code\":\"LEGACY_API_SUNSET\",\"message\":\"/api/v1/sinosecu/ 已于 " + + EsignApiPaths.LEGACY_SUNSET_DATE + " 下线\"}"); + return; + } + + if (legacyApiProperties.isWarnHeaders()) { + response.setHeader(EsignApiPaths.DEPRECATION_HEADER, "true"); + response.setHeader(EsignApiPaths.SUNSET_HEADER, EsignApiPaths.LEGACY_SUNSET_DATE.toString()); + response.setHeader(EsignApiPaths.LINK_HEADER, + "<" + EsignApiPaths.PREFIX + ">; rel=\"successor-version\""); + } + + if (log.isDebugEnabled()) { + log.debug("Legacy sinosecu API: {} {}", request.getMethod(), request.getRequestURI()); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/mappers/SignFieldOhipValueMapper.java b/src/main/java/com/sjmresorts/opsapp/booking/common/mappers/SignFieldOhipValueMapper.java new file mode 100644 index 0000000..3cbd456 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/mappers/SignFieldOhipValueMapper.java @@ -0,0 +1,207 @@ +package com.sjmresorts.opsapp.esign.common.mappers; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.esign.common.dtos.OhipProfileFieldDef; +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import com.sjmresorts.opsapp.esign.common.support.OhipProfileFieldCatalog; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** 按 CMS 映射将 App signaturesJson + signFieldsJson 转为 OHIP 字段代码 → 值。 */ +public final class SignFieldOhipValueMapper { + + private SignFieldOhipValueMapper() { + } + + public static Map mapValues( + String signaturesJson, + String signFieldsJson, + List mappings) { + Map out = new LinkedHashMap<>(); + if (mappings == null || mappings.isEmpty()) { + return out; + } + JSONObject signatures = parseObject(signaturesJson); + JSONArray fields = parseArray(signFieldsJson); + if (signatures == null || signatures.isEmpty() || fields == null || fields.isEmpty()) { + return out; + } + + for (SignFieldOhipMappingItem mapping : mappings) { + if (mapping == null || Boolean.FALSE.equals(mapping.getEnabled())) { + continue; + } + String ohipCode = StringUtils.trimToEmpty(mapping.getOhipFieldCode()); + String fieldName = StringUtils.trimToEmpty(mapping.getFieldName()); + String ohipPath = OhipProfileFieldCatalog.resolvePath(ohipCode).orElse(null); + if (ohipPath == null || StringUtils.isBlank(fieldName)) { + continue; + } + JSONObject field = findFieldByName(fields, fieldName); + if (field == null) { + field = findFieldByOhipCode(fields, ohipCode); + } + if (field == null) { + continue; + } + String sigKey = StringUtils.defaultIfBlank(field.getString("id"), fieldName); + String raw = signatures.getString(sigKey); + if (StringUtils.isBlank(raw)) { + continue; + } + String valueType = normalizeValueType(mapping.getValueType(), field.getString("type")); + if ("signature".equals(valueType)) { + continue; + } + String text = StringUtils.trimToEmpty(raw); + if (text.startsWith("data:image")) { + continue; + } + if (!text.isEmpty()) { + out.put(ohipPath, text); + } + } + return out; + } + + private static JSONObject findFieldByName(JSONArray fields, String fieldName) { + String target = fieldName.toLowerCase(Locale.ROOT); + for (int i = 0; i < fields.size(); i++) { + JSONObject obj = fields.getJSONObject(i); + if (obj == null) { + continue; + } + String name = StringUtils.trimToEmpty(obj.getString("fieldName")); + if (name.equalsIgnoreCase(fieldName)) { + return obj; + } + String id = StringUtils.trimToEmpty(obj.getString("id")); + if (id.equalsIgnoreCase(fieldName) || id.toLowerCase(Locale.ROOT).equals(target)) { + return obj; + } + } + return null; + } + + /** CMS 映射 fieldName 与热区不一致时,按 OHIP 字段类型匹配 phone/email 文字热区。 */ + private static JSONObject findFieldByOhipCode(JSONArray fields, String ohipCode) { + String code = StringUtils.trimToEmpty(ohipCode).toLowerCase(Locale.ROOT); + String wantFormat = null; + if (code.startsWith("phone")) { + wantFormat = "phone"; + } else if (code.startsWith("email")) { + wantFormat = "email"; + } + if (wantFormat == null) { + return null; + } + for (int i = 0; i < fields.size(); i++) { + JSONObject obj = fields.getJSONObject(i); + if (obj == null) { + continue; + } + if (!"text".equalsIgnoreCase(StringUtils.trimToEmpty(obj.getString("type")))) { + continue; + } + if (wantFormat.equals(normalizeInputFormat(obj.getString("inputFormat")))) { + return obj; + } + } + return null; + } + + private static String normalizeInputFormat(String raw) { + String s = StringUtils.trimToEmpty(raw).toLowerCase(Locale.ROOT); + if (s.isEmpty()) { + return "text"; + } + if (s.contains("mail") || s.contains("邮") || s.contains("郵") || "email".equals(s)) { + return "email"; + } + if (s.contains("phone") || s.contains("tel") || s.contains("电话") || s.contains("電話")) { + return "phone"; + } + return s; + } + + private static String normalizeValueType(String mappingType, String fieldType) { + String mt = StringUtils.trimToEmpty(mappingType).toLowerCase(Locale.ROOT); + if ("text".equals(mt) || "signature".equals(mt)) { + return mt; + } + if (fieldType == null) { + return "text"; + } + return "text".equalsIgnoreCase(StringUtils.trimToEmpty(fieldType)) ? "text" : "signature"; + } + + private static JSONObject parseObject(String json) { + if (StringUtils.isBlank(json)) { + return new JSONObject(); + } + try { + JSONObject obj = JSON.parseObject(json.trim()); + return obj != null ? obj : new JSONObject(); + } catch (Exception e) { + return new JSONObject(); + } + } + + private static JSONArray parseArray(String json) { + if (StringUtils.isBlank(json)) { + return new JSONArray(); + } + try { + JSONArray arr = JSON.parseArray(json.trim()); + return arr != null ? arr : new JSONArray(); + } catch (Exception e) { + return new JSONArray(); + } + } + + public static List normalizeMappings(List raw) { + List out = new ArrayList<>(); + if (raw == null) { + return out; + } + for (SignFieldOhipMappingItem item : raw) { + if (item == null) { + continue; + } + String fieldName = sanitizeFieldName(item.getFieldName()); + String ohipFieldCode = resolveStoredOhipCode(item.getOhipFieldCode()); + if (StringUtils.isBlank(fieldName) && StringUtils.isBlank(ohipFieldCode)) { + continue; + } + SignFieldOhipMappingItem next = new SignFieldOhipMappingItem(); + next.setFieldName(fieldName); + next.setLabel(StringUtils.trimToNull(item.getLabel())); + next.setOhipFieldCode(ohipFieldCode); + next.setValueType(normalizeValueType(item.getValueType(), null)); + next.setEnabled(item.getEnabled() == null || Boolean.TRUE.equals(item.getEnabled())); + out.add(next); + } + return out; + } + + private static String sanitizeFieldName(String raw) { + String s = StringUtils.trimToEmpty(raw); + if (s.isEmpty()) { + return ""; + } + return s.replaceAll("[^A-Za-z0-9_]", "_").replaceAll("_+", "_").replaceAll("^_|_$", ""); + } + + private static String resolveStoredOhipCode(String raw) { + return OhipProfileFieldCatalog.findByCodeOrPath(StringUtils.trimToEmpty(raw)) + .map(OhipProfileFieldDef::getCode) + .orElse(""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/support/EsignLogMaskUtil.java b/src/main/java/com/sjmresorts/opsapp/booking/common/support/EsignLogMaskUtil.java new file mode 100644 index 0000000..80f22e5 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/support/EsignLogMaskUtil.java @@ -0,0 +1,103 @@ +package com.sjmresorts.opsapp.esign.common.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Locale; +import java.util.Set; + +/** 日志脱敏:Base64 / 大字段只打长度,不打内容。 */ +public final class EsignLogMaskUtil { + + private static final Set BINARY_KEYS = Set.of( + "base64", + "pdfbase64", + "fileattachment", + "signedpdfbase64", + "image", + "data"); + + private static final int MAX_TEXT_LEN = 240; + + private EsignLogMaskUtil() { + } + + public static String maskJsonForLog(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String trimmed = raw.trim(); + try { + Object parsed = JSON.parse(trimmed); + return JSON.toJSONString(maskNode(parsed)); + } catch (Exception e) { + return truncate(trimmed); + } + } + + public static String sizeTag(Object value) { + if (value == null) { + return "[null]"; + } + if (value instanceof byte[] bytes) { + return "[bytes len=" + bytes.length + "]"; + } + String s = String.valueOf(value); + if (s.isEmpty()) { + return "[empty]"; + } + return "[len=" + s.length() + "]"; + } + + public static String truncate(String text) { + if (text == null) { + return ""; + } + String oneLine = text.replace('\n', ' ').replace('\r', ' '); + if (oneLine.length() <= MAX_TEXT_LEN) { + return oneLine; + } + return oneLine.substring(0, MAX_TEXT_LEN) + "…"; + } + + private static Object maskNode(Object node) { + if (node instanceof JSONObject obj) { + JSONObject masked = new JSONObject(); + for (String key : obj.keySet()) { + Object value = obj.get(key); + if (isBinaryKey(key)) { + masked.put(key, sizeTag(value)); + } else if ("signaturesjson".equals(key.toLowerCase(Locale.ROOT))) { + masked.put(key, truncate(String.valueOf(value))); + } else { + masked.put(key, maskNode(value)); + } + } + return masked; + } + if (node instanceof JSONArray arr) { + JSONArray masked = new JSONArray(); + for (int i = 0; i < arr.size(); i++) { + masked.add(maskNode(arr.get(i))); + } + return masked; + } + if (node instanceof String s && s.length() > MAX_TEXT_LEN) { + return truncate(s); + } + return node; + } + + private static boolean isBinaryKey(String key) { + if (key == null) { + return false; + } + String lower = key.toLowerCase(Locale.ROOT); + if (BINARY_KEYS.contains(lower)) { + return true; + } + return lower.contains("base64") || lower.contains("attachment"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/support/EsignReservationParamUtil.java b/src/main/java/com/sjmresorts/opsapp/booking/common/support/EsignReservationParamUtil.java new file mode 100644 index 0000000..58f34cd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/support/EsignReservationParamUtil.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.common.support; + +/** 预订/酒店 ID 等请求参数的规范化(去空白、去误传引号)。 */ +public final class EsignReservationParamUtil { + + private EsignReservationParamUtil() { + } + + /** 去掉首尾空白及误传的引号(如 'grand-dragon') */ + public static String normalizeParam(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (trimmed.length() >= 2) { + char first = trimmed.charAt(0); + char last = trimmed.charAt(trimmed.length() - 1); + if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) { + trimmed = trimmed.substring(1, trimmed.length() - 1).trim(); + } + } + return trimmed; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/support/OhipProfileFieldCatalog.java b/src/main/java/com/sjmresorts/opsapp/booking/common/support/OhipProfileFieldCatalog.java new file mode 100644 index 0000000..784cb2f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/support/OhipProfileFieldCatalog.java @@ -0,0 +1,135 @@ +package com.sjmresorts.opsapp.esign.common.support; + +import com.sjmresorts.opsapp.esign.common.dtos.OhipProfileFieldDef; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** OHIP CRM profileDetails 固定字段目录(CMS 仅能选择此处定义的字段)。 */ +public final class OhipProfileFieldCatalog { + + private static final List COMMON_FIELD_CODES = List.of( + "primary.givenName", + "primary.surname", + "alternate.givenName", + "alternate.surname", + "customer.gender", + "customer.birthDate", + "customer.nationality", + "id.idType", + "id.idNumber", + "phone.number", + "email.address"); + + private static final List FIELDS = List.of( + def("primary.givenName", "customer.personName[Primary].givenName", "personNamePrimary", "First name", "英文名", true), + def("primary.middleName", "customer.personName[Primary].middleName", "personNamePrimary", "Middle name", "中间名", false), + def("primary.surname", "customer.personName[Primary].surname", "personNamePrimary", "Last name", "英文姓", true), + def("primary.nameTitle", "customer.personName[Primary].nameTitle", "personNamePrimary", "Title", "称谓", false), + def("alternate.givenName", "customer.personName[Alternate].givenName", "personNameAlternate", "Alt. first name", "中文名", true), + def("alternate.surname", "customer.personName[Alternate].surname", "personNameAlternate", "Alt. full name", "中文全名", true), + def("customer.language", "customer.language", "customer", "Language", "语言", false), + def("customer.gender", "customer.gender", "customer", "Gender (M/F/U)", "性别", true), + def("customer.birthDate", "customer.birthDate", "customer", "Date of birth", "出生日期", true), + def("customer.birthPlace", "customer.birthPlace", "customer", "Place of birth", "出生地", false), + def("customer.nationality", "customer.nationality", "customer", "Nationality", "国籍", true), + def("id.idType", "customer.identifications.identificationInfo[0].identification.idType", "identification", "Document type", "证件类型", true), + def("id.idNumber", "customer.identifications.identificationInfo[0].identification.idNumber", "identification", "Document number", "证件号码", true), + def("id.issuedCountry", "customer.identifications.identificationInfo[0].identification.issuedCountry", "identification", "Issue country", "签发国", false), + def("id.issuedDate", "customer.identifications.identificationInfo[0].identification.issuedDate", "identification", "Issue date", "签发日期", false), + def("id.issuedPlace", "customer.identifications.identificationInfo[0].identification.issuedPlace", "identification", "Issue place", "签发地", false), + def("id.expirationDate", "customer.identifications.identificationInfo[0].identification.expirationDate", "identification", "Expiration date", "到期日", false), + def("address.line1", "addresses.addressInfo[0].address.addressLine[0]", "address", "Address line 1", "地址行 1", false), + def("address.line2", "addresses.addressInfo[0].address.addressLine[1]", "address", "Address line 2", "地址行 2", false), + def("address.city", "addresses.addressInfo[0].address.cityName", "address", "City", "城市", false), + def("address.state", "addresses.addressInfo[0].address.state", "address", "State", "省/州", false), + def("address.country", "addresses.addressInfo[0].address.country.value", "address", "Country", "国家", false), + def("phone.useType", "telephones.telephoneInfo[0].telephone.phoneUseType", "telephone", "Phone type", "电话类型", false), + def("phone.number", "telephones.telephoneInfo[0].telephone.phoneNumber", "telephone", "Phone number", "电话号码", true), + def("email.address", "emails.emailInfo[0].email.emailAddress", "email", "Email", "电子邮箱", true), + def("email.type", "emails.emailInfo[0].email.type", "email", "Email type", "邮箱类型", false), + def("keyword.type", "keywords.keyword[0].type", "keyword", "Keyword type", "关键字类型", false), + def("keyword.value", "keywords.keyword[0].keyword", "keyword", "Keyword", "关键字", false)); + + private static final Map BY_CODE; + private static final Map BY_PATH; + + static { + Map byCode = new LinkedHashMap<>(); + Map byPath = new LinkedHashMap<>(); + for (OhipProfileFieldDef field : FIELDS) { + byCode.put(field.getCode(), field); + byPath.put(field.getPath(), field); + } + BY_CODE = Collections.unmodifiableMap(byCode); + BY_PATH = Collections.unmodifiableMap(byPath); + } + + private OhipProfileFieldCatalog() { + } + + public static List all() { + return FIELDS; + } + + public static List commonFields() { + List out = new ArrayList<>(); + for (String code : COMMON_FIELD_CODES) { + OhipProfileFieldDef field = BY_CODE.get(code); + if (field != null) { + out.add(field); + } + } + return out; + } + + public static Optional findByCode(String code) { + if (StringUtils.isBlank(code)) { + return Optional.empty(); + } + return Optional.ofNullable(BY_CODE.get(code.trim())); + } + + public static Optional findByCodeOrPath(String raw) { + String key = StringUtils.trimToNull(raw); + if (key == null) { + return Optional.empty(); + } + OhipProfileFieldDef byCode = BY_CODE.get(key); + if (byCode != null) { + return Optional.of(byCode); + } + return Optional.ofNullable(BY_PATH.get(key)); + } + + public static Optional resolvePath(String codeOrPath) { + return findByCodeOrPath(codeOrPath).map(OhipProfileFieldDef::getPath); + } + + public static List groupOrder() { + List groups = new ArrayList<>(); + for (OhipProfileFieldDef field : FIELDS) { + if (!groups.contains(field.getGroup())) { + groups.add(field.getGroup()); + } + } + return groups; + } + + private static OhipProfileFieldDef def( + String code, String path, String group, String labelEn, String labelZh, boolean common) { + OhipProfileFieldDef def = new OhipProfileFieldDef(); + def.setCode(code); + def.setPath(path); + def.setGroup(group); + def.setLabelEn(labelEn); + def.setLabelZh(labelZh); + def.setCommon(common); + return def; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/common/support/ProfileDetailsJsonReader.java b/src/main/java/com/sjmresorts/opsapp/booking/common/support/ProfileDetailsJsonReader.java new file mode 100644 index 0000000..8681ee8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/common/support/ProfileDetailsJsonReader.java @@ -0,0 +1,188 @@ +package com.sjmresorts.opsapp.esign.common.support; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 从 OHIP GET profile 响应的 profileDetails 中按路径读取字符串值。 */ +public final class ProfileDetailsJsonReader { + + private static final Pattern SEGMENT = + Pattern.compile("([^.\\[]+)(?:\\[([^\\]]+)\\])?"); + + private ProfileDetailsJsonReader() { + } + + public static JSONObject unwrapProfileDetails(String responseBody) { + if (StringUtils.isBlank(responseBody)) { + return new JSONObject(); + } + try { + JSONObject root = JSONObject.parseObject(responseBody.trim()); + if (root == null) { + return new JSONObject(); + } + JSONObject details = root.getJSONObject("profileDetails"); + return details != null ? details : root; + } catch (Exception e) { + return new JSONObject(); + } + } + + public static String getString(JSONObject profileDetails, String path) { + if (profileDetails == null || profileDetails.isEmpty() || StringUtils.isBlank(path)) { + return null; + } + String normalized = normalizePath(path); + if (normalized == null) { + return null; + } + Object node = profileDetails; + List segments = parsePath(normalized); + if (segments.isEmpty()) { + return null; + } + for (int i = 0; i < segments.size() - 1; i++) { + node = navigate(node, segments.get(i)); + if (node == null) { + return null; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(node instanceof JSONObject obj)) { + return null; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null || arr.size() <= leafSelector.index) { + return null; + } + Object item = arr.get(leafSelector.index); + return item != null ? StringUtils.trimToNull(String.valueOf(item)) : null; + } + return StringUtils.trimToNull(obj.getString(leaf.name())); + } + + private static String normalizePath(String raw) { + String path = StringUtils.trimToEmpty(raw); + if (path.startsWith("profileDetails.")) { + path = path.substring("profileDetails.".length()); + } + return StringUtils.trimToNull(path); + } + + private static List parsePath(String path) { + List out = new ArrayList<>(); + Matcher matcher = SEGMENT.matcher(path); + int pos = 0; + while (matcher.find(pos)) { + if (matcher.start() != pos) { + break; + } + String name = matcher.group(1); + String selector = StringUtils.trimToNull(matcher.group(2)); + out.add(new Segment(name, parseSelector(name, selector))); + pos = matcher.end(); + if (pos < path.length() && path.charAt(pos) == '.') { + pos++; + } + } + return out; + } + + private static Selector parseSelector(String segmentName, String raw) { + if (raw == null) { + return null; + } + if (raw.contains("=")) { + int eq = raw.indexOf('='); + return Selector.match(raw.substring(0, eq).trim(), raw.substring(eq + 1).trim()); + } + if (NumberUtils.isDigits(raw)) { + return Selector.index(Integer.parseInt(raw)); + } + if ("personName".equals(segmentName)) { + return Selector.match("nameType", raw); + } + if ("keyword".equals(segmentName)) { + return Selector.index(0); + } + return Selector.match("type", raw); + } + + private static Object navigate(Object current, Segment segment) { + if (!(current instanceof JSONObject obj)) { + return null; + } + Selector selector = segment.selector(); + if (selector == null) { + return obj.get(segment.name()); + } + Object node = obj.get(segment.name()); + if (!(node instanceof JSONArray arr)) { + return null; + } + if (selector.kind == SelectorKind.INDEX) { + if (arr.size() <= selector.index) { + return null; + } + return arr.get(selector.index); + } + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (item instanceof JSONObject itemObj && matches(itemObj, selector)) { + return itemObj; + } + } + return null; + } + + private static boolean matches(JSONObject obj, Selector selector) { + if (selector.kind != SelectorKind.MATCH) { + return false; + } + String actual = StringUtils.trimToEmpty(obj.getString(selector.matchKey)); + return actual.equalsIgnoreCase(selector.matchValue); + } + + private record Segment(String name, Selector selector) { + } + + private enum SelectorKind { + INDEX, + MATCH + } + + private static final class Selector { + private final SelectorKind kind; + private final int index; + private final String matchKey; + private final String matchValue; + + private Selector(SelectorKind kind, int index, String matchKey, String matchValue) { + this.kind = kind; + this.index = index; + this.matchKey = matchKey; + this.matchValue = matchValue; + } + + static Selector index(int index) { + return new Selector(SelectorKind.INDEX, index, null, null); + } + + static Selector match(String key, String value) { + return new Selector( + SelectorKind.MATCH, + -1, + StringUtils.trimToEmpty(key), + StringUtils.trimToEmpty(value)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/controllers/BookingCheckInController.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/controllers/BookingCheckInController.java new file mode 100644 index 0000000..359f151 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/controllers/BookingCheckInController.java @@ -0,0 +1,67 @@ +package com.sjmresorts.opsapp.booking.reservation.controllers; + +import com.sjmresorts.opsapp.esign.common.EsignApiPaths; +import com.sjmresorts.opsapp.esign.reservation.dtos.BookingCheckInResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.BookingStayExtensionRequest; +import com.sjmresorts.opsapp.esign.reservation.dtos.BookingStayExtensionResult; +import com.sjmresorts.opsapp.esign.reservation.services.ReservationThirdPartyClient; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.ResponseEntity; +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; + +@RestController +@RequestMapping(EsignApiPaths.PREFIX + "/bookings") +@Tag(name = "Booking Check In") +public class BookingCheckInController { + + @Resource + private ReservationThirdPartyClient reservationThirdPartyClient; + + @PostMapping("/{reservationId}/check-in") + @Operation(summary = "自动分房并办理 Booking 入住") + public ResponseEntity checkIn( + @PathVariable String reservationId, + @RequestParam(required = false) String hotelId) { + if (StringUtils.isBlank(reservationId)) { + return ResponseEntity.badRequest().body( + BookingCheckInResult.failure(400, reservationId, "reservationId required", null, null)); + } + BookingCheckInResult result = reservationThirdPartyClient.autoAssignRoomAndCheckIn(hotelId, reservationId); + return ResponseEntity.status(resolveHttpStatus(result.statusCode())).body(result); + } + + @PutMapping("/{reservationId}/stay") + @Operation(summary = "续房并修改 Booking 离店日期") + public ResponseEntity extendStay( + @PathVariable String reservationId, + @RequestParam(required = false) String hotelId, + @RequestBody BookingStayExtensionRequest request) { + if (StringUtils.isBlank(reservationId) || request == null || request.departureDate() == null) { + return ResponseEntity.badRequest().body(BookingStayExtensionResult.failure( + 400, + reservationId, + request != null && request.departureDate() != null ? request.departureDate().toString() : null, + "reservationId and departureDate required", + null)); + } + BookingStayExtensionResult result = reservationThirdPartyClient.extendStay( + hotelId, reservationId, request.departureDate()); + return ResponseEntity.status(resolveHttpStatus(result.statusCode())).body(result); + } + + private static int resolveHttpStatus(int statusCode) { + if (statusCode >= 100 && statusCode <= 599) { + return statusCode; + } + return 502; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingCheckInResult.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingCheckInResult.java new file mode 100644 index 0000000..398ef29 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingCheckInResult.java @@ -0,0 +1,34 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Booking 自动分房及入住结果") +public record BookingCheckInResult( + boolean success, + int statusCode, + String reservationId, + String roomId, + String message, + String assignmentResponse, + String checkInResponse) { + + public static BookingCheckInResult failure( + int statusCode, + String reservationId, + String message, + String assignmentResponse, + String checkInResponse) { + return new BookingCheckInResult( + false, statusCode, reservationId, null, message, assignmentResponse, checkInResponse); + } + + public static BookingCheckInResult success( + int statusCode, + String reservationId, + String roomId, + String assignmentResponse, + String checkInResponse) { + return new BookingCheckInResult( + true, statusCode, reservationId, roomId, "Booking checked in", assignmentResponse, checkInResponse); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingStayExtensionRequest.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingStayExtensionRequest.java new file mode 100644 index 0000000..a67756d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingStayExtensionRequest.java @@ -0,0 +1,11 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "续房请求") +public record BookingStayExtensionRequest( + @Schema(description = "新的离店日期", example = "2026-08-27") + LocalDate departureDate) { +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingStayExtensionResult.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingStayExtensionResult.java new file mode 100644 index 0000000..90a9195 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/BookingStayExtensionResult.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "续房结果") +public record BookingStayExtensionResult( + boolean success, + int statusCode, + String reservationId, + String departureDate, + String message, + String response) { + + public static BookingStayExtensionResult failure( + int statusCode, String reservationId, String departureDate, String message, String response) { + return new BookingStayExtensionResult(false, statusCode, reservationId, departureDate, message, response); + } + + public static BookingStayExtensionResult success( + int statusCode, String reservationId, String departureDate, String response) { + return new BookingStayExtensionResult( + true, statusCode, reservationId, departureDate, "Booking stay extended", response); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignReservationResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignReservationResponse.java new file mode 100644 index 0000000..5efd0cb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignReservationResponse.java @@ -0,0 +1,37 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "OHIP 提交结果") +public class EsignReservationResponse { + + @Schema(description = "是否成功") + private boolean success; + + @Schema(description = "状态码", example = "200") + private String code; + + @Schema(description = "说明") + private String message; + + @Schema(description = "业务数据") + private EsignReservationData data; + + @Data + @NoArgsConstructor + @Schema(description = "业务数据详情") + public static class EsignReservationData { + @Schema(description = "从 PDF 解析的 OPERA 确认号") + private String confirmationNumber; + + @Schema(description = "OHIP reservation id") + private String reservationId; + + @Schema(description = "是否已提交至 OPERA fileAttachments") + private Boolean ohipUploaded; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignReservationTaskCompleteRequest.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignReservationTaskCompleteRequest.java new file mode 100644 index 0000000..33d2b53 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignReservationTaskCompleteRequest.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "预订待处理任务完成(从待签列表移除)") +public class EsignReservationTaskCompleteRequest { + + @Schema(description = "任务 ID(与 reservationId+hotelId 二选一)") + private String taskId; + + @Schema(description = "预订 ID") + private String reservationId; + + @Schema(description = "酒店 ID") + private String hotelId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipMappingResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipMappingResponse.java new file mode 100644 index 0000000..128b005 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipMappingResponse.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignSignFieldOhipMappingResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private SignFieldOhipMappingData data; + + @Data + @NoArgsConstructor + public static class SignFieldOhipMappingData { + private SignFieldOhipMappingConfigItem config; + } + + @Data + @NoArgsConstructor + public static class SignFieldOhipMappingConfigItem { + private String configId; + private String hotelId; + private List mappings; + private String updatedAt; + } + + public static EsignSignFieldOhipMappingResponse ok(String message) { + EsignSignFieldOhipMappingResponse r = new EsignSignFieldOhipMappingResponse(); + r.setMessage(message); + return r; + } + + public static EsignSignFieldOhipMappingResponse fail(String code, String message) { + EsignSignFieldOhipMappingResponse r = new EsignSignFieldOhipMappingResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipMappingSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipMappingSaveRequest.java new file mode 100644 index 0000000..62b1c37 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipMappingSaveRequest.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +public class EsignSignFieldOhipMappingSaveRequest { + + /** 留空表示全酒店默认 */ + private String hotelId; + private List mappings; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipPreviewRequest.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipPreviewRequest.java new file mode 100644 index 0000000..7e0e60c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipPreviewRequest.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "签名字段 OHIP 映射 → profileDetails JSON 预览请求") +public class EsignSignFieldOhipPreviewRequest { + + @Schema(description = "当前编辑中的映射行(未保存也可预览)") + private List mappings; + + @Schema(description = "预览用 profileType,默认 Guest") + private String profileType; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipPreviewResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipPreviewResponse.java new file mode 100644 index 0000000..f28e293 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/EsignSignFieldOhipPreviewResponse.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "profileDetails JSON 预览响应") +public class EsignSignFieldOhipPreviewResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private PreviewData data; + + @Data + @NoArgsConstructor + public static class PreviewData { + @Schema(description = "PUT /crm/v1/profiles/{profileId} 请求体(含 profileDetails)") + private Map profileBody; + + @Schema(description = "格式化后的 JSON 字符串,便于 CMS 展示") + private String jsonPreview; + + @Schema(description = "参与组装的映射字段数量(仅 enabled 且 text)") + private Integer mappedFieldCount; + } + + public static EsignSignFieldOhipPreviewResponse ok(PreviewData data) { + EsignSignFieldOhipPreviewResponse r = new EsignSignFieldOhipPreviewResponse(); + r.setMessage("ok"); + r.setData(data); + return r; + } + + public static EsignSignFieldOhipPreviewResponse fail(String code, String message) { + EsignSignFieldOhipPreviewResponse r = new EsignSignFieldOhipPreviewResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/GuestProfileIdResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/GuestProfileIdResponse.java new file mode 100644 index 0000000..43d550f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/GuestProfileIdResponse.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "预订关联的 OHIP Guest Profile ID") +public class GuestProfileIdResponse { + + private boolean success; + private String code; + private String message; + private String reservationId; + private String hotelId; + + @Schema(description = "reservationGuests.profileInfo.profileIdList 中 type=Profile 的 id") + private String profileId; + + @Schema(description = "reservationGuests.profileInfo.profile.profileType,例如 Guest") + private String profileType; + + public static GuestProfileIdResponse ok( + String reservationId, String hotelId, String profileId, String profileType) { + GuestProfileIdResponse r = new GuestProfileIdResponse(); + r.setSuccess(true); + r.setCode("200"); + r.setMessage("ok"); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + r.setProfileId(profileId); + r.setProfileType(profileType); + return r; + } + + public static GuestProfileIdResponse ok(String reservationId, String hotelId, String profileId) { + return ok(reservationId, hotelId, profileId, null); + } + + public static GuestProfileIdResponse fail(String code, String message, String reservationId, String hotelId) { + GuestProfileIdResponse r = new GuestProfileIdResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/GuestProfileRef.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/GuestProfileRef.java new file mode 100644 index 0000000..c27ec3d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/GuestProfileRef.java @@ -0,0 +1,9 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +/** OHIP 预订旅客 Profile 引用(id + type)。 */ +public record GuestProfileRef(String profileId, String profileType) { + + public boolean hasProfileId() { + return profileId != null && !profileId.isBlank(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/HospitalityOAuthSettings.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/HospitalityOAuthSettings.java new file mode 100644 index 0000000..049952b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/HospitalityOAuthSettings.java @@ -0,0 +1,32 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import lombok.Builder; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +@Getter +@Builder +public class HospitalityOAuthSettings { + + private final boolean enabled; + private final String baseUrl; + private final String tokenPath; + private final String clientId; + private final String clientSecret; + private final String appKey; + private final String enterpriseId; + private final String hotelId; + private final String scope; + private final String grantType; + private final int timeoutMs; + + public boolean isComplete() { + return enabled + && StringUtils.isNotBlank(baseUrl) + && StringUtils.isNotBlank(clientId) + && StringUtils.isNotBlank(clientSecret) + && StringUtils.isNotBlank(appKey) + && StringUtils.isNotBlank(enterpriseId) + && StringUtils.isNotBlank(hotelId); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/OAuthTokenResponse.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/OAuthTokenResponse.java new file mode 100644 index 0000000..54a1e25 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/OAuthTokenResponse.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OAuthTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Long expiresIn; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/RegistrationCardResult.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/RegistrationCardResult.java new file mode 100644 index 0000000..42434bf --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/RegistrationCardResult.java @@ -0,0 +1,55 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class RegistrationCardResult { + + private boolean invoked; + private boolean success; + private int statusCode; + private String errorMessage; + private String fileName; + private String fileType; + private String pdfBase64; + private String responseSummary; + + public static RegistrationCardResult skipped(String reason) { + RegistrationCardResult r = new RegistrationCardResult(); + r.setInvoked(false); + r.setSuccess(false); + r.setErrorMessage(reason); + return r; + } + + public static RegistrationCardResult failure(int statusCode, String summary, String errorMessage) { + RegistrationCardResult r = new RegistrationCardResult(); + r.setInvoked(true); + r.setSuccess(false); + r.setStatusCode(statusCode); + r.setResponseSummary(summary); + r.setErrorMessage(errorMessage); + return r; + } + + public static RegistrationCardResult success( + int statusCode, + String fileName, + String fileType, + String pdfBase64, + String responseSummary) { + RegistrationCardResult r = new RegistrationCardResult(); + r.setInvoked(true); + r.setSuccess(true); + r.setStatusCode(statusCode); + r.setFileName(fileName); + r.setFileType(fileType); + r.setPdfBase64(pdfBase64); + r.setResponseSummary(responseSummary); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/ReservationLookupHit.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/ReservationLookupHit.java new file mode 100644 index 0000000..79b6005 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/ReservationLookupHit.java @@ -0,0 +1,5 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +/** OHIP 按确认号查预订时的 reservationId + 可选 Guest Profile。 */ +public record ReservationLookupHit(String reservationId, GuestProfileRef guestProfile) { +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/ReservationThirdPartyResult.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/ReservationThirdPartyResult.java new file mode 100644 index 0000000..c6679a6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/ReservationThirdPartyResult.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ReservationThirdPartyResult { + + private boolean invoked; + private int statusCode; + private String responseBody; + private String errorMessage; + + public static ReservationThirdPartyResult skipped(String reason) { + ReservationThirdPartyResult r = new ReservationThirdPartyResult(); + r.setInvoked(false); + r.setStatusCode(0); + r.setErrorMessage(reason); + return r; + } + + public static ReservationThirdPartyResult success(int statusCode, String responseBody) { + ReservationThirdPartyResult r = new ReservationThirdPartyResult(); + r.setInvoked(true); + r.setStatusCode(statusCode); + r.setResponseBody(responseBody); + return r; + } + + public static ReservationThirdPartyResult failure(int statusCode, String responseBody, String errorMessage) { + ReservationThirdPartyResult r = new ReservationThirdPartyResult(); + r.setInvoked(true); + r.setStatusCode(statusCode); + r.setResponseBody(responseBody); + r.setErrorMessage(errorMessage); + return r; + } + + public boolean isHttpSuccess() { + return invoked && statusCode >= 200 && statusCode < 300; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/SignedRegistrationCardSubmitRequest.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/SignedRegistrationCardSubmitRequest.java new file mode 100644 index 0000000..5809e03 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/SignedRegistrationCardSubmitRequest.java @@ -0,0 +1,44 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "登记卡签名提交 OPERA(通知/预订流程,无需打印机任务 ID)") +public class SignedRegistrationCardSubmitRequest { + + @Schema(description = "OHIP reservation id(linkId)", requiredMode = Schema.RequiredMode.REQUIRED) + private String reservationId; + + @Schema(description = "酒店 ID") + private String hotelId; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "原始登记卡 PDF Base64(用于合成签名或与 signaturesJson 配合)") + private String pdfBase64; + + @Schema(description = "签名后完整 PDF Base64(可选,优先使用)") + private String base64; + + @Schema(description = "签名图层 JSON") + private String signaturesJson; + + @Schema(description = "签名数量") + private Integer signatureCount; + + @Schema(description = "确认号(可选,未传则从 PDF 解析)") + private String confirmationNumber; + + @Schema(description = "签名热区 JSON(与 App loadSignFields 一致)") + private String signFieldsJson; + + @Schema(description = "OHIP Guest Profile ID(可选,未传则服务端从预订详情解析)") + private String profileId; + + @Schema(description = "OHIP profile.profileType(可选,例如 Guest)") + private String profileType; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/SignedRegistrationCardUploadResult.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/SignedRegistrationCardUploadResult.java new file mode 100644 index 0000000..eccadaa --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/dtos/SignedRegistrationCardUploadResult.java @@ -0,0 +1,72 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class SignedRegistrationCardUploadResult { + + private boolean invoked; + private boolean success; + private int statusCode; + private String confirmationNumber; + private String reservationId; + private String fileName; + /** 从预订查询/详情解析的 OHIP Profile ID(用于签名字段回写)。 */ + private String profileId; + private String profileType; + private String errorMessage; + private String responseSummary; + + public static SignedRegistrationCardUploadResult skipped(String reason) { + SignedRegistrationCardUploadResult r = new SignedRegistrationCardUploadResult(); + r.setInvoked(false); + r.setSuccess(false); + r.setErrorMessage(reason); + return r; + } + + public static SignedRegistrationCardUploadResult failure( + int statusCode, String summary, String errorMessage) { + return failure(statusCode, summary, errorMessage, null, null, null); + } + + public static SignedRegistrationCardUploadResult failure( + int statusCode, + String summary, + String errorMessage, + String confirmationNumber, + String reservationId, + String fileName) { + SignedRegistrationCardUploadResult r = new SignedRegistrationCardUploadResult(); + r.setInvoked(true); + r.setSuccess(false); + r.setStatusCode(statusCode); + r.setResponseSummary(summary); + r.setErrorMessage(errorMessage); + r.setConfirmationNumber(confirmationNumber); + r.setReservationId(reservationId); + r.setFileName(fileName); + return r; + } + + public static SignedRegistrationCardUploadResult success( + int statusCode, + String confirmationNumber, + String reservationId, + String fileName, + String responseSummary) { + SignedRegistrationCardUploadResult r = new SignedRegistrationCardUploadResult(); + r.setInvoked(true); + r.setSuccess(true); + r.setStatusCode(statusCode); + r.setConfirmationNumber(confirmationNumber); + r.setReservationId(reservationId); + r.setFileName(fileName); + r.setResponseSummary(responseSummary); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/models/EsignReservationTask.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/models/EsignReservationTask.java new file mode 100644 index 0000000..e2402ed --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/models/EsignReservationTask.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.esign.reservation.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_reservation_tasks") +public class EsignReservationTask { + + @Id + @Column(name = "task_id", nullable = false, length = 36) + private String taskId; + + @Column(name = "reservation_id", nullable = false, length = 64) + private String reservationId; + + @Column(name = "hotel_id", nullable = false, length = 64) + private String hotelId; + + @Column(name = "status", nullable = false, length = 32) + private String status; + + @Column(name = "title", length = 256) + private String title; + + @Column(name = "body", length = 512) + private String body; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + /** 用户打开登记卡并完成处理的时间 */ + @Column(name = "completed_at") + private Instant completedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/models/EsignSignFieldOhipConfig.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/models/EsignSignFieldOhipConfig.java new file mode 100644 index 0000000..093809d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/models/EsignSignFieldOhipConfig.java @@ -0,0 +1,34 @@ +package com.sjmresorts.opsapp.esign.reservation.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_sign_field_ohip_config") +public class EsignSignFieldOhipConfig { + + @Id + @Column(name = "config_id", nullable = false, length = 36) + private String configId; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Lob + @Column(name = "mappings_json", nullable = false) + private String mappingsJson; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/repositories/EsignReservationTaskRepository.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/repositories/EsignReservationTaskRepository.java new file mode 100644 index 0000000..05c02f2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/repositories/EsignReservationTaskRepository.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.reservation.repositories; + +import com.sjmresorts.opsapp.esign.reservation.models.EsignReservationTask; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface EsignReservationTaskRepository extends JpaRepository { + + Optional findFirstByReservationIdAndHotelIdAndStatusOrderByCreatedAtDesc( + String reservationId, String hotelId, String status); + + List findTop100ByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc( + String status, Instant createdAtMinInclusive); + + List findTop100ByStatusAndCreatedAtLessThanOrderByCreatedAtDesc( + String status, Instant createdAtMaxExclusive); + + List findTop100ByStatusOrderByCompletedAtDesc(String status); +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/repositories/EsignSignFieldOhipConfigRepository.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/repositories/EsignSignFieldOhipConfigRepository.java new file mode 100644 index 0000000..e7d2bd6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/repositories/EsignSignFieldOhipConfigRepository.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.esign.reservation.repositories; + +import com.sjmresorts.opsapp.esign.reservation.models.EsignSignFieldOhipConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EsignSignFieldOhipConfigRepository extends JpaRepository { + + Optional findFirstByHotelIdOrderByUpdatedAtDesc(String hotelId); + + Optional findFirstByHotelIdIsNullOrderByUpdatedAtDesc(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/services/ReservationThirdPartyClient.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/services/ReservationThirdPartyClient.java new file mode 100644 index 0000000..dcf9a20 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/services/ReservationThirdPartyClient.java @@ -0,0 +1,948 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import com.sjmresorts.opsapp.common.utils.SimpleHttpClient; +import com.sjmresorts.opsapp.esign.audit.services.EsignAuditLogService; +import com.sjmresorts.opsapp.esign.audit.support.ThirdPartyAuditDetailBuilder; +import com.sjmresorts.opsapp.esign.reservation.dtos.BookingCheckInResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.BookingStayExtensionResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.ReservationThirdPartyResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import com.sjmresorts.opsapp.esign.reservation.support.HospitalityOAuthTokenSingleton; +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileRef; +import com.sjmresorts.opsapp.esign.reservation.support.ProfileDetailsJsonBuilder; +import com.sjmresorts.opsapp.esign.common.support.ProfileDetailsJsonReader; +import com.sjmresorts.opsapp.esign.reservation.support.ReservationDetailResponseParser; +import com.sjmresorts.opsapp.esign.reservation.dtos.ReservationLookupHit; +import com.sjmresorts.opsapp.esign.reservation.support.ReservationLookupResponseParser; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +public class ReservationThirdPartyClient { + + private static final int RESPONSE_BODY_LOG_MAX = 2000; + + private final HospitalityOAuthTokenSingleton oauthTokenSingleton = HospitalityOAuthTokenSingleton.getInstance(); + + @Value("${ops-app.esign.reservation.third-party.base-url:}") + private String baseUrl; + + @Value("${ops-app.esign.reservation.third-party.timeout-ms:30000}") + private int timeoutMs; + + @Value("${ops-app.esign.reservation.third-party.registration-card.regenerate:false}") + private boolean regenerate; + + @Value("${ops-app.esign.reservation.third-party.registration-card.signed-only:false}") + private boolean signedOnly; + + @Value("${ops-app.esign.reservation.third-party.registration-card.reservation-id-context:OPERA}") + private String reservationIdContext; + + @Value("${ops-app.esign.reservation.third-party.registration-card.reservation-id-type:Reservation}") + private String reservationIdType; + + @Value("${ops-app.esign.reservation.third-party.registration-card.language:EN}") + private String language; + + @Value("${ops-app.esign.reservation.third-party.signed-upload.enabled:true}") + private boolean signedUploadEnabled; + + @Value("${ops-app.esign.reservation.third-party.reservation-lookup.path-template:/rsv/v1/hotels/{hotelId}/reservations}") + private String reservationLookupPathTemplate; + + @Value("${ops-app.esign.reservation.third-party.reservation-lookup.fetch-instructions:Reservation}") + private String reservationLookupFetchInstructions; + + @Value("${ops-app.esign.reservation.third-party.reservation-detail.path-template:/rsv/v1/hotels/{hotelId}/reservations/{reservationId}}") + private String reservationDetailPathTemplate; + + @Value("${ops-app.esign.reservation.third-party.booking-check-in.auto-room-assignment.path-template:/fof/v1/hotels/{hotelId}/reservations/autoRoomAssignments}") + private String autoRoomAssignmentPathTemplate; + + @Value("${ops-app.esign.reservation.third-party.booking-check-in.check-in.path-template:/fof/v1/hotels/{hotelId}/reservations/{reservationId}/checkIns}") + private String checkInPathTemplate; + + @Value("${ops-app.esign.reservation.third-party.booking-stay-extension.path-template:/rsv/v1/hotels/{hotelId}/reservations/{reservationId}}") + private String bookingStayExtensionPathTemplate; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.path:/med/config/v1/fileAttachments}") + private String fileAttachmentPath; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.user-name:USER@HOTEL}") + private String fileAttachmentUserName; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.description:E}") + private String fileAttachmentDescription; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.link-type:Reservation}") + private String fileAttachmentLinkType; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.overwrite-existing:Y}") + private String fileAttachmentOverwrite; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.global-yn:N}") + private String fileAttachmentGlobalYn; + + @Value("${ops-app.esign.reservation.third-party.profile-fields.enabled:false}") + private boolean profileFieldsEnabled; + + @Value("${ops-app.esign.reservation.third-party.profile-fields.path-template:/crm/v1/profiles/{profileId}}") + private String profileFieldsPathTemplate; + + @Resource(name = "esignAuditLogService") + private EsignAuditLogService auditLogService; + + /** notify 流程仍跳过第三方,仅发推送 */ + public ReservationThirdPartyResult notifyReservation(String reservationId, String hotelId) { + log.debug("notifyReservation skips third party, reservationId={}, hotelId={}", reservationId, hotelId); + return ReservationThirdPartyResult.skipped("Third party disabled in notify flow"); + } + + /** + * 签名登记卡提交 OPERA:确认号 → reservation id → fileAttachments。 + */ + public SignedRegistrationCardUploadResult uploadSignedRegistrationCard( + String hotelId, + String confirmationNumber, + String signedPdfBase64, + String preferredFileName) { + log.info( + "OHIP uploadSignedRegistrationCard start hotelId={}, confirmation={}, signedPdfLen={}, fileName={}", + hotelId, + confirmationNumber, + StringUtils.length(signedPdfBase64), + preferredFileName); + if (!signedUploadEnabled) { + log.info("OHIP uploadSignedRegistrationCard skip: signed-upload disabled"); + return SignedRegistrationCardUploadResult.skipped("Signed upload disabled"); + } + if (StringUtils.isBlank(baseUrl)) { + log.info("OHIP uploadSignedRegistrationCard skip: base-url not configured"); + return SignedRegistrationCardUploadResult.skipped("Third party base-url not configured"); + } + if (!oauthTokenSingleton.isConfigured()) { + log.info("OHIP uploadSignedRegistrationCard skip: OAuth not configured"); + return SignedRegistrationCardUploadResult.skipped("OHIP OAuth is not configured"); + } + if (StringUtils.isBlank(confirmationNumber)) { + return SignedRegistrationCardUploadResult.failure( + 400, null, "confirmationNumber required"); + } + if (StringUtils.isBlank(signedPdfBase64)) { + log.warn("OHIP uploadSignedRegistrationCard fail: signed PDF empty"); + return SignedRegistrationCardUploadResult.failure( + 400, null, "signed PDF base64 required"); + } + + String ohipHotelId = resolveOhipHotelId(hotelId); + String confNo = confirmationNumber.trim(); + log.info("OHIP step1 reservation lookup hotelId={} (resolved={}), confirmation={}", hotelId, ohipHotelId, confNo); + + Optional lookupHit = lookupReservationByConfirmation(ohipHotelId, confNo); + if (lookupHit.isEmpty()) { + log.warn("OHIP step1 reservation lookup miss confirmation={}", confNo); + return SignedRegistrationCardUploadResult.failure( + 404, null, "Reservation not found for confirmation " + confNo); + } + ReservationLookupHit hit = lookupHit.get(); + String reservationId = hit.reservationId(); + log.info("OHIP step1 reservation lookup hit confirmation={}, reservationId={}", confNo, reservationId); + SignedRegistrationCardUploadResult uploaded = uploadFileAttachment( + ohipHotelId, reservationId, confNo, signedPdfBase64, preferredFileName); + attachGuestProfile(uploaded, hit.guestProfile()); + return uploaded; + } + + private static void attachGuestProfile( + SignedRegistrationCardUploadResult result, GuestProfileRef guestProfile) { + if (result == null || guestProfile == null || !result.isSuccess()) { + return; + } + if (StringUtils.isNotBlank(guestProfile.profileId())) { + result.setProfileId(guestProfile.profileId()); + } + if (StringUtils.isNotBlank(guestProfile.profileType())) { + result.setProfileType(guestProfile.profileType()); + } + } + + /** + * 已知 reservation id 时直接上传(登记卡通知流程),跳过确认号查询。 + */ + public SignedRegistrationCardUploadResult uploadSignedRegistrationCardByReservationId( + String hotelId, + String reservationId, + String confirmationNumber, + String signedPdfBase64, + String preferredFileName) { + if (!signedUploadEnabled) { + return SignedRegistrationCardUploadResult.skipped("Signed upload disabled"); + } + if (StringUtils.isBlank(baseUrl)) { + return SignedRegistrationCardUploadResult.skipped("Third party base-url not configured"); + } + if (!oauthTokenSingleton.isConfigured()) { + return SignedRegistrationCardUploadResult.skipped("OHIP OAuth is not configured"); + } + if (StringUtils.isAnyBlank(reservationId, signedPdfBase64)) { + return SignedRegistrationCardUploadResult.failure( + 400, null, "reservationId and signed PDF base64 required"); + } + String ohipHotelId = resolveOhipHotelId(hotelId); + String confNo = StringUtils.defaultIfBlank(confirmationNumber, reservationId.trim()); + return uploadFileAttachment( + ohipHotelId, reservationId.trim(), confNo, signedPdfBase64, preferredFileName); + } + + public BookingCheckInResult autoAssignRoomAndCheckIn(String hotelId, String reservationId) { + String reservationKey = StringUtils.trimToNull(reservationId); + String ohipHotelId = resolveOhipHotelId(hotelId); + if (StringUtils.isAnyBlank(baseUrl, ohipHotelId, reservationKey)) { + return BookingCheckInResult.failure(400, reservationId, "hotelId and reservationId required", null, null); + } + if (!oauthTokenSingleton.isConfigured()) { + return BookingCheckInResult.failure(503, reservationKey, "OHIP OAuth is not configured", null, null); + } + + JSONObject assignmentBody = new JSONObject(); + JSONObject criteria = new JSONObject(); + JSONArray reservationCriteria = new JSONArray(); + JSONArray reservationGroup = new JSONArray(); + JSONObject reservation = new JSONObject(); + reservation.put("type", "Reservation"); + reservation.put("id", reservationKey); + reservationGroup.add(reservation); + reservationCriteria.add(reservationGroup); + criteria.put("reservationCriteria", reservationCriteria); + assignmentBody.put("criteria", criteria); + + String assignmentPath = autoRoomAssignmentPathTemplate + .replace("{hotelId}", urlEncodePathSegment(ohipHotelId)); + String assignmentUrl = SimpleHttpClient.joinUrl(baseUrl, assignmentPath); + HttpResponseResult assignmentResponse; + try { + SimpleHttpClient.RequestBuilder assignmentRequest = SimpleHttpClient.post(assignmentUrl) + .timeoutMs(timeoutMs) + .jsonBody(assignmentBody.toJSONString()); + applyBusinessAuth(assignmentRequest, ohipHotelId); + long start = System.currentTimeMillis(); + assignmentResponse = assignmentRequest.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_POST_AUTO_ROOM_ASSIGNMENT, + assignmentRequest, + assignmentResponse, + start, + ohipHotelId, + "POST auto room assignment reservationId=" + reservationKey); + } catch (IllegalStateException e) { + return BookingCheckInResult.failure(502, reservationKey, e.getMessage(), null, null); + } + + String assignmentSummary = truncate(assignmentResponse.getResponseContent()); + if (assignmentResponse.isTransportError()) { + return BookingCheckInResult.failure( + 502, reservationKey, StringUtils.defaultIfBlank(assignmentResponse.getErrorMessage(), "OHIP auto room assignment transport error"), assignmentSummary, null); + } + if (!assignmentResponse.isSuccessful()) { + return BookingCheckInResult.failure( + assignmentResponse.getStatusCode(), reservationKey, "OHIP auto room assignment returned HTTP " + assignmentResponse.getStatusCode(), assignmentSummary, null); + } + + String roomId = extractRoomId(assignmentResponse.bodyOrEmpty(), reservationKey); + JSONObject checkInBody = new JSONObject(); + JSONObject checkInReservation = new JSONObject(); + checkInReservation.put("ignoreWarnings", true); + checkInReservation.put("stopCheckin", false); + checkInReservation.put("printRegistration", false); + checkInBody.put("reservation", checkInReservation); + JSONArray fetchReservationInstruction = new JSONArray(); + fetchReservationInstruction.add("ReservationDetail"); + checkInBody.put("fetchReservationInstruction", fetchReservationInstruction); + checkInBody.put("includeNotifications", true); + + String checkInPath = checkInPathTemplate + .replace("{hotelId}", urlEncodePathSegment(ohipHotelId)) + .replace("{reservationId}", urlEncodePathSegment(reservationKey)); + String checkInUrl = SimpleHttpClient.joinUrl(baseUrl, checkInPath); + HttpResponseResult checkInResponse; + try { + SimpleHttpClient.RequestBuilder checkInRequest = SimpleHttpClient.post(checkInUrl) + .timeoutMs(timeoutMs) + .jsonBody(checkInBody.toJSONString()); + applyBusinessAuth(checkInRequest, ohipHotelId); + long start = System.currentTimeMillis(); + checkInResponse = checkInRequest.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_POST_CHECK_IN, + checkInRequest, + checkInResponse, + start, + ohipHotelId, + "POST check in reservationId=" + reservationKey); + } catch (IllegalStateException e) { + return BookingCheckInResult.failure(502, reservationKey, e.getMessage(), assignmentSummary, null); + } + + String checkInSummary = truncate(checkInResponse.getResponseContent()); + if (checkInResponse.isTransportError()) { + return BookingCheckInResult.failure( + 502, reservationKey, StringUtils.defaultIfBlank(checkInResponse.getErrorMessage(), "OHIP check in transport error"), assignmentSummary, checkInSummary); + } + if (!checkInResponse.isSuccessful()) { + return BookingCheckInResult.failure( + checkInResponse.getStatusCode(), reservationKey, extractOhipErrorMessage(checkInResponse.bodyOrEmpty(), "OHIP check in returned HTTP " + checkInResponse.getStatusCode()), assignmentSummary, checkInSummary); + } + return BookingCheckInResult.success( + checkInResponse.getStatusCode(), reservationKey, roomId, assignmentSummary, checkInSummary); + } + + public BookingStayExtensionResult extendStay(String hotelId, String reservationId, LocalDate departureDate) { + String reservationKey = StringUtils.trimToNull(reservationId); + String ohipHotelId = resolveOhipHotelId(hotelId); + String departureDateValue = departureDate != null ? departureDate.toString() : null; + if (StringUtils.isAnyBlank(baseUrl, ohipHotelId, reservationKey) || departureDate == null) { + return BookingStayExtensionResult.failure( + 400, reservationId, departureDateValue, "hotelId, reservationId and departureDate required", null); + } + if (!oauthTokenSingleton.isConfigured()) { + return BookingStayExtensionResult.failure( + 503, reservationKey, departureDateValue, "OHIP OAuth is not configured", null); + } + + JSONObject roomStay = new JSONObject(); + roomStay.put("departureDate", departureDateValue); + JSONObject reservation = new JSONObject(); + reservation.put("roomStay", roomStay); + JSONArray reservations = new JSONArray(); + reservations.add(reservation); + JSONObject body = new JSONObject(); + body.put("reservations", reservations); + + String path = bookingStayExtensionPathTemplate + .replace("{hotelId}", urlEncodePathSegment(ohipHotelId)) + .replace("{reservationId}", urlEncodePathSegment(reservationKey)); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.put(url) + .timeoutMs(timeoutMs) + .jsonBody(body.toJSONString()); + applyBusinessAuth(request, ohipHotelId); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_PUT_STAY_EXTENSION, + request, + response, + start, + ohipHotelId, + "PUT stay extension reservationId=" + reservationKey + " departureDate=" + departureDateValue); + String summary = truncate(response.getResponseContent()); + if (response.isTransportError()) { + return BookingStayExtensionResult.failure( + 502, + reservationKey, + departureDateValue, + StringUtils.defaultIfBlank(response.getErrorMessage(), "OHIP stay extension transport error"), + summary); + } + if (!response.isSuccessful()) { + return BookingStayExtensionResult.failure( + response.getStatusCode(), + reservationKey, + departureDateValue, + extractOhipErrorMessage( + response.bodyOrEmpty(), + "OHIP stay extension returned HTTP " + response.getStatusCode()), + summary); + } + return BookingStayExtensionResult.success( + response.getStatusCode(), reservationKey, departureDateValue, summary); + } catch (IllegalStateException e) { + return BookingStayExtensionResult.failure(502, reservationKey, departureDateValue, e.getMessage(), null); + } + } + + public Optional lookupReservationIdByConfirmation(String hotelId, String confirmationNumber) { + return lookupReservationByConfirmation(hotelId, confirmationNumber).map(ReservationLookupHit::reservationId); + } + + public Optional lookupReservationByConfirmation(String hotelId, String confirmationNumber) { + String ohipHotelId = resolveOhipHotelId(hotelId); + if (StringUtils.isAnyBlank(baseUrl, ohipHotelId, confirmationNumber)) { + log.warn( + "OHIP reservation lookup skip: baseUrl={}, hotelId={}, resolvedHotelId={}, confirmation={}", + StringUtils.isNotBlank(baseUrl), + hotelId, + ohipHotelId, + confirmationNumber); + return Optional.empty(); + } + if (!oauthTokenSingleton.isConfigured()) { + log.warn("OHIP reservation lookup skip: OAuth not configured"); + return Optional.empty(); + } + String path = reservationLookupPathTemplate.replace("{hotelId}", urlEncodePathSegment(ohipHotelId)); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.get(url) + .timeoutMs(timeoutMs) + .queryParam("confirmationNumberList", confirmationNumber.trim()) + .queryParam("fetchInstructions", reservationLookupFetchInstructions); + applyBusinessAuth(request, ohipHotelId); + log.info( + "OHIP GET reservations request url={}, hotelId={}, confirmationNumber={}, headers={}", + request.getRequestUrlWithQuery(), + ohipHotelId, + confirmationNumber, + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_GET_RESERVATIONS, + request, + response, + start, + ohipHotelId, + "GET reservations confirmation=" + confirmationNumber); + String body = response.getResponseContent(); + log.info( + "OHIP GET reservations response http={}, body={}", + response.getStatusCode(), + truncate(body)); + if (response.isTransportError()) { + log.error("OHIP GET reservations transport error: {}", response.errorOrEmpty()); + return Optional.empty(); + } + if (!response.isSuccessful()) { + return Optional.empty(); + } + Optional reservationId = + ReservationLookupResponseParser.extractReservationId(body, confirmationNumber.trim()); + Optional guestProfile = ReservationDetailResponseParser.extractGuestProfile(body); + if (reservationId.isPresent()) { + log.info( + "OHIP GET reservations parsed reservationId={} profileId={} for confirmation={}", + reservationId.get(), + guestProfile.map(GuestProfileRef::profileId).orElse(null), + confirmationNumber); + return Optional.of(new ReservationLookupHit(reservationId.get(), guestProfile.orElse(null))); + } else { + log.warn( + "OHIP GET reservations HTTP 200 but no reservationId for confirmation={}, body={}", + confirmationNumber, + truncate(body)); + } + return Optional.empty(); + } catch (Exception e) { + log.error("OHIP GET reservations failed", e); + return Optional.empty(); + } + } + + public Optional fetchGuestProfileId(String hotelId, String reservationId) { + return fetchGuestProfile(hotelId, reservationId).map(GuestProfileRef::profileId); + } + + /** + * GET /rsv/v1/hotels/{hotelId}/reservations/{reservationId}?fetchInstructions=Reservation + * 解析 reservationGuests[].profileInfo 中的 profileId 与 profile.profileType。 + */ + public Optional fetchGuestProfile(String hotelId, String reservationId) { + String reservationKey = StringUtils.trimToNull(reservationId); + if (StringUtils.isNotBlank(reservationKey) && "printer-import".equalsIgnoreCase(reservationKey)) { + reservationKey = null; + } + String ohipHotelId = resolveOhipHotelId(hotelId); + if (StringUtils.isAnyBlank(baseUrl, ohipHotelId, reservationKey)) { + log.warn( + "OHIP reservation detail skip: baseUrl={}, hotelId={}, resolvedHotelId={}, reservationId={}", + StringUtils.isNotBlank(baseUrl), + hotelId, + ohipHotelId, + reservationId); + return Optional.empty(); + } + if (!oauthTokenSingleton.isConfigured()) { + log.warn("OHIP reservation detail skip: OAuth not configured"); + return Optional.empty(); + } + String path = reservationDetailPathTemplate + .replace("{hotelId}", urlEncodePathSegment(ohipHotelId)) + .replace("{reservationId}", urlEncodePathSegment(reservationKey)); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.get(url) + .timeoutMs(timeoutMs) + .queryParam("fetchInstructions", reservationLookupFetchInstructions); + applyBusinessAuth(request, ohipHotelId); + log.info( + "OHIP GET reservation detail request url={}, hotelId={}, reservationId={}, headers={}", + request.getRequestUrlWithQuery(), + ohipHotelId, + reservationKey, + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_GET_RESERVATIONS, + request, + response, + start, + ohipHotelId, + "GET reservation detail reservationId=" + reservationId); + String body = response.getResponseContent(); + log.info( + "OHIP GET reservation detail response http={}, body={}", + response.getStatusCode(), + truncate(body)); + if (response.isTransportError()) { + log.error("OHIP GET reservation detail transport error: {}", response.errorOrEmpty()); + return Optional.empty(); + } + if (!response.isSuccessful()) { + return Optional.empty(); + } + Optional profile = ReservationDetailResponseParser.extractGuestProfile(body); + if (profile.isPresent()) { + log.info( + "OHIP GET reservation detail profileId={}, profileType={}, reservationId={}", + profile.get().profileId(), + profile.get().profileType(), + reservationKey); + } else { + log.warn( + "OHIP GET reservation detail HTTP 200 but no profile reservationId={}, body={}", + reservationId, + truncate(body)); + } + return profile; + } catch (Exception e) { + log.error("OHIP GET reservation detail failed reservationId={}", reservationId, e); + return Optional.empty(); + } + } + + /** + * GET /crm/v1/profiles/{profileId},返回 profileDetails 对象(用于签名字段预填)。 + */ + public Optional fetchGuestProfileDetails(String hotelId, String profileId) { + if (StringUtils.isAnyBlank(baseUrl, profileId)) { + return Optional.empty(); + } + if (!oauthTokenSingleton.isConfigured()) { + log.warn("OHIP GET profile skip: OAuth not configured"); + return Optional.empty(); + } + String ohipHotelId = StringUtils.isNotBlank(hotelId) ? resolveOhipHotelId(hotelId) : null; + String path = profileFieldsPathTemplate.replace( + "{profileId}", urlEncodePathSegment(profileId.trim())); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.get(url).timeoutMs(timeoutMs); + if (StringUtils.isNotBlank(ohipHotelId)) { + applyBusinessAuth(request, ohipHotelId); + } + log.info( + "OHIP GET profile request url={}, profileId={}, headers={}", + url, + profileId, + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_PUT_PROFILE, + request, + response, + start, + ohipHotelId, + "GET profile profileId=" + profileId); + String body = response.getResponseContent(); + log.info( + "OHIP GET profile response http={}, body={}", + response.getStatusCode(), + truncate(body)); + if (response.isTransportError()) { + log.error("OHIP GET profile transport error: {}", response.errorOrEmpty()); + return Optional.empty(); + } + if (!response.isSuccessful()) { + return Optional.empty(); + } + JSONObject details = ProfileDetailsJsonReader.unwrapProfileDetails(body); + if (details == null || details.isEmpty()) { + log.warn("OHIP GET profile empty profileId={}, body={}", profileId, truncate(body)); + return Optional.empty(); + } + return Optional.of(details); + } catch (Exception e) { + log.error("OHIP GET profile failed profileId={}", profileId, e); + return Optional.empty(); + } + } + + private SignedRegistrationCardUploadResult uploadFileAttachment( + String hotelId, + String reservationId, + String confirmationNumber, + String signedPdfBase64, + String preferredFileName) { + String url = SimpleHttpClient.joinUrl(baseUrl, fileAttachmentPath); + String fileName = buildSignedFileName(preferredFileName, confirmationNumber); + JSONObject body = new JSONObject(); + body.put("fileName", fileName); + body.put("linkId", reservationId); + body.put("overwriteExistingFileYN", fileAttachmentOverwrite); + body.put("description", fileAttachmentDescription); + body.put("linkType", fileAttachmentLinkType); + body.put("hotelId", hotelId); + body.put("userName", fileAttachmentUserName); + body.put("globalYN", fileAttachmentGlobalYn); + String attachmentBase64 = normalizePdfBase64(signedPdfBase64); + body.put("fileAttachment", attachmentBase64); + logAttachmentPreview(attachmentBase64); + + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.post(url) + .timeoutMs(timeoutMs) + .jsonBody(body.toJSONString()); + applyBusinessAuth(request, hotelId); + log.info( + "OHIP POST fileAttachments request url={}, hotelId={}, reservationId={}, confirmation={}, fileName={}, attachmentLen={}, headers={}", + url, + hotelId, + reservationId, + confirmationNumber, + fileName, + StringUtils.length(signedPdfBase64), + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_POST_ATTACHMENT, + request, + response, + start, + hotelId, + "POST fileAttachments confirmation=" + confirmationNumber); + String summary = truncate(response.getResponseContent()); + log.info( + "OHIP POST fileAttachments response http={}, body={}", + response.getStatusCode(), + summary); + if (response.isTransportError()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + StringUtils.defaultIfBlank(response.getErrorMessage(), "fileAttachments transport error"), + confirmationNumber, + reservationId, + fileName); + } + if (!response.isSuccessful()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + "fileAttachments returned HTTP " + response.getStatusCode(), + confirmationNumber, + reservationId, + fileName); + } + return SignedRegistrationCardUploadResult.success( + response.getStatusCode(), + confirmationNumber, + reservationId, + fileName, + summary); + } catch (IllegalStateException e) { + log.error("FileAttachment upload failed", e); + return SignedRegistrationCardUploadResult.failure(-1, null, e.getMessage()); + } + } + + /** + * 将 CMS 映射后的文字字段通过 CRM PUT /profiles/{profileId} 更新旅客 Profile(需 profile-fields.enabled=true)。 + */ + public SignedRegistrationCardUploadResult submitMappedProfileFields( + String hotelId, String reservationId, Map fields) { + return submitMappedProfileFields(hotelId, reservationId, fields, null); + } + + public SignedRegistrationCardUploadResult submitMappedProfileFields( + String hotelId, String reservationId, Map fields, String profileId) { + return submitMappedProfileFields(hotelId, reservationId, fields, profileId, null); + } + + public SignedRegistrationCardUploadResult submitMappedProfileFields( + String hotelId, + String reservationId, + Map fields, + String profileId, + String profileType) { + if (fields == null || fields.isEmpty()) { + return SignedRegistrationCardUploadResult.skipped("No mapped profile fields"); + } + if (StringUtils.isBlank(baseUrl)) { + return SignedRegistrationCardUploadResult.skipped("Third party base-url not configured"); + } + if (!oauthTokenSingleton.isConfigured()) { + return SignedRegistrationCardUploadResult.skipped("OHIP OAuth is not configured"); + } + if (!profileFieldsEnabled) { + return SignedRegistrationCardUploadResult.skipped( + "Profile update mapped but disabled (profile-fields.enabled=false)"); + } + + String resolvedProfileId = StringUtils.trimToNull(profileId); + String resolvedProfileType = StringUtils.trimToNull(profileType); + String reservationKey = StringUtils.trimToNull(reservationId); + if (StringUtils.isNotBlank(reservationKey) && "printer-import".equalsIgnoreCase(reservationKey)) { + reservationKey = null; + } + String effectiveHotelId = resolveOhipHotelId(hotelId); + if (StringUtils.isBlank(resolvedProfileId) && StringUtils.isNotBlank(reservationKey)) { + Optional guest = fetchGuestProfile(effectiveHotelId, reservationKey); + if (guest.isPresent()) { + resolvedProfileId = guest.get().profileId(); + if (StringUtils.isBlank(resolvedProfileType)) { + resolvedProfileType = guest.get().profileType(); + } + } else { + log.warn( + "OHIP CRM profile lookup miss hotelId={}, resolvedHotelId={}, reservationId={}", + hotelId, + effectiveHotelId, + reservationKey); + } + } + if (StringUtils.isBlank(resolvedProfileId)) { + log.warn( + "OHIP CRM profile update skipped: profileId unresolved hotelId={}, reservationId={}", + effectiveHotelId, + reservationKey); + return SignedRegistrationCardUploadResult.skipped( + "profileId not resolved for CRM profile update"); + } + + String ohipHotelId = effectiveHotelId; + log.info( + "OHIP CRM profile update prepared reservationId={}, profileId={}, profileType={}, keys={}", + reservationId, + resolvedProfileId, + resolvedProfileType, + fields.keySet()); + + String path = profileFieldsPathTemplate.replace( + "{profileId}", urlEncodePathSegment(resolvedProfileId)); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + JSONObject body = ProfileDetailsJsonBuilder.buildBody(fields, resolvedProfileType); + String bodyJson = body.toJSONString(); + + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.put(url) + .timeoutMs(timeoutMs) + .jsonBody(bodyJson); + applyBusinessAuth(request, ohipHotelId); + log.info( + "OHIP PUT profile request url={}, profileId={}, reservationId={}, fieldCount={}, body={}, headers={}", + url, + resolvedProfileId, + reservationId, + fields.size(), + truncate(bodyJson), + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_PUT_PROFILE, + request, + response, + start, + ohipHotelId, + "PUT profile profileId=" + resolvedProfileId + " reservationId=" + reservationId); + String summary = truncate(response.getResponseContent()); + log.info("OHIP PUT profile response http={}, body={}", response.getStatusCode(), summary); + if (response.isTransportError()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + StringUtils.defaultIfBlank(response.getErrorMessage(), "profile update transport error"), + null, + reservationId, + null); + } + if (!response.isSuccessful()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + "profile update returned HTTP " + response.getStatusCode(), + null, + reservationId, + null); + } + return SignedRegistrationCardUploadResult.success( + response.getStatusCode(), null, reservationId, null, summary); + } catch (IllegalStateException e) { + log.error("Profile update failed", e); + return SignedRegistrationCardUploadResult.failure(-1, null, e.getMessage()); + } + } + + private static String extractRoomId(String body, String reservationId) { + try { + JSONArray assignments = JSONArray.parseArray(body); + if (assignments == null) { + return null; + } + for (Object assignment : assignments) { + if (!(assignment instanceof JSONObject item)) { + continue; + } + JSONObject assignedReservation = item.getJSONObject("reservationId"); + if (assignedReservation == null + || StringUtils.equals(reservationId, assignedReservation.getString("id"))) { + return item.getString("roomId"); + } + } + } catch (Exception e) { + log.warn("OHIP auto room assignment response has no readable roomId, body={}", truncate(body)); + } + return null; + } + + private static String extractOhipErrorMessage(String body, String defaultMessage) { + try { + JSONObject error = JSONObject.parseObject(body); + if (error != null) { + return StringUtils.defaultIfBlank(error.getString("detail"), + StringUtils.defaultIfBlank(error.getString("title"), defaultMessage)); + } + } catch (Exception e) { + log.debug("OHIP error response is not JSON"); + } + return defaultMessage; + } + + private static String buildSignedFileName(String preferredFileName, String confirmationNumber) { + String attachmentExt = ".pdf"; + if (StringUtils.isNotBlank(preferredFileName)) { + String trimmed = preferredFileName.trim(); + int dot = trimmed.lastIndexOf('.'); + if (dot > 0 && dot < trimmed.length() - 1) { + attachmentExt = trimmed.substring(dot).toLowerCase(); + } + } + // OPERA fileAttachments:.regcard 需 HTML 内容;我们上传的是签名 PDF,必须用 .pdf + if (".regcard".equals(attachmentExt)) { + attachmentExt = ".pdf"; + } + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm")); + return "e-SignedRegCard-" + confirmationNumber + "-" + ts + attachmentExt; + } + + private static void logAttachmentPreview(String attachmentBase64) { + if (StringUtils.isBlank(attachmentBase64)) { + log.warn("OHIP fileAttachments attachment base64 empty"); + return; + } + try { + byte[] bytes = Base64.getDecoder().decode(attachmentBase64); + String magic = bytes.length >= 4 + ? new String(bytes, 0, Math.min(16, bytes.length), java.nio.charset.StandardCharsets.ISO_8859_1) + : ""; + log.info( + "OHIP fileAttachments payload decodedBytes={}, magic={}", + bytes.length, + magic.replaceAll("[\\r\\n]", "")); + } catch (IllegalArgumentException e) { + log.warn("OHIP fileAttachments attachment base64 invalid: {}", e.getMessage()); + } + } + + private static String normalizePdfBase64(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String trimmed = raw.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed.replaceAll("\\s", ""); + } + + private String resolveOhipHotelId(String requestHotelId) { + String configured = oauthTokenSingleton.getHotelId(); + if (StringUtils.isNotBlank(configured)) { + return configured.trim(); + } + return StringUtils.defaultString(requestHotelId).trim(); + } + + private static String buildRegistrationCardPath(String hotelId, String reservationId) { + return "/med/config/v1/hotels/" + urlEncodePathSegment(hotelId) + + "/reservations/" + urlEncodePathSegment(reservationId) + + "/registrationCard"; + } + + private static String urlEncodePathSegment(String value) { + return StringUtils.defaultString(value).trim(); + } + + private void applyBusinessAuth(SimpleHttpClient.RequestBuilder request, String hotelId) { + String accessToken = oauthTokenSingleton.getAccessToken(); + Map headers = oauthTokenSingleton.buildBusinessHeaders(hotelId); + request.bearerToken(accessToken).headers(headers); + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + if (value.length() <= RESPONSE_BODY_LOG_MAX) { + return value; + } + return value.substring(0, RESPONSE_BODY_LOG_MAX) + "...(truncated)"; + } + + private void auditThirdParty( + String eventType, + SimpleHttpClient.RequestBuilder request, + HttpResponseResult response, + long startMs, + String hotelId, + String message) { + if (auditLogService == null) { + return; + } + try { + Integer status = response != null ? response.getStatusCode() : null; + String detail = ThirdPartyAuditDetailBuilder.build(request, response); + auditLogService.appendThirdParty( + eventType, + request != null ? request.getMethod() : null, + request != null ? request.getRequestUrlWithQuery() : null, + status, + System.currentTimeMillis() - startMs, + hotelId, + message, + detail); + } catch (Exception e) { + log.debug("audit third party log failed", e); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/HospitalityOAuthTokenConfigurer.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/HospitalityOAuthTokenConfigurer.java new file mode 100644 index 0000000..1a5226d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/HospitalityOAuthTokenConfigurer.java @@ -0,0 +1,91 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.reservation.dtos.HospitalityOAuthSettings; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HospitalityOAuthTokenConfigurer { + + private final ObjectMapper objectMapper; + + @Value("${ops-app.esign.reservation.third-party.base-url:}") + private String baseUrl; + + @Value("${ops-app.esign.reservation.third-party.oauth.token-path:/oauth/v1/tokens}") + private String tokenPath; + + /** Postman Basic Auth 用户名,对应 CLIENT_ID */ + @Value("${ops-app.esign.reservation.third-party.oauth.client-id:}") + private String clientId; + + /** Postman Basic Auth 密码,对应 CLIENT_SECRET */ + @Value("${ops-app.esign.reservation.third-party.oauth.client-secret:}") + private String clientSecret; + + @Value("${ops-app.esign.reservation.third-party.oauth.app-key:}") + private String appKey; + + @Value("${ops-app.esign.reservation.third-party.oauth.enterprise-id:}") + private String enterpriseId; + + @Value("${ops-app.esign.reservation.third-party.oauth.hotel-id:}") + private String hotelId; + + @Value("${ops-app.esign.reservation.third-party.oauth.scope:}") + private String scope; + + @Value("${ops-app.esign.reservation.third-party.oauth.grant-type:client_credentials}") + private String grantType; + + @Value("${ops-app.esign.reservation.third-party.oauth.timeout-ms:30000}") + private int timeoutMs; + + @Value("${ops-app.esign.reservation.third-party.oauth.enabled:true}") + private boolean oauthEnabled; + + @PostConstruct + public void configureSingleton() { + HospitalityOAuthSettings settings = HospitalityOAuthSettings.builder() + .enabled(oauthEnabled) + .baseUrl(baseUrl) + .tokenPath(tokenPath) + .clientId(clientId) + .clientSecret(clientSecret) + .appKey(appKey) + .enterpriseId(enterpriseId) + .hotelId(hotelId) + .scope(scope) + .grantType(grantType) + .timeoutMs(timeoutMs) + .build(); + HospitalityOAuthTokenSingleton.getInstance().configure(settings, objectMapper); + log.info( + "OHIP OAuth singleton loaded: baseUrl={}, clientId={}, enterpriseId={}, hotelId={}, appKeyPrefix={}, scopeConfigured={}", + baseUrl, + clientId, + enterpriseId, + hotelId, + maskPrefix(appKey), + StringUtils.isNotBlank(scope)); + if (!settings.isComplete()) { + log.warn( + "OHIP OAuth 未完整配置:签名 PDF 归档后不会请求 OPERA(无 THIRD_PARTY 审计日志)。" + + " 请检查 ops-app.esign.reservation.third-party.oauth.* 与 base-url"); + } + } + + private static String maskPrefix(String value) { + if (StringUtils.isBlank(value)) { + return "(empty)"; + } + return value.length() <= 8 ? value : value.substring(0, 8) + "..."; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/HospitalityOAuthTokenSingleton.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/HospitalityOAuthTokenSingleton.java new file mode 100644 index 0000000..a54f059 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/HospitalityOAuthTokenSingleton.java @@ -0,0 +1,240 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import com.sjmresorts.opsapp.common.utils.SimpleHttpClient; +import com.sjmresorts.opsapp.esign.reservation.dtos.HospitalityOAuthSettings; +import com.sjmresorts.opsapp.esign.reservation.dtos.OAuthTokenResponse; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * OHIP OAuth Token 单例:全局仅维护一份 access token,过期前自动刷新。 + */ +public final class HospitalityOAuthTokenSingleton { + + private static final Logger log = LoggerFactory.getLogger(HospitalityOAuthTokenSingleton.class); + private static final long DEFAULT_EXPIRES_SECONDS = 3600; + private static final long REFRESH_BUFFER_SECONDS = 120; + + private static volatile HospitalityOAuthTokenSingleton instance; + private static final Object INSTANCE_LOCK = new Object(); + + private final Object refreshLock = new Object(); + + private volatile HospitalityOAuthSettings settings; + private volatile ObjectMapper objectMapper; + private volatile CachedToken cachedToken; + + private HospitalityOAuthTokenSingleton() { + } + + public static HospitalityOAuthTokenSingleton getInstance() { + if (instance == null) { + synchronized (INSTANCE_LOCK) { + if (instance == null) { + instance = new HospitalityOAuthTokenSingleton(); + } + } + } + return instance; + } + + public void configure(HospitalityOAuthSettings newSettings, ObjectMapper mapper) { + synchronized (INSTANCE_LOCK) { + this.settings = newSettings; + this.objectMapper = mapper; + this.cachedToken = null; + } + log.info("Hospitality OAuth token singleton configured, enabled={}", + newSettings != null && newSettings.isEnabled()); + } + + public boolean isConfigured() { + HospitalityOAuthSettings current = settings; + return current != null && current.isComplete(); + } + + public String getHotelId() { + HospitalityOAuthSettings current = settings; + return current != null ? current.getHotelId() : null; + } + + public String getAppKey() { + HospitalityOAuthSettings current = settings; + return current != null ? current.getAppKey() : null; + } + + public String getAccessToken() { + if (!isConfigured()) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + + CachedToken current = cachedToken; + if (current != null && current.isValid()) { + return current.accessToken; + } + + synchronized (refreshLock) { + current = cachedToken; + if (current != null && current.isValid()) { + return current.accessToken; + } + return refreshAccessToken(); + } + } + + /** 业务 API Header(如 registrationCard):x-hotelid + x-app-key */ + public Map buildBusinessHeaders(String hotelId) { + HospitalityOAuthSettings current = settings; + if (current == null) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + Map headers = new LinkedHashMap<>(); + headers.put("x-hotelid", StringUtils.defaultIfBlank(hotelId, current.getHotelId())); + headers.put("x-app-key", current.getAppKey()); + headers.put("Accept", "application/json"); + headers.put("X-Request-Id", UUID.randomUUID().toString()); + return headers; + } + + public Map buildCommonHeaders() { + HospitalityOAuthSettings current = settings; + if (current == null) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + Map headers = new LinkedHashMap<>(); + headers.put("x-app-key", current.getAppKey()); + headers.put("enterpriseId", current.getEnterpriseId()); + headers.put("X-Request-Id", UUID.randomUUID().toString()); + return headers; + } + + /** 仅用于测试或强制续期。 */ + public void invalidate() { + cachedToken = null; + } + + private static Map buildOAuthTokenHeaders(HospitalityOAuthSettings current, String requestId) { + // 与 Postman /oauth/v1/tokens 一致:仅 x-app-key、enterpriseId、X-Request-Id(不含 hotelId) + Map headers = new LinkedHashMap<>(); + headers.put("x-app-key", current.getAppKey()); + headers.put("enterpriseId", current.getEnterpriseId()); + headers.put("X-Request-Id", requestId); + return headers; + } + + private String refreshAccessToken() { + HospitalityOAuthSettings current = settings; + ObjectMapper mapper = objectMapper; + if (current == null || mapper == null) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + + String url = SimpleHttpClient.joinUrl(current.getBaseUrl(), current.getTokenPath()); + String requestId = UUID.randomUUID().toString(); + Map form = new LinkedHashMap<>(); + form.put("grant_type", StringUtils.defaultIfBlank(current.getGrantType(), "client_credentials")); + if (StringUtils.isNotBlank(current.getScope())) { + form.put("scope", current.getScope().trim()); + } + + SimpleHttpClient.RequestBuilder requestBuilder = SimpleHttpClient.post(url) + .basicAuth(current.getClientId(), current.getClientSecret()) + .headers(buildOAuthTokenHeaders(current, requestId)) + .formBody(form) + .timeoutMs(current.getTimeoutMs()); + + String basicAuthBase64 = SimpleHttpClient.buildBasicAuthBase64( + current.getClientId(), current.getClientSecret()); + + log.info( + "OHIP OAuth token request: method={}, url={}, headers={}, basicAuth={{clientId={}, clientSecret={}}}, body={}", + requestBuilder.getMethod(), + requestBuilder.getRequestUrlWithQuery(), + SimpleHttpClient.maskHeadersForLog(requestBuilder.getHeaders()), + current.getClientId(), + maskSecret(current.getClientSecret()), + form); + log.info( + "OHIP OAuth Authorization compare with Postman: Authorization: Basic {}", + basicAuthBase64); + + HttpResponseResult response = requestBuilder.execute(); + + if (response.isTransportError()) { + log.error("OHIP OAuth token request failed: error={}", response.errorOrEmpty()); + throw new IllegalStateException("OAuth token request failed: " + response.errorOrEmpty()); + } + if (!response.isSuccessful()) { + log.warn("OHIP OAuth token request rejected: status={}, body={}", + response.getStatusCode(), truncate(response.bodyOrEmpty())); + throw new IllegalStateException( + "OAuth token request returned HTTP " + response.getStatusCode() + ": " + response.bodyOrEmpty()); + } + + try { + OAuthTokenResponse tokenResponse = mapper.readValue(response.bodyOrEmpty(), OAuthTokenResponse.class); + if (tokenResponse == null || StringUtils.isBlank(tokenResponse.getAccessToken())) { + throw new IllegalStateException("OAuth token response missing access_token"); + } + + long expiresIn = tokenResponse.getExpiresIn() != null && tokenResponse.getExpiresIn() > 0 + ? tokenResponse.getExpiresIn() + : DEFAULT_EXPIRES_SECONDS; + long effectiveSeconds = Math.max(60, expiresIn - REFRESH_BUFFER_SECONDS); + cachedToken = new CachedToken( + tokenResponse.getAccessToken(), + Instant.now().plusSeconds(effectiveSeconds)); + + log.info("OHIP OAuth token obtained, expiresIn={}s, accessTokenPrefix={}", + expiresIn, maskSecret(tokenResponse.getAccessToken())); + return cachedToken.accessToken; + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Failed to parse OAuth token response: " + e.getMessage(), e); + } + } + + private static final class CachedToken { + private final String accessToken; + private final Instant expiresAt; + + private CachedToken(String accessToken, Instant expiresAt) { + this.accessToken = accessToken; + this.expiresAt = expiresAt; + } + + private boolean isValid() { + return Instant.now().isBefore(expiresAt); + } + } + + private static String maskSecret(String value) { + if (StringUtils.isBlank(value)) { + return "(empty)"; + } + if (value.length() <= 8) { + return "****"; + } + return value.substring(0, 4) + "****" + value.substring(value.length() - 4); + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + int max = 500; + if (value.length() <= max) { + return value; + } + return value.substring(0, max) + "...(truncated)"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/OperaFlexLinkPageBuilder.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/OperaFlexLinkPageBuilder.java new file mode 100644 index 0000000..cd4e325 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/OperaFlexLinkPageBuilder.java @@ -0,0 +1,797 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import org.apache.commons.lang3.StringUtils; + +/** + * Opera Flex Link 页面。 + * Opera iframe(内层):只 postMessage 发信号,不操作外层 DOM。 + * Opera 主页面(外层代理):注入 opera-parent-close-listener.js 负责销毁 Pop-up 外壳。 + * 独立浏览器标签:myapp:// + window.close()。 + */ +public final class OperaFlexLinkPageBuilder { + + private OperaFlexLinkPageBuilder() { + } + + public static String buildLaunchPage( + String deepLinkUrl, + String launchMode, + boolean useBrowserDeepLink, + String serverLaunchMessage, + boolean closePopupEnabled, + boolean closeModalMessage, + String configuredParentOrigin, + String flexLinkOrigin, + long fallbackExtAppId, + int openDelayMs, + int closeDelayMs, + boolean debugPage, + String popupWidth, + String popupHeight) { + String link = escapeJsString(deepLinkUrl); + String launchMsg = escapeJsString(StringUtils.defaultString(serverLaunchMessage)); + String mode = normalizeLaunchMode(launchMode); + int openWaitMs = useBrowserDeepLink ? Math.max(600, openDelayMs) : Math.max(150, openDelayMs); + int closeRetryMs = Math.max(80, closeDelayMs); + String closeScript = buildCloseScript( + mode, closePopupEnabled, closeModalMessage, configuredParentOrigin, flexLinkOrigin, + fallbackExtAppId, openWaitMs, closeRetryMs, debugPage, popupWidth, popupHeight); + boolean newWindow = "new-window".equals(mode); + + if (newWindow) { + return """ + + + + + + 登记程序 + + + +
%s
+ + + + %s + + + + """.formatted( + useBrowserDeepLink + ? "正在唤起登记程序…
若浏览器询问是否打开应用,请先点击「打开」。登记程序启动后本标签将自动关闭。" + : escapeHtml("登记程序已由本机服务启动。" + (StringUtils.isNotBlank(serverLaunchMessage) ? " " + serverLaunchMessage : "") + " 本页将自动关闭。"), + useBrowserDeepLink ? "再次打开登记程序" : "再次启动", + debugPanelHtml(debugPage), + closeScript, + link, + useBrowserDeepLink, + launchMsg, + openWaitMs, + closeRetryMs); + } + + String hintHtml = debugPage + ? (useBrowserDeepLink ? "正在唤起登记程序并关闭 Pop-up…" : escapeHtml("登记程序已由本机服务启动。")) + : ""; + String bodyClass = debugPage ? "" : " stealth"; + String extraStyles = debugPage ? "" : """ + html,body{margin:0!important;padding:0!important;background:transparent!important;overflow:hidden!important} + body.stealth .warn,body.stealth .ok,body.stealth .btn,body.stealth .btn2{display:none!important} + """; + String manualButtonsHtml = debugPage + ? """ + + + """ + : ""; + + return """ + + + + + + 登记程序 + + + + %s +
%s
+
+ %s + %s + + + + """.formatted( + extraStyles, + bodyClass.trim(), + debugPage ? "" : "", + hintHtml, + manualButtonsHtml, + debugPanelHtml(debugPage), + closeScript, + link, + useBrowserDeepLink, + openWaitMs, + closeRetryMs, + debugPage); + } + + public static String buildNotifyPage( + boolean success, + String message, + String reservationId, + String launchMode, + boolean closePopupEnabled, + boolean closeModalMessage, + String configuredParentOrigin, + String flexLinkOrigin, + long fallbackExtAppId, + int openDelayMs, + int closeDelayMs, + boolean debugPage, + String popupWidth, + String popupHeight) { + String statusText = success ? "已通知前台" : "通知失败"; + String detail = escapeHtml(StringUtils.defaultIfBlank(message, statusText)); + String rid = escapeHtml(StringUtils.defaultIfBlank(reservationId, "—")); + String mode = normalizeLaunchMode(launchMode); + int openWaitMs = Math.max(600, openDelayMs); + int closeRetryMs = Math.max(80, closeDelayMs); + String closeScript = buildCloseScript( + mode, closePopupEnabled, closeModalMessage, configuredParentOrigin, flexLinkOrigin, + fallbackExtAppId, openWaitMs, closeRetryMs, debugPage, popupWidth, popupHeight); + String boxClass = success ? "ok" : "err"; + int notifyCloseDelay = "new-window".equals(mode) ? openWaitMs : Math.max(50, closeRetryMs); + String initScript = "new-window".equals(mode) + ? "setTimeout(function () { var r = closeLaunchWindow(); renderLaunchDebug(r); }, " + notifyCloseDelay + ");" + : "initOperaPopupSession(); setTimeout(function () { var r = closeLaunchWindow(); renderLaunchDebug(r); }, " + + notifyCloseDelay + ");"; + + return """ + + + + + + %s + + + +
+ %s
预订号:%s
%s +
+ %s + + + + """.formatted( + statusText, + boxClass, + statusText, + rid, + detail, + debugPanelHtml(debugPage), + closeScript, + initScript); + } + + private static String normalizeLaunchMode(String launchMode) { + return "popup".equalsIgnoreCase(StringUtils.trimToEmpty(launchMode)) ? "popup" : "new-window"; + } + + private static String debugPanelHtml(boolean debugPage) { + if (!debugPage) { + return ""; + } + return "
调试信息
"; + } + + /** + * 内层脚本:Opera iframe 内只 emit 信号;独立标签页才 window.close()。 + * 外层脚本见 {@link #buildOperaParentCloseListenerScript(String)}。 + */ + private static String buildCloseScript( + String launchMode, + boolean closeEnabled, + boolean closeModalMessage, + String configuredParentOrigin, + String flexLinkOrigin, + long fallbackExtAppId, + int openDelayMs, + int closeRetryMs, + boolean debugPage, + String popupWidth, + String popupHeight) { + String origin = escapeJsString(StringUtils.defaultString(configuredParentOrigin)); + String flexOrigin = escapeJsString(StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066")); + String proxyInstallUrl = escapeJsString(StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066") + + "/api/v1/checkin/app/opera-parent-close-listener.user.js"); + String mode = escapeJsString(normalizeLaunchMode(launchMode)); + int popupCloseDelayMs = Math.max(50, closeRetryMs); + return """ + (function () { + var launchMode = "{{LAUNCH_MODE}}"; + var closeEnabled = {{CLOSE_ENABLED}}; + var closeModalMessage = {{CLOSE_MODAL_MESSAGE}}; + var operaOrigin = "{{OPERA_ORIGIN}}"; + var flexLinkOrigin = "{{FLEX_LINK_ORIGIN}}"; + var proxyInstallUrl = "{{PROXY_INSTALL_URL}}"; + var fallbackExtAppId = {{FALLBACK_EXT_APP_ID}}; + var openDelayMs = {{OPEN_DELAY_MS}}; + var closeDelayMs = {{CLOSE_DELAY_MS}}; + var debugPage = {{DEBUG_PAGE}}; + var lastDetail = ""; + var shellProxyAck = false; + + function isOperaPopupIframe() { + try { + if (window.self !== window.top) return true; + } catch (e) { + return true; + } + return false; + } + + function resolveExtAppId() { + try { + var q = new URLSearchParams(location.search).get("ocextappid"); + if (q) return parseInt(q, 10); + } catch (e) {} + return fallbackExtAppId > 0 ? fallbackExtAppId : null; + } + + function resolveOperaOrigin() { + if (operaOrigin) return operaOrigin; + try { + if (document.referrer) return new URL(document.referrer).origin; + } catch (e) {} + return null; + } + + function postToWindow(win, payload, targetOrigin, label) { + try { + win.postMessage(payload, targetOrigin); + return label + "@" + targetOrigin; + } catch (e1) { + try { + win.postMessage(payload, "*"); + return label + "@*"; + } catch (e2) { + return label + ":fail"; + } + } + } + + /** 向内层→外层:纯 postMessage,不触碰 parent DOM */ + function emitShellSignal(action, extra) { + if (!closeModalMessage && (action === "CLOSE_SHELL" || action === "closeModal")) { + return action + ":disabled"; + } + var targetOrigin = resolveOperaOrigin(); + if (!targetOrigin) return action + ":skip no origin"; + var payload = { action: action, source: "auto-check-in-launch", ts: Date.now() }; + if (extra) { + for (var k in extra) { + if (Object.prototype.hasOwnProperty.call(extra, k)) payload[k] = extra[k]; + } + } + var sent = []; + var wins = []; + try { if (window.parent && window.parent !== window) wins.push(["parent", window.parent]); } catch (e) {} + try { if (window.top && window.top !== window) wins.push(["top", window.top]); } catch (e2) {} + for (var i = 0; i < wins.length; i++) { + var r = postToWindow(wins[i][1], payload, targetOrigin, wins[i][0]); + if (r.indexOf(":fail") < 0) sent.push(r); + } + return sent.length ? (action + "->" + sent.join(",")) : (action + ":skip"); + } + + function emitCloseShellBurst() { + var delays = [0, 40, 90, 180]; + delays.forEach(function (ms) { + setTimeout(function () { + emitShellSignal("CLOSE_SHELL"); + emitShellSignal("closeModal"); + }, ms); + }); + return "CLOSE_SHELL burst x" + delays.length; + } + + function emitDoReturnSignal() { + var appId = resolveExtAppId(); + var targetOrigin = resolveOperaOrigin(); + if (!appId || !targetOrigin) return "DO_RETURN:skip"; + var payload = { ocextappid: appId, eventId: "DO_RETURN_EVENT" }; + var sent = false; + var wins = []; + try { if (window.parent && window.parent !== window) wins.push(window.parent); } catch (e) {} + try { if (window.top && window.top !== window) wins.push(window.top); } catch (e2) {} + for (var i = 0; i < wins.length; i++) { + var r = postToWindow(wins[i], payload, targetOrigin, "doReturn"); + if (r.indexOf(":fail") < 0) sent = true; + } + return sent ? "DO_RETURN sent" : "DO_RETURN:skip"; + } + + function emitOperaSetupSignal(eventId, args) { + var appId = resolveExtAppId(); + var targetOrigin = resolveOperaOrigin(); + if (!appId || !targetOrigin) return null; + var payload = { ocextappid: appId, eventId: eventId }; + if (args) payload.args = args; + try { + return postToWindow(window.top, payload, targetOrigin, eventId); + } catch (e) { + return eventId + " error"; + } + } + + function showProxyMissingHelp() { + if (shellProxyAck || !isOperaPopupIframe()) return; + var el = document.getElementById("proxyHelp"); + if (!el) { + el = document.createElement("div"); + el.id = "proxyHelp"; + el.style.cssText = "margin:12px 0;padding:14px;background:#ffebee;border:2px solid #e57373;border-radius:8px;text-align:left;font-size:13px;color:#b71c1c;line-height:1.6"; + var anchor = document.getElementById("hintBox") || document.body.firstChild; + if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(el, anchor.nextSibling); + else document.body.insertBefore(el, document.body.firstChild); + } + el.innerHTML = + "Pop-up 未关闭:Opera 主页面缺少外层代理

" + + "内层已发 CLOSE_SHELL,但未收到 SHELL_CLOSE_ACK。

" + + "一次性安装(推荐 Tampermonkey):
" + + "1. 安装 Tampermonkey 扩展
" + + "2. 打开 " + proxyInstallUrl + " 并「安装此脚本」
" + + "3. 刷新 Opera 主页面后再点 Flex Link

" + + "或手动注入: 在 Opera 主页面(非本 Pop-up)按 F12 → Console,执行:
" + + "" + + "fetch(\\"" + flexLinkOrigin + "/api/v1/checkin/app/opera-parent-close-listener.js\\").then(r=>r.text()).then(eval)" + + ""; + } + + window.initOperaPopupSession = function () { + var h = emitOperaSetupSignal("SET_HEADER_EVENT", { title: "登记程序" }); + lastDetail = "init: " + (h || "skip"); + renderLaunchDebug("内层初始化:SET_HEADER(关窗由延迟 CLOSE_SHELL 触发)"); + return lastDetail; + }; + + function closeStandaloneBrowserTab() { + if (!closeEnabled) return "tab-close:disabled"; + try { + window.close(); + return "tab-close:window.close()"; + } catch (e) { + return "tab-close:error"; + } + } + + /** Opera iframe:只发信号,由外层代理销毁外壳 */ + function requestShellCloseViaProxy() { + if (!closeEnabled) return "signal:disabled"; + shellProxyAck = false; + var steps = []; + steps.push(emitCloseShellBurst()); + steps.push(emitDoReturnSignal()); + lastDetail = steps.join(" | "); + renderLaunchDebug("内层已发 CLOSE_SHELL + DO_RETURN,等待外层代理…"); + setTimeout(function () { + if (!shellProxyAck) { + steps.push(emitShellSignal("CLOSE_SHELL")); + steps.push(emitShellSignal("closeModal")); + steps.push(emitDoReturnSignal()); + lastDetail = steps.join(" | "); + renderLaunchDebug("内层第 2 轮信号已发"); + } + }, Math.max(80, closeDelayMs)); + setTimeout(showProxyMissingHelp, 2500); + return lastDetail; + } + + window.closeLaunchWindow = function () { + if (isOperaPopupIframe()) { + return requestShellCloseViaProxy(); + } + return emitShellSignal("CLOSE_SHELL") + " | " + closeStandaloneBrowserTab(); + }; + + window.addEventListener("message", function (event) { + if (!event.data) return; + if (event.data.action === "SHELL_CLOSE_ACK") { + shellProxyAck = true; + var help = document.getElementById("proxyHelp"); + if (help) help.remove(); + lastDetail = (lastDetail || "") + " | outer ACK ok=" + event.data.ok + + (event.data.frame ? (" frame=" + event.data.frame) : ""); + renderLaunchDebug("外层代理回执 ✓"); + } + }); + + window.renderLaunchDebug = function (note) { + if (!debugPage) return; + var box = document.getElementById("debugBox"); + if (!box) return; + var inIframe = isOperaPopupIframe(); + var appId = resolveExtAppId(); + var targetOrigin = resolveOperaOrigin(); + box.textContent = + (note ? ("[" + note + "]\\n\\n") : "") + + "mode: " + (inIframe ? "iframe-proxy (signal only)" : "standalone-tab") + "\\n" + + "launchMode config: " + launchMode + "\\n" + + "URL: " + location.href + "\\n" + + "shellProxyAck: " + shellProxyAck + "\\n" + + (inIframe ? ("ocextappid: " + (appId || "null") + "\\npostMessage target: " + (targetOrigin || "null") + "\\nproxy: " + proxyInstallUrl + "\\n") : "") + + "lastDetail: " + lastDetail + "\\n\\n" + + (inIframe + ? "内层只发 CLOSE_SHELL / DO_RETURN。外层须装 opera-parent-close-listener.user.js" + : "独立标签:myapp:// 后 window.close()"); + }; + + window.renderOperaDebug = window.renderLaunchDebug; + })(); + """ + .replace("{{LAUNCH_MODE}}", mode) + .replace("{{CLOSE_ENABLED}}", String.valueOf(closeEnabled)) + .replace("{{CLOSE_MODAL_MESSAGE}}", String.valueOf(closeModalMessage)) + .replace("{{OPERA_ORIGIN}}", origin) + .replace("{{FLEX_LINK_ORIGIN}}", flexOrigin) + .replace("{{PROXY_INSTALL_URL}}", proxyInstallUrl) + .replace("{{FALLBACK_EXT_APP_ID}}", String.valueOf(fallbackExtAppId)) + .replace("{{OPEN_DELAY_MS}}", String.valueOf(openDelayMs)) + .replace("{{CLOSE_DELAY_MS}}", String.valueOf(popupCloseDelayMs)) + .replace("{{DEBUG_PAGE}}", String.valueOf(debugPage)); + } + + /** + * 供 Opera 主页面注入(Tampermonkey / Console 一次粘贴)。 + * Flex Link iframe 会 postMessage({ action: 'CLOSE_SHELL' | 'closeModal' }) 触发此逻辑。 + */ + public static String buildOperaParentCloseListenerScript(String flexLinkOrigin) { + String origin = escapeJsString(StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066")); + return """ + (function () { + if (window.__autoCheckInShellProxy) return; + window.__autoCheckInShellProxy = true; + var flexOrigin = "{{FLEX_ORIGIN}}"; + + function isFlexOrigin(origin) { + if (!origin) return false; + if (flexOrigin && origin === flexOrigin) return true; + try { + var u = new URL(origin); + if (u.hostname === "127.0.0.1" || u.hostname === "localhost") return true; + } catch (e) {} + return false; + } + + function isCloseSignal(data) { + if (!data) return false; + if (data.source && data.source !== "auto-check-in-launch") return false; + return data.action === "CLOSE_SHELL" || data.action === "closeModal"; + } + + function clickEl(el) { + if (!el) return false; + try { el.focus(); } catch (e1) {} + try { el.click(); return true; } catch (e2) {} + try { + el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); + return true; + } catch (e3) {} + return false; + } + + function destroyAdfPopupShell() { + var closeSelectors = [ + 'a[id$="::close"][aria-label="Close"][title="Close"]', + 'a.x2c5[title="Close"][id$="::close"]', + '.af_panelWindow_header-close-icon-wrapper a[title="Close"]', + 'a[id$="::close"]' + ]; + var clicked = false; + for (var i = 0; i < closeSelectors.length; i++) { + var el = document.querySelector(closeSelectors[i]); + if (clickEl(el)) { + console.log("[auto-check-in proxy] 点击 Close:", el.id); + clicked = true; + break; + } + } + document.querySelectorAll('div[id*="ocExtWrpBack"] a, div[id*="ocExtWrpPp"] a').forEach(function (a) { + if (clickEl(a)) { + console.log("[auto-check-in proxy] 点击 ocExtWrp 隐藏链:", a.id || a.className); + clicked = true; + } + }); + var removed = 0; + document.querySelectorAll([ + 'div[id$="::popup-container"][role="dialog"]', + 'div[id$="::popup-container"]', + 'div[data-afr-popupid]' + ].join(",")).forEach(function (el) { + el.remove(); + removed++; + console.log("[auto-check-in proxy] 移除 popup-container"); + }); + document.querySelectorAll([ + 'div.x2px[data-afr-panelwindowbackground="1"]', + 'div.AFPopupTail', + '.AFModalGlassPane', + '.oj-dialog-layer', + '.oj-component-layer' + ].join(",")).forEach(function (el) { el.remove(); }); + document.body.style.overflow = "auto"; + document.documentElement.style.overflow = "auto"; + document.body.classList.remove("p_OraModalGlass"); + return { clicked: clicked, removed: removed }; + } + + function ackShellClose(event, ok, detail) { + if (!event || !event.source) return; + try { + event.source.postMessage({ + action: "SHELL_CLOSE_ACK", + ok: ok, + detail: detail, + frame: location.pathname, + source: "auto-check-in-proxy" + }, event.origin); + } catch (e) { + console.warn("[auto-check-in proxy] ACK 失败", e); + } + } + + window.addEventListener("message", function (event) { + if (!isFlexOrigin(event.origin)) return; + if (!isCloseSignal(event.data)) return; + console.log("[auto-check-in proxy] 收到内层信号:", event.data.action, + "from", event.origin, "frame:", location.href); + var last = null; + [0, 30, 80, 150, 300].forEach(function (ms, idx, arr) { + setTimeout(function () { + last = destroyAdfPopupShell(); + if (idx === arr.length - 1) { + ackShellClose(event, true, JSON.stringify(last)); + } + }, ms); + }); + }); + + window.__autoCheckInDestroyAdfPopupShell = destroyAdfPopupShell; + window.__autoCheckInCloseAdfPopup = function () { return destroyAdfPopupShell(); }; + console.log("[auto-check-in proxy] 外层代理已就绪 @", location.href, + "flexOrigin=", flexOrigin, "手动: __autoCheckInDestroyAdfPopupShell()"); + })(); + """.replace("{{FLEX_ORIGIN}}", origin); + } + + public static String buildOperaParentCloseListenerUserScript(String flexLinkOrigin) { + String origin = StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066"); + String userJsUrl = origin + "/api/v1/checkin/app/opera-parent-close-listener.user.js"; + return """ + // ==UserScript== + // @name Opera Auto Check-in Close Popup + // @namespace auto-check-in + // @version 1.2 + // @description 外层代理:接收 CLOSE_SHELL 信号并销毁 Flex Link Pop-up 外壳(装一次,永久自动) + // @match *://*.oraclehospitality.* + // @match *://*.oraclecloud.com/* + // @match *://*.ocs.oraclecloud.com/* + // @updateURL %s + // @downloadURL %s + // @run-at document-idle + // @allFrames true + // @grant none + // ==/UserScript== + """ + .formatted(userJsUrl, userJsUrl) + + buildOperaParentCloseListenerScript(origin); + } + + public static String buildOperaProxyInstallPage(String flexLinkOrigin) { + String base = StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066"); + String userJs = escapeHtml(base + "/api/v1/checkin/app/opera-parent-close-listener.user.js"); + String installPage = escapeHtml(base + "/api/v1/checkin/app/opera-proxy-install"); + String extPath = escapeHtml("scripts/opera-close-extension(项目内文件夹,需「加载已解压的扩展程序」)"); + return """ + + + + + Opera 关窗代理 — 安装向导 + + + +

Opera Flex Link 自动关 Pop-up — 安装向导

+
+ 为什么 Chrome 扩展不能「一条链接自动装上」?
+ 浏览器安全策略禁止从任意网址一键安装扩展(防恶意软件)。 + 只有 应用商店「添加到 Chrome」 或 IT 组策略强制下发 才能接近「一点安装」。 +
+
+ ✓ 最接近「一条链接自动装」:Tampermonkey 脚本
+ 先装一次 Tampermonkey 扩展(应用商店),之后点下面链接即可一键安装关窗脚本,无需加载文件夹。 +
一键安装 Tampermonkey 脚本 + 安装 Tampermonkey(首次) +
+

方式对比

+ + + + + + +
方式能否一条链接说明
Tampermonkey + .user.js✓ 脚本能首次需装 Tampermonkey,之后点链接即装脚本
自研 Chrome 扩展(加载已解压)✗ 不能须 chrome://extensions → 加载文件夹(约 4 步)
上架 Chrome 网上应用店✓ 能需 $5 开发者账号 + 审核,适合全公司统一下载
IT 组策略强制安装✓ 能用户无感,适合企业批量部署
+
+ 方式 A:Tampermonkey(推荐,一条链接装脚本) +
    +
  1. 安装 Tampermonkey(只做一次)
  2. +
  3. 打开 %s → 点「安装」
  4. +
  5. 刷新 Opera Cloud 主页面
  6. +
+
+
+ 方式 B:自研 Chrome 扩展(不能一条链接,但无需 Tampermonkey) +
    +
  1. 打开 chrome://extensions,开启「开发者模式」
  2. +
  3. 「加载已解压的扩展程序」→ 选项目内 %s
  4. +
  5. 刷新 Opera Cloud 主页面
  6. +
+

Console 应出现:[auto-check-in ext] 外层代理已就绪

+
+
+ 验证 +

点 Flex Link → Pop-up 应能自动关闭;调试框可见 shellProxyAck: true

+

主页面 Console:__autoCheckInDestroyAdfPopupShell()

+
+

本页地址:%s

+ + + """.formatted(userJs, userJs, userJs, extPath, installPage, installPage); + } + + private static String escapeHtml(String value) { + return String.valueOf(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private static String escapeJsString(String value) { + return String.valueOf(value) + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ProfileDetailsJsonBuilder.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ProfileDetailsJsonBuilder.java new file mode 100644 index 0000000..0271737 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ProfileDetailsJsonBuilder.java @@ -0,0 +1,353 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 将 CMS 配置的扁平 OHIP 字段路径(相对 profileDetails)构造成 CRM PUT /profiles/{id} 请求体。 + *

+ * 示例路径:{@code customer.personName[Primary].givenName}、 + * {@code customer.identifications.identificationInfo[0].identification.idNumber}、 + * {@code addresses.addressInfo[0].address.cityName}、{@code profileType} + */ +public final class ProfileDetailsJsonBuilder { + + private static final Pattern SEGMENT = + Pattern.compile("([^.\\[]+)(?:\\[([^\\]]+)\\])?"); + + /** OHIP telephone.phoneUseType 未映射时,更新 phoneNumber 的默认值。 */ + private static final String DEFAULT_PHONE_USE_TYPE = "HOME"; + private static final String PHONE_NUMBER_PATH = + "telephones.telephoneInfo[0].telephone.phoneNumber"; + private static final String PHONE_USE_TYPE_PATH = + "telephones.telephoneInfo[0].telephone.phoneUseType"; + private static final String PHONE_PRIMARY_IND_PATH = + "telephones.telephoneInfo[0].telephone.primaryInd"; + + /** OHIP email.type 未映射时,更新 emailAddress 的默认值。 */ + private static final String DEFAULT_EMAIL_TYPE = "EMAIL"; + private static final String EMAIL_ADDRESS_PATH = + "emails.emailInfo[0].email.emailAddress"; + private static final String EMAIL_TYPE_PATH = + "emails.emailInfo[0].email.type"; + private static final String EMAIL_PRIMARY_IND_PATH = + "emails.emailInfo[0].email.primaryInd"; + + private ProfileDetailsJsonBuilder() { + } + + public static JSONObject buildBody(Map mappedFields, String profileType) { + JSONObject profileDetails = new JSONObject(); + if (mappedFields != null) { + for (Map.Entry entry : mappedFields.entrySet()) { + String path = normalizePath(entry.getKey()); + String value = StringUtils.trimToNull(entry.getValue()); + if (path == null || value == null) { + continue; + } + setValue(profileDetails, path, value); + } + } + ensureTelephoneDefaults(profileDetails); + ensureEmailDefaults(profileDetails); + if (StringUtils.isNotBlank(profileType)) { + profileDetails.put("profileType", profileType.trim()); + } + JSONObject root = new JSONObject(); + root.put("profileDetails", profileDetails); + return root; + } + + private static void ensureTelephoneDefaults(JSONObject profileDetails) { + if (StringUtils.isBlank(getStringAtPath(profileDetails, PHONE_NUMBER_PATH))) { + return; + } + if (StringUtils.isBlank(getStringAtPath(profileDetails, PHONE_USE_TYPE_PATH))) { + setValue(profileDetails, PHONE_USE_TYPE_PATH, DEFAULT_PHONE_USE_TYPE); + } + if (!hasLeafAtPath(profileDetails, PHONE_PRIMARY_IND_PATH)) { + setBooleanAtPath(profileDetails, PHONE_PRIMARY_IND_PATH, true); + } + } + + private static void ensureEmailDefaults(JSONObject profileDetails) { + if (StringUtils.isBlank(getStringAtPath(profileDetails, EMAIL_ADDRESS_PATH))) { + return; + } + if (StringUtils.isBlank(getStringAtPath(profileDetails, EMAIL_TYPE_PATH))) { + setValue(profileDetails, EMAIL_TYPE_PATH, DEFAULT_EMAIL_TYPE); + } + if (!hasLeafAtPath(profileDetails, EMAIL_PRIMARY_IND_PATH)) { + setBooleanAtPath(profileDetails, EMAIL_PRIMARY_IND_PATH, true); + } + } + + private static String normalizePath(String raw) { + String path = StringUtils.trimToEmpty(raw); + if (path.startsWith("profileDetails.")) { + path = path.substring("profileDetails.".length()); + } + return StringUtils.trimToNull(path); + } + + private static void setValue(JSONObject root, String path, String value) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), true); + if (current == null) { + return; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null) { + arr = new JSONArray(); + obj.put(leaf.name(), arr); + } + while (arr.size() <= leafSelector.index) { + arr.add(defaultArrayElement(leaf.name())); + } + arr.set(leafSelector.index, value); + return; + } + obj.put(leaf.name(), value); + } + + private static String getStringAtPath(JSONObject root, String path) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return null; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), false); + if (current == null) { + return null; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return null; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null || arr.size() <= leafSelector.index) { + return null; + } + Object value = arr.get(leafSelector.index); + return value != null ? StringUtils.trimToNull(String.valueOf(value)) : null; + } + return StringUtils.trimToNull(obj.getString(leaf.name())); + } + + private static boolean hasLeafAtPath(JSONObject root, String path) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return false; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), false); + if (current == null) { + return false; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return false; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + return arr != null && arr.size() > leafSelector.index && arr.get(leafSelector.index) != null; + } + return obj.containsKey(leaf.name()); + } + + private static void setBooleanAtPath(JSONObject root, String path, boolean value) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), true); + if (current == null) { + return; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null) { + arr = new JSONArray(); + obj.put(leaf.name(), arr); + } + while (arr.size() <= leafSelector.index) { + arr.add(defaultArrayElement(leaf.name())); + } + arr.set(leafSelector.index, value); + return; + } + obj.put(leaf.name(), value); + } + + private static Object defaultArrayElement(String segmentName) { + return "addressLine".equals(segmentName) ? "" : new JSONObject(); + } + + private static List parsePath(String path) { + List out = new ArrayList<>(); + Matcher matcher = SEGMENT.matcher(path); + int pos = 0; + while (matcher.find(pos)) { + if (matcher.start() != pos) { + break; + } + String name = matcher.group(1); + String selector = StringUtils.trimToNull(matcher.group(2)); + out.add(new Segment(name, parseSelector(name, selector))); + pos = matcher.end(); + if (pos < path.length() && path.charAt(pos) == '.') { + pos++; + } + } + return out; + } + + private static Selector parseSelector(String segmentName, String raw) { + if (raw == null) { + return null; + } + if (raw.contains("=")) { + int eq = raw.indexOf('='); + return Selector.match(raw.substring(0, eq).trim(), raw.substring(eq + 1).trim()); + } + if (NumberUtils.isDigits(raw)) { + return Selector.index(Integer.parseInt(raw)); + } + if ("personName".equals(segmentName)) { + return Selector.match("nameType", raw); + } + if ("keyword".equals(segmentName)) { + return Selector.index(0); + } + return Selector.match("type", raw); + } + + private static Object navigate(Object current, Segment segment, boolean create) { + if (!(current instanceof JSONObject obj)) { + return null; + } + Selector selector = segment.selector(); + if (selector == null) { + if (!obj.containsKey(segment.name())) { + if (!create) { + return null; + } + obj.put(segment.name(), new JSONObject()); + } + return obj.get(segment.name()); + } + if (!obj.containsKey(segment.name())) { + if (!create) { + return null; + } + obj.put(segment.name(), new JSONArray()); + } + Object node = obj.get(segment.name()); + final JSONArray arr; + if (node instanceof JSONArray existing) { + arr = existing; + } else { + if (!create) { + return null; + } + arr = new JSONArray(); + obj.put(segment.name(), arr); + } + if (selector.kind == SelectorKind.INDEX) { + while (arr.size() <= selector.index) { + arr.add(defaultArrayElement(segment.name())); + } + return arr.get(selector.index); + } + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (item instanceof JSONObject itemObj && matches(itemObj, selector)) { + return itemObj; + } + } + if (!create) { + return null; + } + JSONObject created = new JSONObject(); + created.put(selector.matchKey, selector.matchValue); + arr.add(created); + return created; + } + + private static boolean matches(JSONObject obj, Selector selector) { + if (selector.kind != SelectorKind.MATCH) { + return false; + } + String actual = StringUtils.trimToEmpty(obj.getString(selector.matchKey)); + return actual.equalsIgnoreCase(selector.matchValue); + } + + private record Segment(String name, Selector selector) { + } + + private enum SelectorKind { + INDEX, + MATCH + } + + private static final class Selector { + private final SelectorKind kind; + private final int index; + private final String matchKey; + private final String matchValue; + + private Selector(SelectorKind kind, int index, String matchKey, String matchValue) { + this.kind = kind; + this.index = index; + this.matchKey = matchKey; + this.matchValue = matchValue; + } + + static Selector index(int index) { + return new Selector(SelectorKind.INDEX, index, null, null); + } + + static Selector match(String key, String value) { + return new Selector( + SelectorKind.MATCH, + -1, + StringUtils.trimToEmpty(key), + StringUtils.trimToEmpty(value)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/RegistrationCardResponseParser.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/RegistrationCardResponseParser.java new file mode 100644 index 0000000..6160317 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/RegistrationCardResponseParser.java @@ -0,0 +1,117 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Base64; +import java.util.Optional; + +public final class RegistrationCardResponseParser { + + private RegistrationCardResponseParser() { + } + + public record PdfPayload(String pdfBase64, String fileName, String fileType) { + } + + public static Optional extract(String body) { + if (StringUtils.isBlank(body)) { + return Optional.empty(); + } + String trimmed = body.trim(); + if (trimmed.startsWith("%PDF")) { + return Optional.of(new PdfPayload( + Base64.getEncoder().encodeToString(trimmed.getBytes()), + "registration-card.pdf", + "PDF")); + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + Object parsed = JSON.parse(trimmed); + Optional fromJson = findInNode(parsed); + if (fromJson.isPresent()) { + return fromJson; + } + } catch (Exception ignored) { + // fall through + } + } + if (looksLikeBase64(trimmed)) { + return Optional.of(new PdfPayload(trimmed, "registration-card.pdf", "PDF")); + } + return Optional.empty(); + } + + private static Optional findInNode(Object node) { + if (node instanceof JSONObject obj) { + String fileData = firstNonBlank( + obj.getString("fileData"), + obj.getString("pdf"), + obj.getString("content"), + obj.getString("registrationCard")); + if (StringUtils.isNotBlank(fileData) && looksLikeBase64OrPdfMarker(fileData)) { + return Optional.of(buildPayload(obj, normalizeBase64(fileData))); + } + for (String key : obj.keySet()) { + Optional nested = findInNode(obj.get(key)); + if (nested.isPresent()) { + return nested; + } + } + } else if (node instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Optional nested = findInNode(arr.get(i)); + if (nested.isPresent()) { + return nested; + } + } + } + return Optional.empty(); + } + + private static PdfPayload buildPayload(JSONObject obj, String pdfBase64) { + String fileName = firstNonBlank( + obj.getString("fileName"), + obj.getString("filename"), + "registration-card.pdf"); + String fileType = StringUtils.defaultIfBlank( + firstNonBlank(obj.getString("fileType"), obj.getString("mimeType")), + "PDF"); + return new PdfPayload(pdfBase64, fileName, fileType); + } + + private static String normalizeBase64(String value) { + String trimmed = value.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed; + } + + private static boolean looksLikeBase64OrPdfMarker(String value) { + if (value.startsWith("%PDF") || value.startsWith("data:")) { + return true; + } + return looksLikeBase64(value); + } + + private static boolean looksLikeBase64(String value) { + if (StringUtils.isBlank(value) || value.length() < 32) { + return false; + } + String sample = value.length() > 256 ? value.substring(0, 256) : value; + return sample.matches("(?s)[A-Za-z0-9+/=\\r\\n-_.]+"); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.isNotBlank(value)) { + return value.trim(); + } + } + return null; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/RegistrationConfirmationExtractor.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/RegistrationConfirmationExtractor.java new file mode 100644 index 0000000..fe5af1e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/RegistrationConfirmationExtractor.java @@ -0,0 +1,94 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import org.apache.commons.lang3.StringUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; + +import java.util.Base64; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 从登记卡 PDF 文本中提取 OPERA 确认号(Conf. No / 確認號碼)。 */ +public final class RegistrationConfirmationExtractor { + + private static final Pattern[] TEXT_PATTERNS = { + Pattern.compile("(?is)conf\\.?\\s*no\\s*確認號碼\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)confirmation\\s*no\\.?[^0-9]{0,48}(\\d{4,12})"), + Pattern.compile("(?is)(?:訂房編號|订房编号|訂房號碼|订房号码)\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)確認號碼\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)confirmation\\s*(?:no\\.?|number)\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)conf\\.?\\s*no\\.?\\s*[::]?\\s*(\\d{4,12})"), + // OCR 常见误识别:Conf. Nof#aa sk: 3041736(No 与数字间可有字母/符号) + Pattern.compile("(?is)conf[\\s.]*no[^0-9]{0,48}(\\d{6,12})"), + Pattern.compile("(?is)conf\\W{0,20}(?:no|n[o0])[^0-9]{0,48}(\\d{6,12})"), + Pattern.compile("(?is)(?:確認號碼|确认号码|確認号码)\\W{0,16}(\\d{4,12})"), + Pattern.compile("(?is)(?:conf|corn|conl)[\\s.]*(?:no|n[o0])[^0-9]{0,48}(\\d{6,12})") + }; + + private RegistrationConfirmationExtractor() { + } + + public static Optional extractFromPdfBase64(String pdfBase64) { + if (StringUtils.isBlank(pdfBase64)) { + return Optional.empty(); + } + try { + byte[] bytes = Base64.getDecoder().decode(normalizeBase64(pdfBase64)); + return extractFromPdfBytes(bytes); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + public static Optional extractFromPdfBytes(byte[] pdfBytes) { + if (pdfBytes == null || pdfBytes.length == 0) { + return Optional.empty(); + } + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDFTextStripper stripper = new PDFTextStripper(); + String text = stripper.getText(doc); + return extractFromText(text); + } catch (Exception e) { + return Optional.empty(); + } + } + + public static Optional extractFromText(String text) { + if (StringUtils.isBlank(text)) { + return Optional.empty(); + } + String normalized = text.replace('\u00a0', ' '); + for (Pattern pattern : TEXT_PATTERNS) { + Matcher matcher = pattern.matcher(normalized); + if (matcher.find()) { + return Optional.of(matcher.group(1).trim()); + } + } + return extractLooseConfNo(normalized); + } + + /** OCR 兜底:Conf / No 附近 48 字符内的 6–12 位数字 */ + private static Optional extractLooseConfNo(String text) { + Matcher label = Pattern.compile("(?is)conf[\\s.]*no").matcher(text); + while (label.find()) { + int start = label.end(); + int end = Math.min(text.length(), start + 48); + Matcher digits = Pattern.compile("(\\d{6,12})").matcher(text.substring(start, end)); + if (digits.find()) { + return Optional.of(digits.group(1).trim()); + } + } + return Optional.empty(); + } + + private static String normalizeBase64(String raw) { + String trimmed = raw.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed.replaceAll("\\s", ""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ReservationDetailResponseParser.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ReservationDetailResponseParser.java new file mode 100644 index 0000000..d8bdacf --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ReservationDetailResponseParser.java @@ -0,0 +1,138 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileRef; +import org.apache.commons.lang3.StringUtils; + +import java.util.Optional; + +/** 解析 OHIP GET /hotels/{hotelId}/reservations/{reservationId} 响应中的 Guest Profile。 */ +public final class ReservationDetailResponseParser { + + private ReservationDetailResponseParser() { + } + + public static Optional extractProfileId(String body) { + return extractGuestProfile(body).map(GuestProfileRef::profileId); + } + + public static Optional extractGuestProfile(String body) { + if (StringUtils.isBlank(body)) { + return Optional.empty(); + } + try { + Object parsed = JSON.parse(body.trim()); + return extractGuestProfile(parsed); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static Optional extractGuestProfile(Object node) { + if (node instanceof JSONObject obj) { + Optional fromGuests = extractFromReservationGuests(obj.get("reservationGuests")); + if (fromGuests.isPresent()) { + return fromGuests; + } + Object reservations = obj.get("reservations"); + if (reservations instanceof JSONObject reservationsObj) { + Optional nested = extractFromReservationsContainer(reservationsObj); + if (nested.isPresent()) { + return nested; + } + } + for (String key : obj.keySet()) { + Optional nested = extractGuestProfile(obj.get(key)); + if (nested.isPresent()) { + return nested; + } + } + } else if (node instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Optional nested = extractGuestProfile(arr.get(i)); + if (nested.isPresent()) { + return nested; + } + } + } + return Optional.empty(); + } + + private static Optional extractFromReservationsContainer(JSONObject reservationsObj) { + Object reservation = reservationsObj.get("reservation"); + if (reservation instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + if (arr.get(i) instanceof JSONObject resObj) { + Optional profile = extractFromReservationGuests(resObj.get("reservationGuests")); + if (profile.isPresent()) { + return profile; + } + } + } + } else if (reservation instanceof JSONObject resObj) { + return extractFromReservationGuests(resObj.get("reservationGuests")); + } + return Optional.empty(); + } + + private static Optional extractFromReservationGuests(Object guestsNode) { + if (!(guestsNode instanceof JSONArray guests)) { + return Optional.empty(); + } + Optional primary = Optional.empty(); + for (int i = 0; i < guests.size(); i++) { + if (!(guests.get(i) instanceof JSONObject guest)) { + continue; + } + Optional profile = extractFromProfileInfo(guest.getJSONObject("profileInfo")); + if (profile.isEmpty()) { + continue; + } + if (Boolean.TRUE.equals(guest.getBoolean("primary"))) { + return profile; + } + if (primary.isEmpty()) { + primary = profile; + } + } + return primary; + } + + private static Optional extractFromProfileInfo(JSONObject profileInfo) { + if (profileInfo == null) { + return Optional.empty(); + } + String profileType = readProfileType(profileInfo.getJSONObject("profile")); + Object list = profileInfo.get("profileIdList"); + if (!(list instanceof JSONArray arr)) { + return Optional.empty(); + } + Optional fallbackId = Optional.empty(); + for (int i = 0; i < arr.size(); i++) { + if (!(arr.get(i) instanceof JSONObject idObj)) { + continue; + } + String id = StringUtils.trimToNull(idObj.getString("id")); + if (id == null) { + continue; + } + String type = StringUtils.defaultString(idObj.getString("type")); + if ("Profile".equalsIgnoreCase(type)) { + return Optional.of(new GuestProfileRef(id, profileType)); + } + if (fallbackId.isEmpty()) { + fallbackId = Optional.of(id); + } + } + return fallbackId.map(id -> new GuestProfileRef(id, profileType)); + } + + private static String readProfileType(JSONObject profile) { + if (profile == null) { + return null; + } + return StringUtils.trimToNull(profile.getString("profileType")); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ReservationLookupResponseParser.java b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ReservationLookupResponseParser.java new file mode 100644 index 0000000..a1f25ee --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/booking/reservation/support/ReservationLookupResponseParser.java @@ -0,0 +1,103 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Optional; + +/** 解析 OHIP GET /reservations 响应中的 reservation id。 */ +public final class ReservationLookupResponseParser { + + private ReservationLookupResponseParser() { + } + + public static Optional extractReservationId(String body, String confirmationNumber) { + if (StringUtils.isBlank(body)) { + return Optional.empty(); + } + try { + Object parsed = JSON.parse(body.trim()); + Optional matched = findReservationId(parsed, confirmationNumber); + if (matched.isPresent()) { + return matched; + } + return findReservationId(parsed, null); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static Optional findReservationId(Object node, String confirmationNumber) { + if (node instanceof JSONObject obj) { + String confInNode = firstNonBlank( + obj.getString("confirmationNumber"), + obj.getString("confirmationNo"), + obj.getString("confirmationId")); + String reservationId = firstReservationId(obj); + if (StringUtils.isNotBlank(reservationId)) { + if (StringUtils.isBlank(confirmationNumber) + || StringUtils.isBlank(confInNode) + || confirmationNumber.equals(confInNode.trim())) { + return Optional.of(reservationId.trim()); + } + } + for (String key : obj.keySet()) { + Optional nested = findReservationId(obj.get(key), confirmationNumber); + if (nested.isPresent()) { + return nested; + } + } + } else if (node instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Optional nested = findReservationId(arr.get(i), confirmationNumber); + if (nested.isPresent()) { + return nested; + } + } + } + return Optional.empty(); + } + + private static String firstReservationId(JSONObject obj) { + String direct = firstNonBlank( + obj.getString("reservationId"), + obj.getString("reservationID"), + obj.getString("id")); + if (StringUtils.isNotBlank(direct) && looksLikeReservationId(direct)) { + return direct; + } + Object list = obj.get("reservationIdList"); + if (list instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (item instanceof JSONObject idObj) { + String type = StringUtils.defaultString(idObj.getString("type")); + String id = firstNonBlank(idObj.getString("id"), idObj.getString("reservationId")); + if (StringUtils.isNotBlank(id) + && (StringUtils.isBlank(type) || type.toLowerCase().contains("reservation"))) { + return id; + } + } else if (item != null && StringUtils.isNotBlank(String.valueOf(item))) { + return String.valueOf(item); + } + } + } + return null; + } + + private static boolean looksLikeReservationId(String value) { + String trimmed = value.trim(); + return trimmed.matches("\\d{3,12}"); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.isNotBlank(value)) { + return value.trim(); + } + } + return null; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/LoginUserInfo.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/LoginUserInfo.java new file mode 100644 index 0000000..5bf331c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/LoginUserInfo.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.common.dtos; + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +@Data +@Getter +@Setter +public class LoginUserInfo { + private Long userId; + private String employeeId; + private String userCode; + private String userName; + private String adGroup; + private String role; + private String email; + private String loginSource; + private String tokenUuid; + private String appVersion; +}