feat:Add extend API

This commit is contained in:
蔡旻峰
2026-09-21 17:16:55 +08:00
parent ad1e3d238f
commit 7562ccceec
51 changed files with 5141 additions and 0 deletions
@@ -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<EsignAuditLogItem> 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;
}
}
@@ -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;
}
@@ -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<EsignAuditLog, String> {
Page<EsignAuditLog> findByLogCategoryOrderByCreatedAtDesc(String logCategory, Pageable pageable);
Page<EsignAuditLog> findByLogCategoryAndEventTypeOrderByCreatedAtDesc(
String logCategory, String eventType, Pageable pageable);
}
@@ -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<EsignAuditLog> 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<EsignAuditLogResponse.EsignAuditLogItem> 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;
}
}
@@ -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)";
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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<String> groups;
private List<OhipProfileFieldDef> fields;
private List<OhipProfileFieldDef> commonFields;
}
public static OhipProfileFieldCatalogResponse ok(
List<String> groups, List<OhipProfileFieldDef> fields, List<OhipProfileFieldDef> 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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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<String, String> mapValues(
String signaturesJson,
String signFieldsJson,
List<SignFieldOhipMappingItem> mappings) {
Map<String, String> 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<SignFieldOhipMappingItem> normalizeMappings(List<SignFieldOhipMappingItem> raw) {
List<SignFieldOhipMappingItem> 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("");
}
}
@@ -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<String> 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");
}
}
@@ -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;
}
}
@@ -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<String> 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<OhipProfileFieldDef> 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<String, OhipProfileFieldDef> BY_CODE;
private static final Map<String, OhipProfileFieldDef> BY_PATH;
static {
Map<String, OhipProfileFieldDef> byCode = new LinkedHashMap<>();
Map<String, OhipProfileFieldDef> 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<OhipProfileFieldDef> all() {
return FIELDS;
}
public static List<OhipProfileFieldDef> commonFields() {
List<OhipProfileFieldDef> 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<OhipProfileFieldDef> findByCode(String code) {
if (StringUtils.isBlank(code)) {
return Optional.empty();
}
return Optional.ofNullable(BY_CODE.get(code.trim()));
}
public static Optional<OhipProfileFieldDef> 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<String> resolvePath(String codeOrPath) {
return findByCodeOrPath(codeOrPath).map(OhipProfileFieldDef::getPath);
}
public static List<String> groupOrder() {
List<String> 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;
}
}
@@ -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<Segment> 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<Segment> parsePath(String path) {
List<Segment> 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));
}
}
}
@@ -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<BookingCheckInResult> 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<BookingStayExtensionResult> 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;
}
}
@@ -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);
}
}
@@ -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) {
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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<SignFieldOhipMappingItem> 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;
}
}
@@ -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<SignFieldOhipMappingItem> mappings;
}
@@ -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<SignFieldOhipMappingItem> mappings;
@Schema(description = "预览用 profileType,默认 Guest")
private String profileType;
}
@@ -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<String, Object> 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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
}
@@ -0,0 +1,5 @@
package com.sjmresorts.opsapp.esign.reservation.dtos;
/** OHIP 按确认号查预订时的 reservationId + 可选 Guest Profile。 */
public record ReservationLookupHit(String reservationId, GuestProfileRef guestProfile) {
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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<EsignReservationTask, String> {
Optional<EsignReservationTask> findFirstByReservationIdAndHotelIdAndStatusOrderByCreatedAtDesc(
String reservationId, String hotelId, String status);
List<EsignReservationTask> findTop100ByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
String status, Instant createdAtMinInclusive);
List<EsignReservationTask> findTop100ByStatusAndCreatedAtLessThanOrderByCreatedAtDesc(
String status, Instant createdAtMaxExclusive);
List<EsignReservationTask> findTop100ByStatusOrderByCompletedAtDesc(String status);
}
@@ -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<EsignSignFieldOhipConfig, String> {
Optional<EsignSignFieldOhipConfig> findFirstByHotelIdOrderByUpdatedAtDesc(String hotelId);
Optional<EsignSignFieldOhipConfig> findFirstByHotelIdIsNullOrderByUpdatedAtDesc();
}
@@ -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<ReservationLookupHit> 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<String> lookupReservationIdByConfirmation(String hotelId, String confirmationNumber) {
return lookupReservationByConfirmation(hotelId, confirmationNumber).map(ReservationLookupHit::reservationId);
}
public Optional<ReservationLookupHit> 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<String> reservationId =
ReservationLookupResponseParser.extractReservationId(body, confirmationNumber.trim());
Optional<GuestProfileRef> 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<String> 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<GuestProfileRef> 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<GuestProfileRef> 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<JSONObject> 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<String, String> fields) {
return submitMappedProfileFields(hotelId, reservationId, fields, null);
}
public SignedRegistrationCardUploadResult submitMappedProfileFields(
String hotelId, String reservationId, Map<String, String> fields, String profileId) {
return submitMappedProfileFields(hotelId, reservationId, fields, profileId, null);
}
public SignedRegistrationCardUploadResult submitMappedProfileFields(
String hotelId,
String reservationId,
Map<String, String> 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<GuestProfileRef> 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<String, String> 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);
}
}
}
@@ -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) + "...";
}
}
@@ -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<String, String> buildBusinessHeaders(String hotelId) {
HospitalityOAuthSettings current = settings;
if (current == null) {
throw new IllegalStateException("OHIP OAuth is not configured");
}
Map<String, String> 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<String, String> buildCommonHeaders() {
HospitalityOAuthSettings current = settings;
if (current == null) {
throw new IllegalStateException("OHIP OAuth is not configured");
}
Map<String, String> 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<String, String> buildOAuthTokenHeaders(HospitalityOAuthSettings current, String requestId) {
// 与 Postman /oauth/v1/tokens 一致:仅 x-app-key、enterpriseId、X-Request-Id(不含 hotelId)
Map<String, String> 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<String, String> 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)";
}
}
@@ -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 """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>登记程序</title>
<style>
body{font-family:system-ui,sans-serif;margin:0;padding:20px;text-align:center;background:#f7f9f8}
.warn{margin:0 0 12px;padding:12px 14px;background:#fff3e0;border:1px solid #ffb74d;border-radius:8px;color:#5d4037;font-size:14px;line-height:1.5;text-align:left}
.ok{margin:0 0 12px;padding:12px 14px;background:#e8f5e9;border:1px solid #81c784;border-radius:8px;color:#1b5e20;font-size:14px;line-height:1.5;text-align:left}
.btn{display:inline-block;padding:12px 28px;background:#555;color:#fff;border:none;border-radius:8px;font-size:14px;cursor:pointer;margin-top:8px}
#debugBox{text-align:left;margin:12px 0 0;padding:12px;background:#fff;border:1px solid #ccc;border-radius:6px;font:12px/1.5 monospace;word-break:break-all;white-space:pre-wrap;color:#333}
</style>
</head>
<body>
<div class="warn" id="hintBox">%s</div>
<div class="ok" id="okBox" style="display:none"></div>
<button type="button" class="btn" id="openBtn">%s</button>
<button type="button" class="btn" id="closeBtn" style="display:none">手动关闭本标签</button>
%s
<script>
%s
var deepLink = "%s";
var useBrowserDeepLink = %s;
var serverLaunchMsg = "%s";
var openDelayMs = %d;
var closeRetryMs = %d;
function showOk(msg) {
document.getElementById("hintBox").style.display = "none";
var ok = document.getElementById("okBox");
ok.style.display = "block";
ok.innerHTML = msg;
document.getElementById("closeBtn").style.display = "inline-block";
}
function scheduleCloseAfterOpen() {
[0, closeRetryMs, closeRetryMs * 2].forEach(function (extra, i) {
setTimeout(function () {
var r = closeLaunchWindow();
if (i === 2 && !window.closed) {
showOk("<strong>登记程序应已打开。</strong><br>关标签结果:" + r + "<br>若本页仍在,请手动关闭标签。");
}
if (i === 2) renderLaunchDebug(r);
}, openDelayMs + extra);
});
}
function openExeAndCloseTab() {
if (useBrowserDeepLink) {
try { window.location.assign(deepLink); } catch (e) {}
}
scheduleCloseAfterOpen();
}
document.getElementById("openBtn").addEventListener("click", function () {
if (useBrowserDeepLink) {
openExeAndCloseTab();
} else {
window.location.reload();
}
});
document.getElementById("closeBtn").addEventListener("click", function () {
var r = closeLaunchWindow();
showOk("关标签:" + r);
renderLaunchDebug(r);
});
openExeAndCloseTab();
</script>
</body>
</html>
""".formatted(
useBrowserDeepLink
? "正在唤起登记程序…<br>若浏览器询问是否打开应用,请先点击「打开」。登记程序启动后本标签将自动关闭。"
: 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
? """
<button type="button" class="btn" id="openBtn">再次打开登记程序</button>
<button type="button" class="btn btn2" id="closeBtn">仅测试关窗</button>
"""
: "";
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>登记程序</title>
<style>
body{font-family:system-ui,sans-serif;margin:0;padding:20px;text-align:center;background:#f7f9f8}
.warn{margin:0 0 12px;padding:12px 14px;background:#fff3e0;border:1px solid #ffb74d;border-radius:8px;color:#5d4037;font-size:14px;line-height:1.5;text-align:left}
.ok{margin:0 0 12px;padding:12px 14px;background:#e8f5e9;border:1px solid #81c784;border-radius:8px;color:#1b5e20;font-size:14px;line-height:1.5;text-align:left;display:none}
.btn{display:inline-block;padding:12px 28px;background:#1a3d36;color:#fff;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;margin:4px}
.btn2{background:#555}
#debugBox{text-align:left;margin:12px 0 0;padding:12px;background:#fff;border:1px solid #ccc;border-radius:6px;font:12px/1.5 monospace;word-break:break-all;white-space:pre-wrap;color:#333}
%s
</style>
</head>
<body class="%s">
%s
<div class="warn" id="hintBox">%s</div>
<div class="ok" id="okBox"></div>
%s
%s
<script>
%s
var deepLink = "%s";
var useBrowserDeepLink = %s;
var openDelayMs = %d;
var closeRetryMs = %d;
var debugPage = %s;
function showOk(msg) {
document.getElementById("hintBox").style.display = "none";
var ok = document.getElementById("okBox");
ok.style.display = "block";
ok.innerHTML = msg;
}
function scheduleCloseAfterOpen() {
var closeDelays = [0, 40, 90, 180];
closeDelays.forEach(function (ms, i) {
setTimeout(function () {
var r = closeLaunchWindow();
if (debugPage && i === closeDelays.length - 1) {
showOk("<strong>登记程序应已打开。</strong><br>关 Pop-up 结果:" + r);
renderLaunchDebug(r);
}
}, ms);
});
}
function openExeAndClosePopup() {
if (useBrowserDeepLink) {
try { window.location.assign(deepLink); } catch (e) {}
}
scheduleCloseAfterOpen();
}
initOperaPopupSession();
openExeAndClosePopup();
var openBtn = document.getElementById("openBtn");
var closeBtn = document.getElementById("closeBtn");
if (openBtn) {
openBtn.addEventListener("click", function () {
openExeAndClosePopup();
showOk("<strong>登记程序已打开。</strong><br>正在尝试关闭 Pop-up…");
});
}
if (closeBtn) {
closeBtn.addEventListener("click", function () {
var r = closeLaunchWindow();
showOk("关窗测试:<br>" + r);
renderLaunchDebug(r);
});
}
</script>
</body>
</html>
""".formatted(
extraStyles,
bodyClass.trim(),
debugPage ? "" : "<!-- stealth launch -->",
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 """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>
body{font-family:system-ui,sans-serif;margin:0;padding:20px;text-align:center;background:#f7f9f8}
.ok{margin:0 0 16px;padding:12px 14px;background:#e8f5e9;border:1px solid #81c784;border-radius:8px;color:#1b5e20;font-size:14px;line-height:1.5;text-align:left}
.err{margin:0 0 16px;padding:12px 14px;background:#ffebee;border:1px solid #e57373;border-radius:8px;color:#b71c1c;font-size:14px;line-height:1.5;text-align:left}
#debugBox{text-align:left;margin:16px 0 0;padding:12px;background:#fff;border:1px solid #ccc;border-radius:6px;font:12px/1.5 monospace;word-break:break-all;white-space:pre-wrap;color:#333}
</style>
</head>
<body>
<div class="%s">
<strong>%s</strong><br>预订号:%s<br>%s
</div>
%s
<script>
%s
%s
</script>
</body>
</html>
""".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 "<pre id=\"debugBox\">调试信息</pre>";
}
/**
* 内层脚本: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 =
"<strong>Pop-up 未关闭:Opera 主页面缺少外层代理</strong><br><br>" +
"内层已发 CLOSE_SHELL,但未收到 SHELL_CLOSE_ACK。<br><br>" +
"<strong>一次性安装(推荐 Tampermonkey):</strong><br>" +
"1. 安装 Tampermonkey 扩展<br>" +
"2. 打开 <a href=\\"" + proxyInstallUrl + "\\" target=\\"_blank\\" rel=\\"noopener\\">" + proxyInstallUrl + "</a> 并「安装此脚本」<br>" +
"3. 刷新 Opera 主页面后再点 Flex Link<br><br>" +
"<strong>或手动注入:</strong> 在 Opera <em>主页面</em>(非本 Pop-up)按 F12 → Console,执行:<br>" +
"<code style=\\"display:block;margin-top:6px;padding:8px;background:#fff;border:1px solid #ccc;word-break:break-all;\\">" +
"fetch(\\"" + flexLinkOrigin + "/api/v1/checkin/app/opera-parent-close-listener.js\\").then(r=&gt;r.text()).then(eval)" +
"</code>";
}
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 """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Opera 关窗代理 — 安装向导</title>
<style>
body{font-family:system-ui,sans-serif;max-width:760px;margin:24px auto;padding:0 16px;line-height:1.65;color:#222}
h1{font-size:22px}
h2{font-size:17px;margin-top:24px}
.note{margin:16px 0;padding:14px 16px;background:#fff8e1;border:1px solid #ffcc80;border-radius:8px}
.best{margin:16px 0;padding:14px 16px;background:#e8f5e9;border:1px solid #81c784;border-radius:8px}
.step{margin:16px 0;padding:14px 16px;background:#f5f5f5;border-radius:8px}
code{background:#eee;padding:2px 6px;border-radius:4px;word-break:break-all}
a.btn{display:inline-block;margin:8px 8px 0 0;padding:10px 18px;background:#1a3d36;color:#fff;text-decoration:none;border-radius:6px;font-size:14px}
a.btn2{background:#555}
table{width:100%%;border-collapse:collapse;font-size:14px;margin-top:8px}
th,td{border:1px solid #ddd;padding:8px;text-align:left}
th{background:#fafafa}
</style>
</head>
<body>
<h1>Opera Flex Link 自动关 Pop-up — 安装向导</h1>
<div class="note">
<strong>为什么 Chrome 扩展不能「一条链接自动装上」?</strong><br>
浏览器安全策略禁止从任意网址一键安装扩展(防恶意软件)。
只有 <strong>应用商店「添加到 Chrome」</strong> 或 <strong>IT 组策略强制下发</strong> 才能接近「一点安装」。
</div>
<div class="best">
<strong>✓ 最接近「一条链接自动装」:Tampermonkey 脚本</strong><br>
先装一次 Tampermonkey 扩展(应用商店),之后点下面链接即可<strong>一键安装关窗脚本</strong>,无需加载文件夹。
<br><a class="btn" href="%s">一键安装 Tampermonkey 脚本</a>
<a class="btn btn2" href="https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo" target="_blank" rel="noopener">安装 Tampermonkey(首次)</a>
</div>
<h2>方式对比</h2>
<table>
<tr><th>方式</th><th>能否一条链接</th><th>说明</th></tr>
<tr><td>Tampermonkey + .user.js</td><td>✓ 脚本能</td><td>首次需装 Tampermonkey,之后点链接即装脚本</td></tr>
<tr><td>自研 Chrome 扩展(加载已解压)</td><td>✗ 不能</td><td>须 chrome://extensions → 加载文件夹(约 4 步)</td></tr>
<tr><td>上架 Chrome 网上应用店</td><td>✓ 能</td><td>需 $5 开发者账号 + 审核,适合全公司统一下载</td></tr>
<tr><td>IT 组策略强制安装</td><td>✓ 能</td><td>用户无感,适合企业批量部署</td></tr>
</table>
<div class="step">
<strong>方式 A:Tampermonkey(推荐,一条链接装脚本)</strong>
<ol>
<li>安装 <a href="https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo" target="_blank" rel="noopener">Tampermonkey</a>(只做一次)</li>
<li>打开 <a href="%s">%s</a> → 点「安装」</li>
<li>刷新 Opera Cloud 主页面</li>
</ol>
</div>
<div class="step">
<strong>方式 B:自研 Chrome 扩展(不能一条链接,但无需 Tampermonkey)</strong>
<ol>
<li>打开 <code>chrome://extensions</code>,开启「开发者模式」</li>
<li>「加载已解压的扩展程序」→ 选项目内 %s</li>
<li>刷新 Opera Cloud 主页面</li>
</ol>
<p>Console 应出现:<code>[auto-check-in ext] 外层代理已就绪</code></p>
</div>
<div class="step">
<strong>验证</strong>
<p>点 Flex Link → Pop-up 应能自动关闭;调试框可见 <code>shellProxyAck: true</code></p>
<p>主页面 Console:<code>__autoCheckInDestroyAdfPopupShell()</code></p>
</div>
<p style="color:#666;font-size:13px">本页地址:<a href="%s">%s</a></p>
</body>
</html>
""".formatted(userJs, userJs, userJs, extPath, installPage, installPage);
}
private static String escapeHtml(String value) {
return String.valueOf(value)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
private static String escapeJsString(String value) {
return String.valueOf(value)
.replace("\\", "\\\\")
.replace("\"", "\\\"");
}
}
@@ -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} 请求体。
* <p>
* 示例路径:{@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<String, String> mappedFields, String profileType) {
JSONObject profileDetails = new JSONObject();
if (mappedFields != null) {
for (Map.Entry<String, String> 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<Segment> 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<Segment> 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<Segment> 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<Segment> 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<Segment> parsePath(String path) {
List<Segment> 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));
}
}
}
@@ -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<PdfPayload> 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<PdfPayload> 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<PdfPayload> 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<PdfPayload> 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<PdfPayload> 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;
}
}
@@ -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<String> 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<String> 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<String> 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<String> 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", "");
}
}
@@ -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<String> extractProfileId(String body) {
return extractGuestProfile(body).map(GuestProfileRef::profileId);
}
public static Optional<GuestProfileRef> 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<GuestProfileRef> extractGuestProfile(Object node) {
if (node instanceof JSONObject obj) {
Optional<GuestProfileRef> fromGuests = extractFromReservationGuests(obj.get("reservationGuests"));
if (fromGuests.isPresent()) {
return fromGuests;
}
Object reservations = obj.get("reservations");
if (reservations instanceof JSONObject reservationsObj) {
Optional<GuestProfileRef> nested = extractFromReservationsContainer(reservationsObj);
if (nested.isPresent()) {
return nested;
}
}
for (String key : obj.keySet()) {
Optional<GuestProfileRef> 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<GuestProfileRef> nested = extractGuestProfile(arr.get(i));
if (nested.isPresent()) {
return nested;
}
}
}
return Optional.empty();
}
private static Optional<GuestProfileRef> 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<GuestProfileRef> 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<GuestProfileRef> extractFromReservationGuests(Object guestsNode) {
if (!(guestsNode instanceof JSONArray guests)) {
return Optional.empty();
}
Optional<GuestProfileRef> primary = Optional.empty();
for (int i = 0; i < guests.size(); i++) {
if (!(guests.get(i) instanceof JSONObject guest)) {
continue;
}
Optional<GuestProfileRef> 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<GuestProfileRef> 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<String> 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"));
}
}
@@ -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<String> extractReservationId(String body, String confirmationNumber) {
if (StringUtils.isBlank(body)) {
return Optional.empty();
}
try {
Object parsed = JSON.parse(body.trim());
Optional<String> matched = findReservationId(parsed, confirmationNumber);
if (matched.isPresent()) {
return matched;
}
return findReservationId(parsed, null);
} catch (Exception e) {
return Optional.empty();
}
}
private static Optional<String> 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<String> 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<String> 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;
}
}
@@ -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;
}