|
|
@@ -0,0 +1,391 @@
|
|
|
+package com.fs.app.controller.aiSipCall;
|
|
|
+
|
|
|
+import cn.hutool.core.util.ObjectUtil;
|
|
|
+import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
|
|
|
+import com.fs.aiSipCall.domain.AiSipCallUser;
|
|
|
+import com.fs.aiSipCall.domain.CcCustInfo;
|
|
|
+import com.fs.aiSipCall.param.ApiCallRecordByUuidQueryParams;
|
|
|
+import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
|
|
|
+import com.fs.aiSipCall.service.IAiSipCallUserService;
|
|
|
+import com.fs.app.annotation.Login;
|
|
|
+import com.fs.app.controller.AppBaseController;
|
|
|
+import com.fs.common.core.domain.AjaxResult;
|
|
|
+import com.fs.common.core.domain.R;
|
|
|
+import com.fs.common.core.page.TableDataInfo;
|
|
|
+import com.fs.common.utils.SecurityUtils;
|
|
|
+import com.fs.company.domain.CompanyUser;
|
|
|
+import com.fs.company.mapper.EasyCallMapper;
|
|
|
+import com.fs.company.service.ICompanyUserService;
|
|
|
+import com.fs.company.util.OrderUtils;
|
|
|
+import com.fs.company.vo.easycall.EasyCallOutBoundVO;
|
|
|
+import com.fs.crm.domain.CrmCustomer;
|
|
|
+import com.fs.crm.param.CrmCustomeReceiveParam;
|
|
|
+import com.fs.crm.param.CrmFullCustomerListQueryParam;
|
|
|
+import com.fs.crm.param.CrmMyCustomerListQueryParam;
|
|
|
+import com.fs.crm.mapper.CrmCustomerMapper;
|
|
|
+import com.fs.crm.service.ICrmCustomerService;
|
|
|
+import com.fs.crm.vo.CrmFullCustomerListQueryVO;
|
|
|
+import com.fs.crm.vo.CrmMyCustomerListQueryVO;
|
|
|
+import com.fs.his.utils.PhoneUtil;
|
|
|
+import com.github.pagehelper.PageHelper;
|
|
|
+import io.swagger.annotations.Api;
|
|
|
+import io.swagger.annotations.ApiOperation;
|
|
|
+import io.swagger.annotations.ApiParam;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestParam;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.SecureRandom;
|
|
|
+import java.util.Base64;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 销售端APP - aiSIP软电话接口(手动外呼)
|
|
|
+ * <p>
|
|
|
+ * 提供给APP端浏览器/JsSIP软电话工具条所需的最小可用后端能力:
|
|
|
+ * <ul>
|
|
|
+ * <li>拉取当前销售绑定的SIP分机账号 - 用于注册SIP UA</li>
|
|
|
+ * <li>拉取IPCC工具条基础参数 - 用于建立IPCC WebSocket控制通道</li>
|
|
|
+ * <li>通话前手机号密文转换 - 配合密文拨号模式</li>
|
|
|
+ * <li>通话沟通信息查询/沟通记录补录</li>
|
|
|
+ * </ul>
|
|
|
+ * <p>
|
|
|
+ * 注意:本Controller仅复用 fs-service 中已有Service方法,
|
|
|
+ * 不修改 fs-service / fs-company / fs-framework 等任何已有模块代码。
|
|
|
+ * <p>
|
|
|
+ * 仍待用户决策的能力(依赖 fs-service 暂未提供的方法/字段,详见随附差缺清单):
|
|
|
+ * <ul>
|
|
|
+ * <li>已补齐:agentLogin、callEndSyncByUuid、getCustCommunicationInfo(dialMode)</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * @author migrated from his_java/fs-company AiSipCallUserController & AiSipCallOutboundCdrController
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Api("软电话接口")
|
|
|
+@RestController
|
|
|
+@RequestMapping("/app/aiSipCall")
|
|
|
+public class AiSipCallController extends AppBaseController {
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private IAiSipCallUserService aiSipCallUserService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private IAiSipCallOutboundCdrService aiSipCallOutboundCdrService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private ICompanyUserService companyUserService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private EasyCallMapper easyCallMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private ICrmCustomerService crmCustomerService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private CrmCustomerMapper crmCustomerMapper;
|
|
|
+
|
|
|
+ private final String AUDIO_BASE_URL = "http://129.28.164.235:8899";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 是否使用自有线路(迁移自 his_java sip.call.myGateway,原放在 AiSipCallUserServiceImpl)。
|
|
|
+ * <p>SaaS 老 ServiceImpl 已被精简没有兜底逻辑,故在 Controller 层注入并保持 his_java 行为一致。
|
|
|
+ */
|
|
|
+ @Value("${sip.call.myGateway:false}")
|
|
|
+ private boolean isMyGateway;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 手动外呼网关前缀(迁移自 his_java sip.call.manualGatewayPrefix)
|
|
|
+ */
|
|
|
+ @Value("${sip.call.manualGatewayPrefix:weizhi}")
|
|
|
+ private String manualGatewayPrefix;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 公共线路网关前缀(迁移自 his_java sip.call.publicGatewayPrefix)
|
|
|
+ */
|
|
|
+ @Value("${sip.call.publicGatewayPrefix:outbound}")
|
|
|
+ private String publicGatewayPrefix;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 加密手机号末尾随机串长度(与前端约定,与 his_java 的 RandomUtil.generateRandomCode 等价)
|
|
|
+ */
|
|
|
+ private static final int RANDOM_TAIL_LEN = 6;
|
|
|
+
|
|
|
+ private static final char[] RANDOM_CHARS =
|
|
|
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
|
|
|
+
|
|
|
+ private static final SecureRandom RANDOM = new SecureRandom();
|
|
|
+
|
|
|
+ /**
|
|
|
+ * XOR加密公钥(与 his_java 的 PhoneUtil.PUBLIC_KEY_STR 保持一致,避免污染 SaaS 原有 PhoneUtil)
|
|
|
+ */
|
|
|
+ private static final String XOR_KEY = "ylrz112233";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询当前登录销售绑定的SIP分机账号
|
|
|
+ * <p>前端 softPhone.vue 启动时调用,用于初始化JsSIP UA配置(user/domain/extPass)。
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("查询当前销售的SIP分机账号")
|
|
|
+ @GetMapping("/myCallUser")
|
|
|
+ public AjaxResult myCallUser(AiSipCallUser aiSipCallUser) {
|
|
|
+ if (aiSipCallUser == null) {
|
|
|
+ aiSipCallUser = new AiSipCallUser();
|
|
|
+ }
|
|
|
+ aiSipCallUser.setCompanyUserId(getCompanyUserId());
|
|
|
+ List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
|
|
|
+ if (list != null && !list.isEmpty()) {
|
|
|
+ return AjaxResult.success(list.get(0));
|
|
|
+ }
|
|
|
+ return AjaxResult.error("未创建sip角色");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询aiSIP工具条基础配置参数
|
|
|
+ * <p>前端获取IPCC WebSocket地址、外呼网关等参数。
|
|
|
+ *
|
|
|
+ * @param param 至少包含 extNum(分机号)
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("查询aiSIP工具条基础参数")
|
|
|
+ @PostMapping("/getToolbarBasicParam")
|
|
|
+ public AjaxResult getToolbarBasicParam(@RequestBody Map<String, String> param) {
|
|
|
+ if (param == null || param.get("extNum") == null) {
|
|
|
+ return AjaxResult.error("分机号参数缺失");
|
|
|
+ }
|
|
|
+ // ===== 步骤1:迁移自 fs-company AiSipCallUserController#getToolbarBasicParam =====
|
|
|
+ // 优先使用当前销售所属公司绑定的网关ids
|
|
|
+ Long companyId = getCompanyId();
|
|
|
+ if (companyId != null) {
|
|
|
+ String ids = aiSipCallUserService.getGateWayIdListByCompanyId(companyId);
|
|
|
+ if (StringUtils.isNotBlank(ids)) {
|
|
|
+ param.put("myGateway", ids);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // ===== 步骤2:迁移自 his_java AiSipCallUserServiceImpl#getToolbarBasicParam =====
|
|
|
+ // 公司没绑定网关 + 前端也没传 myGateway,按 yml 配置兜底(与 his_java 行为一致)
|
|
|
+// String myGateway = param.get("myGateway");
|
|
|
+// if (StringUtils.isBlank(myGateway)) {
|
|
|
+// if (isMyGateway) {
|
|
|
+// // 自己有线路 → 使用手动外呼网关前缀
|
|
|
+// param.put("wgName", manualGatewayPrefix);
|
|
|
+// } else {
|
|
|
+// // 没有给默认线路 → 使用公共线路前缀
|
|
|
+// param.put("wgName", publicGatewayPrefix);
|
|
|
+// }
|
|
|
+// }
|
|
|
+ return aiSipCallUserService.getToolbarBasicParam(param);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取手动外呼客户沟通信息
|
|
|
+ *
|
|
|
+ * @param phoneNum 手机号(明文或密文)
|
|
|
+ * @param callType 类型 1呼入 2外呼
|
|
|
+ * @param uuid 通话UUID
|
|
|
+ * @param dialMode plaintext / encrypted (为空按明文处理)
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("获取手动外呼客户沟通信息")
|
|
|
+ @GetMapping("/getCustCommunicationInfo")
|
|
|
+ public AjaxResult getCustCommunicationInfo(
|
|
|
+ @ApiParam(value = "手机号", required = true) @RequestParam("phoneNum") String phoneNum,
|
|
|
+ @ApiParam(value = "1呼入 2外呼", required = true) @RequestParam("callType") Integer callType,
|
|
|
+ @ApiParam(value = "通话UUID", required = true) @RequestParam("uuid") String uuid,
|
|
|
+ @ApiParam(value = "拨号模式 plaintext/encrypted") @RequestParam(value = "dialMode", required = false) String dialMode) {
|
|
|
+ return aiSipCallOutboundCdrService.getCustCommunicationInfo(phoneNum, callType, uuid, dialMode);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 新增保存手动外呼沟通记录
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("新增手动外呼沟通记录")
|
|
|
+ @PostMapping("/add/custcallrecord")
|
|
|
+ public AjaxResult addCustcallrecord(@RequestBody CcCustInfo ccCustInfo) {
|
|
|
+ return aiSipCallOutboundCdrService.addCustcallrecord(ccCustInfo);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 手机号密文转换:先解密再用xor重新加密 + 末尾追加随机串
|
|
|
+ * <p>用于密文拨号模式:前端把"原始密文+6位随机数"丢上来,后端返回"新密文+6位随机数"。
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("手机号密文转换(解密->xor加密)")
|
|
|
+ @PostMapping("/encryptMobile")
|
|
|
+ public AjaxResult encryptMobile(@RequestBody Map<String, String> request) {
|
|
|
+ String combined = request != null ? request.get("data") : null;
|
|
|
+ if (combined == null || combined.length() <= RANDOM_TAIL_LEN) {
|
|
|
+ return AjaxResult.error("加密参数缺失或格式不正确");
|
|
|
+ }
|
|
|
+ // 截掉末尾随机串
|
|
|
+ String original = combined.substring(0, combined.length() - RANDOM_TAIL_LEN);
|
|
|
+ try {
|
|
|
+ String decrypted = PhoneUtil.decryptPhone(original);
|
|
|
+ String encrypted = xorEncrypt(decrypted);
|
|
|
+ return AjaxResult.success("获取成功", encrypted + generateRandomTail());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[aiSipCall][app] encryptMobile失败", e);
|
|
|
+ return AjaxResult.error("获取加密手机号失败:" + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 坐席登录IPCC外呼平台
|
|
|
+ * <p>前端 JsSIP 工具条启动后,使用此接口让 IPCC 平台知晓"该分机已上线、可派发呼叫"。
|
|
|
+ *
|
|
|
+ * @param param 至少包含 username/password/extNum 等表单参数
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("坐席登录IPCC外呼平台")
|
|
|
+ @PostMapping("/agentLogin")
|
|
|
+ public AjaxResult agentLogin(@RequestBody Map<String, Object> param) {
|
|
|
+ return aiSipCallUserService.agentLogin(param);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通话挂断后根据UUID同步话单
|
|
|
+ * <p>前端 JsSIP 监听到 BYE/挂机后调用本接口,由后端从 IPCC 拉取话单并落库。
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("通话挂断后同步话单")
|
|
|
+ @PostMapping("/callEndSyncByUuid")
|
|
|
+ public AjaxResult callEndSyncByUuid(@RequestBody AiSipCallOutboundCdr request) {
|
|
|
+ if (request == null || StringUtils.isBlank(request.getUuid())) {
|
|
|
+ return AjaxResult.error("获取手动外呼通话记录同步失败,uuid为空");
|
|
|
+ }
|
|
|
+ // 用APP登录态填充必备的归属字段(覆盖前端任何尝试性传值,避免越权)
|
|
|
+ request.setSourceType("0");
|
|
|
+ Long companyId = getCompanyId();
|
|
|
+ Long companyUserId = getCompanyUserId();
|
|
|
+ request.setCompanyId(companyId);
|
|
|
+ request.setCompanyUserId(companyUserId);
|
|
|
+ if (companyUserId != null) {
|
|
|
+ CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
|
|
|
+ if (companyUser != null) {
|
|
|
+ request.setCompanyUserName(companyUser.getUserName());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ request.setStatus(0);
|
|
|
+ aiSipCallOutboundCdrService.callEndSyncByUuid(request);
|
|
|
+ return AjaxResult.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取有效客户列表(effective_customer=1)
|
|
|
+ * <p>按销售id、创建时间范围、加密手机号、备注等条件筛选。
|
|
|
+ * 手机号传入明文,后端加密后进行LIKE匹配。
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("获取有效客户列表")
|
|
|
+ @GetMapping("/getEffectiveCustomerList")
|
|
|
+ public TableDataInfo getEffectiveCustomerList(
|
|
|
+ @RequestParam(value = "startTime", required = false) String startTime,
|
|
|
+ @RequestParam(value = "endTime", required = false) String endTime,
|
|
|
+ @RequestParam(value = "mobile", required = false) String mobile,
|
|
|
+ @RequestParam(value = "remark", required = false) String remark,
|
|
|
+ @RequestParam(value = "callStatus", required = false) Integer callStatus,
|
|
|
+ @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
|
|
|
+ @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
|
|
|
+ String encryptedMobile = null;
|
|
|
+ if (StringUtils.isNotBlank(mobile)) {
|
|
|
+ encryptedMobile = PhoneUtil.encryptPhone(mobile);
|
|
|
+ }
|
|
|
+ PageHelper.startPage(pageNum, pageSize);
|
|
|
+ List<CrmCustomer> list = crmCustomerMapper.selectEffectiveCustomerList(
|
|
|
+ getCompanyUserId(), startTime, endTime, encryptedMobile, remark, callStatus);
|
|
|
+ if (list != null) {
|
|
|
+ for (CrmCustomer c : list) {
|
|
|
+ if (StringUtils.isNotBlank(c.getMobile())) {
|
|
|
+ try {
|
|
|
+ String decrypted = PhoneUtil.decryptPhone(c.getMobile());
|
|
|
+ c.setMobile(decrypted.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
|
|
|
+ if (Integer.valueOf(1).equals(c.getCanDecrypt())) {
|
|
|
+ c.setDecryptedMobile(decrypted);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("[aiSipCall] 解密手机号失败, customerId={}", c.getCustomerId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return getDataTable(list);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据客户ID查询呼叫记录
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("根据客户ID查询呼叫记录")
|
|
|
+ @GetMapping("/getCallRecordByCustomerId")
|
|
|
+ public TableDataInfo getCallRecordByCustomerId(
|
|
|
+ @RequestParam("customerId") Long customerId,
|
|
|
+ @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
|
|
|
+ @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
|
|
|
+ AiSipCallOutboundCdr query = new AiSipCallOutboundCdr();
|
|
|
+ query.setCustomerId(customerId);
|
|
|
+ PageHelper.startPage(pageNum, pageSize);
|
|
|
+ List<AiSipCallOutboundCdr> list = aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrList(query);
|
|
|
+ //拼接音频文件
|
|
|
+ list.forEach(data -> {
|
|
|
+ if (StringUtils.isNotBlank(data.getWavfile())) {
|
|
|
+ data.setWavfile(AUDIO_BASE_URL + data.getWavfile());
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return getDataTable(list);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据客户ID获取解密后的手机号明文
|
|
|
+ */
|
|
|
+ @Login
|
|
|
+ @ApiOperation("根据客户ID获取解密手机号")
|
|
|
+ @GetMapping("/decryptMobileByCustomerId")
|
|
|
+ public AjaxResult decryptMobileByCustomerId(@RequestParam("customerId") Long customerId) {
|
|
|
+ String encryptedMobile = crmCustomerMapper.selectCrmCustomerPhoneByCustomerId(customerId);
|
|
|
+ if (StringUtils.isBlank(encryptedMobile)) {
|
|
|
+ return AjaxResult.error("该客户无手机号");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ String decrypted = PhoneUtil.decryptPhone(encryptedMobile);
|
|
|
+ return AjaxResult.success("获取成功", decrypted);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[aiSipCall] 解密手机号失败, customerId={}", customerId, e);
|
|
|
+ return AjaxResult.error("手机号解密失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成6位随机串(替代 his_java 中的 com.fs.his.utils.RandomUtil#generateRandomCode)
|
|
|
+ */
|
|
|
+ private String generateRandomTail() {
|
|
|
+ StringBuilder sb = new StringBuilder(RANDOM_TAIL_LEN);
|
|
|
+ for (int i = 0; i < RANDOM_TAIL_LEN; i++) {
|
|
|
+ sb.append(RANDOM_CHARS[RANDOM.nextInt(RANDOM_CHARS.length)]);
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * XOR 加密(与 his_java 的 PhoneUtil.xorEncrypt 算法一致)。
|
|
|
+ * <p>内联在此,避免修改 SaaS 原有 PhoneUtil。
|
|
|
+ */
|
|
|
+ private String xorEncrypt(String data) {
|
|
|
+ byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8);
|
|
|
+ byte[] keyBytes = XOR_KEY.getBytes(StandardCharsets.UTF_8);
|
|
|
+ byte[] result = new byte[dataBytes.length];
|
|
|
+ for (int i = 0; i < dataBytes.length; i++) {
|
|
|
+ result[i] = (byte) (dataBytes[i] ^ keyBytes[i % keyBytes.length]);
|
|
|
+ }
|
|
|
+ return Base64.getEncoder().encodeToString(result);
|
|
|
+ }
|
|
|
+}
|