diff --git a/pom.xml b/pom.xml
index 590b802..e5093ae 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,6 +72,13 @@
flyway-mysql
+
+
+ com.maxmind.geoip2
+ geoip2
+ 4.2.0
+
+
diff --git a/src/main/java/com/honey/honey/controller/AuthController.java b/src/main/java/com/honey/honey/controller/AuthController.java
index cfe1e2a..3cedc89 100644
--- a/src/main/java/com/honey/honey/controller/AuthController.java
+++ b/src/main/java/com/honey/honey/controller/AuthController.java
@@ -2,17 +2,16 @@ package com.honey.honey.controller;
import com.honey.honey.dto.CreateSessionRequest;
import com.honey.honey.dto.CreateSessionResponse;
-import com.honey.honey.model.User;
-import com.honey.honey.repository.UserRepository;
+import com.honey.honey.model.UserA;
import com.honey.honey.service.SessionService;
import com.honey.honey.service.TelegramAuthService;
-import lombok.Data;
+import com.honey.honey.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
-import java.time.LocalDateTime;
+import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
@Slf4j
@@ -23,33 +22,33 @@ public class AuthController {
private final TelegramAuthService telegramAuthService;
private final SessionService sessionService;
- private final UserRepository userRepository;
+ private final UserService userService;
/**
* Creates a session by validating Telegram initData.
* This is the only endpoint that accepts initData.
+ * Handles user registration/login and referral system.
*/
@PostMapping("/tma/session")
- public CreateSessionResponse createSession(@RequestBody CreateSessionRequest request) {
+ public CreateSessionResponse createSession(
+ @RequestBody CreateSessionRequest request,
+ HttpServletRequest httpRequest) {
String initData = request.getInitData();
if (initData == null || initData.isBlank()) {
throw new IllegalArgumentException("initData is required");
}
- // Validate Telegram initData signature
- Map tgUser = telegramAuthService.validateAndParseInitData(initData);
+ // Validate Telegram initData signature and parse data
+ Map tgUserData = telegramAuthService.validateAndParseInitData(initData);
- Long telegramId = ((Number) tgUser.get("id")).longValue();
- String username = (String) tgUser.get("username");
-
- // Get or create user
- User user = getOrCreateUser(telegramId, username);
+ // Get or create user (handles registration, login update, and referral system)
+ UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
// Create session
String sessionId = sessionService.createSession(user);
- log.info("Session created for userId={}, telegramId={}", user.getId(), telegramId);
+ log.info("Session created for userId={}, telegramId={}", user.getId(), user.getTelegramId());
return CreateSessionResponse.builder()
.access_token(sessionId)
@@ -76,33 +75,6 @@ public class AuthController {
}
}
- /**
- * Gets existing user or creates a new one.
- */
- private User getOrCreateUser(Long telegramId, String username) {
- var existingUser = userRepository.findByTelegramId(telegramId);
-
- if (existingUser.isPresent()) {
- User user = existingUser.get();
- // Update username if it changed
- if (username != null && !username.equals(user.getUsername())) {
- user.setUsername(username);
- userRepository.save(user);
- }
- return user;
- }
-
- // Create new user
- log.info("Creating new user for telegramId={}, username={}", telegramId, username);
- return userRepository.save(
- User.builder()
- .telegramId(telegramId)
- .username(username)
- .createdAt(LocalDateTime.now())
- .build()
- );
- }
-
/**
* Extracts Bearer token from Authorization header.
*/
diff --git a/src/main/java/com/honey/honey/controller/UserController.java b/src/main/java/com/honey/honey/controller/UserController.java
index fac05fa..c092f28 100644
--- a/src/main/java/com/honey/honey/controller/UserController.java
+++ b/src/main/java/com/honey/honey/controller/UserController.java
@@ -1,13 +1,14 @@
package com.honey.honey.controller;
import com.honey.honey.dto.UserDto;
-import com.honey.honey.model.User;
+import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
+import com.honey.honey.service.UserService;
+import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@@ -15,14 +16,31 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class UserController {
+ private final UserService userService;
+
@GetMapping("/current")
public UserDto getCurrentUser() {
- User user = UserContext.get();
+ UserA user = UserContext.get();
return UserDto.builder()
.telegram_id(user.getTelegramId())
- .username(user.getUsername())
+ .username(user.getTelegramName())
.build();
}
+
+ /**
+ * Updates user's language code.
+ * Called when user changes language in app header.
+ */
+ @PutMapping("/language")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void updateLanguage(@RequestBody UpdateLanguageRequest request) {
+ UserA user = UserContext.get();
+ userService.updateLanguageCode(user.getId(), request.getLanguageCode());
+ }
+
+ @Data
+ public static class UpdateLanguageRequest {
+ private String languageCode;
+ }
}
-
diff --git a/src/main/java/com/honey/honey/model/Session.java b/src/main/java/com/honey/honey/model/Session.java
index 768519c..bc58daf 100644
--- a/src/main/java/com/honey/honey/model/Session.java
+++ b/src/main/java/com/honey/honey/model/Session.java
@@ -21,9 +21,8 @@ public class Session {
@Column(name = "session_id_hash", unique = true, nullable = false, length = 255)
private String sessionIdHash;
- @ManyToOne(fetch = FetchType.EAGER)
- @JoinColumn(name = "user_id", nullable = false)
- private User user;
+ @Column(name = "user_id", nullable = false)
+ private Integer userId;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
diff --git a/src/main/java/com/honey/honey/model/User.java b/src/main/java/com/honey/honey/model/User.java
deleted file mode 100644
index a75a6c9..0000000
--- a/src/main/java/com/honey/honey/model/User.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.honey.honey.model;
-
-import jakarta.persistence.*;
-import lombok.*;
-
-import java.time.LocalDateTime;
-
-@Entity
-@Table(name = "users")
-@Getter
-@Setter
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class User {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- @Column(name = "telegram_id", unique = true, nullable = false)
- private Long telegramId;
-
- @Column(name = "username")
- private String username;
-
- @Column(name = "created_at")
- private LocalDateTime createdAt;
-
- @PrePersist
- public void prePersist() {
- if (createdAt == null) {
- createdAt = LocalDateTime.now();
- }
- }
-}
-
diff --git a/src/main/java/com/honey/honey/model/UserA.java b/src/main/java/com/honey/honey/model/UserA.java
new file mode 100644
index 0000000..24de1ba
--- /dev/null
+++ b/src/main/java/com/honey/honey/model/UserA.java
@@ -0,0 +1,62 @@
+package com.honey.honey.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "db_users_a")
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserA {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Integer id;
+
+ @Column(name = "screen_name", nullable = false, length = 75)
+ @Builder.Default
+ private String screenName = "-";
+
+ @Column(name = "telegram_id", unique = true)
+ private Long telegramId;
+
+ @Column(name = "telegram_name", nullable = false, length = 33)
+ @Builder.Default
+ private String telegramName = "-";
+
+ @Column(name = "is_premium", nullable = false)
+ @Builder.Default
+ private Integer isPremium = 0;
+
+ @Column(name = "language_code", nullable = false, length = 2)
+ @Builder.Default
+ private String languageCode = "XX";
+
+ @Column(name = "country_code", nullable = false, length = 2)
+ @Builder.Default
+ private String countryCode = "XX";
+
+ @Column(name = "device_code", nullable = false, length = 5)
+ @Builder.Default
+ private String deviceCode = "XX";
+
+ @Column(name = "ip", columnDefinition = "VARBINARY(16)")
+ private byte[] ip;
+
+ @Column(name = "date_reg", nullable = false)
+ @Builder.Default
+ private Integer dateReg = 0;
+
+ @Column(name = "date_login", nullable = false)
+ @Builder.Default
+ private Integer dateLogin = 0;
+
+ @Column(name = "banned", nullable = false)
+ @Builder.Default
+ private Integer banned = 0;
+}
+
diff --git a/src/main/java/com/honey/honey/model/UserB.java b/src/main/java/com/honey/honey/model/UserB.java
new file mode 100644
index 0000000..cfea42f
--- /dev/null
+++ b/src/main/java/com/honey/honey/model/UserB.java
@@ -0,0 +1,43 @@
+package com.honey.honey.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "db_users_b")
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserB {
+
+ @Id
+ @Column(name = "id")
+ private Integer id;
+
+ @Column(name = "balance_a", nullable = false)
+ @Builder.Default
+ private Long balanceA = 0L;
+
+ @Column(name = "balance_b", nullable = false)
+ @Builder.Default
+ private Long balanceB = 0L;
+
+ @Column(name = "deposit_total", nullable = false)
+ @Builder.Default
+ private Long depositTotal = 0L;
+
+ @Column(name = "deposit_count", nullable = false)
+ @Builder.Default
+ private Integer depositCount = 0;
+
+ @Column(name = "withdraw_total", nullable = false)
+ @Builder.Default
+ private Long withdrawTotal = 0L;
+
+ @Column(name = "withdraw_count", nullable = false)
+ @Builder.Default
+ private Integer withdrawCount = 0;
+}
+
diff --git a/src/main/java/com/honey/honey/model/UserD.java b/src/main/java/com/honey/honey/model/UserD.java
new file mode 100644
index 0000000..ace3413
--- /dev/null
+++ b/src/main/java/com/honey/honey/model/UserD.java
@@ -0,0 +1,103 @@
+package com.honey.honey.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "db_users_d")
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserD {
+
+ @Id
+ @Column(name = "id")
+ private Integer id;
+
+ @Column(name = "referer_id_1", nullable = false)
+ @Builder.Default
+ private Integer refererId1 = 0;
+
+ @Column(name = "referer_id_2", nullable = false)
+ @Builder.Default
+ private Integer refererId2 = 0;
+
+ @Column(name = "referer_id_3", nullable = false)
+ @Builder.Default
+ private Integer refererId3 = 0;
+
+ @Column(name = "referer_id_4", nullable = false)
+ @Builder.Default
+ private Integer refererId4 = 0;
+
+ @Column(name = "referer_id_5", nullable = false)
+ @Builder.Default
+ private Integer refererId5 = 0;
+
+ @Column(name = "master_id", nullable = false)
+ @Builder.Default
+ private Integer masterId = 0;
+
+ @Column(name = "referals_1", nullable = false)
+ @Builder.Default
+ private Integer referals1 = 0;
+
+ @Column(name = "referals_2", nullable = false)
+ @Builder.Default
+ private Integer referals2 = 0;
+
+ @Column(name = "referals_3", nullable = false)
+ @Builder.Default
+ private Integer referals3 = 0;
+
+ @Column(name = "referals_4", nullable = false)
+ @Builder.Default
+ private Integer referals4 = 0;
+
+ @Column(name = "referals_5", nullable = false)
+ @Builder.Default
+ private Integer referals5 = 0;
+
+ @Column(name = "from_referals_1", nullable = false)
+ @Builder.Default
+ private Long fromReferals1 = 0L;
+
+ @Column(name = "from_referals_2", nullable = false)
+ @Builder.Default
+ private Long fromReferals2 = 0L;
+
+ @Column(name = "from_referals_3", nullable = false)
+ @Builder.Default
+ private Long fromReferals3 = 0L;
+
+ @Column(name = "from_referals_4", nullable = false)
+ @Builder.Default
+ private Long fromReferals4 = 0L;
+
+ @Column(name = "from_referals_5", nullable = false)
+ @Builder.Default
+ private Long fromReferals5 = 0L;
+
+ @Column(name = "to_referer_1", nullable = false)
+ @Builder.Default
+ private Long toReferer1 = 0L;
+
+ @Column(name = "to_referer_2", nullable = false)
+ @Builder.Default
+ private Long toReferer2 = 0L;
+
+ @Column(name = "to_referer_3", nullable = false)
+ @Builder.Default
+ private Long toReferer3 = 0L;
+
+ @Column(name = "to_referer_4", nullable = false)
+ @Builder.Default
+ private Long toReferer4 = 0L;
+
+ @Column(name = "to_referer_5", nullable = false)
+ @Builder.Default
+ private Long toReferer5 = 0L;
+}
+
diff --git a/src/main/java/com/honey/honey/repository/SessionRepository.java b/src/main/java/com/honey/honey/repository/SessionRepository.java
index 8939053..b77807c 100644
--- a/src/main/java/com/honey/honey/repository/SessionRepository.java
+++ b/src/main/java/com/honey/honey/repository/SessionRepository.java
@@ -19,15 +19,15 @@ public interface SessionRepository extends JpaRepository {
/**
* Counts active (non-expired) sessions for a user.
*/
- @Query("SELECT COUNT(s) FROM Session s WHERE s.user.id = :userId AND s.expiresAt > :now")
- long countActiveSessionsByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now);
+ @Query("SELECT COUNT(s) FROM Session s WHERE s.userId = :userId AND s.expiresAt > :now")
+ long countActiveSessionsByUserId(@Param("userId") Integer userId, @Param("now") LocalDateTime now);
/**
* Finds oldest active sessions for a user, ordered by created_at ASC.
* Used to delete oldest sessions when max limit is exceeded.
*/
- @Query("SELECT s FROM Session s WHERE s.user.id = :userId AND s.expiresAt > :now ORDER BY s.createdAt ASC")
- List findOldestActiveSessionsByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now, Pageable pageable);
+ @Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.expiresAt > :now ORDER BY s.createdAt ASC")
+ List findOldestActiveSessionsByUserId(@Param("userId") Integer userId, @Param("now") LocalDateTime now, Pageable pageable);
/**
* Batch deletes expired sessions (up to batchSize).
diff --git a/src/main/java/com/honey/honey/repository/UserARepository.java b/src/main/java/com/honey/honey/repository/UserARepository.java
new file mode 100644
index 0000000..0e72c55
--- /dev/null
+++ b/src/main/java/com/honey/honey/repository/UserARepository.java
@@ -0,0 +1,13 @@
+package com.honey.honey.repository;
+
+import com.honey.honey.model.UserA;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface UserARepository extends JpaRepository {
+ Optional findByTelegramId(Long telegramId);
+}
+
diff --git a/src/main/java/com/honey/honey/repository/UserBRepository.java b/src/main/java/com/honey/honey/repository/UserBRepository.java
new file mode 100644
index 0000000..85482be
--- /dev/null
+++ b/src/main/java/com/honey/honey/repository/UserBRepository.java
@@ -0,0 +1,10 @@
+package com.honey.honey.repository;
+
+import com.honey.honey.model.UserB;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserBRepository extends JpaRepository {
+}
+
diff --git a/src/main/java/com/honey/honey/repository/UserDRepository.java b/src/main/java/com/honey/honey/repository/UserDRepository.java
new file mode 100644
index 0000000..d726f60
--- /dev/null
+++ b/src/main/java/com/honey/honey/repository/UserDRepository.java
@@ -0,0 +1,48 @@
+package com.honey.honey.repository;
+
+import com.honey.honey.model.UserD;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserDRepository extends JpaRepository {
+
+ /**
+ * Increments referals_1 for a user.
+ */
+ @Modifying
+ @Query("UPDATE UserD u SET u.referals1 = u.referals1 + 1 WHERE u.id = :userId")
+ void incrementReferals1(@Param("userId") Integer userId);
+
+ /**
+ * Increments referals_2 for a user.
+ */
+ @Modifying
+ @Query("UPDATE UserD u SET u.referals2 = u.referals2 + 1 WHERE u.id = :userId")
+ void incrementReferals2(@Param("userId") Integer userId);
+
+ /**
+ * Increments referals_3 for a user.
+ */
+ @Modifying
+ @Query("UPDATE UserD u SET u.referals3 = u.referals3 + 1 WHERE u.id = :userId")
+ void incrementReferals3(@Param("userId") Integer userId);
+
+ /**
+ * Increments referals_4 for a user.
+ */
+ @Modifying
+ @Query("UPDATE UserD u SET u.referals4 = u.referals4 + 1 WHERE u.id = :userId")
+ void incrementReferals4(@Param("userId") Integer userId);
+
+ /**
+ * Increments referals_5 for a user.
+ */
+ @Modifying
+ @Query("UPDATE UserD u SET u.referals5 = u.referals5 + 1 WHERE u.id = :userId")
+ void incrementReferals5(@Param("userId") Integer userId);
+}
+
diff --git a/src/main/java/com/honey/honey/repository/UserRepository.java b/src/main/java/com/honey/honey/repository/UserRepository.java
deleted file mode 100644
index c6df6e4..0000000
--- a/src/main/java/com/honey/honey/repository/UserRepository.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.honey.honey.repository;
-
-import com.honey.honey.model.User;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
-
-import java.util.List;
-import java.util.Optional;
-
-@Repository
-public interface UserRepository extends JpaRepository {
- List findAllByTelegramId(Long telegramId);
- Optional findByTelegramId(Long telegramId);
-}
-
diff --git a/src/main/java/com/honey/honey/security/AuthInterceptor.java b/src/main/java/com/honey/honey/security/AuthInterceptor.java
index 13c4e86..c43ae02 100644
--- a/src/main/java/com/honey/honey/security/AuthInterceptor.java
+++ b/src/main/java/com/honey/honey/security/AuthInterceptor.java
@@ -1,6 +1,6 @@
package com.honey.honey.security;
-import com.honey.honey.model.User;
+import com.honey.honey.model.UserA;
import com.honey.honey.service.SessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -38,7 +38,7 @@ public class AuthInterceptor implements HandlerInterceptor {
}
// Validate session and get user
- Optional userOpt = sessionService.getUserBySession(sessionId);
+ Optional userOpt = sessionService.getUserBySession(sessionId);
if (userOpt.isEmpty()) {
log.warn("❌ Invalid or expired session: {}", maskSessionId(sessionId));
@@ -47,7 +47,7 @@ public class AuthInterceptor implements HandlerInterceptor {
}
// Put user in context
- User user = userOpt.get();
+ UserA user = userOpt.get();
UserContext.set(user);
log.debug("🔑 Authenticated userId={} via session", user.getId());
diff --git a/src/main/java/com/honey/honey/security/UserContext.java b/src/main/java/com/honey/honey/security/UserContext.java
index 33f6002..90b9f4f 100644
--- a/src/main/java/com/honey/honey/security/UserContext.java
+++ b/src/main/java/com/honey/honey/security/UserContext.java
@@ -1,16 +1,16 @@
package com.honey.honey.security;
-import com.honey.honey.model.User;
+import com.honey.honey.model.UserA;
public class UserContext {
- private static final ThreadLocal current = new ThreadLocal<>();
+ private static final ThreadLocal current = new ThreadLocal<>();
- public static void set(User user) {
+ public static void set(UserA user) {
current.set(user);
}
- public static User get() {
+ public static UserA get() {
return current.get();
}
diff --git a/src/main/java/com/honey/honey/service/CountryCodeService.java b/src/main/java/com/honey/honey/service/CountryCodeService.java
new file mode 100644
index 0000000..f694aa9
--- /dev/null
+++ b/src/main/java/com/honey/honey/service/CountryCodeService.java
@@ -0,0 +1,156 @@
+package com.honey.honey.service;
+
+import com.maxmind.db.CHMCache;
+import com.maxmind.geoip2.DatabaseReader;
+import com.maxmind.geoip2.exception.GeoIp2Exception;
+import com.maxmind.geoip2.model.CountryResponse;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetAddress;
+
+/**
+ * Service for determining country code from IP address using MaxMind GeoLite2 database.
+ * Thread-safe singleton that maintains a single DatabaseReader instance.
+ */
+@Slf4j
+@Service
+public class CountryCodeService {
+
+ private final String dbPath;
+ private final ResourceLoader resourceLoader;
+ private DatabaseReader reader;
+
+ public CountryCodeService(
+ @Value("${geoip.db-path:}") String dbPath,
+ ResourceLoader resourceLoader) {
+ this.dbPath = dbPath;
+ this.resourceLoader = resourceLoader;
+ }
+
+ @PostConstruct
+ public void init() throws IOException {
+ File dbFile = null;
+
+ // Try external file path first (from config/env)
+ if (dbPath != null && !dbPath.isBlank()) {
+ dbFile = new File(dbPath);
+ if (dbFile.exists() && dbFile.isFile()) {
+ log.info("Loading GeoIP database from external path: {}", dbFile.getAbsolutePath());
+ } else {
+ log.warn("GeoIP database file not found at configured path: {}, falling back to resources", dbFile.getAbsolutePath());
+ dbFile = null;
+ }
+ }
+
+ // Fallback to resources directory
+ if (dbFile == null) {
+ try {
+ Resource resource = resourceLoader.getResource("classpath:geoip/GeoLite2-Country.mmdb");
+ if (resource.exists() && resource.isReadable()) {
+ // Try to get file path first (works if resource is a file system resource)
+ try {
+ dbFile = resource.getFile();
+ log.info("Loading GeoIP database from resources: {}", dbFile.getAbsolutePath());
+ } catch (Exception e) {
+ // Resource is in JAR, need to extract to temp file
+ log.debug("GeoIP database is in JAR, extracting to temp file");
+ InputStream inputStream = resource.getInputStream();
+ File tempFile = File.createTempFile("GeoLite2-Country", ".mmdb");
+ tempFile.deleteOnExit();
+
+ try (inputStream) {
+ java.nio.file.Files.copy(inputStream, tempFile.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ dbFile = tempFile;
+ log.info("Loading GeoIP database from JAR resources (extracted to temp file)");
+ }
+ } else {
+ throw new IllegalStateException("GeoIP database not found in resources: classpath:geoip/GeoLite2-Country.mmdb");
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to load GeoIP database from resources: " + e.getMessage(), e);
+ }
+ }
+
+ // Initialize DatabaseReader with cache for better performance
+ try {
+ this.reader = new DatabaseReader.Builder(dbFile)
+ .withCache(new CHMCache())
+ .build();
+ log.info("GeoIP database loaded successfully");
+ } catch (IOException e) {
+ log.error("Failed to initialize GeoIP database reader", e);
+ throw new IllegalStateException("Failed to initialize GeoIP database reader", e);
+ }
+ }
+
+ @PreDestroy
+ public void destroy() {
+ if (reader != null) {
+ try {
+ reader.close();
+ log.info("GeoIP database reader closed");
+ } catch (IOException e) {
+ log.warn("Error closing GeoIP database reader", e);
+ }
+ }
+ }
+
+ /**
+ * Determines country code from IP address using MaxMind GeoLite2 database.
+ *
+ * @param ipAddress IP address as string (IPv4 or IPv6)
+ * @return ISO 3166-1 alpha-2 country code (e.g., "UA", "PL", "DE"), or "XX" if unknown/invalid/private
+ */
+ public String getCountryCode(String ipAddress) {
+ if (ipAddress == null || ipAddress.isBlank()) {
+ return "XX";
+ }
+
+ if (reader == null) {
+ log.warn("GeoIP database reader not initialized, returning 'XX' for IP: {}", ipAddress);
+ return "XX";
+ }
+
+ try {
+ InetAddress addr = InetAddress.getByName(ipAddress);
+
+ // Check if it's a private/local IP (won't be in GeoIP database)
+ if (addr.isLoopbackAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
+ log.debug("Private/local IP address detected: {}, returning 'XX'", ipAddress);
+ return "XX";
+ }
+
+ CountryResponse response = reader.country(addr);
+ String iso = response.getCountry().getIsoCode();
+
+ if (iso != null && iso.length() == 2) {
+ return iso.toUpperCase(); // Ensure uppercase (ISO codes should be uppercase)
+ } else {
+ log.debug("Invalid ISO code returned for IP {}: {}", ipAddress, iso);
+ return "XX";
+ }
+ } catch (IOException e) {
+ log.debug("Failed to resolve IP address: {}", ipAddress, e);
+ return "XX";
+ } catch (GeoIp2Exception e) {
+ log.debug("GeoIP lookup failed for IP {}: {}", ipAddress, e.getMessage());
+ return "XX";
+ } catch (IllegalArgumentException e) {
+ log.debug("Invalid IP address format: {}", ipAddress, e);
+ return "XX";
+ }
+ }
+
+}
+
diff --git a/src/main/java/com/honey/honey/service/SessionService.java b/src/main/java/com/honey/honey/service/SessionService.java
index c23572b..c436443 100644
--- a/src/main/java/com/honey/honey/service/SessionService.java
+++ b/src/main/java/com/honey/honey/service/SessionService.java
@@ -1,8 +1,10 @@
package com.honey.honey.service;
import com.honey.honey.model.Session;
-import com.honey.honey.model.User;
+import com.honey.honey.model.UserA;
import com.honey.honey.repository.SessionRepository;
+import com.honey.honey.repository.UserARepository;
+import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -24,9 +26,15 @@ import java.util.Optional;
public class SessionService {
private final SessionRepository sessionRepository;
+ private final UserARepository userARepository;
private static final int SESSION_TTL_HOURS = 24; // 1 day
private static final SecureRandom secureRandom = new SecureRandom();
-
+
+ /**
+ * -- GETTER --
+ * Gets max active sessions per user.
+ */
+ @Getter
@Value("${app.session.max-active-per-user:5}")
private int maxActiveSessionsPerUser;
@@ -36,7 +44,7 @@ public class SessionService {
* Returns the raw session ID (to be sent to frontend) and stores the hash in DB.
*/
@Transactional
- public String createSession(User user) {
+ public String createSession(UserA user) {
LocalDateTime now = LocalDateTime.now();
// Generate cryptographically random session ID
@@ -56,7 +64,7 @@ public class SessionService {
// Create and save session
Session session = Session.builder()
.sessionIdHash(sessionIdHash)
- .user(user)
+ .userId(user.getId())
.createdAt(now)
.expiresAt(expiresAt)
.build();
@@ -70,7 +78,7 @@ public class SessionService {
/**
* Enforces MAX_ACTIVE_SESSIONS_PER_USER by deleting oldest active sessions if limit exceeded.
*/
- private void enforceMaxActiveSessions(Long userId, LocalDateTime now) {
+ private void enforceMaxActiveSessions(Integer userId, LocalDateTime now) {
long activeCount = sessionRepository.countActiveSessionsByUserId(userId, now);
if (activeCount >= maxActiveSessionsPerUser) {
@@ -98,7 +106,7 @@ public class SessionService {
* Returns empty if session is invalid or expired.
*/
@Transactional(readOnly = true)
- public Optional getUserBySession(String sessionId) {
+ public Optional getUserBySession(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return Optional.empty();
}
@@ -120,12 +128,8 @@ public class SessionService {
return Optional.empty();
}
- // Access user properties while still in transaction to initialize lazy proxy
- User user = session.getUser();
- // Force initialization by accessing a property
- user.getTelegramId();
-
- return Optional.of(user);
+ // Load user by ID
+ return userARepository.findById(session.getUserId());
}
/**
@@ -172,12 +176,6 @@ public class SessionService {
public int getSessionTtlSeconds() {
return SESSION_TTL_HOURS * 3600;
}
-
- /**
- * Gets max active sessions per user.
- */
- public int getMaxActiveSessionsPerUser() {
- return maxActiveSessionsPerUser;
- }
+
}
diff --git a/src/main/java/com/honey/honey/service/TelegramAuthService.java b/src/main/java/com/honey/honey/service/TelegramAuthService.java
index cb1426c..d45d8e7 100644
--- a/src/main/java/com/honey/honey/service/TelegramAuthService.java
+++ b/src/main/java/com/honey/honey/service/TelegramAuthService.java
@@ -30,6 +30,9 @@ public class TelegramAuthService {
/**
* Validates and parses Telegram initData string.
+ * Returns a map containing:
+ * - "user": parsed user data (Map)
+ * - "start": referral start parameter from URL (e.g., "774876" from /honey?start=774876) (String, can be null)
*/
public Map validateAndParseInitData(String initData) {
@@ -60,16 +63,21 @@ public class TelegramAuthService {
throw new UnauthorizedException("Invalid Telegram signature");
}
- // Step 5. Extract the user JSON from initData.
+ // Step 5. Extract the user JSON and start parameter from initData.
Map decoded = decodeQueryParams(initData);
String userJson = decoded.get("user");
+ String start = decoded.get("start"); // Referral parameter from URL: /honey?start=774876
if (userJson == null) {
throw new UnauthorizedException("initData does not contain 'user' field");
}
- // Step 6. Parse JSON into map.
- return objectMapper.readValue(userJson, Map.class);
+ // Step 6. Parse JSON into map and add start parameter.
+ Map result = new HashMap<>();
+ result.put("user", objectMapper.readValue(userJson, Map.class));
+ result.put("start", start);
+
+ return result;
} catch (UnauthorizedException ex) {
throw ex;
diff --git a/src/main/java/com/honey/honey/service/UserService.java b/src/main/java/com/honey/honey/service/UserService.java
new file mode 100644
index 0000000..59d23aa
--- /dev/null
+++ b/src/main/java/com/honey/honey/service/UserService.java
@@ -0,0 +1,317 @@
+package com.honey.honey.service;
+
+import com.honey.honey.model.UserA;
+import com.honey.honey.model.UserB;
+import com.honey.honey.model.UserD;
+import com.honey.honey.repository.UserARepository;
+import com.honey.honey.repository.UserBRepository;
+import com.honey.honey.repository.UserDRepository;
+import com.honey.honey.util.IpUtils;
+import com.honey.honey.util.TimeProvider;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Service for user management with sharded tables.
+ * Handles registration, login, and referral system.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UserService {
+
+ private final UserARepository userARepository;
+ private final UserBRepository userBRepository;
+ private final UserDRepository userDRepository;
+ private final CountryCodeService countryCodeService;
+
+ /**
+ * Gets or creates a user based on Telegram initData.
+ * Updates user data on each login.
+ * Handles referral system if start parameter is present.
+ *
+ * @param tgUserData Parsed Telegram data from initData (contains "user" map and "start" string)
+ * @param request HTTP request for IP extraction
+ * @return UserA entity
+ */
+ @Transactional
+ public UserA getOrCreateUser(Map tgUserData, HttpServletRequest request) {
+ // Extract user data and start parameter (from URL: /honey?start=774876)
+ @SuppressWarnings("unchecked")
+ Map tgUser = (Map) tgUserData.get("user");
+ String start = (String) tgUserData.get("start");
+ Long telegramId = ((Number) tgUser.get("id")).longValue();
+ String firstName = (String) tgUser.get("first_name");
+ String lastName = (String) tgUser.get("last_name");
+ String username = (String) tgUser.get("username");
+ Boolean isPremium = (Boolean) tgUser.get("is_premium");
+ String languageCode = (String) tgUser.get("language_code");
+
+ // Build screen_name from first_name and last_name
+ String screenName = buildScreenName(firstName, lastName);
+
+ // device_code should be language_code from initData
+ String deviceCode = languageCode != null ? languageCode : "XX";
+
+ // Get client IP and convert to bytes
+ String clientIp = IpUtils.getClientIp(request);
+ byte[] ipBytes = IpUtils.ipToBytes(clientIp);
+
+ // Get country code from IP
+ String countryCode = countryCodeService.getCountryCode(clientIp);
+
+ // Get current timestamp
+ long nowSeconds = TimeProvider.nowSeconds();
+
+ // Check if user exists
+ Optional existingUserOpt = userARepository.findByTelegramId(telegramId);
+
+ if (existingUserOpt.isPresent()) {
+ // User exists - update login data
+ UserA userA = existingUserOpt.get();
+ updateUserOnLogin(userA, screenName, username, isPremium, languageCode, countryCode, deviceCode, ipBytes, nowSeconds);
+ return userA;
+ } else {
+ // New user - create in all 3 tables
+ return createNewUser(telegramId, screenName, username, isPremium, languageCode, countryCode, deviceCode, ipBytes, nowSeconds, start);
+ }
+ }
+
+ /**
+ * Updates user data on login (when session is created).
+ * Note: language_code is NOT updated here - it should be updated via separate endpoint when user changes language in app.
+ */
+ private void updateUserOnLogin(UserA userA, String screenName, String username, Boolean isPremium,
+ String languageCode, String countryCode, String deviceCode,
+ byte[] ipBytes, long nowSeconds) {
+ userA.setScreenName(screenName);
+ userA.setTelegramName(username != null ? username : "-");
+ userA.setIsPremium(isPremium != null && isPremium ? 1 : 0);
+ userA.setCountryCode(countryCode);
+ userA.setDeviceCode(deviceCode != null ? deviceCode : "XX");
+ userA.setIp(ipBytes);
+ userA.setDateLogin((int) nowSeconds);
+ // language_code is NOT updated here - it's updated via separate endpoint when user changes language
+
+ userARepository.save(userA);
+ log.debug("Updated user data on login: userId={}", userA.getId());
+ }
+
+ /**
+ * Updates user's language code (called when user changes language in app header).
+ */
+ @Transactional
+ public void updateLanguageCode(Integer userId, String languageCode) {
+ Optional userOpt = userARepository.findById(userId);
+ if (userOpt.isPresent()) {
+ UserA user = userOpt.get();
+ user.setLanguageCode(languageCode != null && languageCode.length() == 2 ? languageCode.toUpperCase() : "XX");
+ userARepository.save(user);
+ log.debug("Updated language_code for userId={}: {}", userId, user.getLanguageCode());
+ }
+ }
+
+ /**
+ * Creates a new user in all 3 tables with referral handling.
+ */
+ private UserA createNewUser(Long telegramId, String screenName, String username, Boolean isPremium,
+ String languageCode, String countryCode, String deviceCode,
+ byte[] ipBytes, long nowSeconds, String start) {
+
+ // Create UserA
+ UserA userA = UserA.builder()
+ .screenName(screenName)
+ .telegramId(telegramId)
+ .telegramName(username != null ? username : "-")
+ .isPremium(isPremium != null && isPremium ? 1 : 0)
+ .languageCode(languageCode != null ? languageCode : "XX")
+ .countryCode(countryCode)
+ .deviceCode(deviceCode != null ? deviceCode : "XX")
+ .ip(ipBytes)
+ .dateReg((int) nowSeconds)
+ .dateLogin((int) nowSeconds)
+ .banned(0)
+ .build();
+
+ userA = userARepository.save(userA);
+ Integer userId = userA.getId();
+
+ log.info("Created new user: userId={}, telegramId={}", userId, telegramId);
+
+ // Create UserB with same ID
+ UserB userB = UserB.builder()
+ .id(userId)
+ .balanceA(0L)
+ .balanceB(0L)
+ .depositTotal(0L)
+ .depositCount(0)
+ .withdrawTotal(0L)
+ .withdrawCount(0)
+ .build();
+ userBRepository.save(userB);
+
+ // Create UserD with referral handling
+ UserD userD = createUserDWithReferral(userId, start);
+ userDRepository.save(userD);
+
+ return userA;
+ }
+
+ /**
+ * Creates UserD entity with referral chain setup.
+ * @param userId New user's ID
+ * @param start Referral parameter from URL (e.g., "774876" from /honey?start=774876)
+ */
+ private UserD createUserDWithReferral(Integer userId, String start) {
+ UserD.UserDBuilder builder = UserD.builder()
+ .id(userId)
+ .refererId1(0)
+ .refererId2(0)
+ .refererId3(0)
+ .refererId4(0)
+ .refererId5(0)
+ .masterId(1) // Default master_id = 1
+ .referals1(0)
+ .referals2(0)
+ .referals3(0)
+ .referals4(0)
+ .referals5(0)
+ .fromReferals1(0L)
+ .fromReferals2(0L)
+ .fromReferals3(0L)
+ .fromReferals4(0L)
+ .fromReferals5(0L)
+ .toReferer1(0L)
+ .toReferer2(0L)
+ .toReferer3(0L)
+ .toReferer4(0L)
+ .toReferer5(0L);
+
+ if (start != null && !start.isEmpty()) {
+ try {
+ Integer refererId = Integer.parseInt(start);
+ Optional refererUserDOpt = userDRepository.findById(refererId);
+
+ if (refererUserDOpt.isPresent()) {
+ UserD refererUserD = refererUserDOpt.get();
+
+ // Set referral chain: shift referer's chain down by 1 level
+ builder.refererId1(refererId)
+ .masterId(refererUserD.getMasterId())
+ .refererId2(refererUserD.getRefererId1())
+ .refererId3(refererUserD.getRefererId2())
+ .refererId4(refererUserD.getRefererId3())
+ .refererId5(refererUserD.getRefererId4());
+
+ // Increment referal counts for all 5 levels up the chain
+ setupReferralChain(userId, refererId);
+ } else {
+ // Referer doesn't exist, just set referer_id_1
+ log.warn("Referer with id {} not found, setting only referer_id_1", refererId);
+ builder.refererId1(refererId);
+ }
+ } catch (NumberFormatException e) {
+ log.warn("Invalid start parameter format: {}", start);
+ }
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Sets up referral chain and increments referal counts for all 5 levels.
+ * Example: If user F registers with referer E, increments:
+ * - referals_1 for E
+ * - referals_2 for D (E's referer_id_1)
+ * - referals_3 for C (D's referer_id_1)
+ * - referals_4 for B (C's referer_id_1)
+ * - referals_5 for A (B's referer_id_1)
+ */
+ private void setupReferralChain(Integer newUserId, Integer refererId) {
+ // Level 1: Direct referer
+ userDRepository.incrementReferals1(refererId);
+
+ Optional level1Opt = userDRepository.findById(refererId);
+ if (level1Opt.isEmpty()) {
+ return;
+ }
+
+ UserD level1 = level1Opt.get();
+
+ // Level 2
+ if (level1.getRefererId1() > 0) {
+ userDRepository.incrementReferals2(level1.getRefererId1());
+
+ Optional level2Opt = userDRepository.findById(level1.getRefererId1());
+ if (level2Opt.isPresent()) {
+ UserD level2 = level2Opt.get();
+
+ // Level 3
+ if (level2.getRefererId1() > 0) {
+ userDRepository.incrementReferals3(level2.getRefererId1());
+
+ Optional level3Opt = userDRepository.findById(level2.getRefererId1());
+ if (level3Opt.isPresent()) {
+ UserD level3 = level3Opt.get();
+
+ // Level 4
+ if (level3.getRefererId1() > 0) {
+ userDRepository.incrementReferals4(level3.getRefererId1());
+
+ Optional level4Opt = userDRepository.findById(level3.getRefererId1());
+ if (level4Opt.isPresent()) {
+ UserD level4 = level4Opt.get();
+
+ // Level 5
+ if (level4.getRefererId1() > 0) {
+ userDRepository.incrementReferals5(level4.getRefererId1());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ log.info("Referral chain setup completed: newUserId={}, refererId={}", newUserId, refererId);
+ }
+
+ /**
+ * Builds screen_name from first_name and last_name.
+ */
+ private String buildScreenName(String firstName, String lastName) {
+ StringBuilder sb = new StringBuilder();
+ if (firstName != null && !firstName.isEmpty()) {
+ sb.append(firstName);
+ }
+ if (lastName != null && !lastName.isEmpty()) {
+ if (sb.length() > 0) {
+ sb.append(" ");
+ }
+ sb.append(lastName);
+ }
+ String result = sb.toString().trim();
+ return result.isEmpty() ? "-" : (result.length() > 75 ? result.substring(0, 75) : result);
+ }
+
+ /**
+ * Gets user by ID.
+ */
+ public Optional getUserById(Integer userId) {
+ return userARepository.findById(userId);
+ }
+
+ /**
+ * Gets user by Telegram ID.
+ */
+ public Optional getUserByTelegramId(Long telegramId) {
+ return userARepository.findByTelegramId(telegramId);
+ }
+}
+
diff --git a/src/main/java/com/honey/honey/util/IpUtils.java b/src/main/java/com/honey/honey/util/IpUtils.java
new file mode 100644
index 0000000..4dc06e9
--- /dev/null
+++ b/src/main/java/com/honey/honey/util/IpUtils.java
@@ -0,0 +1,177 @@
+package com.honey.honey.util;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/**
+ * Utility for IP address handling with reverse proxy support.
+ */
+@Slf4j
+public class IpUtils {
+
+ /**
+ * Extracts client IP address from request, handling reverse proxies.
+ * Priority:
+ * 1. X-Forwarded-For header (first IP in chain)
+ * 2. Forwarded header (RFC 7239)
+ * 3. X-Real-IP header
+ * 4. Remote address (fallback)
+ *
+ * @param request HTTP request
+ * @return Client IP address as string (normalized), or null if not available
+ */
+ public static String getClientIp(HttpServletRequest request) {
+ // 1) Standard proxy header (most common)
+ String xff = request.getHeader("X-Forwarded-For");
+ if (xff != null && !xff.isBlank() && !"unknown".equalsIgnoreCase(xff)) {
+ // Format: "client, proxy1, proxy2" - take first IP
+ String first = xff.split(",")[0].trim();
+ if (!first.isEmpty()) {
+ return normalize(first);
+ }
+ }
+
+ // 2) RFC 7239 Forwarded header (optional, more complex)
+ String forwarded = request.getHeader("Forwarded");
+ if (forwarded != null && !forwarded.isBlank()) {
+ String ip = extractFromForwardedHeader(forwarded);
+ if (ip != null) {
+ return normalize(ip);
+ }
+ }
+
+ // 3) X-Real-IP header (some proxies use this)
+ String xRealIp = request.getHeader("X-Real-IP");
+ if (xRealIp != null && !xRealIp.isBlank() && !"unknown".equalsIgnoreCase(xRealIp)) {
+ return normalize(xRealIp);
+ }
+
+ // 4) Fallback to remote address
+ return normalize(request.getRemoteAddr());
+ }
+
+ /**
+ * Extracts IP from RFC 7239 Forwarded header.
+ * Format: "for=192.0.2.60;proto=http;by=203.0.113.43"
+ */
+ private static String extractFromForwardedHeader(String forwarded) {
+ try {
+ // Look for "for=" pattern
+ int forIndex = forwarded.toLowerCase().indexOf("for=");
+ if (forIndex == -1) {
+ return null;
+ }
+
+ // Extract value after "for="
+ String afterFor = forwarded.substring(forIndex + 4);
+ // Value can be quoted or unquoted, and may contain port
+ // Stop at semicolon, comma, quote, or space
+ StringBuilder ipBuilder = new StringBuilder();
+ for (int i = 0; i < afterFor.length(); i++) {
+ char c = afterFor.charAt(i);
+ if (c == ';' || c == ',' || c == ' ' || c == '"') {
+ break;
+ }
+ ipBuilder.append(c);
+ }
+
+ String candidate = ipBuilder.toString().trim();
+ return candidate.isEmpty() ? null : candidate;
+ } catch (Exception e) {
+ log.debug("Failed to parse Forwarded header: {}", forwarded, e);
+ return null;
+ }
+ }
+
+ /**
+ * Normalizes IP address string:
+ * - Removes brackets from IPv6 (e.g., "[2001:db8::1]" -> "2001:db8::1")
+ * - Removes port number if present (e.g., "192.168.1.1:8080" -> "192.168.1.1")
+ * - Trims whitespace
+ *
+ * @param ip IP address string (may contain brackets, port, etc.)
+ * @return Normalized IP address, or null if input is null
+ */
+ private static String normalize(String ip) {
+ if (ip == null) {
+ return null;
+ }
+
+ ip = ip.trim();
+ if (ip.isEmpty()) {
+ return null;
+ }
+
+ // Handle IPv6 in brackets, e.g., "[2001:db8::1]"
+ if (ip.startsWith("[") && ip.endsWith("]")) {
+ ip = ip.substring(1, ip.length() - 1);
+ }
+
+ // Handle "ip:port" formats
+ // For IPv4: "192.168.1.1:8080" -> "192.168.1.1"
+ // For IPv6: "2001:db8::1:8080" is ambiguous, but we try to detect port
+ int lastColon = ip.lastIndexOf(':');
+ if (lastColon > 0) {
+ // Check if part after last colon looks like a port number
+ String afterColon = ip.substring(lastColon + 1);
+ if (afterColon.chars().allMatch(Character::isDigit)) {
+ // Likely a port number
+ String beforeColon = ip.substring(0, lastColon);
+ // For IPv4, we can safely remove port
+ if (beforeColon.contains(".") && !beforeColon.contains("::")) {
+ ip = beforeColon;
+ }
+ // For IPv6, we need to be more careful - only remove if it's clearly a port
+ // IPv6 addresses can contain colons, so we check if it's a valid IPv6 format
+ // For simplicity, if it contains "::" or more than 2 colons, assume it's IPv6 and don't remove
+ }
+ }
+
+ return ip;
+ }
+
+ /**
+ * Converts IP address string to varbinary(16) format for database storage.
+ * Supports both IPv4 and IPv6.
+ *
+ * @param ipAddress IP address as string
+ * @return IP address as byte array (4 bytes for IPv4, 16 bytes for IPv6), or null if invalid
+ */
+ public static byte[] ipToBytes(String ipAddress) {
+ if (ipAddress == null || ipAddress.isEmpty()) {
+ return null;
+ }
+
+ try {
+ InetAddress inetAddress = InetAddress.getByName(ipAddress);
+ return inetAddress.getAddress();
+ } catch (UnknownHostException e) {
+ log.warn("Failed to parse IP address: {}", ipAddress, e);
+ return null;
+ }
+ }
+
+ /**
+ * Converts IP address from varbinary(16) format to string.
+ *
+ * @param ipBytes IP address as byte array
+ * @return IP address as string, or null if invalid
+ */
+ public static String bytesToIp(byte[] ipBytes) {
+ if (ipBytes == null || ipBytes.length == 0) {
+ return null;
+ }
+
+ try {
+ InetAddress inetAddress = InetAddress.getByAddress(ipBytes);
+ return inetAddress.getHostAddress();
+ } catch (UnknownHostException e) {
+ log.warn("Failed to convert IP bytes to string", e);
+ return null;
+ }
+ }
+}
+
diff --git a/src/main/java/com/honey/honey/util/TimeProvider.java b/src/main/java/com/honey/honey/util/TimeProvider.java
new file mode 100644
index 0000000..9c4fee3
--- /dev/null
+++ b/src/main/java/com/honey/honey/util/TimeProvider.java
@@ -0,0 +1,27 @@
+package com.honey.honey.util;
+
+import java.time.Instant;
+
+/**
+ * Centralized time provider for consistent time handling across the application.
+ * All time-related operations should use this provider.
+ */
+public class TimeProvider {
+
+ /**
+ * Gets current Unix timestamp in seconds.
+ * @return Current epoch seconds
+ */
+ public static long nowSeconds() {
+ return Instant.now().getEpochSecond();
+ }
+
+ /**
+ * Gets current Unix timestamp in milliseconds.
+ * @return Current epoch milliseconds
+ */
+ public static long nowMillis() {
+ return Instant.now().toEpochMilli();
+ }
+}
+
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 2166dd6..4f7ddbf 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -46,6 +46,12 @@ app:
# Maximum number of batches to process per cleanup run
max-batches-per-run: ${APP_SESSION_CLEANUP_MAX_BATCHES:20}
+# GeoIP configuration
+# Set GEOIP_DB_PATH environment variable to use external file (recommended for production)
+# If not set, falls back to classpath:geoip/GeoLite2-Country.mmdb
+geoip:
+ db-path: ${GEOIP_DB_PATH:}
+
logging:
level:
root: INFO
diff --git a/src/main/resources/db/migration/V3__replace_users_with_sharded_tables.sql b/src/main/resources/db/migration/V3__replace_users_with_sharded_tables.sql
new file mode 100644
index 0000000..6720f0c
--- /dev/null
+++ b/src/main/resources/db/migration/V3__replace_users_with_sharded_tables.sql
@@ -0,0 +1,107 @@
+-- Drop foreign key constraint from sessions table if it exists
+-- Find the foreign key name dynamically (MySQL auto-generates names like sessions_ibfk_1)
+SET @fk_name = NULL;
+SELECT CONSTRAINT_NAME INTO @fk_name
+FROM information_schema.TABLE_CONSTRAINTS
+WHERE CONSTRAINT_SCHEMA = DATABASE()
+AND TABLE_NAME = 'sessions'
+AND CONSTRAINT_TYPE = 'FOREIGN KEY'
+LIMIT 1;
+
+SET @sql = IF(@fk_name IS NOT NULL,
+ CONCAT('ALTER TABLE sessions DROP FOREIGN KEY ', @fk_name),
+ 'DO 1');
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- Drop old users table
+DROP TABLE IF EXISTS users;
+
+-- Create db_users_a table
+CREATE TABLE `db_users_a` (
+ `id` int NOT NULL,
+ `screen_name` varchar(75) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '-',
+ `telegram_id` bigint UNSIGNED DEFAULT NULL,
+ `telegram_name` varchar(33) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '-',
+ `is_premium` int NOT NULL DEFAULT '0',
+ `language_code` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'XX',
+ `country_code` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'XX',
+ `device_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'XX',
+ `ip` varbinary(16) DEFAULT NULL,
+ `date_reg` int NOT NULL DEFAULT '0',
+ `date_login` int NOT NULL DEFAULT '0',
+ `banned` int NOT NULL DEFAULT '0'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+-- Create db_users_b table
+CREATE TABLE `db_users_b` (
+ `id` int NOT NULL DEFAULT '0',
+ `balance_a` bigint UNSIGNED NOT NULL DEFAULT '0',
+ `balance_b` bigint UNSIGNED NOT NULL DEFAULT '0',
+ `deposit_total` bigint NOT NULL DEFAULT '0',
+ `deposit_count` int NOT NULL DEFAULT '0',
+ `withdraw_total` bigint NOT NULL DEFAULT '0',
+ `withdraw_count` int NOT NULL DEFAULT '0'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+-- Create db_users_d table
+CREATE TABLE `db_users_d` (
+ `id` int NOT NULL DEFAULT '0',
+ `referer_id_1` int NOT NULL DEFAULT '0',
+ `referer_id_2` int NOT NULL DEFAULT '0',
+ `referer_id_3` int NOT NULL DEFAULT '0',
+ `referer_id_4` int NOT NULL DEFAULT '0',
+ `referer_id_5` int NOT NULL DEFAULT '0',
+ `master_id` int NOT NULL DEFAULT '0',
+ `referals_1` int NOT NULL DEFAULT '0',
+ `referals_2` int NOT NULL DEFAULT '0',
+ `referals_3` int NOT NULL DEFAULT '0',
+ `referals_4` int NOT NULL DEFAULT '0',
+ `referals_5` int NOT NULL DEFAULT '0',
+ `from_referals_1` bigint NOT NULL DEFAULT '0',
+ `from_referals_2` bigint NOT NULL DEFAULT '0',
+ `from_referals_3` bigint NOT NULL DEFAULT '0',
+ `from_referals_4` bigint NOT NULL DEFAULT '0',
+ `from_referals_5` bigint NOT NULL DEFAULT '0',
+ `to_referer_1` bigint NOT NULL DEFAULT '0',
+ `to_referer_2` bigint NOT NULL DEFAULT '0',
+ `to_referer_3` bigint NOT NULL DEFAULT '0',
+ `to_referer_4` bigint NOT NULL DEFAULT '0',
+ `to_referer_5` bigint NOT NULL DEFAULT '0'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+-- Add indexes for db_users_a
+ALTER TABLE `db_users_a`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `telegram_id` (`telegram_id`),
+ ADD KEY `telegram_name` (`telegram_name`),
+ ADD KEY `ip` (`ip`);
+
+-- Add indexes for db_users_b
+ALTER TABLE `db_users_b`
+ ADD KEY `id` (`id`);
+
+-- Add indexes for db_users_d
+ALTER TABLE `db_users_d`
+ ADD KEY `id` (`id`),
+ ADD KEY `referer_id_1` (`referer_id_1`),
+ ADD KEY `referer_id_2` (`referer_id_2`),
+ ADD KEY `referer_id_3` (`referer_id_3`),
+ ADD KEY `referer_id_4` (`referer_id_4`),
+ ADD KEY `referer_id_5` (`referer_id_5`),
+ ADD KEY `master_id` (`master_id`);
+
+-- Set auto increment for db_users_a
+ALTER TABLE `db_users_a`
+ MODIFY `id` int NOT NULL AUTO_INCREMENT;
+
+-- Update sessions table: change user_id from BIGINT to INT to match db_users_a.id
+ALTER TABLE `sessions`
+ MODIFY `user_id` int NOT NULL;
+
+-- Add foreign key constraint from sessions to db_users_a
+ALTER TABLE `sessions`
+ ADD CONSTRAINT `fk_sessions_user_id` FOREIGN KEY (`user_id`) REFERENCES `db_users_a`(`id`) ON DELETE CASCADE;
+
diff --git a/src/main/resources/geoip/GeoLite2-Country.mmdb b/src/main/resources/geoip/GeoLite2-Country.mmdb
new file mode 100644
index 0000000..280252c
Binary files /dev/null and b/src/main/resources/geoip/GeoLite2-Country.mmdb differ