From 9b715df09d00e6c41f2b8e2f0fb31fbf9a2d5acf Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Tue, 3 Feb 2026 05:42:46 +0200 Subject: [PATCH] =?UTF-8?q?=D0=A5=D1=8D=D0=BD=D0=B4=D1=88=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA,=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D1=8B,=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D1=82=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5,=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/im/database/Repository.java | 135 ++++++++++++++++++ .../rosetta/im/database/entity/Device.java | 8 +- .../database/repository/DeviceRepository.java | 22 ++- .../HandshakeDeviceConfirmEvent.java | 18 +++ .../im/executors/Executor0Handshake.java | 90 ++++++++++-- .../java/com/rosetta/im/service/Service.java | 23 +++ .../im/service/services/DeviceService.java | 48 +++++++ src/main/java/io/orprotocol/Server.java | 23 ++- .../java/io/orprotocol/client/Client.java | 4 +- src/main/java/io/orprotocol/lock/Lock.java | 23 +++ .../java/io/orprotocol/lock/ThreadLocker.java | 82 +++++++++++ src/main/resources/hibernate.cfg.xml | 1 + 12 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/rosetta/im/event/events/handshake/HandshakeDeviceConfirmEvent.java create mode 100644 src/main/java/com/rosetta/im/service/Service.java create mode 100644 src/main/java/com/rosetta/im/service/services/DeviceService.java create mode 100644 src/main/java/io/orprotocol/lock/Lock.java create mode 100644 src/main/java/io/orprotocol/lock/ThreadLocker.java diff --git a/src/main/java/com/rosetta/im/database/Repository.java b/src/main/java/com/rosetta/im/database/Repository.java index 2d0895f..d0e15cb 100644 --- a/src/main/java/com/rosetta/im/database/Repository.java +++ b/src/main/java/com/rosetta/im/database/Repository.java @@ -1,6 +1,7 @@ package com.rosetta.im.database; import java.util.HashMap; +import java.util.List; import org.hibernate.Session; import org.hibernate.Transaction; @@ -16,6 +17,11 @@ public abstract class Repository { this.entityClass = entityClass; } + /** + * Сохранение сущности в базе данных + * @param entity сущность для сохранения + * @return сохраненная сущность + */ public T save(T entity) { return executeInTransaction(session -> { session.persist(entity); @@ -23,6 +29,11 @@ public abstract class Repository { }); } + /** + * Обновление сущности в базе данных + * @param entity сущность для обновления + * @return обновленная сущность + */ public T update(T entity) { return executeInTransaction(session -> { session.merge(entity); @@ -30,6 +41,10 @@ public abstract class Repository { }); } + /** + * Удаление сущности из базы данных + * @param entity сущность для удаления + */ public void delete(T entity) { executeInTransaction(session -> { session.remove(entity); @@ -76,6 +91,126 @@ public abstract class Repository { }); } + /** + * Удаление сущностей по значению одного поля + * @param fieldName поле + * @param value значение + */ + public void deleteByField(String fieldName, Object value) { + executeInTransaction(session -> { + String queryString = "DELETE FROM " + entityClass.getSimpleName() + " WHERE " + fieldName + " = :value"; + session.createQuery(queryString, entityClass) + .setParameter("value", value) + .executeUpdate(); + return null; + }); + } + + /** + * Поиск всех сущностей по значению одного поля + * @param fieldName поле + * @param value значение + * @return список найденных сущностей + */ + public List findAllByField(String fieldName, Object value) { + return executeInSession(session -> { + String queryString = "FROM " + entityClass.getSimpleName() + " WHERE " + fieldName + " = :value"; + return session.createQuery(queryString, entityClass) + .setParameter("value", value) + .list(); + }); + } + + /** + * Поиск всех сущностей по значению набора полей + * @param fields карта полей и их значений + * @return список найденных сущностей + */ + public List findAllByField(HashMap fields) { + return executeInSession(session -> { + StringBuilder queryString = new StringBuilder("FROM " + entityClass.getSimpleName() + " WHERE "); + int index = 0; + for (String fieldName : fields.keySet()) { + if (index > 0) { + queryString.append(" AND "); + } + queryString.append(fieldName).append(" = :").append(fieldName); + index++; + } + var query = session.createQuery(queryString.toString(), this.entityClass); + for (var entry : fields.entrySet()) { + query.setParameter(entry.getKey(), entry.getValue()); + } + return query.list(); + }); + } + + /** + * Поиск всех сущностей, тяжелый метод, лучше не выполнять без необходимости + * @return список всех сущностей + */ + public List findAll() { + return executeInSession(session -> { + String queryString = "FROM " + entityClass.getSimpleName(); + return session.createQuery(queryString, entityClass).list(); + }); + } + + /** + * Подсчет всех сущностей в таблице + * @return количество сущностей + */ + public long countAll() { + return executeInSession(session -> { + String queryString = "SELECT COUNT(*) FROM " + entityClass.getSimpleName(); + return session.createQuery(queryString, Long.class).uniqueResult(); + }); + } + + /** + * Подсчет сущностей по значению одного поля + * @param fieldName поле + * @param value значение + * @return количество сущностей + */ + public long countByField(String fieldName, Object value) { + return executeInSession(session -> { + String queryString = "SELECT COUNT(*) FROM " + entityClass.getSimpleName() + " WHERE " + fieldName + " = :value"; + return session.createQuery(queryString, Long.class) + .setParameter("value", value) + .uniqueResult(); + }); + } + + /** + * Подсчет сущностей по набору полей + * @param fields карта полей и их значений + * @return количество сущностей + */ + public long countByField(HashMap fields) { + return executeInSession(session -> { + StringBuilder queryString = new StringBuilder("SELECT COUNT(*) FROM " + entityClass.getSimpleName() + " WHERE "); + int index = 0; + for (String fieldName : fields.keySet()) { + if (index > 0) { + queryString.append(" AND "); + } + queryString.append(fieldName).append(" = :").append(fieldName); + index++; + } + var query = session.createQuery(queryString.toString(), Long.class); + for (var entry : fields.entrySet()) { + query.setParameter(entry.getKey(), entry.getValue()); + } + return query.uniqueResult(); + }); + } + + /** + * Обновление полей сущности по заданным условиям + * @param fieldsToUpdate поля для обновления + * @param whereFields условия для выбора сущностей + */ public void update(HashMap fieldsToUpdate, HashMap whereFields) { executeInTransaction(session -> { StringBuilder queryString = new StringBuilder("UPDATE " + entityClass.getSimpleName() + " SET "); diff --git a/src/main/java/com/rosetta/im/database/entity/Device.java b/src/main/java/com/rosetta/im/database/entity/Device.java index fe40cae..53c4648 100644 --- a/src/main/java/com/rosetta/im/database/entity/Device.java +++ b/src/main/java/com/rosetta/im/database/entity/Device.java @@ -7,7 +7,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; import jakarta.persistence.Table; @Entity @@ -29,14 +28,9 @@ public class Device extends CreateUpdateEntity { /** * Время завершения сессии устройства */ - @Column(name = "leaveTime", nullable = true) + @Column(name = "leaveTime", nullable = true, columnDefinition = "bigint default 0") private Long leaveTime; - @PrePersist - protected void onCreate() { - this.leaveTime = 0L; - } - public Long getId() { return id; } diff --git a/src/main/java/com/rosetta/im/database/repository/DeviceRepository.java b/src/main/java/com/rosetta/im/database/repository/DeviceRepository.java index fd28cdd..6989f66 100644 --- a/src/main/java/com/rosetta/im/database/repository/DeviceRepository.java +++ b/src/main/java/com/rosetta/im/database/repository/DeviceRepository.java @@ -1,5 +1,25 @@ package com.rosetta.im.database.repository; -public class DeviceRepository { +import java.util.List; + +import com.rosetta.im.database.Repository; +import com.rosetta.im.database.entity.Device; +import com.rosetta.im.database.entity.User; + +public class DeviceRepository extends Repository { + + public DeviceRepository() { + super(Device.class); + } + + /** + * Найти все устройства пользователя + * @param user пользователь + * @return список устройств + */ + public List findAll(User user) { + return this.findAllByField("publicKey", user.getPublicKey()); + } + } diff --git a/src/main/java/com/rosetta/im/event/events/handshake/HandshakeDeviceConfirmEvent.java b/src/main/java/com/rosetta/im/event/events/handshake/HandshakeDeviceConfirmEvent.java new file mode 100644 index 0000000..120ce39 --- /dev/null +++ b/src/main/java/com/rosetta/im/event/events/handshake/HandshakeDeviceConfirmEvent.java @@ -0,0 +1,18 @@ +package com.rosetta.im.event.events.handshake; + +import com.rosetta.im.client.tags.ECIAuthentificate; +import com.rosetta.im.client.tags.ECIDevice; + +import io.orprotocol.client.Client; + +/** + * Вызывается когда устройство клиента нуждается в подтверждении + * пользователем с другого устрйоства для завершения хэндшейка. + */ +public class HandshakeDeviceConfirmEvent extends BaseHandshakeEvent { + + public HandshakeDeviceConfirmEvent(String publicKey, String privateKey, ECIDevice device, ECIAuthentificate eciAuthentificate, Client client) { + super(publicKey, privateKey, device, eciAuthentificate, client); + } + +} diff --git a/src/main/java/com/rosetta/im/executors/Executor0Handshake.java b/src/main/java/com/rosetta/im/executors/Executor0Handshake.java index 0f0b999..910c1b1 100644 --- a/src/main/java/com/rosetta/im/executors/Executor0Handshake.java +++ b/src/main/java/com/rosetta/im/executors/Executor0Handshake.java @@ -5,23 +5,32 @@ import com.rosetta.im.Configuration; import com.rosetta.im.Failures; import com.rosetta.im.client.tags.ECIAuthentificate; import com.rosetta.im.client.tags.ECIDevice; +import com.rosetta.im.database.entity.Device; import com.rosetta.im.database.entity.User; +import com.rosetta.im.database.repository.DeviceRepository; import com.rosetta.im.database.repository.UserRepository; import com.rosetta.im.event.events.handshake.HandshakeCompletedEvent; +import com.rosetta.im.event.events.handshake.HandshakeDeviceConfirmEvent; import com.rosetta.im.event.events.handshake.HandshakeFailedEvent; import com.rosetta.im.packet.Packet0Handshake; import com.rosetta.im.packet.enums.HandshakeStage; +import com.rosetta.im.service.services.DeviceService; import io.orprotocol.ProtocolException; import io.orprotocol.client.Client; +import io.orprotocol.lock.Lock; import io.orprotocol.packet.Packet; import io.orprotocol.packet.PacketExecutor; public class Executor0Handshake extends PacketExecutor { private final UserRepository userRepository = new UserRepository(); + private final DeviceRepository deviceRepository = new DeviceRepository(); + private final DeviceService deviceService = new DeviceService(deviceRepository); + @Override + @Lock(lockFor = "publicKey") public void onPacketReceived(Packet packet, Client client) throws ProtocolException { Packet0Handshake handshake = (Packet0Handshake) packet; String publicKey = handshake.getPublicKey(); @@ -31,7 +40,6 @@ public class Executor0Handshake extends PacketExecutor { String deviceOs = handshake.getDeviceOs(); int protocolVersion = handshake.getProtocolVersion(); AppContext context = (AppContext) this.getContext(); - /** * Получаем информацию об аутентификации клиента * используя возможности ECI тэгов. @@ -43,7 +51,6 @@ public class Executor0Handshake extends PacketExecutor { */ return; } - /** * Проверяем корректность версии протокола */ @@ -56,6 +63,7 @@ public class Executor0Handshake extends PacketExecutor { * Создаем минимальную информацию об устройстве клиента */ ECIDevice device = new ECIDevice(deviceId, deviceName, deviceOs); + client.addTag(device); /** * Проверяем есть ли такой пользователь @@ -100,10 +108,9 @@ public class Executor0Handshake extends PacketExecutor { /** * Отправляем клиенту подтверждение успешного хэндшейка */ - Packet0Handshake response = new Packet0Handshake(); - response.setHandshakeStage(HandshakeStage.COMPLETED); - response.setHeartbeatInterval(this.settings.heartbeatInterval); - client.send(response); + handshake.setHandshakeStage(HandshakeStage.COMPLETED); + handshake.setHeartbeatInterval(this.settings.heartbeatInterval); + client.send(handshake); return; } /** @@ -117,10 +124,77 @@ public class Executor0Handshake extends PacketExecutor { client.disconnect(Failures.AUTHENTIFICATION_ERROR); return; } + long userDevicesCount = deviceService.countUserDevices(user); + /** + * Проверяем верифицировано ли устройство + */ + if(userDevicesCount > 0 && !deviceService.isDeviceVerifiedByUser(deviceId, user)) { + /** + * Устройство не верифицировано, нужно отправить клиента + * на подтверждение устройства + */ + handshake.setHandshakeStage(HandshakeStage.NEED_DEVICE_VERIFICATION); + handshake.setHeartbeatInterval(this.settings.heartbeatInterval); + /** + * Вызываем событие подтверждения устройства + */ + context.getEventManager().callEvent( + new HandshakeDeviceConfirmEvent(publicKey, privateKey, device, authentificate, client) + ); + /** + * Ставим метку аутентификации на клиента + */ + ECIAuthentificate eciTag = new ECIAuthentificate + (publicKey, privateKey, HandshakeStage.NEED_DEVICE_VERIFICATION); + client.addTag(eciTag); + /** + * Отправляем клиенту информацию о необходимости + * подтверждения устройства + */ + client.send(handshake); + return; + } + + if(userDevicesCount == 0) { + /** + * Это первое устройство пользователя, сохраняем его + * как верифицированное + */ + Device newDevice = new Device(); + newDevice.setDeviceId(deviceId); + newDevice.setDeviceName(deviceName); + newDevice.setDeviceOs(deviceOs); + newDevice.setPublicKey(publicKey); + newDevice.setLeaveTime(System.currentTimeMillis()); + deviceRepository.save(newDevice); + } - - + /** + * Ставим метку аутентификации на клиента + */ + ECIAuthentificate eciTag = new ECIAuthentificate + (publicKey, privateKey, HandshakeStage.COMPLETED); + client.addTag(eciTag); + /** + * Вызываем событие завершения хэндшейка + */ + boolean cancelled = context.getEventManager().callEvent( + new HandshakeCompletedEvent(publicKey, privateKey, device, eciTag, client) + ); + if(cancelled) { + /** + * Событие было отменено, не даем завершить хэндшейк + */ + client.disconnect(Failures.DATA_MISSMATCH); + return; + } + /** + * Отправляем клиенту подтверждение успешного хэндшейка + */ + handshake.setHandshakeStage(HandshakeStage.COMPLETED); + handshake.setHeartbeatInterval(this.settings.heartbeatInterval); + client.send(handshake); } } diff --git a/src/main/java/com/rosetta/im/service/Service.java b/src/main/java/com/rosetta/im/service/Service.java new file mode 100644 index 0000000..c73fa9f --- /dev/null +++ b/src/main/java/com/rosetta/im/service/Service.java @@ -0,0 +1,23 @@ +package com.rosetta.im.service; + +/** + * Базовый класс для всех сервисов. Нужно чтобы унифицировать доступ к репозиториям, + * а так же не раздувать логику в executor'ах. Так код в executor'ах будет чище и + * проще для понимания. Для атомарных операций с сущностями сервисы не используются, они используются только для + * более сложной логики, требующей взаимодействия с несколькими репозиториями или + * иной бизнес-логики. + * @param тип репозитория + */ +public abstract class Service { + + private T repository; + + public Service(T repository) { + this.repository = repository; + } + + public T getRepository() { + return repository; + } + +} diff --git a/src/main/java/com/rosetta/im/service/services/DeviceService.java b/src/main/java/com/rosetta/im/service/services/DeviceService.java new file mode 100644 index 0000000..124c340 --- /dev/null +++ b/src/main/java/com/rosetta/im/service/services/DeviceService.java @@ -0,0 +1,48 @@ +package com.rosetta.im.service.services; + +import java.util.List; + +import com.rosetta.im.database.entity.Device; +import com.rosetta.im.database.entity.User; +import com.rosetta.im.database.repository.DeviceRepository; +import com.rosetta.im.service.Service; + +public class DeviceService extends Service { + + public DeviceService(DeviceRepository repository) { + super(repository); + } + + /** + * Проверяет, верифицировано ли устройство с deviceId для пользователя user + * @param deviceId ID устройства + * @param user пользователь + * @return true если устройство верифицировано, иначе false + */ + public boolean isDeviceVerifiedByUser(String deviceId, User user) { + List devices = this.getRepository().findAll(user); + if(devices.size() == 0) { + /** + * Если у пользователя нет устройств, значит текущее устройство верифицировано + * такого быть не может, это избыточная проверка + */ + return true; + } + for(Device device : devices) { + if(device.getDeviceId().equals(deviceId)) { + return true; + } + } + return false; + } + + /** + * Считает количество устройств пользователя + * @param user пользователь + * @return количество устройств + */ + public long countUserDevices(User user) { + return this.getRepository().countByField("publicKey", user.getPublicKey()); + } + +} diff --git a/src/main/java/io/orprotocol/Server.java b/src/main/java/io/orprotocol/Server.java index 51ed4bd..27a5848 100644 --- a/src/main/java/io/orprotocol/Server.java +++ b/src/main/java/io/orprotocol/Server.java @@ -11,6 +11,7 @@ import org.java_websocket.handshake.ClientHandshake; import org.java_websocket.server.WebSocketServer; import io.orprotocol.client.Client; +import io.orprotocol.lock.ThreadLocker; import io.orprotocol.packet.Packet; import io.orprotocol.packet.PacketExecutor; import io.orprotocol.packet.PacketManager; @@ -22,6 +23,7 @@ public class Server extends WebSocketServer { private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private Context context; private ServerListener listener; + private ThreadLocker threadLocker = new ThreadLocker(); /** * Конструктор сервера @@ -144,10 +146,27 @@ public class Server extends WebSocketServer { */ return; } - executor.onPacketReceived(packet, client); + /** + * Проверяем наличие блокировки для данного пакета и ключа в аннотации @Lock. + */ + if(!threadLocker.acquireLock(packet, executorClass)) { + /** + * Если блокировка уже существует, значит другой поток обрабатывает пакет + * с таким же значением lockFor, отклоняем текущий пакет. + */ + return; + } + try { + executor.onPacketReceived(packet, client); + } finally { + /** + * Снимаем блокировку после обработки пакета. + */ + threadLocker.releaseLock(packet, executorClass); + } } catch (Exception e) { System.out.println("Error while processing packet " + packetClass.getName()); - System.out.println(e.getStackTrace()); + e.printStackTrace(); } } diff --git a/src/main/java/io/orprotocol/client/Client.java b/src/main/java/io/orprotocol/client/Client.java index bb24b1f..05f6323 100644 --- a/src/main/java/io/orprotocol/client/Client.java +++ b/src/main/java/io/orprotocol/client/Client.java @@ -46,7 +46,7 @@ public class Client { this.eciTags = new HashMap, ECITag>(); this.heartbeatInterval = heartbeatInterval; this.lastHeartbeatTime = System.currentTimeMillis(); - this.packetManager = new PacketManager(); + this.packetManager = packetManager; } /** @@ -57,7 +57,7 @@ public class Client { * @return */ public boolean isAlive() { - return (System.currentTimeMillis() - this.lastHeartbeatTime) * 2 <= this.heartbeatInterval * 1000; + return (System.currentTimeMillis() - this.lastHeartbeatTime) <= ((this.heartbeatInterval * 1000) * 2); } /** diff --git a/src/main/java/io/orprotocol/lock/Lock.java b/src/main/java/io/orprotocol/lock/Lock.java new file mode 100644 index 0000000..4a14a1f --- /dev/null +++ b/src/main/java/io/orprotocol/lock/Lock.java @@ -0,0 +1,23 @@ +package io.orprotocol.lock; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Аннотация для указания блокировки + * при обработке пакета. + * Мультипоточная блокировка, то есть блокировка сработает только + * если другой поток уже обрабатывает пакет с полем lockFor, однако другие потоки + * могут обрабатывать пакеты с другими значениями lockFor параллельно. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Lock { + /** + * По какому полю в пакете + * будет осуществляться блокировка + */ + String lockFor(); +} diff --git a/src/main/java/io/orprotocol/lock/ThreadLocker.java b/src/main/java/io/orprotocol/lock/ThreadLocker.java new file mode 100644 index 0000000..9a7441b --- /dev/null +++ b/src/main/java/io/orprotocol/lock/ThreadLocker.java @@ -0,0 +1,82 @@ +package io.orprotocol.lock; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.ConcurrentHashMap; + +import io.orprotocol.client.Client; +import io.orprotocol.packet.Packet; +import io.orprotocol.packet.PacketExecutor; + +/** + * Менеджер блокировок для обработки пакетов с аннотацией @Lock. + */ +public class ThreadLocker { + + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + /** + * Пытается захватить блокировку для указанного пакета и ключа в аннотации @Lock. + * @param packet Пакет для которого требуется блокировка + * @param exectuor Класс исполнителя пакета + * @return true, если блокировка успешно захвачена, иначе false. + */ + public boolean acquireLock(Packet packet, Class exectuor) { + try{ + Method prMethod = exectuor.getMethod("onPacketReceived", Packet.class, Client.class); + if(prMethod == null) { + return true; + } + Lock lockAnnotation = prMethod.getAnnotation(Lock.class); + if(lockAnnotation == null) { + return true; + } + String fieldName = lockAnnotation.lockFor(); + Field field = packet.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + String fieldValue = (String) field.get(packet); + String lockValue = packet.getClass().getName() + "_" + fieldValue; + if(locks.putIfAbsent(lockValue, true) != null) { + /** + * Если блокировка уже существует, значит другой поток обрабатывает пакет + * с таким же значением lockFor, отклоняем текущий пакет. + */ + return false; + } + return true; + }catch(Exception e) { + /** + * Игнорируем ошибки при попытке блокировки, + * чтобы не блокировать обработку пакета из-за ошибок рефлексии + */ + return true; + } + } + + /** + * Освобождает блокировку для указанного пакета и ключа в аннотации @Lock. + * @param packet Пакет для которого требуется разблокировка + * @param exectuor Класс исполнителя пакета + */ + public void releaseLock(Packet packet, Class exectuor) { + try{ + Method prMethod = exectuor.getMethod("onPacketReceived", Packet.class, Client.class); + if(prMethod == null) { + return; + } + Lock lockAnnotation = prMethod.getAnnotation(Lock.class); + if(lockAnnotation == null) { + return; + } + String fieldName = lockAnnotation.lockFor(); + Field field = packet.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + String fieldValue = (String) field.get(packet); + String lockValue = packet.getClass().getName() + "_" + fieldValue; + locks.remove(lockValue); + }catch(Exception e) { + // Игнорируем ошибки при разблокировке + } + } + +} diff --git a/src/main/resources/hibernate.cfg.xml b/src/main/resources/hibernate.cfg.xml index 40ca860..1a1dafc 100644 --- a/src/main/resources/hibernate.cfg.xml +++ b/src/main/resources/hibernate.cfg.xml @@ -10,6 +10,7 @@ update +