feat:Add check in API
This commit is contained in:
@@ -69,20 +69,20 @@ public class WebFilterConfig {
|
||||
"/api/v1/logging/auditLog/**",
|
||||
"/api/v1/logging/apiLog/**",
|
||||
"/api/v1/logs/**",
|
||||
"/api/v1/esign/capture-logs/**",
|
||||
"/api/v1/esign/auth/**",
|
||||
"/api/v1/esign/**",
|
||||
"/esign-api/api/v1/esign/**",
|
||||
"**/api/v1/esign/**",
|
||||
"/api/v1/checkin/capture-logs/**",
|
||||
"/api/v1/checkin/auth/**",
|
||||
"/api/v1/checkin/**",
|
||||
"/esign-api/api/v1/checkin/**",
|
||||
"**/api/v1/checkin/**",
|
||||
"/api/v1/sinosecu/auth/**",
|
||||
"/api/v1/sinosecu/**",
|
||||
"/esign-api/api/v1/sinosecu/**",
|
||||
"**/api/v1/sinosecu/**",
|
||||
"/api/v1/esign/ios-capture-submit",
|
||||
"/api/v1/esign/ios-pending-tasks",
|
||||
"/api/v1/esign/ios-task-list",
|
||||
"/api/v1/esign/ios-task-sign",
|
||||
"/api/v1/esign/ios-task/*",
|
||||
"/api/v1/checkin/ios-capture-submit",
|
||||
"/api/v1/checkin/ios-pending-tasks",
|
||||
"/api/v1/checkin/ios-task-list",
|
||||
"/api/v1/checkin/ios-task-sign",
|
||||
"/api/v1/checkin/ios-task/*",
|
||||
"/api/v1/sinosecu/ios-capture-submit",
|
||||
"/api/v1/sinosecu/ios-pending-tasks",
|
||||
"/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;
|
||||
}
|
||||
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
-92
@@ -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;
|
||||
}
|
||||
}
|
||||
-38
@@ -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");
|
||||
}
|
||||
}
|
||||
-70
@@ -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);
|
||||
}
|
||||
}
|
||||
-207
@@ -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");
|
||||
}
|
||||
}
|
||||
-24
@@ -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;
|
||||
}
|
||||
}
|
||||
-135
@@ -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;
|
||||
}
|
||||
}
|
||||
-188
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
-20
@@ -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;
|
||||
}
|
||||
-48
@@ -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;
|
||||
}
|
||||
}
|
||||
-16
@@ -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;
|
||||
}
|
||||
-20
@@ -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;
|
||||
}
|
||||
-48
@@ -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;
|
||||
}
|
||||
}
|
||||
-50
@@ -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();
|
||||
}
|
||||
}
|
||||
-32
@@ -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) {
|
||||
}
|
||||
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
-44
@@ -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;
|
||||
}
|
||||
-72
@@ -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;
|
||||
}
|
||||
}
|
||||
-45
@@ -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;
|
||||
}
|
||||
-34
@@ -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;
|
||||
}
|
||||
-24
@@ -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);
|
||||
}
|
||||
-13
@@ -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();
|
||||
}
|
||||
-105
@@ -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;
|
||||
}
|
||||
}
|
||||
-194
@@ -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]", "_");
|
||||
}
|
||||
}
|
||||
-733
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-91
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
-240
@@ -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)";
|
||||
}
|
||||
}
|
||||
-797
@@ -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=>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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
|
||||
private static String escapeJsString(String value) {
|
||||
return String.valueOf(value)
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
-353
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
-94
@@ -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", "");
|
||||
}
|
||||
}
|
||||
-138
@@ -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"));
|
||||
}
|
||||
}
|
||||
-103
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,11 @@ ops-app:
|
||||
reservation-lookup:
|
||||
path-template: /rsv/v1/hotels/{hotelId}/reservations
|
||||
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:
|
||||
path: /med/config/v1/fileAttachments
|
||||
user-name: USER@HOTEL
|
||||
|
||||
@@ -69,7 +69,7 @@ ops-app:
|
||||
close-popup: true
|
||||
close-modal-message: true
|
||||
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
|
||||
# popup 关窗不再等待 open-delay;仅 new-window 标签关窗使用。越小关 Pop-up 越快(建议 50~120)
|
||||
close-delay-ms: 50
|
||||
@@ -124,7 +124,7 @@ ops-app:
|
||||
action: OPEN_RESERVATION
|
||||
third-party:
|
||||
# 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
|
||||
registration-card:
|
||||
regenerate: false
|
||||
@@ -137,6 +137,11 @@ ops-app:
|
||||
reservation-lookup:
|
||||
path-template: /rsv/v1/hotels/{hotelId}/reservations
|
||||
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:
|
||||
path: /med/config/v1/fileAttachments
|
||||
user-name: USER@HOTEL
|
||||
@@ -151,12 +156,12 @@ ops-app:
|
||||
enabled: true
|
||||
token-path: /oauth/v1/tokens
|
||||
# Postman Authorization → Basic Auth:用户名=CLIENT_ID,密码=CLIENT_SECRET
|
||||
client-id: RPHALVA-UAT-SHKPRH-mtcs2ua-willtech_test
|
||||
client-secret: idcscs-138f2ac5-563c-47fc-ab8f-5ff8b7177af4
|
||||
client-id: WTMLQA-UAT-WTML-mucu1ua-OCR_Scanner
|
||||
client-secret: e85ac385-fe94-4189-84e6-ee3568ac2be9
|
||||
# Postman Header {{AppKey}}(非 HashedAppKey)
|
||||
app-key: a895c827-603e-4cf8-8aaf-7631dbd0d309
|
||||
enterprise-id: SHKPRH
|
||||
hotel-id: RPH
|
||||
app-key: 2dddec89-6e37-403f-8a5b-7aa1d3fd46bf
|
||||
enterprise-id: WTML
|
||||
hotel-id: WTQA
|
||||
scope: urn:opc:hgbu:ws:__myscopes__
|
||||
grant-type: client_credentials
|
||||
timeout-ms: 30000
|
||||
|
||||
Reference in New Issue
Block a user