|
@@ -0,0 +1,391 @@
|
|
|
|
|
+package com.fs.company.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.alibaba.fastjson.JSONArray;
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import com.fs.aicall.domain.CcTtsAliyun;
|
|
|
|
|
+import com.fs.aicall.service.ICcParamsService;
|
|
|
|
|
+import com.fs.aicall.service.ICcTtsAliyunService;
|
|
|
|
|
+import com.fs.common.core.domain.AjaxResult;
|
|
|
|
|
+import com.fs.common.utils.StringUtils;
|
|
|
|
|
+import com.fs.company.service.ICompanyVoiceCloneService;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import okhttp3.*;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.IOException;
|
|
|
|
|
+import java.util.Base64;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.UUID;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 豆包声音克隆 Service 实现
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 参照 DoubaoVclController 实现:
|
|
|
|
|
+ * 1. uploadAndTrain: 从 EASYCALL 读取账号,HTTP 调用豆包训练接口,成功后写入音色表
|
|
|
|
|
+ * 2. doubaoTtsTest: HTTP 调用豆包 TTS 接口,返回 base64 音频数据
|
|
|
|
|
+ * </p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * @author fs
|
|
|
|
|
+ */
|
|
|
|
|
+@Service
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+public class CompanyVoiceCloneServiceImpl implements ICompanyVoiceCloneService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String HOST = "https://openspeech.bytedance.com";
|
|
|
|
|
+ private static final String TRAIN_URL = HOST + "/api/v1/mega_tts/audio/upload";
|
|
|
|
|
+ private static final String STATUS_URL = HOST + "/api/v1/mega_tts/status";
|
|
|
|
|
+ private static final String TTS_URL = HOST + "/api/v1/tts";
|
|
|
|
|
+
|
|
|
|
|
+ private static final String DOUBAO_ACCOUNT_PARAM_CODE = "doubao-tts-account-json";
|
|
|
|
|
+
|
|
|
|
|
+ private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
|
|
|
|
|
+
|
|
|
|
|
+ /** 错误码映射 */
|
|
|
|
|
+ private static final Map<Integer, String> ERROR_MAP = new HashMap<Integer, String>() {{
|
|
|
|
|
+ put(1001, "BadRequestError: 请求参数有误");
|
|
|
|
|
+ put(1101, "AudioUploadError: 音频上传失败");
|
|
|
|
|
+ put(1102, "ASRError: ASR(语音识别成文字)转写失败");
|
|
|
|
|
+ put(1103, "SIDError: SID声纹检测失败");
|
|
|
|
|
+ put(1104, "SIDFailError: 声纹检测未通过,声纹跟名人相似度过高");
|
|
|
|
|
+ put(1105, "GetAudioDataError: 获取音频数据失败");
|
|
|
|
|
+ put(1106, "SpeakerIDDuplicationError: SpeakerID重复");
|
|
|
|
|
+ put(1107, "SpeakerIDNotFoundError: SpeakerID未找到");
|
|
|
|
|
+ put(1108, "AudioConvertError: 音频转码失败");
|
|
|
|
|
+ put(1109, "WERError: wer检测错误,上传音频与请求携带文本对比字错率过高");
|
|
|
|
|
+ put(1111, "AEEDerror: aed检测错误,通常由于音频不包含说话声");
|
|
|
|
|
+ put(1112, "SNRError: SNR检测错误,通常由于信噪比过高");
|
|
|
|
|
+ put(1113, "DenoiseError: 降噪处理失败");
|
|
|
|
|
+ put(1114, "AudioQualityError: 音频质量低,降噪失败");
|
|
|
|
|
+ put(1122, "ASRNoSpeakerError: 未检测到人声");
|
|
|
|
|
+ put(1123, "MaxTrainNumLimitReached: 上传接口已经达到次数限制,目前同一个音色支持10次上传");
|
|
|
|
|
+ put(403, "ParameterError: 参数错误:请检查 声音ID、app_id、access_token 是否正确、检查 声音ID 是否和模型类型匹配?(也可能没有开通相关服务)");
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private ICcParamsService ccParamsService;
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private ICcTtsAliyunService ccTtsAliyunService;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 上传并训练
|
|
|
|
|
+ * @param voiceName 音色名称
|
|
|
|
|
+ * @param speakerId 声音ID
|
|
|
|
|
+ * @param language 语种 (0-中文, 1-英文...)
|
|
|
|
|
+ * @param modelType 模型类型 (1-ICL1.0, 2-DiT标准版, 3-DiT还原版, 4-ICL2.0)
|
|
|
|
|
+ * @param file 音频文件
|
|
|
|
|
+ * @return
|
|
|
|
|
+ */
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public AjaxResult uploadAndTrain(String voiceName, String speakerId,
|
|
|
|
|
+ Integer language, Integer modelType,
|
|
|
|
|
+ MultipartFile file) {
|
|
|
|
|
+ if (file == null || file.isEmpty()) {
|
|
|
|
|
+ return AjaxResult.error("请选择音频文件");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isEmpty(voiceName)) {
|
|
|
|
|
+ return AjaxResult.error("请填写音色名称");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isEmpty(speakerId)) {
|
|
|
|
|
+ return AjaxResult.error("请填写声音ID");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 从 EASYCALL 数据源读取豆包账号配置
|
|
|
|
|
+ String ttsAccountJson = ccParamsService.getParamValueByCode(DOUBAO_ACCOUNT_PARAM_CODE, "{}");
|
|
|
|
|
+ JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
|
|
|
|
|
+ String appid = paramValues.getString("app_id");
|
|
|
|
|
+ String token = paramValues.getString("access_token");
|
|
|
|
|
+
|
|
|
|
|
+ if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(token)) {
|
|
|
|
|
+ return AjaxResult.error("豆包账号未配置,请在系统参数中设置 " + DOUBAO_ACCOUNT_PARAM_CODE);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 获取音频格式和字节数据
|
|
|
|
|
+ String originalFilename = file.getOriginalFilename();
|
|
|
|
|
+ int strLen = originalFilename != null ? originalFilename.length() : 0;
|
|
|
|
|
+ String audioFormat = strLen > 3 ? originalFilename.substring(strLen - 3, strLen) : "wav";
|
|
|
|
|
+ byte[] audioBytes = file.getBytes();
|
|
|
|
|
+
|
|
|
|
|
+ // 调用豆包训练接口
|
|
|
|
|
+ String response = train(appid, token, audioFormat, audioBytes, speakerId,
|
|
|
|
|
+ String.valueOf(language != null ? language : 0),
|
|
|
|
|
+ String.valueOf(modelType != null ? modelType : 2));
|
|
|
|
|
+
|
|
|
|
|
+ if (!StringUtils.isEmpty(response)) {
|
|
|
|
|
+ JSONObject jsonObject = JSON.parseObject(response);
|
|
|
|
|
+ JSONObject baseResp = jsonObject.getJSONObject("BaseResp");
|
|
|
|
|
+ if (baseResp == null) {
|
|
|
|
|
+ return AjaxResult.error("响应格式异常:" + response);
|
|
|
|
|
+ }
|
|
|
|
|
+ int statusCode = baseResp.getInteger("StatusCode");
|
|
|
|
|
+
|
|
|
|
|
+ if (statusCode == 0) {
|
|
|
|
|
+ // 训练成功,写入音色表
|
|
|
|
|
+ addSpeakerId(voiceName, speakerId);
|
|
|
|
|
+
|
|
|
|
|
+ // 查询训练状态
|
|
|
|
|
+ boolean modelReady = getStatus(appid, token, speakerId);
|
|
|
|
|
+ String tips = modelReady ? "模型已就绪" : "模型训练中,请稍后查询状态";
|
|
|
|
|
+ return AjaxResult.success("上传训练成功!\n" + tips);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ String errDetails = baseResp.getString("StatusMessage");
|
|
|
|
|
+ String errMsg = ERROR_MAP.get(statusCode);
|
|
|
|
|
+ if (!StringUtils.isEmpty(errMsg)) {
|
|
|
|
|
+ return AjaxResult.error(errMsg + "\n" + errDetails);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return AjaxResult.error("未知错误!\n" + response);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return AjaxResult.error("响应为空!");
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (IOException e) {
|
|
|
|
|
+ log.error("声音克隆上传训练异常,speakerId={}", speakerId, e);
|
|
|
|
|
+ return AjaxResult.error("服务器内部错误:" + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 调用豆包训练接口
|
|
|
|
|
+ * @param appid
|
|
|
|
|
+ * @param token
|
|
|
|
|
+ * @param audioFormat
|
|
|
|
|
+ * @param audioBytes
|
|
|
|
|
+ * @param spkId
|
|
|
|
|
+ * @param language
|
|
|
|
|
+ * @param modelType
|
|
|
|
|
+ * @return
|
|
|
|
|
+ * @throws IOException
|
|
|
|
|
+ */
|
|
|
|
|
+ private String train(String appid, String token, String audioFormat, byte[] audioBytes,
|
|
|
|
|
+ String spkId, String language, String modelType) throws IOException {
|
|
|
|
|
+ String result;
|
|
|
|
|
+ OkHttpClient client = createHttpClient();
|
|
|
|
|
+
|
|
|
|
|
+ // 编码音频文件
|
|
|
|
|
+ String encodedData = Base64.getEncoder().encodeToString(audioBytes);
|
|
|
|
|
+
|
|
|
|
|
+ JSONArray audios = new JSONArray();
|
|
|
|
|
+ JSONObject audioObj = new JSONObject();
|
|
|
|
|
+ audioObj.put("audio_bytes", encodedData);
|
|
|
|
|
+ audioObj.put("audio_format", audioFormat);
|
|
|
|
|
+ audios.add(audioObj);
|
|
|
|
|
+
|
|
|
|
|
+ // 构建请求体
|
|
|
|
|
+ JSONObject requestBody = new JSONObject();
|
|
|
|
|
+ requestBody.put("appid", appid);
|
|
|
|
|
+ requestBody.put("speaker_id", spkId);
|
|
|
|
|
+ requestBody.put("audios", audios);
|
|
|
|
|
+ requestBody.put("source", 2);
|
|
|
|
|
+ requestBody.put("language", Integer.parseInt(language));
|
|
|
|
|
+ requestBody.put("model_type", Integer.parseInt(modelType));
|
|
|
|
|
+
|
|
|
|
|
+ RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, requestBody.toString());
|
|
|
|
|
+
|
|
|
|
|
+ // 根据 modelType 决定 Resource-Id
|
|
|
|
|
+ String resourceId = "seed-icl-1.0";
|
|
|
|
|
+ if (Integer.parseInt(modelType) == 4) {
|
|
|
|
|
+ resourceId = "seed-icl-2.0";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Request request = new Request.Builder()
|
|
|
|
|
+ .url(TRAIN_URL)
|
|
|
|
|
+ .post(body)
|
|
|
|
|
+ .addHeader("Content-Type", "application/json")
|
|
|
|
|
+ .addHeader("Authorization", "Bearer;" + token)
|
|
|
|
|
+ .addHeader("Resource-Id", resourceId)
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
|
|
+ try (Response response = client.newCall(request).execute()) {
|
|
|
|
|
+ log.info("doubaovcl train http response status code {}", response.code());
|
|
|
|
|
+ String responseStr = response.body().string();
|
|
|
|
|
+ log.info("doubaovcl train response: {}", responseStr);
|
|
|
|
|
+
|
|
|
|
|
+ if (response.code() == 403) {
|
|
|
|
|
+ result = "{\"BaseResp\":{\"StatusCode\": 403 }}";
|
|
|
|
|
+ } else {
|
|
|
|
|
+ result = responseStr;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 查询训练状态
|
|
|
|
|
+ * @param appid
|
|
|
|
|
+ * @param token
|
|
|
|
|
+ * @param spkId
|
|
|
|
|
+ * @return
|
|
|
|
|
+ */
|
|
|
|
|
+ private boolean getStatus(String appid, String token, String spkId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ OkHttpClient client = createHttpClient();
|
|
|
|
|
+ JSONObject requestBody = new JSONObject();
|
|
|
|
|
+ requestBody.put("appid", appid);
|
|
|
|
|
+ requestBody.put("speaker_id", spkId);
|
|
|
|
|
+
|
|
|
|
|
+ RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, requestBody.toString());
|
|
|
|
|
+
|
|
|
|
|
+ Request request = new Request.Builder()
|
|
|
|
|
+ .url(STATUS_URL)
|
|
|
|
|
+ .post(body)
|
|
|
|
|
+ .addHeader("Content-Type", "application/json")
|
|
|
|
|
+ .addHeader("Authorization", "Bearer;" + token)
|
|
|
|
|
+ .addHeader("Resource-Id", "volc.megatts.voiceclone")
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
|
|
+ try (Response response = client.newCall(request).execute()) {
|
|
|
|
|
+ if (response.isSuccessful()) {
|
|
|
|
|
+ String json = response.body().string();
|
|
|
|
|
+ log.info("doubaovcl getStatus response: {}", json);
|
|
|
|
|
+ JSONObject jsonObject = JSON.parseObject(json);
|
|
|
|
|
+ Integer status = jsonObject.getInteger("status");
|
|
|
|
|
+ return status != null && (status == 2 || status == 4);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("查询训练状态异常:{}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 新增/更新音色到 EASYCALL 数据库
|
|
|
|
|
+ * @param nameParam
|
|
|
|
|
+ * @param speakerId
|
|
|
|
|
+ */
|
|
|
|
|
+ private synchronized void addSpeakerId(String nameParam, String speakerId) {
|
|
|
|
|
+ CcTtsAliyun ttsSpeaker = ccTtsAliyunService.selectCcTtsAliyunByVoiceCode(speakerId);
|
|
|
|
|
+ String name = nameParam.replace("'", "").replace(" ", "");
|
|
|
|
|
+ if (name.length() > 20) {
|
|
|
|
|
+ name = name.substring(0, 20);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ boolean update = false;
|
|
|
|
|
+ if (ttsSpeaker == null) {
|
|
|
|
|
+ ttsSpeaker = new CcTtsAliyun();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ update = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ttsSpeaker.setVoiceCode(speakerId);
|
|
|
|
|
+ ttsSpeaker.setVoiceEnabled(1);
|
|
|
|
|
+ ttsSpeaker.setVoiceName(String.format("[%s] - %s", speakerId, name));
|
|
|
|
|
+ ttsSpeaker.setVoiceSource("doubao_vcl_tts");
|
|
|
|
|
+ ttsSpeaker.setPriority(0);
|
|
|
|
|
+ ttsSpeaker.setProvider("doubao");
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (update) {
|
|
|
|
|
+ ccTtsAliyunService.updateCcTtsAliyun(ttsSpeaker);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ccTtsAliyunService.insertCcTtsAliyun(ttsSpeaker);
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("save doubaovcl speakerId succeed. {} {}", name, speakerId);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("save doubaovcl speakerId error: {}", e.getMessage(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * TTS 测试
|
|
|
|
|
+ * @param speakerId 声音ID
|
|
|
|
|
+ * @param language 语种
|
|
|
|
|
+ * @param text 要合成的文本
|
|
|
|
|
+ * @return
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public AjaxResult doubaoTtsTest(String speakerId, Integer language, String text) {
|
|
|
|
|
+ if (StringUtils.isEmpty(speakerId)) {
|
|
|
|
|
+ return AjaxResult.error("请填写声音ID");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isEmpty(text)) {
|
|
|
|
|
+ return AjaxResult.error("请输入测试文本");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 从 EASYCALL 数据源读取豆包账号配置
|
|
|
|
|
+ String ttsAccountJson = ccParamsService.getParamValueByCode(DOUBAO_ACCOUNT_PARAM_CODE, "{}");
|
|
|
|
|
+ JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
|
|
|
|
|
+ String appid = paramValues.getString("app_id");
|
|
|
|
|
+ String token = paramValues.getString("access_token");
|
|
|
|
|
+
|
|
|
|
|
+ if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(token)) {
|
|
|
|
|
+ return AjaxResult.error("豆包账号未配置,请在系统参数中设置 " + DOUBAO_ACCOUNT_PARAM_CODE);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 构建请求体(与 DoubaoVclController 保持一致)
|
|
|
|
|
+ Map<String, Object> requestBody = buildTtsRequestBody(appid, token, speakerId, language, text);
|
|
|
|
|
+ String reqBodyJson = JSON.toJSONString(requestBody);
|
|
|
|
|
+ log.info("doubaovcl tts request: {}", reqBodyJson);
|
|
|
|
|
+
|
|
|
|
|
+ OkHttpClient client = createHttpClient();
|
|
|
|
|
+ RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, reqBodyJson);
|
|
|
|
|
+ Request ttsRequest = new Request.Builder()
|
|
|
|
|
+ .url(TTS_URL)
|
|
|
|
|
+ .header("Authorization", "Bearer;" + token)
|
|
|
|
|
+ .post(body)
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
|
|
+ try (Response response = client.newCall(ttsRequest).execute()) {
|
|
|
|
|
+ String respStr = response.body().string();
|
|
|
|
|
+ if (!response.isSuccessful()) {
|
|
|
|
|
+ log.info("doubaovcl tts response: {}", respStr);
|
|
|
|
|
+ return AjaxResult.error("语音合成失败!\n网络繁忙,请稍后重试");
|
|
|
|
|
+ }
|
|
|
|
|
+ return AjaxResult.success("tts request success.", respStr);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (IOException e) {
|
|
|
|
|
+ log.error("TTS 测试异常,speakerId={}", speakerId, e);
|
|
|
|
|
+ return AjaxResult.error("TTS 合成失败:" + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 构建 TTS 请求体
|
|
|
|
|
+ */
|
|
|
|
|
+ private Map<String, Object> buildTtsRequestBody(String appid, String token,
|
|
|
|
|
+ String speakerId, Integer language, String text) {
|
|
|
|
|
+ Map<String, Object> app = new HashMap<>();
|
|
|
|
|
+ app.put("appid", appid);
|
|
|
|
|
+ app.put("cluster", "volcano_icl");
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> user = new HashMap<>();
|
|
|
|
|
+ user.put("uid", "uid");
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> audio = new HashMap<>();
|
|
|
|
|
+ audio.put("encoding", "mp3");
|
|
|
|
|
+ audio.put("voice_type", speakerId);
|
|
|
|
|
+ audio.put("language", String.valueOf(language != null ? language : 0));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> request = new HashMap<>();
|
|
|
|
|
+ request.put("reqid", UUID.randomUUID().toString());
|
|
|
|
|
+ request.put("operation", "query");
|
|
|
|
|
+ request.put("text", text);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("app", app);
|
|
|
|
|
+ body.put("user", user);
|
|
|
|
|
+ body.put("audio", audio);
|
|
|
|
|
+ body.put("request", request);
|
|
|
|
|
+ return body;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 创建带超时配置的 OkHttpClient
|
|
|
|
|
+ */
|
|
|
|
|
+ private OkHttpClient createHttpClient() {
|
|
|
|
|
+ return new OkHttpClient.Builder()
|
|
|
|
|
+ .connectTimeout(30, TimeUnit.SECONDS)
|
|
|
|
|
+ .readTimeout(60, TimeUnit.SECONDS)
|
|
|
|
|
+ .writeTimeout(60, TimeUnit.SECONDS)
|
|
|
|
|
+ .build();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|