feat:Add check in API

This commit is contained in:
蔡旻峰
2026-09-21 16:46:59 +08:00
parent 6fa2365b87
commit ad1e3d238f
49 changed files with 28 additions and 4874 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>21</java.version> <java.version>24</java.version>
<logback.version>1.5.20</logback.version> <logback.version>1.5.20</logback.version>
<maven.compiler.source>${java.version}</maven.compiler.source> <maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target> <maven.compiler.target>${java.version}</maven.compiler.target>
@@ -69,20 +69,20 @@ public class WebFilterConfig {
"/api/v1/logging/auditLog/**", "/api/v1/logging/auditLog/**",
"/api/v1/logging/apiLog/**", "/api/v1/logging/apiLog/**",
"/api/v1/logs/**", "/api/v1/logs/**",
"/api/v1/esign/capture-logs/**", "/api/v1/checkin/capture-logs/**",
"/api/v1/esign/auth/**", "/api/v1/checkin/auth/**",
"/api/v1/esign/**", "/api/v1/checkin/**",
"/esign-api/api/v1/esign/**", "/esign-api/api/v1/checkin/**",
"**/api/v1/esign/**", "**/api/v1/checkin/**",
"/api/v1/sinosecu/auth/**", "/api/v1/sinosecu/auth/**",
"/api/v1/sinosecu/**", "/api/v1/sinosecu/**",
"/esign-api/api/v1/sinosecu/**", "/esign-api/api/v1/sinosecu/**",
"**/api/v1/sinosecu/**", "**/api/v1/sinosecu/**",
"/api/v1/esign/ios-capture-submit", "/api/v1/checkin/ios-capture-submit",
"/api/v1/esign/ios-pending-tasks", "/api/v1/checkin/ios-pending-tasks",
"/api/v1/esign/ios-task-list", "/api/v1/checkin/ios-task-list",
"/api/v1/esign/ios-task-sign", "/api/v1/checkin/ios-task-sign",
"/api/v1/esign/ios-task/*", "/api/v1/checkin/ios-task/*",
"/api/v1/sinosecu/ios-capture-submit", "/api/v1/sinosecu/ios-capture-submit",
"/api/v1/sinosecu/ios-pending-tasks", "/api/v1/sinosecu/ios-pending-tasks",
"/api/v1/sinosecu/ios-task-list", "/api/v1/sinosecu/ios-task-list",
@@ -1,51 +0,0 @@
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;
}
}
@@ -1,73 +0,0 @@
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;
}
@@ -1,16 +0,0 @@
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);
}
@@ -1,240 +0,0 @@
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";
@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;
}
}
@@ -1,92 +0,0 @@
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)";
}
}
@@ -1,26 +0,0 @@
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/esign";
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);
}
}
@@ -1,17 +0,0 @@
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;
}
@@ -1,29 +0,0 @@
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;
}
}
@@ -1,38 +0,0 @@
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;
}
}
@@ -1,26 +0,0 @@
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;
}
@@ -1,23 +0,0 @@
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;
}
@@ -1,104 +0,0 @@
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");
}
}
@@ -1,70 +0,0 @@
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/esign/\"}");
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);
}
}
@@ -1,207 +0,0 @@
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("");
}
}
@@ -1,103 +0,0 @@
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");
}
}
@@ -1,24 +0,0 @@
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;
}
}
@@ -1,135 +0,0 @@
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;
}
}
@@ -1,188 +0,0 @@
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));
}
}
}
@@ -1,20 +0,0 @@
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;
}
@@ -1,48 +0,0 @@
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;
}
}
@@ -1,16 +0,0 @@
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;
}
@@ -1,20 +0,0 @@
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;
}
@@ -1,48 +0,0 @@
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;
}
}
@@ -1,50 +0,0 @@
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;
}
}
@@ -1,9 +0,0 @@
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();
}
}
@@ -1,32 +0,0 @@
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);
}
}
@@ -1,21 +0,0 @@
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;
}
@@ -1,5 +0,0 @@
package com.sjmresorts.opsapp.esign.reservation.dtos;
/** OHIP 按确认号查预订时的 reservationId + 可选 Guest Profile。 */
public record ReservationLookupHit(String reservationId, GuestProfileRef guestProfile) {
}
@@ -1,45 +0,0 @@
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;
}
}
@@ -1,44 +0,0 @@
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;
}
@@ -1,72 +0,0 @@
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;
}
}
@@ -1,45 +0,0 @@
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;
}
@@ -1,34 +0,0 @@
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;
}
@@ -1,24 +0,0 @@
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);
}
@@ -1,13 +0,0 @@
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();
}
@@ -1,105 +0,0 @@
package com.sjmresorts.opsapp.esign.reservation.services;
import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil;
import com.sjmresorts.opsapp.esign.reservation.models.EsignReservationTask;
import com.sjmresorts.opsapp.esign.reservation.repositories.EsignReservationTaskRepository;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Service
public class EsignReservationTaskService {
public static final String TASK_TYPE = "RESERVATION";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_COMPLETED = "COMPLETED";
private static final int TASK_WINDOW_HOURS = 24;
@Resource
private EsignReservationTaskRepository reservationTaskRepository;
private Instant taskWindowCutoff() {
return Instant.now().minus(TASK_WINDOW_HOURS, ChronoUnit.HOURS);
}
/**
* 预订通知触发时落库;同 reservationId+hotelId 若已有待处理则刷新 created_at。
*
* @return 任务 ID(供 FCM data 携带)
*/
@Transactional
public String createOrRefreshPendingTask(String reservationId, String hotelId, String title, String body) {
String rid = EsignReservationParamUtil.normalizeParam(reservationId);
String hid = EsignReservationParamUtil.normalizeParam(hotelId);
if (StringUtils.isAnyBlank(rid, hid)) {
return null;
}
Instant now = Instant.now();
Optional<EsignReservationTask> existing =
reservationTaskRepository.findFirstByReservationIdAndHotelIdAndStatusOrderByCreatedAtDesc(
rid, hid, STATUS_PENDING);
if (existing.isPresent()) {
EsignReservationTask t = existing.get();
t.setCreatedAt(now);
if (StringUtils.isNotBlank(title)) {
t.setTitle(trim(title, 256));
}
if (StringUtils.isNotBlank(body)) {
t.setBody(trim(body, 512));
}
reservationTaskRepository.save(t);
log.info("Reservation task refreshed taskId={}, reservationId={}, hotelId={}", t.getTaskId(), rid, hid);
return t.getTaskId();
}
EsignReservationTask task = new EsignReservationTask();
task.setTaskId(UUID.randomUUID().toString());
task.setReservationId(rid);
task.setHotelId(hid);
task.setStatus(STATUS_PENDING);
task.setTitle(trim(title, 256));
task.setBody(trim(body, 512));
task.setCreatedAt(now);
reservationTaskRepository.save(task);
log.info("Reservation task created taskId={}, reservationId={}, hotelId={}", task.getTaskId(), rid, hid);
return task.getTaskId();
}
private Optional<EsignReservationTask> resolveTask(String taskId, String reservationId, String hotelId) {
if (StringUtils.isNotBlank(taskId)) {
return reservationTaskRepository.findById(taskId.trim());
}
String rid = EsignReservationParamUtil.normalizeParam(reservationId);
String hid = EsignReservationParamUtil.normalizeParam(hotelId);
if (StringUtils.isAnyBlank(rid, hid)) {
return Optional.empty();
}
return reservationTaskRepository.findFirstByReservationIdAndHotelIdAndStatusOrderByCreatedAtDesc(
rid, hid, STATUS_PENDING);
}
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;
}
}
@@ -1,194 +0,0 @@
package com.sjmresorts.opsapp.esign.reservation.services;
import com.alibaba.fastjson2.JSON;
import com.sjmresorts.opsapp.esign.common.dtos.OhipProfileFieldCatalogResponse;
import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem;
import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipMappingResponse;
import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipMappingSaveRequest;
import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipPreviewRequest;
import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipPreviewResponse;
import com.sjmresorts.opsapp.esign.reservation.models.EsignSignFieldOhipConfig;
import com.sjmresorts.opsapp.esign.reservation.repositories.EsignSignFieldOhipConfigRepository;
import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil;
import com.sjmresorts.opsapp.esign.common.support.OhipProfileFieldCatalog;
import com.sjmresorts.opsapp.esign.reservation.support.ProfileDetailsJsonBuilder;
import com.sjmresorts.opsapp.esign.common.mappers.SignFieldOhipValueMapper;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class EsignSignFieldOhipMappingService {
public static final String DEFAULT_CONFIG_ID = "DEFAULT";
@Resource
private EsignSignFieldOhipConfigRepository configRepository;
public EsignSignFieldOhipMappingResponse getForAdmin() {
return buildOk(ensureDefaultRow());
}
public EsignSignFieldOhipMappingResponse getForAdmin(String hotelId) {
return buildOk(resolveConfig(hotelId));
}
public List<SignFieldOhipMappingItem> getMappingsForHotel(String hotelId) {
return parseMappings(resolveConfig(hotelId).getMappingsJson());
}
public Map<String, String> mapSignatureValues(
String hotelId, String signaturesJson, String signFieldsJson) {
List<SignFieldOhipMappingItem> mappings = getMappingsForHotel(hotelId);
return SignFieldOhipValueMapper.mapValues(signaturesJson, signFieldsJson, mappings);
}
public OhipProfileFieldCatalogResponse listProfileFields() {
return OhipProfileFieldCatalogResponse.ok(
OhipProfileFieldCatalog.groupOrder(),
OhipProfileFieldCatalog.all(),
OhipProfileFieldCatalog.commonFields());
}
/**
* 根据当前映射配置生成 PUT /crm/v1/profiles/{profileId} 请求体预览。
* 示例值优先取备注 label,其次 fieldName。
*/
public EsignSignFieldOhipPreviewResponse previewProfileBody(EsignSignFieldOhipPreviewRequest request) {
List<SignFieldOhipMappingItem> mappings = request != null && request.getMappings() != null
? SignFieldOhipValueMapper.normalizeMappings(request.getMappings())
: Collections.emptyList();
Map<String, String> sampleFields = new LinkedHashMap<>();
for (SignFieldOhipMappingItem item : mappings) {
if (item == null || Boolean.FALSE.equals(item.getEnabled())) {
continue;
}
String valueType = StringUtils.trimToEmpty(item.getValueType()).toLowerCase();
if ("signature".equals(valueType)) {
continue;
}
String path = OhipProfileFieldCatalog.resolvePath(item.getOhipFieldCode()).orElse(null);
if (path == null) {
continue;
}
String sample = StringUtils.firstNonBlank(
StringUtils.trimToNull(item.getLabel()),
StringUtils.trimToNull(item.getFieldName()),
"示例值");
sampleFields.put(path, sample);
}
String profileType = StringUtils.defaultIfBlank(
request != null ? StringUtils.trimToNull(request.getProfileType()) : null,
"Guest");
var body = ProfileDetailsJsonBuilder.buildBody(sampleFields, profileType);
String jsonPreview = JSON.toJSONString(body, com.alibaba.fastjson2.JSONWriter.Feature.PrettyFormat);
EsignSignFieldOhipPreviewResponse.PreviewData data =
new EsignSignFieldOhipPreviewResponse.PreviewData();
data.setProfileBody(JSON.parseObject(body.toJSONString(), Map.class));
data.setJsonPreview(jsonPreview);
data.setMappedFieldCount(sampleFields.size());
return EsignSignFieldOhipPreviewResponse.ok(data);
}
@Transactional
public EsignSignFieldOhipMappingResponse save(EsignSignFieldOhipMappingSaveRequest request) {
if (request == null) {
return EsignSignFieldOhipMappingResponse.fail("400", "request required");
}
String hotelId = normalizeHotelId(request.getHotelId());
List<SignFieldOhipMappingItem> mappings =
SignFieldOhipValueMapper.normalizeMappings(request.getMappings());
EsignSignFieldOhipConfig row = hotelId == null
? ensureDefaultRow()
: configRepository
.findFirstByHotelIdOrderByUpdatedAtDesc(hotelId)
.orElseGet(() -> {
EsignSignFieldOhipConfig created = new EsignSignFieldOhipConfig();
created.setConfigId(generateConfigId(hotelId));
created.setHotelId(hotelId);
return created;
});
row.setMappingsJson(JSON.toJSONString(mappings));
row.setUpdatedAt(Instant.now());
configRepository.save(row);
return buildOk(row);
}
private EsignSignFieldOhipConfig resolveConfig(String hotelId) {
String normalized = normalizeHotelId(hotelId);
if (normalized != null) {
EsignSignFieldOhipConfig hotelRow =
configRepository.findFirstByHotelIdOrderByUpdatedAtDesc(normalized).orElse(null);
if (hotelRow != null) {
return hotelRow;
}
}
return ensureDefaultRow();
}
private EsignSignFieldOhipConfig ensureDefaultRow() {
return configRepository
.findById(DEFAULT_CONFIG_ID)
.orElseGet(() -> {
EsignSignFieldOhipConfig row = new EsignSignFieldOhipConfig();
row.setConfigId(DEFAULT_CONFIG_ID);
row.setHotelId(null);
row.setMappingsJson("[]");
row.setUpdatedAt(Instant.now());
return configRepository.save(row);
});
}
private List<SignFieldOhipMappingItem> parseMappings(String json) {
if (StringUtils.isBlank(json)) {
return Collections.emptyList();
}
try {
List<SignFieldOhipMappingItem> list =
JSON.parseArray(json.trim(), SignFieldOhipMappingItem.class);
return SignFieldOhipValueMapper.normalizeMappings(list);
} catch (Exception e) {
return Collections.emptyList();
}
}
private EsignSignFieldOhipMappingResponse buildOk(EsignSignFieldOhipConfig row) {
EsignSignFieldOhipMappingResponse resp = EsignSignFieldOhipMappingResponse.ok("ok");
EsignSignFieldOhipMappingResponse.SignFieldOhipMappingData data =
new EsignSignFieldOhipMappingResponse.SignFieldOhipMappingData();
EsignSignFieldOhipMappingResponse.SignFieldOhipMappingConfigItem item =
new EsignSignFieldOhipMappingResponse.SignFieldOhipMappingConfigItem();
item.setConfigId(row.getConfigId());
item.setHotelId(row.getHotelId());
item.setMappings(parseMappings(row.getMappingsJson()));
item.setUpdatedAt(row.getUpdatedAt() != null ? row.getUpdatedAt().toString() : null);
data.setConfig(item);
resp.setData(data);
return resp;
}
private static String normalizeHotelId(String hotelId) {
if (StringUtils.isBlank(hotelId)) {
return null;
}
try {
return EsignReservationParamUtil.normalizeParam(hotelId);
} catch (Exception e) {
return StringUtils.trimToNull(hotelId);
}
}
private static String generateConfigId(String hotelId) {
return "HOTEL_" + StringUtils.defaultString(hotelId).replaceAll("[^A-Za-z0-9]", "_");
}
}
@@ -1,733 +0,0 @@
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.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.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.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 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 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);
}
}
}
@@ -1,91 +0,0 @@
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) + "...";
}
}
@@ -1,240 +0,0 @@
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)";
}
}
@@ -1,797 +0,0 @@
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/esign/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/esign/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/esign/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/esign/app/opera-parent-close-listener.user.js");
String installPage = escapeHtml(base + "/api/v1/esign/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("\"", "\\\"");
}
}
@@ -1,353 +0,0 @@
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));
}
}
}
@@ -1,94 +0,0 @@
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", "");
}
}
@@ -1,138 +0,0 @@
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"));
}
}
@@ -1,103 +0,0 @@
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;
}
}
+5
View File
@@ -140,6 +140,11 @@ ops-app:
reservation-lookup: reservation-lookup:
path-template: /rsv/v1/hotels/{hotelId}/reservations path-template: /rsv/v1/hotels/{hotelId}/reservations
fetch-instructions: Reservation fetch-instructions: Reservation
booking-check-in:
auto-room-assignment:
path-template: /fof/v1/hotels/{hotelId}/reservations/autoRoomAssignments
check-in:
path-template: /fof/v1/hotels/{hotelId}/reservations/{reservationId}/checkIns
file-attachment: file-attachment:
path: /med/config/v1/fileAttachments path: /med/config/v1/fileAttachments
user-name: USER@HOTEL user-name: USER@HOTEL
+12 -7
View File
@@ -69,7 +69,7 @@ ops-app:
close-popup: true close-popup: true
close-modal-message: true close-modal-message: true
flex-link-origin: http://127.0.0.1:8066 flex-link-origin: http://127.0.0.1:8066
parent-origin: https://mtcs2ua.hospitality-api.ap-singapore-1.ocs.oc-test.com parent-origin: https://mucu1ua.hospitality-api.us-ashburn-1.ocs.oc-test.com
open-delay-ms: 1200 open-delay-ms: 1200
# popup 关窗不再等待 open-delay;仅 new-window 标签关窗使用。越小关 Pop-up 越快(建议 50~120) # popup 关窗不再等待 open-delay;仅 new-window 标签关窗使用。越小关 Pop-up 越快(建议 50~120)
close-delay-ms: 50 close-delay-ms: 50
@@ -124,7 +124,7 @@ ops-app:
action: OPEN_RESERVATION action: OPEN_RESERVATION
third-party: third-party:
# Oracle Hospitality (OHIP) Gateway # Oracle Hospitality (OHIP) Gateway
base-url: https://mtcs2ua.hospitality-api.ap-singapore-1.ocs.oc-test.com base-url: https://mucu1ua.hospitality-api.us-ashburn-1.ocs.oc-test.com
timeout-ms: 30000 timeout-ms: 30000
registration-card: registration-card:
regenerate: false regenerate: false
@@ -137,6 +137,11 @@ ops-app:
reservation-lookup: reservation-lookup:
path-template: /rsv/v1/hotels/{hotelId}/reservations path-template: /rsv/v1/hotels/{hotelId}/reservations
fetch-instructions: Reservation fetch-instructions: Reservation
booking-check-in:
auto-room-assignment:
path-template: /fof/v1/hotels/{hotelId}/reservations/autoRoomAssignments
check-in:
path-template: /fof/v1/hotels/{hotelId}/reservations/{reservationId}/checkIns
file-attachment: file-attachment:
path: /med/config/v1/fileAttachments path: /med/config/v1/fileAttachments
user-name: USER@HOTEL user-name: USER@HOTEL
@@ -151,12 +156,12 @@ ops-app:
enabled: true enabled: true
token-path: /oauth/v1/tokens token-path: /oauth/v1/tokens
# Postman Authorization → Basic Auth:用户名=CLIENT_ID,密码=CLIENT_SECRET # Postman Authorization → Basic Auth:用户名=CLIENT_ID,密码=CLIENT_SECRET
client-id: RPHALVA-UAT-SHKPRH-mtcs2ua-willtech_test client-id: WTMLQA-UAT-WTML-mucu1ua-OCR_Scanner
client-secret: idcscs-138f2ac5-563c-47fc-ab8f-5ff8b7177af4 client-secret: e85ac385-fe94-4189-84e6-ee3568ac2be9
# Postman Header {{AppKey}}(非 HashedAppKey) # Postman Header {{AppKey}}(非 HashedAppKey)
app-key: a895c827-603e-4cf8-8aaf-7631dbd0d309 app-key: 2dddec89-6e37-403f-8a5b-7aa1d3fd46bf
enterprise-id: SHKPRH enterprise-id: WTML
hotel-id: RPH hotel-id: WTQA
scope: urn:opc:hgbu:ws:__myscopes__ scope: urn:opc:hgbu:ws:__myscopes__
grant-type: client_credentials grant-type: client_credentials
timeout-ms: 30000 timeout-ms: 30000