caotao 2 недель назад
Родитель
Сommit
8c3867dee4
20 измененных файлов с 2658 добавлено и 24 удалено
  1. 18 0
      ship-module-product/ship-module-product-api/pom.xml
  2. 398 0
      ship-module-product/ship-module-product-api/src/main/java/com/yc/ship/module/product/api/AgentOrderApi.java
  3. 446 0
      ship-module-product/ship-module-product-api/src/main/java/com/yc/ship/module/product/api/ShipPlatformApi.java
  4. 279 0
      ship-module-product/ship-module-product-api/src/main/java/com/yc/ship/module/product/api/dto/TradeVisitorDTO.java
  5. 9 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/controller/admin/voyage/VoyageController.java
  6. 22 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/dataobject/voyage/OrderVisitorDo.java
  7. 16 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/dataobject/voyage/TicketInfoDo.java
  8. 9 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/dataobject/voyage/VoyageDO.java
  9. 17 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/mysql/voyage/VoyageMapper.java
  10. 55 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/framework/config/ShipPlatformProperties.java
  11. 16 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageAsyncService.java
  12. 68 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageAsyncServiceImpl.java
  13. 2 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageService.java
  14. 716 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageServiceImpl.java
  15. 363 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/utils/SM2Utils.java
  16. 49 0
      ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/utils/TicketTypeUtil.java
  17. 159 18
      ship-module-product/ship-module-product-biz/src/main/resources/mapper/voyage/VoyageMapper.xml
  18. 4 1
      ship-module-resource/ship-module-resource-biz/src/main/java/com/yc/ship/module/resource/dal/dataobject/dock/ResourceDockDO.java
  19. 12 1
      ship-module-resource/ship-module-resource-biz/src/main/java/com/yc/ship/module/resource/dal/dataobject/route/ResourceRouteDO.java
  20. 0 4
      ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/order/impl/TradeOrderServiceImpl.java

+ 18 - 0
ship-module-product/ship-module-product-api/pom.xml

@@ -38,6 +38,24 @@
             <version>2.2.20</version>
             <scope>compile</scope>
         </dependency>
+
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-annotation</artifactId>
+            <version>3.5.10.1</version>
+            <scope>compile</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>com.yc.ship</groupId>
+            <artifactId>module-infra-biz</artifactId>
+            <version>${revision}</version>
+        </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>4.12.0</version>
+        </dependency>
     </dependencies>
 
 </project>

+ 398 - 0
ship-module-product/ship-module-product-api/src/main/java/com/yc/ship/module/product/api/AgentOrderApi.java

@@ -0,0 +1,398 @@
+package com.yc.ship.module.product.api;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.yc.ship.module.infra.dal.dataobject.logger.ExternalApiCallLogDO;
+import com.yc.ship.module.infra.dal.mysql.logger.ExternalApiCallLogMapper;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 代理商订单接口调用客户端
+ * 基于文档:接口文档_代理商订单接口-20250324.doc
+ */
+@Slf4j
+@Component
+public class AgentOrderApi {
+
+    @Value("${agent.platform.request-url}")
+    private String requestUrl;
+
+    private String baseUrl;
+//    private HttpClient httpClient;
+
+    @Resource
+    private ExternalApiCallLogMapper externalApiCallLogMapper;
+
+    @PostConstruct
+    public void init() {
+        this.baseUrl = requestUrl;
+
+    }
+
+    /**
+     * 2.1 登录接口
+     *
+     * @param username 用户名
+     * @param password 密码
+     * @return 登录成功返回 token,失败抛异常
+     * @throws Exception 网络或解析异常
+     */
+    public String login(String username, String password) throws Exception {
+        String url = baseUrl + "/agt/login/loginNoAuthCode.do";
+        JSONObject body = new JSONObject();
+        body.put("username", username);
+        body.put("password", password);
+
+        String response = doPost(url, body.toJSONString(), null);
+        JSONObject respJson = JSON.parseObject(response);
+
+        Boolean success = respJson.getBoolean("success");
+        if (success != null && success) {
+            return respJson.getString("data");
+        }
+        String message = respJson.getString("message");
+        throw new RuntimeException("登录失败:" + (message != null ? message : response));
+    }
+
+    /**
+     * 登录(带日志记录)
+     * @param taskId   关联的任务ID,可为null(不记录日志)
+     * @param username 用户名
+     * @param password 密码
+     * @return token
+     */
+    public String login(Long taskId, String username, String password) throws Exception {
+        String url = baseUrl + "/agt/login/loginNoAuthCode.do";
+        JSONObject body = new JSONObject();
+        body.put("username", username);
+        body.put("password", password);
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        String token = null;
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, body.toJSONString(), null);
+            JSONObject respJson = JSON.parseObject(responseBody);
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag != null && successFlag) {
+                token = respJson.getString("data");
+                success = true;
+                return token;
+            } else {
+                errorMsg = respJson.getString("message");
+                throw new RuntimeException("登录失败:" + (errorMsg != null ? errorMsg : responseBody));
+            }
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "AGENT_LOGIN", url,
+                        "{\"username\":\"***\"}",
+                        success ? "token:" + (token != null ? token.substring(0, 8) + "***" : "") : responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+
+
+    /**
+     *
+     */
+    private String doPost(String url, String requestBody, String token) throws IOException {
+
+        OkHttpClient client = new OkHttpClient.Builder()
+                .connectTimeout(30, TimeUnit.SECONDS)
+                .readTimeout(30, TimeUnit.SECONDS)
+                .writeTimeout(30, TimeUnit.SECONDS)
+                .build();
+
+        MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+        RequestBody body = RequestBody.create(requestBody, JSON);
+        Request.Builder requestBuilder = new Request.Builder()
+                .url(url)
+                .post(body)
+                .header("Content-Type", "application/json; charset=utf-8");
+        if (token != null && !token.trim().isEmpty()) {
+            requestBuilder.header("Authorization", token);
+        }
+        Request request = requestBuilder.build();
+
+        try (Response response = client.newCall(request).execute()) {
+            if (!response.isSuccessful()) {
+                String responseBody = response.body() != null ? response.body().string() : "";
+                throw new RuntimeException("HTTP请求失败,状态码:" + response.code() + ",响应:" + responseBody);
+            }
+            return response.body() != null ? response.body().string() : "";
+        }
+
+    }
+
+    /**
+     * 2.2 预定订单
+     *
+     * @param orderData 订单数据
+     * @param token     授权令牌
+     * @return data 对象
+     * @throws Exception 异常
+     */
+    public JSONObject bookingOrder(JSONObject orderData, String token) throws Exception {
+        String url = baseUrl + "/agt/order/external/booking.do";
+        String response = doPost(url, orderData.toJSONString(), token);
+        JSONObject respJson = JSON.parseObject(response);
+
+        Boolean success = respJson.getBoolean("success");
+        System.out.println("success"+success);
+        if (success != null && success) {
+            return respJson.getJSONObject("data");
+        }
+        String message = respJson.getString("message");
+        throw new RuntimeException("预定订单失败:" + (message != null ? message : response));
+    }
+
+
+    public JSONObject bookingOrder(Long taskId, JSONObject orderData, String token) throws Exception {
+        String url = baseUrl + "/agt/order/external/booking.do";
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        JSONObject result = null;
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+        String requestParams = orderData.toJSONString();
+
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag != null && successFlag) {
+                result = respJson.getJSONObject("data");
+                success = true;
+                return result;
+            } else {
+                errorMsg = respJson.getString("message");
+                throw new RuntimeException("预定订单失败:" + (errorMsg != null ? errorMsg : responseBody));
+            }
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "AGENT_BOOKING-预定订单", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+
+    }
+    /**
+     * 申请普通退票(带日志记录)
+     *
+     * @param taskId    关联的任务ID,可为null(不记录日志)
+     * @param orderId   订单ID
+     * @param ticketIds 需要退票的票ID数组
+     * @param token     授权令牌
+     * @return true=成功
+     * @throws Exception 异常
+     */
+    public boolean applyNormalRefund(Long taskId, String orderId, List<String> ticketIds, String token) throws Exception {
+        String url = baseUrl + "/agt/normalRefund/applyNormalRefund.do";
+        JSONObject body = new JSONObject();
+        body.put("orderId", orderId);
+        body.put("ticketIds", ticketIds);
+        String requestParams = body.toJSONString();
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+            Boolean successFlag = respJson.getBoolean("success");
+            String message = respJson.getString("message");
+
+            if (successFlag == null) {
+                throw new RuntimeException("退票响应格式异常:" + responseBody);
+            }
+            if (!successFlag) {
+                throw new RuntimeException("退票失败: " + (message != null ? message : responseBody));
+            }
+            success = true;
+            return true;
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "AGENT_NORMAL_REFUND", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+
+    /**
+     * 修改乘客信息(带日志记录)
+     * @param taskId     关联的任务ID,可为null(不记录日志)
+     * @param modifyData 修改参数
+     * @param token      授权令牌
+     * @return true=成功
+     * @throws Exception 异常
+     */
+    public boolean applyChangePassenger(Long taskId, JSONObject modifyData, String token) throws Exception {
+        String url = baseUrl + "/agt/order/applyChangePassenger.do";
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+        String requestParams = modifyData.toJSONString();
+        String passengerName = modifyData.getString("name");
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag == null) {
+                throw new RuntimeException("修改乘客信息响应格式异常:" + responseBody);
+            }
+            if (!successFlag) {
+                String message = respJson.getString("message");
+                throw new RuntimeException("修改乘客信息失败:" + (message != null ? message : responseBody));
+            }
+            success = true;
+            return true;
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "AGENT_CHANGE_PASSENGER-修改乘客[" + passengerName + "]", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+
+
+    private void saveCallLog(Long taskId, String callType, String url,
+                             String requestParams, String responseBody,
+                             boolean success, String errorMsg,
+                             LocalDateTime callTime, int costMillis) {
+        try {
+            ExternalApiCallLogDO logDO = new ExternalApiCallLogDO();
+            logDO.setTaskId(taskId);
+            logDO.setCallType(callType);
+            logDO.setRequestUrl(url);
+            logDO.setRequestParams(requestParams);
+            logDO.setResponseBody(responseBody);
+            logDO.setSuccessFlag(success);
+            logDO.setErrorMessage(errorMsg);
+            logDO.setCallTime(callTime);
+            logDO.setCostMillis(costMillis);
+            logDO.setCreateTime(LocalDateTime.now());
+            externalApiCallLogMapper.insert(logDO);
+        } catch (Exception e) {
+            log.error("保存外部接口调用日志失败", e);
+        }
+    }
+
+    public static void main(String[] args) {
+        AgentOrderApi agentOrderApi = new AgentOrderApi();
+        agentOrderApi.baseUrl = "https://sxlwsp.3xylly.com/api";
+
+        try {
+            String username = "hjscjxzy"; // 替换为实际用户名
+            String password = "5253ebe844c5a19b518af2d185ab1a71466e4e433741813536d308b6ea39b7ba"; // 替换为实际密码
+
+            System.out.println("开始登录...");
+            String token = agentOrderApi.login(username, password);
+            System.out.println("登录成功!Token: " + token);
+
+            // 3. 构建预定订单数据
+            JSONObject orderData = buildDemoOrder();
+            System.out.println("订单请求数据: " + orderData.toJSONString());
+//             4. 调用预定接口
+            JSONObject orderResult = agentOrderApi.bookingOrder(orderData,token);
+            System.out.println("预定成功,返回数据: " + orderResult.toJSONString());
+
+        } catch (Exception e) {
+            System.err.println("登录失败:" + e.getMessage());
+            e.printStackTrace();
+        }
+    }
+    /**
+     * 构建预定订单数据
+     */
+    private static JSONObject buildDemoOrder() {
+        JSONObject order = new JSONObject();
+
+        // 1. 联系人
+        JSONObject contactMan = new JSONObject();
+        contactMan.put("name", "张三323");
+        contactMan.put("phone", "13800138000");
+        order.put("contactMan", contactMan);
+
+        // 2. 航次信息
+        JSONArray cruisePlans = new JSONArray();
+        JSONObject cruisePlan = new JSONObject();
+        cruisePlan.put("cruisePlanId", "308880573728817152");
+
+        // 3. planstocks 数组
+        JSONArray planstocks = new JSONArray();
+        JSONObject stock = new JSONObject();
+        stock.put("roomTypeName", "test");
+        stock.put("ticketName", "成人票");
+
+        // 4. 乘客数组
+        JSONArray passengers = new JSONArray();
+        JSONObject passenger = new JSONObject();
+        passenger.put("no", 1);
+        passenger.put("name", "张三22333");
+        passenger.put("certNo","540101196112229198");
+        passenger.put("countryId", "CHN");
+        passenger.put("certType", "外国人永久居留身份证");
+        passenger.put("passengerType", "tourist");
+        passenger.put("phone", "13800138000");
+        passenger.put("age", 30);
+        passenger.put("isTransfer", false);
+        passenger.put("gobackFlag", false);
+        passenger.put("sex", "male");
+        passenger.put("category", "adult");
+        passenger.put("groupSortNo", 1);
+        passengers.add(passenger);
+
+        // 将乘客数组放入 stock
+        stock.put("passengers", passengers);
+        stock.put("num", passengers.size());
+
+        planstocks.add(stock);
+        cruisePlan.put("planstocks", planstocks);
+        cruisePlans.add(cruisePlan);
+        order.put("cruisePlans", cruisePlans);
+
+        // 5. 三方订单号
+        order.put("externalOrderId", "EXT" + System.currentTimeMillis());
+
+        return order;
+    }
+
+}

+ 446 - 0
ship-module-product/ship-module-product-api/src/main/java/com/yc/ship/module/product/api/ShipPlatformApi.java

@@ -0,0 +1,446 @@
+package com.yc.ship.module.product.api;
+
+import com.alibaba.excel.util.StringUtils;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.yc.ship.framework.common.util.http.HttpUtils;
+import com.yc.ship.module.infra.dal.dataobject.logger.ExternalApiCallLogDO;
+import com.yc.ship.module.infra.dal.mysql.logger.ExternalApiCallLogMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 用于测试 ‘船企平台航次管理接口文档-20240102’ 的接口
+ *
+ */
+
+@Slf4j
+@Component
+public class ShipPlatformApi {
+    @Value("${ship.platform.request-url}")
+    private String requestUrl;
+
+    private  String baseUrl;
+    private String authToken;
+    @Resource
+    private ExternalApiCallLogMapper externalApiCallLogMapper;
+
+
+    @PostConstruct
+    public void  init() {
+        this.baseUrl = requestUrl;
+    }
+
+    /**
+     * 登录接口
+     *
+     * @param username 用户名
+     * @param password 密码
+     * @return true=登录成功,false=失败
+     * @throws Exception 网络或解析异常
+     */
+    public String login(String username, String password) throws Exception {
+        String url = baseUrl + "/spl/login/loginNoAuthCode.do";
+        System.out.println(url);
+        JSONObject body = new JSONObject();
+        body.put("username", username);
+        body.put("password", password);
+
+        String response = doPost(url, body.toJSONString(), null);
+        JSONObject respJson = JSON.parseObject(response);
+
+        Boolean success = respJson.getBoolean("success");
+        if (success != null && success) {
+            return respJson.getString("data");
+        }
+        String message = respJson.getString("message");
+        throw new RuntimeException("登录失败:" + (message != null ? message : response));
+    }
+    // 带日志版本
+    public String login(Long taskId, String username, String password) throws Exception {
+        String url = baseUrl + "/spl/login/loginNoAuthCode.do";
+        JSONObject body = new JSONObject();
+        body.put("username", username);
+        body.put("password", password);
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        String token = null;
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, body.toJSONString(), null);
+            JSONObject respJson = JSON.parseObject(responseBody);
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag != null && successFlag) {
+                token = respJson.getString("data");
+                success = true;
+                return token;
+            } else {
+                errorMsg = respJson.getString("message");
+                throw new RuntimeException("登录失败:" + (errorMsg != null ? errorMsg : responseBody));
+            }
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "SHIP_LOGIN", url,
+                        "{\"username\":\"***\"}",
+                        success ? "token:" + (token != null ? token.substring(0, 8) + "***" : "") : responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+    /**
+     * 执行POST请求
+     *
+     * @param url         完整URL
+     * @param requestBody JSON请求体
+     * @param token       authorization header值(可为null)
+     * @return 响应字符串
+     */
+    private String doPost(String url, String requestBody, String token) {
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Content-Type", "application/json; charset=utf-8");
+
+        if (StringUtils.isNotBlank(token)) {
+            headers.put("Authorization", token);
+        }
+        return HttpUtils.post(url, headers, requestBody);
+    }
+
+    /**
+     * 航次添加(单个)
+     *
+     * @param productId  产品ID
+     * @param voyagePlan 航次数据
+     * @return 返回发布成功的航次ID
+     * @throws Exception 异常
+     */
+    public String addVoyage(String productId, JSONObject voyagePlan,String token) throws Exception {
+        String url = baseUrl + "/spl/voyage/external/addVoyagePlan.do";
+
+        JSONObject requestBody = new JSONObject();
+
+        requestBody.put("productId", productId);
+        // voyagePlanAddVos 是一个数组,这里包装成单元素数组
+        JSONArray voyageArray = new JSONArray();
+        voyageArray.add(voyagePlan);
+        requestBody.put("voyagePlanAddVos", voyageArray);
+        String response = doPost(url, requestBody.toJSONString(), token);
+        JSONObject respJson = JSON.parseObject(response);
+        Boolean success = respJson.getBoolean("success");
+        System.out.println("添加航次结果: " + response);
+        if (success != null && success) {
+            Object data = respJson.get("data");
+            return data != null ? data.toString() : null;
+        }
+        String message = respJson.getString("message");
+        throw new RuntimeException("添加航次失败:" + (message != null ? message : response));
+    }
+
+
+
+    // 带日志版本
+    public String addVoyage(Long taskId, String productId, JSONObject voyagePlan, String token) throws Exception {
+        String url = baseUrl + "/spl/voyage/external/addVoyagePlan.do";
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("productId", productId);
+        JSONArray voyageArray = new JSONArray();
+        voyageArray.add(voyagePlan);
+        requestBody.put("voyagePlanAddVos", voyageArray);
+        String requestParams = requestBody.toJSONString();
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        String planId = null;
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag != null && successFlag) {
+                Object data = respJson.get("data");
+                planId = data != null ? data.toString() : null;
+                success = true;
+                return planId;
+            } else {
+                errorMsg = respJson.getString("message");
+                throw new RuntimeException("添加航次失败:" + (errorMsg != null ? errorMsg : responseBody));
+            }
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "SHIP_ADD_VOYAGE-添加航次", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+    /**
+     * 航次修改(带日志版本)
+     *
+     * @param taskId     任务ID(用于关联日志)
+     * @param modifyData 修改参数
+     * @param token      授权token
+     * @return true=成功
+     * @throws Exception 异常
+     */
+    public boolean modifyVoyage(Long taskId, JSONObject modifyData, String token) throws Exception {
+        String url = baseUrl + "/spl/voyage/external/modifyVoyagePlan.do";
+        String requestParams = modifyData.toJSONString();
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag == null) {
+                throw new RuntimeException("修改航次响应格式异常:" + responseBody);
+            }
+            if (!successFlag) {
+                String message = respJson.getString("message");
+                throw new RuntimeException("修改航次失败:" + (message != null ? message : responseBody));
+            }
+            success = true;
+            return true;
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "SHIP_MODIFY_VOYAGE-修改航次", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+
+    /**
+     * 航次删除(批量)- 带日志版本
+     *
+     * @param taskId  任务ID(用于关联日志)
+     * @param planIds 航次ID列表
+     * @param token   authorization header值
+     * @return true=成功
+     * @throws Exception 异常
+     */
+    public boolean deleteVoyages(Long taskId, List<String> planIds, String token) throws Exception {
+        String url = baseUrl + "/spl/voyage/batchDeletePlan.do";
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("planId", planIds);
+        String requestParams = requestBody.toJSONString();
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag == null) {
+                throw new RuntimeException("删除航次响应格式异常:" + responseBody);
+            }
+
+            if (!successFlag) {
+                String message = respJson.getString("message");
+                throw new RuntimeException("删除航次失败:" + (message != null ? message : responseBody));
+            }
+
+            success = true;
+            return true;
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "SHIP_DELETE_VOYAGE", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+
+    /**
+     * 航次批量添加
+     * @param productId 产品ID
+     * @param voyagePlans 航次数据列表
+     * @return 批量添加结果列表
+     * @throws Exception 异常
+     */
+    public List<JSONObject> batchAddVoyages(String productId, List<JSONObject> voyagePlans) throws Exception {
+        String url = baseUrl + "/spl/voyage/external/batchAddVoyagePlan.do";
+
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("productId", productId);
+        requestBody.put("voyagePlanAddVos", voyagePlans);
+
+        String response = doPost(url, requestBody.toJSONString(), authToken);
+        JSONObject respJson = JSON.parseObject(response);
+
+        Boolean success = respJson.getBoolean("success");
+        if (success != null && success) {
+            Object data = respJson.get("data");
+            if (data instanceof JSONArray) {
+                return ((JSONArray) data).toJavaList(JSONObject.class);
+            } else {
+                return Collections.emptyList();
+            }
+        }
+        String message = respJson.getString("message");
+        throw new RuntimeException("批量添加航次失败:" + (message != null ? message : response));
+    }
+
+    /**
+     * 批量设置航次禁售(带日志版本)
+     *
+     * @param taskId  任务ID(用于关联日志)
+     * @param planIds 航次ID列表
+     * @param token   authorization header值
+     * @return true=成功
+     * @throws Exception 异常
+     */
+    public boolean batchDisablePlan(Long taskId, List<String> planIds, String token) throws Exception {
+        String url = baseUrl + "/spl/voyage/batchDisablePlan.do";
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("planId", planIds);
+        String requestParams = requestBody.toJSONString();
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag == null) {
+                throw new RuntimeException("批量设置航次禁售响应格式异常:" + responseBody);
+            }
+
+            if (!successFlag) {
+                String message = respJson.getString("message");
+                throw new RuntimeException("批量设置航次禁售失败:" + (message != null ? message : responseBody));
+            }
+
+            success = true;
+            return true;
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "SHIP_BATCH_DISABLE_PLAN-航次禁售", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+
+
+
+
+
+    /**
+     * 批量设置航次可售(带日志版本)
+     *
+     * @param taskId  任务ID(用于关联日志)
+     * @param planIds 航次ID列表
+     * @param token   authorization header值
+     * @return true=成功
+     * @throws Exception 异常
+     */
+    public boolean batchEnablePlan(Long taskId, List<String> planIds, String token) throws Exception {
+        String url = baseUrl + "/spl/voyage/batchEnablePlan.do";
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("planId", planIds);
+        String requestParams = requestBody.toJSONString();
+
+        LocalDateTime callTime = LocalDateTime.now();
+        long start = System.currentTimeMillis();
+        boolean success = false;
+        String errorMsg = null;
+        String responseBody = null;
+
+        try {
+            responseBody = doPost(url, requestParams, token);
+            JSONObject respJson = JSON.parseObject(responseBody);
+
+            Boolean successFlag = respJson.getBoolean("success");
+            if (successFlag == null) {
+                throw new RuntimeException("批量设置航次可售响应格式异常:" + responseBody);
+            }
+
+            if (!successFlag) {
+                String message = respJson.getString("message");
+                throw new RuntimeException("批量设置航次可售失败:" + (message != null ? message : responseBody));
+            }
+
+            success = true;
+            return true;
+        } catch (Exception e) {
+            errorMsg = e.getMessage();
+            throw e;
+        } finally {
+            if (taskId != null) {
+                int cost = (int) (System.currentTimeMillis() - start);
+                saveCallLog(taskId, "SHIP_BATCH_ENABLE_PLAN-航次可售", url, requestParams, responseBody,
+                        success, errorMsg, callTime, cost);
+            }
+        }
+    }
+    private void saveCallLog(Long taskId, String callType, String url,
+                             String requestParams, String responseBody,
+                             boolean success, String errorMsg,
+                             LocalDateTime callTime, int costMillis) {
+        try {
+            ExternalApiCallLogDO logDO = new ExternalApiCallLogDO();
+            logDO.setTaskId(taskId);
+            logDO.setCallType(callType);
+            logDO.setRequestUrl(url);
+            logDO.setRequestParams(requestParams);
+            logDO.setResponseBody(responseBody);
+            logDO.setSuccessFlag(success);
+            logDO.setErrorMessage(errorMsg);
+            logDO.setCallTime(callTime);
+            logDO.setCostMillis(costMillis);
+            logDO.setCreateTime(LocalDateTime.now());
+            externalApiCallLogMapper.insert(logDO);
+        } catch (Exception e) {
+            log.error("保存外部接口调用日志失败", e);
+        }
+    }
+
+}

+ 279 - 0
ship-module-product/ship-module-product-api/src/main/java/com/yc/ship/module/product/api/dto/TradeVisitorDTO.java

@@ -0,0 +1,279 @@
+package com.yc.ship.module.product.api.dto;
+
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.yc.ship.framework.tenant.core.db.TenantBaseDO;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 游客 DO
+ *
+ * @author 管理员
+ */
+@TableName("trade_visitor")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TradeVisitorDTO extends TenantBaseDO {
+
+    /**
+     * 主键
+     */
+    @TableId(type= IdType.ASSIGN_ID)
+    @Schema(description = "主键")
+    private Long id;
+
+    /**
+     * 游客ID
+     */
+    private String vid;
+    /**
+     * 明细ID
+     */
+    private Long detailId;
+
+    /**
+     * 订单ID
+     */
+    private Long orderId;
+    /**
+     * 游客姓名
+     */
+    @Schema(description = "游客姓名")
+    private String name;
+    /**
+     * 性别
+     */
+    @Schema(description = "性别")
+    private Integer gender;
+    /**
+     * 证件号
+     */
+    @Schema(description = "证件号")
+    private String credentialNo;
+
+    /**
+     * 国籍
+     */
+    @Schema(description = "国籍")
+    private String nationality;
+
+    @Schema(description = "生日")
+    private String birthday;
+
+
+    /**
+     * 证件类型
+     */
+    @Schema(description = "证件类型")
+    private Integer credentialType;
+    /**
+     * 手机号
+     */
+    @Schema(description = "手机号")
+    private String mobile;
+    /**
+     * 分销商的明细ID
+     */
+    @Schema(description = "分销商的明细ID")
+    private String otaDetailId;
+    /**
+     * 是否投保 0否 1是
+     */
+    private Integer isInsure;
+
+    @Schema(description = "是否推送实名制系统")
+    private Integer isPush;
+
+    @Schema(description = "分销商的明细ID")
+    @TableField(exist = false)
+    private String voucherCode;
+
+    @Schema(description = "关联的游客ID")
+    @TableField(exist = false)
+    private Long associatedVisitorId;
+
+    @Schema(description = "是否宜昌中转 0:否 1是")
+    private Integer yczz;
+
+    @Schema(description = "是否重庆中转 0:否 1是")
+    private Integer cqzz;
+
+    private String roomId;
+    private String cohabitation;
+    @TableField(exist = false)
+    private String jzTime;
+    @TableField(exist = false)
+    private String jzPhone;
+    @TableField(exist = false)
+    private String jzCard;
+    @TableField(exist = false)
+    private String jzAddress;
+
+    @Schema(description = "国籍名称")
+    @TableField(exist = false)
+    private String nationalityName;
+
+    @Schema(description = "备注")
+    private String remark;
+
+    @Schema(description = "预分房间ID", example = "")
+    private String initRoomId;
+
+    @Schema(description = "实际入住房间ID", example = "")
+    private String finalRoomId;
+
+    @Schema(description = "临时入住房间ID", example = "")
+    private String tempRoomId;
+
+    @Schema(description = "入住日期", example = "")
+    private String inDate;
+
+    @Schema(description = "离开日期", example = "")
+    private String outDate;
+
+    @Schema(description = "年龄", example = "")
+    private Integer age;
+
+    @Schema(description = "游客类型", example = "")
+    private String type;
+
+    @Schema(description = "地址", example = "")
+    private String address;
+
+    @Schema(description = "产品类型 0 游船产品 1 附加产品 2:赠票", example = "")
+    @TableField(exist = false)
+    private Integer productType;
+
+    /**
+     * 订单房型的唯一ID,查询入住房型的信息
+     */
+    private String roomIndexId;
+
+    @Schema(description = "区域", example = "")
+    private String area;
+
+    @Schema(description = "房型ID", example = "")
+    private Long roomModelId;
+
+    @Schema(description = "楼层", example = "")
+    private Integer floor;
+
+    @Schema(description = "饮食忌口", example = "")
+    private String diet;
+
+    @Schema(description = "行程安排", example = "")
+    private String itinerary;
+
+    @Schema(description = "照片", example = "")
+    private String images;
+
+    @Schema(description = "是否加床", example = "")
+    private String isAddBed;
+
+    @Schema(description = "房型", example = "")
+    @TableField(exist = false)
+    private String roomModelName;
+
+    @Schema(description = "是否独居")
+    @TableField(exist = false)
+    private Integer isAlone;
+
+    @Schema(description = "是否具备同住关系", example = "")
+    @TableField(exist = false)
+    private Integer isHaveTogethers;
+
+    @Schema(description = "当前房间ID", example = "")
+    @TableField(exist = false)
+    private Long nowRoomId;
+
+    /**
+     * 入住类型
+     * 1:实际入住 2:临时入住 3:预分房
+     */
+    @Schema(description = "入住类型", example = "")
+    @TableField(exist = false)
+    private String occupancyType;
+
+
+    @Schema(description = "订单编号", example = "")
+    @TableField(exist = false)
+    private String orderNo;
+
+    @Schema(description = "订单类型", example = "")
+    @TableField(exist = false)
+    private String orderType;
+
+    @Schema(description = "是否制卡", example = "")
+    @TableField(exist = false)
+    private String isMakeCard;
+
+    @Schema(description = "是否身份证登记", example = "")
+    @TableField(exist = false)
+    private String isIdCardRegister;
+
+    @Schema(description = "账单总和", example = "")
+    @TableField(exist = false)
+    private BigDecimal billAmount;
+
+    @Schema(description = "未出账总和", example = "")
+    @TableField(exist = false)
+    private BigDecimal unBillAmount;
+
+    /**
+     * 房间名称
+     */
+    @TableField(exist = false)
+    private String roomName;
+
+    /**
+     * 团名
+     */
+    @TableField(exist = false)
+    private String groupNo;
+
+    /**
+     * 同步状态, 0未同步,1同步中,2已同步
+     */
+    private String syncStatus;
+    /**
+     * 同步开始时间
+     */
+    private LocalDateTime syncBeginTime;
+    /**
+     * 同步结束时间
+     */
+    private LocalDateTime syncEndTime;
+    /**
+     * 同步ID, 客户端同步ID
+     */
+    private String syncId;
+
+    private String externalPassengerIdApi;
+
+    @Schema(description = "国籍id")
+    @TableField(exist = false)
+    private String nationalityId;
+
+    @Schema(description = "乘客类型")
+    @TableField(exist = false)
+    private String category;
+
+    @Schema(description = "证件类型 名称")
+    @TableField(exist = false)
+    private String certType;
+   @TableField(exist = false)
+    private Integer isTransfer;
+
+
+}

+ 9 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/controller/admin/voyage/VoyageController.java

@@ -243,4 +243,13 @@ public class VoyageController {
         voyageService.updateLockVoyage(id);
         return success(true);
     }
+
+    @GetMapping("/sync-voyage")
+    @Operation(summary = "同步航次数据")
+    @Parameter(name = "id", description = "编号", required = true, example = "1024")
+    @PreAuthorize("@ss.hasPermission('product:voyage:update')")
+    public CommonResult<Boolean> syncVoyage(@RequestParam("id") Long id) {
+        voyageService.syncVoyage(id);
+        return success(true);
+    }
 }

+ 22 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/dataobject/voyage/OrderVisitorDo.java

@@ -0,0 +1,22 @@
+package com.yc.ship.module.product.dal.dataobject.voyage;
+
+import lombok.Data;
+
+@Data
+public class OrderVisitorDo {
+    private Long id;          // 游客id
+    private Long orderId;          // 游客的订单id
+    private String orderNo;          // 订单编号
+    private String name;          // 游客姓名
+    private String certNo;          // 游客姓名
+    private String phone;         // 手机号
+    private Integer age;          // 年龄
+    private Integer isTransfer;   // 是否转团 0-否 1-是
+    private Integer sex;           // 性别
+    private String category;      // 类别:aduit/kid/未知
+    private String countryId;       // 国家ID(从national_api_country_id查询)
+    private String certType;      // 证件类型名称(从system_dict_data查询)
+
+    private String externalPassengerIdApi;
+
+}

+ 16 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/dataobject/voyage/TicketInfoDo.java

@@ -0,0 +1,16 @@
+package com.yc.ship.module.product.dal.dataobject.voyage;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class TicketInfoDo {
+    private Integer childFlag;
+    //该票型有多少人  如:成人票:8人 儿童票:2人
+    private Integer personCount;
+    private String ticketTypeName;
+    private BigDecimal publicPrice;
+    private BigDecimal price;
+
+}

+ 9 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/dataobject/voyage/VoyageDO.java

@@ -145,4 +145,13 @@ public class VoyageDO extends TenantBaseDO {
      * 备注
      */
     private String remark;
+
+    /**
+     * 航次同步后 返回的planid
+     * 备注
+     */
+    private String externalPlanId;
+    private Integer externalApiStatus;
+    private String bookingOrderReturnJson;
+    private Integer shipIsSync;
 }

+ 17 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/dal/mysql/voyage/VoyageMapper.java

@@ -1,17 +1,22 @@
 package com.yc.ship.module.product.dal.mysql.voyage;
 
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.yc.ship.framework.common.pojo.PageResult;
 import com.yc.ship.framework.mybatis.core.mapper.BaseMapperX;
 import com.yc.ship.framework.mybatis.core.query.LambdaQueryWrapperX;
+import com.yc.ship.module.product.api.dto.TradeVisitorDTO;
 import com.yc.ship.module.product.controller.admin.voyage.vo.VoyageCalendarReqVO;
 import com.yc.ship.module.product.controller.admin.voyage.vo.VoyagePageReqVO;
 import com.yc.ship.module.product.controller.admin.voyage.vo.VoyageReqVO;
 import com.yc.ship.module.product.controller.app.voyage.vo.AppVoyageDayRespVO;
+import com.yc.ship.module.product.dal.dataobject.voyage.OrderVisitorDo;
+import com.yc.ship.module.product.dal.dataobject.voyage.TicketInfoDo;
 import com.yc.ship.module.product.dal.dataobject.voyage.VoyageDO;
 import com.yc.ship.module.product.enums.VoyageShelfStatusEnum;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 import java.time.LocalDate;
 import java.time.LocalDateTime;
@@ -128,6 +133,15 @@ public interface VoyageMapper extends BaseMapperX<VoyageDO> {
      */
     List<Map<String, Object>> countBatchByVoyageIds(@Param("voyageIds") List<Long> voyageIds);
 
+    List<TicketInfoDo> getTicketInfoByRoomType(@Param("voyageId") Long id, @Param("roomTypeCode") String s);
+
+    @InterceptorIgnore(tenantLine = "true")
+    List<OrderVisitorDo> getPassengerData(@Param("voyageId") Long voyageId, @Param("typeList") List<String> typeList, @Param("roomTypeName") String roomTypeName);
+
+    @InterceptorIgnore(tenantLine = "true")
+    List<TradeVisitorDTO> getLocalVisitorsByVoyageId(Long voyageId);
+    void batchUpdateExternalPassengerId(@Param("list") List<TradeVisitorDTO> updateList);
+
     /**
      * 查询订单的航次
      * @param day 提前天数
@@ -135,4 +149,7 @@ public interface VoyageMapper extends BaseMapperX<VoyageDO> {
      * @return
      */
     List<Map<String, Object>> queryOrderVoyage(@Param("day")int day,@Param("direction") int type);
+
+    @Select("SELECT direction FROM resource_route where id =(SELECT route_id FROM product_voyage where id = #{voyageId})")
+    String getVoyageDirectionByVoyageId(Long voyageId);
 }

+ 55 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/framework/config/ShipPlatformProperties.java

@@ -0,0 +1,55 @@
+package com.yc.ship.module.product.framework.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "ship.platform")
+public class ShipPlatformProperties {
+
+    private String username;
+    private String password;
+    private String requestUrl;
+
+    // key = 系统内 shipId, value = 该船的详细配置
+    private Map<Long, ShipConfig> ships;
+
+    @Data
+    public static class ShipConfig {
+        private String cruiseCode;   // 必填
+        private String productId;    // 必填
+    }
+
+    /**
+     * 获取某船的 cruiseCode
+     */
+    public String getCruiseCode(Long shipId) {
+        if (ships == null || !ships.containsKey(shipId)) {
+            throw new IllegalArgumentException("暂不支持此船"+"shipId=" + shipId +"的同步");
+        }
+        ShipConfig config = ships.get(shipId);
+        System.out.println("shipconfig:"+ config);
+        if (config == null || config.getCruiseCode() == null) {
+            throw new IllegalArgumentException("shipId=" + shipId + " 未配置 cruise-code");
+        }
+        return config.getCruiseCode();
+    }
+
+    /**
+     * 获取某船的 productId
+     */
+    public String getProductId(Long shipId) {
+        if (ships == null || !ships.containsKey(shipId)) {
+            throw new IllegalArgumentException("暂不支持此船"+"shipId=" + shipId +"的同步");
+        }
+        ShipConfig config = ships.get(shipId);
+        if (config == null || config.getProductId() == null) {
+            throw new IllegalArgumentException("shipId=" + shipId + " 未配置 product-id");
+        }
+        return config.getProductId();
+    }
+}

+ 16 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageAsyncService.java

@@ -0,0 +1,16 @@
+package com.yc.ship.module.product.service.voyage;
+
+import com.yc.ship.module.product.api.dto.TradeVisitorDTO;
+
+import java.util.List;
+
+public interface VoyageAsyncService {
+    /**
+     * 异步处理已同步乘客的信息修改
+     *
+     * @param syncedVisitors 已同步的乘客列表
+     * @param taskId         任务ID
+     * @param agentToken     代理商token
+     */
+    void asyncModifySyncedVisitors(List<TradeVisitorDTO> syncedVisitors, Long taskId, String agentToken);
+}

+ 68 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageAsyncServiceImpl.java

@@ -0,0 +1,68 @@
+
+package com.yc.ship.module.product.service.voyage;
+
+import com.alibaba.fastjson.JSONObject;
+import com.yc.ship.module.product.api.AgentOrderApi;
+import com.yc.ship.module.product.api.dto.TradeVisitorDTO;
+import com.yc.ship.module.product.utils.SM2Utils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class VoyageAsyncServiceImpl implements VoyageAsyncService {
+
+    @Resource
+    private AgentOrderApi agentOrderApi;
+
+    @Value("${sm2.public-key}")
+    private String sm2PublicKey;
+
+    @Override
+    @Async
+    public void asyncModifySyncedVisitors(List<TradeVisitorDTO> syncedVisitors, Long taskId, String agentToken) {
+        log.info("开始异步处理 {} 个已同步乘客的修改", syncedVisitors.size());
+        int successCount = 0;
+        int failCount = 0;
+
+        for (TradeVisitorDTO visitor : syncedVisitors) {
+            try {
+                JSONObject modifyData = buildModOrder(visitor);
+                boolean modifyResult = agentOrderApi.applyChangePassenger(taskId, modifyData, agentToken);
+                if (modifyResult) {
+                    successCount++;
+                } else {
+                    failCount++;
+                }
+                log.info("修改代理商订单结果: {}, passengerId: {}", modifyResult, visitor.getId());
+            } catch (Exception e) {
+                failCount++;
+                log.error("修改乘客信息失败,passengerId: {}---{}", visitor.getId(),visitor.getName(), e);
+            }
+        }
+
+        log.info("异步处理完成,总数: {}, 成功: {}, 失败: {}", syncedVisitors.size(), successCount, failCount);
+    }
+
+    // 构建单个乘客的修改请求
+    private JSONObject buildModOrder(TradeVisitorDTO visitor) {
+        JSONObject modifyData = new JSONObject();
+        modifyData.put("orderPassengerId", visitor.getExternalPassengerIdApi());
+        modifyData.put("name", visitor.getName());
+        modifyData.put("certType", visitor.getCertType());
+        modifyData.put("certNo", SM2Utils.encrypt(visitor.getCredentialNo(), sm2PublicKey));
+        modifyData.put("countryId", visitor.getNationalityId());
+        modifyData.put("phone", visitor.getMobile());
+
+        int sex = visitor.getGender() == null ? 1 : visitor.getGender();
+        modifyData.put("sex", sex == 1 ? "male" : "female");
+        modifyData.put("age", visitor.getAge());
+        modifyData.put("category", visitor.getCategory());
+        return modifyData;
+    }
+}

+ 2 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageService.java

@@ -105,4 +105,6 @@ public interface VoyageService {
      * @return
      */
     List<Map<String, Object>> countBatchByVoyageIds(@Param("voyageId") List<Long> voyageIds);
+
+    void syncVoyage(Long id);
 }

+ 716 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/service/voyage/VoyageServiceImpl.java

@@ -1,11 +1,21 @@
 package com.yc.ship.module.product.service.voyage;
 
+import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.date.DatePattern;
 import cn.hutool.core.date.LocalDateTimeUtil;
 import cn.hutool.core.util.IdUtil;
+import com.alibaba.excel.util.StringUtils;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.google.common.collect.Lists;
 import com.yc.ship.framework.common.pojo.PageResult;
 import com.yc.ship.framework.common.util.collection.CollectionUtils;
 import com.yc.ship.framework.common.util.object.BeanUtils;
+import com.yc.ship.module.infra.dal.dataobject.logger.SyncTaskDO;
+import com.yc.ship.module.infra.dal.mysql.logger.SyncTaskMapper;
+import com.yc.ship.module.product.api.AgentOrderApi;
+import com.yc.ship.module.product.api.ShipPlatformApi;
+import com.yc.ship.module.product.api.dto.TradeVisitorDTO;
 import com.yc.ship.module.product.controller.admin.pricetemplate.vo.*;
 import com.yc.ship.module.product.controller.admin.pricevoyage.vo.PriceVoyageSaveReqVO;
 import com.yc.ship.module.product.controller.admin.voyage.vo.*;
@@ -17,6 +27,8 @@ import com.yc.ship.module.product.dal.dataobject.priceroommodel.PriceRoomModelDO
 import com.yc.ship.module.product.dal.dataobject.priceroommodeltype.PriceRoomModelTypeDO;
 import com.yc.ship.module.product.dal.dataobject.pricesinglesetting.PriceSingleSettingDO;
 import com.yc.ship.module.product.dal.dataobject.pricespu.PriceSpuDO;
+import com.yc.ship.module.product.dal.dataobject.voyage.OrderVisitorDo;
+import com.yc.ship.module.product.dal.dataobject.voyage.TicketInfoDo;
 import com.yc.ship.module.product.dal.dataobject.voyage.VoyageDO;
 import com.yc.ship.module.product.dal.dataobject.voyagestock.VoyageStockDO;
 import com.yc.ship.module.product.dal.dataobject.voyagestockdetail.VoyageStockDetailDO;
@@ -28,16 +40,28 @@ import com.yc.ship.module.product.dal.mysql.voyagestockdetail.VoyageStockDetailM
 import com.yc.ship.module.product.dal.mysql.voyagestockdistribute.VoyageStockDistributeNewMapper;
 import com.yc.ship.module.product.enums.UseStatusEnum;
 import com.yc.ship.module.product.enums.YesOrNoEnum;
+import com.yc.ship.module.product.framework.config.ShipPlatformProperties;
 import com.yc.ship.module.product.framework.lock.ProductRedisKeyConstants;
 import com.yc.ship.module.product.service.pricetemplate.PriceTemplateService;
 import com.yc.ship.module.product.service.pricevoyage.PriceVoyageService;
+import com.yc.ship.module.product.utils.SM2Utils;
+import com.yc.ship.module.product.utils.TicketTypeUtil;
 import com.yc.ship.module.product.utils.VoyageUUCodeUtils;
 import com.yc.ship.module.resource.api.ship.ShipApi;
 import com.yc.ship.module.resource.api.ship.dto.RoomModelFloorNumDTO;
 import com.yc.ship.module.resource.api.ship.dto.ShipRespDTO;
+import com.yc.ship.module.resource.dal.dataobject.dock.ResourceDockDO;
+import com.yc.ship.module.resource.dal.dataobject.roommodel.ResourceRoomModelDO;
+import com.yc.ship.module.resource.dal.dataobject.route.ResourceRouteDO;
+import com.yc.ship.module.resource.dal.mysql.dock.ResourceDockMapper;
+import com.yc.ship.module.resource.dal.mysql.route.ResourceRouteMapper;
+import com.yc.ship.module.resource.service.room.ResourceRoomService;
+import com.yc.ship.module.resource.service.roommodel.ResourceRoomModelService;
 import lombok.extern.slf4j.Slf4j;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
@@ -45,13 +69,17 @@ import org.springframework.validation.annotation.Validated;
 import javax.annotation.Resource;
 
 import java.math.BigDecimal;
+import java.time.Duration;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.time.temporal.ChronoUnit;
+import java.util.*;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 import static com.yc.ship.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static com.yc.ship.framework.common.exception.util.ServiceExceptionUtil.exception0;
@@ -94,9 +122,48 @@ public class VoyageServiceImpl implements VoyageService {
     @Resource
     private PriceVoyageService priceVoyageService;
 
+    @Resource
+    private ResourceRouteMapper resourceRouteMapper;
+
+    @Resource
+    private ResourceRoomModelService roomModelService;
+
+    @Resource
+    private ResourceDockMapper resourceDockMapper;
     @Resource
     private VoyageStockLogMapper voyageStockLogMapper;
 
+    @Resource
+    private StringRedisTemplate stringRedisTemplate;
+
+    @Resource
+    private VoyageAsyncService voyageAsyncService;
+
+    @Resource
+    private ShipPlatformProperties shipPlatformProperties;
+
+    @Resource
+    private ResourceRoomService roomService;
+
+    @Value("${agent.platform.username}")
+    private String agentUsername;
+    @Value("${agent.platform.password}")
+    private String agentPassword;
+
+    //    公钥
+    @Value("${sm2.public-key}")
+    private String sm2PublicKey;
+
+    @Resource
+    private SyncTaskMapper syncTaskMapper;
+
+    @Resource
+    private ShipPlatformApi shipPlatformApi;
+
+    @Resource
+    private AgentOrderApi agentOrderApi;
+
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Long createVoyage(VoyageSaveReqVO createReqVO) {
@@ -528,4 +595,653 @@ public class VoyageServiceImpl implements VoyageService {
         return voyageMapper.countBatchByVoyageIds(voyageIds);
     }
 
+    @Override
+    public void syncVoyage(Long id) {
+        VoyageDO voyageDO = voyageMapper.selectById(id);
+        log.info("开始同步航次,id: {}", id);
+        String planId = null;
+        String finalStatus = "FAILED";
+        String errorSummary = null;
+        LocalDateTime taskEndTime;
+        int taskCost;
+
+        // 标记是否为新增操作(用于失败时决定是否清理外部航次)
+        boolean isAddOperation = false;
+        // 判断是否已经同步成功(存在 externalPlanId 且状态为成功)
+        boolean alreadySynced = StringUtils.isNotBlank(voyageDO.getExternalPlanId())
+                && Integer.valueOf(1).equals(voyageDO.getExternalApiStatus());
+
+        // 1. 创建任务主记录
+        SyncTaskDO task = new SyncTaskDO();
+        task.setVoyageId(id);
+        task.setTaskStatus("PROCESSING");
+        task.setStartTime(LocalDateTime.now());
+        task.setCreateTime(LocalDateTime.now());
+        syncTaskMapper.insert(task);
+
+        String validToken = null;
+        String agentToken = null;
+
+        try {
+            validToken = getValidToken(task.getId());
+            agentToken = getAgentToken(task.getId());
+            log.info("获取船平台token成功{}-{},taskId: {}", validToken, agentToken, task.getId());
+            if (alreadySynced) {
+                log.info("航次已同步,执行更新操作,planId: {}", voyageDO.getExternalPlanId());
+                planId = voyageDO.getExternalPlanId();  // 使用已有的 planId
+//                禁用航次
+                shipPlatformApi.batchDisablePlan(task.getId(), Lists.newArrayList(planId), validToken);
+                log.info("禁用航次计划成功,planId: {}", planId);
+
+                JSONObject jsonModShip = batchModVoyages(planId, voyageDO);
+                // 2.1 修改航次计划
+                boolean updateResult = shipPlatformApi.modifyVoyage(task.getId(), jsonModShip, validToken);
+                log.info("修改航次计划结果: {}, planId: {}", updateResult, planId);
+
+                // 2.2 修改代理商订单
+                List<TradeVisitorDTO> visitors = voyageMapper.getLocalVisitorsByVoyageId(id);
+                log.info("查询到乘客数量: {}", visitors != null ? visitors.size() : 0);
+                // 分类乘客:已同步的和未同步的
+                List<TradeVisitorDTO> syncedVisitors = new ArrayList<>();
+                List<TradeVisitorDTO> unsyncedVisitors = new ArrayList<>();
+
+                for (TradeVisitorDTO visitor : visitors) {
+                    if (!StringUtils.isEmpty(visitor.getExternalPassengerIdApi())) {
+                        syncedVisitors.add(visitor);
+                    } else {
+                        unsyncedVisitors.add(visitor);
+                    }
+                }
+                log.info("已同步乘客数量: {}, 未同步乘客数量: {}", syncedVisitors.size(), unsyncedVisitors.size());
+                // 2.2.1 异步修改已同步的乘客信息
+                if (!syncedVisitors.isEmpty()) {
+                    voyageAsyncService.asyncModifySyncedVisitors(syncedVisitors, task.getId(), agentToken);
+                    log.info("已提交异步任务处理 {} 个已同步乘客", syncedVisitors.size());
+                }
+                //                修改完成后设置航次可售
+                shipPlatformApi.batchEnablePlan(task.getId(), Lists.newArrayList(planId), validToken);
+                // 2.2.2 为未同步的乘客创建新订单
+                if (!unsyncedVisitors.isEmpty()) {
+                    processUnsyncedVisitors(unsyncedVisitors, planId, id, task.getId(), agentToken);
+                }
+            }
+            else {
+                log.info("航次未同步,执行新增操作");
+                isAddOperation = true;
+                JSONObject jsonObject = batchAddVoyages(voyageDO);
+                JSONObject jsonObjectAgent = JSONObject.parseObject(jsonObject.toJSONString());
+                removePersonCount(jsonObject);
+
+                planId = shipPlatformApi.addVoyage(task.getId(), shipPlatformProperties.getProductId(voyageDO.getShipId()), jsonObject, validToken);
+                log.info("添加航次计划成功,planId: {}", planId);
+                if (StringUtils.isBlank(planId)) {
+                    throw new RuntimeException("添加航次计划返回的planId为空 ");
+                }
+                //              调用代理商api 传入用户信息
+                JSONObject jsonOrder = buildOrder(jsonObjectAgent, planId, id);
+                System.out.println("jsonOrder" + jsonOrder);
+                JSONObject bookingOrderReturnJson = agentOrderApi.bookingOrder(task.getId(), jsonOrder, agentToken);
+                log.info("代理商下单成功,planId: {}", planId);
+                if (bookingOrderReturnJson == null) {
+                    throw new RuntimeException("代理商返回数据为空 ");
+                }
+
+//                将返回的乘客id 存起来,用于后续的更新
+                JSONArray tickets = bookingOrderReturnJson.getJSONArray("tickets");
+                if (tickets != null && !tickets.isEmpty()) {
+                    List<TradeVisitorDTO> updateList = new ArrayList<>();
+                    for (int i = 0; i < tickets.size(); i++) {
+                        JSONObject ticket = tickets.getJSONObject(i);
+                        String externalPassengerId = ticket.getString("externalPassengerId");
+                        String orderPassengerId = ticket.getString("orderPassengerId");
+
+                        if (StringUtils.isBlank(externalPassengerId)) {
+                            continue;
+                        }
+                        TradeVisitorDTO dto = new TradeVisitorDTO();
+                        dto.setId(Long.valueOf(externalPassengerId));
+                        dto.setExternalPassengerIdApi(orderPassengerId);
+                        updateList.add(dto);
+                    }
+                    if (!updateList.isEmpty()) {
+                        voyageMapper.batchUpdateExternalPassengerId(updateList);
+                        log.info("批量更新乘客外部ID成功,数量: {}", updateList.size());
+                    }
+                }
+
+                // ========== 两个操作全部成功后,写入 planId ==========
+                VoyageDO updateDO = new VoyageDO();
+                updateDO.setId(id);
+                updateDO.setExternalPlanId(planId);
+                updateDO.setExternalApiStatus(1);
+                updateDO.setBookingOrderReturnJson(bookingOrderReturnJson.toJSONString());
+                updateDO.setShipIsSync(1);
+                voyageMapper.updateById(updateDO);
+                // ============================================================
+                log.info("api同步返回的信息 同步本地 更新成功,id: {}", id);
+            }
+            finalStatus = "SUCCESS";
+
+        } catch (Exception e) {
+            errorSummary = e.getMessage();
+            finalStatus = "FAILED";
+            log.error("航次同步业务异常,id: {}, planId: {}, error: {}", id, planId, errorSummary, e);
+            compensateOnError(isAddOperation, planId, task,validToken);
+            throw new RuntimeException("航次同步业务异常: " + e.getMessage(), e);
+        } finally {
+            // 6. 更新任务主记录
+            taskEndTime = LocalDateTime.now();
+            taskCost = (int) Duration.between(task.getStartTime(), taskEndTime).toMillis();
+            task.setEndTime(taskEndTime);
+            task.setCostMillis(taskCost);
+            task.setTaskStatus(finalStatus);
+            task.setErrorSummary(errorSummary);
+            syncTaskMapper.updateById(task);
+        }
+    }
+
+    private String getValidToken(Long taskId) throws Exception {
+        String token = stringRedisTemplate.opsForValue().get("ship:platform:token");
+        if (StringUtils.isBlank(token)) {
+            token = shipPlatformApi.login(taskId, shipPlatformProperties.getUsername(), shipPlatformProperties.getPassword());
+            stringRedisTemplate.opsForValue().set("ship:platform:token", token, 10, TimeUnit.MINUTES);
+        }
+        return token;
+    }
+
+    private String getAgentToken(Long taskId) throws Exception {
+        String token = stringRedisTemplate.opsForValue().get("agent:platform:token");
+        if (StringUtils.isBlank(token)) {
+            token = agentOrderApi.login(taskId, agentUsername, agentPassword);
+            stringRedisTemplate.opsForValue().set("agent:platform:token", token, 10, TimeUnit.MINUTES);
+        }
+        return token;
+    }
+
+    /**
+     * 根据本地航次数据构建外部平台所需的 JSON 参数
+     */
+    public JSONObject batchAddVoyages(VoyageDO voyageDO) {
+        log.info("开始构建航次新增请求,voyageId: {}, shipId: {}", voyageDO.getId(), voyageDO.getShipId());
+        ResourceRouteDO resourceRouteDO = resourceRouteMapper.selectById(voyageDO.getRouteId());
+        log.info("航线信息: {}", resourceRouteDO);
+        JSONObject plan = new JSONObject();
+        plan.put("departureDate", voyageDO.getBoardingTime()); //改为登船时间
+        plan.put("arriveDate", voyageDO.getLeaveTime()); //预计抵达时间
+        plan.put("activeStatus", "enable");//航次销售状态:启用enable;禁用 disable
+        plan.put("stopSeller", 30);                  // 提前30分钟停止售票
+        plan.put("total", 650);                      // 总库存
+        plan.put("enlargeTotal", 200);               // 放大库存
+        plan.put("stockWarning", 100);                // 库存警告
+        plan.put("bookingPrice", 100);           // 预订价格
+        plan.put("crossedPrice", 100);           // 划线价
+
+        String cruiseCode = shipPlatformProperties.getCruiseCode(voyageDO.getShipId());
+        /* 正式环境在启用*/
+        plan.put("cruiseCode", cruiseCode);        // 邮轮ID
+        Map<Long, ResourceDockDO> dockMap = resourceDockMapper.selectBatchIds(
+                Arrays.asList(resourceRouteDO.getOnDockId(), resourceRouteDO.getLeaveDockId())
+        ).stream().collect(Collectors.toMap(ResourceDockDO::getId, d -> d));
+        log.info("码头映射信息: {}", dockMap);
+        plan.put("embarkPort", String.valueOf(dockMap.get(resourceRouteDO.getOnDockId()).getExternalDockId()));
+        plan.put("debarkPort", String.valueOf(dockMap.get(resourceRouteDO.getLeaveDockId()).getExternalDockId()));
+        plan.put("deptCode", resourceRouteDO.getStartPortId());            // 出发港口ID
+        plan.put("destCode", resourceRouteDO.getEndPortId());            // 抵达港口ID
+        plan.put("shipRouteId", resourceRouteDO.getExternalRouteId());              // 航线ID
+
+        // 航次中途港口
+        JSONArray planPorts = new JSONArray();
+        plan.put("planPorts", planPorts);
+
+        // 航次房型数据
+        // 1. 获取该游轮的所有房型
+        List<ResourceRoomModelDO> roomModelList = roomModelService.getRoomModelListByShipId(String.valueOf(voyageDO.getShipId()));
+        log.info("房型列表数量: {}", roomModelList != null ? roomModelList.size() : 0);
+        JSONArray roomTypes = new JSONArray();
+
+        // 2. 遍历每种房型,查询票种价格
+        for (ResourceRoomModelDO roomModel : roomModelList) {
+
+            // 3. 查询该房型+航次的票种价格
+            List<TicketInfoDo> ticketInfoList = voyageMapper.getTicketInfoByRoomType(
+                    voyageDO.getId(),
+                    String.valueOf(roomModel.getId())
+            );
+            System.out.println("票价查询参数:" + voyageDO.getId() + "  " + roomModel.getId());
+            System.out.println("ticketInfoList" + ticketInfoList);
+
+            if (CollUtil.isNotEmpty(ticketInfoList)) {
+                JSONObject room = new JSONObject();
+                room.put("roomTypeCode", String.valueOf(roomModel.getId()));  // 房型ID作为代码
+                room.put("roomTypeName", roomModel.getName());                // 房型名称
+                room.put("status", "enable");
+                JSONArray ticketPrices = new JSONArray();
+                for (TicketInfoDo info : ticketInfoList) {
+                    JSONObject ticket = new JSONObject();
+                    ticket.put("ticketTypeName", info.getTicketTypeName());
+                    ticket.put("childFlag", info.getChildFlag() == 1);
+                    ticket.put("publicPrice", 4999);
+                    ticket.put("price", 4999);
+                    ticket.put("couponFlag", false);
+                    ticket.put("stockConsumeFlag", 1);
+                    ticket.put("status", "enable");
+                    ticket.put("personCount", info.getPersonCount());
+                    ticketPrices.add(ticket);
+                }
+                room.put("ticketTypePriceAddVos", ticketPrices);
+                roomTypes.add(room);
+            }
+        }
+
+        System.out.println("roomTypes" + roomTypes);
+        // 校验:确保至少有一个有效房型
+        if (roomTypes.isEmpty()) {
+            throw new RuntimeException("该航次未配置任何房型的票价数据,请先配置");
+        }
+
+        plan.put("voyageRoomTypeAddVos", roomTypes);
+        log.info("航次新增请求构建完成");
+        System.out.println("plan" + plan);
+        return plan;
+    }
+
+    /**
+     * 根据本地航次数据构建外部平台所需的 JSON 参数--修改航次
+     */
+    public JSONObject batchModVoyages(String planId, VoyageDO voyageDO) {
+        // 1. 查询航线信息(用于获取港口、码头等)
+        ResourceRouteDO resourceRouteDO = resourceRouteMapper.selectById(voyageDO.getRouteId());
+
+        JSONObject modifyRequest = new JSONObject();
+        // 航次ID
+        modifyRequest.put("planId", planId);
+        // 航次出发日期 / 预计抵达时间
+        modifyRequest.put("departureDate", voyageDO.getStartTime());   // yyyy-MM-dd HH:mm:ss
+        modifyRequest.put("arriveDate", voyageDO.getLeaveTime());      // yyyy-MM-dd HH:mm:ss
+        // 航次销售状态(启用/禁用)
+        modifyRequest.put("activeStatus", "disable");
+        // 提前停止售票(分钟)
+        modifyRequest.put("stopSeller", 30);
+        // 总库存 / 放大库存 / 库存警告
+        modifyRequest.put("total", 650);
+        modifyRequest.put("enlargeTotal", 200);
+        modifyRequest.put("stockWarning", 100);
+        // 预订价格 / 划线价
+        modifyRequest.put("bookingPrice", 100);
+        modifyRequest.put("crossedPrice", 100);
+
+        // 港口码头信息(从航线中获取)
+        /* 正式环境在启用*/
+//        modifyRequest.put("cruiseCode", String.valueOf(voyageDO.getShipId()));        // 邮轮ID
+        Map<Long, ResourceDockDO> dockMap = resourceDockMapper.selectBatchIds(
+                Arrays.asList(resourceRouteDO.getOnDockId(), resourceRouteDO.getLeaveDockId())
+        ).stream().collect(Collectors.toMap(ResourceDockDO::getId, d -> d));
+
+        modifyRequest.put("embarkPort", String.valueOf(dockMap.get(resourceRouteDO.getOnDockId()).getExternalDockId()));
+        modifyRequest.put("debarkPort", String.valueOf(dockMap.get(resourceRouteDO.getLeaveDockId()).getExternalDockId()));
+        modifyRequest.put("deptCode", resourceRouteDO.getStartPortId());            // 出发港口ID
+        modifyRequest.put("destCode", resourceRouteDO.getEndPortId());            // 抵达港口ID
+        modifyRequest.put("shipRouteId", resourceRouteDO.getExternalRouteId());              // 航线ID
+        // 附件IDs
+        modifyRequest.put("fileIds", "");
+
+        // 2. 航次中途港口
+        JSONArray planPorts = new JSONArray();
+        modifyRequest.put("planPorts", planPorts);
+
+        // 3. 航次房型数据
+        // 获取该游轮的所有房型
+        List<ResourceRoomModelDO> roomModelList = roomModelService.getRoomModelListByShipId(String.valueOf(voyageDO.getShipId()));
+        System.out.println("roomModelList:" + roomModelList);
+
+        JSONArray roomTypes = new JSONArray();
+        for (ResourceRoomModelDO roomModel : roomModelList) {
+
+
+            // 4. 查询该房型 + 当前航次下的票种价格
+            List<TicketInfoDo> ticketInfoList = voyageMapper.getTicketInfoByRoomType(
+                    voyageDO.getId(),
+                    String.valueOf(roomModel.getId())
+            );
+            System.out.println("票价查询参数:planId=" + planId + ", roomId=" + roomModel.getId());
+            System.out.println("ticketInfoList:" + ticketInfoList);
+
+
+            if (CollUtil.isNotEmpty(ticketInfoList)) {
+                JSONObject room = new JSONObject();
+                room.put("roomTypeCode", String.valueOf(roomModel.getId()));
+                room.put("roomTypeName", roomModel.getName());
+                room.put("status", "enable");
+
+                JSONArray ticketPrices = new JSONArray();
+                for (TicketInfoDo info : ticketInfoList) {
+                    JSONObject ticket = new JSONObject();
+                    ticket.put("ticketTypeName", info.getTicketTypeName());
+                    ticket.put("childFlag", info.getChildFlag() == 1);
+                    ticket.put("publicPrice", 4999);
+                    ticket.put("price", 4999);
+                    ticket.put("couponFlag", false);
+                    ticket.put("stockConsumeFlag", true);
+                    ticket.put("status", "enable");
+                    ticketPrices.add(ticket);
+                }
+                room.put("ticketTypePriceModifyVos", ticketPrices);
+                roomTypes.add(room);
+            }
+        }
+        modifyRequest.put("voyageRoomTypeModifyVos", roomTypes);
+        // 如果所有房型都无票种
+        if (roomTypes.isEmpty()) {
+            throw new RuntimeException("该航次没有可修改的房型票价数据,请先配置票种");
+        }
+        System.out.println("修改请求体:" + modifyRequest);
+        return modifyRequest;
+    }
+
+
+    /**
+     * 构建用户预定订单信息为api格式
+     */
+    private JSONObject buildOrder(JSONObject jsonObjectAgent, String planId, Long voyageId) {
+        JSONObject order = new JSONObject();
+        // 1. 联系人
+        JSONObject contactMan = new JSONObject();
+        //获取航向
+        String voyageDirection = voyageMapper.getVoyageDirectionByVoyageId(voyageId);
+        if ("1".equals(voyageDirection)) {
+            contactMan.put("name", "罗利娅");
+            contactMan.put("phone", "15717896534");
+        } else {
+            contactMan.put("name", "汪子祎");
+            contactMan.put("phone", "18580594535");
+        }
+        order.put("contactMan", contactMan);
+
+        // 2. 航次信息
+        JSONArray cruisePlans = new JSONArray();
+        JSONObject cruisePlan = new JSONObject();
+        //        航次添加后返回的planid
+//        cruisePlan.put("cruisePlanId", "305898028676415488");
+        cruisePlan.put("cruisePlanId", planId);
+
+        // 3. planstocks 数组
+        JSONArray planstocks = new JSONArray();
+        JSONArray voyageRoomTypeAddVos = jsonObjectAgent.getJSONArray("voyageRoomTypeAddVos");
+
+        for (int i = 0; i < voyageRoomTypeAddVos.size(); i++) {
+            JSONObject roomType = voyageRoomTypeAddVos.getJSONObject(i);
+            String roomTypeName = roomType.getString("roomTypeName");
+            JSONArray ticketTypes = roomType.getJSONArray("ticketTypePriceAddVos");
+
+            for (int j = 0; j < ticketTypes.size(); j++) {
+                JSONObject ticket = ticketTypes.getJSONObject(j);
+                int personCount = ticket.getIntValue("personCount");
+                if (personCount <= 0) {
+                    continue;   // 跳过无人数的票种
+                }
+                String ticketTypeName = ticket.getString("ticketTypeName");
+
+                JSONObject planstock = new JSONObject();
+                planstock.put("num", personCount);
+                planstock.put("ticketName", ticketTypeName);
+                planstock.put("roomTypeName", roomTypeName);
+
+                // 生成 passengers 数组,根据房型 航号,票型 查找对应的乘客数据
+                List<String> dbTypes = TicketTypeUtil.toDbTypes(ticketTypeName, roomTypeName, voyageId);
+                System.out.println("dbTypes" + dbTypes);
+                List<OrderVisitorDo> passengerData = voyageMapper.getPassengerData(voyageId, dbTypes, roomTypeName);
+                System.out.println("ticketTypeName" + ticketTypeName);
+                System.out.println("passengerData" + passengerData);
+                if (passengerData == null || passengerData.isEmpty()) {
+                    // 没有乘客数据,跳过该 planstock
+                    continue;
+                }
+                JSONArray passengers = new JSONArray();
+                int no = 1;
+                for (OrderVisitorDo visitor : passengerData) {
+//                    if (!StringUtils.isEmpty(visitor.getExternalPassengerIdApi())) {
+//                        log.info("用户:{}, 外部ID:{} 已经同步过,忽略...", visitor.getId(), visitor.getExternalPassengerIdApi());
+//                        continue;
+//                    }
+                    JSONObject passenger = new JSONObject();
+                    String name = visitor.getName();
+                    if (name != null) {
+                        name = name.replaceAll("^\\p{javaWhitespace}+|\\p{javaWhitespace}+$", "");
+                    }
+                    passenger.put("name", name);
+                    String certNo = visitor.getCertNo();
+                    if (certNo == null || certNo.trim().isEmpty()) {
+                        throw new IllegalArgumentException("旅客证件号不能为空,旅客姓名:" + visitor.getName());
+                    }
+                    passenger.put("certNo", SM2Utils.encrypt(certNo, sm2PublicKey)); // 需SM2加密
+                    passenger.put("phone", visitor.getPhone());     // 手机号
+                    passenger.put("age",visitor.getAge());         // 年龄
+                    passenger.put("isTransfer", visitor.getIsTransfer() == 1);// 是否接送
+
+                    int sex = visitor.getSex() == null ? 1 : visitor.getSex();
+                    passenger.put("sex", sex == 1 ? "male" : "female");
+                    passenger.put("category", visitor.getCategory()); // 类别:aduit/kid
+                    passenger.put("countryId", visitor.getCountryId());   // 国家ID
+                    passenger.put("certType", visitor.getCertType());     // 证件类型
+                    passenger.put("gobackFlag", false);
+                    String[] split = visitor.getOrderNo().split("-");
+                    passenger.put("groupSortNo", split[split.length - 1]);
+                    passenger.put("no", no++);
+                    passenger.put("externalPassengerId", visitor.getId());  //游客id
+                    passengers.add(passenger);
+                }
+                planstock.put("passengers", passengers);
+                planstocks.add(planstock);
+            }
+        }
+
+        cruisePlan.put("planstocks", planstocks);
+        cruisePlans.add(cruisePlan);
+        order.put("cruisePlans", cruisePlans);
+
+        // 4. 三方订单号
+        order.put("externalOrderId", "" + System.currentTimeMillis());
+        return order;
+    }
+
+
+    // 构建单个乘客的修改请求
+    private JSONObject buildModOrder(TradeVisitorDTO visitor) {
+        JSONObject modifyData = new JSONObject();
+        modifyData.put("orderPassengerId", visitor.getExternalPassengerIdApi());
+        modifyData.put("name", visitor.getName());
+        modifyData.put("certType", visitor.getCertType());
+        modifyData.put("certNo", SM2Utils.encrypt(visitor.getCredentialNo(), sm2PublicKey));
+        modifyData.put("countryId", visitor.getNationalityId());
+        modifyData.put("phone", visitor.getMobile());
+
+        int sex = visitor.getGender() == null ? 1 : visitor.getGender();
+        modifyData.put("sex", sex == 1 ? "male" : "female");
+        modifyData.put("age", visitor.getAge());
+        modifyData.put("category", visitor.getCategory());
+        return modifyData;
+    }
+
+    /**
+     * 递归移除 JSON 中的 personCount 字段
+     */
+    private void removePersonCount(Object obj) {
+        if (obj instanceof JSONObject) {
+            JSONObject json = (JSONObject) obj;
+            json.remove("personCount");
+            for (Object value : json.values()) {
+                removePersonCount(value);
+            }
+        } else if (obj instanceof JSONArray) {
+            JSONArray array = (JSONArray) obj;
+            for (int i = 0; i < array.size(); i++) {
+                removePersonCount(array.get(i));
+            }
+        }
+    }
+
+    /**
+     * 补偿操作:当航次同步失败时,清理已创建的外部航次
+     *
+     * @param isAddOperation 是否为新增操作
+     * @param planId         外部航次ID
+     * @param task           同步任务记录
+     */
+    private void compensateOnError(boolean isAddOperation, String planId, SyncTaskDO task,String validToken) {
+        if (isAddOperation && StringUtils.isNotBlank(planId)) {
+            try {
+                log.info("开始执行补偿操作,清理外部航次,planId: {}", planId);
+                String compensateToken = getValidToken(task.getId());
+                List<String> delList = new ArrayList<>();
+                delList.add(planId);
+                boolean deleteResult = shipPlatformApi.deleteVoyages(task.getId(), delList, compensateToken);
+                if (deleteResult) {
+                    log.info("航次同步失败后清理成功,planId: {}", planId);
+                } else {
+                    log.error("航次同步失败后清理失败,planId: {},需要人工介入处理", planId);
+                }
+            } catch (Exception deleteException) {
+                log.error("航次同步失败后清理异常,planId: {},需要人工介入处理", planId, deleteException);
+            }
+        } else if (!isAddOperation && StringUtils.isNotBlank(planId)) {
+            try {
+                log.info("修改操作失败,恢复航次为开启状态,planId: {}", planId);
+                shipPlatformApi.batchEnablePlan(task.getId(), Lists.newArrayList(planId), validToken);
+                log.info("航次状态恢复成功,planId: {}", planId);
+            } catch (Exception enableException) {
+                log.error("航次状态恢复异常,planId: {},需要人工介入处理", planId, enableException);
+            }
+        } else {
+            log.warn("航次同步失败,跳过清理操作,isAddOperation: {}, planId: {}", isAddOperation, planId);
+        }
+    }
+
+    /**
+     * 处理未同步的乘客,为他们创建新订单
+     */
+    private void processUnsyncedVisitors(List<TradeVisitorDTO> unsyncedVisitors, String planId,
+                                         Long voyageId, Long taskId, String agentToken) {
+        log.info("开始为 {} 个未同步乘客创建新订单", unsyncedVisitors.size());
+        JSONObject order = new JSONObject();
+        // 1. 联系人
+        JSONObject contactMan = new JSONObject();
+        //获取航向
+        String voyageDirection = voyageMapper.getVoyageDirectionByVoyageId(voyageId);
+        if ("1".equals(voyageDirection)) {
+            contactMan.put("name", "罗利娅");
+            contactMan.put("phone", "15717896534");
+        } else {
+            contactMan.put("name", "汪子祎");
+            contactMan.put("phone", "18580594535");
+        }
+        order.put("contactMan", contactMan);
+
+        // 2. 航次信息
+        JSONArray cruisePlans = new JSONArray();
+        JSONObject cruisePlan = new JSONObject();
+        //        航次添加后返回的planid
+        cruisePlan.put("cruisePlanId", planId);
+        // 3. 按房型和票型分组处理未同步乘客
+        JSONArray planstocks = new JSONArray();
+        // 按房型名称和票型类型分组
+        Map<String, List<TradeVisitorDTO>> groupedVisitors = unsyncedVisitors.stream()
+                .filter(visitor -> visitor.getRoomModelName() != null && visitor.getType() != null)
+                .collect(Collectors.groupingBy(
+                        visitor -> visitor.getRoomModelName() + "_" + visitor.getType()
+                ));
+
+        // 为每个分组构建 planstock
+        for (Map.Entry<String, List<TradeVisitorDTO>> entry : groupedVisitors.entrySet()) {
+            String[] keys = entry.getKey().split("_");
+            String roomTypeName = keys[0];
+            String ticketTypeName = keys[1];
+            List<TradeVisitorDTO> visitors = entry.getValue();
+
+            if (visitors.isEmpty()) {
+                continue;
+            }
+
+            JSONObject planstock = new JSONObject();
+            planstock.put("num", visitors.size());
+            planstock.put("ticketName", ticketTypeName);
+            planstock.put("roomTypeName", roomTypeName);
+
+            // 构建乘客列表
+            JSONArray passengers = new JSONArray();
+            int no = 1;
+            for (TradeVisitorDTO visitor : visitors) {
+                JSONObject passenger = new JSONObject();
+                String name = visitor.getName();
+                if (name != null) {
+                    name = name.replaceAll("^\\p{javaWhitespace}+|\\p{javaWhitespace}+$", "");
+                }
+                passenger.put("name", name);
+                String certNo = visitor.getCredentialNo();
+                if (certNo == null || certNo.trim().isEmpty()) {
+                    throw new IllegalArgumentException("旅客证件号不能为空,旅客姓名:" + visitor.getName());
+                }
+                passenger.put("certNo", SM2Utils.encrypt(certNo, sm2PublicKey)); // 需SM2加密
+                passenger.put("phone", visitor.getMobile());     // 手机号
+                passenger.put("age", visitor.getAge());         // 年龄
+                passenger.put("isTransfer", visitor.getIsTransfer() == 1);// 是否接送
+
+
+                int sex = visitor.getGender() == null ? 1 : visitor.getGender();
+                passenger.put("sex", sex == 1 ? "male" : "female");
+                passenger.put("category", visitor.getCategory()); // 类别:aduit/kid
+                passenger.put("countryId", visitor.getNationalityId());   // 国家ID
+                passenger.put("certType", visitor.getCertType());     // 证件类型
+                passenger.put("gobackFlag", false);
+                passenger.put("groupSortNo", 1);
+                passenger.put("no", no++);
+                passenger.put("externalPassengerId", visitor.getId());  //游客id
+                passengers.add(passenger);
+
+            }
+            planstock.put("passengers", passengers);
+            planstocks.add(planstock);
+        }
+
+        cruisePlan.put("planstocks", planstocks);
+        cruisePlans.add(cruisePlan);
+        order.put("cruisePlans", cruisePlans);
+        // 4. 三方订单号
+        order.put("externalOrderId", "" + System.currentTimeMillis());
+        log.info("构建完成");;
+
+        System.out.println("processUnsyncedVisitors Order:" + order);
+
+        try {
+            JSONObject bookingOrderReturnJson = agentOrderApi.bookingOrder(taskId, order, agentToken);
+            log.info("代理商下单成功,planId: {}", planId);
+            if (bookingOrderReturnJson == null) {
+                throw new RuntimeException("代理商返回数据为空 ");
+            }
+//                将返回的乘客id 存起来,用于后续的更新
+            JSONArray tickets = bookingOrderReturnJson.getJSONArray("tickets");
+            if (tickets != null && !tickets.isEmpty()) {
+                List<TradeVisitorDTO> updateList = new ArrayList<>();
+                for (int i = 0; i < tickets.size(); i++) {
+                    JSONObject ticket = tickets.getJSONObject(i);
+                    String externalPassengerId = ticket.getString("externalPassengerId");
+                    String orderPassengerId = ticket.getString("orderPassengerId");
+
+                    if (StringUtils.isBlank(externalPassengerId)) {
+                        continue;
+                    }
+                    TradeVisitorDTO dto = new TradeVisitorDTO();
+                    dto.setId(Long.valueOf(externalPassengerId));
+                    dto.setExternalPassengerIdApi(orderPassengerId);
+                    updateList.add(dto);
+                }
+                if (!updateList.isEmpty()) {
+                    voyageMapper.batchUpdateExternalPassengerId(updateList);
+                    log.info("批量更新乘客外部ID成功,数量: {}", updateList.size());
+                }
+            }
+        } catch (Exception e) {
+            log.error("代理商下单异常,planId: {}", planId, e);
+        }
+    }
+
 }

+ 363 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/utils/SM2Utils.java

@@ -0,0 +1,363 @@
+package com.yc.ship.module.product.utils;
+
+import org.bouncycastle.asn1.gm.GMNamedCurves;
+import org.bouncycastle.asn1.x9.X9ECParameters;
+import org.bouncycastle.crypto.CipherParameters;
+import org.bouncycastle.crypto.engines.SM2Engine;
+import org.bouncycastle.crypto.params.ECDomainParameters;
+import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
+import org.bouncycastle.crypto.params.ECPublicKeyParameters;
+import org.bouncycastle.crypto.params.ParametersWithRandom;
+import org.bouncycastle.crypto.signers.SM2Signer;
+import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey;
+import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.jce.spec.ECParameterSpec;
+import org.bouncycastle.jce.spec.ECPrivateKeySpec;
+import org.bouncycastle.math.ec.ECPoint;
+import org.bouncycastle.util.encoders.Hex;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.*;
+import java.security.spec.ECGenParameterSpec;
+import java.util.Base64;
+
+/**
+ * SM2工具类
+ * @author van
+ */
+public class SM2Utils {
+
+    /**
+     * 生成 SM2 公私钥对
+     *
+     * @return
+     * @throws NoSuchAlgorithmException
+     * @throws InvalidAlgorithmParameterException
+     */
+    public static KeyPair geneSM2KeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException {
+        final ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1");
+        // 获取一个椭圆曲线类型的密钥对生成器
+        final KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", new BouncyCastleProvider());
+        // 产生随机数
+        SecureRandom secureRandom = new SecureRandom();
+        // 使用SM2参数初始化生成器
+        kpg.initialize(sm2Spec, secureRandom);
+        // 获取密钥对
+        KeyPair keyPair = kpg.generateKeyPair();
+        return keyPair;
+    }
+
+    /**
+     * 生产hex秘钥对
+     */
+    public static void geneSM2HexKeyPair(){
+        try {
+            KeyPair keyPair = geneSM2KeyPair();
+            PrivateKey privateKey = keyPair.getPrivate();
+            PublicKey publicKey = keyPair.getPublic();
+            System.out.println("========  EC X Y keyPair    ========");
+            System.out.println(privateKey);
+            System.out.println(publicKey);
+            System.out.println("========  hex keyPair       ========");
+            System.out.println("hex priKey: " + getPriKeyHexString(privateKey));
+            System.out.println("hex pubKey: " + getPubKeyHexString(publicKey));
+            System.out.println("========  base64 keyPair    ========");
+            System.out.println("base64 priKey: " + new String(Base64.getEncoder().encode(privateKey.getEncoded())));
+            System.out.println("base64 pubKey: " + new String(Base64.getEncoder().encode(publicKey.getEncoded())));
+            System.out.println("========  generate finished ========");
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+    /**
+     * 获取私钥(16进制字符串,头部不带00长度共64)
+     *
+     * @param privateKey 私钥PrivateKey型
+     * @return
+     */
+    public static String getPriKeyHexString(PrivateKey privateKey) {
+        // OK
+//        BCECPrivateKey s=(BCECPrivateKey)privateKey;
+//        String priKeyHexString = Hex.toHexString(s.getD().toByteArray());
+//        if(null!= priKeyHexString && priKeyHexString.length()==66 && "00".equals(priKeyHexString.substring(0,2))){
+//            return priKeyHexString.substring(2);
+//        }
+        // OK
+        BCECPrivateKey key = (BCECPrivateKey) privateKey;
+        BigInteger intPrivateKey = key.getD();
+        String priKeyHexString = intPrivateKey.toString(16);
+        return priKeyHexString;
+    }
+    /**
+     * 获取私钥 base64字符串
+     *
+     * @param privateKey 私钥PrivateKey型
+     * @return
+     */
+    public static String getPriKeyBase64String(PrivateKey privateKey) {
+        return new String(Base64.getEncoder().encode(privateKey.getEncoded()));
+    }
+
+    /**
+     * 获取公钥(16进制字符串,头部带04长度共130)
+     *
+     * @param publicKey 公钥PublicKey型
+     * @return
+     */
+    public static String getPubKeyHexString(PublicKey publicKey) {
+        BCECPublicKey key = (BCECPublicKey) publicKey;
+        return Hex.toHexString(key.getQ().getEncoded(false));
+    }
+    /**
+     * 获取公钥 base64字符串
+     *
+     * @param publicKey 公钥PublicKey型
+     * @return
+     */
+    public static String getPubKeyBase64String(PublicKey publicKey) {
+        return new String(Base64.getEncoder().encode(publicKey.getEncoded()));
+    }
+
+    /**
+     * SM2加密算法
+     *
+     * @param publicKey 公钥
+     * @param data      明文数据
+     * @return
+     */
+    public static String encrypt(String data, PublicKey publicKey) {
+        return encrypt(data.getBytes(StandardCharsets.UTF_8), publicKey);
+    }
+
+    public static String encrypt(byte[] data, PublicKey publicKey) {
+        BCECPublicKey key = (BCECPublicKey) publicKey;
+        return encrypt(data, Hex.toHexString(key.getQ().getEncoded(false)));
+    }
+
+    public static String encrypt(String data, String pubKeyHexString) {
+        return encrypt(data.getBytes(StandardCharsets.UTF_8), pubKeyHexString);
+    }
+
+    /**
+     * SM2加密算法
+     *
+     * @param pubKeyHexString 公钥(16进制字符串)
+     * @param data            明文数据
+     * @return hex字符串
+     */
+    public static String encrypt(byte[] data, String pubKeyHexString) {
+        // 获取一条SM2曲线参数
+        X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
+        // 构造ECC算法参数,曲线方程、椭圆曲线G点、大整数N
+        ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
+        //提取公钥点
+        ECPoint pukPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(pubKeyHexString));
+        // 公钥前面的02或者03表示是压缩公钥,04表示未压缩公钥, 04的时候,可以去掉前面的04
+        ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(pukPoint, domainParameters);
+
+        SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
+        // 设置sm2为加密模式
+        sm2Engine.init(true, new ParametersWithRandom(publicKeyParameters, new SecureRandom()));
+
+        byte[] arrayOfBytes = null;
+        try {
+            arrayOfBytes = sm2Engine.processBlock(data, 0, data.length);
+        } catch (Exception e) {
+            System.out.println("SM2加密时出现异常:" + e.getMessage());
+        }
+        return Hex.toHexString(arrayOfBytes);
+
+    }
+
+    /**
+     * SM2解密算法
+     * @param cipherData    hex格式密文
+     * @param privateKey    密钥PrivateKey型
+     * @return              明文
+     */
+    public static String decrypt(String cipherData, PrivateKey privateKey) {
+        return decrypt(Hex.decode(cipherData), privateKey);
+    }
+
+    public static String decrypt(byte[] cipherData, PrivateKey privateKey) {
+        BCECPrivateKey key = (BCECPrivateKey) privateKey;
+        return decrypt(cipherData, Hex.toHexString(key.getD().toByteArray()));
+    }
+
+    public static String decrypt(String cipherData, String priKeyHexString) {
+        // 使用BC库加解密时密文以04开头,传入的密文前面没有04则补上
+        if (!cipherData.startsWith("04")) {
+            cipherData = "04" + cipherData;
+        }
+        return decrypt(Hex.decode(cipherData), priKeyHexString);
+    }
+
+    /**
+     * SM2解密算法
+     *
+     * @param cipherData      密文数据
+     * @param priKeyHexString 私钥(16进制字符串)
+     * @return
+     */
+    public static String decrypt(byte[] cipherData, String priKeyHexString) {
+        //获取一条SM2曲线参数
+        X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
+        //构造domain参数
+        ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
+
+        BigInteger privateKeyD = new BigInteger(priKeyHexString, 16);
+        ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(privateKeyD, domainParameters);
+
+        SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
+        // 设置sm2为解密模式
+        sm2Engine.init(false, privateKeyParameters);
+
+        String result = "";
+        try {
+            byte[] arrayOfBytes = sm2Engine.processBlock(cipherData, 0, cipherData.length);
+            return new String(arrayOfBytes);
+        } catch (Exception e) {
+            System.out.println("SM2解密时出现异常:" + e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * @param data
+     * @param priKeyHexString hex私钥,长度64
+     * @return hex格式签名值
+     * @throws Exception
+     */
+    public static String sign(String data, String priKeyHexString) throws Exception {
+        return sign(data.getBytes(StandardCharsets.UTF_8), priKeyHexString);
+    }
+
+    /**
+     * 签名
+     * @param data              原始数据,字节数组
+     * @param priKeyHexString   hex私钥,64长度
+     * @return                  Hex字符串
+     * @throws Exception
+     */
+    public static String sign(byte[] data, String priKeyHexString) throws Exception {
+        String signValue = null;
+        SM2Signer signer = new SM2Signer();
+        X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
+        //构造domain参数
+        ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
+        CipherParameters param = new ParametersWithRandom(new ECPrivateKeyParameters(new BigInteger(priKeyHexString, 16), domainParameters));
+        signer.init(true, param);
+        signer.update(data, 0, data.length);
+        signValue = Hex.toHexString(signer.generateSignature());
+        return signValue;
+    }
+
+    /**
+     * 验签
+     * @param data                  原始数据
+     * @param signValue             原始签名值(hex型)
+     * @param publicKeyHexString    hex130长度公钥
+     * @return                      ture or false
+     * @throws Exception
+     */
+    public static boolean verify(String data, String signValue, String publicKeyHexString) throws Exception {
+        return verify(data.getBytes(StandardCharsets.UTF_8), Hex.decode(signValue), publicKeyHexString);
+    }
+
+    /**
+     * 验签
+     * @param data                  原始数据字节数组
+     * @param sign                  字节数组()
+     * @param publicKeyHexString    hex130长度公钥
+     * @return                      true or false
+     * @throws Exception
+     */
+    public static boolean verify(byte[] data, byte[] sign, String publicKeyHexString) throws Exception {
+        SM2Signer signer = new SM2Signer();
+        X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
+        //构造domain参数
+        ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
+        if (publicKeyHexString.length() == 128) {
+            publicKeyHexString = "04" + publicKeyHexString;
+        }
+        ECPoint ecPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(publicKeyHexString));
+        CipherParameters param = new ECPublicKeyParameters(ecPoint, domainParameters);
+        signer.init(false, param);
+        signer.update(data, 0, data.length);
+        return signer.verifySignature(sign);
+    }
+
+    /**
+     * 私钥生成公钥
+     * @param priKeyHexString 私钥Hex格式,必须64位
+     * @return 公钥Hex格式,04开头,130位
+     * @throws Exception 例如:
+     *                   04181db7fe400641115c0dec08e23d8ddb94c5999f2fb6efd03030780142e077a63eb4d47947ef5baee7f40fec2c29181d2a714d9c6cba87b582f252a4e3e9a9f8
+     *                   11d0a44d47449d48d614f753ded6b06af76033b9c3a2af2b8b2239374ccbce3a
+     */
+    public static String getPubKeyByPriKey(String priKeyHexString) throws Exception {
+        if (priKeyHexString == null || priKeyHexString.length() != 64) {
+            System.err.println("priKey 必须是Hex 64位格式,例如:11d0a44d47449d48d614f753ded6b06af76033b9c3a2af2b8b2239374ccbce3a");
+            return "";
+        }
+        String pubKeyHexString = null;
+        X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
+        //构造domain参数
+        BigInteger privateKeyD = new BigInteger(priKeyHexString, 16);
+
+        ECParameterSpec ecParameterSpec = new ECParameterSpec(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
+        ECPrivateKeySpec ecPrivateKeySpec = new ECPrivateKeySpec(privateKeyD, ecParameterSpec);
+        PrivateKey privateKey = null;
+        privateKey = KeyFactory.getInstance("EC", new BouncyCastleProvider()).generatePrivate(ecPrivateKeySpec);
+
+        // 临时解决办法
+        String pointString = privateKey.toString();
+//        System.out.println(pointString);
+        String pointString_X = pointString.substring(pointString.indexOf("X: ") + "X: ".length(), pointString.indexOf("Y: ")).trim();
+        String pointString_Y = pointString.substring(pointString.indexOf("Y: ") + "Y: ".length()).trim();
+//        System.out.println(pointString_X);
+//        System.out.println(pointString_Y);
+
+        pubKeyHexString = "04" + pointString_X + pointString_Y;
+        return pubKeyHexString;
+
+    }
+
+    public static void main(String[] args) throws Exception {
+        System.out.println("======  sm2utils test  ======");
+
+        String M = "data_message";
+        System.out.println("mingwen\t" + M);
+
+        System.out.println("begin 开始生成密钥对>>>");
+        KeyPair keyPair = geneSM2KeyPair();
+
+        PublicKey publicKey = keyPair.getPublic();
+        String pubKeyHexString = getPubKeyHexString(publicKey);
+        System.out.println("publicKey\t" + pubKeyHexString);
+
+        PrivateKey privateKey = keyPair.getPrivate();
+        String priKeyHexString = getPriKeyHexString(privateKey);
+        System.out.println("privateKey\t" + priKeyHexString);
+        System.out.println("end   结束生成密钥对>>>");
+
+        priKeyHexString="4f0341e5977b175e0ec0e1fdefd2799b13dd25c51716133ef42ba76c0edd973d"; //1
+        pubKeyHexString="04e523210b3407464839723558e0d82765b9e2cac9491bd86c99c89b9fc43fbe9a94395ee3138dbc4ae43daa8fe01fd512de8568102e34c66989eb2b306611b518"; //1
+
+        String cipherData = encrypt(M, pubKeyHexString);
+        System.out.println("miwen\t" + cipherData);
+
+        String text = decrypt(cipherData, priKeyHexString);
+        System.out.println("jiemi\t" + text);
+
+        String sign = sign(M, priKeyHexString);
+        System.out.println("signvalue\t" + sign);
+        sign="304402204bbd4b026f58f1e072c2ab1f736a730ed5c2f6773ef4855df5e87f9ea54f14be02205e9b6146b5273e6f37fe6d9d8f059bc46f7042a10da224130a4e0ba8619d967c";
+
+        boolean verifyResult = verify(M, sign, pubKeyHexString);
+        System.out.println("verifyResult\t" + verifyResult);
+
+    }
+}

+ 49 - 0
ship-module-product/ship-module-product-biz/src/main/java/com/yc/ship/module/product/utils/TicketTypeUtil.java

@@ -0,0 +1,49 @@
+package com.yc.ship.module.product.utils;
+
+import java.util.*;
+
+/*
+* 用于将票型映射为对应英文
+*
+* */
+public class TicketTypeUtil {
+    private static final Map<String, List<String>> TYPE_MAP;
+
+    static {
+        Map<String, List<String>> map = new HashMap<>();
+        map.put("成人票", Arrays.asList("adultPlus", "adultTake"));
+        map.put("儿童票", Arrays.asList("babyTake", "babyPlus", "babyNonTake", "childTake", "childPlus", "childNonTake"));
+        map.put("陪同票", Arrays.asList("with"));
+        map.put("领队票", Arrays.asList("leader"));
+        TYPE_MAP = Collections.unmodifiableMap(map);
+    }
+
+    /**
+     * 票型名称 -> 数据库类型列表
+     * @throws IllegalArgumentException 未知票型
+     */
+    public static List<String> toDbTypes(String ticketTypeName, String roomTypeName, Long voyageId) {
+        if (ticketTypeName == null || ticketTypeName.trim().isEmpty()) {
+            StringBuilder errorMsg = new StringBuilder("票型名称不能为空");
+            if (roomTypeName != null) {
+                errorMsg.append(",房型:").append(roomTypeName);
+            }
+            if (voyageId != null) {
+                errorMsg.append(",航次ID:").append(voyageId);
+            }
+            throw new IllegalArgumentException(errorMsg.toString());
+        }
+        List<String> dbTypes = TYPE_MAP.get(ticketTypeName);
+        if (dbTypes == null) {
+            StringBuilder errorMsg = new StringBuilder("未知票型: " + ticketTypeName + ",有效值为: " + TYPE_MAP.keySet());
+            if (roomTypeName != null) {
+                errorMsg.append(",房型:").append(roomTypeName);
+            }
+            if (voyageId != null) {
+                errorMsg.append(",航次ID:").append(voyageId);
+            }
+            throw new IllegalArgumentException(errorMsg.toString());
+        }
+        return dbTypes;
+    }
+}

+ 159 - 18
ship-module-product/ship-module-product-biz/src/main/resources/mapper/voyage/VoyageMapper.xml

@@ -9,8 +9,8 @@
         文档可见:https://www.iocoder.cn/MyBatis/x-plugins/
      -->
     <select id="selectVovageList" resultType="com.yc.ship.module.product.dal.dataobject.voyage.VoyageDO">
-         select  w.*,r.direction from product_voyage w inner join resource_route r on w.route_id = r.id
-         where w.deleted = 0 and r.deleted = 0 and w.ship_id = #{shipId} and w.start_time > DATE_SUB(now(), INTERVAL 1 MONTH) and w.shelf_status = 1 and w.channel like '%1%' order by w.create_time asc;
+        select  w.*,r.direction from product_voyage w inner join resource_route r on w.route_id = r.id
+        where w.deleted = 0 and r.deleted = 0 and w.ship_id = #{shipId} and w.start_time > DATE_SUB(now(), INTERVAL 1 MONTH) and w.shelf_status = 1 and w.channel like '%1%' order by w.create_time asc;
     </select>
     <select id="selectListCalendar2" resultType="com.yc.ship.module.product.dal.dataobject.voyage.VoyageDO">
         select w.*,r.price basicPrice from product_voyage w inner join resource_route r on w.route_id = r.id
@@ -30,14 +30,14 @@
         <if test="vo.channel != null and vo.channel != ''">
             and w.channel like concat('%',#{vo.channel},'%')
         </if>
-         and w.shelf_status = 1 order by w.start_time asc;
+        and w.shelf_status = 1 order by w.start_time asc;
     </select>
     <select id="selectVoyageListByShipIdAndDate"
             resultType="com.yc.ship.module.product.controller.app.voyage.vo.AppVoyageDayRespVO">
         select w.id voyage_id, w.name as voyage_name,
-               s.id ship_id, s.name as ship_name, s.short_name as ship_short_name,
-               r.id route_id, r.name as route_name, r.short_name as route_short_name,
-               r.duration, r.price as route_price, r.direction
+        s.id ship_id, s.name as ship_name, s.short_name as ship_short_name,
+        r.id route_id, r.name as route_name, r.short_name as route_short_name,
+        r.duration, r.price as route_price, r.direction
         from product_voyage w
         inner join resource_ship s on w.ship_id = s.id
         inner join resource_route r on w.route_id = r.id
@@ -45,14 +45,14 @@
         where w.deleted = 0 and r.deleted = 0 and s.deleted = 0 and t.deleted = 0
         and w.channel like '%2%'
         and w.start_time > now()
-          <if test="shipId != null and shipId != ''">
-              and w.ship_id = #{shipId}
-          </if>
-          <if test="date != null and date != ''">
-              and w.start_time >= #{date}
-              and w.start_time &lt;= CONCAT(#{date},' 23:59:59')
-          </if>
-         and w.shelf_status = 1
+        <if test="shipId != null and shipId != ''">
+            and w.ship_id = #{shipId}
+        </if>
+        <if test="date != null and date != ''">
+            and w.start_time >= #{date}
+            and w.start_time &lt;= CONCAT(#{date},' 23:59:59')
+        </if>
+        and w.shelf_status = 1
         order by w.start_time asc
     </select>
 
@@ -66,16 +66,157 @@
             #{voyageId}
         </foreach>
         and tv.deleted = 0
-        and b.order_status = 6
+        and b.order_status in (1,6,13,14)
         group by b.voyage_id
     </select>
 
+
+    <select id="getTicketInfoByRoomType" resultType="com.yc.ship.module.product.dal.dataobject.voyage.TicketInfoDo">
+        SELECT
+            ticketTypeName,
+            MAX(childFlag) AS childFlag,
+            ROUND(AVG(publicPrice), 2) AS publicPrice,
+            ROUND(AVG(price), 2) AS price,
+            COUNT(*) AS personCount
+        FROM (
+                 SELECT
+                     CASE
+                         WHEN tv.type IN ('adultPlus', 'adultTake') THEN '成人票'
+                         WHEN tv.type IN ('babyTake', 'babyPlus', 'babyNonTake', 'childTake', 'childPlus', 'childNonTake') THEN '儿童票'
+                         WHEN tv.type = 'with' THEN '陪同票'
+                         WHEN tv.type = 'leader' THEN '领队票'
+                         ELSE '其他'
+                         END AS ticketTypeName,
+                     CASE
+                         WHEN tv.type IN ('babyTake', 'babyPlus', 'babyNonTake', 'childTake', 'childPlus', 'childNonTake') THEN 1
+                         ELSE 0
+                         END AS childFlag,
+                     tro.amount AS publicPrice,
+                     tro.pay_amount AS price
+                 FROM trade_visitor tv
+                          JOIN trade_order tro ON tro.id = tv.order_id
+                 WHERE tro.voyage_id = #{voyageId}
+                   AND tv.room_model_id = #{roomTypeCode}
+                   AND tv.type IS NOT NULL
+                   AND tro.deleted = 0      -- 订单未删除
+                   AND tv.deleted = 0       -- 游客未删除
+                   AND tro.order_status in (1,6,13,14)
+             ) t
+        GROUP BY ticketTypeName
+    </select>
+
+    <select id="getPassengerData" resultType="com.yc.ship.module.product.dal.dataobject.voyage.OrderVisitorDo">
+        SELECT
+        tv.id,
+        tv.order_id,
+        tro.order_no as orderNo,
+        tv.external_passenger_id_api as externalPassengerIdApi,
+        tv.name ,tv.credential_no as certNo ,tv.mobile as phone,
+        tv.age as age,IF(COALESCE(tv.yczz, 0) = 1 OR COALESCE(tv.cqzz, 0) = 1, 1, 0) AS isTransfer,
+        COALESCE(tv.gender, 0) AS sex,
+        CASE
+        WHEN tv.age >= 12 THEN 'adult'
+        WHEN tv.age &lt; 12 THEN 'kid'
+        ELSE 'adult'
+        END AS category,
+        (select id from national_api_country_id where name=(select name from area where id=tv.nationality)) as countryId,
+        (
+        SELECT
+        CASE
+        WHEN tv.credential_type = '0' THEN '身份证'
+        WHEN tv.credential_type = '2' THEN '外国人永久居留身份证'
+        WHEN tv.credential_type = '7' THEN '港澳居民来往内地通行证'
+        WHEN tv.credential_type = '8' THEN '台湾居民往来通行证'
+        WHEN tv.credential_type = '9' THEN '普通护照'
+        WHEN tv.credential_type = '11' THEN '港澳台居民居住证'
+        ELSE '未知'
+        END
+        FROM system_dict_data d
+        WHERE d.dict_type = 'credential_type'
+        AND d.deleted = 0
+        AND d.value = tv.credential_type
+        ) as certType
+        FROM `trade_order` tro
+        INNER JOIN `trade_visitor` tv ON tro.id = tv.order_id
+        INNER JOIN `resource_room_model` rrm ON rrm.id = tv.room_model_id
+        WHERE tro.voyage_id = #{voyageId}
+        AND tv.type IN
+        <foreach collection="typeList" item="type" open="(" separator="," close=")">
+            #{type}
+        </foreach>
+        AND rrm.name = #{roomTypeName}
+        AND tro.deleted = 0      -- 订单未删除
+        AND tv.deleted = 0       -- 游客未删除
+        AND tro.order_status in (1,6,13,14)
+    </select>
+
+
+    <select id="getLocalVisitorsByVoyageId" resultType="com.yc.ship.module.product.api.dto.TradeVisitorDTO">
+        select tv.id, tv.external_passenger_id_api as externalPassengerIdApi,tv.credential_no as credentialNo ,
+               (select id from national_api_country_id where name=(select name from area where id=tv.nationality)) as nationalityId,
+               IF(COALESCE(tv.yczz, 0) = 1 OR COALESCE(tv.cqzz, 0) = 1, 1, 0) AS isTransfer,
+               tv.name,
+               (SELECT
+                    CASE
+                        WHEN tv.credential_type = '0' THEN '身份证'
+                        WHEN tv.credential_type = '2' THEN '外国人永久居留身份证'
+                        WHEN tv.credential_type = '7' THEN '港澳居民来往内地通行证'
+                        WHEN tv.credential_type = '8' THEN '台湾居民往来通行证'
+                        WHEN tv.credential_type = '9' THEN '普通护照'
+                        WHEN tv.credential_type = '11' THEN '港澳台居民居住证'
+                        ELSE '未知'
+                        END
+                FROM system_dict_data d
+                WHERE d.dict_type = 'credential_type'
+                  AND d.deleted = 0
+                  AND d.value = tv.credential_type
+               ) as certType,
+               tv.credential_no,
+               tv.mobile as mobile,
+               COALESCE(tv.gender, 0) AS gender,
+               tv.age as age,
+               CASE
+                   WHEN tv.age >= 12 THEN 'adult'
+                   WHEN tv.age &lt; 12 THEN 'kid'
+                   ELSE 'adult'
+                   END AS category,
+               CASE
+                   WHEN tv.type IN ('adultPlus', 'adultTake') THEN '成人票'
+                   WHEN tv.type IN ('babyTake', 'babyPlus', 'babyNonTake', 'childTake', 'childPlus', 'childNonTake') THEN '儿童票'
+                   WHEN tv.type = 'with' THEN '陪同票'
+                   WHEN tv.type = 'leader' THEN '领队票'
+                   ELSE '其他'
+                   END AS type,
+               tv.room_model_id as roomModelId,
+               rrm.name as roomModelName
+
+        from trade_visitor tv
+                 inner join trade_order tro on tro.id=tv.order_id
+                 left join resource_room_model rrm on rrm.id = tv.room_model_id
+        WHERE tro.voyage_id=#{voyageId}
+          and tv.room_model_id is not null
+          and tro.deleted = 0
+          and tv.deleted = 0
+          and tro.order_status in (1,6,13,14)
+    </select>
+    <update id="batchUpdateExternalPassengerId" parameterType="list">
+        UPDATE trade_visitor
+        SET external_passenger_id_api = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.externalPassengerIdApi}
+        </foreach>
+        END
+        WHERE id IN
+        <foreach collection="list" item="item" open="(" close=")" separator=",">
+            #{item.id}
+        </foreach>
+    </update>
+
     <select id="queryOrderVoyage" resultType="java.util.Map">
         select distinct pv.boarding_address,pv.id voyageId from trade_order td
-            LEFT JOIN product_voyage pv ON td.voyage_id = pv.id
-            LEFT JOIN resource_route rr ON pv.route_id = rr.id
+                                                                    LEFT JOIN product_voyage pv ON td.voyage_id = pv.id
+                                                                    LEFT JOIN resource_route rr ON pv.route_id = rr.id
         where DATE_FORMAT(td.travel_date, '%Y-%m-%d') = DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL (-#{day}) DAY), '%Y-%m-%d')
           and td.deleted='0' and  td.order_status in (0,1,2,3,6,7,8,9,12,13,14,15) and rr.direction = #{direction}
     </select>
-
 </mapper>

+ 4 - 1
ship-module-resource/ship-module-resource-biz/src/main/java/com/yc/ship/module/resource/dal/dataobject/dock/ResourceDockDO.java

@@ -84,5 +84,8 @@ public class ResourceDockDO extends TenantBaseDO {
      * 枚举 {@link TODO common_status 对应的类}
      */
     private Integer status;
-
+    /**
+     * 外部码头id
+     */
+    private String externalDockId;
 }

+ 12 - 1
ship-module-resource/ship-module-resource-biz/src/main/java/com/yc/ship/module/resource/dal/dataobject/route/ResourceRouteDO.java

@@ -155,5 +155,16 @@ public class ResourceRouteDO extends BaseDO {
     @Schema(description = "航线行程")
     @TableField(exist = false)
     private List<ResourceRouteTripDO> routeTrips;
-
+    /**
+     * 开始港口id
+     */
+    private String startPortId;
+    /**
+     * 结束港口id
+     */
+    private String endPortId;
+    /**
+     * 外部航线id
+     */
+    private String externalRouteId;
 }

+ 0 - 4
ship-module-trade/ship-module-trade-biz/src/main/java/com/yc/ship/module/trade/service/order/impl/TradeOrderServiceImpl.java

@@ -183,7 +183,6 @@ public class TradeOrderServiceImpl implements TradeOrderService {
         }
         //当前用户角色
         List<Long> currentRoleList = roleApi.getCurrentRoleList();
-        log.info("查询TradeOrder任务开始时间{}", LocalDateTime.now());
         List<Long> orderIds = tradeOrderMapper.findOrderIdsByCondition(page, pageReqVO);
         List<TradeOrderRespVO> records = new ArrayList<>();
         if(!orderIds.isEmpty()){
@@ -192,10 +191,7 @@ public class TradeOrderServiceImpl implements TradeOrderService {
 
         IPage<TradeOrderRespVO> iPage = page.setRecords( records);
 //        IPage<TradeOrderRespVO> iPage = tradeOrderMapper.getTradeOrderUserPage(page, pageReqVO);
-        log.info("查询TradeOrder任务结束时间{}", LocalDateTime.now());
-        log.info("查询AdminUser任务开始时间{}", LocalDateTime.now());
         List<AdminUserRespDTO> userList = adminUserApi.getUserList();
-        log.info("查询AdminUser任务结束时间{}", LocalDateTime.now());
         Map<Long, String> userMap = new HashMap<>(userList.stream().collect(Collectors.toMap(AdminUserRespDTO::getId, AdminUserRespDTO::getNickname)));
         List<AdminRoleRespDTO> roleList = roleApi.getRoleList();
         Map<Long, String> roleMap = new HashMap<>(roleList.stream().collect(Collectors.toMap(AdminRoleRespDTO::getId, AdminRoleRespDTO::getName)));