Files
iiko-connector/src/main/java/su/xserver/iikocon/MainVerticle.java
2026-05-07 18:42:25 +03:00

688 lines
27 KiB
Java

package su.xserver.iikocon;
import io.vertx.config.ConfigRetriever;
import io.vertx.config.ConfigRetrieverOptions;
import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpServer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.sstore.SessionStore;
import io.vertx.ext.web.sstore.redis.RedisSessionStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.AppConfig;
import su.xserver.iikocon.handler.*;
import su.xserver.iikocon.iiko.IikoHandler;
import su.xserver.iikocon.iiko.IikoOlapClient;
import su.xserver.iikocon.iiko.OlapQueryService;
import su.xserver.iikocon.service.*;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class MainVerticle extends AbstractVerticle {
private final Logger log = LoggerFactory.getLogger("[MainVerticle]");
private DataBaseService db;
private RedisService redis;
private HttpServer httpServer;
private AppConfig config;
private SessionStore sessionStore;
private UserService userService;
private RestaurantService restaurantService;
private ExternalDataBaseService externalDataBaseService;
private SettingsService settingsService;
private OlapQueryService olapQueryService;
@Override
public void start(Promise<Void> startPromise) throws ClassNotFoundException {
Class.forName("com.mysql.cj.jdbc.Driver");
Class.forName("org.postgresql.Driver");
ConfigStoreOptions classpathStore = new ConfigStoreOptions()
.setType("file")
.setFormat("json")
.setConfig(new JsonObject().put("path", "config.json").put("hierarchical", true))
.setOptional(false);
ConfigRetrieverOptions options = new ConfigRetrieverOptions()
.addStore(classpathStore);
ConfigRetriever retriever = ConfigRetriever.create(vertx, options);
retriever.getConfig()
.onSuccess(cfg -> {
config = AppConfig.from(cfg);
db = new DataBaseService(vertx, config.database);
redis = new RedisService(vertx, config.redis);
userService = new UserService(db.getPool());
restaurantService = new RestaurantService(db.getPool());
settingsService = new SettingsService(db.getPool());
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
olapQueryService = new OlapQueryService(db.getPool(), externalDataBaseService);
userService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
restaurantService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
settingsService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
externalDataBaseService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
olapQueryService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
createRouterAndStartHttp(startPromise);
})
.onFailure(startPromise::fail);
}
private void createRouterAndStartHttp(Promise<Void> startPromise) {
settingsService.get("session_timeout_minutes")
.compose(timeoutStr -> {
long timeoutMinutes = 60; // default
if (timeoutStr != null && !timeoutStr.isEmpty()) {
try {
timeoutMinutes = Long.parseLong(timeoutStr);
} catch (NumberFormatException ignored) {}
}
long timeoutMs = timeoutMinutes * 60 * 1000;
sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
.setCookieSecureFlag(false)
.setSessionTimeout(timeoutMs);
Router router = initRouter(sessionHandler);
startHttp(router, startPromise);
return Future.succeededFuture();
})
.onFailure(err -> {
log.error("Failed to get session timeout", err);
startPromise.fail(err);
});
}
private void setupPhpmyadminProxy(Router router) {
if (config.pma == null || !config.pma.enabled) return;
String upstream = config.pma.upstream;
String basePath = config.pma.basePath;
final URI upstreamUri = URI.create(upstream);
final String host = upstreamUri.getHost();
int portTmp = upstreamUri.getPort();
if (portTmp == -1) {
portTmp = "https".equals(upstreamUri.getScheme()) ? 443 : 80;
}
final int port = portTmp;
final WebClient webClient = WebClient.create(vertx);
router.route(basePath + "/*").handler(ctx -> {
if (ctx.session() != null && "admin".equals(ctx.session().get("role"))) {
ctx.next();
} else {
ctx.response().putHeader("Location", "/").setStatusCode(302).end();
}
});
router.route(basePath + "/*").handler(ctx -> {
String targetPathBase = ctx.request().path().substring(basePath.length());
if (targetPathBase.isEmpty()) targetPathBase = "/";
String targetPath = targetPathBase;
String query = ctx.request().query();
if (query != null && !query.isEmpty()) {
targetPath += "?" + query;
}
final String targetPathFinal = targetPath;
final HttpRequest<Buffer> proxyReq = webClient.request(
ctx.request().method(), port, host, targetPathFinal
);
ctx.request().headers().forEach(header -> {
if (!"host".equalsIgnoreCase(header.getKey())) {
proxyReq.putHeader(header.getKey(), header.getValue());
}
});
proxyReq.putHeader("Host", host + ":" + port);
ctx.request().bodyHandler(body -> {
if (body != null && body.length() > 0) {
proxyReq.sendBuffer(body)
.onSuccess(resp -> sendResponse(ctx, resp))
.onFailure(err -> sendError(ctx, err));
} else {
proxyReq.send()
.onSuccess(resp -> sendResponse(ctx, resp))
.onFailure(err -> sendError(ctx, err));
}
});
});
}
private void sendResponse(RoutingContext ctx, HttpResponse<Buffer> resp) {
ctx.response().setStatusCode(resp.statusCode());
resp.headers().forEach(h -> ctx.response().putHeader(h.getKey(), h.getValue()));
ctx.response().end(resp.body());
}
private void sendError(RoutingContext ctx, Throwable err) {
log.error("Proxy error: {}", err.getMessage());
ctx.response().setStatusCode(502).end("Bad Gateway: " + err.getMessage());
}
private Router initRouter(SessionHandler sessionHandler) {
Router router = Router.router(vertx);
router.route().handler(ctx -> {
String path = ctx.request().path();
if (path != null && path.startsWith(config.pma.basePath + "/")) {
ctx.next(); // пропускаем BodyHandler для прокси
} else {
BodyHandler.create().handle(ctx);
}
});
router.route().handler(sessionHandler);
setupPhpmyadminProxy(router);
SecurityHandler securityHandlers = new SecurityHandler(settingsService);
// Обработчики безопасности
router.route().handler(securityHandlers.hostValidator());
router.route().handler(securityHandlers.proxyHeadersHandler());
router.route().handler(securityHandlers.cspHeader());
// CORS для разработки
router.route().handler(ctx -> {
ctx.response()
.putHeader("Access-Control-Allow-Origin", "*")
.putHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
.putHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
.putHeader("Access-Control-Allow-Credentials", "true");
if (ctx.request().method().name().equals("OPTIONS")) {
ctx.response().end();
} else {
ctx.next();
}
});
router.route().handler(ctx -> {
long start = System.currentTimeMillis();
String method = ctx.request().method().name();
String path = ctx.request().path();
final String remoteIp = ctx.get("realClientIp") != null ?
ctx.get("realClientIp") :
ctx.request().remoteAddress().host();
ctx.addBodyEndHandler(v -> {
long duration = System.currentTimeMillis() - start;
log.info("{} {} - {} ms - {} - {}",
method, path, duration, ctx.response().getStatusCode(), remoteIp);
});
ctx.next();
});
// ------ Раздаём Vue статику ------
router.route("/assets/*").handler(StaticHandler.create("webroot/assets"));
router.route("/favicon.ico").handler(ctx -> ctx.response().sendFile("webroot/favicon.ico"));
// ------ SPA fallback: отдаём index.html на все не-API запросы ------
router.route().handler(ctx -> {
if (ctx.request().path().startsWith("/api")) {
ctx.next();
} else {
ctx.response()
.putHeader("Content-Type", "text/html")
.sendFile("webroot/index.html");
}
});
// Rate Limiter Handler
RedisRateLimiter limiter = new RedisRateLimiter(
redis.getRedis(), 60, 60_000
);
router.route().handler(limiter);
// Health Checks
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
healthCheckService.registerHealthCheck(router);
// API маршруты
AuthHandler authHandler = new AuthHandler(userService);
SetupHandler setupHandler = new SetupHandler(userService);
router.get("/api/status").handler(setupHandler::checkStatus);
router.post("/api/setup").handler(setupHandler::handleSetup);
router.post("/api/login").handler(authHandler::handleLogin);
router.post("/api/logout").handler(authHandler::handleLogout);
router.post("/api/register").handler(rc -> settingsService.get("enable_registration").onComplete(regCheck -> {
if (regCheck.succeeded() && "false".equals(regCheck.result())) {
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
return;
}
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
String email = body.getString("email");
String password = body.getString("password");
String ip = rc.request().remoteAddress().host();
if (login == null || email == null || password == null) {
rc.response().setStatusCode(400).end("Missing fields");
return;
}
userService.createUser(login, email, password, ip)
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}));
router.route("/api/profile").handler(authHandler::requireAuth);
router.get("/api/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
userService.getProfile(userId)
.onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
JsonObject body = rc.body().asJsonObject();
String email = body.getString("email");
String password = body.getString("password");
String language = body.getString("language");
userService.updateProfile(userId, email, password, language)
.onSuccess(v -> {
if (language != null) rc.session().put("language", language);
rc.response().end(new JsonObject().put("success", true).encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.route("/api/admin/*").handler(authHandler::requireAuth);
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
.putHeader("Content-Type", "application/json")
.end(ar.result().encode());
} else {
rc.response().setStatusCode(500).end(ar.cause().getMessage());
}
}));
router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
router.post("/api/admin/users").handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
String email = body.getString("email");
String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host();
if (login == null || email == null || password == null) {
rc.response().setStatusCode(400).end("Missing login, email or password");
return;
}
if (role == null || role.isEmpty()) role = "user";
userService.createUser(login, email, password, ip, true, role)
.onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/users/:id").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
String email = body.getString("email");
String password = body.getString("password");
String role = body.getString("role");
String ip = rc.request().remoteAddress().host();
if (login == null || email == null) {
rc.response().setStatusCode(400).end("Missing login or email");
return;
}
userService.updateUser(id, login, email, password, ip, role)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.delete("/api/admin/users/:id").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
Integer currentUserId = rc.session().get("userId");
if (currentUserId != null && currentUserId == id) {
rc.response().setStatusCode(403).end(new JsonObject()
.put("error", "You cannot delete your own account")
.encode());
return;
}
userService.deleteUser(id)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/users/:id/activate").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
boolean active = Boolean.parseBoolean(rc.queryParam("active").getFirst());
Integer currentUserId = rc.session().get("userId");
if (currentUserId != null && currentUserId == id) {
rc.response().setStatusCode(403).end(new JsonObject().put("error", "You cannot deactivate yourself").encode());
return;
}
userService.setActive(id, active)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
.putHeader("Content-Type", "application/json")
.end(ar.result().encode());
} else {
rc.response().setStatusCode(500).end(ar.cause().getMessage());
}
}));
router.get("/api/admin/restaurants/:id").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
restaurantService.findById(id)
.onSuccess(rest -> {
if (rest == null) rc.response().setStatusCode(404).end();
else rc.response().putHeader("Content-Type", "application/json").end(rest.encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.get("/api/admin/restaurants/:id/check").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
restaurantService.findById(id)
.onSuccess(rest -> {
if (rest == null) {
rc.response().setStatusCode(404).end();
} else {
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
iiko.checkConnection()
.onSuccess(res -> rc.response().putHeader("Content-Type", "application/json").end(res.encode()))
.onFailure(err -> rc.response().putHeader("Content-Type", "application/json").end(
new JsonObject().put("success", false).put("error", err.getMessage()).encode()));
}
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.post("/api/admin/restaurants").handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
String login = body.getString("login");
String password = body.getString("password");
String host = body.getString("host");
boolean https = body.getBoolean("https", false);
if (name == null || login == null || password == null || host == null) {
rc.response().setStatusCode(400).end("Missing fields");
return;
}
restaurantService.createRestaurant(name, login, password, host, https)
.onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/restaurants/:id").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
String login = body.getString("login");
String password = body.getString("password");
String host = body.getString("host");
boolean https = body.getBoolean("https", false);
if (name == null || login == null || host == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
restaurantService.updateRestaurant(id, name, login, password, host, https)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.delete("/api/admin/restaurants/:id").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
restaurantService.deleteRestaurant(id)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.get("/api/settings").handler(rc -> {
settingsService.getPublicSettings()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end());
});
router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
router.get("/api/admin/settings/meta").handler(rc -> {
settingsService.getMetadata()
.onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.get("/api/admin/settings").handler(rc -> {
settingsService.getAllWithDefaults()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/settings").handler(rc -> {
JsonObject body = rc.body().asJsonObject();
List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void>
body.forEach(entry -> futures.add(settingsService.set(entry.getKey(), entry.getValue().toString())));
Future.all(futures)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
externalDataBaseService.handleRoute(router);
new IikoHandler(vertx, router, db, restaurantService, authHandler);
// Роуты для OLAP запросов
router.get("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> {
olapQueryService.getAllQueries()
.onSuccess(queries -> rc.response().putHeader("Content-Type", "application/json").end(queries.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.get("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
olapQueryService.getQueryById(id)
.onSuccess(query -> {
if (query == null) rc.response().setStatusCode(404).end();
else rc.response().putHeader("Content-Type", "application/json").end(query.encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.post("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
Integer dbConnectionId = body.getInteger("dbConnectionId");
JsonObject config = body.getJsonObject("config");
JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray());
List<Integer> restaurantIds = restaurantIdsArray.stream().map(id -> (Integer) id).collect(Collectors.toList());
// Получаем active: сначала из тела, иначе из конфига, иначе true
Boolean active = body.getBoolean("active");
if (active == null && config != null) active = config.getBoolean("active", true);
if (active == null) active = true;
if (name == null || dbConnectionId == null || config == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
String tableName = config.getString("tableName");
if (tableName.isEmpty()) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
if (!olapQueryService.isValidTableName(tableName)) {
rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits");
return;
}
Boolean finalActive = active;
olapQueryService.generateSql(config, dbConnectionId)
.compose(sql -> olapQueryService.createQuery(name, dbConnectionId, config, restaurantIds, sql, finalActive))
.onSuccess(id -> rc.response().setStatusCode(201).putHeader("Content-Type", "application/json").end(new JsonObject().put("id", id).encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
Integer dbConnectionId = body.getInteger("dbConnectionId");
JsonObject config = body.getJsonObject("config");
JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray());
List<Integer> restaurantIds = restaurantIdsArray.stream().map(v -> (Integer) v).collect(Collectors.toList());
Boolean active = body.getBoolean("active");
if (active == null && config != null) active = config.getBoolean("active", true);
if (active == null) active = true;
if (name == null || dbConnectionId == null || config == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
String tableName = config.getString("tableName");
if (tableName.isEmpty()) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
if (!olapQueryService.isValidTableName(tableName)) {
rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits");
return;
}
Boolean finalActive = active;
olapQueryService.generateSql(config, dbConnectionId)
.compose(sql -> olapQueryService.updateQuery(id, name, dbConnectionId, config, restaurantIds, sql, finalActive))
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.delete("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
olapQueryService.deleteQuery(id)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.post("/api/olap/generate-sql").handler(authHandler::requireAuth).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
JsonObject config = body.getJsonObject("config");
Integer dbConnectionId = body.getInteger("dbConnectionId");
if (config == null || dbConnectionId == null) {
rc.response().setStatusCode(400).end("Missing config or dbConnectionId");
return;
}
olapQueryService.generateSql(config, dbConnectionId)
.onSuccess(sql -> rc.response().putHeader("Content-Type", "text/plain").end(sql))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.post("/api/olap/export-json").handler(authHandler::requireAuth).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
JsonObject config = body.getJsonObject("config");
if (config == null) {
rc.response().setStatusCode(400).end("Missing config");
return;
}
JsonObject fullJson = olapQueryService.generateFullIikoJson(config);
rc.response()
.putHeader("Content-Type", "application/json")
.putHeader("Content-Disposition", "attachment; filename=olap_export.json")
.end(fullJson.encodePrettily());
});
return router;
}
private void startHttp(Router router, Promise<Void> startPromise) {
httpServer = vertx.createHttpServer();
httpServer.requestHandler(router).listen(config.server.port, config.server.host)
.onSuccess(server -> {
log.info("HTTP server started on port {}", server.actualPort());
startPromise.complete();
})
.onFailure(throwable -> {
log.error(throwable.getMessage());
startPromise.fail(throwable);
});
}
@Override
public void stop(Promise<Void> stopPromise) {
this.httpServer.close()
.onSuccess(server -> {
log.info("Stop HTTP server");
this.db.disconnect();
this.redis.disconnect();
this.vertx.close()
.onSuccess(vertx -> {
log.info("Stop Vert.x");
stopPromise.complete();
})
.onFailure(throwable -> log.info(throwable.getMessage()));
})
.onFailure(throwable -> log.info(throwable.getMessage()));
}
}