Jelajahi Sumber

手动外呼查询通话记录信息

lmx 3 hari lalu
induk
melakukan
5c58caf680

+ 234 - 0
fs-company/src/main/java/com/fs/company/controller/common/RecordingProxyController.java

@@ -0,0 +1,234 @@
+package com.fs.company.controller.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 录音文件代理接口
+ * 解决 HTTPS 页面下无法直接访问 HTTP 录音资源的问题
+ */
+@RestController
+public class RecordingProxyController {
+
+    private static final Logger log = LoggerFactory.getLogger(RecordingProxyController.class);
+
+    /**
+     * 允许代理的录音服务器地址白名单
+     */
+    private static final List<String> ALLOWED_HOSTS = Arrays.asList(
+            "129.28.164.235:8899"
+    );
+
+    /**
+     * 允许代理的路径前缀
+     */
+    private static final String ALLOWED_PATH_PREFIX = "/recordings/";
+
+    /**
+     * 流式缓冲区大小
+     */
+    private static final int BUFFER_SIZE = 4096;
+
+    /**
+     * 连接超时时间(毫秒)
+     */
+    private static final int CONNECT_TIMEOUT = 5000;
+
+    /**
+     * 读取超时时间(毫秒)
+     */
+    private static final int READ_TIMEOUT = 30000;
+
+    /**
+     * 录音文件代理接口
+     * 前端通过此接口请求录音文件,后端转发到录音服务器获取文件流
+     *
+     * @param url      录音文件的原始 HTTP 地址
+     * @param request  HTTP 请求
+     * @param response HTTP 响应
+     */
+    @GetMapping("/common/proxy/recording")
+    public void proxyRecording(@RequestParam("url") String url,
+                               HttpServletRequest request,
+                               HttpServletResponse response) {
+        // 1. 安全校验
+        if (!isUrlAllowed(url)) {
+            log.warn("录音代理请求被拒绝,非法地址: {}", url);
+            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+            try {
+                response.getWriter().write("Access denied: URL not allowed");
+            } catch (IOException e) {
+                log.error("写入错误响应失败", e);
+            }
+            return;
+        }
+
+        HttpURLConnection connection = null;
+        InputStream inputStream = null;
+        OutputStream outputStream = null;
+
+        try {
+            // 2. 建立到录音服务器的连接
+            URL targetUrl = new URL(url);
+            connection = (HttpURLConnection) targetUrl.openConnection();
+            connection.setRequestMethod("GET");
+            connection.setConnectTimeout(CONNECT_TIMEOUT);
+            connection.setReadTimeout(READ_TIMEOUT);
+            connection.setInstanceFollowRedirects(true);
+
+            // 3. 转发 Range 请求头(支持音频播放器拖动进度条)
+            String rangeHeader = request.getHeader("Range");
+            if (rangeHeader != null && !rangeHeader.isEmpty()) {
+                connection.setRequestProperty("Range", rangeHeader);
+            }
+
+            // 4. 发起请求
+            int responseCode = connection.getResponseCode();
+
+            // 5. 处理错误响应
+            if (responseCode >= 400) {
+                log.error("录音服务器返回错误状态码: {}, url: {}", responseCode, url);
+                response.setStatus(responseCode);
+                return;
+            }
+
+            // 6. 设置响应状态码(200 或 206)
+            response.setStatus(responseCode);
+
+            // 7. 设置 Content-Type
+            String contentType = connection.getContentType();
+            if (contentType != null && !contentType.isEmpty()) {
+                response.setContentType(contentType);
+            } else {
+                // 根据文件扩展名推断 Content-Type
+                response.setContentType(guessContentType(url));
+            }
+
+            // 8. 转发关键响应头
+            String contentLength = connection.getHeaderField("Content-Length");
+            if (contentLength != null) {
+                response.setHeader("Content-Length", contentLength);
+            }
+
+            String contentRange = connection.getHeaderField("Content-Range");
+            if (contentRange != null) {
+                response.setHeader("Content-Range", contentRange);
+            }
+
+            String acceptRanges = connection.getHeaderField("Accept-Ranges");
+            if (acceptRanges != null) {
+                response.setHeader("Accept-Ranges", acceptRanges);
+            } else {
+                response.setHeader("Accept-Ranges", "bytes");
+            }
+
+            // 9. 流式传输文件内容
+            inputStream = connection.getInputStream();
+            outputStream = response.getOutputStream();
+            byte[] buffer = new byte[BUFFER_SIZE];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, bytesRead);
+            }
+            outputStream.flush();
+
+        } catch (IOException e) {
+            log.error("录音文件代理请求失败, url: {}", url, e);
+            if (!response.isCommitted()) {
+                response.setStatus(HttpServletResponse.SC_BAD_GATEWAY);
+            }
+        } finally {
+            if (inputStream != null) {
+                try {
+                    inputStream.close();
+                } catch (IOException e) {
+                    log.error("关闭输入流失败", e);
+                }
+            }
+            if (outputStream != null) {
+                try {
+                    outputStream.close();
+                } catch (IOException e) {
+                    log.error("关闭输出流失败", e);
+                }
+            }
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    /**
+     * 校验 URL 是否在允许的白名单范围内
+     *
+     * @param url 待校验的 URL
+     * @return 是否允许代理
+     */
+    private boolean isUrlAllowed(String url) {
+        if (url == null || url.isEmpty()) {
+            return false;
+        }
+
+        // 必须是 http 协议
+        if (!url.startsWith("http://") && !url.startsWith("https://")) {
+            return false;
+        }
+
+        try {
+            URL parsedUrl = new URL(url);
+            String host = parsedUrl.getHost();
+            int port = parsedUrl.getPort();
+            String hostWithPort = port > 0 ? host + ":" + port : host;
+            String path = parsedUrl.getPath();
+
+            // 校验主机地址是否在白名单内
+            boolean hostAllowed = ALLOWED_HOSTS.contains(hostWithPort);
+
+            // 校验路径是否包含允许的前缀
+            boolean pathAllowed = path != null && path.startsWith(ALLOWED_PATH_PREFIX);
+
+            return hostAllowed && pathAllowed;
+        } catch (Exception e) {
+            log.warn("URL 解析失败: {}", url, e);
+            return false;
+        }
+    }
+
+    /**
+     * 根据文件扩展名推断 Content-Type
+     *
+     * @param url 文件 URL
+     * @return Content-Type
+     */
+    private String guessContentType(String url) {
+        if (url == null) {
+            return "application/octet-stream";
+        }
+        String lowerUrl = url.toLowerCase();
+        if (lowerUrl.contains(".wav")) {
+            return "audio/wav";
+        } else if (lowerUrl.contains(".mp3")) {
+            return "audio/mpeg";
+        } else if (lowerUrl.contains(".ogg")) {
+            return "audio/ogg";
+        } else if (lowerUrl.contains(".flac")) {
+            return "audio/flac";
+        } else if (lowerUrl.contains(".m4a") || lowerUrl.contains(".aac")) {
+            return "audio/aac";
+        }
+        return "application/octet-stream";
+    }
+}

+ 1 - 0
fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -119,6 +119,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/common/getId**").anonymous()
                 .antMatchers("/common/uploadOSS**").anonymous()
+                .antMatchers("/common/proxy/recording").anonymous()
                 .antMatchers("/company/user/common/uploadOSS").anonymous()
                 .antMatchers("/pay/wxPay/payNotify**").anonymous()
                 .antMatchers("/common/uploadWang**").anonymous()

+ 4 - 0
fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java

@@ -183,6 +183,10 @@ public class CrmCustomer extends BaseEntity
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date visitTime;
 
+    /** 历史沟通记录 */
+    @Excel(name = "历史沟通记录")
+    private String historicalCommunication;
+
 
 
 }

+ 7 - 3
fs-service/src/main/resources/mapper/crm/CrmCustomerMapper.xml

@@ -54,10 +54,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="thirdAccount"    column="third_account"    />
         <result property="clueId"    column="clue_id"    />
         <result property="qwName"    column="qw_name"    />
+        <result property="historicalCommunication"    column="historical_communication"    />
     </resultMap>
 
     <sql id="selectCrmCustomerVo">
-        select customer_id, customer_code, customer_name, mobile, sex, weixin, remark, user_id, create_user_id, receive_user_id, customer_user_id, address,city_ids, location, detail_address, lng, lat, create_time, update_time, status, is_receive, dept_id, is_del, customer_type, receive_time, pool_time, company_id, is_line, source, tags,ext_json,visit_status,register_date,register_link_url,register_desc,register_submit_time,is_pool,register_type,pay_money,buy_count,source_code,push_time,push_code,visit_time,traffic_source,import_type,third_account,clue_id,qw_name from crm_customer
+        select customer_id, customer_code, customer_name, mobile, sex, weixin, remark, user_id, create_user_id, receive_user_id, customer_user_id, address,city_ids, location, detail_address, lng, lat, create_time, update_time, status, is_receive, dept_id, is_del, customer_type, receive_time, pool_time, company_id, is_line, source, tags,ext_json,visit_status,register_date,register_link_url,register_desc,register_submit_time,is_pool,register_type,pay_money,buy_count,source_code,push_time,push_code,visit_time,traffic_source,import_type,third_account,clue_id,qw_name,historical_communication from crm_customer
     </sql>
 
     <select id="selectCrmCustomerList" parameterType="CrmCustomer" resultMap="CrmCustomerResult">
@@ -159,6 +160,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="thirdAccount != null">third_account,</if>
             <if test="clueId != null">clue_id,</if>
             <if test="qwName != null">qw_name,</if>
+            <if test="historicalCommunication != null">historical_communication,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="customerCode != null">#{customerCode},</if>
@@ -209,6 +211,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="thirdAccount != null">#{thirdAccount},</if>
             <if test="clueId != null">#{clueId},</if>
             <if test="qwName != null">#{qwName},</if>
+            <if test="historicalCommunication != null">#{historicalCommunication},</if>
          </trim>
     </insert>
 
@@ -263,6 +266,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="thirdAccount != null">third_account = #{thirdAccount},</if>
             <if test="clueId != null">clue_id = #{clueId},</if>
             <if test="qwName != null">qw_name = #{qwName},</if>
+            <if test="historicalCommunication != null">historical_communication = #{historicalCommunication},</if>
         </trim>
         where customer_id = #{customerId}
     </update>
@@ -531,7 +535,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             receive_time, pool_time, company_id, is_line, source, tags, ext_json, visit_status,
             register_date, register_link_url, register_desc, register_submit_time, is_pool,
             register_type, pay_money, buy_count, source_code, push_time, push_code,
-            visit_time, traffic_source, import_type, third_account, clue_id, qw_name
+            visit_time, traffic_source, import_type, third_account, clue_id, qw_name, historical_communication
         )
         values
         <foreach collection="list" item="item" separator=",">
@@ -542,7 +546,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 #{item.receiveTime}, #{item.poolTime}, #{item.companyId}, #{item.isLine}, #{item.source}, #{item.tags}, #{item.extJson}, #{item.visitStatus},
                 #{item.registerDate}, #{item.registerLinkUrl}, #{item.registerDesc}, #{item.registerSubmitTime}, #{item.isPool},
                 #{item.registerType}, #{item.payMoney}, #{item.buyCount}, #{item.sourceCode}, #{item.pushTime}, #{item.pushCode},
-                #{item.visitTime}, #{item.trafficSource}, #{item.importType}, #{item.thirdAccount}, #{item.clueId}, #{item.qwName}
+                #{item.visitTime}, #{item.trafficSource}, #{item.importType}, #{item.thirdAccount}, #{item.clueId}, #{item.qwName}, #{item.historicalCommunication}
             )
         </foreach>
     </insert>