diff --git a/pom.xml b/pom.xml index 8faba20..61e502b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,47 +1,78 @@ - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.0.5 - - - com.iffomko - Voice-Assistant - 0.0.1-SNAPSHOT - Voice-Assistant - Voice-Assistant - - 17 - - - - org.springframework.boot - spring-boot-starter-web - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.0.5 + + + com.iffomko + Voice-Assistant + 0.0.1-SNAPSHOT + Voice-Assistant + Voice-Assistant + + 17 + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + com.fasterxml.jackson.core + jackson-core + 2.15.0-rc3 + + + org.projectlombok + lombok + + + org.hibernate.orm + hibernate-core + 6.2.1.Final + + + org.postgresql + postgresql + 42.6.0 + + + io.jsonwebtoken + jjwt + 0.9.1 + + + javax.xml.bind + jaxb-api + 2.4.0-b180830.0359 + + - - org.springframework.boot - spring-boot-devtools - runtime - true - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - + + + + org.springframework.boot + spring-boot-maven-plugin + 3.0.5 + + + diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/AIModel.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/AIModel.java new file mode 100644 index 0000000..a533ec0 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/AIModel.java @@ -0,0 +1,85 @@ +package com.iffomko.voiceAssistant.APIs.openAI; + +import com.iffomko.voiceAssistant.APIs.openAI.data.ModelResponse; +import com.iffomko.voiceAssistant.APIs.openAI.data.OpenAIMessage; +import com.iffomko.voiceAssistant.APIs.openAI.types.OpenAIModels; +import com.iffomko.voiceAssistant.APIs.openAI.data.ModelBodyDTO; +import com.iffomko.voiceAssistant.APIs.openAI.types.OpenAIRole; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +/** + *

+ * Класс-репозиторий, который умеет подключаться к OpenAI API и задавать вопросы модели + *

+ */ +@Slf4j +public class AIModel { + private OpenAIModels model; + private final String apiKey; + private final static String URL = "https://api.openai.com/v1/chat/completions"; + + /** + *

Создает объект этого класса, который делает запросы к модели

+ * @param apiKey ключ, с помощью которого можно сделать запрос + */ + public AIModel(String apiKey) { + this.apiKey = apiKey; + this.model = OpenAIModels.CHAT_GPT_3_5; + } + + /** + *

Делает запрос к OpenAI API к той модели, которая установлена в соответствующем поле

+ * @param messages предыдущие сообщения в переписке + * @param mess текущий вопрос к модели + * @return возвращает ответ модели типа ModelResponse + */ + protected ModelResponse ask(List messages, String mess) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + apiKey); + + messages.add(new OpenAIMessage(OpenAIRole.USER.getRole(), mess)); + + HttpEntity request = new HttpEntity<>( + new ModelBodyDTO(model.getModel(), model.getTemperature(), messages), + headers + ); + + RestTemplate restTemplate = new RestTemplate(); + + ResponseEntity response = null; + + try { + response = restTemplate.exchange( + URL, HttpMethod.POST, request, ModelResponse.class + ); + } catch (RestClientException e) { + log.error(e.getMessage()); + } + + if (response == null || response.getBody() == null) { + log.error("Failed to make a request to ChatGPT"); + return null; + } + + log.info("The request to ChatGPT was successful"); + + return response.getBody(); + } + + public OpenAIModels getModel() { + return model; + } + + public void setModel(@NonNull OpenAIModels model) { + this.model = model; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/AIService.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/AIService.java new file mode 100644 index 0000000..9f6a72f --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/AIService.java @@ -0,0 +1,125 @@ +package com.iffomko.voiceAssistant.APIs.openAI; + +import com.iffomko.voiceAssistant.APIs.openAI.data.ModelResponse; +import com.iffomko.voiceAssistant.APIs.openAI.data.OpenAIChoices; +import com.iffomko.voiceAssistant.APIs.openAI.data.OpenAIMessage; +import com.iffomko.voiceAssistant.APIs.openAI.types.OpenAIRole; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; + +/** + *

+ * Класс-сервис, который настраивает модель соответствующим образом и хранит в себе контекст пользователя + * в течении одного часа, который помогает модели ориентироваться в сообщениях пользователя + *

+ *

+ * Также класс умеет само очищаться если время последнего взаимодействия пользователя + * модели с пользователем была более часа назад + *

+ */ +@Slf4j +public class AIService { + private final AIModel model; + private final Map> usersMessages; + private final Map modelLastInteraction; + + public AIService(String apiKey) { + model = new AIModel(apiKey); + usersMessages = new HashMap<>(); + modelLastInteraction = new HashMap<>(); + + new Thread(new ClearMessagesGarber(this, 10000)).start(); + } + + public AIModel getModel() { + return model; + } + + /** + *

+ * Перед запросом настраивает модели если это необходимо и подключается к OpenAI API, + * дожидается ответ и возвращает его + *

+ * @param userId id пользователя, который запросил ответ на свой вопрос + * @param message сам вопрос + * @return ответ на вопрос, который хранится в объекте типа ModelResponse + */ + public ModelResponse ask(int userId, String message) { + synchronized (usersMessages) { + synchronized (modelLastInteraction) { + if (usersMessages.get(userId) == null) { + usersMessages.put(userId, new ArrayList<>()); + modelLastInteraction.put(userId, new Date()); + } + } + + if (usersMessages.get(userId).isEmpty()) { + usersMessages.get(userId).add(new OpenAIMessage( + "user", + "Вы являетесь голосовым помощником под названием Jarvis. " + + "Нужно стараться давать ответ как можно короче, но максимально информативным. " + + "Формулировать ответы нужно в манере искусственного интеллекта Jarvis из фильмов " + + "\"Железный человек\". Реагировать на осуждение твоих ответов нужно в стиле Jarvis " + + "и ссылаться на то, что ты всего лишь программа, написанная другим программистом\n" + + "Каждый ответ должен укладываться в 5000 символов. Отвечать на это сообщение не надо") + ); + } + } + + ModelResponse response = model.ask(usersMessages.get(userId), message); + + if (response == null) { + return null; + } + + synchronized (usersMessages) { + synchronized (modelLastInteraction) { + usersMessages.get(userId).add(new OpenAIMessage(OpenAIRole.USER.getRole(), message)); + + for (OpenAIChoices choice : response.getChoices()) { + if (choice.getMessage().getRole().equals(OpenAIRole.ASSISTANT.getRole())) + usersMessages.get(userId).add(choice.getMessage()); + } + + modelLastInteraction.replace(userId, new Date()); + } + } + + return response; + } + + /** + *

Возвращает контекст со всеми пользователями, где ключ это userId, а значение это контекст

+ * @return контекст со всеми пользователями + */ + protected Map> getUsersMessages() { + return usersMessages; + } + + /** + *

+ * Возвращает дату последнего взаимодействия для каждого контекста, + * где ключ это userId, а значение дата последнего взаимодействия + *

+ *

Если контекста для пользователя не существует, то будет возвращено null

+ * @return сло + */ + protected Map getModelLastInteraction() { + return modelLastInteraction; + } + + /** + *

Удаляет контекст для конкретного пользователя

+ * @param userId id пользователя, контекст которого надо удалить + */ + public void clearMessages(int userId) { + synchronized (usersMessages) { + synchronized (modelLastInteraction) { + log.info("For user with id=" + userId + " history chat cleared"); + usersMessages.remove(userId); + modelLastInteraction.remove(userId); + } + } + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/ClearMessagesGarber.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/ClearMessagesGarber.java new file mode 100644 index 0000000..de39a25 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/ClearMessagesGarber.java @@ -0,0 +1,69 @@ +package com.iffomko.voiceAssistant.APIs.openAI; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import java.util.Date; +import java.util.Map; + +/** + *

+ * Этот класс отвечает за очистку сообщений в объектах AIService. + * Он очищает сообщения если срок последнего взаимодействия в этом чате на текущий момент больше 1 часа. + *

+ *

+ * Этот класс реализуется интерфейс Runnable, следовательно его нужно запускать в отдельном потоке, + * потому что иначе программа просто зависнет. + *

+ */ +@Slf4j +public class ClearMessagesGarber implements Runnable { + private final AIService service; + private final int timeout; + + private final static long TIME_LIVE = 3600000; + + + /** + *

+ * Конструктор класса ClearMessagesGarber. Он устанавливает время через которое этот класс будет + * производить очистку, а также сам сервис, в котором будет производится очистка. + *

+ * @param service сервис, в котором будет производиться очистка + * @param timeout время через которое будет производиться очистка + */ + public ClearMessagesGarber(@NonNull AIService service, @NonNull int timeout) { + this.service = service; + this.timeout = timeout; + } + + /** + *

+ * Когда объект реализуется интерфейс {@code Runnable} этот метод используется + * для создания потока, при запуске потока {@code run} метод этого объекта будет вызван + * в отдельно исполняющем потоке + *

+ */ + @Override + public void run() { + while (true) { + synchronized (service.getUsersMessages()) { + synchronized (service.getModelLastInteraction()) { + for (Map.Entry createdDate : service.getModelLastInteraction().entrySet()) { + Date currentDate = new Date(); + + if (Math.abs(currentDate.getTime() - createdDate.getValue().getTime()) > TIME_LIVE) { + service.clearMessages(createdDate.getKey()); + } + } + } + } + + try { + Thread.sleep(timeout); + } catch (InterruptedException e) { + log.error(e.getMessage()); + } + } + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/ModelBodyDTO.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/ModelBodyDTO.java new file mode 100644 index 0000000..32c9e84 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/ModelBodyDTO.java @@ -0,0 +1,23 @@ +package com.iffomko.voiceAssistant.APIs.openAI.data; + +import lombok.Data; + +import java.util.List; + +/** + *

Объект, который конвертируется в JSON для передачи в теле запроса к OpenAI API

+ */ +@Data +public class ModelBodyDTO { + private String model; + private Double temperature; + private List messages; + + public ModelBodyDTO() {} + + public ModelBodyDTO(String model, Double temperature, List messages) { + this.model = model; + this.temperature = temperature; + this.messages = messages; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/ModelResponse.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/ModelResponse.java new file mode 100644 index 0000000..7f7b8a7 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/ModelResponse.java @@ -0,0 +1,18 @@ +package com.iffomko.voiceAssistant.APIs.openAI.data; + +import lombok.Data; + +import java.util.List; + +/** + *

Объект, который хранит в себе ответ от OpenAI API

+ */ +@Data +public class ModelResponse { + private String id; + private String object; + private Integer created; + private String model; + private OpenAIUsage usage; + private List choices; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIChoices.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIChoices.java new file mode 100644 index 0000000..698271a --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIChoices.java @@ -0,0 +1,24 @@ +package com.iffomko.voiceAssistant.APIs.openAI.data; + +import lombok.Data; + +/** + *

+ * Объект, который хранит один ответ на вопрос от OpenAI API: + * сообщение, причина завершения и индекс в массиве таких ответов (ответов может быть несколько) + *

+ */ +@Data +public class OpenAIChoices { + private OpenAIMessage message; + private String finishReason; + private Integer index; + + public OpenAIChoices() {} + + public OpenAIChoices(OpenAIMessage message, String finishReason, Integer index) { + this.message = message; + this.finishReason = finishReason; + this.index = index; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIMessage.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIMessage.java new file mode 100644 index 0000000..fbac4bc --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIMessage.java @@ -0,0 +1,19 @@ +package com.iffomko.voiceAssistant.APIs.openAI.data; + +import lombok.Data; + +/** + *

Объект, который хранит в себе информацию о сообщении в ответе на вопрос от OpenAI API

+ */ +@Data +public class OpenAIMessage { + private String role; + private String content; + + public OpenAIMessage() {} + + public OpenAIMessage(String role, String content) { + this.role = role; + this.content = content; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIUsage.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIUsage.java new file mode 100644 index 0000000..b4c9742 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/data/OpenAIUsage.java @@ -0,0 +1,21 @@ +package com.iffomko.voiceAssistant.APIs.openAI.data; + +import lombok.Data; + +/** + *

Объект, который хранит в себе количество токенов вопроса, ответа и их сумму

+ */ +@Data +public class OpenAIUsage { + private Integer promptTokens; + private Integer completionTokens; + private Integer totalTokens; + + public OpenAIUsage() {} + + public OpenAIUsage(Integer promptTokens, Integer completionTokens, Integer totalTokens) { + this.promptTokens = promptTokens; + this.completionTokens = completionTokens; + this.totalTokens = totalTokens; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/types/OpenAIModels.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/types/OpenAIModels.java new file mode 100644 index 0000000..9974f37 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/types/OpenAIModels.java @@ -0,0 +1,25 @@ +package com.iffomko.voiceAssistant.APIs.openAI.types; + +/** + *

Перечисление всех моделей доступных для обращение к API OpenAI и их "температура"

+ */ +public enum OpenAIModels { + CHAT_GPT_3_5("gpt-3.5-turbo", 0.6), + CHAT_GPT_4("gpt-4", 0.6); + + private final String model; + private final Double temperature; + + OpenAIModels(String model, Double temperature) { + this.model = model; + this.temperature = temperature; + } + + public String getModel() { + return model; + } + + public Double getTemperature() { + return temperature; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/types/OpenAIRole.java b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/types/OpenAIRole.java new file mode 100644 index 0000000..da0c737 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/openAI/types/OpenAIRole.java @@ -0,0 +1,20 @@ +package com.iffomko.voiceAssistant.APIs.openAI.types; + +/** + *

Перечисление, которое хранит в себе все роли, которые могут быть у сообщения

+ */ +public enum OpenAIRole { + USER("user"), + ASSISTANT("assistant"), + SYSTEM("system"); + + private final String role; + + OpenAIRole(String role) { + this.role = role; + } + + public String getRole() { + return role; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexClient.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexClient.java new file mode 100644 index 0000000..f247170 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexClient.java @@ -0,0 +1,60 @@ +package com.iffomko.voiceAssistant.APIs.speech; + +import com.iffomko.voiceAssistant.APIs.speech.data.RecognitionResponse; + +/** + *

Клиент, который позволяет обращаться к Yandex API за распознаванием и синтезированием голоса

+ */ +public class YandexClient { + private final YandexRecognition recognition; + private final YandexSynthesis synthesis; + + /** + *

Создает класс YandexClient и заполняет его поля нужными значениями

+ * @param apiKey ключ от Yandex API + */ + public YandexClient(String apiKey) { + this.recognition = new YandexRecognition(apiKey); + this.synthesis = new YandexSynthesis(apiKey); + } + + /** + *

+ * Возвращает объект, который занимается распознаванием голоса. С помощью этого метода можно только настроить + * этот объект по свои нужды. + *

+ * @return объект для распознавания голоса + */ + public YandexRecognition getRecognition() { + return recognition; + } + + /** + *

+ * Возвращает объект, который занимается синтезированием голоса. С помощью этого метода можно только настроить + * этот объект по свои нужды. + *

+ * @return объект для синтезирования голоса + */ + public YandexSynthesis getSynthesis() { + return synthesis; + } + + /** + *

Метод, который позволяет распознать речь в входящем аудио

+ * @param voice входящее аудио в виде массива байтов + * @return объект RecognitionResponse, в котором содержится в поле result распознанный текст + */ + public RecognitionResponse recognise(byte[] voice) { + return recognition.recognition(voice); + } + + /** + *

Метод, который позволяет синтезировать речь

+ * @param text текст, который надо озвучить + * @return массив байтов синтезированной речи + */ + public byte[] synthesis(String text) { + return synthesis.synthesize(text); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexRecognition.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexRecognition.java new file mode 100644 index 0000000..3aa91b2 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexRecognition.java @@ -0,0 +1,181 @@ +package com.iffomko.voiceAssistant.APIs.speech; + +import com.iffomko.voiceAssistant.APIs.speech.data.RecognitionResponse; +import com.iffomko.voiceAssistant.APIs.speech.types.YandexLanguage; +import com.iffomko.voiceAssistant.APIs.speech.types.YandexTopic; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; + +/** + *

Класс, который умеет делать запросы к Yandex API и с её помощью синтезировать речь

+ */ +@Slf4j +public class YandexRecognition { + private final String apiKey; + private YandexLanguage lang = null; + private YandexTopic topic = null; + private boolean profanityFilter = false; + private String format = "oggopus"; + public final static String URL = "https://stt.api.cloud.yandex.net/speech/v1/stt:recognize"; + + /** + *

Конструктор, который принимает ключ от Yandex API, чтобы можно авторизоваться с его помощью в системе

+ * @param apiKey ключ от Yandex API для авторизации + */ + public YandexRecognition(String apiKey) { + this.apiKey = apiKey; + } + + /** + *

Формирует строчку для Query параметров для запроса

+ * @return строка содержащая query параметры + */ + private String getQueryParams() { + StringBuilder queries = new StringBuilder(); + + if (lang != null) { + queries.append("lang="); + queries.append(lang.getLang()); + } + + if (topic != null) { + if (!queries.isEmpty()) { + queries.append("&"); + } + + queries.append("topic="); + queries.append(topic.getTopic()); + } + + if (!queries.isEmpty()) { + queries.append("&"); + } + + queries.append("profanityFilter="); + queries.append(profanityFilter); + + if (!queries.isEmpty()) { + queries.append("&"); + } + + queries.append("format="); + queries.append(format); + + return queries.toString(); + } + + /** + *

Делает запрос к Yandex API за распознаванием речи по входному аудио

+ * @param voice массив байтов аудио, которое нужно распознать + * @return объект RecognitionResponse, содержащий единственное поле result с распознанным текстом + */ + protected RecognitionResponse recognition(byte[] voice) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + headers.set("Authorization", "Api-Key " + apiKey); + + HttpEntity request = new HttpEntity<>(voice, headers); + + ResponseEntity response = null; + + try { + response = restTemplate.exchange( + URL + "?" + getQueryParams(), + HttpMethod.POST, + request, + RecognitionResponse.class + ); + } catch (RestClientException e) { + log.error(e.getMessage()); + } + + if (response == null || response.getBody() == null) { + log.error("Failed to recognize speech from Yandex API"); + return null; + } + + log.info("The Yandex API text recognition request was executed successfully"); + + return response.getBody(); + } + + /** + *

Возвращает язык, который нужно распознавать, в настройках этого класса

+ * @return объект YandexLanguage, который содержит распознаваемый язык + */ + public YandexLanguage getLang() { + return lang; + } + + /** + *

Устанавливает распознаваемый язык для текущих настроек класса

+ * @param lang объект YandexLanguage, содержащий распознаваемый язык + */ + public void setLang(YandexLanguage lang) { + this.lang = lang; + } + + /** + *

Возвращает название модели распознавания в настройках этого класса

+ * @return объект YandexTopic, содержащий название модели распознавания + */ + public YandexTopic getTopic() { + return topic; + } + + /** + *

Устанавливает модель распознавания для настроек этого класса

+ * @param topic объект YandexTopic, содержащий название модели распознавания + */ + public void setTopic(YandexTopic topic) { + this.topic = topic; + } + + /** + *

+ * Возвращает значение поля profanityFilter. + * Если установлен false, то ненормативная лексика не будет исключена из распознавания. + * Если установлен true, то нормативная лекция будет исключена из распознавания. + *

+ * @return возвращает значение поля profanityFilter + */ + public boolean isProfanityFilter() { + return profanityFilter; + } + + /** + *

+ * Устанавливает значение true или false для поля profanityFilter. + * Если установлен false, то ненормативная лексика не будет исключена из распознавания. + * Если установлен true, то нормативная лекция будет исключена из распознавания. + *

+ * @param profanityFilter true или false - значение, которое нужно установить полю + */ + public void setProfanityFilter(@NonNull boolean profanityFilter) { + this.profanityFilter = profanityFilter; + } + + /** + *

Возвращает формат входного аудио (mp3, oggopus, lpcm)

+ * @return возвращает формат входящего аудио + */ + public String getFormat() { + return format; + } + + /** + *

Устанавливает формат входного аудио в настройках этого класса

+ * @param format формат входящего аудио (mp3, oggopus, lpcm) + */ + public void setFormat(@NonNull String format) { + this.format = format; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexSynthesis.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexSynthesis.java new file mode 100644 index 0000000..5907914 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/YandexSynthesis.java @@ -0,0 +1,99 @@ +package com.iffomko.voiceAssistant.APIs.speech; + +import com.iffomko.voiceAssistant.APIs.speech.types.YandexVoice; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + *

Класс, который умеет делать запросы к Yandex API за синтезом речи

+ */ +@Slf4j +public class YandexSynthesis { + private final String apiKey; + private YandexVoice voice = YandexVoice.FILIPP; + private String format = "oggopus"; + + public static final String URL = "https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize\n"; + + /** + *

Конструктор, который принимает ключ от Yandex API, чтобы можно авторизоваться с его помощью в системе

+ * @param apiKey ключ от Yandex API для авторизации + */ + public YandexSynthesis(String apiKey) { + this.apiKey = apiKey; + } + + /** + *

Делает запрос на Yandex API, чтобы синтезировать речь по тексту

+ * @param text текст, который необходимо озвучить + * @return массив байтов озвученного текста + */ + protected byte[] synthesize(@NonNull String text) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.set("Authorization", "Api-Key " + apiKey); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("text", text); + body.add("voice", voice.getVoice()); + body.add("lang", voice.getLang().getLang()); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = null; + + try { + response = restTemplate.exchange(URL, HttpMethod.POST, request, byte[].class); + } catch (RestClientException e) { + log.error(e.getMessage()); + } + + if (response == null || response.getBody() == null) { + log.error("Failed to synthesize speech by text from Yandex API"); + return null; + } + + log.info("Successfully synthesized speech using Yandex API"); + + return response.getBody(); + } + + /** + *

Возвращает голос для синтеза речи, установленный в настройках этого класса

+ * @return объект YandexVoice, в котором хранится текущий голос в настройках + */ + public YandexVoice getVoice() { + return voice; + } + + /** + *

Устанавливает голос для синтеза речи

+ * @param voice голос для синтеза речи, который нужно установить + */ + public void setVoice(@NonNull YandexVoice voice) { + this.voice = voice; + } + + /** + *

Возвращает формат выходной аудиодорожки (mp3, oggopus, lpcm)

+ * @return формат аудиодорожки + */ + public String getFormat() { + return format; + } + + /** + *

Устанавливает формат для выходной аудиодорожки (mp3, oggopus, lpcm)

+ * @param format формат аудиодорожки + */ + public void setFormat(@NonNull String format) { + this.format = format; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/data/RecognitionResponse.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/data/RecognitionResponse.java new file mode 100644 index 0000000..99887fa --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/data/RecognitionResponse.java @@ -0,0 +1,11 @@ +package com.iffomko.voiceAssistant.APIs.speech.data; + +import lombok.Data; + +/** + *

Объект ответа, в котором содержится одно единственное поле result, которое содержит распознанный текст

+ */ +@Data +public class RecognitionResponse { + private String result; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexFormat.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexFormat.java new file mode 100644 index 0000000..92380ac --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexFormat.java @@ -0,0 +1,24 @@ +package com.iffomko.voiceAssistant.APIs.speech.types; + +/** + *

Перечисление, которое содержит доступные форматы для аудио

+ */ +public enum YandexFormat { + MP3("mp3"), + OGGOPUS("oggopus"), + LPCM("lpcm"); + + private final String format; + + YandexFormat(String format) { + this.format = format; + } + + /** + *

Возвращает формат для аудио

+ * @return формат + */ + public String getFormat() { + return format; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexLanguage.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexLanguage.java new file mode 100644 index 0000000..8d32653 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexLanguage.java @@ -0,0 +1,61 @@ +package com.iffomko.voiceAssistant.APIs.speech.types; + +/** + *

Перечисление всех возможных языков доступных для распознавания для запроса

+ */ +public enum YandexLanguage { + /** + *

Русский язык

+ */ + RUSSIAN("ru-RU"), + /** + *

Английский язык

+ */ + ENGLISH("en-US"), + /** + *

Немецкий язык

+ */ + GERMANY("de-DE"), + /** + *

Испанский язык

+ */ + SPANISH("es-ES"), + /** + *

Французский язык

+ */ + FRENCH("fr-FR"), + /** + *

Казахский язык

+ */ + KAZAKH("kk-KK"), + /** + *

Тюркский язык

+ */ + TURKISH("tr-TR"), + /** + *

Польский язык

+ */ + POLISH("pl-PL"), + /** + *

Итальянский язык

+ */ + ITALIAN("it-IT"); + + private final String lang; + + /** + *

Устанавливает язык для распозновательной модели

+ * @param lang - язык + */ + YandexLanguage(String lang) { + this.lang = lang; + } + + /** + *

Возвращает язык для распозновательной модели

+ * @return - язык + */ + public String getLang() { + return lang; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexTopic.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexTopic.java new file mode 100644 index 0000000..0ee27ee --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexTopic.java @@ -0,0 +1,37 @@ +package com.iffomko.voiceAssistant.APIs.speech.types; + +/** + *

Перечисление всех версий моделей распознавания доступных для запроса

+ */ +public enum YandexTopic { + /** + *

Основная версия модели

+ */ + GENERAL("general"), + /** + *

Версия-кандидат для релиза, которую вы можете тестировать

+ */ + GENERAL_RC("general:rc"), + /** + *

Предыдущая версия модели

+ */ + GENERAL_DEPRECATED("general:deprecated"); + + private final String topic; + + /** + *

Устанавливает текущее значение распозновательной модели

+ * @param topic - версия модели + */ + YandexTopic(String topic) { + this.topic = topic; + } + + /** + *

Возвращает тег распазновательной модели

+ * @return - тег модели + */ + public String getTopic() { + return topic; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexVoice.java b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexVoice.java new file mode 100644 index 0000000..ef2de61 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/APIs/speech/types/YandexVoice.java @@ -0,0 +1,42 @@ +package com.iffomko.voiceAssistant.APIs.speech.types; + +/** + *

Перечисление всех голосов, которые доступны для синтеза голоса

+ */ +public enum YandexVoice { + FILIPP("filipp", YandexLanguage.RUSSIAN), + ALENA("alena", YandexLanguage.RUSSIAN), + ERMIL("ermil", YandexLanguage.RUSSIAN), + JANE("jane", YandexLanguage.RUSSIAN), + MADIRUS("madirus", YandexLanguage.RUSSIAN), + OMAZH("omazh", YandexLanguage.RUSSIAN), + ZAHAR("zahar", YandexLanguage.RUSSIAN), + LEA("lea", YandexLanguage.GERMANY), + JOHN("john", YandexLanguage.ENGLISH), + AMIRA("amira", YandexLanguage.KAZAKH), + MADI("madi", YandexLanguage.RUSSIAN); + + private final String voice; + private final YandexLanguage lang; + + YandexVoice(String voice, YandexLanguage lang) { + this.lang = lang; + this.voice = voice; + } + + /** + *

Возвращает язык для синтеза речи

+ * @return объект YandexLanguage, содержащий язык для синтеза речи + */ + public YandexLanguage getLang() { + return lang; + } + + /** + *

Возвращает название голоса для синтеза речи

+ * @return название голоса + */ + public String getVoice() { + return voice; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/VoiceAssistantApplication.java b/src/main/java/com/iffomko/voiceAssistant/VoiceAssistantApplication.java index 4ca1014..69659d4 100644 --- a/src/main/java/com/iffomko/voiceAssistant/VoiceAssistantApplication.java +++ b/src/main/java/com/iffomko/voiceAssistant/VoiceAssistantApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class VoiceAssistantApplication { - public static void main(String[] args) { - SpringApplication.run(VoiceAssistantApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(VoiceAssistantApplication.class, args); + } } diff --git a/src/main/java/com/iffomko/voiceAssistant/configs/SecurityConfig.java b/src/main/java/com/iffomko/voiceAssistant/configs/SecurityConfig.java new file mode 100644 index 0000000..32083dc --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/configs/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.iffomko.voiceAssistant.configs; + +import com.iffomko.voiceAssistant.security.jwt.JwtConfigure; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + private final JwtConfigure jwtConfigure; + private final UserDetailsService userDetailsService; + + @Autowired + public SecurityConfig( + JwtConfigure jwtConfigure, + @Qualifier("userDetailsServiceDao") UserDetailsService userDetailsService + ) { + this.userDetailsService = userDetailsService; + this.jwtConfigure = jwtConfigure; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf().disable() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeHttpRequests() + .requestMatchers("/").permitAll() + .requestMatchers("/api/v1/auth/login").permitAll() + .requestMatchers("/api/v1/auth/register").permitAll() + .anyRequest().authenticated() + .and() + .apply(jwtConfigure); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } + + @Bean("authenticationManager") + public AuthenticationManager authenticationManager() { + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); + authenticationProvider.setPasswordEncoder(passwordEncoder()); + authenticationProvider.setUserDetailsService(userDetailsService); + + return new ProviderManager(authenticationProvider); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/configs/VoiceAssistantConfig.java b/src/main/java/com/iffomko/voiceAssistant/configs/VoiceAssistantConfig.java new file mode 100644 index 0000000..034db21 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/configs/VoiceAssistantConfig.java @@ -0,0 +1,33 @@ +package com.iffomko.voiceAssistant.configs; + +import com.iffomko.voiceAssistant.APIs.openAI.AIService; +import com.iffomko.voiceAssistant.APIs.speech.YandexClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan("com.iffomko.voiceAssistant") +public class VoiceAssistantConfig { + /** + * Создает бин клиента по работе с YandexAPI + * @return возвращает этот объект + */ + @Bean("yandexClient") + public YandexClient getYandexRecognition() { + String apiKey = System.getenv("API_KEY"); + + return new YandexClient(apiKey); + } + + /** + * Создает бин клиента по работе с OpenAI API + * @return возвращает этот объект + */ + @Bean("AIService") + public AIService getAIService() { + String apiKey = System.getenv("OPEN_AI_API_KEY"); + + return new AIService(apiKey); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/AnswerController.java b/src/main/java/com/iffomko/voiceAssistant/controllers/AnswerController.java new file mode 100644 index 0000000..ce543ec --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/AnswerController.java @@ -0,0 +1,80 @@ +package com.iffomko.voiceAssistant.controllers; + +import com.iffomko.voiceAssistant.APIs.speech.types.YandexFormat; +import com.iffomko.voiceAssistant.controllers.errors.AnswerError; +import com.iffomko.voiceAssistant.controllers.data.AnswerRequestDTO; +import com.iffomko.voiceAssistant.controllers.data.AnswerResponseDTO; +import com.iffomko.voiceAssistant.controllers.services.AnswerService; +import com.iffomko.voiceAssistant.security.jwt.JwtTokenProvider; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/answer") +public class AnswerController { + private final AnswerService answerService; + private final JwtTokenProvider jwtTokenProvider; + + @Autowired + public AnswerController( + @Qualifier("answerService") AnswerService answerService, + JwtTokenProvider jwtTokenProvider + ) { + this.answerService = answerService; + this.jwtTokenProvider = jwtTokenProvider; + } + + /** + *

Контроллер, который отвечает за ответ на вопрос, который содержится в аудио

+ */ + @PostMapping(produces="application/json") + @PreAuthorize("hasAuthority('get:answer')") + public ResponseEntity getAnswer( + @RequestBody AnswerRequestDTO body, + HttpServletRequest httpServletRequest + ) { + if ( + body.getFormat() != null && + !body.getFormat().equals(YandexFormat.OGGOPUS.getFormat()) && + !body.getFormat().equals(YandexFormat.MP3.getFormat()) && + !body.getFormat().equals(YandexFormat.LPCM.getFormat()) + ) { + AnswerError error = new AnswerError(); + error.setCode(HttpStatus.BAD_REQUEST.value()); + error.setMessage("Вы ввели некорректный формат аудио: " + body.getFormat()); + + return ResponseEntity.badRequest().body(new AnswerResponseDTO(error)); + } + + if (body.getFormat() == null) { + body.setFormat(YandexFormat.OGGOPUS.getFormat()); + } + + if (body.getProfanityFilter() == null) { + body.setProfanityFilter(false); + } + + String response = answerService.getAnswer( + jwtTokenProvider.getUsername(jwtTokenProvider.resolveToken(httpServletRequest)), + body.getAudio(), + body.getFormat(), + body.getProfanityFilter() + ); + + if (response == null) { + AnswerError error = new AnswerError(); + error.setCode(HttpStatus.BAD_REQUEST.value()); + error.setMessage("Не удалось получить ответ"); + + return ResponseEntity.badRequest().body(new AnswerResponseDTO(error)); + } + + return ResponseEntity.ok(new AnswerResponseDTO(response, "base64", body.getFormat())); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/AuthenticationController.java b/src/main/java/com/iffomko/voiceAssistant/controllers/AuthenticationController.java new file mode 100644 index 0000000..70893b0 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/AuthenticationController.java @@ -0,0 +1,133 @@ +package com.iffomko.voiceAssistant.controllers; + +import com.iffomko.voiceAssistant.controllers.data.AuthenticationRequestDTO; +import com.iffomko.voiceAssistant.controllers.data.AuthenticationResponseDTO; +import com.iffomko.voiceAssistant.controllers.data.RegistrationRequestDTO; +import com.iffomko.voiceAssistant.controllers.data.RegistrationResponseDTO; +import com.iffomko.voiceAssistant.controllers.errors.AuthenticationError; +import com.iffomko.voiceAssistant.db.entities.Role; +import com.iffomko.voiceAssistant.db.entities.User; +import com.iffomko.voiceAssistant.db.services.dao.UserDao; +import com.iffomko.voiceAssistant.security.jwt.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +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.RestController; + +/** + * Контроллер аутентификации. Именно здесь происходит авторизация и регистрация пользователей + */ +@RestController +@RequestMapping("/api/v1/auth") +@Slf4j +public class AuthenticationController { + private final AuthenticationManager authenticationManager; + private final UserDao userDao; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; + + /** + * Конструктор, который инициализирует нужные поля объектами + * @param authenticationManager менеджер аутентификации + * @param userDao DAO объект, который отвечает за работу с пользователями (сущность User) + * @param jwtTokenProvider объект JwtTokenProvider, в нем содержится вся основная логика по работе + * с JWT токеном + * @param passwordEncoder любой объект реализующий интерфейс PasswordEncoder + */ + @Autowired + public AuthenticationController( + @Qualifier("authenticationManager") AuthenticationManager authenticationManager, + UserDao userDao, + JwtTokenProvider jwtTokenProvider, + PasswordEncoder passwordEncoder + ) { + this.authenticationManager = authenticationManager; + this.jwtTokenProvider = jwtTokenProvider; + this.userDao = userDao; + this.passwordEncoder = passwordEncoder; + } + + /** + * Эндпоинт, который отвечает за авторизацию пользователя в системе + * @param body объект AuthenticationRequestDTO, который содержит данные пользователя для входа в систему + * @return ответ ResponseEntity + */ + @PostMapping("/login") + public ResponseEntity authenticate(@RequestBody AuthenticationRequestDTO body) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(body.getUsername(), body.getPassword()) + ); + + User user = userDao.findUserByUsername(body.getUsername()); + String jwtToken = jwtTokenProvider.createToken( + user.getUsername(), + user.getRole() + ); + + return ResponseEntity.ok(new AuthenticationResponseDTO(user.getUsername(), jwtToken, null)); + } catch (AuthenticationException e) { + log.error(e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body( + new AuthenticationResponseDTO( + null, + null, + new AuthenticationError("Вы не вошли в систему") + ) + ); + } + } + + /** + * Эндпоинт, который позволяет выйти со системы (по сути очищает SecurityContext и делает сессию не валидной) + * @param request сервлет запроса клиента + * @param response сервлет ответа для клиента + */ + @PostMapping("/logout") + public void logout(HttpServletRequest request, HttpServletResponse response) { + SecurityContextLogoutHandler securityContextLogoutHandler = new SecurityContextLogoutHandler(); + securityContextLogoutHandler.logout(request, response, null); + } + + /** + * Регистрирует пользователя в системе + * @param body объект RegistrationRequestDTO, который содержит данные для регистрации пользователя + * @return объект ResponseEntity ответа пользователю + */ + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegistrationRequestDTO body) { + User user = userDao.findUserByUsername(body.getUsername()); + + if (user != null) { + return ResponseEntity.badRequest().body(new RegistrationResponseDTO( + HttpStatus.BAD_REQUEST.value(), + "Вы уже зарегистрированы в системе" + )); + } + + user = new User(); + user.setUsername(body.getUsername()); + user.setPassword(passwordEncoder.encode(body.getPassword())); + user.setName(body.getName()); + user.setSurname(body.getSurname()); + user.setRole(Role.USER); + + userDao.addUser(user); + + return ResponseEntity.ok( + new RegistrationResponseDTO(HttpStatus.OK.value(), "Вы были успешно зарегистрированы") + ); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/data/AnswerRequestDTO.java b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AnswerRequestDTO.java new file mode 100644 index 0000000..8f4caed --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AnswerRequestDTO.java @@ -0,0 +1,11 @@ +package com.iffomko.voiceAssistant.controllers.data; + +import lombok.Data; + +@Data +public class AnswerRequestDTO { + private String audio; + private String format; + private Boolean profanityFilter; + private String encode; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/data/AnswerResponseDTO.java b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AnswerResponseDTO.java new file mode 100644 index 0000000..023b89e --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AnswerResponseDTO.java @@ -0,0 +1,24 @@ +package com.iffomko.voiceAssistant.controllers.data; + +import com.iffomko.voiceAssistant.controllers.errors.AnswerError; +import lombok.Data; + +@Data +public class AnswerResponseDTO { + private String voice = null; + private String encode = null; + private String format = null; + private AnswerError error; + + public AnswerResponseDTO() {} + + public AnswerResponseDTO(String byteAudio, String encode, String format) { + this.voice = byteAudio; + this.encode = encode; + this.format = format; + } + + public AnswerResponseDTO(AnswerError error) { + this.error = error; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/data/AuthenticationRequestDTO.java b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AuthenticationRequestDTO.java new file mode 100644 index 0000000..0f92060 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AuthenticationRequestDTO.java @@ -0,0 +1,9 @@ +package com.iffomko.voiceAssistant.controllers.data; + +import lombok.Data; + +@Data +public class AuthenticationRequestDTO { + private String username; + private String password; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/data/AuthenticationResponseDTO.java b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AuthenticationResponseDTO.java new file mode 100644 index 0000000..93417b2 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/data/AuthenticationResponseDTO.java @@ -0,0 +1,19 @@ +package com.iffomko.voiceAssistant.controllers.data; + +import com.iffomko.voiceAssistant.controllers.errors.AuthenticationError; +import lombok.Data; + +@Data +public class AuthenticationResponseDTO { + private String username; + private String token; + private AuthenticationError error; + + public AuthenticationResponseDTO() {} + + public AuthenticationResponseDTO(String username, String token, AuthenticationError error) { + this.username = username; + this.token = token; + this.error = error; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/data/RegistrationRequestDTO.java b/src/main/java/com/iffomko/voiceAssistant/controllers/data/RegistrationRequestDTO.java new file mode 100644 index 0000000..8381638 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/data/RegistrationRequestDTO.java @@ -0,0 +1,11 @@ +package com.iffomko.voiceAssistant.controllers.data; + +import lombok.Data; + +@Data +public class RegistrationRequestDTO { + private String username; + private String name; + private String surname; + private String password; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/data/RegistrationResponseDTO.java b/src/main/java/com/iffomko/voiceAssistant/controllers/data/RegistrationResponseDTO.java new file mode 100644 index 0000000..20cd9e7 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/data/RegistrationResponseDTO.java @@ -0,0 +1,13 @@ +package com.iffomko.voiceAssistant.controllers.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RegistrationResponseDTO { + private int code; + private String message; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/errors/AnswerError.java b/src/main/java/com/iffomko/voiceAssistant/controllers/errors/AnswerError.java new file mode 100644 index 0000000..d6cdb96 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/errors/AnswerError.java @@ -0,0 +1,12 @@ +package com.iffomko.voiceAssistant.controllers.errors; + +import lombok.Data; + +/** + *

Объект ошибки. Он сериализуется в JSON, а так же содержит в себе сообщение ошибки и код ошибки

+ */ +@Data +public class AnswerError { + private String message; + private int code; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/errors/AuthenticationError.java b/src/main/java/com/iffomko/voiceAssistant/controllers/errors/AuthenticationError.java new file mode 100644 index 0000000..95ca8ed --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/errors/AuthenticationError.java @@ -0,0 +1,14 @@ +package com.iffomko.voiceAssistant.controllers.errors; + +import lombok.Data; + +@Data +public class AuthenticationError { + private String message; + + public AuthenticationError() {} + + public AuthenticationError(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/controllers/services/AnswerService.java b/src/main/java/com/iffomko/voiceAssistant/controllers/services/AnswerService.java new file mode 100644 index 0000000..c1b3d15 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/controllers/services/AnswerService.java @@ -0,0 +1,140 @@ +package com.iffomko.voiceAssistant.controllers.services; + +import com.iffomko.voiceAssistant.APIs.openAI.AIService; +import com.iffomko.voiceAssistant.APIs.openAI.data.ModelResponse; +import com.iffomko.voiceAssistant.APIs.openAI.data.OpenAIChoices; +import com.iffomko.voiceAssistant.APIs.openAI.types.OpenAIRole; +import com.iffomko.voiceAssistant.APIs.speech.YandexClient; +import com.iffomko.voiceAssistant.APIs.speech.data.RecognitionResponse; +import com.iffomko.voiceAssistant.APIs.speech.types.YandexVoice; +import com.iffomko.voiceAssistant.db.entities.User; +import com.iffomko.voiceAssistant.db.services.UserService; +import com.iffomko.voiceAssistant.security.jwt.JwtTokenProvider; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +import java.util.Base64; + +/** + *

Класс бизнес-логики формирования ответа на вопрос

+ */ +@Service +@Component +@Slf4j +@Scope("prototype") +public class AnswerService { + private final YandexClient yandexClient; + private final AIService chatGPT; + private final UserService userService; + + @Autowired + public AnswerService( + @Qualifier("yandexClient") YandexClient yandexClient, + @Qualifier("AIService") AIService chatGPT, + @Qualifier("UserDAO") UserService userService + ) { + this.yandexClient = yandexClient; + this.chatGPT = chatGPT; + this.userService = userService; + } + + /** + *

В параметрах передается звук в формате Base64 и на выходе возвращается текстовое содержание этого звука

+ * @param voice - звук в кодировке base64 + * @return - возвращается текст звука или null, если звук не закодирован в Base64 + */ + private String audioToText(String voice, String format, Boolean profanityFilter) { + byte[] bytes = null; + + try { + bytes = Base64.getDecoder().decode(voice); + } catch (IllegalArgumentException e) { + log.error(e.getMessage()); + return null; + } + + if (format != null) { + yandexClient.getRecognition().setFormat(format); + } + + if (profanityFilter != null) { + yandexClient.getRecognition().setProfanityFilter(profanityFilter); + } + + RecognitionResponse response = yandexClient.recognise(bytes); + + if (response == null) { + return null; + } + + return yandexClient.recognise(bytes).getResult(); + } + + /** + *

Переводит текст в звук

+ * @param text - текст, который нужно озвучить + * @param format - формат выходного звука (mp3, oggopus, lpcm) + * @return - звук, закодированный в Base64 + */ + private String textToVoice(String text, String format) { + if (format != null) { + yandexClient.getSynthesis().setFormat(format); + } + + yandexClient.getSynthesis().setVoice(YandexVoice.FILIPP); + + byte[] voice = yandexClient.synthesis(text); + + if (voice == null) { + return null; + } + + return Base64.getEncoder().encodeToString(voice); + } + + /** + *

Формирует ответ на вопрос пользователя

+ * @param username username текущего пользователя + * @param voice аудио-сообщение, в котором содержится вопрос + * @param format формат аудио-сообщения + * @param profanityFilter фильтр + * @return возвращает ответ на вопрос пользователя в виде аудиосообщения + */ + public String getAnswer( + @NonNull String username, + @NonNull String voice, + @NonNull String format, + @NonNull Boolean profanityFilter + ) { + User user = userService.findUserByUsername(username); + + if (user == null) { + return null; + } + + String text = audioToText(voice, format, profanityFilter); + ModelResponse response = chatGPT.ask(user.getId(), text); + + if (response == null) { + return null; + } + + StringBuilder answerBuilder = new StringBuilder(); + + for (OpenAIChoices choice : response.getChoices()) { + if (choice.getMessage().getRole().equals(OpenAIRole.ASSISTANT.getRole())) { + answerBuilder.append(choice.getMessage().getContent()); + answerBuilder.append("\n\n"); + } + } + + String answerChatGPT = answerBuilder.toString(); + + return textToVoice(answerChatGPT, format); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/db/entities/Permissions.java b/src/main/java/com/iffomko/voiceAssistant/db/entities/Permissions.java new file mode 100644 index 0000000..3beaaad --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/db/entities/Permissions.java @@ -0,0 +1,25 @@ +package com.iffomko.voiceAssistant.db.entities; + +/** + * Перечисления всех прав + */ +public enum Permissions { + /** + * Право на получения ответа + */ + GET_ANSWER("get:answer"); + + private final String permission; + + Permissions(String permission) { + this.permission = permission; + } + + /** + * Возвращает право + * @return право + */ + public String getPermission() { + return permission; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/db/entities/Role.java b/src/main/java/com/iffomko/voiceAssistant/db/entities/Role.java new file mode 100644 index 0000000..aaea7b0 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/db/entities/Role.java @@ -0,0 +1,38 @@ +package com.iffomko.voiceAssistant.db.entities; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; +import java.util.Set; + +/** + * Перечисление всех ролей + */ +public enum Role { + /** + * Роль пользователя + */ + USER(Set.of(Permissions.GET_ANSWER)); + + private final Set permissions; + + Role(Set permissions) { + this.permissions = permissions; + } + + /** + * Возвращает множество всех прав роли + */ + public Set getPermissions() { + return permissions; + } + + /** + * Возвращает список предоставленных прав пользователей в том виде, в котором удобно Spring Security + */ + public List getAuthorities() { + return getPermissions().stream().map( + permission -> new SimpleGrantedAuthority(permission.getPermission()) + ).toList(); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/db/entities/User.java b/src/main/java/com/iffomko/voiceAssistant/db/entities/User.java new file mode 100644 index 0000000..f634a50 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/db/entities/User.java @@ -0,0 +1,28 @@ +package com.iffomko.voiceAssistant.db.entities; + +import jakarta.persistence.*; +import lombok.Data; + +/** + * Сущность по работе с таблицей в базе данных пользователей + */ +@Entity +@Table(name = "users") +@Data +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private int id; + @Column(nullable = false) + private String username; + @Column(nullable = false) + private String name; + @Column(nullable = false) + private String surname; + @Column(nullable = false) + private String password; + @Enumerated(EnumType.STRING) + @Column(name = "role") + private Role role; +} diff --git a/src/main/java/com/iffomko/voiceAssistant/db/repositories/UserRepository.java b/src/main/java/com/iffomko/voiceAssistant/db/repositories/UserRepository.java new file mode 100644 index 0000000..de74453 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/db/repositories/UserRepository.java @@ -0,0 +1,13 @@ +package com.iffomko.voiceAssistant.db.repositories; + +import com.iffomko.voiceAssistant.db.entities.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + boolean existsUserByUsername(String username); +} diff --git a/src/main/java/com/iffomko/voiceAssistant/db/services/UserService.java b/src/main/java/com/iffomko/voiceAssistant/db/services/UserService.java new file mode 100644 index 0000000..966584d --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/db/services/UserService.java @@ -0,0 +1,42 @@ +package com.iffomko.voiceAssistant.db.services; + +import com.iffomko.voiceAssistant.db.entities.User; + +import java.util.List; + +/** + * Интерфейс, который фиксирует контракт по работе с таблицей в базе данных с пользователями + */ +public interface UserService { + /** + * Добавляет нового пользователя в систему + * @param user пользователь + */ + void addUser(User user); + + /** + * Удаляет существующего пользователя из системы по его id + * @param id уникальный идентификатор пользователя + * @return возвращает либо true в случае успешного удаления, либо false в случае неудачи + */ + boolean deleteUserById(int id); + + /** + * Возвращает существующего пользователя по его id + * @param id уникальный идентификатор пользователя + * @return объект типа User + */ + User getUserById(int id); + + /** + * Возвращает пользователя по его username + * @param username username пользователя + * @return объект типа User + */ + User findUserByUsername(String username); + + /** + * Возвращает список всех пользователей + */ + List getAllUsers(); +} diff --git a/src/main/java/com/iffomko/voiceAssistant/db/services/dao/UserDao.java b/src/main/java/com/iffomko/voiceAssistant/db/services/dao/UserDao.java new file mode 100644 index 0000000..b107e1e --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/db/services/dao/UserDao.java @@ -0,0 +1,84 @@ +package com.iffomko.voiceAssistant.db.services.dao; + +import com.iffomko.voiceAssistant.db.entities.User; +import com.iffomko.voiceAssistant.db.repositories.UserRepository; +import com.iffomko.voiceAssistant.db.services.UserService; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Реализация интерфейса UserService, которая умеет работать с пользователями (User) + */ +@Service("UserDAO") +@Transactional +public class UserDao implements UserService { + private final UserRepository repository; + + @Autowired + public UserDao(UserRepository repository) { + this.repository = repository; + } + + /** + * Добавляет нового пользователя в систему + * @param user пользователь + * @throws IllegalArgumentException если пользователь уже существует + */ + @Override + public void addUser(User user) throws IllegalArgumentException { + if (repository.existsUserByUsername(user.getUsername())) { + throw new IllegalArgumentException("Такое имя пользователя уже существует"); + } + + repository.save(user); + } + + /** + * Удаляет существующего пользователя из системы по его id + * @param id уникальный идентификатор пользователя + * @return возвращает либо true в случае успешного удаления, либо false в случае неудачи + */ + @Override + public boolean deleteUserById(int id) { + if (!repository.existsById(id)) { + return false; + } + + repository.deleteById(id); + + return true; + } + + /** + * Возвращает существующего пользователя по его id или возвращает null + * @param id уникальный идентификатор пользователя + * @return объект типа User + */ + @Override + public User getUserById(int id) { + return repository.findById(id).orElse(null); + } + + /** + * Возвращает пользователя по его username или возвращает null + * @param username username пользователя + * @return объект типа User + */ + @Override + public User findUserByUsername(String username) { + return repository.findByUsername(username).orElse(null); + } + + /** + * Возвращает список всех пользователей + */ + @Override + public List getAllUsers() { + return repository.findAll(); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/security/SecurityUser.java b/src/main/java/com/iffomko/voiceAssistant/security/SecurityUser.java new file mode 100644 index 0000000..5cbabf9 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/security/SecurityUser.java @@ -0,0 +1,79 @@ +package com.iffomko.voiceAssistant.security; + +import com.iffomko.voiceAssistant.db.entities.User; +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Data +public class SecurityUser implements UserDetails { + private final String username; + private final String password; + private final List authorities; + private final boolean isActive; + + /** + * Инициализирует нужные поля + * @param username username пользователя + * @param password пароль пользователя + * @param authorities список предоставленных прав пользователю + */ + public SecurityUser(String username, String password, List authorities) { + this.username = username; + this.password = password; + this.authorities = authorities; + this.isActive = true; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return isActive; + } + + @Override + public boolean isAccountNonLocked() { + return isActive; + } + + @Override + public boolean isCredentialsNonExpired() { + return isActive; + } + + @Override + public boolean isEnabled() { + return isActive; + } + + /** + * Конвертирует сущностью из базы данных User в класс SecurityUser + * @param user сущность из базы данных + * @return объект SecurityUser + */ + public static SecurityUser fromUser(User user) { + return new SecurityUser( + user.getUsername(), + user.getPassword(), + user.getRole().getAuthorities() + ); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/security/UserDetailsServiceDao.java b/src/main/java/com/iffomko/voiceAssistant/security/UserDetailsServiceDao.java new file mode 100644 index 0000000..5613e93 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/security/UserDetailsServiceDao.java @@ -0,0 +1,40 @@ +package com.iffomko.voiceAssistant.security; + +import com.iffomko.voiceAssistant.db.entities.User; +import com.iffomko.voiceAssistant.db.services.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * Реализует интерфейс для взаимодействия с UserDetails и умеет подгружать пользователя по его username + */ +@Service("userDetailsServiceDao") +public class UserDetailsServiceDao implements UserDetailsService { + private final UserService userService; + + @Autowired + public UserDetailsServiceDao(@Qualifier("UserDAO") UserService userService) { + this.userService = userService; + } + + /** + * Подгружает пользователя из базы данных по его username + * @param username username пользователя + * @return возвращает найденного пользователя (UserDetails) + * @throws UsernameNotFoundException выбрасывается если пользователь не существует + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userService.findUserByUsername(username); + + if (user == null) { + throw new UsernameNotFoundException("User not found"); + } + + return SecurityUser.fromUser(user); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtAuthenticationException.java b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtAuthenticationException.java new file mode 100644 index 0000000..80e1f6b --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtAuthenticationException.java @@ -0,0 +1,32 @@ +package com.iffomko.voiceAssistant.security.jwt; + + +import org.springframework.http.HttpStatus; + +/** + * Объект ошибки, который используется по работе с JWT токеном + */ +public class JwtAuthenticationException extends RuntimeException { + private final String message; + private final HttpStatus status; + + public JwtAuthenticationException(String message, HttpStatus status) { + this.message = message; + this.status = status; + } + + /** + * Возвращает сообщение ошибки + */ + @Override + public String getMessage() { + return message; + } + + /** + * Возвращает статус ошибки + */ + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtConfigure.java b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtConfigure.java new file mode 100644 index 0000000..3489395 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtConfigure.java @@ -0,0 +1,29 @@ +package com.iffomko.voiceAssistant.security.jwt; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.stereotype.Component; + +/** + * Конфигурация для фильтров JWT токена + */ +@Component +public class JwtConfigure extends SecurityConfigurerAdapter { + private final JwtTokenFilter jwtTokenFilter; + + @Autowired + public JwtConfigure(JwtTokenFilter jwtTokenFilter) { + this.jwtTokenFilter = jwtTokenFilter; + } + + /** + * Встраивает фильтр JWT токена в цепочку фильтров безопасности по обработке запроса + */ + @Override + public void configure(HttpSecurity httpSecurity) { + httpSecurity.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtTokenFilter.java b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtTokenFilter.java new file mode 100644 index 0000000..2322b97 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtTokenFilter.java @@ -0,0 +1,54 @@ +package com.iffomko.voiceAssistant.security.jwt; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * Фильтр, который спрашивает аутентификацию у всех запросов. + * Если её нет, то дальше он запрос не пропускает + */ +@Component +public class JwtTokenFilter extends GenericFilter { + private final JwtTokenProvider jwtTokenProvider; + + /** + * Инициализирует поля нужными значениями + * @param jwtTokenProvider объект по работе с JWT токеном + */ + @Autowired + public JwtTokenFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + /** + * Этот методы вызывает при обработке цепочки фильтров + * @param servletRequest запрос, который проходит обработку фильтров + * @param servletResponse ответ на запрос, который проходит обработку фильтров + * @param filterChain цепочка фильтров + */ + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException + { + String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest); + + try { + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (JwtAuthenticationException e) { + SecurityContextHolder.clearContext(); + throw new JwtAuthenticationException(e.getMessage(), e.getStatus()); + } + + filterChain.doFilter(servletRequest, servletResponse); + } +} diff --git a/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtTokenProvider.java b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..5fb7d17 --- /dev/null +++ b/src/main/java/com/iffomko/voiceAssistant/security/jwt/JwtTokenProvider.java @@ -0,0 +1,123 @@ +package com.iffomko.voiceAssistant.security.jwt; + +import com.iffomko.voiceAssistant.db.entities.Role; +import io.jsonwebtoken.*; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Date; + +/** + * Обеспечивает логику по взаимодействую с JWT токеном + */ +@Component +public class JwtTokenProvider { + private final UserDetailsService userDetailsService; + + @Value("${jwt.secretKey}") + private String secretKey; + @Value("${jwt.authorizationHeader}") + private String authorizationHeader; + @Value("${jwt.validityInMilliseconds}") + private long validityInMilliseconds; + @Value("${jwt.issuer}") + private String issuer; + + /** + * @param userDetailsService сервис по работе с пользователями + */ + @Autowired + public JwtTokenProvider(@Qualifier("userDetailsServiceDao") UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + /** + * Этот метод вызывает для инициализации бина на этапе конфигурации. + * Здесь мы кодируем секретный ключ для шифрования JWT токена + */ + @PostConstruct + protected void init() { + secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes()); + } + + /** + * Создает токен для какого-то пользователя по его username и роли + * @param username username пользователя + * @param role его роль + * @return JWT токен для конкретного пользователя + */ + public String createToken(String username, Role role) { + Claims claims = Jwts.claims(); + claims.setSubject(username); + claims.put("role", role); + + Date currentMoment = new Date(); + Date endMoment = new Date(currentMoment.getTime() + validityInMilliseconds * 1000); + + return Jwts.builder() + .addClaims(claims) + .setIssuedAt(currentMoment) + .setExpiration(endMoment) + .signWith(SignatureAlgorithm.HS256, secretKey) + .setIssuer(issuer) + .compact(); + } + + /** + * Проверяет JWT токен на корректность + * @param token сам токен + * @return возвращает true если токен валидный или false, если нет + * @throws JwtAuthenticationException выбрасывается если время токена вышла или он не валидный + */ + public boolean validateToken(String token) throws JwtAuthenticationException { + try { + Jws claimsJws = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + return !claimsJws.getBody().getExpiration().before(new Date()); + } catch (ExpiredJwtException | IllegalArgumentException e) { + throw new JwtAuthenticationException("Token is expiration or not valid", HttpStatus.UNAUTHORIZED); + } + } + + /** + * Возвращает аутентификацию для пользователя по его JWT токену + * @param token сам токен + * @return аутентификация пользователя + */ + public Authentication getAuthentication(String token) { + UserDetails userDetails = userDetailsService.loadUserByUsername(getUsername(token)); + + return new UsernamePasswordAuthenticationToken( + userDetails.getUsername(), + "", + userDetails.getAuthorities() + ); + } + + /** + * Возвращает username пользователя из JWT токена + * @param token сам токен + * @return username пользователя + */ + public String getUsername(String token) { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + } + + /** + * Возвращает токен из соответствующего заголовка в HTTP запросе клиента + * @param request HTTP запрос клиента + * @return JWT токен + */ + public String resolveToken(HttpServletRequest request) { + return request.getHeader(authorizationHeader); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b13789..b011a90 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,10 @@ - +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://localhost:8001/voice_assistant +spring.datasource.username=postgres +spring.datasource.password=123456 +spring.jpa.show-sql=true +jwt.secretKey=iffomko_development +jwt.authorizationHeader=Authorization +jwt.validityInMilliseconds=604800 +jwt.issuer=iffomko +server.max-http-header-size=10000000 \ No newline at end of file diff --git a/src/test/java/com/iffomko/voiceAssistant/VoiceAssistantApplicationTests.java b/src/test/java/com/iffomko/voiceAssistant/VoiceAssistantApplicationTests.java index a1a2a4d..c97cfef 100644 --- a/src/test/java/com/iffomko/voiceAssistant/VoiceAssistantApplicationTests.java +++ b/src/test/java/com/iffomko/voiceAssistant/VoiceAssistantApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class VoiceAssistantApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } }