|
|
@@ -0,0 +1,326 @@
|
|
|
+package com.fs.company.service.easycall;
|
|
|
+
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.alibaba.fastjson.JSONArray;
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
+import com.fs.common.utils.StringUtils;
|
|
|
+import com.fs.company.vo.easycall.*;
|
|
|
+import cn.hutool.http.HttpRequest;
|
|
|
+import cn.hutool.http.HttpUtil;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+/**
|
|
|
+ * EasyCallCenter365 外呼服务实现类
|
|
|
+ * <p>
|
|
|
+ * 对接 EasyCallCenter365 外呼系统的所有 HTTP 接口,包括:
|
|
|
+ * 网关、大模型、音色、技能组的基础数据查询,
|
|
|
+ * 外呼任务的创建/启动/停止,名单追加,以及通话记录查询。
|
|
|
+ * <p>
|
|
|
+ * 服务地址通过配置项 easycall.base-url 注入,默认值:http://129.28.164.235:8899
|
|
|
+ */
|
|
|
+@Service
|
|
|
+@Slf4j
|
|
|
+public class EasyCallServiceImpl implements IEasyCallService {
|
|
|
+
|
|
|
+ /** EasyCallCenter365 服务器基础地址,从配置文件 easycall.base-url 读取 */
|
|
|
+ @Value("${easycall.base-url:http://129.28.164.235:8899}")
|
|
|
+ private String baseUrl;
|
|
|
+
|
|
|
+ // =================== EasyCallCenter365 接口路径常量 ===================
|
|
|
+ /** 获取外呼网关列表 */
|
|
|
+ private static final String API_GATEWAY_LIST = "/aicall/api/gateway/list";
|
|
|
+ /** 获取大模型配置列表 */
|
|
|
+ private static final String API_LLMACCOUNT_LIST = "/aicall/api/llmacount/list";
|
|
|
+ /** 获取音色列表 */
|
|
|
+ private static final String API_VOICECODE_LIST = "/aicall/api/voicecode/list";
|
|
|
+ /** 获取技能组(业务组)列表 */
|
|
|
+ private static final String API_BUSIGROUP_LIST = "/aicall/api/busigroup/list";
|
|
|
+ /** 分页查询任务列表 */
|
|
|
+ private static final String API_CALLTASK_LIST = "/aicall/api/calltask/list";
|
|
|
+ /** 分页查询通话记录 */
|
|
|
+ private static final String API_RECORDS_LIST = "/aicall/api/records/list";
|
|
|
+ /** 创建外呼任务 */
|
|
|
+ private static final String API_CREATE_TASK = "/aicall/api/ai/createTask";
|
|
|
+ /** 启动外呼任务(GET,携带 batchId 参数) */
|
|
|
+ private static final String API_START_TASK = "/aicall/api/ai/startTask";
|
|
|
+ /** 停止外呼任务(GET,携带 batchId 参数) */
|
|
|
+ private static final String API_STOP_TASK = "/aicall/api/ai/stopTask";
|
|
|
+ /** AI外呼专用追加名单(仅支持 AI 外呼任务,phoneList 为纯号码列表) */
|
|
|
+ private static final String API_AI_ADD_CALL_LIST = "/aicall/api/ai/addCallList";
|
|
|
+ /** 通用追加名单(同时支持 AI 外呼和通知提醒任务,支持传入提醒内容和业务数据) */
|
|
|
+ private static final String API_COMMON_ADD_LIST = "/aicall/api/common/addCallList";
|
|
|
+
|
|
|
+ // =================== 基础数据查询接口 ===================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询外呼网关列表
|
|
|
+ * 网关用于创建任务时选择线路,purpose=2 表示仅限AI外呼,purpose=3 表示不限制
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public List<EasyCallGatewayVO> getGatewayList(Long companyId) {
|
|
|
+ // 拼接完整请求地址并发起 GET 请求
|
|
|
+ String url = buildUrl(API_GATEWAY_LIST);
|
|
|
+ JSONObject result = doGet(url);
|
|
|
+ // 从响应的 data 字段中取出网关数组并转为 Java 对象列表
|
|
|
+ JSONArray data = result.getJSONArray("data");
|
|
|
+ return data == null ? new ArrayList<>() : data.toJavaList(EasyCallGatewayVO.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询大模型配置列表
|
|
|
+ * 大模型配置用于创建任务时绑定 AI 话术,支持 DeepSeek、Coze、MaxKB、Dify 等
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public List<EasyCallLlmAccountVO> getLlmAccountList(Long companyId) {
|
|
|
+ String url = buildUrl(API_LLMACCOUNT_LIST);
|
|
|
+ JSONObject result = doGet(url);
|
|
|
+ JSONArray data = result.getJSONArray("data");
|
|
|
+ return data == null ? new ArrayList<>() : data.toJavaList(EasyCallLlmAccountVO.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询音色列表
|
|
|
+ * 音色用于 AI 外呼任务(taskType=1)时配置语音合成的声音风格
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public List<EasyCallVoiceCodeVO> getVoiceCodeList(Long companyId) {
|
|
|
+ String url = buildUrl(API_VOICECODE_LIST);
|
|
|
+ JSONObject result = doGet(url);
|
|
|
+ JSONArray data = result.getJSONArray("data");
|
|
|
+ return data == null ? new ArrayList<>() : data.toJavaList(EasyCallVoiceCodeVO.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询技能组(业务组)列表
|
|
|
+ * 技能组用于 AI 外呼需要转人工时,指定接入的人工客服分组
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public List<EasyCallBusiGroupVO> getBusiGroupList(Long companyId) {
|
|
|
+ String url = buildUrl(API_BUSIGROUP_LIST);
|
|
|
+ JSONObject result = doGet(url);
|
|
|
+ JSONArray data = result.getJSONArray("data");
|
|
|
+ return data == null ? new ArrayList<>() : data.toJavaList(EasyCallBusiGroupVO.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // =================== 任务管理接口 ===================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分页查询外呼任务列表
|
|
|
+ * 返回任务基本信息以及各任务的拨打统计数据(总量、接通量、接通率等)
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public EasyCallPageResult<EasyCallTaskVO> getTaskList(EasyCallTaskQueryParam param, Long companyId) {
|
|
|
+ String url = buildUrl(API_CALLTASK_LIST);
|
|
|
+ // 将查询参数序列化为 JSON 后 POST 发送
|
|
|
+ JSONObject result = doPost(url, JSON.toJSONString(param));
|
|
|
+ // 响应为分页格式:{ total, rows },统一用 parsePageResult 解析
|
|
|
+ return parsePageResult(result, EasyCallTaskVO.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分页查询通话记录
|
|
|
+ * 支持按号码、拨打时间、通话时长、通话状态等多条件过滤
|
|
|
+ * callType:01-呼入,02-AI外呼,03-人工外呼,三种类型不支持混合查询
|
|
|
+ * pageNum 和 pageSize 均为空时返回全部数据
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public EasyCallPageResult<EasyCallRecordVO> getRecordList(EasyCallRecordQueryParam param, Long companyId) {
|
|
|
+ String url = buildUrl(API_RECORDS_LIST);
|
|
|
+ JSONObject result = doPost(url, JSON.toJSONString(param));
|
|
|
+ return parsePageResult(result, EasyCallRecordVO.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建外呼任务
|
|
|
+ * taskType=1 为 AI 外呼(需传 voiceCode、voiceSource)
|
|
|
+ * taskType=2 为通知提醒(需传 playTimes)
|
|
|
+ * 创建成功后返回包含 batchId 的任务信息,后续启动/停止/追加名单均依赖 batchId
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public EasyCallTaskVO createTask(EasyCallCreateTaskParam param, Long companyId) {
|
|
|
+ String url = buildUrl(API_CREATE_TASK);
|
|
|
+ JSONObject result = doPost(url, JSON.toJSONString(param));
|
|
|
+ // 响应的 data 字段即为创建成功的任务对象
|
|
|
+ JSONObject data = result.getJSONObject("data");
|
|
|
+ return data == null ? null : data.toJavaObject(EasyCallTaskVO.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 启动外呼任务
|
|
|
+ * 任务创建后默认处于待机状态,调用此接口后开始拨打名单中的号码
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public void startTask(Long batchId, Long companyId) {
|
|
|
+ // batchId 以 Query 参数形式拼接到 URL
|
|
|
+ String url = buildUrl(API_START_TASK) + "?batchId=" + batchId;
|
|
|
+ JSONObject result = doGet(url);
|
|
|
+ checkSuccess(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止外呼任务
|
|
|
+ * 停止后任务不再继续拨打,可重新启动
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public void stopTask(Long batchId, Long companyId) {
|
|
|
+ String url = buildUrl(API_STOP_TASK) + "?batchId=" + batchId;
|
|
|
+ JSONObject result = doGet(url);
|
|
|
+ checkSuccess(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ // =================== 名单管理接口 ===================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * AI 外呼专用追加名单
|
|
|
+ * 仅支持 AI 外呼任务(taskType=1),phoneList 传入纯手机号列表
|
|
|
+ * 注意:任务启动后也可以继续追加名单
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public void addAiCallList(EasyCallAddCallListParam param, Long companyId) {
|
|
|
+ String url = buildUrl(API_AI_ADD_CALL_LIST);
|
|
|
+ JSONObject result = doPost(url, JSON.toJSONString(param));
|
|
|
+ checkSuccess(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通用追加名单
|
|
|
+ * 同时支持 AI 外呼(taskType=1)和通知提醒(taskType=2)任务
|
|
|
+ * 通知提醒任务必须在 phoneList 的每条记录中填写 noticeContent(播报内容)
|
|
|
+ * bizJson 字段可传入需要传递给机器人的业务数据(如客户姓名、订单号等)
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId) {
|
|
|
+ String url = buildUrl(API_COMMON_ADD_LIST);
|
|
|
+ JSONObject result = doPost(url, JSON.toJSONString(param));
|
|
|
+ checkSuccess(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ // =================== 录音相关接口 ===================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拼接录音文件完整访问 URL
|
|
|
+ * 通话记录查询返回的 wavFileUrl 是相对路径(如 /recordings/files?filename=xxx.wav)
|
|
|
+ * 需要拼接系统 baseUrl 才能直接在浏览器访问或下载
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public String getRecordFileUrl(String wavFileUrl, Long companyId) {
|
|
|
+ // 录音路径为空时直接返回空字符串,避免拼出错误地址
|
|
|
+ if (StringUtils.isEmpty(wavFileUrl)) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String base = trimTrailingSlash(baseUrl);
|
|
|
+ return base + wavFileUrl;
|
|
|
+ }
|
|
|
+
|
|
|
+ // =================== 私有工具方法 ===================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拼接完整请求地址
|
|
|
+ * 将 baseUrl(如 http://129.28.164.235:8899)与接口路径(如 /aicall/api/gateway/list)拼接
|
|
|
+ */
|
|
|
+ private String buildUrl(String path) {
|
|
|
+ return trimTrailingSlash(baseUrl) + path;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 去除 URL 末尾多余的斜杠
|
|
|
+ * 防止拼接后出现 http://xxx//path 的双斜杠问题
|
|
|
+ */
|
|
|
+ private String trimTrailingSlash(String url) {
|
|
|
+ if (url != null && url.endsWith("/")) {
|
|
|
+ return url.substring(0, url.length() - 1);
|
|
|
+ }
|
|
|
+ return url;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 发送 GET 请求
|
|
|
+ * 用于不需要请求体的查询类接口(如网关列表、启动/停止任务等)
|
|
|
+ */
|
|
|
+ private JSONObject doGet(String url) {
|
|
|
+ log.info("EasyCall GET 请求: {}", url);
|
|
|
+ try {
|
|
|
+ String body = HttpUtil.createGet(url)
|
|
|
+ .header("Accept", "application/json")
|
|
|
+ .execute()
|
|
|
+ .body();
|
|
|
+ log.info("EasyCall GET 响应: {}", body);
|
|
|
+ // 解析响应 JSON 并校验 code 字段,非 0 则抛异常
|
|
|
+ return parseAndCheck(body);
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ // RuntimeException 直接向上抛(已经是业务异常,不重新包装)
|
|
|
+ throw e;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("EasyCall GET 请求异常, url: {}", url, e);
|
|
|
+ throw new RuntimeException("请求EasyCallCenter365失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 发送 POST 请求
|
|
|
+ * 用于需要传入 JSON 请求体的接口(如创建任务、查询列表、追加名单等)
|
|
|
+ */
|
|
|
+ private JSONObject doPost(String url, String jsonBody) {
|
|
|
+ log.info("EasyCall POST 请求: {}, body: {}", url, jsonBody);
|
|
|
+ try {
|
|
|
+ HttpRequest request = HttpUtil.createPost(url)
|
|
|
+ .header("Accept", "application/json")
|
|
|
+ .header("Content-Type", "application/json")
|
|
|
+ .body(jsonBody);
|
|
|
+ String body = request.execute().body();
|
|
|
+ log.info("EasyCall POST 响应: {}", body);
|
|
|
+ return parseAndCheck(body);
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ throw e;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("EasyCall POST 请求异常, url: {}", url, e);
|
|
|
+ throw new RuntimeException("请求EasyCallCenter365失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析响应 JSON 并校验业务状态码
|
|
|
+ * EasyCallCenter365 的接口规范:code=0 表示成功,非 0 表示失败
|
|
|
+ * 失败时将 msg 字段内容包装为 RuntimeException 向上抛出
|
|
|
+ */
|
|
|
+ private JSONObject parseAndCheck(String responseBody) {
|
|
|
+ JSONObject json = JSON.parseObject(responseBody);
|
|
|
+ Integer code = json.getInteger("code");
|
|
|
+ if (code == null || code != 0) {
|
|
|
+ String msg = json.getString("msg");
|
|
|
+ log.error("EasyCallCenter365 接口返回错误, code: {}, msg: {}", code, msg);
|
|
|
+ throw new RuntimeException("EasyCallCenter365 接口错误: " + msg);
|
|
|
+ }
|
|
|
+ return json;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 操作类接口成功校验(占位方法)
|
|
|
+ * 启动、停止、追加名单等操作类接口无 data 返回,
|
|
|
+ * 成功与否已由 parseAndCheck 中的 code 判断保证,此处无需额外处理
|
|
|
+ */
|
|
|
+ private void checkSuccess(JSONObject result) {
|
|
|
+ // parseAndCheck 中 code != 0 时已抛异常,走到这里即代表操作成功
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析分页响应结果
|
|
|
+ * EasyCallCenter365 分页接口格式:{ code, msg, total, rows }
|
|
|
+ * 将 total(总数)和 rows(当前页数据)封装到 EasyCallPageResult 中返回
|
|
|
+ */
|
|
|
+ private <T> EasyCallPageResult<T> parsePageResult(JSONObject result, Class<T> clazz) {
|
|
|
+ EasyCallPageResult<T> pageResult = new EasyCallPageResult<>();
|
|
|
+ // total 可能为 null(如无数据时),默认为 0
|
|
|
+ Long total = result.getLong("total");
|
|
|
+ pageResult.setTotal(total == null ? 0L : total);
|
|
|
+ // rows 为当前页的数据数组,反序列化为指定 VO 类型列表
|
|
|
+ JSONArray rows = result.getJSONArray("rows");
|
|
|
+ pageResult.setRows(rows == null ? new ArrayList<>() : rows.toJavaList(clazz));
|
|
|
+ return pageResult;
|
|
|
+ }
|
|
|
+}
|