diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..69c31df Binary files /dev/null and b/.DS_Store differ diff --git a/pom.xml b/pom.xml index 88a43ce..d56828d 100644 --- a/pom.xml +++ b/pom.xml @@ -79,12 +79,6 @@ 4.2.0 - - - org.springframework.boot - spring-boot-starter-websocket - - org.telegram diff --git a/src/main/java/com/lottery/lottery/config/WebConfig.java b/src/main/java/com/lottery/lottery/config/WebConfig.java index 3305311..041c6fd 100644 --- a/src/main/java/com/lottery/lottery/config/WebConfig.java +++ b/src/main/java/com/lottery/lottery/config/WebConfig.java @@ -32,7 +32,6 @@ public class WebConfig implements WebMvcConfigurer { "/api/check_user/**", // User check endpoint for external applications (open endpoint) "/api/deposit_webhook/**", // 3rd party deposit completion webhook (token in path, no auth) "/api/notify_broadcast/**", // Notify broadcast start/stop (token in path, no auth) - "/api/remotebet/**", // Remote bet: token + feature switch protected, no user auth "/api/admin/**" // Admin endpoints are handled by Spring Security ); diff --git a/src/main/java/com/lottery/lottery/config/WebSocketAuthInterceptor.java b/src/main/java/com/lottery/lottery/config/WebSocketAuthInterceptor.java deleted file mode 100644 index bb5a3c8..0000000 --- a/src/main/java/com/lottery/lottery/config/WebSocketAuthInterceptor.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.lottery.lottery.config; - -import com.lottery.lottery.model.UserA; -import com.lottery.lottery.security.UserContext; -import com.lottery.lottery.service.SessionService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageChannel; -import org.springframework.messaging.simp.stomp.StompCommand; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.messaging.support.ChannelInterceptor; -import org.springframework.messaging.support.MessageHeaderAccessor; -import org.springframework.stereotype.Component; - -import java.security.Principal; -import java.util.List; - -@Slf4j -@Component -@RequiredArgsConstructor -public class WebSocketAuthInterceptor implements ChannelInterceptor { - - private final SessionService sessionService; - - @Override - public Message preSend(Message message, MessageChannel channel) { - StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); - - if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { - // Extract Bearer token from headers - List authHeaders = accessor.getNativeHeader("Authorization"); - String token = null; - - if (authHeaders != null && !authHeaders.isEmpty()) { - String authHeader = authHeaders.get(0); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - token = authHeader.substring(7); - } - } - - // Also check query parameter (for SockJS fallback) - if (token == null) { - String query = accessor.getFirstNativeHeader("query"); - if (query != null && query.contains("token=")) { - int tokenStart = query.indexOf("token=") + 6; - int tokenEnd = query.indexOf("&", tokenStart); - if (tokenEnd == -1) { - tokenEnd = query.length(); - } - token = query.substring(tokenStart, tokenEnd); - } - } - - if (token == null || token.isBlank()) { - log.warn("WebSocket connection rejected: No token provided"); - throw new SecurityException("Authentication required"); - } - - // Validate token and get user - var userOpt = sessionService.getUserBySession(token); - if (userOpt.isEmpty()) { - log.warn("WebSocket connection rejected: Invalid token"); - throw new SecurityException("Invalid authentication token"); - } - - UserA user = userOpt.get(); - accessor.setUser(new StompPrincipal(user.getId(), user)); - UserContext.set(user); - - log.debug("WebSocket connection authenticated for user {}", user.getId()); - } - - return message; - } - - @Override - public void postSend(Message message, MessageChannel channel, boolean sent) { - UserContext.clear(); - } - - // Simple principal class to store user info - public static class StompPrincipal implements Principal { - private final Integer userId; - private final UserA user; - - public StompPrincipal(Integer userId, UserA user) { - this.userId = userId; - this.user = user; - } - - public Integer getUserId() { - return userId; - } - - public UserA getUser() { - return user; - } - - @Override - public String getName() { - return String.valueOf(userId); - } - } -} - diff --git a/src/main/java/com/lottery/lottery/config/WebSocketConfig.java b/src/main/java/com/lottery/lottery/config/WebSocketConfig.java deleted file mode 100644 index e53ad4f..0000000 --- a/src/main/java/com/lottery/lottery/config/WebSocketConfig.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.lottery.lottery.config; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.messaging.simp.config.ChannelRegistration; -import org.springframework.messaging.simp.config.MessageBrokerRegistry; -import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; -import org.springframework.web.socket.config.annotation.StompEndpointRegistry; -import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; - -import java.util.Arrays; -import java.util.List; - -@Slf4j -@Configuration -@EnableWebSocketMessageBroker -public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - private final WebSocketAuthInterceptor authInterceptor; - - @Value("${app.websocket.allowed-origins:https://win-spin.live,https://lottery-fe-production.up.railway.app,https://web.telegram.org,https://webk.telegram.org,https://webz.telegram.org}") - private String allowedOrigins; - - public WebSocketConfig(WebSocketAuthInterceptor authInterceptor) { - this.authInterceptor = authInterceptor; - } - - @Override - public void configureMessageBroker(MessageBrokerRegistry config) { - // Enable simple broker for sending messages to clients - config.enableSimpleBroker("/topic", "/queue"); - // Prefix for messages from client to server - config.setApplicationDestinationPrefixes("/app"); - } - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - // Parse allowed origins from configuration - // Spring's setAllowedOriginPatterns uses Ant-style patterns, not regex - // For exact matches, use the URL as-is - // For subdomain matching, use https://*.example.com - List origins = Arrays.asList(allowedOrigins.split(",")); - String[] originPatterns = origins.stream() - .map(String::trim) - .filter(origin -> !origin.isEmpty()) - .toArray(String[]::new); - - log.info("[WEBSOCKET] Configuring WebSocket endpoint /ws with allowed origins: {}", Arrays.toString(originPatterns)); - - // WebSocket endpoint - clients connect here - registry.addEndpoint("/ws") - .setAllowedOriginPatterns(originPatterns) // Restricted to configured domains - .withSockJS(); - } - - @Override - public void configureClientInboundChannel(ChannelRegistration registration) { - registration.interceptors(authInterceptor); - } -} - diff --git a/src/main/java/com/lottery/lottery/config/WebSocketSubscriptionListener.java b/src/main/java/com/lottery/lottery/config/WebSocketSubscriptionListener.java deleted file mode 100644 index 1a4ddef..0000000 --- a/src/main/java/com/lottery/lottery/config/WebSocketSubscriptionListener.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.lottery.lottery.config; - -import com.lottery.lottery.dto.GameRoomStateDto; -import com.lottery.lottery.service.GameRoomService; -import com.lottery.lottery.service.RoomConnectionService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.messaging.SessionDisconnectEvent; -import org.springframework.web.socket.messaging.SessionSubscribeEvent; -import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -@Slf4j -@Component -@RequiredArgsConstructor -public class WebSocketSubscriptionListener { - - private final GameRoomService gameRoomService; - private final SimpMessagingTemplate messagingTemplate; - private final RoomConnectionService roomConnectionService; - - // Pattern to match room subscription: /topic/room/{roomNumber} - private static final Pattern ROOM_SUBSCRIPTION_PATTERN = Pattern.compile("/topic/room/(\\d+)"); - - /** - * Listens for WebSocket subscription events. - * When a client subscribes to a room topic, sends the current room state immediately. - */ - @EventListener - public void handleSubscription(SessionSubscribeEvent event) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - String destination = accessor.getDestination(); - - if (destination == null) { - return; - } - - // Check if this is a room subscription - Matcher matcher = ROOM_SUBSCRIPTION_PATTERN.matcher(destination); - if (matcher.matches()) { - try { - Integer roomNumber = Integer.parseInt(matcher.group(1)); - - // Get the user ID from the principal - Object principal = accessor.getUser(); - Integer userId = null; - if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) { - userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId(); - } - - // Get session ID - String sessionId = accessor.getSessionId(); - - log.info("Client subscribed to room {} (userId: {}, sessionId: {})", roomNumber, userId, sessionId); - - // Register session for disconnect tracking - if (sessionId != null && userId != null) { - roomConnectionService.registerSession(sessionId, userId); - } - - // Track room-level connection (not just round participation) - if (userId != null && sessionId != null) { - roomConnectionService.addUserToRoom(userId, roomNumber, sessionId); - } else { - log.warn("Cannot track room connection: userId={}, sessionId={}", userId, sessionId); - } - - // Get current room state and send it to the subscribing client - // This ensures client gets authoritative state immediately on subscribe - GameRoomStateDto state = gameRoomService.getRoomState(roomNumber); - - // Send state directly to the destination (room topic) - // This will be received by the subscribing client - messagingTemplate.convertAndSend(destination, state); - - log.debug("Sent initial room state to subscriber: room={}, phase={}, participants={}, connectedUsers={}", - roomNumber, state.getPhase(), - state.getParticipants() != null ? state.getParticipants().size() : 0, - state.getConnectedUsers()); - - } catch (NumberFormatException e) { - log.warn("Invalid room number in subscription destination: {}", destination); - } catch (Exception e) { - log.error("Error sending initial state for room subscription: {}", destination, e); - } - } - } - - /** - * Listens for WebSocket unsubscribe events. - * When a client unsubscribes from a room topic, removes them from room connections. - */ - @EventListener - public void handleUnsubscribe(SessionUnsubscribeEvent event) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - String destination = accessor.getDestination(); - - // Skip if destination is null (Spring WebSocket sometimes sends unsubscribe events without destination during cleanup) - if (destination == null) { - return; - } - - log.debug("Unsubscribe event received for destination: {}", destination); - - // Check if this is a room unsubscription - Matcher matcher = ROOM_SUBSCRIPTION_PATTERN.matcher(destination); - if (matcher.matches()) { - try { - Integer roomNumber = Integer.parseInt(matcher.group(1)); - - // Get the user ID from the principal - Object principal = accessor.getUser(); - Integer userId = null; - if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) { - userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId(); - } else { - log.warn("Unsubscribe event: principal is not StompPrincipal, type: {}", - principal != null ? principal.getClass().getName() : "null"); - } - - // Get session ID - String sessionId = accessor.getSessionId(); - - if (userId != null && sessionId != null) { - log.info("Client unsubscribed from room {} (userId: {}, sessionId: {})", roomNumber, userId, sessionId); - roomConnectionService.removeUserFromRoom(userId, roomNumber, sessionId); - } else { - log.warn("Unsubscribe event: userId or sessionId is null for destination: {} (userId: {}, sessionId: {})", - destination, userId, sessionId); - } - } catch (NumberFormatException e) { - log.warn("Invalid room number in unsubscription destination: {}", destination); - } catch (Exception e) { - log.error("Error handling room unsubscription: {}", destination, e); - } - } else { - log.debug("Unsubscribe event destination does not match room pattern: {}", destination); - } - } - - /** - * Listens for WebSocket disconnect events. - * When a client disconnects completely, removes them from all rooms. - */ - @EventListener - public void handleDisconnect(SessionDisconnectEvent event) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - String sessionId = accessor.getSessionId(); - - // Try to get user ID from principal first - Object principal = accessor.getUser(); - Integer userId = null; - if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) { - userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId(); - } - - if (userId != null && sessionId != null) { - log.info("Client disconnected (userId: {}, sessionId: {}), removing session from all rooms", userId, sessionId); - // Remove only this specific session from all rooms - roomConnectionService.removeUserFromAllRooms(userId, sessionId); - // Also remove session mapping - roomConnectionService.removeSession(sessionId); - } else if (sessionId != null) { - // Principal might be lost, try to get userId from session mapping - log.info("Client disconnected (sessionId: {}), principal lost, using session mapping", sessionId); - roomConnectionService.removeUserFromAllRoomsBySession(sessionId); - } else { - log.warn("Disconnect event: both userId and sessionId are null, cannot remove from rooms"); - } - } -} - diff --git a/src/main/java/com/lottery/lottery/controller/AdminAnalyticsController.java b/src/main/java/com/lottery/lottery/controller/AdminAnalyticsController.java index e2b222b..c9064b5 100644 --- a/src/main/java/com/lottery/lottery/controller/AdminAnalyticsController.java +++ b/src/main/java/com/lottery/lottery/controller/AdminAnalyticsController.java @@ -2,7 +2,6 @@ package com.lottery.lottery.controller; import com.lottery.lottery.model.Payment; import com.lottery.lottery.model.Payout; -import com.lottery.lottery.repository.GameRoundRepository; import com.lottery.lottery.repository.PaymentRepository; import com.lottery.lottery.repository.PayoutRepository; import com.lottery.lottery.repository.UserARepository; @@ -30,7 +29,6 @@ public class AdminAnalyticsController { private final UserARepository userARepository; private final PaymentRepository paymentRepository; private final PayoutRepository payoutRepository; - private final GameRoundRepository gameRoundRepository; /** * Get revenue and payout time series data for charts. @@ -181,14 +179,11 @@ public class AdminAnalyticsController { // Count active players (logged in) in this period long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs); - // Count rounds resolved in this period - long rounds = gameRoundRepository.countByResolvedAtBetween(current, periodEnd); - Map point = new HashMap<>(); point.put("date", current.getEpochSecond()); point.put("newUsers", newUsers); point.put("activePlayers", activePlayers); - point.put("rounds", rounds); + point.put("rounds", 0L); dataPoints.add(point); diff --git a/src/main/java/com/lottery/lottery/controller/AdminBotConfigController.java b/src/main/java/com/lottery/lottery/controller/AdminBotConfigController.java deleted file mode 100644 index 3bf508d..0000000 --- a/src/main/java/com/lottery/lottery/controller/AdminBotConfigController.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.lottery.lottery.controller; - -import com.lottery.lottery.dto.AdminBotConfigDto; -import com.lottery.lottery.dto.AdminBotConfigRequest; -import com.lottery.lottery.service.AdminBotConfigService; -import com.lottery.lottery.service.ConfigurationService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import jakarta.validation.Valid; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -@RestController -@RequestMapping("/api/admin/bots") -@RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") -public class AdminBotConfigController { - - private final AdminBotConfigService adminBotConfigService; - private final ConfigurationService configurationService; - - @GetMapping - public ResponseEntity> list() { - return ResponseEntity.ok(adminBotConfigService.listAll()); - } - - @GetMapping("/{id}") - public ResponseEntity getById(@PathVariable Integer id) { - Optional dto = adminBotConfigService.getById(id); - return dto.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); - } - - @PostMapping - public ResponseEntity create(@Valid @RequestBody AdminBotConfigRequest request) { - try { - AdminBotConfigDto created = adminBotConfigService.create(request); - return ResponseEntity.status(HttpStatus.CREATED).body(created); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); - } - } - - @PutMapping("/{id}") - public ResponseEntity update(@PathVariable Integer id, @Valid @RequestBody AdminBotConfigRequest request) { - try { - Optional updated = adminBotConfigService.update(id, request); - return updated.map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.notFound().build()); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); - } - } - - @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable Integer id) { - boolean deleted = adminBotConfigService.delete(id); - return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); - } - - /** - * Shuffle time windows for bots that have the given room enabled. - * Redistributes the same set of time windows randomly across those bots. - */ - @PostMapping("/shuffle") - public ResponseEntity shuffleTimeWindows(@RequestParam int roomNumber) { - if (roomNumber != 2 && roomNumber != 3) { - return ResponseEntity.badRequest().body(Map.of("error", "roomNumber must be 2 or 3")); - } - try { - adminBotConfigService.shuffleTimeWindowsForRoom(roomNumber); - return ResponseEntity.ok().build(); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); - } - } - - @GetMapping("/settings") - public ResponseEntity> getBotSettings() { - return ResponseEntity.ok(Map.of("maxParticipantsBeforeBotJoin", configurationService.getMaxParticipantsBeforeBotJoin())); - } - - @PatchMapping("/settings") - public ResponseEntity updateBotSettings(@RequestBody Map body) { - Integer v = body != null ? body.get("maxParticipantsBeforeBotJoin") : null; - if (v == null) { - return ResponseEntity.badRequest().body(Map.of("error", "maxParticipantsBeforeBotJoin is required")); - } - int updated = configurationService.setMaxParticipantsBeforeBotJoin(v); - return ResponseEntity.ok(Map.of("maxParticipantsBeforeBotJoin", updated)); - } -} diff --git a/src/main/java/com/lottery/lottery/controller/AdminConfigurationsController.java b/src/main/java/com/lottery/lottery/controller/AdminConfigurationsController.java deleted file mode 100644 index 7eee83f..0000000 --- a/src/main/java/com/lottery/lottery/controller/AdminConfigurationsController.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.lottery.lottery.controller; - -import com.lottery.lottery.dto.AdminConfigurationsRequest; -import com.lottery.lottery.service.BotConfigService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Admin API for safe bots and flexible bots (winner-override config used e.g. with /remotebet). - * Configurations tab in admin panel uses GET/PUT /api/admin/configurations. - */ -@RestController -@RequestMapping("/api/admin/configurations") -@RequiredArgsConstructor -@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") -public class AdminConfigurationsController { - - private final BotConfigService botConfigService; - - @GetMapping - public ResponseEntity getConfig() { - return ResponseEntity.ok(botConfigService.getConfig()); - } - - @PutMapping - public ResponseEntity updateConfig( - @RequestBody AdminConfigurationsRequest request - ) { - List safeIds = request.getSafeBotUserIds() != null - ? request.getSafeBotUserIds() - : Collections.emptyList(); - List flexibleBots = Collections.emptyList(); - if (request.getFlexibleBots() != null) { - flexibleBots = request.getFlexibleBots().stream() - .filter(e -> e != null && e.getUserId() != null && e.getWinRate() != null) - .map(e -> new BotConfigService.FlexibleBotEntryDto(e.getUserId(), e.getWinRate())) - .collect(Collectors.toList()); - } - botConfigService.setSafeBotUserIds(safeIds); - botConfigService.setFlexibleBots(flexibleBots); - return ResponseEntity.ok(botConfigService.getConfig()); - } -} diff --git a/src/main/java/com/lottery/lottery/controller/AdminDashboardController.java b/src/main/java/com/lottery/lottery/controller/AdminDashboardController.java index fd57d7f..af7c27b 100644 --- a/src/main/java/com/lottery/lottery/controller/AdminDashboardController.java +++ b/src/main/java/com/lottery/lottery/controller/AdminDashboardController.java @@ -4,7 +4,6 @@ import com.lottery.lottery.model.Payment; import com.lottery.lottery.model.Payout; import com.lottery.lottery.model.SupportTicket; import com.lottery.lottery.model.UserA; -import com.lottery.lottery.repository.GameRoundRepository; import com.lottery.lottery.repository.PaymentRepository; import com.lottery.lottery.repository.PayoutRepository; import com.lottery.lottery.repository.SupportTicketRepository; @@ -31,7 +30,6 @@ public class AdminDashboardController { private final UserARepository userARepository; private final PaymentRepository paymentRepository; private final PayoutRepository payoutRepository; - private final GameRoundRepository gameRoundRepository; private final SupportTicketRepository supportTicketRepository; @GetMapping("/stats") @@ -105,17 +103,6 @@ public class AdminDashboardController { BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek); BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth); - // Game Rounds - long totalRounds = gameRoundRepository.count(); - long roundsToday = gameRoundRepository.countByResolvedAtAfter(todayStart); - long roundsWeek = gameRoundRepository.countByResolvedAtAfter(weekStart); - long roundsMonth = gameRoundRepository.countByResolvedAtAfter(monthStart); - - // Average Round Pool (from resolved rounds) - round to int - Double avgPoolDouble = gameRoundRepository.avgTotalBetByResolvedAtAfter(monthStart) - .orElse(0.0); - int avgPool = (int) Math.round(avgPoolDouble); - // Support Tickets long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED); // Count tickets closed today @@ -176,11 +163,11 @@ public class AdminDashboardController { stats.put("crypto", crypto); stats.put("rounds", Map.of( - "total", totalRounds, - "today", roundsToday, - "week", roundsWeek, - "month", roundsMonth, - "avgPool", avgPool + "total", 0L, + "today", 0L, + "week", 0L, + "month", 0L, + "avgPool", 0 )); stats.put("supportTickets", Map.of( diff --git a/src/main/java/com/lottery/lottery/controller/AdminRoomController.java b/src/main/java/com/lottery/lottery/controller/AdminRoomController.java deleted file mode 100644 index 58ba3d7..0000000 --- a/src/main/java/com/lottery/lottery/controller/AdminRoomController.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.lottery.lottery.controller; - -import com.lottery.lottery.dto.AdminRoomDetailDto; -import com.lottery.lottery.dto.AdminRoomOnlineUserDto; -import com.lottery.lottery.dto.AdminRoomSummaryDto; -import com.lottery.lottery.service.GameRoomService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/admin/rooms") -@RequiredArgsConstructor -public class AdminRoomController { - - private final GameRoomService gameRoomService; - - @GetMapping - @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") - public ResponseEntity> listRooms() { - return ResponseEntity.ok(gameRoomService.getAdminRoomSummaries()); - } - - @GetMapping("/online-users") - @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") - public ResponseEntity> getOnlineUsers() { - return ResponseEntity.ok(gameRoomService.getAdminOnlineUsersAcrossRooms()); - } - - @GetMapping("/{roomNumber}") - @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") - public ResponseEntity getRoomDetail(@PathVariable Integer roomNumber) { - if (roomNumber == null || roomNumber < 1 || roomNumber > 3) { - return ResponseEntity.badRequest().build(); - } - return ResponseEntity.ok(gameRoomService.getAdminRoomDetail(roomNumber)); - } - - @PostMapping("/{roomNumber}/repair") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity> repairRoom(@PathVariable Integer roomNumber) { - if (roomNumber == null || roomNumber < 1 || roomNumber > 3) { - return ResponseEntity.badRequest().build(); - } - try { - gameRoomService.repairRoom(roomNumber); - return ResponseEntity.ok(Map.of("success", true, "message", "Repair completed for room " + roomNumber)); - } catch (Exception e) { - return ResponseEntity.internalServerError() - .body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Repair failed")); - } - } -} diff --git a/src/main/java/com/lottery/lottery/controller/AdminUserController.java b/src/main/java/com/lottery/lottery/controller/AdminUserController.java index ea3ed5a..53acb6c 100644 --- a/src/main/java/com/lottery/lottery/controller/AdminUserController.java +++ b/src/main/java/com/lottery/lottery/controller/AdminUserController.java @@ -27,7 +27,7 @@ public class AdminUserController { private static final Set SORTABLE_FIELDS = Set.of( "id", "screenName", "telegramId", "telegramName", "isPremium", "languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned", - "balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit" + "balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit" ); private static final Set DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt"); private static final Set WITHDRAWAL_SORT_FIELDS = Set.of("id", "usdAmount", "cryptoName", "amountToSend", "txhash", "status", "paymentId", "createdAt", "resolvedAt"); @@ -57,17 +57,14 @@ public class AdminUserController { @RequestParam(required = false) Integer dateRegTo, @RequestParam(required = false) Long balanceMin, @RequestParam(required = false) Long balanceMax, - @RequestParam(required = false) Integer roundsPlayedMin, - @RequestParam(required = false) Integer roundsPlayedMax, @RequestParam(required = false) Integer referralCountMin, @RequestParam(required = false) Integer referralCountMax, @RequestParam(required = false) Integer referrerId, @RequestParam(required = false) Integer referralLevel, @RequestParam(required = false) String ip) { - // Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, roundsPlayed, referralCount) - // are handled in service via custom query; others are applied to UserA. - Set sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit"); + // Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, referralCount) are handled in service via custom query. + Set sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit"); String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null); if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) { // Pass through; service will use custom ordered query @@ -96,8 +93,6 @@ public class AdminUserController { dateRegTo, balanceMinBigint, balanceMaxBigint, - roundsPlayedMin, - roundsPlayedMax, referralCountMin, referralCountMax, referrerId, @@ -152,28 +147,6 @@ public class AdminUserController { return ResponseEntity.ok(response); } - @GetMapping("/{id}/game-rounds") - @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") - public ResponseEntity> getUserGameRounds( - @PathVariable Integer id, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "50") int size) { - - Pageable pageable = PageRequest.of(page, size); - Page rounds = adminUserService.getUserGameRounds(id, pageable); - - Map response = new HashMap<>(); - response.put("content", rounds.getContent()); - response.put("totalElements", rounds.getTotalElements()); - response.put("totalPages", rounds.getTotalPages()); - response.put("currentPage", rounds.getNumber()); - response.put("size", rounds.getSize()); - response.put("hasNext", rounds.hasNext()); - response.put("hasPrevious", rounds.hasPrevious()); - - return ResponseEntity.ok(response); - } - @GetMapping("/{id}/payments") @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") public ResponseEntity> getUserPayments( diff --git a/src/main/java/com/lottery/lottery/controller/GameController.java b/src/main/java/com/lottery/lottery/controller/GameController.java deleted file mode 100644 index a95ca38..0000000 --- a/src/main/java/com/lottery/lottery/controller/GameController.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.lottery.lottery.controller; - -import com.lottery.lottery.dto.CompletedRoundDto; -import com.lottery.lottery.dto.GameHistoryEntryDto; -import com.lottery.lottery.model.GameRound; -import com.lottery.lottery.repository.GameRoundRepository; -import com.lottery.lottery.security.UserContext; -import com.lottery.lottery.service.AvatarService; -import com.lottery.lottery.service.GameHistoryService; -import com.lottery.lottery.repository.UserARepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.stream.Collectors; - -@Slf4j -@RestController -@RequestMapping("/api/game") -@RequiredArgsConstructor -public class GameController { - - private final GameRoundRepository gameRoundRepository; - private final UserARepository userARepository; - private final AvatarService avatarService; - private final GameHistoryService gameHistoryService; - - /** - * Gets the last 10 completed rounds for a specific room. - * Fetches data from game_rounds table only. - */ - @GetMapping("/room/{roomNumber}/completed-rounds") - public ResponseEntity> getCompletedRounds( - @PathVariable Integer roomNumber - ) { - List rounds = gameRoundRepository.findLastCompletedRoundsByRoomNumber( - roomNumber, - PageRequest.of(0, 10) - ); - - List completedRounds = rounds.stream() - .map(round -> { - // Calculate winner's chance from game_rounds table data - Double winChance = null; - if (round.getWinnerBet() != null && round.getTotalBet() != null && round.getTotalBet() > 0) { - winChance = ((double) round.getWinnerBet() / round.getTotalBet()) * 100.0; - } - - // Get winner's screen name and avatar - String screenName = null; - String avatarUrl = null; - if (round.getWinnerUserId() != null) { - screenName = userARepository.findById(round.getWinnerUserId()) - .map(userA -> userA.getScreenName()) - .orElse(null); - avatarUrl = avatarService.getAvatarUrl(round.getWinnerUserId()); - } - - return CompletedRoundDto.builder() - .roundId(round.getId()) - .winnerUserId(round.getWinnerUserId()) - .winnerScreenName(screenName) - .winnerAvatarUrl(avatarUrl) - .winnerBet(round.getWinnerBet()) - .payout(round.getPayout()) - .totalBet(round.getTotalBet()) - .winChance(winChance) - .resolvedAt(round.getResolvedAt() != null ? round.getResolvedAt().toEpochMilli() : null) - .build(); - }) - .collect(Collectors.toList()); - - return ResponseEntity.ok(completedRounds); - } - - /** - * Gets WIN transactions for the current user from the last 30 days with pagination. - * - * @param page Page number (0-indexed, default 0) - * @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC. - */ - @GetMapping("/history") - public ResponseEntity> getUserGameHistory( - @RequestParam(defaultValue = "0") int page, - @RequestParam(required = false) String timezone) { - Integer userId = UserContext.get().getId(); - com.lottery.lottery.model.UserA user = UserContext.get(); - String languageCode = user.getLanguageCode(); - if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { - languageCode = "EN"; - } - org.springframework.data.domain.Page history = gameHistoryService.getUserGameHistory(userId, page, timezone, languageCode); - return ResponseEntity.ok(history); - } -} - diff --git a/src/main/java/com/lottery/lottery/controller/GameWebSocketController.java b/src/main/java/com/lottery/lottery/controller/GameWebSocketController.java deleted file mode 100644 index cbea96f..0000000 --- a/src/main/java/com/lottery/lottery/controller/GameWebSocketController.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.lottery.lottery.controller; - -import com.lottery.lottery.config.WebSocketAuthInterceptor; -import com.lottery.lottery.dto.BalanceUpdateDto; -import com.lottery.lottery.dto.GameRoomStateDto; -import com.lottery.lottery.dto.JoinRoundRequest; -import com.lottery.lottery.exception.GameException; -import com.lottery.lottery.service.GameRoomService; -import com.lottery.lottery.service.LocalizationService; -import com.lottery.lottery.service.UserService; -import jakarta.annotation.PostConstruct; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; -import org.springframework.messaging.handler.annotation.MessageExceptionHandler; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.messaging.simp.annotation.SubscribeMapping; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Controller; -import org.springframework.validation.annotation.Validated; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Slf4j -@Controller -@RequiredArgsConstructor -public class GameWebSocketController { - - private final GameRoomService gameRoomService; - private final SimpMessagingTemplate messagingTemplate; - private final UserService userService; - private final LocalizationService localizationService; - - // Track which users are subscribed to which rooms - private final Map userRoomSubscriptions = new ConcurrentHashMap<>(); - - // Track winners who have already received balance updates (to avoid duplicates) - private final Map notifiedWinners = new ConcurrentHashMap<>(); // roomNumber -> winnerUserId - - /** - * Initializes the controller and sets up balance update callback. - */ - @PostConstruct - public void init() { - // Set callback for balance update notifications - gameRoomService.setBalanceUpdateCallback(this::notifyBalanceUpdate); - - // Set callback for state broadcast notifications (event-driven) - gameRoomService.setStateBroadcastCallback(this::broadcastRoomState); - } - - /** - * Notifies a user about balance update. - * Called by GameRoomService for single participant refunds (no spin, so immediate update is fine). - */ - private void notifyBalanceUpdate(Integer userId) { - String username = String.valueOf(userId); - sendBalanceUpdate(username, userId); - } - - /** - * Handles join round request from client. - */ - @MessageMapping("/game/join") - public void joinRound(@Valid @Payload JoinRoundRequest request, WebSocketAuthInterceptor.StompPrincipal principal) { - Integer userId = principal.getUserId(); - - // Additional validation beyond @Valid annotations - // @Valid handles null checks and basic constraints, but we add explicit checks for clarity - if (request == null) { - throw new GameException(localizationService.getMessage("game.error.invalidRequest")); - } - - // Validate room number range (1-3) - // This is also covered by @Min/@Max, but explicit check provides better error message - if (request.getRoomNumber() == null || request.getRoomNumber() < 1 || request.getRoomNumber() > 3) { - throw new GameException(localizationService.getMessage("game.error.roomNumberInvalid")); - } - - // Validate bet amount is positive (also covered by @Positive, but explicit for clarity) - if (request.getBetAmount() == null || request.getBetAmount() <= 0) { - throw new GameException(localizationService.getMessage("game.error.betMustBePositive")); - } - - try { - // Join the round - GameRoomStateDto state = gameRoomService.joinRound(userId, request.getRoomNumber(), request.getBetAmount()); - - // Track subscription - userRoomSubscriptions.put(userId, request.getRoomNumber()); - - // Send balance update to the user who joined - sendBalanceUpdate(principal.getName(), userId); - - // State is already broadcast by GameRoomService.joinRound() via callback (event-driven) - // No need to broadcast again here - - } catch (GameException e) { - // User-friendly error message - sendErrorToUser(principal.getName(), e.getUserMessage()); - } catch (Exception e) { - // Generic error - don't expose technical details - log.error("Unexpected error joining round for user {}", userId, e); - sendErrorToUser(principal.getName(), localizationService.getMessage("common.error.unknown")); - } - } - - /** - * Sends error message to user. - */ - private void sendErrorToUser(String username, String errorMessage) { - messagingTemplate.convertAndSendToUser( - username, - "/queue/errors", - Map.of("error", errorMessage) - ); - } - - /** - * Global exception handler for WebSocket messages. - */ - @MessageExceptionHandler - public void handleException(Exception ex, WebSocketAuthInterceptor.StompPrincipal principal) { - String userMessage; - - if (ex instanceof GameException) { - userMessage = ((GameException) ex).getUserMessage(); - } else if (ex instanceof ConstraintViolationException) { - // Handle validation errors from @Valid annotation - ConstraintViolationException cve = (ConstraintViolationException) ex; - userMessage = cve.getConstraintViolations().stream() - .map(ConstraintViolation::getMessage) - .findFirst() - .orElse("Validation failed. Please check your input."); - log.warn("Validation error for user {}: {}", principal.getUserId(), userMessage); - } else { - log.error("Unexpected WebSocket error", ex); - userMessage = localizationService.getMessage("common.error.unknown"); - } - - sendErrorToUser(principal.getName(), userMessage); - } - - /** - * Sends current room state when client subscribes. - * Note: SubscribeMapping doesn't support path variables well, so we'll handle subscription in joinRound - */ - - /** - * Broadcasts room state to all subscribers. - * Called by GameRoomService via callback (event-driven). - */ - public void broadcastRoomState(Integer roomNumber, GameRoomStateDto state) { - messagingTemplate.convertAndSend("/topic/room/" + roomNumber, state); - } - - /** - * Sends balance update to a specific user. - */ - private void sendBalanceUpdate(String username, Integer userId) { - try { - // Get current balance from database - Long balance = userService.getUserBalance(userId); - if (balance != null) { - BalanceUpdateDto balanceUpdate = BalanceUpdateDto.builder() - .balanceA(balance) - .build(); - messagingTemplate.convertAndSendToUser( - username, - "/queue/balance", - balanceUpdate - ); - } - } catch (Exception e) { - log.error("Failed to send balance update to user {}", userId, e); - } - } -} - diff --git a/src/main/java/com/lottery/lottery/controller/RemoteBetController.java b/src/main/java/com/lottery/lottery/controller/RemoteBetController.java deleted file mode 100644 index 78a9272..0000000 --- a/src/main/java/com/lottery/lottery/controller/RemoteBetController.java +++ /dev/null @@ -1,191 +0,0 @@ -package com.lottery.lottery.controller; - -import com.lottery.lottery.exception.GameException; -import com.lottery.lottery.service.FeatureSwitchService; -import com.lottery.lottery.service.GameRoomService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.concurrent.ThreadLocalRandom; - -/** - * Unauthenticated API for 3rd party to register a user into a round (remote bet). - * Protected by a shared token in the path and a runtime feature switch. - * Same business logic as in-app join (balance, commissions, transactions, visibility in app). - */ -@Slf4j -@RestController -@RequestMapping("/api/remotebet") -@RequiredArgsConstructor -public class RemoteBetController { - - private static final long TICKETS_TO_BIGINT = 1_000_000L; - - @Value("${app.remote-bet.token:}") - private String configuredToken; - - private final FeatureSwitchService featureSwitchService; - private final GameRoomService gameRoomService; - - /** - * Registers the user to the current round in the given room with the given bet. - * GET /api/remotebet/{token}?user_id=228&room=2&amount=5&unique=false - * Or with random range: at least one of rand_min or rand_max (amount ignored). - * - user_id: db_users_a.id - * - room: room number (1, 2, or 3) - * - amount: bet in tickets. Ignored when rand_min and/or rand_max are provided. - * - unique: optional. If true, user can only have one bet per room per round (repeated calls no-op). - * - rand_min: optional. If only rand_min: random between rand_min and room max. If both: random between rand_min and rand_max. - * - rand_max: optional. If only rand_max: random between room min and rand_max. If both: random between rand_min and rand_max. - * Params are validated against room min/max (rand_min >= room min, rand_max <= room max; when both, rand_min <= rand_max). - */ - @GetMapping("/{token}") - public ResponseEntity remoteBet( - @PathVariable String token, - @RequestParam(name = "user_id") Integer userId, - @RequestParam(name = "room") Integer room, - @RequestParam(name = "amount") Integer amountTickets, - @RequestParam(name = "unique", required = false) Boolean unique, - @RequestParam(name = "rand_min", required = false) Integer randMin, - @RequestParam(name = "rand_max", required = false) Integer randMax) { - - if (configuredToken == null || configuredToken.isEmpty() || !configuredToken.equals(token)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - if (!featureSwitchService.isRemoteBetEnabled()) { - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); - } - - boolean useRandomRange = randMin != null || randMax != null; - long betAmount; - int amountTicketsForLog; - - if (useRandomRange) { - GameRoomService.BetLimits limits = GameRoomService.getBetLimitsForRoom(room); - long roomMinTickets = limits.minBet() / TICKETS_TO_BIGINT; - long roomMaxTickets = limits.maxBet() / TICKETS_TO_BIGINT; - - long effectiveMinTickets; - long effectiveMaxTickets; - - if (randMin != null && randMax != null) { - if (randMin < roomMinTickets) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "rand_min must not be lower than room min bet (" + roomMinTickets + " tickets)")); - } - if (randMax > roomMaxTickets) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "rand_max must not be higher than room max bet (" + roomMaxTickets + " tickets)")); - } - if (randMin > randMax) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "rand_min must be less than or equal to rand_max")); - } - effectiveMinTickets = randMin; - effectiveMaxTickets = randMax; - } else if (randMin != null) { - if (randMin < roomMinTickets) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "rand_min must not be lower than room min bet (" + roomMinTickets + " tickets)")); - } - if (randMin > roomMaxTickets) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "rand_min must not be higher than room max bet (" + roomMaxTickets + " tickets)")); - } - effectiveMinTickets = randMin; - effectiveMaxTickets = roomMaxTickets; - } else { - if (randMax < roomMinTickets) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "rand_max must not be lower than room min bet (" + roomMinTickets + " tickets)")); - } - if (randMax > roomMaxTickets) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "rand_max must not be higher than room max bet (" + roomMaxTickets + " tickets)")); - } - effectiveMinTickets = roomMinTickets; - effectiveMaxTickets = randMax; - } - - long currentUserBetBigint = gameRoomService.getCurrentUserBetInRoom(userId, room); - long maxAdditionalBigint = Math.max(0L, limits.maxBet() - currentUserBetBigint); - long maxAdditionalTickets = maxAdditionalBigint / TICKETS_TO_BIGINT; - if (maxAdditionalTickets < limits.minBet() / TICKETS_TO_BIGINT) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "Max bet for this room already reached")); - } - - effectiveMaxTickets = Math.min(effectiveMaxTickets, maxAdditionalTickets); - if (effectiveMinTickets > effectiveMaxTickets) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "Random range exceeds remaining bet capacity for this room")); - } - - // Room 1: any integer; Room 2: divisible by 10; Room 3: divisible by 100 - long step = room == 2 ? 10L : (room == 3 ? 100L : 1L); - long minAligned = roundUpToMultiple(effectiveMinTickets, step); - long maxAligned = roundDownToMultiple(effectiveMaxTickets, step); - if (minAligned > maxAligned) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, - "No valid random value in range for room " + room + " (room 2 must be multiple of 10, room 3 multiple of 100)")); - } - long randomTickets = minAligned >= maxAligned - ? minAligned - : minAligned + step * ThreadLocalRandom.current().nextLong(0, (maxAligned - minAligned) / step + 1); - betAmount = randomTickets * TICKETS_TO_BIGINT; - amountTicketsForLog = (int) randomTickets; - } else { - betAmount = (long) amountTickets * TICKETS_TO_BIGINT; - amountTicketsForLog = amountTickets; - } - - boolean uniqueBet = Boolean.TRUE.equals(unique); - try { - var result = gameRoomService.joinRoundWithResult(userId, room, betAmount, uniqueBet); - var state = result.getState(); - Long roundId = state.getRoundId(); - int betTicketsForResponse = result.getBetTicketsForResponse(); - String randRangeLog = useRandomRange ? (randMin != null ? randMin : "roomMin") + "-" + (randMax != null ? randMax : "roomMax") : "no"; - log.info("Remote bet: user connected to round remotely, userId={}, roundId={}, roomId={}, betTickets={}, unique={}, randRange={}", - userId, roundId, room, betTicketsForResponse, uniqueBet, randRangeLog); - return ResponseEntity.ok(new RemoteBetResponse(true, roundId != null ? roundId.intValue() : null, room, betTicketsForResponse)); - } catch (GameException e) { - return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, amountTicketsForLog, e.getUserMessage())); - } catch (Exception e) { - log.warn("Remote bet failed for userId={}, room={}, amount={}", userId, room, amountTicketsForLog, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new RemoteBetResponse(false, null, room, amountTicketsForLog, "Internal error")); - } - } - - @lombok.Data - @lombok.AllArgsConstructor - @lombok.NoArgsConstructor - public static class RemoteBetResponse { - private boolean success; - private Integer roundId; - private Integer room; - private Integer betTickets; - private String error; - - public RemoteBetResponse(boolean success, Integer roundId, Integer room, Integer betTickets) { - this(success, roundId, room, betTickets, null); - } - } - - /** Round value up to next multiple of step (e.g. 23, 10 -> 30). */ - private static long roundUpToMultiple(long value, long step) { - if (step <= 0) return value; - return ((value + step - 1) / step) * step; - } - - /** Round value down to previous multiple of step (e.g. 197, 10 -> 190). */ - private static long roundDownToMultiple(long value, long step) { - if (step <= 0) return value; - return (value / step) * step; - } -} diff --git a/src/main/java/com/lottery/lottery/controller/UserCheckController.java b/src/main/java/com/lottery/lottery/controller/UserCheckController.java index e9cf991..2f6e364 100644 --- a/src/main/java/com/lottery/lottery/controller/UserCheckController.java +++ b/src/main/java/com/lottery/lottery/controller/UserCheckController.java @@ -70,9 +70,6 @@ public class UserCheckController { // Convert to tickets (balance_a / 1,000,000) Double tickets = balanceA / 1_000_000.0; - // Get rounds_played from db_users_b - Integer roundsPlayed = userBOpt.map(UserB::getRoundsPlayed).orElse(0); - // Get referer_id_1 from db_users_d Optional userDOpt = userDRepository.findById(userId); Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0); @@ -91,7 +88,6 @@ public class UserCheckController { .tickets(tickets) .depositTotal(depositTotal) .refererId(refererId) - .roundsPlayed(roundsPlayed) .build(); return ResponseEntity.ok(response); diff --git a/src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java b/src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java deleted file mode 100644 index 2558288..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminBotConfigDto { - private Integer id; - private Integer userId; - /** User screen name from db_users_a (for display). */ - private String screenName; - private Boolean room1; - private Boolean room2; - private Boolean room3; - /** Time window start UTC, format HH:mm (e.g. "14:00"). */ - private String timeUtcStart; - /** Time window end UTC, format HH:mm (e.g. "17:00"). */ - private String timeUtcEnd; - /** Min bet in bigint (1 ticket = 1_000_000). */ - private Long betMin; - /** Max bet in bigint. */ - private Long betMax; - private String persona; - private Boolean active; - private Instant createdAt; - private Instant updatedAt; -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminBotConfigRequest.java b/src/main/java/com/lottery/lottery/dto/AdminBotConfigRequest.java deleted file mode 100644 index f0cf85d..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminBotConfigRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.lottery.lottery.dto; - -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminBotConfigRequest { - @NotNull(message = "userId is required") - private Integer userId; - @NotNull - private Boolean room1; - @NotNull - private Boolean room2; - @NotNull - private Boolean room3; - /** Time window start UTC, format HH:mm (e.g. "14:00"). */ - @NotNull - private String timeUtcStart; - /** Time window end UTC, format HH:mm (e.g. "17:00"). */ - @NotNull - private String timeUtcEnd; - /** Min bet in bigint (1 ticket = 1_000_000). */ - @NotNull - private Long betMin; - /** Max bet in bigint. */ - @NotNull - private Long betMax; - private String persona; // conservative, aggressive, balanced; default balanced - @NotNull - private Boolean active; -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminConfigurationsRequest.java b/src/main/java/com/lottery/lottery/dto/AdminConfigurationsRequest.java deleted file mode 100644 index cf7032d..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminConfigurationsRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.Data; - -import java.util.ArrayList; -import java.util.List; - -@Data -public class AdminConfigurationsRequest { - - private List safeBotUserIds = new ArrayList<>(); - private List flexibleBots = new ArrayList<>(); - - @Data - public static class FlexibleBotEntry { - private Integer userId; - private Double winRate; - } -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java b/src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java deleted file mode 100644 index 76008d1..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminGameRoundDto { - private Long roundId; - private Integer roomNumber; - private String phase; - private Long totalBet; - private Long userBet; - private Integer winnerUserId; - private Long winnerBet; - private Long payout; - private Long commission; - private Instant startedAt; - private Instant resolvedAt; - private Boolean isWinner; -} - diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomDetailDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomDetailDto.java deleted file mode 100644 index cb78f0a..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminRoomDetailDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminRoomDetailDto { - private Integer roomNumber; - private String phase; - private Long roundId; - private Long totalBetTickets; - private Double totalBetUsd; - private Integer registeredPlayers; - private Integer connectedUsers; - private List participants; - /** Viewers: same as participants section format but without tickets/chances (screen name + id). */ - private List connectedViewers; - private AdminRoomWinnerDto winner; // when phase is SPINNING or RESOLUTION -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomOnlineUserDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomOnlineUserDto.java deleted file mode 100644 index 0ab24c5..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminRoomOnlineUserDto.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * One row for the "all online users across rooms" admin table. - * currentBetTickets null = viewer (not registered in current round). - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminRoomOnlineUserDto { - private Integer userId; - private String screenName; - private Integer roomNumber; - /** Current round bet in tickets; null if viewer (not registered). */ - private Long currentBetTickets; - /** balance_a in bigint (divide by 1_000_000 for display). */ - private Long balanceA; - private Long depositTotal; - private Integer depositCount; - private Long withdrawTotal; - private Integer withdrawCount; - private Integer roundsPlayed; -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomParticipantDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomParticipantDto.java deleted file mode 100644 index 6f294e1..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminRoomParticipantDto.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminRoomParticipantDto { - private Integer userId; - private String screenName; - private Long betTickets; - private Double chancePct; -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomSummaryDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomSummaryDto.java deleted file mode 100644 index 4dc9b97..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminRoomSummaryDto.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminRoomSummaryDto { - private Integer roomNumber; - private String phase; // WAITING, COUNTDOWN, SPINNING, RESOLUTION - private Integer connectedUsers; - private Integer registeredPlayers; - private Long totalBetTickets; - private Double totalBetUsd; // tickets / 1000 for display - private Long roundId; -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomViewerDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomViewerDto.java deleted file mode 100644 index 561c421..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminRoomViewerDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminRoomViewerDto { - private Integer userId; - private String screenName; -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomWinnerDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomWinnerDto.java deleted file mode 100644 index 6d40935..0000000 --- a/src/main/java/com/lottery/lottery/dto/AdminRoomWinnerDto.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AdminRoomWinnerDto { - private Integer userId; - private String screenName; - private Long betTickets; - private Double winChancePct; -} diff --git a/src/main/java/com/lottery/lottery/dto/AdminUserDetailDto.java b/src/main/java/com/lottery/lottery/dto/AdminUserDetailDto.java index 3ebe2af..fe506d9 100644 --- a/src/main/java/com/lottery/lottery/dto/AdminUserDetailDto.java +++ b/src/main/java/com/lottery/lottery/dto/AdminUserDetailDto.java @@ -41,9 +41,6 @@ public class AdminUserDetailDto { /** When true, the user cannot create any payout request. */ private Boolean withdrawalsDisabled; - // Game Stats - private Integer roundsPlayed; - // Referral Info private Integer referralCount; private Long totalCommissionsEarned; diff --git a/src/main/java/com/lottery/lottery/dto/AdminUserDto.java b/src/main/java/com/lottery/lottery/dto/AdminUserDto.java index 8167057..4f2cbe2 100644 --- a/src/main/java/com/lottery/lottery/dto/AdminUserDto.java +++ b/src/main/java/com/lottery/lottery/dto/AdminUserDto.java @@ -22,7 +22,6 @@ public class AdminUserDto { private Integer depositCount; private Long withdrawTotal; private Integer withdrawCount; - private Integer roundsPlayed; private Integer dateReg; private Integer dateLogin; private Integer banned; diff --git a/src/main/java/com/lottery/lottery/dto/CompletedRoundDto.java b/src/main/java/com/lottery/lottery/dto/CompletedRoundDto.java deleted file mode 100644 index 14ec720..0000000 --- a/src/main/java/com/lottery/lottery/dto/CompletedRoundDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class CompletedRoundDto { - private Long roundId; - private Integer winnerUserId; - private String winnerScreenName; - private String winnerAvatarUrl; - private Long winnerBet; - private Long payout; - private Long totalBet; - private Double winChance; // winner's chance percentage - private Long resolvedAt; // timestamp -} - - - - diff --git a/src/main/java/com/lottery/lottery/dto/GameRoomStateDto.java b/src/main/java/com/lottery/lottery/dto/GameRoomStateDto.java deleted file mode 100644 index 69be243..0000000 --- a/src/main/java/com/lottery/lottery/dto/GameRoomStateDto.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.lottery.lottery.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; -import java.util.List; -import java.util.Map; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class GameRoomStateDto { - @JsonProperty("rN") - private Integer roomNumber; - - @JsonProperty("rI") - private Long roundId; // Current round id (null when no active round) - - @JsonProperty("p") - private Integer phase; // 1=WAITING, 2=COUNTDOWN, 3=SPINNING, 4=RESOLUTION - - @JsonProperty("tB") - private Long totalBet; // In tickets (not bigint) - - @JsonProperty("rP") - private Integer registeredPlayers; // Users registered in current round - - @JsonProperty("cU") - private Integer connectedUsers; // Total users connected to room (regardless of round participation) - - @JsonProperty("aR") - private Map allRoomsConnectedUsers; // Connected users count for all rooms (roomNumber -> count) - - @JsonProperty("mB") - private Long minBet; // Minimum bet for this room (in tickets, not bigint) - - @JsonProperty("mX") - private Long maxBet; // Maximum bet for this room (in tickets, not bigint) - - @JsonProperty("cE") - private Instant countdownEndAt; - - @JsonProperty("cR") - private Long countdownRemainingSeconds; - - @JsonProperty("ps") - private List participants; - - @JsonProperty("w") - private WinnerDto winner; - - @JsonProperty("sD") - private Long spinDuration; // milliseconds - - @JsonProperty("sI") - private Long stopIndex; // for spin animation -} - diff --git a/src/main/java/com/lottery/lottery/dto/JoinRoundResult.java b/src/main/java/com/lottery/lottery/dto/JoinRoundResult.java deleted file mode 100644 index 0765765..0000000 --- a/src/main/java/com/lottery/lottery/dto/JoinRoundResult.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Result of joining a round (used by remote bet API to return state and bet amount for response). - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class JoinRoundResult { - private GameRoomStateDto state; - /** Bet tickets to report in API response (amount added this call, or current bet when unique=true no-op). */ - private int betTicketsForResponse; -} diff --git a/src/main/java/com/lottery/lottery/dto/RoomUpdateDto.java b/src/main/java/com/lottery/lottery/dto/RoomUpdateDto.java deleted file mode 100644 index 9a08b80..0000000 --- a/src/main/java/com/lottery/lottery/dto/RoomUpdateDto.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.lottery.lottery.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Incremental update DTO for room state changes. - * Used to send only what changed instead of full state. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class RoomUpdateDto { - - /** - * Update type: USER_JOINED, USER_LEFT, FULL_STATE, PHASE_CHANGED - */ - private String type; - - /** - * Participant data (for USER_JOINED) - */ - private ParticipantDto participant; - - /** - * User ID (for USER_LEFT) - */ - private Integer userId; - - /** - * Full state (for FULL_STATE - fallback when incremental not possible) - */ - private GameRoomStateDto fullState; - - /** - * Phase change data (for PHASE_CHANGED) - */ - private String phase; - private Long countdownRemaining; - private WinnerDto winner; - private Long stopIndex; - private Long spinDuration; -} - - - diff --git a/src/main/java/com/lottery/lottery/dto/TransactionDto.java b/src/main/java/com/lottery/lottery/dto/TransactionDto.java index 6c732e8..830ac44 100644 --- a/src/main/java/com/lottery/lottery/dto/TransactionDto.java +++ b/src/main/java/com/lottery/lottery/dto/TransactionDto.java @@ -26,19 +26,14 @@ public class TransactionDto { private String date; /** - * Transaction type: DEPOSIT, WITHDRAWAL, WIN, BET, TASK_BONUS, DAILY_BONUS + * Transaction type: DEPOSIT, WITHDRAWAL, TASK_BONUS, CANCELLATION_OF_WITHDRAWAL */ private String type; /** - * Task ID for TASK_BONUS type (null for DAILY_BONUS and other types) + * Task ID for TASK_BONUS type (null for other types) */ private Integer taskId; - - /** - * Round ID for WIN/BET type (null for other types) - */ - private Long roundId; } diff --git a/src/main/java/com/lottery/lottery/dto/UserCheckDto.java b/src/main/java/com/lottery/lottery/dto/UserCheckDto.java index 3e04a49..51589de 100644 --- a/src/main/java/com/lottery/lottery/dto/UserCheckDto.java +++ b/src/main/java/com/lottery/lottery/dto/UserCheckDto.java @@ -21,6 +21,5 @@ public class UserCheckDto { private Double tickets; // balance_a / 1,000,000 private Integer depositTotal; // Sum of completed payments stars_amount private Integer refererId; // referer_id_1 from db_users_d - private Integer roundsPlayed; // rounds_played from db_users_b } diff --git a/src/main/java/com/lottery/lottery/exception/BetDecisionException.java b/src/main/java/com/lottery/lottery/exception/BetDecisionException.java deleted file mode 100644 index ddce2ae..0000000 --- a/src/main/java/com/lottery/lottery/exception/BetDecisionException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.lottery.lottery.exception; - -/** - * Thrown when bet amount cannot be decided (e.g. invalid bot range). - * Scheduler must not register the bot when this is thrown. - */ -public class BetDecisionException extends RuntimeException { - - public BetDecisionException(String message) { - super(message); - } - - public BetDecisionException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/com/lottery/lottery/model/FlexibleBotConfig.java b/src/main/java/com/lottery/lottery/model/FlexibleBotConfig.java deleted file mode 100644 index cecab2c..0000000 --- a/src/main/java/com/lottery/lottery/model/FlexibleBotConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.lottery.lottery.model; - -import jakarta.persistence.*; -import lombok.*; - -import java.math.BigDecimal; -import java.time.Instant; - -@Entity -@Table(name = "flexible_bot_configs") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class FlexibleBotConfig { - - @Id - @Column(name = "user_id", nullable = false) - private Integer userId; - - /** Win rate 0.0 to 1.0 (e.g. 0.25 = 25%). */ - @Column(name = "win_rate", nullable = false, precision = 5, scale = 4) - private BigDecimal winRate; - - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @PreUpdate - @PrePersist - protected void onUpdate() { - updatedAt = Instant.now(); - } -} diff --git a/src/main/java/com/lottery/lottery/model/GameRoom.java b/src/main/java/com/lottery/lottery/model/GameRoom.java deleted file mode 100644 index d9baa05..0000000 --- a/src/main/java/com/lottery/lottery/model/GameRoom.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.lottery.lottery.model; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; - -@Entity -@Table(name = "game_rooms") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class GameRoom { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; - - @Column(name = "room_number", unique = true, nullable = false) - private Integer roomNumber; - - @Enumerated(EnumType.STRING) - @Column(name = "current_phase", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") - @Builder.Default - private GamePhase currentPhase = GamePhase.WAITING; - - @Column(name = "countdown_end_at") - private Instant countdownEndAt; - - @Column(name = "total_bet", nullable = false) - @Builder.Default - private Long totalBet = 0L; - - @Column(name = "registered_players", nullable = false) - @Builder.Default - private Integer registeredPlayers = 0; - - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @PrePersist - protected void onCreate() { - createdAt = Instant.now(); - updatedAt = Instant.now(); - } - - @PreUpdate - protected void onUpdate() { - updatedAt = Instant.now(); - } -} - diff --git a/src/main/java/com/lottery/lottery/model/GameRound.java b/src/main/java/com/lottery/lottery/model/GameRound.java deleted file mode 100644 index 020a671..0000000 --- a/src/main/java/com/lottery/lottery/model/GameRound.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.lottery.lottery.model; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; - -@Entity -@Table(name = "game_rounds") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class GameRound { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id", nullable = false) - private GameRoom room; - - @Enumerated(EnumType.STRING) - @Column(name = "phase", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") - private GamePhase phase; - - @Column(name = "total_bet", nullable = false) - private Long totalBet; - - @Column(name = "winner_user_id") - private Integer winnerUserId; - - @Column(name = "winner_bet", nullable = false) - @Builder.Default - private Long winnerBet = 0L; - - @Column(name = "commission", nullable = false) - @Builder.Default - private Long commission = 0L; - - @Column(name = "payout", nullable = false) - @Builder.Default - private Long payout = 0L; - - @Column(name = "started_at", nullable = false) - private Instant startedAt; - - @Column(name = "countdown_started_at") - private Instant countdownStartedAt; - - @Column(name = "countdown_ended_at") - private Instant countdownEndedAt; - - @Column(name = "resolved_at") - private Instant resolvedAt; - - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @PrePersist - protected void onCreate() { - createdAt = Instant.now(); - } -} - diff --git a/src/main/java/com/lottery/lottery/model/GameRoundParticipant.java b/src/main/java/com/lottery/lottery/model/GameRoundParticipant.java deleted file mode 100644 index f701ec2..0000000 --- a/src/main/java/com/lottery/lottery/model/GameRoundParticipant.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.lottery.lottery.model; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; - -@Entity -@Table(name = "game_round_participants") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class GameRoundParticipant { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "round_id", nullable = false) - private GameRound round; - - @Column(name = "user_id", nullable = false) - private Integer userId; - - @Column(name = "bet", nullable = false) - private Long bet; - - @Column(name = "joined_at", nullable = false, updatable = false) - private Instant joinedAt; - - @PrePersist - protected void onCreate() { - joinedAt = Instant.now(); - } -} - diff --git a/src/main/java/com/lottery/lottery/model/LotteryBotConfig.java b/src/main/java/com/lottery/lottery/model/LotteryBotConfig.java deleted file mode 100644 index 164946c..0000000 --- a/src/main/java/com/lottery/lottery/model/LotteryBotConfig.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.lottery.lottery.model; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalTime; -import java.time.Instant; - -@Entity -@Table(name = "lottery_bot_configs") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class LotteryBotConfig { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Integer id; - - @Column(name = "user_id", nullable = false, unique = true) - private Integer userId; - - @Column(name = "room_1", nullable = false) - @Builder.Default - private Boolean room1 = false; - - @Column(name = "room_2", nullable = false) - @Builder.Default - private Boolean room2 = false; - - @Column(name = "room_3", nullable = false) - @Builder.Default - private Boolean room3 = false; - - @Column(name = "time_utc_start", nullable = false) - private LocalTime timeUtcStart; - - @Column(name = "time_utc_end", nullable = false) - private LocalTime timeUtcEnd; - - @Column(name = "bet_min", nullable = false) - private Long betMin; - - @Column(name = "bet_max", nullable = false) - private Long betMax; - - @Column(name = "persona", nullable = false, length = 20) - @Builder.Default - private String persona = "balanced"; - - @Column(name = "active", nullable = false) - @Builder.Default - private Boolean active = true; - - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @PrePersist - protected void onCreate() { - Instant now = Instant.now(); - if (createdAt == null) createdAt = now; - if (updatedAt == null) updatedAt = now; - } - - @PreUpdate - protected void onUpdate() { - updatedAt = Instant.now(); - } -} diff --git a/src/main/java/com/lottery/lottery/model/SafeBotUser.java b/src/main/java/com/lottery/lottery/model/SafeBotUser.java deleted file mode 100644 index 9e6f637..0000000 --- a/src/main/java/com/lottery/lottery/model/SafeBotUser.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.lottery.lottery.model; - -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Table(name = "safe_bot_users") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class SafeBotUser { - - @Id - @Column(name = "user_id", nullable = false) - private Integer userId; -} diff --git a/src/main/java/com/lottery/lottery/model/Transaction.java b/src/main/java/com/lottery/lottery/model/Transaction.java index c405cc5..e99d7d8 100644 --- a/src/main/java/com/lottery/lottery/model/Transaction.java +++ b/src/main/java/com/lottery/lottery/model/Transaction.java @@ -32,9 +32,6 @@ public class Transaction { @Column(name = "task_id") private Integer taskId; // Task ID for TASK_BONUS type - @Column(name = "round_id") - private Long roundId; // Round ID for WIN/BET type - @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -49,12 +46,7 @@ public class Transaction { public enum TransactionType { DEPOSIT, // Payment/deposit WITHDRAWAL, // Payout/withdrawal - WIN, // Game round win (total payout) - BET, // Game round bet (for all participants, winners and losers) - @Deprecated - LOSS, // Legacy: Old bet type, replaced by BET (kept for backward compatibility with old database records) TASK_BONUS, // Task reward - DAILY_BONUS, // Daily bonus reward (no taskId) CANCELLATION_OF_WITHDRAWAL // Cancellation of withdrawal (payout cancelled by admin) } } diff --git a/src/main/java/com/lottery/lottery/model/UserB.java b/src/main/java/com/lottery/lottery/model/UserB.java index fed6662..594867f 100644 --- a/src/main/java/com/lottery/lottery/model/UserB.java +++ b/src/main/java/com/lottery/lottery/model/UserB.java @@ -40,15 +40,6 @@ public class UserB { @Builder.Default private Integer withdrawCount = 0; - @Column(name = "rounds_played", nullable = false) - @Builder.Default - private Integer roundsPlayed = 0; - - /** Total winnings since last deposit (bigint: 1 ticket = 1_000_000). Reset to 0 on deposit; incremented on round win; reduced when payout is created. */ - @Column(name = "total_win_after_deposit", nullable = false) - @Builder.Default - private Long totalWinAfterDeposit = 0L; - /** When true, the user cannot create any payout request (blocked on backend). */ @Column(name = "withdrawals_disabled", nullable = false) @Builder.Default diff --git a/src/main/java/com/lottery/lottery/model/UserDailyBonusClaim.java b/src/main/java/com/lottery/lottery/model/UserDailyBonusClaim.java deleted file mode 100644 index 3f1863f..0000000 --- a/src/main/java/com/lottery/lottery/model/UserDailyBonusClaim.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.lottery.lottery.model; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "user_daily_bonus_claims") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class UserDailyBonusClaim { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; - - @Column(name = "user_id", nullable = false) - private Integer userId; - - @Column(name = "avatar_url", length = 255) - private String avatarUrl; - - @Column(name = "screen_name", nullable = false, length = 75) - @Builder.Default - private String screenName = "-"; - - @Column(name = "claimed_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") - private LocalDateTime claimedAt; - - @PrePersist - protected void onCreate() { - if (claimedAt == null) { - claimedAt = LocalDateTime.now(); - } - } -} - diff --git a/src/main/java/com/lottery/lottery/repository/FlexibleBotConfigRepository.java b/src/main/java/com/lottery/lottery/repository/FlexibleBotConfigRepository.java deleted file mode 100644 index 1c67603..0000000 --- a/src/main/java/com/lottery/lottery/repository/FlexibleBotConfigRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.lottery.lottery.repository; - -import com.lottery.lottery.model.FlexibleBotConfig; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface FlexibleBotConfigRepository extends JpaRepository { - - List findAllByOrderByUserIdAsc(); -} diff --git a/src/main/java/com/lottery/lottery/repository/GameRoomRepository.java b/src/main/java/com/lottery/lottery/repository/GameRoomRepository.java deleted file mode 100644 index e7ca749..0000000 --- a/src/main/java/com/lottery/lottery/repository/GameRoomRepository.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.lottery.lottery.repository; - -import com.lottery.lottery.model.GamePhase; -import com.lottery.lottery.model.GameRoom; -import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface GameRoomRepository extends JpaRepository { - Optional findByRoomNumber(Integer roomNumber); - - // Efficient query for rooms in specific phase (uses index on current_phase) - List findByCurrentPhase(GamePhase phase); - - /** - * Finds room by room number with pessimistic write lock to prevent race conditions. - * This ensures only one transaction can update the room at a time. - */ - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT r FROM GameRoom r WHERE r.roomNumber = :roomNumber") - Optional findByRoomNumberWithLock(@Param("roomNumber") Integer roomNumber); -} - diff --git a/src/main/java/com/lottery/lottery/repository/GameRoundParticipantRepository.java b/src/main/java/com/lottery/lottery/repository/GameRoundParticipantRepository.java deleted file mode 100644 index 8a5efbf..0000000 --- a/src/main/java/com/lottery/lottery/repository/GameRoundParticipantRepository.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.lottery.lottery.repository; - -import com.lottery.lottery.model.GameRoundParticipant; -import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -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; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; - -@Repository -public interface GameRoundParticipantRepository extends JpaRepository { - List findByRoundId(Long roundId); - - @Query("SELECT p FROM GameRoundParticipant p WHERE p.round.id = :roundId AND p.userId = :userId") - List findByRoundIdAndUserId(@Param("roundId") Long roundId, @Param("userId") Integer userId); - - /** - * Finds participant by ID with pessimistic write lock to prevent race conditions. - * This ensures only one transaction can update the participant at a time. - */ - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM GameRoundParticipant p WHERE p.id = :id") - Optional findByIdWithLock(@Param("id") Long id); - - /** - * Finds all rounds where the user participated, ordered by resolution time (newest first). - * Only returns completed rounds (phase = RESOLUTION, resolvedAt IS NOT NULL). - */ - @Query("SELECT p FROM GameRoundParticipant p " + - "WHERE p.userId = :userId " + - "AND p.round.phase = 'RESOLUTION' " + - "AND p.round.resolvedAt IS NOT NULL " + - "ORDER BY p.round.resolvedAt DESC") - List findUserCompletedRounds(@Param("userId") Integer userId, - org.springframework.data.domain.Pageable pageable); - - /** - * Batch deletes participants older than the specified date (up to batchSize). - * Returns the number of deleted rows. - * Note: MySQL requires LIMIT to be used directly in DELETE statements. - */ - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(value = "DELETE FROM game_round_participants WHERE joined_at < :cutoffDate LIMIT :batchSize", nativeQuery = true) - int deleteOldParticipantsBatch(@Param("cutoffDate") Instant cutoffDate, @Param("batchSize") int batchSize); -} - - diff --git a/src/main/java/com/lottery/lottery/repository/GameRoundRepository.java b/src/main/java/com/lottery/lottery/repository/GameRoundRepository.java deleted file mode 100644 index b147a0e..0000000 --- a/src/main/java/com/lottery/lottery/repository/GameRoundRepository.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.lottery.lottery.repository; - -import com.lottery.lottery.model.GameRound; -import com.lottery.lottery.model.GameRoom; -import com.lottery.lottery.model.GamePhase; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -@Repository -public interface GameRoundRepository extends JpaRepository { - - /** Fetch rounds by ids with room loaded (for admin game history). */ - @Query("SELECT r FROM GameRound r LEFT JOIN FETCH r.room WHERE r.id IN :ids") - List findAllByIdWithRoom(@Param("ids") Set ids); - - /** - * Finds the most recent active round(s) for a room, ordered by startedAt DESC. - * Use Pageable with size 1 to get only the single most recent round (resilient to corrupted data with multiple rounds in same phase). - */ - @Query("SELECT r FROM GameRound r WHERE r.room.id = :roomId AND r.phase IN :phases ORDER BY r.startedAt DESC") - List findMostRecentActiveRoundsByRoomId( - @Param("roomId") Integer roomId, - @Param("phases") List phases, - Pageable pageable - ); - - /** - * Finds the last N completed rounds for a room, ordered by resolution time (newest first). - * Only returns rounds that have a winner (winner_user_id IS NOT NULL). - */ - @Query("SELECT r FROM GameRound r WHERE r.room.roomNumber = :roomNumber AND r.phase = 'RESOLUTION' AND r.resolvedAt IS NOT NULL AND r.winnerUserId IS NOT NULL ORDER BY r.resolvedAt DESC") - List findLastCompletedRoundsByRoomNumber( - @Param("roomNumber") Integer roomNumber, - org.springframework.data.domain.Pageable pageable - ); - - /** - * Counts rounds resolved after the specified date. - */ - long countByResolvedAtAfter(Instant date); - - /** - * Calculates average total_bet for rounds resolved after the specified date. - */ - @Query("SELECT AVG(r.totalBet) FROM GameRound r WHERE r.resolvedAt >= :after AND r.resolvedAt IS NOT NULL") - Optional avgTotalBetByResolvedAtAfter(@Param("after") Instant after); - - /** - * Counts rounds resolved between two dates. - */ - long countByResolvedAtBetween(Instant start, Instant end); -} - diff --git a/src/main/java/com/lottery/lottery/repository/LotteryBotConfigRepository.java b/src/main/java/com/lottery/lottery/repository/LotteryBotConfigRepository.java deleted file mode 100644 index a943f97..0000000 --- a/src/main/java/com/lottery/lottery/repository/LotteryBotConfigRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.lottery.lottery.repository; - -import com.lottery.lottery.model.LotteryBotConfig; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface LotteryBotConfigRepository extends JpaRepository { - - List findAllByOrderByIdAsc(); - - List findAllByActiveTrue(); - - List findAllByRoom2True(); - - List findAllByRoom3True(); - - Optional findByUserId(Integer userId); - - boolean existsByUserId(Integer userId); -} diff --git a/src/main/java/com/lottery/lottery/repository/SafeBotUserRepository.java b/src/main/java/com/lottery/lottery/repository/SafeBotUserRepository.java deleted file mode 100644 index f21edbf..0000000 --- a/src/main/java/com/lottery/lottery/repository/SafeBotUserRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.lottery.lottery.repository; - -import com.lottery.lottery.model.SafeBotUser; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface SafeBotUserRepository extends JpaRepository { - - List findAllByOrderByUserIdAsc(); -} diff --git a/src/main/java/com/lottery/lottery/repository/TransactionRepository.java b/src/main/java/com/lottery/lottery/repository/TransactionRepository.java index 0c1e101..f17c522 100644 --- a/src/main/java/com/lottery/lottery/repository/TransactionRepository.java +++ b/src/main/java/com/lottery/lottery/repository/TransactionRepository.java @@ -3,8 +3,6 @@ package com.lottery.lottery.repository; import com.lottery.lottery.model.Transaction; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -12,7 +10,6 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; -import java.util.Set; import java.time.Instant; @Repository @@ -24,13 +21,6 @@ public interface TransactionRepository extends JpaRepository */ Page findByUserIdOrderByCreatedAtDesc(Integer userId, Pageable pageable); - /** - * Finds WIN transactions for a user created after the specified date, ordered by creation time descending. - * Used for game history (win history). - */ - Page findByUserIdAndTypeAndCreatedAtAfterOrderByCreatedAtDesc( - Integer userId, Transaction.TransactionType type, Instant createdAfter, Pageable pageable); - /** * Batch deletes all transactions older than the specified date (up to batchSize). * Returns the number of deleted rows. @@ -42,7 +32,6 @@ public interface TransactionRepository extends JpaRepository /** * Counts transactions of a specific type for a user. - * Used to check if this is the user's 3rd bet for referral bonus. */ long countByUserIdAndType(Integer userId, Transaction.TransactionType type); @@ -52,11 +41,5 @@ public interface TransactionRepository extends JpaRepository @Query("SELECT t.userId, COALESCE(SUM(t.amount), 0) FROM Transaction t WHERE t.userId IN :userIds GROUP BY t.userId") List sumAmountByUserIdIn(@Param("userIds") List userIds); - /** BET transactions for a user, ordered by createdAt desc (for game history). */ - Page findByUserIdAndTypeOrderByCreatedAtDesc(Integer userId, Transaction.TransactionType type, Pageable pageable); - - /** WIN transactions for a user and given round IDs (batch). */ - @Query("SELECT t FROM Transaction t WHERE t.userId = :userId AND t.type = 'WIN' AND t.roundId IN :roundIds") - List findByUserIdAndTypeWinAndRoundIdIn(@Param("userId") Integer userId, @Param("roundIds") Set roundIds); } diff --git a/src/main/java/com/lottery/lottery/repository/UserDailyBonusClaimRepository.java b/src/main/java/com/lottery/lottery/repository/UserDailyBonusClaimRepository.java deleted file mode 100644 index 383cd25..0000000 --- a/src/main/java/com/lottery/lottery/repository/UserDailyBonusClaimRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.lottery.lottery.repository; - -import com.lottery.lottery.model.UserDailyBonusClaim; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface UserDailyBonusClaimRepository extends JpaRepository { - - /** - * Finds the most recent daily bonus claim for a user. - * Used to check if user can claim (24h cooldown). - */ - Optional findFirstByUserIdOrderByClaimedAtDesc(Integer userId); - - /** - * Finds the 50 most recent daily bonus claims ordered by claimed_at DESC. - * Simple query without JOINs - all data is in the same table. - */ - List findTop50ByOrderByClaimedAtDesc(); - - /** - * Finds all daily bonus claims for a user, ordered by claimed_at DESC. - */ - List findByUserIdOrderByClaimedAtDesc(Integer userId); -} - diff --git a/src/main/java/com/lottery/lottery/service/AdminBotConfigService.java b/src/main/java/com/lottery/lottery/service/AdminBotConfigService.java deleted file mode 100644 index fc4b6b6..0000000 --- a/src/main/java/com/lottery/lottery/service/AdminBotConfigService.java +++ /dev/null @@ -1,210 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.dto.AdminBotConfigDto; -import com.lottery.lottery.dto.AdminBotConfigRequest; -import com.lottery.lottery.model.LotteryBotConfig; -import com.lottery.lottery.model.UserA; -import com.lottery.lottery.repository.LotteryBotConfigRepository; -import com.lottery.lottery.repository.UserARepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.time.LocalTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class AdminBotConfigService { - - private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); - - private final LotteryBotConfigRepository lotteryBotConfigRepository; - private final UserARepository userARepository; - - public List listAll() { - List configs = lotteryBotConfigRepository.findAllByOrderByIdAsc(); - if (configs.isEmpty()) { - return List.of(); - } - List userIds = configs.stream().map(LotteryBotConfig::getUserId).distinct().toList(); - Map screenNameByUserId = userARepository.findAllById(userIds).stream() - .collect(Collectors.toMap(UserA::getId, u -> u.getScreenName() != null ? u.getScreenName() : "-")); - return configs.stream() - .map(c -> toDto(c, screenNameByUserId.getOrDefault(c.getUserId(), "-"))) - .toList(); - } - - public Optional getById(Integer id) { - return lotteryBotConfigRepository.findById(id) - .map(c -> { - String screenName = userARepository.findById(c.getUserId()) - .map(UserA::getScreenName) - .orElse("-"); - return toDto(c, screenName); - }); - } - - public Optional getByUserId(Integer userId) { - return lotteryBotConfigRepository.findByUserId(userId) - .map(c -> { - String screenName = userARepository.findById(c.getUserId()) - .map(UserA::getScreenName) - .orElse("-"); - return toDto(c, screenName); - }); - } - - @Transactional - public AdminBotConfigDto create(AdminBotConfigRequest request) { - if (!userARepository.existsById(request.getUserId())) { - throw new IllegalArgumentException("User with id " + request.getUserId() + " does not exist"); - } - if (lotteryBotConfigRepository.existsByUserId(request.getUserId())) { - throw new IllegalArgumentException("Bot config already exists for user id " + request.getUserId()); - } - LotteryBotConfig config = toEntity(request); - config.setId(null); - config.setCreatedAt(Instant.now()); - config.setUpdatedAt(Instant.now()); - config = lotteryBotConfigRepository.save(config); - String screenName = userARepository.findById(config.getUserId()).map(UserA::getScreenName).orElse("-"); - return toDto(config, screenName); - } - - @Transactional - public Optional update(Integer id, AdminBotConfigRequest request) { - Optional opt = lotteryBotConfigRepository.findById(id); - if (opt.isEmpty()) return Optional.empty(); - if (!userARepository.existsById(request.getUserId())) { - throw new IllegalArgumentException("User with id " + request.getUserId() + " does not exist"); - } - LotteryBotConfig existing = opt.get(); - if (!existing.getUserId().equals(request.getUserId()) && lotteryBotConfigRepository.existsByUserId(request.getUserId())) { - throw new IllegalArgumentException("Bot config already exists for user id " + request.getUserId()); - } - updateEntity(existing, request); - existing.setUpdatedAt(Instant.now()); - LotteryBotConfig saved = lotteryBotConfigRepository.save(existing); - String screenName = userARepository.findById(saved.getUserId()).map(UserA::getScreenName).orElse("-"); - return Optional.of(toDto(saved, screenName)); - } - - @Transactional - public boolean delete(Integer id) { - if (!lotteryBotConfigRepository.existsById(id)) return false; - lotteryBotConfigRepository.deleteById(id); - return true; - } - - /** - * Shuffles time windows for bots that have the given room enabled. - * Groups configs by their current time window, then randomly redistributes those same windows across all configs. - */ - @Transactional - public void shuffleTimeWindowsForRoom(int roomNumber) { - List configs = roomNumber == 2 - ? lotteryBotConfigRepository.findAllByRoom2True() - : lotteryBotConfigRepository.findAllByRoom3True(); - if (configs.isEmpty()) { - throw new IllegalArgumentException("No bot configs with room " + roomNumber + " enabled"); - } - shuffleWindows(configs); - Instant now = Instant.now(); - for (LotteryBotConfig c : configs) { - c.setUpdatedAt(now); - } - lotteryBotConfigRepository.saveAll(configs); - } - - /** Groups configs by (start, end) window, collects one slot per config, shuffles slots, assigns back. */ - private static void shuffleWindows(List configs) { - Map> byWindow = new LinkedHashMap<>(); - for (LotteryBotConfig c : configs) { - if (c.getTimeUtcStart() == null || c.getTimeUtcEnd() == null) continue; - TimeWindow w = new TimeWindow(c.getTimeUtcStart(), c.getTimeUtcEnd()); - byWindow.computeIfAbsent(w, k -> new ArrayList<>()).add(c); - } - List windowSlots = new ArrayList<>(); - for (Map.Entry> e : byWindow.entrySet()) { - for (int i = 0; i < e.getValue().size(); i++) { - windowSlots.add(e.getKey()); - } - } - Collections.shuffle(windowSlots); - int idx = 0; - for (LotteryBotConfig c : configs) { - if (c.getTimeUtcStart() != null && c.getTimeUtcEnd() != null && idx < windowSlots.size()) { - TimeWindow w = windowSlots.get(idx++); - c.setTimeUtcStart(w.start); - c.setTimeUtcEnd(w.end); - } - } - } - - private record TimeWindow(LocalTime start, LocalTime end) {} - - private static AdminBotConfigDto toDto(LotteryBotConfig c, String screenName) { - return AdminBotConfigDto.builder() - .id(c.getId()) - .userId(c.getUserId()) - .screenName(screenName) - .room1(c.getRoom1()) - .room2(c.getRoom2()) - .room3(c.getRoom3()) - .timeUtcStart(c.getTimeUtcStart() != null ? c.getTimeUtcStart().format(TIME_FORMAT) : null) - .timeUtcEnd(c.getTimeUtcEnd() != null ? c.getTimeUtcEnd().format(TIME_FORMAT) : null) - .betMin(c.getBetMin()) - .betMax(c.getBetMax()) - .persona(c.getPersona() != null ? c.getPersona() : "balanced") - .active(c.getActive()) - .createdAt(c.getCreatedAt()) - .updatedAt(c.getUpdatedAt()) - .build(); - } - - private static LotteryBotConfig toEntity(AdminBotConfigRequest r) { - return LotteryBotConfig.builder() - .userId(r.getUserId()) - .room1(r.getRoom1()) - .room2(r.getRoom2()) - .room3(r.getRoom3()) - .timeUtcStart(parseTime(r.getTimeUtcStart())) - .timeUtcEnd(parseTime(r.getTimeUtcEnd())) - .betMin(r.getBetMin()) - .betMax(r.getBetMax()) - .persona(r.getPersona() != null && !r.getPersona().isBlank() ? r.getPersona() : "balanced") - .active(r.getActive()) - .build(); - } - - private static void updateEntity(LotteryBotConfig existing, AdminBotConfigRequest r) { - existing.setUserId(r.getUserId()); - existing.setRoom1(r.getRoom1()); - existing.setRoom2(r.getRoom2()); - existing.setRoom3(r.getRoom3()); - existing.setTimeUtcStart(parseTime(r.getTimeUtcStart())); - existing.setTimeUtcEnd(parseTime(r.getTimeUtcEnd())); - existing.setBetMin(r.getBetMin()); - existing.setBetMax(r.getBetMax()); - existing.setPersona(r.getPersona() != null && !r.getPersona().isBlank() ? r.getPersona() : "balanced"); - existing.setActive(r.getActive()); - } - - private static LocalTime parseTime(String s) { - if (s == null || s.isBlank()) throw new IllegalArgumentException("Time is required (HH:mm)"); - try { - return LocalTime.parse(s.trim(), TIME_FORMAT); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid time format, use HH:mm (e.g. 14:00)"); - } - } -} diff --git a/src/main/java/com/lottery/lottery/service/AdminUserService.java b/src/main/java/com/lottery/lottery/service/AdminUserService.java index 58001c1..35e447b 100644 --- a/src/main/java/com/lottery/lottery/service/AdminUserService.java +++ b/src/main/java/com/lottery/lottery/service/AdminUserService.java @@ -42,14 +42,11 @@ public class AdminUserService { private final UserBRepository userBRepository; private final UserDRepository userDRepository; private final TransactionRepository transactionRepository; - private final GameRoundParticipantRepository gameRoundParticipantRepository; private final PaymentRepository paymentRepository; private final PayoutRepository payoutRepository; private final UserTaskClaimRepository userTaskClaimRepository; private final TaskRepository taskRepository; - private final UserDailyBonusClaimRepository userDailyBonusClaimRepository; private final EntityManager entityManager; - private final GameRoundRepository gameRoundRepository; public Page getUsers( Pageable pageable, @@ -61,8 +58,6 @@ public class AdminUserService { Integer dateRegTo, Long balanceMin, Long balanceMax, - Integer roundsPlayedMin, - Integer roundsPlayedMax, Integer referralCountMin, Integer referralCountMax, Integer referrerId, @@ -142,8 +137,8 @@ public class AdminUserService { predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp)); } - // Balance / rounds / referral filters via subqueries so DB handles pagination - if (balanceMin != null || balanceMax != null || roundsPlayedMin != null || roundsPlayedMax != null) { + // Balance / referral filters via subqueries so DB handles pagination + if (balanceMin != null || balanceMax != null) { Subquery subB = query.subquery(Integer.class); Root br = subB.from(UserB.class); subB.select(br.get("id")); @@ -156,8 +151,6 @@ public class AdminUserService { subPreds.add(cb.lessThanOrEqualTo( cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMax)); } - if (roundsPlayedMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("roundsPlayed"), roundsPlayedMin)); - if (roundsPlayedMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("roundsPlayed"), roundsPlayedMax)); subB.where(cb.and(subPreds.toArray(new Predicate[0]))); predicates.add(cb.in(root.get("id")).value(subB)); } @@ -193,7 +186,7 @@ public class AdminUserService { return cb.and(predicates.toArray(new Predicate[0])); }; - Set sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit"); + Set sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit"); boolean useJoinSort = sortBy != null && sortRequiresJoin.contains(sortBy); List userList; long totalElements; @@ -202,7 +195,7 @@ public class AdminUserService { List orderedIds = getOrderedUserIdsForAdminList( search, banned, countryCode, languageCode, dateRegFrom, dateRegTo, balanceMin, balanceMax, - roundsPlayedMin, roundsPlayedMax, referralCountMin, referralCountMax, + referralCountMin, referralCountMax, referrerId, referralLevel, ipFilter, sortBy, sortDir != null ? sortDir : "desc", pageable.getPageSize(), (int) pageable.getOffset(), @@ -242,7 +235,6 @@ public class AdminUserService { .depositCount(0) .withdrawTotal(0L) .withdrawCount(0) - .roundsPlayed(0) .build()); UserD userD = userDMap.getOrDefault(userA.getId(), @@ -280,7 +272,6 @@ public class AdminUserService { .depositCount(userB.getDepositCount()) .withdrawTotal(userB.getWithdrawTotal()) .withdrawCount(userB.getWithdrawCount()) - .roundsPlayed(userB.getRoundsPlayed()) .dateReg(userA.getDateReg()) .dateLogin(userA.getDateLogin()) .banned(userA.getBanned()) @@ -312,8 +303,6 @@ public class AdminUserService { Integer dateRegTo, Long balanceMin, Long balanceMax, - Integer roundsPlayedMin, - Integer roundsPlayedMax, Integer referralCountMin, Integer referralCountMax, Integer referrerId, @@ -395,16 +384,6 @@ public class AdminUserService { params.add(balanceMax); paramIndex++; } - if (roundsPlayedMin != null) { - sql.append(" AND b.rounds_played >= ?"); - params.add(roundsPlayedMin); - paramIndex++; - } - if (roundsPlayedMax != null) { - sql.append(" AND b.rounds_played <= ?"); - params.add(roundsPlayedMax); - paramIndex++; - } if (referralCountMin != null || referralCountMax != null) { sql.append(" AND (d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)"); if (referralCountMin != null && referralCountMax != null) { @@ -436,11 +415,10 @@ public class AdminUserService { } } - String orderColumn = switch (sortBy) { + String orderColumn = switch (sortBy != null ? sortBy : "") { case "balanceA" -> "b.balance_a"; case "depositTotal" -> "b.deposit_total"; case "withdrawTotal" -> "b.withdraw_total"; - case "roundsPlayed" -> "b.rounds_played"; case "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)"; case "profit" -> "(b.deposit_total - b.withdraw_total)"; default -> "a.id"; @@ -506,8 +484,6 @@ public class AdminUserService { .depositCount(0) .withdrawTotal(0L) .withdrawCount(0) - .roundsPlayed(0) - .totalWinAfterDeposit(0L) .withdrawalsDisabled(false) .build()); @@ -610,7 +586,6 @@ public class AdminUserService { .depositTotalUsd(depositTotalUsd) .withdrawTotalUsd(withdrawTotalUsd) .withdrawalsDisabled(Boolean.TRUE.equals(userB.getWithdrawalsDisabled())) - .roundsPlayed(userB.getRoundsPlayed()) .referralCount(totalReferrals) .totalCommissionsEarned(totalCommissions) .totalCommissionsEarnedUsd(totalCommissionsEarnedUsd) @@ -665,53 +640,6 @@ public class AdminUserService { .build()); } - /** - * Game history from transactions (BET/WIN). Participants table is cleaned after each round. - */ - public Page getUserGameRounds(Integer userId, Pageable pageable) { - Page betPage = transactionRepository.findByUserIdAndTypeOrderByCreatedAtDesc(userId, Transaction.TransactionType.BET, pageable); - List bets = betPage.getContent(); - if (bets.isEmpty()) { - return new PageImpl<>(List.of(), pageable, 0); - } - Set roundIds = bets.stream().map(Transaction::getRoundId).filter(java.util.Objects::nonNull).collect(Collectors.toSet()); - List wins = roundIds.isEmpty() ? List.of() : transactionRepository.findByUserIdAndTypeWinAndRoundIdIn(userId, roundIds); - Map payoutByRound = wins.stream().collect(Collectors.toMap(Transaction::getRoundId, t -> t.getAmount() != null ? t.getAmount() : 0L, (a, b) -> a)); - Map resolvedAtByRound = wins.stream().collect(Collectors.toMap(Transaction::getRoundId, t -> t.getCreatedAt() != null ? t.getCreatedAt() : Instant.EPOCH, (a, b) -> a)); - - Map roundById = roundIds.isEmpty() ? Map.of() : gameRoundRepository.findAllByIdWithRoom(roundIds).stream() - .collect(Collectors.toMap(GameRound::getId, r -> r, (a, b) -> a)); - - List rounds = bets.stream() - .map(bet -> { - Long roundId = bet.getRoundId(); - GameRound gr = roundById.get(roundId); - Integer roomNumber = gr != null && gr.getRoom() != null ? gr.getRoom().getRoomNumber() : null; - Long totalBet = gr != null ? gr.getTotalBet() : null; - long userBet = bet.getAmount() != null ? Math.abs(bet.getAmount()) : 0L; - Long payout = payoutByRound.getOrDefault(roundId, 0L); - boolean isWinner = payout > 0; - Instant resolvedAt = resolvedAtByRound.getOrDefault(roundId, bet.getCreatedAt()); - return AdminGameRoundDto.builder() - .roundId(roundId) - .roomNumber(roomNumber) - .phase(gr != null && gr.getPhase() != null ? gr.getPhase().name() : null) - .totalBet(totalBet) - .userBet(userBet) - .winnerUserId(isWinner ? userId : null) - .winnerBet(isWinner ? userBet : null) - .payout(isWinner ? payout : 0L) - .commission(null) - .startedAt(null) - .resolvedAt(resolvedAt) - .isWinner(isWinner) - .build(); - }) - .collect(Collectors.toList()); - - return new PageImpl<>(rounds, pageable, betPage.getTotalElements()); - } - public Map getUserTasks(Integer userId) { List claims = userTaskClaimRepository.findByUserId(userId); List allTasks = taskRepository.findAll(); @@ -749,25 +677,9 @@ public class AdminUserService { )) .collect(Collectors.toList()); - // Get daily bonus claims - List dailyBonusClaims = userDailyBonusClaimRepository.findByUserIdOrderByClaimedAtDesc(userId); - List> dailyBonuses = dailyBonusClaims.stream() - .map(claim -> { - Instant claimedAtInstant = claim.getClaimedAt() != null - ? claim.getClaimedAt().atZone(ZoneId.of("UTC")).toInstant() - : null; - return Map.of( - "id", claim.getId(), - "claimedAt", claimedAtInstant != null ? claimedAtInstant.toEpochMilli() : null, - "screenName", claim.getScreenName() != null ? claim.getScreenName() : "-" - ); - }) - .collect(Collectors.toList()); - return Map.of( "completed", completedTasks, - "available", availableTasks, - "dailyBonuses", dailyBonuses + "available", availableTasks ); } @@ -805,7 +717,6 @@ public class AdminUserService { .depositCount(0) .withdrawTotal(0L) .withdrawCount(0) - .roundsPlayed(0) .build()); // Store previous balances diff --git a/src/main/java/com/lottery/lottery/service/BetDecisionService.java b/src/main/java/com/lottery/lottery/service/BetDecisionService.java deleted file mode 100644 index 29797d8..0000000 --- a/src/main/java/com/lottery/lottery/service/BetDecisionService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.lottery.lottery.service; - -/** - * Decides bet amount (in tickets) for a lottery bot. - * Implemented in-process by persona + loss-streak and zone logic (no external API). - */ -public interface BetDecisionService { - - /** - * Returns bet amount in tickets (1 ticket = 1_000_000 in DB bigint). - * Must be within room min/max; caller will clamp if needed. - * - * @param context room, round, config, etc. (for future AI) - * @return bet amount in tickets (e.g. 1000) - */ - long decideBetAmountTickets(BotBetContext context); -} diff --git a/src/main/java/com/lottery/lottery/service/BotBetContext.java b/src/main/java/com/lottery/lottery/service/BotBetContext.java deleted file mode 100644 index 1728ede..0000000 --- a/src/main/java/com/lottery/lottery/service/BotBetContext.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.model.LotteryBotConfig; -import lombok.Builder; -import lombok.Data; - -import java.util.List; - -/** - * Context passed to bet decision (ChatGPT). Bot range and history are required for the prompt. - */ -@Data -@Builder -public class BotBetContext { - - private int roomNumber; - private Long roundId; - private int participantCount; - private LotteryBotConfig config; - - /** Room min/max bet in tickets. */ - private long roomMinTickets; - private long roomMaxTickets; - /** Bot config min/max bet in tickets (hard bounds for output). */ - private long botMinTickets; - private long botMaxTickets; - /** Current round total bet in tickets (pot so far). */ - private long currentRoundTotalBetTickets; - /** Last 10 bet amounts in tickets (oldest → newest). Padded with 0 if fewer than 10. */ - private List lastBets10; - /** Last 10 results: W=win, L=loss, N=no data (oldest → newest). Padded with N if fewer than 10. */ - private List lastResults10; -} diff --git a/src/main/java/com/lottery/lottery/service/BotBetHistoryService.java b/src/main/java/com/lottery/lottery/service/BotBetHistoryService.java deleted file mode 100644 index f734a75..0000000 --- a/src/main/java/com/lottery/lottery/service/BotBetHistoryService.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.model.Transaction; -import com.lottery.lottery.repository.TransactionRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Loads last N bet amounts and win/loss results for a user from transactions (BET + WIN). - * Used to build ChatGPT prompt context. - */ -@Service -@RequiredArgsConstructor -public class BotBetHistoryService { - - private static final long TICKETS_TO_BIGINT = 1_000_000L; - private static final String RESULT_WIN = "W"; - private static final String RESULT_LOSS = "L"; - private static final String RESULT_NONE = "N"; - - private final TransactionRepository transactionRepository; - - /** - * Returns last {@code count} bet amounts (in tickets) and results (W/L/N), oldest first. - * If fewer than count bets exist, pads with 0 and "N". - */ - @Transactional(readOnly = true) - public BetHistoryResult getLastBetsAndResults(int userId, int count) { - if (count <= 0) { - return new BetHistoryResult(Collections.nCopies(count, 0), Collections.nCopies(count, RESULT_NONE)); - } - List betTxs = transactionRepository - .findByUserIdAndTypeOrderByCreatedAtDesc(userId, Transaction.TransactionType.BET, PageRequest.of(0, count)) - .getContent(); - if (betTxs.isEmpty()) { - return new BetHistoryResult( - Collections.nCopies(count, 0), - Collections.nCopies(count, RESULT_NONE)); - } - // Newest first → reverse to oldest first - List oldestFirst = new ArrayList<>(betTxs); - Collections.reverse(oldestFirst); - - List bets = new ArrayList<>(); - List roundIds = new ArrayList<>(); - for (Transaction t : oldestFirst) { - // BET amounts are stored as negative (debit); use abs for ticket count in prompt - long amount = t.getAmount() != null ? Math.abs(t.getAmount()) : 0L; - long tickets = amount / TICKETS_TO_BIGINT; - bets.add((int) Math.max(0, Math.min(Integer.MAX_VALUE, tickets))); - roundIds.add(t.getRoundId()); - } - - Set roundIdsToCheck = roundIds.stream().filter(id -> id != null).collect(Collectors.toSet()); - Set roundIdsWithWin = Set.of(); - if (!roundIdsToCheck.isEmpty()) { - List winTxs = transactionRepository.findByUserIdAndTypeWinAndRoundIdIn(userId, roundIdsToCheck); - roundIdsWithWin = winTxs.stream().map(Transaction::getRoundId).filter(id -> id != null).collect(Collectors.toSet()); - } - - List results = new ArrayList<>(); - for (Long roundId : roundIds) { - results.add(roundId != null && roundIdsWithWin.contains(roundId) ? RESULT_WIN : RESULT_LOSS); - } - - // Left-pad to count so format is [oldest …, newest] - while (bets.size() < count) { - bets.add(0, 0); - results.add(0, RESULT_NONE); - } - return new BetHistoryResult(bets, results); - } - - public record BetHistoryResult(List lastBets, List lastResults) {} -} diff --git a/src/main/java/com/lottery/lottery/service/BotConfigService.java b/src/main/java/com/lottery/lottery/service/BotConfigService.java deleted file mode 100644 index 194054c..0000000 --- a/src/main/java/com/lottery/lottery/service/BotConfigService.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.model.FlexibleBotConfig; -import com.lottery.lottery.model.GameRoundParticipant; -import com.lottery.lottery.model.SafeBotUser; -import com.lottery.lottery.model.UserB; -import com.lottery.lottery.repository.FlexibleBotConfigRepository; -import com.lottery.lottery.repository.SafeBotUserRepository; -import com.lottery.lottery.repository.UserBRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.time.Instant; -import java.util.*; -import java.util.stream.Collectors; - -/** - * Bot configuration and winner-override logic for safe/flexible bots. - * Does not affect displayed chances or tape; only who is selected as winner at resolution. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class BotConfigService { - - /** Balance below this (bigint: 1 ticket = 1_000_000) → safe bot gets 100% win. 20_000 tickets = 20$ */ - private static final long SAFE_BOT_BALANCE_THRESHOLD = 20_000L * 1_000_000L; // 20_000_000_000 - - private final SafeBotUserRepository safeBotUserRepository; - private final FlexibleBotConfigRepository flexibleBotConfigRepository; - private final UserBRepository userBRepository; - - /** - * If a bot override applies, returns the participant to use as winner; otherwise empty (use normal weighted random). - * Order: 1) Safe bot with balance < threshold wins. 2) Flexible bot with configured win rate. 3) Normal. - */ - @Transactional(readOnly = true) - public Optional resolveWinnerOverride( - List participants, - long totalBet - ) { - if (participants == null || participants.isEmpty()) return Optional.empty(); - - Set safeBotUserIds = getSafeBotUserIds(); - Map flexibleWinRates = getFlexibleBotWinRates(); - - // 1) Safe bot: any safe bot in round with balance < threshold wins (pick one randomly if multiple) - List safeBotsInRound = participants.stream() - .filter(p -> safeBotUserIds.contains(p.getUserId())) - .toList(); - if (!safeBotsInRound.isEmpty()) { - List lowBalanceSafeBots = new ArrayList<>(); - for (GameRoundParticipant p : safeBotsInRound) { - UserB userB = userBRepository.findById(p.getUserId()).orElse(null); - if (userB != null && userB.getBalanceA() != null && userB.getBalanceA() < SAFE_BOT_BALANCE_THRESHOLD) { - lowBalanceSafeBots.add(p); - } - } - if (!lowBalanceSafeBots.isEmpty()) { - GameRoundParticipant chosen = lowBalanceSafeBots.get(new Random().nextInt(lowBalanceSafeBots.size())); - log.debug("Safe bot winner override: userId={}, balance below threshold", chosen.getUserId()); - return Optional.of(chosen); - } - } - - // 2) Flexible bot: with probability win_rate that bot wins; remaining probability = normal weighted random - List flexBotsInRound = participants.stream() - .filter(p -> flexibleWinRates.containsKey(p.getUserId())) - .toList(); - if (flexBotsInRound.isEmpty()) return Optional.empty(); - - double roll = new Random().nextDouble(); - double cumulative = 0; - for (GameRoundParticipant p : flexBotsInRound) { - double rate = flexibleWinRates.get(p.getUserId()); - cumulative += rate; - if (roll < cumulative) { - log.debug("Flexible bot winner override: userId={}, winRate={}", p.getUserId(), rate); - return Optional.of(p); - } - } - // roll >= cumulative: fall through to normal (don't return empty here - we already have normal logic in caller) - return Optional.empty(); - } - - public Set getSafeBotUserIds() { - return safeBotUserRepository.findAllByOrderByUserIdAsc().stream() - .map(SafeBotUser::getUserId) - .collect(Collectors.toSet()); - } - - public Map getFlexibleBotWinRates() { - Map map = new HashMap<>(); - for (FlexibleBotConfig c : flexibleBotConfigRepository.findAllByOrderByUserIdAsc()) { - if (c.getWinRate() != null) { - map.put(c.getUserId(), c.getWinRate().doubleValue()); - } - } - return map; - } - - @Transactional(readOnly = true) - public BotConfigDto getConfig() { - List safeBotUserIds = safeBotUserRepository.findAllByOrderByUserIdAsc().stream() - .map(SafeBotUser::getUserId) - .toList(); - List flexibleBots = flexibleBotConfigRepository.findAllByOrderByUserIdAsc().stream() - .map(c -> new FlexibleBotEntryDto(c.getUserId(), c.getWinRate() != null ? c.getWinRate().doubleValue() : 0)) - .toList(); - return new BotConfigDto(safeBotUserIds, flexibleBots); - } - - @Transactional - public void setSafeBotUserIds(List userIds) { - safeBotUserRepository.deleteAll(); - if (userIds != null) { - for (Integer id : userIds) { - if (id != null) safeBotUserRepository.save(SafeBotUser.builder().userId(id).build()); - } - } - } - - @Transactional - public void setFlexibleBots(List entries) { - flexibleBotConfigRepository.deleteAll(); - if (entries != null) { - for (FlexibleBotEntryDto e : entries) { - if (e != null && e.userId() != null && e.winRate() != null) { - double r = Math.max(0, Math.min(1, e.winRate())); - flexibleBotConfigRepository.save(FlexibleBotConfig.builder() - .userId(e.userId()) - .winRate(BigDecimal.valueOf(r)) - .updatedAt(Instant.now()) - .build()); - } - } - } - } - - public record BotConfigDto(List safeBotUserIds, List flexibleBots) {} - public record FlexibleBotEntryDto(Integer userId, Double winRate) {} -} diff --git a/src/main/java/com/lottery/lottery/service/DataCleanupService.java b/src/main/java/com/lottery/lottery/service/DataCleanupService.java index 7b365ec..1f7d419 100644 --- a/src/main/java/com/lottery/lottery/service/DataCleanupService.java +++ b/src/main/java/com/lottery/lottery/service/DataCleanupService.java @@ -1,6 +1,5 @@ package com.lottery.lottery.service; -import com.lottery.lottery.repository.GameRoundParticipantRepository; import com.lottery.lottery.repository.TransactionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/lottery/lottery/service/GameHistoryService.java b/src/main/java/com/lottery/lottery/service/GameHistoryService.java deleted file mode 100644 index 65f1105..0000000 --- a/src/main/java/com/lottery/lottery/service/GameHistoryService.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.dto.GameHistoryEntryDto; -import com.lottery.lottery.model.Transaction; -import com.lottery.lottery.repository.TransactionRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Service for retrieving game history for users. - * Fetches WIN transactions from the last 30 days. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class GameHistoryService { - - private final TransactionRepository transactionRepository; - private final LocalizationService localizationService; - private static final int PAGE_SIZE = 50; - private static final int DAYS_TO_FETCH = 30; - - /** - * Gets WIN transactions for a user from the last 30 days with pagination. - * - * @param userId User ID - * @param page Page number (0-indexed) - * @param timezone Optional timezone (e.g., "Europe/London"). If null, uses UTC. - * @param languageCode Optional language code for date formatting (e.g., "EN", "RU"). If null, uses "EN". - * @return Page of game history entries with amount and date - */ - public Page getUserGameHistory(Integer userId, int page, String timezone, String languageCode) { - Instant thirtyDaysAgo = Instant.now().minus(DAYS_TO_FETCH, ChronoUnit.DAYS); - Pageable pageable = PageRequest.of(page, PAGE_SIZE); - - // Fetch WIN transactions from the last 30 days - Page transactions = transactionRepository.findByUserIdAndTypeAndCreatedAtAfterOrderByCreatedAtDesc( - userId, Transaction.TransactionType.WIN, thirtyDaysAgo, pageable); - - // Determine timezone to use - ZoneId zoneId; - try { - zoneId = (timezone != null && !timezone.trim().isEmpty()) - ? ZoneId.of(timezone) - : ZoneId.of("UTC"); - } catch (Exception e) { - // Invalid timezone, fallback to UTC - zoneId = ZoneId.of("UTC"); - } - - // Get localized "at" word - String atWord = localizationService.getMessage("dateTime.at", languageCode); - if (atWord == null || atWord.isEmpty()) { - atWord = "at"; // Fallback to English - } - - // Create formatter with localized "at" word - final ZoneId finalZoneId = zoneId; - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM '" + atWord + "' HH:mm") - .withZone(finalZoneId); - - return transactions.map(transaction -> { - // Format date as dd.MM at HH:mm (with localized "at" word) - String date = formatter.format(transaction.getCreatedAt()); - - // Amount is the total payout (already positive in WIN transactions) - return GameHistoryEntryDto.builder() - .amount(transaction.getAmount()) - .date(date) - .build(); - }); - } -} - - diff --git a/src/main/java/com/lottery/lottery/service/GameRoomService.java b/src/main/java/com/lottery/lottery/service/GameRoomService.java deleted file mode 100644 index cb97182..0000000 --- a/src/main/java/com/lottery/lottery/service/GameRoomService.java +++ /dev/null @@ -1,1424 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.dto.AdminRoomDetailDto; -import com.lottery.lottery.dto.AdminRoomOnlineUserDto; -import com.lottery.lottery.dto.AdminRoomParticipantDto; -import com.lottery.lottery.dto.AdminRoomSummaryDto; -import com.lottery.lottery.dto.AdminRoomViewerDto; -import com.lottery.lottery.dto.AdminRoomWinnerDto; -import com.lottery.lottery.dto.GameRoomStateDto; -import com.lottery.lottery.dto.JoinRoundResult; -import com.lottery.lottery.dto.ParticipantDto; -import com.lottery.lottery.dto.WinnerDto; -import com.lottery.lottery.exception.*; -import com.lottery.lottery.model.*; -import com.lottery.lottery.repository.*; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Isolation; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -public class GameRoomService { - - private static final long COUNTDOWN_DURATION_SECONDS = 20; - private static final long SPIN_DURATION_MS = 5000; // 5 seconds - - /** - * Gets room-specific bet limits in bigint format (database format with 6 decimal places). - * Room1: 1-200, Room2: 10-5000, Room3: 1000-50000 tickets - */ - private static long getMinBet(Integer roomNumber) { - return switch (roomNumber) { - case 1 -> 1_000_000L; // 1 - case 2 -> 10_000_000L; // 10 - case 3 -> 1_000_000_000L; // 1000 - default -> 1_000_000L; // Default to Room1 limits - }; - } - - private static long getMaxBet(Integer roomNumber) { - return switch (roomNumber) { - case 1 -> 200_000_000L; // 200 - case 2 -> 5_000_000_000L; // 5000 - case 3 -> 50_000_000_000L; // 50000 - default -> 200_000_000L; // Default to Room1 limits - }; - } - - /** - * Returns bet limits for a room (min and max in bigint format). - * Used by remote bet API when rand=true to pick a random amount. - */ - public static BetLimits getBetLimitsForRoom(int roomNumber) { - return new BetLimits(getMinBet(roomNumber), getMaxBet(roomNumber)); - } - - /** Min and max bet in bigint format (1 ticket = 1_000_000). */ - public record BetLimits(long minBet, long maxBet) {} - - /** - * Returns the user's current total bet in the given room's current round (bigint). - * Returns 0 if room not found, no active round, or user is not in the round. - * Used by remote bet API when rand=true to cap the random additional bet. - */ - @Transactional(readOnly = true) - public long getCurrentUserBetInRoom(int userId, int roomNumber) { - Optional roomOpt = gameRoomRepository.findByRoomNumber(roomNumber); - if (roomOpt.isEmpty()) { - return 0L; - } - GameRoom room = roomOpt.get(); - GameRound round = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.WAITING, GamePhase.COUNTDOWN)).orElse(null); - if (round == null) { - return 0L; - } - List list = participantRepository.findByRoundIdAndUserId(round.getId(), userId); - return list.isEmpty() ? 0L : list.get(0).getBet(); - } - - private final GameRoomRepository gameRoomRepository; - private final GameRoundRepository gameRoundRepository; - private final GameRoundParticipantRepository participantRepository; - private final UserARepository userARepository; - private final UserBRepository userBRepository; - private final UserDRepository userDRepository; - private final AvatarService avatarService; - private final RoomConnectionService roomConnectionService; - private final ReferralCommissionService referralCommissionService; - private final TransactionService transactionService; - private final LocalizationService localizationService; - private final BotConfigService botConfigService; - private final PromotionService promotionService; - - // Track last bet time per user per round to prevent fast clicks (rate limiting) - // Key: "userId:roundId", Value: last bet timestamp - private final Map lastBetTimes = new ConcurrentHashMap<>(); - private static final long MIN_BET_INTERVAL_MS = 1000; // 1 second - - // Callback for balance update notifications (set by controller) - private BalanceUpdateCallback balanceUpdateCallback; - - // Callback for state broadcast notifications (set by controller) - private StateBroadcastCallback stateBroadcastCallback; - - /** - * Returns the single most recent active round for a room in the given phases. - * Uses limit 1 in DB so corrupted data (multiple rounds in same phase) never breaks the game. - */ - private Optional getMostRecentActiveRound(Integer roomId, List phases) { - List list = gameRoundRepository.findMostRecentActiveRoundsByRoomId( - roomId, phases, PageRequest.of(0, 1)); - return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0)); - } - - /** - * Sets the callback for balance update notifications. - * Called by GameWebSocketController during initialization. - */ - public void setBalanceUpdateCallback(BalanceUpdateCallback callback) { - this.balanceUpdateCallback = callback; - } - - /** - * Sets the callback for state broadcast notifications. - * Called by GameWebSocketController during initialization. - * Also sets up room connection change callback to broadcast state updates. - */ - public void setStateBroadcastCallback(StateBroadcastCallback callback) { - this.stateBroadcastCallback = callback; - - // Set callback to broadcast state when room connections change - // This is set here (not in @PostConstruct) to ensure stateBroadcastCallback is not null - roomConnectionService.setConnectionChangeCallback((roomNumber, connectedCount) -> { - // Broadcast updated state to ALL rooms when connections change - // This ensures all clients receive updated allRoomsConnectedUsers map, - // regardless of which room they're subscribed to - if (stateBroadcastCallback != null) { - try { - // Broadcast state for all rooms (1, 2, 3) so all clients get updated counts - for (int roomNum = 1; roomNum <= 3; roomNum++) { - try { - GameRoomStateDto state = getRoomState(roomNum); - stateBroadcastCallback.broadcastState(roomNum, state); - } catch (Exception e) { - log.error("Error broadcasting state update for room {} after connection change in room {}", - roomNum, roomNumber, e); - } - } - } catch (Exception e) { - log.error("Error broadcasting state updates after connection change in room {}", roomNumber, e); - } - } - }); - } - - /** - * Broadcasts current state for all rooms (1, 2, 3). - * Used when a client subscribes so all clients (including the new one) get updated allRoomsConnectedUsers. - */ - public void broadcastStateToAllRooms() { - if (stateBroadcastCallback == null) return; - for (int roomNum = 1; roomNum <= 3; roomNum++) { - try { - GameRoomStateDto state = getRoomState(roomNum); - stateBroadcastCallback.broadcastState(roomNum, state); - } catch (Exception e) { - log.error("Error broadcasting state for room {}", roomNum, e); - } - } - } - - /** - * Callback interface for balance update notifications. - */ - @FunctionalInterface - public interface BalanceUpdateCallback { - void notifyBalanceUpdate(Integer userId); - } - - /** - * Callback interface for state broadcast notifications. - */ - @FunctionalInterface - public interface StateBroadcastCallback { - void broadcastState(Integer roomNumber, GameRoomStateDto state); - } - - /** - * Joins a user to a game round (for in-app WebSocket). - * Validates bet, deducts balance, and starts countdown if needed. - */ - @Transactional(isolation = Isolation.READ_COMMITTED) - public GameRoomStateDto joinRound(Integer userId, Integer roomNumber, Long betAmount) { - return joinRoundWithResult(userId, roomNumber, betAmount, false).getState(); - } - - /** - * Joins a user to a game round with optional unique-bet behaviour. - * When {@code uniqueBet} is true, if the user has already placed a bet in this room's current round, - * no additional bet is placed and the current state is returned with their existing bet amount. - * - * @param uniqueBet when true, at most one bet per user per room per round (no accumulation) - * @return result with state and bet amount to report in API response - */ - @Transactional(isolation = Isolation.READ_COMMITTED) - public JoinRoundResult joinRoundWithResult(Integer userId, Integer roomNumber, Long betAmount, boolean uniqueBet) { - - // Validate room number range (1-3) - if (roomNumber == null || roomNumber < 1 || roomNumber > 3) { - throw new GameException(localizationService.getMessage("game.error.roomNumberInvalid")); - } - - // Validate betAmount is not null - if (betAmount == null) { - throw new GameException(localizationService.getMessage("validation.error.required", "Bet amount")); - } - - // Validate bet amount is a positive integer (defense against float values) - // Jackson may truncate floats when deserializing to Long (e.g., 50.99 -> 50) - // This explicit check ensures we reject any non-integer values - if (betAmount <= 0) { - throw new GameException(localizationService.getMessage("game.error.betMustBePositive")); - } - - // Additional validation: Check if betAmount represents a whole number - // Since betAmount is already Long (integer type), this is mainly for documentation - // and defense-in-depth. If a float was sent and Jackson truncated it, the value - // would still be a valid Long, but the original intent was a decimal. - // Note: We can't detect truncation at this point, but we ensure the value is valid. - - // Validate bet amount against room-specific limits - long minBet = getMinBet(roomNumber); - long maxBet = getMaxBet(roomNumber); - if (betAmount < minBet || betAmount > maxBet) { - throw InvalidBetAmountException.create(localizationService, minBet, maxBet); - } - - // Get room with pessimistic lock to prevent race conditions - // This ensures only one transaction can update the room at a time - GameRoom room = gameRoomRepository.findByRoomNumberWithLock(roomNumber) - .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.roomNotFound"))); - - // Check if room is joinable - if (room.getCurrentPhase() == GamePhase.SPINNING || room.getCurrentPhase() == GamePhase.RESOLUTION) { - throw new RoomNotJoinableException(room.getCurrentPhase(), localizationService); - } - - // Get or create current round (must be done before checking participants) - GameRound currentRound = getOrCreateCurrentRound(room); - - // Ensure round is persisted and flushed before checking participants - if (currentRound.getId() == null) { - currentRound = gameRoundRepository.saveAndFlush(currentRound); - } else { - // Refresh from database to ensure it's properly loaded - currentRound = gameRoundRepository.findById(currentRound.getId()) - .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.roundNotFound"))); - } - - // Check if user already joined this round - List existingParticipants = participantRepository - .findByRoundIdAndUserId(currentRound.getId(), userId); - - // When unique=true, do not add another bet if user already has a bet in this room - if (uniqueBet && !existingParticipants.isEmpty()) { - GameRoomStateDto state = buildRoomState(room, currentRound); - int currentBetTickets = (int) (existingParticipants.get(0).getBet() / 1_000_000L); - return new JoinRoundResult(state, currentBetTickets); - } - - // Rate limiting: prevent fast clicks (backend validation) - String rateLimitKey = userId + ":" + currentRound.getId(); - Instant lastBetTime = lastBetTimes.get(rateLimitKey); - if (lastBetTime != null) { - long timeSinceLastBet = Instant.now().toEpochMilli() - lastBetTime.toEpochMilli(); - if (timeSinceLastBet < MIN_BET_INTERVAL_MS) { - throw new GameException(localizationService.getMessage("game.error.rateLimitWait")); - } - } - - // Update last bet time - lastBetTimes.put(rateLimitKey, Instant.now()); - - boolean isNewParticipant = existingParticipants.isEmpty(); - GameRoundParticipant participant; - Long previousBet = 0L; - Long userTotalBet = 0L; - - if (isNewParticipant) { - // New participant - validate total bet doesn't exceed max - userTotalBet = betAmount; - if (userTotalBet > maxBet) { - long maxBetTickets = maxBet / 1_000_000L; - long currentTotalBetTickets = userTotalBet / 1_000_000L; - String message = localizationService.getMessage("game.error.maxBetExceeded", - String.valueOf(maxBetTickets), - String.valueOf(currentTotalBetTickets), - "0"); - throw new GameException(message); - } - - // New participant - create new entry - // Reload round from database to ensure it's in the persistence context - GameRound managedRound = gameRoundRepository.findById(currentRound.getId()) - .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.roundNotFound"))); - - participant = GameRoundParticipant.builder() - .round(managedRound) - .userId(userId) - .bet(betAmount) - .build(); - } else { - // Existing participant - extend their bet - // Use pessimistic lock to prevent race conditions on bet updates - participant = existingParticipants.get(0); - previousBet = participant.getBet(); - // Reload participant with pessimistic lock to prevent concurrent updates - participant = participantRepository.findByIdWithLock(participant.getId()) - .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.participantNotFound"))); - - // Validate total bet doesn't exceed max - userTotalBet = participant.getBet() + betAmount; - if (userTotalBet > maxBet) { - long currentTotalBetTickets = participant.getBet() / 1_000_000L; - long maxBetTickets = maxBet / 1_000_000L; - long remainingCapacity = maxBetTickets - currentTotalBetTickets; - String message = localizationService.getMessage("game.error.maxBetExceeded", - String.valueOf(maxBetTickets), - String.valueOf(currentTotalBetTickets), - String.valueOf(remainingCapacity)); - throw new GameException(message); - } - - participant.setBet(userTotalBet); - } - - // Check user balance - // betAmount is already in bigint format (database format) - UserB userB = userBRepository.findById(userId) - .orElseThrow(() -> new GameException(localizationService.getMessage("user.error.notFound"))); - - if (userB.getBalanceA() < betAmount) { - throw new InsufficientBalanceException(localizationService.getMessage("game.error.insufficientBalance")); - } - - // Deduct balance (both in bigint format) - userB.setBalanceA(userB.getBalanceA() - betAmount); - userBRepository.save(userB); - - // Save participant (new or updated) - participantRepository.saveAndFlush(participant); - - // CRITICAL FIX: Calculate totalBet from actual participants (source of truth) - // This prevents race conditions - don't use room.getTotalBet() which can be corrupted - List allParticipants = participantRepository.findByRoundId(currentRound.getId()); - Long actualTotalBet = allParticipants.stream() - .mapToLong(GameRoundParticipant::getBet) - .sum(); - - // Update room totals with calculated value (not increment) - room.setTotalBet(actualTotalBet); - int actualParticipantCount = allParticipants.size(); - room.setRegisteredPlayers(actualParticipantCount); - - // Start countdown when we have at least 2 players and room is still WAITING. - // Using >= 2 (not == 2) so that if countdown wasn't started when the 2nd joined (e.g. race/multi-instance), - // the 3rd or any later join will unblock the room. - if (actualParticipantCount >= 2 && room.getCurrentPhase() == GamePhase.WAITING) { - startCountdown(room, currentRound); - } - - gameRoomRepository.save(room); - - GameRoomStateDto state = buildRoomState(room, currentRound); - - // Immediately broadcast state update (event-driven) - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(roomNumber, state); - } - - int betTicketsForResponse = (int) (betAmount / 1_000_000L); - return new JoinRoundResult(state, betTicketsForResponse); - } - - /** - * Starts the countdown for a round. - */ - private void startCountdown(GameRoom room, GameRound round) { - Instant countdownEnd = Instant.now().plusSeconds(COUNTDOWN_DURATION_SECONDS); - room.setCountdownEndAt(countdownEnd); - room.setCurrentPhase(GamePhase.COUNTDOWN); - - round.setCountdownStartedAt(Instant.now()); - round.setPhase(GamePhase.COUNTDOWN); - - gameRoomRepository.save(room); - gameRoundRepository.save(round); - - // Immediately broadcast state update (event-driven) - GameRoomStateDto state = buildRoomState(room, round); - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); - } - } - - /** - * Gets or creates the current active round for a room. - * Uses DB as single source of truth (no in-memory round cache). - */ - private GameRound getOrCreateCurrentRound(GameRoom room) { - Optional foundRound = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.WAITING, GamePhase.COUNTDOWN)); - return foundRound.orElseGet(() -> createNewRound(room)); - } - - /** - * Creates a new round for a room. - */ - private GameRound createNewRound(GameRoom room) { - GameRound newRound = GameRound.builder() - .room(room) - .phase(GamePhase.WAITING) - .totalBet(0L) - .startedAt(Instant.now()) - .build(); - newRound = gameRoundRepository.saveAndFlush(newRound); - return newRound; - } - - /** - * Checks for countdowns that have ended and starts spins. - * Lightweight check (only queries COUNTDOWN phase rooms, uses index). - * Most countdowns are handled event-driven, but this catches edge cases. - */ - @Scheduled(fixedRate = 500) // Check every 500ms for countdowns (lightweight query) - @Transactional - public void checkCountdowns() { - // Only query rooms in COUNTDOWN phase (indexed query, very fast) - List roomsInCountdown = gameRoomRepository.findByCurrentPhase(GamePhase.COUNTDOWN); - - for (GameRoom room : roomsInCountdown) { - if (room.getCountdownEndAt() != null && Instant.now().isAfter(room.getCountdownEndAt())) { - try { - startSpin(room); - } catch (Exception e) { - log.error("Error starting spin for room {}", room.getRoomNumber(), e); - } - } - } - } - - /** - * Starts the spin phase and selects winner. - */ - @Transactional - public void startSpin(GameRoom room) { - - // CRITICAL FIX: Prevent concurrent execution - if room is already in SPINNING, skip - // This can happen if checkCountdowns is called multiple times concurrently - if (room.getCurrentPhase() == GamePhase.SPINNING) { - log.debug("Room {} is already in SPINNING phase, skipping", room.getRoomNumber()); - return; - } - - // Also check if room is not in COUNTDOWN phase (shouldn't happen, but safety check) - if (room.getCurrentPhase() != GamePhase.COUNTDOWN) { - log.warn("Room {} is not in COUNTDOWN phase (current: {}), skipping", - room.getRoomNumber(), room.getCurrentPhase()); - return; - } - - GameRound round = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.COUNTDOWN, GamePhase.WAITING)).orElse(null); - if (round == null) { - log.warn("No active round found for room {}", room.getRoomNumber()); - return; - } - - // Check participant count before starting spin - // If only 1 participant, refund immediately without starting spin animation - List participants = participantRepository.findByRoundId(round.getId()); - if (participants.size() < 2) { - if (participants.size() == 1) { - GameRoundParticipant participant = participants.get(0); - UserB userB = userBRepository.findById(participant.getUserId()) - .orElseThrow(() -> new IllegalStateException("User balance not found")); - userB.setBalanceA(userB.getBalanceA() + participant.getBet()); - userBRepository.save(userB); - - round.setPhase(GamePhase.RESOLUTION); - round.setResolvedAt(Instant.now()); - gameRoundRepository.save(round); - - // Set room to RESOLUTION (not WAITING) to allow frontend to process - room.setCurrentPhase(GamePhase.RESOLUTION); - gameRoomRepository.save(room); - - // Notify balance update - if (balanceUpdateCallback != null) { - balanceUpdateCallback.notifyBalanceUpdate(participant.getUserId()); - } - - // Don't reset room immediately - let scheduled task handle it after delay - } else { - // No participants, just reset - resetRoom(room); - } - return; - } - - // CRITICAL FIX: Calculate totalBet from actual participants (source of truth) - // Don't use room.getTotalBet() which can be corrupted by race conditions - Long actualRoundTotalBet = participants.stream() - .mapToLong(GameRoundParticipant::getBet) - .sum(); - - // Update round with actual totals from participants - round.setTotalBet(actualRoundTotalBet); - round.setCountdownEndedAt(Instant.now()); - round.setPhase(GamePhase.SPINNING); - - // CRITICAL: Determine winner NOW (before SPINNING phase) so frontend can generate tape correctly - // This ensures stopIndex and winner are available during SPINNING phase - // The actual payout will be applied later in resolveWinner() during RESOLUTION phase - // Use actualRoundTotalBet (from participants) not room.getTotalBet() (which might be corrupted) - long totalBet = actualRoundTotalBet; - if (totalBet <= 0) { - log.error("Invalid totalBet for round - roundId={}, totalBet={}, participants={}", - round.getId(), totalBet, participants.size()); - throw new IllegalStateException("Cannot start spin with zero total bet"); - } - - // Bot override: safe bot (balance < threshold) or flexible bot (fixed win rate) may force winner - GameRoundParticipant winner = botConfigService.resolveWinnerOverride(participants, totalBet) - .orElse(null); - - if (winner == null) { - // Normal weighted random by bet - long randomValue = new Random().nextLong(totalBet); - if (randomValue < 0) { - randomValue = -randomValue; - } - long cumulative = 0; - for (GameRoundParticipant p : participants) { - cumulative += p.getBet(); - if (randomValue < cumulative) { - winner = p; - break; - } - } - if (winner == null) { - winner = participants.get(participants.size() - 1); - } - } - - // Set winner in round (but don't apply payout yet - that happens in resolveWinner) - round.setWinnerUserId(winner.getUserId()); - round.setWinnerBet(winner.getBet()); - gameRoundRepository.save(round); - - // Update room phase - room.setCurrentPhase(GamePhase.SPINNING); - gameRoomRepository.save(room); - - // Immediately broadcast SPINNING state (event-driven) - // This state will now include winner and stopIndex for tape generation - GameRoomStateDto state = buildRoomState(room, round); - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); - } - - // Winner resolution is handled by resolveSpins() scheduled task - // It checks countdownEndedAt + SPIN_DURATION_MS to determine when to resolve - // Note: Winner is already determined above, resolveWinner() will only apply payout - } - - /** - * Resolves the winner and applies payouts. - * Called after spin duration. - * Checks only rooms in SPINNING phase (lightweight query). - */ - @Scheduled(fixedRate = 500) // Check every 500ms for spins (lightweight query) - @Transactional - public void resolveSpins() { - // Only query rooms in SPINNING phase (indexed query, very fast) - List roomsSpinning = gameRoomRepository.findByCurrentPhase(GamePhase.SPINNING); - - for (GameRoom room : roomsSpinning) { - GameRound round = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.SPINNING)).orElse(null); - if (round == null) { - log.warn("Room {} is in SPINNING phase but no round found in database", room.getRoomNumber()); - resetRoom(room); - continue; - } - - if (round.getCountdownEndedAt() == null) { - log.warn("Round {} is in SPINNING phase but countdownEndedAt is null", round.getId()); - // This shouldn't happen, but if it does, set it to now to allow resolution - round.setCountdownEndedAt(Instant.now()); - gameRoundRepository.save(round); - } - - // Check if spin duration has passed - Instant spinEndTime = round.getCountdownEndedAt().plusMillis(SPIN_DURATION_MS); - if (Instant.now().isBefore(spinEndTime)) { - continue; // Still spinning - } - - // Re-check participant count before resolving - // If participants dropped below 2, handle refund instead - List participants = participantRepository.findByRoundId(round.getId()); - if (participants.size() < 2) { - log.warn("Participant count dropped during spin: room={}, roundId={}, count={}", - room.getRoomNumber(), round.getId(), participants.size()); - if (participants.size() == 1) { - GameRoundParticipant participant = participants.get(0); - UserB userB = userBRepository.findById(participant.getUserId()) - .orElseThrow(() -> new IllegalStateException("User balance not found")); - userB.setBalanceA(userB.getBalanceA() + participant.getBet()); - userBRepository.save(userB); - - log.info("Round refunded (participant dropped during spin) - room={}, roundId={}, userId={}, refundAmount={}", - room.getRoomNumber(), round.getId(), participant.getUserId(), participant.getBet()); - - round.setPhase(GamePhase.RESOLUTION); - round.setResolvedAt(Instant.now()); - gameRoundRepository.save(round); - - room.setCurrentPhase(GamePhase.RESOLUTION); - gameRoomRepository.save(room); - - // Immediately broadcast RESOLUTION state (event-driven) - GameRoomStateDto state = buildRoomState(room, round); - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); - } - - // Notify balance update (event-driven) - if (balanceUpdateCallback != null) { - balanceUpdateCallback.notifyBalanceUpdate(participant.getUserId()); - } - - continue; - } else { - // No participants, reset room - resetRoom(room); - continue; - } - } - - try { - Integer winnerUserId = resolveWinner(room, round); - - // Balance update is already sent by resolveWinner via callback (event-driven) - // State broadcast is already done by resolveWinner via callback (event-driven) - } catch (Exception e) { - log.error("Error resolving winner for room {}", room.getRoomNumber(), e); - } - } - - // Second, reset rooms that have been in RESOLUTION for at least 4 seconds - // This allows frontend to process RESOLUTION phase and clear tape via animation callback - // Only query rooms in RESOLUTION phase (indexed query, very fast) - // DB as authority: if round is not in cache (e.g. after restart), load from DB so we still reset - List roomsInResolution = gameRoomRepository.findByCurrentPhase(GamePhase.RESOLUTION); - - for (GameRoom room : roomsInResolution) { - GameRound round = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.RESOLUTION)).orElse(null); - if (round != null && round.getResolvedAt() != null) { - // Check if RESOLUTION phase has been visible for at least 4 seconds - long secondsSinceResolution = Instant.now().getEpochSecond() - round.getResolvedAt().getEpochSecond(); - if (secondsSinceResolution >= 4) { - resetRoom(room); - - // Immediately broadcast WAITING state (event-driven) - GameRoomStateDto state = buildRoomState(room, null); - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); - } - } - } - } - } - - /** - * Selects winner and applies payouts. - * @return Winner user ID for balance update notification - */ - @Transactional - public Integer resolveWinner(GameRoom room, GameRound round) { - // Re-read room so we see latest phase (avoid double payout if called twice) - GameRoom currentRoom = gameRoomRepository.findByRoomNumber(room.getRoomNumber()).orElse(null); - if (currentRoom == null || currentRoom.getCurrentPhase() != GamePhase.SPINNING) { - log.debug("Room {} no longer SPINNING (phase={}), skipping resolveWinner", - room.getRoomNumber(), currentRoom != null ? currentRoom.getCurrentPhase() : null); - return null; - } - room = currentRoom; - - // Get all participants - List participants = participantRepository.findByRoundId(round.getId()); - - if (participants.isEmpty()) { - log.warn("No participants found for round {}", round.getId()); - resetRoom(room); - return null; - } - - if (participants.size() == 1) { - // Only one participant, refund - // All values are in bigint format (database format) - GameRoundParticipant participant = participants.get(0); - UserB userB = userBRepository.findById(participant.getUserId()) - .orElseThrow(() -> new IllegalStateException("User balance not found")); - userB.setBalanceA(userB.getBalanceA() + participant.getBet()); - // Increment rounds_played even for refunded rounds (user still participated) - userB.setRoundsPlayed(userB.getRoundsPlayed() + 1); - userBRepository.save(userB); - - log.info("Round refunded (single participant) - room={}, roundId={}, userId={}, refundAmount={}", - room.getRoomNumber(), round.getId(), participant.getUserId(), participant.getBet()); - - round.setPhase(GamePhase.RESOLUTION); - round.setResolvedAt(Instant.now()); - gameRoundRepository.save(round); - - // Set room to RESOLUTION (not WAITING) to allow frontend to process - room.setCurrentPhase(GamePhase.RESOLUTION); - gameRoomRepository.save(room); - - - // Immediately broadcast RESOLUTION state (event-driven) - GameRoomStateDto state = buildRoomState(room, round); - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); - } - - // Notify user's balance update via callback (event-driven) - if (balanceUpdateCallback != null) { - balanceUpdateCallback.notifyBalanceUpdate(participant.getUserId()); - } - - return participant.getUserId(); - } - - // Winner was already determined in startSpin() to enable tape generation - // Here we only need to apply the payout - Integer winnerUserId = round.getWinnerUserId(); - if (winnerUserId == null) { - // Defensive recovery: round in SPINNING without winner (e.g. replication lag, restart). - // Pick winner using same weighted-random logic as startSpin() so we can resolve and unblock the room. - log.warn("Defensive recovery: winner was null for round {} (room {}), selecting winner from participants", - round.getId(), room.getRoomNumber()); - long actualTotalBetRecovery = participants.stream().mapToLong(GameRoundParticipant::getBet).sum(); - if (actualTotalBetRecovery <= 0) { - log.error("Cannot recover round {} - totalBet is zero", round.getId()); - resetRoom(room); - return null; - } - long randomValue = new Random().nextLong(actualTotalBetRecovery); - if (randomValue < 0) { - randomValue = -randomValue; - } - long cumulative = 0; - GameRoundParticipant selectedWinner = null; - for (GameRoundParticipant p : participants) { - cumulative += p.getBet(); - if (randomValue < cumulative) { - selectedWinner = p; - break; - } - } - if (selectedWinner == null) { - selectedWinner = participants.get(participants.size() - 1); - } - round.setWinnerUserId(selectedWinner.getUserId()); - round.setWinnerBet(selectedWinner.getBet()); - round.setTotalBet(actualTotalBetRecovery); - gameRoundRepository.save(round); - winnerUserId = round.getWinnerUserId(); - } - - final Integer winnerIdForLookup = winnerUserId; - GameRoundParticipant winner = participants.stream() - .filter(p -> p.getUserId().equals(winnerIdForLookup)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Winner participant not found")); - - // CRITICAL FIX: Calculate totalBet from actual participants (source of truth) - // Don't use round.getTotalBet() which can be corrupted by race conditions - long actualTotalBet = participants.stream() - .mapToLong(GameRoundParticipant::getBet) - .sum(); - - // Update round with actual totalBet to ensure consistency - if (actualTotalBet != round.getTotalBet()) { - log.warn("Round totalBet mismatch - roundId={}, roundTotalBet={}, actualTotalBet={}, updating round", - round.getId(), round.getTotalBet(), actualTotalBet); - round.setTotalBet(actualTotalBet); - } - - // Calculate commission and payout - // All values are in bigint format (database format) - long totalBet = actualTotalBet; - long winnerBet = winner.getBet(); - long commission = (long) ((totalBet - winnerBet) * 0.2); - long payout = totalBet - commission; - - // Update winner balance (all values in bigint format) - UserB winnerBalance = userBRepository.findById(winner.getUserId()) - .orElseThrow(() -> new IllegalStateException("Winner balance not found")); - winnerBalance.setBalanceA(winnerBalance.getBalanceA() + payout); - // Increment total winnings since last deposit (for withdrawal limit) - long currentWinAfterDeposit = winnerBalance.getTotalWinAfterDeposit() != null ? winnerBalance.getTotalWinAfterDeposit() : 0L; - winnerBalance.setTotalWinAfterDeposit(currentWinAfterDeposit + payout); - userBRepository.save(winnerBalance); - - log.info("Round completed: room={}, roundId={}, winner={}, totalBet={}, payout={}", - room.getRoomNumber(), round.getId(), winner.getUserId(), totalBet, payout); - - // Create win transaction (total payout, not net profit) - try { - transactionService.createWinTransaction(winner.getUserId(), payout, round.getId()); - } catch (Exception e) { - log.error("Error creating win transaction: userId={}, roundId={}", winner.getUserId(), round.getId(), e); - // Continue even if transaction record creation fails - } - - // Add net win to active NET_WIN promotions (net win = payout - winner's bet) - long netWinBigint = payout - winnerBet; - if (netWinBigint > 0) { - try { - promotionService.addNetWinPoints(winner.getUserId(), netWinBigint); - } catch (Exception e) { - log.error("Error adding promotion points: userId={}, roundId={}", winner.getUserId(), round.getId(), e); - // Continue even if promotion update fails - } - // NET_WIN_MAX_BET: same points but only when winner made max bet in this room - long roomMaxBet = getMaxBet(room.getRoomNumber()); - if (winnerBet == roomMaxBet) { - try { - promotionService.addNetWinMaxBetPoints(winner.getUserId(), netWinBigint); - } catch (Exception e) { - log.error("Error adding NET_WIN_MAX_BET promotion points: userId={}, roundId={}", winner.getUserId(), round.getId(), e); - } - } - } - - Set refererIdsToNotify = new HashSet<>(); - - // REF_COUNT: for each participant whose first round this is (rounds_played == 0), add 1 point to their referer (level 1) - // only if the referral was registered during the promotion's timeframe (not before promo start) - for (GameRoundParticipant participant : participants) { - try { - UserB userB = userBRepository.findById(participant.getUserId()) - .orElseThrow(() -> new IllegalStateException("User balance not found for userId=" + participant.getUserId())); - if (userB.getRoundsPlayed() != null && userB.getRoundsPlayed() == 0) { - userDRepository.findById(participant.getUserId()).ifPresent(userD -> { - Integer refererId1 = userD.getRefererId1(); - if (refererId1 != null && refererId1 > 0) { - Instant referralRegTime = userARepository.findById(participant.getUserId()) - .filter(ua -> ua.getDateReg() != null && ua.getDateReg() > 0) - .map(ua -> Instant.ofEpochSecond(ua.getDateReg().longValue())) - .orElse(null); - try { - promotionService.addRefCountPoints(refererId1, referralRegTime); - } catch (Exception e) { - log.error("Error adding REF_COUNT promotion point for refererId={}, roundId={}", refererId1, round.getId(), e); - } - } - }); - } - } catch (Exception e) { - log.error("Error checking REF_COUNT for userId={}, roundId={}", participant.getUserId(), round.getId(), e); - } - } - - // Increment rounds_played for all participants - for (GameRoundParticipant participant : participants) { - try { - UserB userB = userBRepository.findById(participant.getUserId()) - .orElseThrow(() -> new IllegalStateException("User balance not found for userId=" + participant.getUserId())); - userB.setRoundsPlayed(userB.getRoundsPlayed() + 1); - userBRepository.save(userB); - } catch (Exception e) { - log.error("Error incrementing rounds_played for userId={}, roundId={}", participant.getUserId(), round.getId(), e); - // Continue processing other participants even if one fails - } - } - - // Create bet transactions for all participants (winners and losers) - for (GameRoundParticipant participant : participants) { - try { - // Check if this will be the user's 3rd bet BEFORE creating the transaction - boolean isThirdBet = referralCommissionService.willBeThirdBet(participant.getUserId()); - - transactionService.createBetTransaction(participant.getUserId(), participant.getBet(), round.getId()); - - // If this was the 3rd bet, give bonus to referrer 1 - if (isThirdBet) { - try { - Integer referer1Id = referralCommissionService.giveThirdBetBonus(participant.getUserId()); - if (referer1Id != null) { - refererIdsToNotify.add(referer1Id); - } - } catch (Exception e) { - log.error("Error giving 3rd bet bonus for userId={}", participant.getUserId(), e); - // Continue even if bonus fails - } - } - } catch (Exception e) { - log.error("Error creating bet transaction: userId={}, roundId={}", participant.getUserId(), round.getId(), e); - // Continue processing other participants even if one fails - } - } - - // Delete all participants for this round immediately after round finishes - try { - participantRepository.deleteAll(participants); - // Participants deleted for round (no log needed - happens on every round completion) - } catch (Exception e) { - log.error("Error deleting participants for roundId={}", round.getId(), e); - // Continue even if participant deletion fails - } - - // Process referral commissions for the winner and collect referer IDs - try { - Set winnerReferers = referralCommissionService.processWinnerCommissions( - winner.getUserId(), winnerBet, totalBet, commission); - refererIdsToNotify.addAll(winnerReferers); - } catch (Exception e) { - log.error("Error processing winner referral commissions for userId={}", winner.getUserId(), e); - // Continue even if referral commission processing fails - } - - // Process referral commissions for all losers and collect referer IDs - for (GameRoundParticipant participant : participants) { - if (!participant.getUserId().equals(winnerUserId)) { - try { - Set loserReferers = referralCommissionService.processLoserCommissions( - participant.getUserId(), participant.getBet()); - refererIdsToNotify.addAll(loserReferers); - } catch (Exception e) { - log.error("Error processing loser referral commissions for userId={}", participant.getUserId(), e); - // Continue processing other participants even if one fails - } - } - } - - // Notify referers of balance updates - if (balanceUpdateCallback != null) { - for (Integer refererId : refererIdsToNotify) { - try { - balanceUpdateCallback.notifyBalanceUpdate(refererId); - } catch (Exception e) { - log.error("Error notifying balance update for refererId={}", refererId, e); - } - } - } - - // Update round (winner info already set in startSpin, just update phase and payout info) - round.setWinnerBet(winnerBet); - round.setCommission(commission); - round.setPayout(payout); - round.setPhase(GamePhase.RESOLUTION); - round.setResolvedAt(Instant.now()); - gameRoundRepository.save(round); - - // Set room phase to RESOLUTION - room.setCurrentPhase(GamePhase.RESOLUTION); - gameRoomRepository.save(room); - - // Immediately broadcast RESOLUTION state (event-driven) - GameRoomStateDto state = buildRoomState(room, round); - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); - } - - // Send balance update to winner (event-driven) - if (balanceUpdateCallback != null) { - balanceUpdateCallback.notifyBalanceUpdate(winner.getUserId()); - } - - // Return winner ID - return winner.getUserId(); - } - - /** - * Resets room to WAITING state for next round. - */ - private void resetRoom(GameRoom room) { - room.setCurrentPhase(GamePhase.WAITING); - room.setTotalBet(0L); - room.setRegisteredPlayers(0); - room.setCountdownEndAt(null); - gameRoomRepository.save(room); - - // Clean up rate limit entries for the round we're leaving (prevent memory leaks) - getMostRecentActiveRound(room.getId(), - List.of(GamePhase.RESOLUTION, GamePhase.SPINNING, GamePhase.COUNTDOWN)) - .ifPresent(r -> { - if (r.getId() != null) { - Long roundId = r.getId(); - lastBetTimes.entrySet().removeIf(entry -> entry.getKey().endsWith(":" + roundId)); - } - }); - - log.info("Room {} reset to WAITING state", room.getRoomNumber()); - } - - /** - * Resets room after RESOLUTION phase has been broadcast. - * Called after balance update is sent to winner. - */ - public void resetRoomAfterResolution(Integer roomNumber) { - GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber) - .orElse(null); - if (room != null && room.getCurrentPhase() == GamePhase.RESOLUTION) { - resetRoom(room); - } - } - - /** - * Gets current room state. - */ - @Transactional(readOnly = true) - public GameRoomStateDto getRoomState(Integer roomNumber) { - GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber) - .orElseThrow(() -> new IllegalArgumentException("Room not found: " + roomNumber)); - List phases = room.getCurrentPhase() == GamePhase.RESOLUTION - ? List.of(GamePhase.RESOLUTION) - : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); - GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); - return buildRoomState(room, round); - } - - /** - * Builds room state DTO. - */ - private GameRoomStateDto buildRoomState(GameRoom room, GameRound round) { - List participants = new ArrayList<>(); - WinnerDto winner = null; - Long countdownRemaining = null; - Long spinDuration = null; - Long stopIndex = null; - - if (round != null) { - // Get participants - List participantList = participantRepository.findByRoundId(round.getId()); - - // Optimize: Fetch all avatar URLs in one query (avoids N+1 query problem) - List userIds = participantList.stream() - .map(GameRoundParticipant::getUserId) - .collect(Collectors.toList()); - Map avatarUrlMap = avatarService.getAvatarUrls(userIds); - - participants = participantList.stream() - .map(p -> { - String avatarUrl = avatarUrlMap.get(p.getUserId()); - return ParticipantDto.builder() - .userId(p.getUserId()) - .bet(p.getBet() / 1_000_000L) // Convert bigint to tickets - .avatarUrl(avatarUrl) - .build(); - }) - .collect(Collectors.toList()); - - // Calculate countdown remaining - if (room.getCurrentPhase() == GamePhase.COUNTDOWN && room.getCountdownEndAt() != null) { - long remaining = room.getCountdownEndAt().getEpochSecond() - Instant.now().getEpochSecond(); - countdownRemaining = Math.max(0, remaining); - } - - // Winner info - include during SPINNING phase (for tape generation) and RESOLUTION phase (for display) - if ((round.getPhase() == GamePhase.SPINNING || round.getPhase() == GamePhase.RESOLUTION) && round.getWinnerUserId() != null) { - // Fetch winner's screen name from UserA - String winnerScreenName = "-"; - Optional winnerUser = userARepository.findById(round.getWinnerUserId()); - if (winnerUser.isPresent()) { - winnerScreenName = winnerUser.get().getScreenName(); - } - - // Generate avatar URL for winner - String winnerAvatarUrl = avatarService.getAvatarUrl(round.getWinnerUserId()); - - // Calculate winner's chance percentage from actual participant bets (same as CompletedRoundDto) - // This ensures consistency - calculate totalBet from participants, not from round.getTotalBet() - // which might be out of sync with actual participant bets - Double winChance = null; - Long winnerBetBigint = round.getWinnerBet(); - Long winnerBetTickets = winnerBetBigint / 1_000_000L; - if (winnerBetBigint > 0) { - // Calculate actual totalBet from participants (source of truth) - Long actualTotalBetFromParticipants = participantList.stream() - .mapToLong(GameRoundParticipant::getBet) - .sum(); - - if (actualTotalBetFromParticipants > 0) { - winChance = ((double) winnerBetBigint / actualTotalBetFromParticipants) * 100.0; - } - } - - winner = WinnerDto.builder() - .userId(round.getWinnerUserId()) - .screenName(winnerScreenName) - .avatarUrl(winnerAvatarUrl) - .bet(winnerBetTickets) // Convert bigint to tickets - .payout(round.getPayout()) // Keep in bigint format - .commission(round.getCommission()) // Keep in bigint format - .winChance(winChance) // Calculated from actual participant bets - .build(); - } - - // Spin animation parameters - if (round.getPhase() == GamePhase.SPINNING || round.getPhase() == GamePhase.RESOLUTION) { - spinDuration = SPIN_DURATION_MS; - // Calculate stop index based on winner's position in cumulative bet - // stopIndex is in tickets (not bigint) for consistency - if (round.getWinnerUserId() != null) { - long cumulative = 0; - for (GameRoundParticipant p : participantList) { - if (p.getUserId().equals(round.getWinnerUserId())) { - // Calculate in bigint, then convert to tickets - long betInTickets = p.getBet() / 1_000_000L; - stopIndex = cumulative + (betInTickets / 2); // Middle of winner's range - break; - } - cumulative += p.getBet() / 1_000_000L; // Convert to tickets for cumulative - } - } - } - } - - // Get connected users count from room connection service - int connectedUsers = roomConnectionService.getConnectedUsersCount(room.getRoomNumber()); - - // Get connected users count for all rooms (1, 2, 3) so frontend can update all room counters - Map allRoomsConnectedUsers = new HashMap<>(); - for (int roomNum = 1; roomNum <= 3; roomNum++) { - allRoomsConnectedUsers.put(roomNum, roomConnectionService.getConnectedUsersCount(roomNum)); - } - - // Get room-specific bet limits (convert from bigint to tickets) - long minBetBigint = getMinBet(room.getRoomNumber()); - long maxBetBigint = getMaxBet(room.getRoomNumber()); - long minBet = minBetBigint / 1_000_000L; - long maxBet = maxBetBigint / 1_000_000L; - - // Convert phase enum to integer: 1=WAITING, 2=COUNTDOWN, 3=SPINNING, 4=RESOLUTION - int phaseInt = switch (room.getCurrentPhase()) { - case WAITING -> 1; - case COUNTDOWN -> 2; - case SPINNING -> 3; - case RESOLUTION -> 4; - }; - - // Convert totalBet from bigint to tickets - long totalBetTickets = room.getTotalBet() / 1_000_000L; - - return GameRoomStateDto.builder() - .roomNumber(room.getRoomNumber()) - .roundId(round != null ? round.getId() : null) - .phase(phaseInt) - .totalBet(totalBetTickets) - .registeredPlayers(room.getRegisteredPlayers()) - .connectedUsers(connectedUsers) - .allRoomsConnectedUsers(allRoomsConnectedUsers) - .minBet(minBet) - .maxBet(maxBet) - .countdownEndAt(room.getCountdownEndAt()) - .countdownRemainingSeconds(countdownRemaining) - .participants(participants) - .winner(winner) - .spinDuration(spinDuration) - .stopIndex(stopIndex) - .build(); - } - - // --- Admin room management --- - - private static final long REPAIR_COUNTDOWN_DEAD_GRACE_SECONDS = 30; - - /** - * Returns summary for all rooms (for admin list). Uses DB and connection service. - */ - @Transactional(readOnly = true) - public List getAdminRoomSummaries() { - List list = new ArrayList<>(); - for (int roomNumber = 1; roomNumber <= 3; roomNumber++) { - GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber).orElse(null); - if (room == null) continue; - List phases = room.getCurrentPhase() == GamePhase.RESOLUTION - ? List.of(GamePhase.RESOLUTION) - : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); - GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); - long totalBetTickets = room.getTotalBet() != null ? room.getTotalBet() / 1_000_000L : 0L; - double totalBetUsd = totalBetTickets / 1000.0; - int connected = roomConnectionService.getConnectedUsersCount(roomNumber); - list.add(AdminRoomSummaryDto.builder() - .roomNumber(room.getRoomNumber()) - .phase(room.getCurrentPhase().name()) - .connectedUsers(connected) - .registeredPlayers(room.getRegisteredPlayers() != null ? room.getRegisteredPlayers() : 0) - .totalBetTickets(totalBetTickets) - .totalBetUsd(totalBetUsd) - .roundId(round != null ? round.getId() : null) - .build()); - } - return list; - } - - /** - * Returns full detail for one room (for admin room detail screen). - */ - @Transactional(readOnly = true) - public AdminRoomDetailDto getAdminRoomDetail(Integer roomNumber) { - GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber) - .orElseThrow(() -> new IllegalArgumentException("Room not found: " + roomNumber)); - List phases = room.getCurrentPhase() == GamePhase.RESOLUTION - ? List.of(GamePhase.RESOLUTION) - : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); - GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); - long totalBetTickets = room.getTotalBet() != null ? room.getTotalBet() / 1_000_000L : 0L; - double totalBetUsd = totalBetTickets / 1000.0; - int connected = roomConnectionService.getConnectedUsersCount(roomNumber); - List connectedUserIds = roomConnectionService.getConnectedUserIds(roomNumber); - List connectedViewers = new ArrayList<>(); - for (Integer viewerId : connectedUserIds) { - String screenName = "-"; - Optional u = userARepository.findById(viewerId); - if (u.isPresent() && u.get().getScreenName() != null) screenName = u.get().getScreenName(); - connectedViewers.add(AdminRoomViewerDto.builder().userId(viewerId).screenName(screenName).build()); - } - - List participants = new ArrayList<>(); - AdminRoomWinnerDto winnerDto = null; - if (round != null) { - List participantList = participantRepository.findByRoundId(round.getId()); - long totalBetBigint = participantList.stream().mapToLong(GameRoundParticipant::getBet).sum(); - for (GameRoundParticipant p : participantList) { - double chancePct = totalBetBigint > 0 ? (p.getBet() * 100.0 / totalBetBigint) : 0; - String screenName = "-"; - Optional u = userARepository.findById(p.getUserId()); - if (u.isPresent() && u.get().getScreenName() != null) screenName = u.get().getScreenName(); - participants.add(AdminRoomParticipantDto.builder() - .userId(p.getUserId()) - .screenName(screenName) - .betTickets(p.getBet() / 1_000_000L) - .chancePct(Math.round(chancePct * 100.0) / 100.0) - .build()); - } - if ((round.getPhase() == GamePhase.SPINNING || round.getPhase() == GamePhase.RESOLUTION) && round.getWinnerUserId() != null) { - long winnerBetBigint = round.getWinnerBet() != null ? round.getWinnerBet() : 0L; - double winChancePct = totalBetBigint > 0 ? (winnerBetBigint * 100.0 / totalBetBigint) : 0; - String winnerScreenName = "-"; - Optional u = userARepository.findById(round.getWinnerUserId()); - if (u.isPresent() && u.get().getScreenName() != null) winnerScreenName = u.get().getScreenName(); - winnerDto = AdminRoomWinnerDto.builder() - .userId(round.getWinnerUserId()) - .screenName(winnerScreenName) - .betTickets(winnerBetBigint / 1_000_000L) - .winChancePct(Math.round(winChancePct * 100.0) / 100.0) - .build(); - } - } - - return AdminRoomDetailDto.builder() - .roomNumber(room.getRoomNumber()) - .phase(room.getCurrentPhase().name()) - .roundId(round != null ? round.getId() : null) - .totalBetTickets(totalBetTickets) - .totalBetUsd(totalBetUsd) - .registeredPlayers(room.getRegisteredPlayers() != null ? room.getRegisteredPlayers() : 0) - .connectedUsers(connected) - .participants(participants) - .connectedViewers(connectedViewers) - .winner(winnerDto) - .build(); - } - - /** - * Returns all users currently connected to any room (viewers + participants), with room, current bet, balance, deposits, withdrawals, rounds played. - * For admin "online users across all rooms" table. - */ - @Transactional(readOnly = true) - public List getAdminOnlineUsersAcrossRooms() { - List result = new ArrayList<>(); - for (int roomNumber = 1; roomNumber <= 3; roomNumber++) { - GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber).orElse(null); - if (room == null) continue; - List phases = room.getCurrentPhase() == GamePhase.RESOLUTION - ? List.of(GamePhase.RESOLUTION) - : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); - GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); - List connectedUserIds = roomConnectionService.getConnectedUserIds(roomNumber); - for (Integer userId : connectedUserIds) { - Long currentBetTickets = null; - if (round != null) { - List participantList = participantRepository.findByRoundIdAndUserId(round.getId(), userId); - if (!participantList.isEmpty()) { - currentBetTickets = participantList.get(0).getBet() / 1_000_000L; - } - } - String screenName = "-"; - Optional ua = userARepository.findById(userId); - if (ua.isPresent() && ua.get().getScreenName() != null) screenName = ua.get().getScreenName(); - UserB userB = userBRepository.findById(userId).orElse(UserB.builder() - .id(userId) - .balanceA(0L) - .depositTotal(0L) - .depositCount(0) - .withdrawTotal(0L) - .withdrawCount(0) - .roundsPlayed(0) - .build()); - result.add(AdminRoomOnlineUserDto.builder() - .userId(userId) - .screenName(screenName) - .roomNumber(roomNumber) - .currentBetTickets(currentBetTickets) - .balanceA(userB.getBalanceA()) - .depositTotal(userB.getDepositTotal()) - .depositCount(userB.getDepositCount()) - .withdrawTotal(userB.getWithdrawTotal()) - .withdrawCount(userB.getWithdrawCount()) - .roundsPlayed(userB.getRoundsPlayed()) - .build()); - } - } - return result; - } - - /** - * Admin-only: run repair logic for one room (fix dead state). Uses room lock. - */ - @Transactional - public void repairRoom(int roomNumber) { - if (roomNumber < 1 || roomNumber > 3) { - throw new IllegalArgumentException("Invalid room number: " + roomNumber); - } - GameRoom room = gameRoomRepository.findByRoomNumberWithLock(roomNumber).orElse(null); - if (room == null) return; - GamePhase phase = room.getCurrentPhase(); - switch (phase) { - case WAITING -> recoverWaitingIfDead(room); - case COUNTDOWN -> recoverCountdownIfDead(room); - case SPINNING -> recoverSpinningIfDead(room); - case RESOLUTION -> recoverResolutionIfDead(room); - } - } - - private void recoverWaitingIfDead(GameRoom room) { - Optional roundOpt = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.WAITING)); - if (roundOpt.isEmpty()) return; - GameRound round = roundOpt.get(); - int count = participantRepository.findByRoundId(round.getId()).size(); - if (count < 2) return; - log.warn("Admin repair: room {} was WAITING with {} participants, starting countdown", room.getRoomNumber(), count); - startCountdown(room, round); - } - - private void recoverCountdownIfDead(GameRoom room) { - Instant now = Instant.now(); - if (room.getCountdownEndAt() == null) { - log.warn("Admin repair: room {} was COUNTDOWN with null countdownEndAt, setting to past", room.getRoomNumber()); - room.setCountdownEndAt(now.minusSeconds(1)); - gameRoomRepository.save(room); - return; - } - if (now.minusSeconds(REPAIR_COUNTDOWN_DEAD_GRACE_SECONDS).isBefore(room.getCountdownEndAt())) return; - log.warn("Admin repair: room {} was COUNTDOWN past end time, starting spin", room.getRoomNumber()); - startSpin(room); - } - - private void recoverSpinningIfDead(GameRoom room) { - Optional roundOpt = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.SPINNING)); - if (roundOpt.isEmpty()) return; - GameRound round = roundOpt.get(); - if (round.getCountdownEndedAt() == null) { - round.setCountdownEndedAt(Instant.now()); - gameRoundRepository.save(round); - } - Instant spinEnd = round.getCountdownEndedAt().plusMillis(SPIN_DURATION_MS); - if (Instant.now().isBefore(spinEnd)) return; - log.warn("Admin repair: room {} was SPINNING past spin end, resolving winner", room.getRoomNumber()); - try { - resolveWinner(room, round); - } catch (Exception e) { - log.error("Admin repair: failed to resolve winner for room {}", room.getRoomNumber(), e); - } - } - - private void recoverResolutionIfDead(GameRoom room) { - Optional roundOpt = getMostRecentActiveRound( - room.getId(), List.of(GamePhase.RESOLUTION)); - if (roundOpt.isEmpty()) return; - GameRound round = roundOpt.get(); - if (round.getResolvedAt() == null) return; - long secondsSince = Instant.now().getEpochSecond() - round.getResolvedAt().getEpochSecond(); - if (secondsSince < 4) return; - log.warn("Admin repair: room {} was RESOLUTION for {}s, resetting to WAITING", room.getRoomNumber(), secondsSince); - resetRoom(room); - GameRoomStateDto state = buildRoomState(room, null); - if (stateBroadcastCallback != null) { - stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); - } - } -} diff --git a/src/main/java/com/lottery/lottery/service/LotteryBotSchedulerService.java b/src/main/java/com/lottery/lottery/service/LotteryBotSchedulerService.java deleted file mode 100644 index 8adac50..0000000 --- a/src/main/java/com/lottery/lottery/service/LotteryBotSchedulerService.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.exception.BetDecisionException; -import com.lottery.lottery.exception.GameException; -import com.lottery.lottery.model.GamePhase; -import com.lottery.lottery.model.GameRound; -import com.lottery.lottery.model.GameRoundParticipant; -import com.lottery.lottery.model.GameRoom; -import com.lottery.lottery.model.LotteryBotConfig; -import com.lottery.lottery.repository.GameRoomRepository; -import com.lottery.lottery.repository.GameRoundParticipantRepository; -import com.lottery.lottery.repository.GameRoundRepository; -import com.lottery.lottery.repository.LotteryBotConfigRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.time.LocalTime; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Scheduler that registers lottery bots into joinable rounds based on - * lottery_bot_configs (time window, room flags, active). Joins only when: - * - round has no participants for at least 1 minute, or - * - round has exactly one participant who has been waiting longer than 10 seconds. - * Does not join when 2+ participants. Uses BetDecisionService (persona + streak) for bet amount. - * Does not use /remotebet. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class LotteryBotSchedulerService { - - private static final long TICKETS_TO_BIGINT = 1_000_000L; - /** Round empty (0 participants) for at least this long before a bot may join. */ - private static final long EMPTY_ROOM_THRESHOLD_SECONDS = 1L; - /** Single participant must be waiting at least this long before a bot may join. */ - private static final long ONE_PARTICIPANT_WAIT_THRESHOLD_SECONDS = 3L; - - private static final int BOT_HISTORY_SIZE = 10; - - private final GameRoomRepository gameRoomRepository; - private final GameRoundRepository gameRoundRepository; - private final GameRoundParticipantRepository participantRepository; - private final LotteryBotConfigRepository lotteryBotConfigRepository; - private final GameRoomService gameRoomService; - private final BetDecisionService betDecisionService; - private final BotBetHistoryService botBetHistoryService; - private final FeatureSwitchService featureSwitchService; - private final ConfigurationService configurationService; - - /** Per room: first time we observed no active round (for EMPTY_ROOM_THRESHOLD). Cleared when room has an active round again. */ - private final Map roomFirstSeenNoRound = new ConcurrentHashMap<>(); - - /** - * Every 15 seconds: for each room, if joinable and round state allows (0 participants >= 1 min, or 1 participant >= 10 sec), register eligible bots. - * Controlled by feature switch {@code lottery_bot_scheduler_enabled} (default on). - */ - @Scheduled(fixedDelayString = "${app.lottery-bot.schedule-fixed-delay-ms:5000}") - public void registerBotsForJoinableRooms() { - boolean featureOn = featureSwitchService.isLotteryBotSchedulerEnabled(); - List activeConfigs = lotteryBotConfigRepository.findAllByActiveTrue(); - - if (!featureOn) { - return; - } - if (activeConfigs.isEmpty()) { - return; - } - - LocalTime nowUtc = LocalTime.now(ZoneOffset.UTC); - - for (int roomNumber = 1; roomNumber <= 3; roomNumber++) { - Optional roomOpt = gameRoomRepository.findByRoomNumber(roomNumber); - if (roomOpt.isEmpty()) { - continue; - } - GameRoom room = roomOpt.get(); - if (room.getCurrentPhase() != GamePhase.WAITING && room.getCurrentPhase() != GamePhase.COUNTDOWN) { - continue; - } - - // 0 participants: no round exists yet (round is created on first join). Check no active round in WAITING/COUNTDOWN/SPINNING. - // 1 participant: we need an active round in WAITING phase only (not COUNTDOWN). - List roundsActive = gameRoundRepository.findMostRecentActiveRoundsByRoomId( - room.getId(), List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING), PageRequest.of(0, 1)); - - GameRound round = null; - List participants = List.of(); - int participantCount = 0; - boolean mayJoin = false; - - if (roundsActive.isEmpty()) { - // No active round → 0 participants. Enforce EMPTY_ROOM_THRESHOLD: only join after room has been empty for that long. - participantCount = 0; - Instant now = Instant.now(); - Instant firstSeenNoRound = roomFirstSeenNoRound.computeIfAbsent(roomNumber, k -> now); - mayJoin = !now.isBefore(firstSeenNoRound.plusSeconds(EMPTY_ROOM_THRESHOLD_SECONDS)); - if (!mayJoin) { - continue; - } - } else { - roomFirstSeenNoRound.remove(roomNumber); // room has an active round again, clear so next "no round" starts 30s from scratch - round = roundsActive.get(0); - participants = participantRepository.findByRoundId(round.getId()); - participantCount = participants.size(); - - int maxParticipantsBeforeBotJoin = configurationService.getMaxParticipantsBeforeBotJoin(); - if (participantCount > maxParticipantsBeforeBotJoin) { - continue; - } - - Instant now = Instant.now(); - if (participantCount == 0) { - mayJoin = round.getStartedAt() != null - && round.getStartedAt().plusSeconds(EMPTY_ROOM_THRESHOLD_SECONDS).isBefore(now); - } else { - // 1..N participants: only join if round is in WAITING (not COUNTDOWN) and oldest participant waited long enough - if (round.getPhase() != GamePhase.WAITING) { - continue; - } - Instant oldestJoined = participants.stream() - .map(GameRoundParticipant::getJoinedAt) - .min(Instant::compareTo) - .orElse(Instant.EPOCH); - mayJoin = oldestJoined.plusSeconds(ONE_PARTICIPANT_WAIT_THRESHOLD_SECONDS).isBefore(now); - } - if (!mayJoin) { - continue; - } - } - - // Shuffle so we don't always try the same bot first (e.g. by config id) - List configsToTry = new ArrayList<>(activeConfigs); - Collections.shuffle(configsToTry); - - for (LotteryBotConfig config : configsToTry) { - if (!isRoomEnabledForConfig(config, roomNumber)) { - continue; - } - if (!isCurrentTimeInWindow(nowUtc, config.getTimeUtcStart(), config.getTimeUtcEnd())) { - continue; - } - int userId = config.getUserId(); - if (gameRoomService.getCurrentUserBetInRoom(userId, roomNumber) > 0L) { - continue; - } - - GameRoomService.BetLimits limits = GameRoomService.getBetLimitsForRoom(roomNumber); - long roomMinTickets = limits.minBet() / TICKETS_TO_BIGINT; - long roomMaxTickets = limits.maxBet() / TICKETS_TO_BIGINT; - long botMinTickets = config.getBetMin() != null ? config.getBetMin() / TICKETS_TO_BIGINT : roomMinTickets; - long botMaxTickets = config.getBetMax() != null ? config.getBetMax() / TICKETS_TO_BIGINT : roomMaxTickets; - long currentRoundTotalBetTickets = participants.stream() - .mapToLong(p -> p.getBet() != null ? p.getBet() / TICKETS_TO_BIGINT : 0L) - .sum(); - var history = botBetHistoryService.getLastBetsAndResults(userId, BOT_HISTORY_SIZE); - - try { - long tickets = betDecisionService.decideBetAmountTickets( - BotBetContext.builder() - .roomNumber(roomNumber) - .roundId(round != null ? round.getId() : null) - .participantCount(participantCount) - .config(config) - .roomMinTickets(roomMinTickets) - .roomMaxTickets(roomMaxTickets) - .botMinTickets(botMinTickets) - .botMaxTickets(botMaxTickets) - .currentRoundTotalBetTickets(currentRoundTotalBetTickets) - .lastBets10(history.lastBets()) - .lastResults10(history.lastResults()) - .build()); - long betBigint = Math.max(1L, tickets) * TICKETS_TO_BIGINT; - betBigint = Math.max(limits.minBet(), Math.min(limits.maxBet(), betBigint)); - - gameRoomService.joinRoundWithResult(userId, roomNumber, betBigint, true); - log.info("Lottery bot registered: userId={}, room={}, betTickets={}", userId, roomNumber, tickets); - // Only one bot per room per run; next run will see updated participant count and enforce 7s wait for second bot - break; - } catch (BetDecisionException e) { - log.warn("Bot not registered (bet decision failed): userId={}, room={}, reason={}", userId, roomNumber, e.getMessage()); - } catch (GameException e) { - log.warn("Bot join skipped: userId={}, room={}, reason={}", userId, roomNumber, e.getUserMessage()); - } catch (Exception e) { - log.warn("Bot join failed: userId={}, room={}", userId, roomNumber, e); - } - } - } - } - - private static boolean isRoomEnabledForConfig(LotteryBotConfig config, int roomNumber) { - return switch (roomNumber) { - case 1 -> Boolean.TRUE.equals(config.getRoom1()); - case 2 -> Boolean.TRUE.equals(config.getRoom2()); - case 3 -> Boolean.TRUE.equals(config.getRoom3()); - default -> false; - }; - } - - /** - * Returns true if current UTC time is within [start, end]. - * Handles overnight window: e.g. start 22:00, end 06:00 → true if now >= 22:00 or now <= 06:00. - */ - private static boolean isCurrentTimeInWindow(LocalTime now, LocalTime start, LocalTime end) { - if (start == null || end == null) { - return false; - } - if (!start.isAfter(end)) { - return !now.isBefore(start) && !now.isAfter(end); - } - return !now.isBefore(start) || !now.isAfter(end); - } -} diff --git a/src/main/java/com/lottery/lottery/service/PaymentService.java b/src/main/java/com/lottery/lottery/service/PaymentService.java index cdfae4f..085ed3a 100644 --- a/src/main/java/com/lottery/lottery/service/PaymentService.java +++ b/src/main/java/com/lottery/lottery/service/PaymentService.java @@ -230,8 +230,6 @@ public class PaymentService { // Update deposit statistics userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount); userB.setDepositCount(userB.getDepositCount() + 1); - // Reset total winnings since last deposit (withdrawal limit is based on this) - userB.setTotalWinAfterDeposit(0L); userBRepository.save(userB); @@ -378,7 +376,6 @@ public class PaymentService { userB.setBalanceA(userB.getBalanceA() + ticketsAmount); userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount); userB.setDepositCount(userB.getDepositCount() + 1); - userB.setTotalWinAfterDeposit(0L); userBRepository.save(userB); try { diff --git a/src/main/java/com/lottery/lottery/service/PayoutService.java b/src/main/java/com/lottery/lottery/service/PayoutService.java index 239873a..2e4315f 100644 --- a/src/main/java/com/lottery/lottery/service/PayoutService.java +++ b/src/main/java/com/lottery/lottery/service/PayoutService.java @@ -135,13 +135,6 @@ public class PayoutService { throw new IllegalArgumentException(localizationService.getMessage("payout.error.invalidPayoutType")); } - // Withdrawal cannot exceed total winnings since last deposit - long maxWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; - if (payout.getTotal() > maxWinAfterDeposit) { - long maxTickets = maxWinAfterDeposit / 1_000_000L; - throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets))); - } - // Validate tickets amount and user balance validateTicketsAmount(userId, payout.getTotal()); @@ -176,12 +169,6 @@ public class PayoutService { validateTicketsAmount(userId, total); validateCryptoWithdrawalMaxTwoDecimals(total); - long maxWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; - if (total > maxWinAfterDeposit) { - long maxTickets = maxWinAfterDeposit / 1_000_000L; - throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets))); - } - if (payoutRepository.existsByUserIdAndStatus(userId, Payout.PayoutStatus.PROCESSING)) { throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalInProgress")); } @@ -199,11 +186,6 @@ public class PayoutService { throw new IllegalArgumentException(localizationService.getMessage("payout.error.insufficientBalanceDetailed", String.valueOf(userB.getBalanceA() / 1_000_000.0), String.valueOf(total / 1_000_000.0))); } - long maxWin = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; - if (total > maxWin) { - long maxTickets = maxWin / 1_000_000L; - throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets))); - } double amountUsd = total / 1_000_000_000.0; boolean noWithdrawalsYet = (userB.getWithdrawCount() != null ? userB.getWithdrawCount() : 0) == 0; @@ -514,7 +496,7 @@ public class PayoutService { } /** - * Applies balance and totalWinAfterDeposit deduction to an already-loaded (and locked) UserB. + * Applies balance deduction to an already-loaded (and locked) UserB. * Caller must hold a pessimistic lock on the UserB row (e.g. from findByIdForUpdate). */ private void applyDeductToUserB(UserB userB, Integer userId, Long total) { @@ -522,8 +504,6 @@ public class PayoutService { throw new IllegalStateException(localizationService.getMessage("payout.error.insufficientBalance")); } userB.setBalanceA(userB.getBalanceA() - total); - long currentWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; - userB.setTotalWinAfterDeposit(Math.max(0L, currentWinAfterDeposit - total)); userBRepository.save(userB); try { diff --git a/src/main/java/com/lottery/lottery/service/PersonaBetDecisionService.java b/src/main/java/com/lottery/lottery/service/PersonaBetDecisionService.java deleted file mode 100644 index 8012a6d..0000000 --- a/src/main/java/com/lottery/lottery/service/PersonaBetDecisionService.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.exception.BetDecisionException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.concurrent.ThreadLocalRandom; - -/** - * Bet decision using persona + loss streak and zone logic (no external API). - * Zones are defined as percentages of the room/bot range [min, max]; actual ticket - * bounds are computed from (minPct, maxPct) so the same behaviour applies for any range. - */ -@Slf4j -@Service -public class PersonaBetDecisionService implements BetDecisionService { - - private static final String LOSS = "L"; - - @Override - public long decideBetAmountTickets(BotBetContext context) { - long botMin = context.getBotMinTickets(); - long botMax = context.getBotMaxTickets(); - if (botMin <= 0 || botMax < botMin) { - throw new BetDecisionException("Invalid bot range [" + botMin + ", " + botMax + "]"); - } - - String persona = context.getConfig() != null && context.getConfig().getPersona() != null - ? context.getConfig().getPersona().trim().toLowerCase() : "balanced"; - List results = context.getLastResults10(); - if (results == null) results = List.of(); - - int streak = countConsecutiveLossesFromEnd(results); - long[] zone = getZoneTicketsForPersonaAndStreak(persona, streak, botMin, botMax); - long zoneMin = zone[0]; - long zoneMax = zone[1]; - - // Clamp zone to bot range - long clampedMin = Math.max(zoneMin, botMin); - long clampedMax = Math.min(zoneMax, botMax); - if (clampedMin > clampedMax) { - clampedMin = botMin; - clampedMax = botMax; - } - - // Pick random in [clampedMin, clampedMax] for variety - long rawBet = clampedMin == clampedMax ? clampedMin - : clampedMin + ThreadLocalRandom.current().nextLong(clampedMax - clampedMin + 1); - - long step = getStep(botMin); - long bet = roundToStep(rawBet, botMin, botMax, step); - - log.debug("Persona bet decision: persona={}, streak={}, zone=[{}, {}] -> {} tickets", persona, streak, zoneMin, zoneMax, bet); - return bet; - } - - /** Step 1 if min < 10, step 10 if 10 <= min < 1000, step 100 if min >= 1000. */ - private static long getStep(long botMin) { - if (botMin >= 1000) return 100; - if (botMin >= 10) return 10; - return 1; - } - - /** Count consecutive L from the end of last results (oldest → newest). */ - private static int countConsecutiveLossesFromEnd(List results) { - int count = 0; - for (int i = results.size() - 1; i >= 0; i--) { - if (LOSS.equals(results.get(i))) count++; - else break; - } - return count; - } - - /** - * Zone rule: when loss streak >= streakThreshold, use [minPct, maxPct] of range (0 = min, 100 = max). - * Rules are evaluated in descending streak order (highest threshold first). - */ - private static final class ZoneRule { - final int streakThreshold; - final double minPct; - final double maxPct; - - ZoneRule(int streakThreshold, double minPct, double maxPct) { - this.streakThreshold = streakThreshold; - this.minPct = minPct; - this.maxPct = maxPct; - } - } - - // Conservative: usually 1–10%, streak 5 → 19–24%, streak 7 → 39–50% - private static final List CONSERVATIVE_RULES = List.of( - new ZoneRule(7, 45, 68), - new ZoneRule(5, 17, 28), - new ZoneRule(0, 1, 10) - ); - // Balanced: usually 5–15%, streak 3 → 29–39%, streak 5 → 59–69% - private static final List BALANCED_RULES = List.of( - new ZoneRule(5, 64, 79), - new ZoneRule(3, 25, 37), - new ZoneRule(0, 2, 12) - ); - // Aggressive: usually 15–25%, streak 2 → 39–50%, streak 3 → 79–100% - private static final List AGGRESSIVE_RULES = List.of( - new ZoneRule(3, 79, 100), - new ZoneRule(2, 33, 42), - new ZoneRule(0, 5, 15) - ); - - /** Returns [zoneMinTickets, zoneMaxTickets] from percentage-of-range rules for persona and streak. */ - private static long[] getZoneTicketsForPersonaAndStreak(String persona, int streak, long rangeMin, long rangeMax) { - List rules = switch (persona) { - case "conservative" -> CONSERVATIVE_RULES; - case "aggressive" -> AGGRESSIVE_RULES; - default -> BALANCED_RULES; - }; - ZoneRule rule = rules.stream() - .filter(r -> streak >= r.streakThreshold) - .findFirst() - .orElse(rules.get(rules.size() - 1)); - long range = rangeMax - rangeMin; - long zoneMin = rangeMin + Math.round(range * rule.minPct / 100.0); - long zoneMax = rangeMin + Math.round(range * rule.maxPct / 100.0); - zoneMin = Math.max(rangeMin, Math.min(zoneMin, rangeMax)); - zoneMax = Math.max(rangeMin, Math.min(zoneMax, rangeMax)); - if (zoneMin > zoneMax) zoneMin = zoneMax; - return new long[]{zoneMin, zoneMax}; - } - - /** Round value to nearest valid step in [min, max]. */ - private static long roundToStep(long value, long min, long max, long step) { - long offset = value - min; - long steps = Math.round((double) offset / step); - long rounded = min + steps * step; - return Math.max(min, Math.min(max, rounded)); - } -} diff --git a/src/main/java/com/lottery/lottery/service/ReferralCommissionService.java b/src/main/java/com/lottery/lottery/service/ReferralCommissionService.java deleted file mode 100644 index c66f06b..0000000 --- a/src/main/java/com/lottery/lottery/service/ReferralCommissionService.java +++ /dev/null @@ -1,289 +0,0 @@ -package com.lottery.lottery.service; - -import com.lottery.lottery.model.Transaction; -import com.lottery.lottery.model.UserB; -import com.lottery.lottery.model.UserD; -import com.lottery.lottery.repository.TransactionRepository; -import com.lottery.lottery.repository.UserBRepository; -import com.lottery.lottery.repository.UserDRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.HashSet; -import java.util.Set; - -/** - * Service for handling referral commission logic. - * Processes commissions for referers when their referrals win or lose game rounds. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class ReferralCommissionService { - - private final UserDRepository userDRepository; - private final UserBRepository userBRepository; - private final TransactionRepository transactionRepository; - - // Commission rates (as percentages) - private static final double WIN_PROFIT_COMMISSION_RATE = 0.01; // 1% of net profit for winners (all levels) - // Loss commission rates per referrer level - private static final double LOSS_COMMISSION_RATE_LEVEL1 = 0.04; // 4% of loss for referrer 1 - private static final double LOSS_COMMISSION_RATE_LEVEL2 = 0.02; // 2% of loss for referrer 2 - private static final double LOSS_COMMISSION_RATE_LEVEL3 = 0.01; // 1% of loss for referrer 3 - - /** - * Processes referral commissions for a user who won a round. - * - * @param userId The user who won - * @param userBet The user's bet amount (in bigint format) - * @param totalBet The total bet of the round (in bigint format) - * @param houseCommission The house commission amount (20% of totalBet - userBet, in bigint format) - * @return Set of referer user IDs who received commissions (for balance update notifications) - */ - @Transactional - public Set processWinnerCommissions(Integer userId, Long userBet, Long totalBet, Long houseCommission) { - Set refererIds = new HashSet<>(); - // Calculate user's net profit: (totalBet - houseCommission) - userBet - // This is the actual profit after the house takes its 20% commission - long userProfit = totalBet - houseCommission - userBet; - - if (userProfit <= 0) { - log.debug("No profit to distribute for winner userId={}, userProfit={}", userId, userProfit); - return refererIds; - } - - // Calculate referral commission amount: 1% of user's net profit - long commissionAmount = (long) (userProfit * WIN_PROFIT_COMMISSION_RATE); - - if (commissionAmount <= 0) { - log.debug("Commission amount too small for winner userId={}, commissionAmount={}", userId, commissionAmount); - return refererIds; - } - - log.info("Processing winner commissions: userId={}, userProfit={}, houseCommission={}, commissionAmount={}", - userId, userProfit, houseCommission, commissionAmount); - - // Get user's referral chain - UserD userD = userDRepository.findById(userId).orElse(null); - if (userD == null) { - log.warn("UserD not found for userId={}, skipping referral commissions", userId); - return refererIds; - } - - // Process commissions for referer_1, referer_2, referer_3 - Integer referer1 = processRefererCommission(userD.getRefererId1(), commissionAmount, 1, userId, true); - if (referer1 != null) refererIds.add(referer1); - - Integer referer2 = processRefererCommission(userD.getRefererId2(), commissionAmount, 2, userId, true); - if (referer2 != null) refererIds.add(referer2); - - Integer referer3 = processRefererCommission(userD.getRefererId3(), commissionAmount, 3, userId, true); - if (referer3 != null) refererIds.add(referer3); - - return refererIds; - } - - /** - * Processes referral commissions for a user who lost a round. - * - * @param userId The user who lost - * @param userBet The user's bet amount (in bigint format) - * @return Set of referer user IDs who received commissions (for balance update notifications) - */ - @Transactional - public Set processLoserCommissions(Integer userId, Long userBet) { - Set refererIds = new HashSet<>(); - if (userBet <= 0) { - log.debug("No bet to process commissions for loser userId={}, userBet={}", userId, userBet); - return refererIds; - } - - log.info("Processing loser commissions: userId={}, userBet={}, rates: level1={}%, level2={}%, level3={}%", - userId, userBet, LOSS_COMMISSION_RATE_LEVEL1 * 100, LOSS_COMMISSION_RATE_LEVEL2 * 100, LOSS_COMMISSION_RATE_LEVEL3 * 100); - - // Get user's referral chain - UserD userD = userDRepository.findById(userId).orElse(null); - if (userD == null) { - log.warn("UserD not found for userId={}, skipping referral commissions", userId); - return refererIds; - } - - // Process commissions for referer_1, referer_2, referer_3 with different rates - // Referrer 1: 4% of user's bet - long commissionAmount1 = (long) (userBet * LOSS_COMMISSION_RATE_LEVEL1); - if (commissionAmount1 > 0) { - Integer referer1 = processRefererCommission(userD.getRefererId1(), commissionAmount1, 1, userId, false); - if (referer1 != null) refererIds.add(referer1); - } - - // Referrer 2: 2% of user's bet - long commissionAmount2 = (long) (userBet * LOSS_COMMISSION_RATE_LEVEL2); - if (commissionAmount2 > 0) { - Integer referer2 = processRefererCommission(userD.getRefererId2(), commissionAmount2, 2, userId, false); - if (referer2 != null) refererIds.add(referer2); - } - - // Referrer 3: 1% of user's bet - long commissionAmount3 = (long) (userBet * LOSS_COMMISSION_RATE_LEVEL3); - if (commissionAmount3 > 0) { - Integer referer3 = processRefererCommission(userD.getRefererId3(), commissionAmount3, 3, userId, false); - if (referer3 != null) refererIds.add(referer3); - } - - return refererIds; - } - - /** - * Processes commission for a single referer. - * - * @param refererId The referer's user ID (0 if no referer) - * @param commissionAmount The commission amount to award (in bigint format) - * @param refererLevel The referer level (1, 2, or 3) - * @param userId The user who triggered the commission - * @param isWinner Whether the user won (true) or lost (false) - * @return The referer ID if commission was processed, null otherwise - */ - private Integer processRefererCommission(Integer refererId, Long commissionAmount, int refererLevel, - Integer userId, boolean isWinner) { - // Skip if no referer - if (refererId == null || refererId <= 0) { - return null; - } - - try { - // Get referer's UserB (balance) and UserD (referral stats) - UserB refererBalance = userBRepository.findById(refererId).orElse(null); - UserD refererD = userDRepository.findById(refererId).orElse(null); - - if (refererBalance == null || refererD == null) { - log.warn("Referer not found: refererId={}, refererLevel={}, userId={}", - refererId, refererLevel, userId); - return null; - } - - // Credit referer's balance - refererBalance.setBalanceA(refererBalance.getBalanceA() + commissionAmount); - userBRepository.save(refererBalance); - - // Update referer's from_referals_X (they earned from their referral) - switch (refererLevel) { - case 1: - refererD.setFromReferals1(refererD.getFromReferals1() + commissionAmount); - break; - case 2: - refererD.setFromReferals2(refererD.getFromReferals2() + commissionAmount); - break; - case 3: - refererD.setFromReferals3(refererD.getFromReferals3() + commissionAmount); - break; - } - userDRepository.save(refererD); - - // Update user's to_referer_X (they paid commission to their referer) - UserD userD = userDRepository.findById(userId).orElse(null); - if (userD != null) { - switch (refererLevel) { - case 1: - userD.setToReferer1(userD.getToReferer1() + commissionAmount); - break; - case 2: - userD.setToReferer2(userD.getToReferer2() + commissionAmount); - break; - case 3: - userD.setToReferer3(userD.getToReferer3() + commissionAmount); - break; - } - userDRepository.save(userD); - } - - log.info("Commission processed: refererId={}, refererLevel={}, userId={}, " + - "commissionAmount={}, isWinner={}", - refererId, refererLevel, userId, commissionAmount, isWinner); - - return refererId; - - } catch (Exception e) { - log.error("Error processing commission for refererId={}, refererLevel={}, userId={}", - refererId, refererLevel, userId, e); - // Continue processing other referers even if one fails - return null; - } - } - - /** - * Checks if the next bet will be the user's 3rd bet. - * - * @param userId The user who is about to place a bet - * @return true if this will be the 3rd bet, false otherwise - */ - public boolean willBeThirdBet(Integer userId) { - try { - // Get user's rounds_played count from db_users_b - UserB userB = userBRepository.findById(userId).orElse(null); - if (userB == null) { - log.warn("UserB not found for userId={}", userId); - return false; - } - // If current rounds_played is 2, the next round will be the 3rd - return userB.getRoundsPlayed() == 2; - } catch (Exception e) { - log.error("Error checking rounds_played for userId={}", userId, e); - return false; - } - } - - /** - * Gives a one-time bonus of 1 ticket to referrer 1 when user places their 3rd bet. - * - * @param userId The user who placed their 3rd bet - * @return The referrer 1 user ID if bonus was given, null otherwise - */ - @Transactional - public Integer giveThirdBetBonus(Integer userId) { - try { - // Get user's referral chain - UserD userD = userDRepository.findById(userId).orElse(null); - if (userD == null || userD.getRefererId1() == null || userD.getRefererId1() <= 0) { - log.debug("No referrer 1 for userId={}, skipping 3rd bet bonus", userId); - return null; - } - - Integer referer1Id = userD.getRefererId1(); - - // Get referrer's balance and referral stats - UserB refererBalance = userBRepository.findById(referer1Id).orElse(null); - UserD refererD = userDRepository.findById(referer1Id).orElse(null); - - if (refererBalance == null || refererD == null) { - log.warn("Referrer 1 not found: refererId={}, userId={}", referer1Id, userId); - return null; - } - - // Give 1 ticket bonus (1,000,000 in bigint format) - long bonusAmount = 1_000_000L; - refererBalance.setBalanceA(refererBalance.getBalanceA() + bonusAmount); - userBRepository.save(refererBalance); - - // Update referrer's from_referals_1 (they earned from their referral) - refererD.setFromReferals1(refererD.getFromReferals1() + bonusAmount); - userDRepository.save(refererD); - - // Update user's to_referer_1 (they gave bonus to their referrer) - userD.setToReferer1(userD.getToReferer1() + bonusAmount); - userDRepository.save(userD); - - log.info("3rd bet bonus given: userId={}, referer1Id={}, bonusAmount={}", - userId, referer1Id, bonusAmount); - - return referer1Id; - } catch (Exception e) { - log.error("Error giving 3rd bet bonus for userId={}", userId, e); - // Don't throw - this is a bonus, shouldn't break the main flow - return null; - } - } -} - diff --git a/src/main/java/com/lottery/lottery/service/RoomConnectionService.java b/src/main/java/com/lottery/lottery/service/RoomConnectionService.java deleted file mode 100644 index c816f42..0000000 --- a/src/main/java/com/lottery/lottery/service/RoomConnectionService.java +++ /dev/null @@ -1,309 +0,0 @@ -package com.lottery.lottery.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.function.BiConsumer; - -/** - * Tracks room-level WebSocket connections. - * Tracks which users are connected to which rooms, regardless of round participation. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class RoomConnectionService { - - // Callback to notify when room connections change (set by GameRoomService) - private BiConsumer connectionChangeCallback; - - // Track room connections: roomNumber -> userId -> Set of sessionIds - // This allows tracking multiple sessions per user (e.g., web + iOS) - private final Map>> roomConnections = new ConcurrentHashMap<>(); - - // Track session to user mapping: sessionId -> userId (for disconnect events when principal is lost) - private final Map sessionToUser = new ConcurrentHashMap<>(); - - /** - * Sets callback to be notified when room connections change. - * Called by GameRoomService during initialization. - */ - public void setConnectionChangeCallback(BiConsumer callback) { - this.connectionChangeCallback = callback; - } - - /** - * Registers a session-to-user mapping. - * Called when user connects to track sessions for disconnect events. - */ - public void registerSession(String sessionId, Integer userId) { - sessionToUser.put(sessionId, userId); - log.debug("Registered session {} for user {}", sessionId, userId); - } - - /** - * Removes a session-to-user mapping. - * Called when user disconnects. - */ - public Integer removeSession(String sessionId) { - Integer userId = sessionToUser.remove(sessionId); - if (userId != null) { - log.debug("Removed session {} for user {}", sessionId, userId); - } - return userId; - } - - /** - * Adds a user to a room's connection list. - * Called when user subscribes to room topic. - * - * @param userId The user ID - * @param roomNumber The room number - * @param sessionId The WebSocket session ID - */ - public void addUserToRoom(Integer userId, Integer roomNumber, String sessionId) { - if (userId == null || roomNumber == null || sessionId == null) { - log.warn("Attempted to add user to room with null parameters: userId={}, roomNumber={}, sessionId={}", - userId, roomNumber, sessionId); - return; - } - - // Get or create the map of users for this room - Map> roomUsers = roomConnections.computeIfAbsent(roomNumber, k -> new ConcurrentHashMap<>()); - - // Get or create the set of sessions for this user in this room - Set userSessions = roomUsers.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()); - - // Add the session - boolean isNewUser = userSessions.isEmpty(); - userSessions.add(sessionId); - - int connectedCount = getConnectedUsersCount(roomNumber); - - if (isNewUser) { - log.debug("User {} connected to room {} (session: {}). Total connected users: {}", - userId, roomNumber, sessionId, connectedCount); - } else { - log.debug("User {} added additional session to room {} (session: {}). Total sessions for user: {}, Total connected users: {}", - userId, roomNumber, sessionId, userSessions.size(), connectedCount); - } - - // Notify callback to broadcast updated state (only if this is a new user, not just a new session) - if (connectionChangeCallback != null && isNewUser) { - connectionChangeCallback.accept(roomNumber, connectedCount); - } - } - - /** - * Legacy method for backward compatibility. Uses sessionId from sessionToUser mapping. - * @deprecated Use addUserToRoom(userId, roomNumber, sessionId) instead - */ - @Deprecated - public void addUserToRoom(Integer userId, Integer roomNumber) { - // Try to find a session for this user (not ideal, but for backward compatibility) - String sessionId = sessionToUser.entrySet().stream() - .filter(entry -> entry.getValue().equals(userId)) - .map(Map.Entry::getKey) - .findFirst() - .orElse("legacy-" + userId + "-" + System.currentTimeMillis()); - - addUserToRoom(userId, roomNumber, sessionId); - } - - /** - * Removes a user's session from a room's connection list. - * Only removes the user from the room if this is their last session. - * Called when user disconnects or unsubscribes. - * - * @param userId The user ID - * @param roomNumber The room number - * @param sessionId The WebSocket session ID - */ - public void removeUserFromRoom(Integer userId, Integer roomNumber, String sessionId) { - if (userId == null || roomNumber == null || sessionId == null) { - log.warn("Attempted to remove user from room with null parameters: userId={}, roomNumber={}, sessionId={}", - userId, roomNumber, sessionId); - return; - } - - Map> roomUsers = roomConnections.get(roomNumber); - if (roomUsers == null) { - return; - } - - Set userSessions = roomUsers.get(userId); - if (userSessions == null) { - return; - } - - // Remove the session - boolean removed = userSessions.remove(sessionId); - if (!removed) { - log.debug("Session {} not found for user {} in room {}", sessionId, userId, roomNumber); - return; - } - - // Check if this was the last session for this user in this room - boolean wasLastSession = userSessions.isEmpty(); - - if (wasLastSession) { - // Remove the user from the room - roomUsers.remove(userId); - log.debug("User {} disconnected from room {} (last session: {}). Total connected users: {}", - userId, roomNumber, sessionId, getConnectedUsersCount(roomNumber)); - } else { - log.debug("User {} removed session from room {} (session: {}). Remaining sessions: {}, Total connected users: {}", - userId, roomNumber, sessionId, userSessions.size(), getConnectedUsersCount(roomNumber)); - } - - // Clean up empty room - if (roomUsers.isEmpty()) { - roomConnections.remove(roomNumber); - } - - int connectedCount = getConnectedUsersCount(roomNumber); - - // Notify callback to broadcast updated state (only if user was actually removed) - if (connectionChangeCallback != null && wasLastSession) { - connectionChangeCallback.accept(roomNumber, connectedCount); - } - } - - /** - * Legacy method for backward compatibility. Removes all sessions for the user. - * @deprecated Use removeUserFromRoom(userId, roomNumber, sessionId) instead - */ - @Deprecated - public void removeUserFromRoom(Integer userId, Integer roomNumber) { - // Remove all sessions for this user in this room - Map> roomUsers = roomConnections.get(roomNumber); - if (roomUsers == null) { - return; - } - - Set userSessions = roomUsers.get(userId); - if (userSessions == null || userSessions.isEmpty()) { - return; - } - - // Remove all sessions (create a copy to avoid concurrent modification) - Set sessionsToRemove = new java.util.HashSet<>(userSessions); - for (String sessionId : sessionsToRemove) { - removeUserFromRoom(userId, roomNumber, sessionId); - } - } - - /** - * Removes a specific session from all rooms. - * Only removes the user from a room if this is their last session in that room. - * Called when a session disconnects completely. - * - * @param userId The user ID - * @param sessionId The WebSocket session ID - */ - public void removeUserFromAllRooms(Integer userId, String sessionId) { - if (userId == null || sessionId == null) { - log.warn("Attempted to remove user from all rooms with null parameters: userId={}, sessionId={}", - userId, sessionId); - return; - } - - // Iterate through all rooms and remove this session - roomConnections.forEach((roomNumber, roomUsers) -> { - Set userSessions = roomUsers.get(userId); - if (userSessions != null && userSessions.contains(sessionId)) { - // Use the existing method which handles the logic correctly - removeUserFromRoom(userId, roomNumber, sessionId); - } - }); - } - - /** - * Legacy method that removes all sessions for a user from all rooms. - * @deprecated Use removeUserFromAllRooms(userId, sessionId) instead - */ - @Deprecated - public void removeUserFromAllRooms(Integer userId) { - if (userId == null) { - log.warn("Attempted to remove null user from all rooms"); - return; - } - - // Find all sessions for this user and remove them - roomConnections.forEach((roomNumber, roomUsers) -> { - Set userSessions = roomUsers.get(userId); - if (userSessions != null && !userSessions.isEmpty()) { - // Remove all sessions (create a copy to avoid concurrent modification) - Set sessionsToRemove = new java.util.HashSet<>(userSessions); - for (String sessionId : sessionsToRemove) { - removeUserFromRoom(userId, roomNumber, sessionId); - } - } - }); - } - - /** - * Removes a user from all rooms by session ID. - * Used when principal is lost during disconnect. - * - * @param sessionId The WebSocket session ID - */ - public void removeUserFromAllRoomsBySession(String sessionId) { - if (sessionId == null) { - log.warn("Attempted to remove user from all rooms with null sessionId"); - return; - } - - Integer userId = sessionToUser.get(sessionId); - if (userId != null) { - // Remove this specific session from all rooms - removeUserFromAllRooms(userId, sessionId); - // Also remove session mapping - removeSession(sessionId); - } else { - log.warn("Session {} not found in session-to-user mapping", sessionId); - } - } - - /** - * Gets the count of connected users in a room. - * Counts unique users, not sessions (a user with multiple sessions counts as 1). - */ - public int getConnectedUsersCount(Integer roomNumber) { - Map> roomUsers = roomConnections.get(roomNumber); - return roomUsers != null ? roomUsers.size() : 0; - } - - /** - * Checks if a user is connected to a room. - * Returns true if the user has at least one active session in the room. - */ - public boolean isUserConnectedToRoom(Integer userId, Integer roomNumber) { - Map> roomUsers = roomConnections.get(roomNumber); - if (roomUsers == null) { - return false; - } - Set userSessions = roomUsers.get(userId); - return userSessions != null && !userSessions.isEmpty(); - } - - /** - * Gets the list of user IDs currently connected (viewing) a room. - * Used by admin room management. - */ - public List getConnectedUserIds(Integer roomNumber) { - Map> roomUsers = roomConnections.get(roomNumber); - if (roomUsers == null || roomUsers.isEmpty()) { - return Collections.emptyList(); - } - return roomUsers.keySet().stream().sorted().collect(Collectors.toList()); - } -} - diff --git a/src/main/java/com/lottery/lottery/service/TaskService.java b/src/main/java/com/lottery/lottery/service/TaskService.java index da49d66..e93f3eb 100644 --- a/src/main/java/com/lottery/lottery/service/TaskService.java +++ b/src/main/java/com/lottery/lottery/service/TaskService.java @@ -9,13 +9,11 @@ import com.lottery.lottery.model.UserA; import com.lottery.lottery.model.UserB; import com.lottery.lottery.model.UserD; import com.lottery.lottery.model.UserTaskClaim; -import com.lottery.lottery.model.UserDailyBonusClaim; import com.lottery.lottery.repository.TaskRepository; import com.lottery.lottery.repository.UserARepository; import com.lottery.lottery.repository.UserBRepository; import com.lottery.lottery.repository.UserDRepository; import com.lottery.lottery.repository.UserTaskClaimRepository; -import com.lottery.lottery.repository.UserDailyBonusClaimRepository; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,7 +33,6 @@ public class TaskService { private final TaskRepository taskRepository; private final UserTaskClaimRepository userTaskClaimRepository; - private final UserDailyBonusClaimRepository userDailyBonusClaimRepository; private final UserDRepository userDRepository; private final UserBRepository userBRepository; private final UserARepository userARepository; @@ -76,6 +73,7 @@ public class TaskService { final List finalClaimedTaskIds = claimedTaskIds; return tasks.stream() + .filter(task -> !"daily".equals(task.getType())) .filter(task -> !finalClaimedTaskIds.contains(task.getId())) .filter(task -> isReferralTaskEnabled(task)) .map(task -> { @@ -219,15 +217,19 @@ public class TaskService { Task task = taskOpt.get(); + // Daily bonus removed - reject daily task claims + if ("daily".equals(task.getType())) { + return false; + } + // Reject claim if this referral task (50 or 100 friends) is temporarily disabled if (!isReferralTaskEnabled(task)) { log.debug("Task disabled by feature switch: taskId={}, type={}, requirement={}", taskId, task.getType(), task.getRequirement()); return false; } - // For non-daily tasks, check if already claimed FIRST to prevent abuse - // This prevents users from claiming rewards multiple times by leaving/rejoining channels - if (!"daily".equals(task.getType()) && userTaskClaimRepository.existsByUserIdAndTaskId(userId, taskId)) { + // Check if already claimed to prevent abuse + if (userTaskClaimRepository.existsByUserIdAndTaskId(userId, taskId)) { log.debug("Task already claimed: userId={}, taskId={}", userId, taskId); return false; } @@ -239,44 +241,18 @@ public class TaskService { return false; } - // For daily tasks, save to user_daily_bonus_claims table with user info - if ("daily".equals(task.getType())) { - // Get user data for the claim record - Optional userOpt = userARepository.findById(userId); - String avatarUrl = null; - String screenName = "-"; - if (userOpt.isPresent()) { - UserA user = userOpt.get(); - avatarUrl = user.getAvatarUrl(); - screenName = user.getScreenName() != null ? user.getScreenName() : "-"; - } - - // Save to user_daily_bonus_claims table - UserDailyBonusClaim dailyClaim = UserDailyBonusClaim.builder() - .userId(userId) - .avatarUrl(avatarUrl) - .screenName(screenName) - .build(); - userDailyBonusClaimRepository.save(dailyClaim); - } else { - // For non-daily tasks, save to user_task_claims table - UserTaskClaim claim = UserTaskClaim.builder() - .userId(userId) - .taskId(taskId) - .build(); - userTaskClaimRepository.save(claim); - } + // Save to user_task_claims table + UserTaskClaim claim = UserTaskClaim.builder() + .userId(userId) + .taskId(taskId) + .build(); + userTaskClaimRepository.save(claim); // Give reward (rewardAmount is already in bigint format) giveReward(userId, task.getRewardAmount()); - // Create transaction - use DAILY_BONUS for daily tasks, TASK_BONUS for others try { - if ("daily".equals(task.getType())) { - transactionService.createDailyBonusTransaction(userId, task.getRewardAmount()); - } else { - transactionService.createTaskBonusTransaction(userId, task.getRewardAmount(), taskId); - } + transactionService.createTaskBonusTransaction(userId, task.getRewardAmount(), taskId); } catch (Exception e) { log.error("Error creating transaction: userId={}, taskId={}, type={}", userId, taskId, task.getType(), e); // Continue even if transaction record creation fails @@ -343,21 +319,8 @@ public class TaskService { } if ("daily".equals(task.getType())) { - // For daily bonus, check if 24 hours have passed since last claim - // Use user_daily_bonus_claims table instead of user_task_claims - Optional claimOpt = userDailyBonusClaimRepository.findFirstByUserIdOrderByClaimedAtDesc(userId); - if (claimOpt.isEmpty()) { - // Never claimed, so it's available - return true; - } - - UserDailyBonusClaim claim = claimOpt.get(); - LocalDateTime claimedAt = claim.getClaimedAt(); - LocalDateTime now = LocalDateTime.now(); - long hoursSinceClaim = java.time.Duration.between(claimedAt, now).toHours(); - - // Available if 24 hours or more have passed - return hoursSinceClaim >= 24; + // Daily bonus removed - never completed + return false; } return false; @@ -367,58 +330,14 @@ public class TaskService { * Gets daily bonus status for a user. * Returns availability status and cooldown time if on cooldown. */ + /** Daily bonus removed - always returns unavailable. */ public com.lottery.lottery.dto.DailyBonusStatusDto getDailyBonusStatus(Integer userId) { - // Find daily bonus task - List dailyTasks = taskRepository.findByTypeOrderByDisplayOrderAsc("daily"); - if (dailyTasks.isEmpty()) { - log.warn("Daily bonus task not found"); - return com.lottery.lottery.dto.DailyBonusStatusDto.builder() - .available(false) - .cooldownSeconds(0L) - .rewardAmount(0L) - .build(); - } - - Task dailyTask = dailyTasks.get(0); - - // Check if user has claimed before using user_daily_bonus_claims table - Optional claimOpt = userDailyBonusClaimRepository.findFirstByUserIdOrderByClaimedAtDesc(userId); - - if (claimOpt.isEmpty()) { - // Never claimed, so it's available - return com.lottery.lottery.dto.DailyBonusStatusDto.builder() - .taskId(dailyTask.getId()) - .available(true) - .cooldownSeconds(null) - .rewardAmount(dailyTask.getRewardAmount()) - .build(); - } - - // Check cooldown - UserDailyBonusClaim claim = claimOpt.get(); - LocalDateTime claimedAt = claim.getClaimedAt(); - LocalDateTime now = LocalDateTime.now(); - long secondsSinceClaim = java.time.Duration.between(claimedAt, now).getSeconds(); - long hoursSinceClaim = secondsSinceClaim / 3600; - - if (hoursSinceClaim >= 24) { - // Cooldown expired, available - return com.lottery.lottery.dto.DailyBonusStatusDto.builder() - .taskId(dailyTask.getId()) - .available(true) - .cooldownSeconds(null) - .rewardAmount(dailyTask.getRewardAmount()) - .build(); - } else { - // Still on cooldown - long secondsUntilAvailable = (24 * 3600) - secondsSinceClaim; - return com.lottery.lottery.dto.DailyBonusStatusDto.builder() - .taskId(dailyTask.getId()) - .available(false) - .cooldownSeconds(secondsUntilAvailable) - .rewardAmount(dailyTask.getRewardAmount()) - .build(); - } + return com.lottery.lottery.dto.DailyBonusStatusDto.builder() + .taskId(null) + .available(false) + .cooldownSeconds(null) + .rewardAmount(0L) + .build(); } /** @@ -430,47 +349,9 @@ public class TaskService { * @param languageCode User's language code for localization (e.g., "EN", "RU") * @return List of RecentBonusClaimDto with avatar URL, screen name, and formatted claim timestamp */ + /** Daily bonus removed - always returns empty list. */ public List getRecentDailyBonusClaims(String timezone, String languageCode) { - // Get recent claims - simple query, no JOINs needed - List claims = userDailyBonusClaimRepository.findTop50ByOrderByClaimedAtDesc(); - - // Determine timezone to use - java.time.ZoneId zoneId; - try { - zoneId = (timezone != null && !timezone.trim().isEmpty()) - ? java.time.ZoneId.of(timezone) - : java.time.ZoneId.of("UTC"); - } catch (Exception e) { - // Invalid timezone, fallback to UTC - zoneId = java.time.ZoneId.of("UTC"); - } - - // Get localized "at" word - String atWord = localizationService.getMessage("dateTime.at", languageCode); - if (atWord == null || atWord.isEmpty()) { - atWord = "at"; // Fallback to English - } - - // Create formatter with localized "at" word - final java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM '" + atWord + "' HH:mm") - .withZone(zoneId); - - // Convert to DTOs with formatted date - return claims.stream() - .map(claim -> { - // Convert LocalDateTime to Instant (assuming it's stored in UTC) - // LocalDateTime doesn't have timezone info, so we treat it as UTC - java.time.Instant instant = claim.getClaimedAt().atZone(java.time.ZoneId.of("UTC")).toInstant(); - String formattedDate = formatter.format(instant); - - return RecentBonusClaimDto.builder() - .avatarUrl(claim.getAvatarUrl()) - .screenName(claim.getScreenName()) - .claimedAt(claim.getClaimedAt()) - .date(formattedDate) - .build(); - }) - .collect(Collectors.toList()); + return List.of(); } /** diff --git a/src/main/java/com/lottery/lottery/service/TransactionService.java b/src/main/java/com/lottery/lottery/service/TransactionService.java index b291e0d..37b6a10 100644 --- a/src/main/java/com/lottery/lottery/service/TransactionService.java +++ b/src/main/java/com/lottery/lottery/service/TransactionService.java @@ -63,44 +63,6 @@ public class TransactionService { log.debug("Created withdrawal transaction: userId={}, amount={}", userId, amount); } - /** - * Creates a win transaction. - * - * @param userId User ID - * @param amount Amount in bigint format (positive, total payout) - * @param roundId Round ID - */ - @Transactional - public void createWinTransaction(Integer userId, Long amount, Long roundId) { - Transaction transaction = Transaction.builder() - .userId(userId) - .amount(amount) - .type(Transaction.TransactionType.WIN) - .roundId(roundId) - .build(); - transactionRepository.save(transaction); - log.debug("Created win transaction: userId={}, amount={}, roundId={}", userId, amount, roundId); - } - - /** - * Creates a bet transaction. - * - * @param userId User ID - * @param amount Amount in bigint format (positive, will be stored as negative) - * @param roundId Round ID - */ - @Transactional - public void createBetTransaction(Integer userId, Long amount, Long roundId) { - Transaction transaction = Transaction.builder() - .userId(userId) - .amount(-amount) // Store as negative - .type(Transaction.TransactionType.BET) - .roundId(roundId) - .build(); - transactionRepository.save(transaction); - log.debug("Created bet transaction: userId={}, amount={}, roundId={}", userId, amount, roundId); - } - /** * Creates a task bonus transaction. * @@ -120,24 +82,6 @@ public class TransactionService { log.debug("Created task bonus transaction: userId={}, amount={}, taskId={}", userId, amount, taskId); } - /** - * Creates a daily bonus transaction. - * - * @param userId User ID - * @param amount Amount in bigint format (positive) - */ - @Transactional - public void createDailyBonusTransaction(Integer userId, Long amount) { - Transaction transaction = Transaction.builder() - .userId(userId) - .amount(amount) - .type(Transaction.TransactionType.DAILY_BONUS) - .taskId(null) // Daily bonus doesn't have taskId - .build(); - transactionRepository.save(transaction); - log.debug("Created daily bonus transaction: userId={}, amount={}", userId, amount); - } - /** * Creates a cancellation of withdrawal transaction. * Used when admin cancels a payout - refunds tickets to user. @@ -153,7 +97,6 @@ public class TransactionService { .amount(amount) // Positive amount (credit back to user) .type(Transaction.TransactionType.CANCELLATION_OF_WITHDRAWAL) .taskId(null) - .roundId(null) .createdAt(createdAt) // Set custom timestamp (@PrePersist will check if null) .build(); transactionRepository.save(transaction); @@ -203,27 +146,19 @@ public class TransactionService { // Format date String date = formatter.format(transaction.getCreatedAt()); - // Send enum value as string (e.g., "TASK_BONUS", "WIN") - frontend will handle localization String typeEnumValue = transaction.getType().name(); - - // For DAILY_BONUS, don't include taskId (it should be null) - // For TASK_BONUS, include taskId - Integer taskIdToInclude = (transaction.getType() == Transaction.TransactionType.DAILY_BONUS) - ? null - : transaction.getTaskId(); + Integer taskIdToInclude = transaction.getTaskId(); return TransactionDto.builder() .amount(transaction.getAmount()) .date(date) - .type(typeEnumValue) // Send enum value, not localized string + .type(typeEnumValue) .taskId(taskIdToInclude) - .roundId(transaction.getRoundId()) .build(); }); } - // Note: Transaction type localization is now handled in the frontend. - // Backend sends enum values (TASK_BONUS, WIN, etc.) and frontend translates them. + // Transaction type localization is handled in the frontend. // This method is no longer used but kept for reference. @Deprecated private String mapTransactionTypeToDisplayName(Transaction.TransactionType type, String languageCode) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8dcc5aa..943c0b9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -95,16 +95,6 @@ app: # Avatar URL cache TTL in minutes (default: 5 minutes) cache-ttl-minutes: ${APP_AVATAR_CACHE_TTL_MINUTES:5} - websocket: - # Allowed origins for WebSocket CORS (comma-separated) - # Default includes production domain and Telegram WebView domains - allowed-origins: ${APP_WEBSOCKET_ALLOWED_ORIGINS:https://win-spin.live,https://lottery-fe-production.up.railway.app,https://web.telegram.org,https://webk.telegram.org,https://webz.telegram.org} - - # Lottery bot scheduler: auto-joins bots from lottery_bot_configs into joinable rounds. Toggle via admin Feature Switches (lottery_bot_scheduler_enabled). - # Bet amount is decided in-process by persona + loss-streak and zone logic (no external API). - lottery-bot: - schedule-fixed-delay-ms: ${APP_LOTTERY_BOT_SCHEDULE_FIXED_DELAY_MS:5000} - # Secret token for remote bet API (GET /api/remotebet/{token}?user_id=&room=&amount=). No auth; enable via Feature Switchers in admin. remote-bet: token: ${APP_REMOTE_BET_TOKEN:} diff --git a/src/main/resources/db/migration/V12__create_transactions_table.sql b/src/main/resources/db/migration/V12__create_transactions_table.sql index 5154dfb..d3c014a 100644 --- a/src/main/resources/db/migration/V12__create_transactions_table.sql +++ b/src/main/resources/db/migration/V12__create_transactions_table.sql @@ -3,9 +3,8 @@ CREATE TABLE transactions ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, amount BIGINT NOT NULL COMMENT 'Amount in bigint format (positive for credits, negative for debits)', - type VARCHAR(50) NOT NULL COMMENT 'Transaction type: DEPOSIT, WITHDRAWAL, WIN, LOSS, TASK_BONUS', + type VARCHAR(50) NOT NULL COMMENT 'Transaction type: DEPOSIT, WITHDRAWAL, TASK_BONUS, CANCELLATION_OF_WITHDRAWAL', task_id INT NULL COMMENT 'Task ID for TASK_BONUS type', - round_id BIGINT NULL COMMENT 'Round ID for WIN/LOSS type', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id_created_at (user_id, created_at DESC), INDEX idx_user_id_type (user_id, type), diff --git a/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql b/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql index 9d87ab8..a70445d 100644 --- a/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql +++ b/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql @@ -1,12 +1,4 @@ --- Add index on game_rounds for join query optimization --- This helps with the query: SELECT p FROM GameRoundParticipant p WHERE p.userId = :userId --- AND p.round.phase = 'RESOLUTION' AND p.round.resolvedAt IS NOT NULL ORDER BY p.round.resolvedAt DESC -CREATE INDEX idx_round_phase_resolved ON game_rounds (id, phase, resolved_at DESC); - --- Add index on game_round_participants for cleanup by joined_at -CREATE INDEX idx_joined_at ON game_round_participants (joined_at); - --- Add index on transactions for game history queries (filtering by WIN type) and cleanup by created_at +-- Add index on transactions for cleanup by created_at CREATE INDEX idx_type_created_at ON transactions (type, created_at); diff --git a/src/main/resources/db/migration/V18__remove_unused_indexes.sql b/src/main/resources/db/migration/V18__remove_unused_indexes.sql deleted file mode 100644 index 746407a..0000000 --- a/src/main/resources/db/migration/V18__remove_unused_indexes.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Remove unused index on game_round_participants --- This index was only used for cleanup, which is no longer performed on this table --- Participants are now deleted immediately after rounds finish -DROP INDEX idx_joined_at ON game_round_participants; - --- Update comment: idx_type_created_at is now used for game history queries (filtering by WIN type) --- The cleanup query no longer filters by type, but the index is still useful for game history - - diff --git a/src/main/resources/db/migration/V19__add_daily_bonus_task.sql b/src/main/resources/db/migration/V19__add_daily_bonus_task.sql index 03cadb2..180fd41 100644 --- a/src/main/resources/db/migration/V19__add_daily_bonus_task.sql +++ b/src/main/resources/db/migration/V19__add_daily_bonus_task.sql @@ -1,8 +1,3 @@ --- Insert Daily Bonus task --- reward_amount is in bigint format (1 ticket = 1000000) --- requirement is 24 hours in milliseconds (86400000), but we'll use 0 as placeholder since we check claimed_at timestamp --- The actual 24h check is done in TaskService.isTaskCompleted() for "daily" type -INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) VALUES -('daily', 0, 1000000, 'Tickets', 1, 'Daily Bonus', 'Claim 1 free ticket every 24 hours'); +-- Daily bonus task removed (user_daily_bonus_claims table and related logic removed). diff --git a/src/main/resources/db/migration/V20__create_user_daily_bonus_claims_table.sql b/src/main/resources/db/migration/V20__create_user_daily_bonus_claims_table.sql deleted file mode 100644 index 7cdcb86..0000000 --- a/src/main/resources/db/migration/V20__create_user_daily_bonus_claims_table.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Create user_daily_bonus_claims table for daily bonus claims --- This table stores daily bonus claims with user information to avoid JOINs -CREATE TABLE IF NOT EXISTS `user_daily_bonus_claims` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `user_id` int NOT NULL, - `avatar_url` varchar(255) DEFAULT NULL, - `screen_name` varchar(75) NOT NULL DEFAULT '-', - `claimed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - KEY `idx_user_id` (`user_id`), - KEY `idx_claimed_at` (`claimed_at` DESC), - CONSTRAINT `fk_user_daily_bonus_claims_user` FOREIGN KEY (`user_id`) REFERENCES `db_users_a` (`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - diff --git a/src/main/resources/db/migration/V21__add_rounds_played_to_users_b.sql b/src/main/resources/db/migration/V21__add_rounds_played_to_users_b.sql deleted file mode 100644 index 5540fd8..0000000 --- a/src/main/resources/db/migration/V21__add_rounds_played_to_users_b.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add rounds_played column to db_users_b table --- This column tracks how many rounds each user has participated in --- Used for third bet bonus logic instead of counting transactions -ALTER TABLE `db_users_b` - ADD COLUMN `rounds_played` int NOT NULL DEFAULT '0' AFTER `withdraw_count`; - diff --git a/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql b/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql index 7c66f9c..046d9de 100644 --- a/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql +++ b/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql @@ -36,13 +36,6 @@ CREATE INDEX idx_payouts_status_created_at ON payouts(status, created_at); -- Index for payout type filtering CREATE INDEX idx_payouts_type ON payouts(type); --- ============================================ --- game_rounds indexes --- ============================================ --- Composite index for queries filtering by phase and resolved_at --- This helps with queries like countByResolvedAtAfter when combined with phase filters -CREATE INDEX idx_game_rounds_phase_resolved_at ON game_rounds(phase, resolved_at); - -- ============================================ -- support_tickets indexes -- ============================================ diff --git a/src/main/resources/db/migration/V2__create_game_tables.sql b/src/main/resources/db/migration/V2__create_game_tables.sql deleted file mode 100644 index 336337b..0000000 --- a/src/main/resources/db/migration/V2__create_game_tables.sql +++ /dev/null @@ -1,55 +0,0 @@ --- Create game_rooms table -CREATE TABLE IF NOT EXISTS game_rooms ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - room_number INT NOT NULL UNIQUE, - current_phase VARCHAR(20) NOT NULL DEFAULT 'WAITING', - countdown_end_at TIMESTAMP NULL, - total_tickets BIGINT UNSIGNED NOT NULL DEFAULT 0, - registered_players INT NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_phase (current_phase), - INDEX idx_countdown (countdown_end_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- Create game_rounds table (completed rounds history) -CREATE TABLE IF NOT EXISTS game_rounds ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - room_id INT NOT NULL, - phase VARCHAR(20) NOT NULL, - total_tickets BIGINT UNSIGNED NOT NULL, - winner_user_id INT NULL, - winner_tickets BIGINT UNSIGNED NOT NULL DEFAULT 0, - commission BIGINT UNSIGNED NOT NULL DEFAULT 0, - payout BIGINT UNSIGNED NOT NULL DEFAULT 0, - started_at TIMESTAMP NOT NULL, - countdown_started_at TIMESTAMP NULL, - countdown_ended_at TIMESTAMP NULL, - resolved_at TIMESTAMP NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_room (room_id), - INDEX idx_winner (winner_user_id), - INDEX idx_resolved (resolved_at), - FOREIGN KEY (room_id) REFERENCES game_rooms(id) ON DELETE RESTRICT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- Create game_round_participants table (who joined which round) -CREATE TABLE IF NOT EXISTS game_round_participants ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - round_id BIGINT NOT NULL, - user_id INT NOT NULL, - tickets BIGINT UNSIGNED NOT NULL, - joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_round (round_id), - INDEX idx_user (user_id), - INDEX idx_round_user (round_id, user_id), - FOREIGN KEY (round_id) REFERENCES game_rounds(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE RESTRICT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- Insert default rooms (Room 1, 2, 3) -INSERT INTO game_rooms (room_number, current_phase, total_tickets, registered_players) -VALUES (1, 'WAITING', 0, 0), - (2, 'WAITING', 0, 0), - (3, 'WAITING', 0, 0); - diff --git a/src/main/resources/db/migration/V35__add_total_win_after_deposit_to_users_b.sql b/src/main/resources/db/migration/V35__add_total_win_after_deposit_to_users_b.sql deleted file mode 100644 index 95111af..0000000 --- a/src/main/resources/db/migration/V35__add_total_win_after_deposit_to_users_b.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add total_win_after_deposit to db_users_b (bigint: 1 ticket = 1_000_000). --- Reset to 0 on each deposit; incremented by round win amount when user wins; reduced when user creates a payout. -ALTER TABLE `db_users_b` - ADD COLUMN `total_win_after_deposit` BIGINT NOT NULL DEFAULT 0 AFTER `rounds_played`; diff --git a/src/main/resources/db/migration/V36__create_feature_switches.sql b/src/main/resources/db/migration/V36__create_feature_switches.sql index 9e01e7b..53796ca 100644 --- a/src/main/resources/db/migration/V36__create_feature_switches.sql +++ b/src/main/resources/db/migration/V36__create_feature_switches.sql @@ -1,10 +1,7 @@ --- Runtime feature toggles (e.g. remote bet endpoint). Can be changed from admin panel without restart. +-- Runtime feature toggles. Can be changed from admin panel without restart. Kept empty (no seeds). CREATE TABLE `feature_switches` ( `key` VARCHAR(64) NOT NULL, `enabled` TINYINT(1) NOT NULL DEFAULT 0, `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- Insert default: remote bet endpoint disabled until explicitly enabled from admin -INSERT INTO `feature_switches` (`key`, `enabled`) VALUES ('remote_bet_enabled', 1); diff --git a/src/main/resources/db/migration/V3__rename_tickets_to_bet.sql b/src/main/resources/db/migration/V3__rename_tickets_to_bet.sql deleted file mode 100644 index 89c9e9d..0000000 --- a/src/main/resources/db/migration/V3__rename_tickets_to_bet.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Rename tickets columns to bet for currency-agnostic naming -ALTER TABLE game_rooms CHANGE COLUMN total_tickets total_bet BIGINT UNSIGNED NOT NULL DEFAULT 0; -ALTER TABLE game_rounds CHANGE COLUMN total_tickets total_bet BIGINT UNSIGNED NOT NULL; -ALTER TABLE game_rounds CHANGE COLUMN winner_tickets winner_bet BIGINT UNSIGNED NOT NULL DEFAULT 0; -ALTER TABLE game_round_participants CHANGE COLUMN tickets bet BIGINT UNSIGNED NOT NULL; - - - - - diff --git a/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql b/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql index 643978e..ced6038 100644 --- a/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql +++ b/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql @@ -1,4 +1 @@ --- Feature switchers for payment (deposits) and payout (withdrawals). Enabled by default. -INSERT INTO `feature_switches` (`key`, `enabled`) VALUES - ('payment_enabled', 1), - ('payout_enabled', 1); +-- Feature switches: no seeds (kept empty). diff --git a/src/main/resources/db/migration/V4__add_composite_index_for_completed_rounds.sql b/src/main/resources/db/migration/V4__add_composite_index_for_completed_rounds.sql deleted file mode 100644 index da69639..0000000 --- a/src/main/resources/db/migration/V4__add_composite_index_for_completed_rounds.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add composite index for optimized querying of completed rounds with winners --- This index supports the query: WHERE room_id = X AND phase = 'RESOLUTION' AND resolved_at IS NOT NULL AND winner_user_id IS NOT NULL ORDER BY resolved_at DESC --- Index order: --- 1. room_id (exact match, most selective WHERE filter) --- 2. phase (exact match WHERE filter) --- 3. resolved_at (ORDER BY column - placed after equality filters for efficient sorting) --- 4. winner_user_id (IS NOT NULL filter - MySQL can still use index efficiently) --- This allows MySQL to efficiently filter by room_id and phase, then sort by resolved_at using the index -CREATE INDEX idx_room_phase_resolved_winner ON game_rounds (room_id, phase, resolved_at, winner_user_id); - diff --git a/src/main/resources/db/migration/V51__feature_switches_referral_tasks_50_100.sql b/src/main/resources/db/migration/V51__feature_switches_referral_tasks_50_100.sql index 3ca5618..ced6038 100644 --- a/src/main/resources/db/migration/V51__feature_switches_referral_tasks_50_100.sql +++ b/src/main/resources/db/migration/V51__feature_switches_referral_tasks_50_100.sql @@ -1,5 +1 @@ --- Toggle "Invite 50 friends" and "Invite 100 friends" referral tasks. When disabled (0), tasks are hidden and cannot be claimed. -INSERT INTO `feature_switches` (`key`, `enabled`) VALUES - ('task_referral_50_enabled', 0), - ('task_referral_100_enabled', 0) -ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); +-- Feature switches: no seeds (kept empty). diff --git a/src/main/resources/db/migration/V52__bot_config_tables.sql b/src/main/resources/db/migration/V52__bot_config_tables.sql deleted file mode 100644 index 673d01b..0000000 --- a/src/main/resources/db/migration/V52__bot_config_tables.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Safe bot users: when balance < threshold they get 100% win rate at resolution (display unchanged) -CREATE TABLE IF NOT EXISTS safe_bot_users ( - user_id INT NOT NULL PRIMARY KEY, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- Flexible bot config: user has fixed win rate (0-1) regardless of bet -CREATE TABLE IF NOT EXISTS flexible_bot_configs ( - user_id INT NOT NULL PRIMARY KEY, - win_rate DECIMAL(5,4) NOT NULL COMMENT '0.0000-1.0000', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT chk_win_rate CHECK (win_rate >= 0 AND win_rate <= 1) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql b/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql index f2b4355..7285f72 100644 --- a/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql +++ b/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql @@ -1,9 +1,8 @@ --- Indexes for admin users list sorting (Balance, Profit, Deposits, Withdraws, Rounds, Referrals) --- db_users_b: balance_a, deposit_total, withdraw_total, rounds_played +-- Indexes for admin users list sorting (Balance, Deposits, Withdraws, Referrals) +-- db_users_b: balance_a, deposit_total, withdraw_total CREATE INDEX idx_users_b_balance_a ON db_users_b(balance_a); CREATE INDEX idx_users_b_deposit_total ON db_users_b(deposit_total); CREATE INDEX idx_users_b_withdraw_total ON db_users_b(withdraw_total); -CREATE INDEX idx_users_b_rounds_played ON db_users_b(rounds_played); -- db_users_d: for referral count (sum of referals_1..5) we filter by referer_id_N; indexes already exist (V34) -- For sorting by total referral count we could use a composite; referer_id_1 is used for "referrals of user X" diff --git a/src/main/resources/db/migration/V54__lottery_bot_configs.sql b/src/main/resources/db/migration/V54__lottery_bot_configs.sql deleted file mode 100644 index ed8e505..0000000 --- a/src/main/resources/db/migration/V54__lottery_bot_configs.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Bot behaviour config: links a real user (db_users_a/b) to play as a bot with time windows, rooms, bet range. --- One config per user; user_id must exist in db_users_a. -CREATE TABLE IF NOT EXISTS lottery_bot_configs ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - room_1 TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Can play room 1', - room_2 TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Can play room 2', - room_3 TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Can play room 3', - time_utc_start TIME NOT NULL COMMENT 'Start of active window (UTC)', - time_utc_end TIME NOT NULL COMMENT 'End of active window (UTC)', - bet_min BIGINT NOT NULL COMMENT 'Min bet in bigint (1 ticket = 1000000)', - bet_max BIGINT NOT NULL COMMENT 'Max bet in bigint', - persona VARCHAR(20) NOT NULL DEFAULT 'balanced' COMMENT 'conservative, aggressive, balanced', - active TINYINT(1) NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_lottery_bot_configs_user_id (user_id), - KEY idx_lottery_bot_configs_active_rooms (active, room_1, room_2, room_3), - CONSTRAINT fk_lottery_bot_configs_user FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V55__feature_switch_lottery_bot_scheduler.sql b/src/main/resources/db/migration/V55__feature_switch_lottery_bot_scheduler.sql deleted file mode 100644 index a940824..0000000 --- a/src/main/resources/db/migration/V55__feature_switch_lottery_bot_scheduler.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Toggle for lottery bot scheduler (auto-join bots from lottery_bot_configs into joinable rounds). When disabled (0), scheduler skips registration. -INSERT INTO `feature_switches` (`key`, `enabled`) VALUES - ('lottery_bot_scheduler_enabled', 1) -ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); diff --git a/src/main/resources/db/migration/V57__seed_first_net_win_promotion.sql b/src/main/resources/db/migration/V57__seed_first_net_win_promotion.sql index ae0febe..0642fab 100644 --- a/src/main/resources/db/migration/V57__seed_first_net_win_promotion.sql +++ b/src/main/resources/db/migration/V57__seed_first_net_win_promotion.sql @@ -1,20 +1 @@ --- First NET_WIN promotion: 26.02.2026 12:00 UTC -> 01.03.2026 12:00 UTC -INSERT INTO promotions (type, start_time, end_time, status) VALUES -('NET_WIN', '2026-02-26 12:00:00', '2026-03-01 12:00:00', 'PLANNED'); - --- Rewards: 1 ticket = 1,000,000 in bigint --- place 1: 50,000 tickets = 50000000000 --- place 2: 30,000 = 30000000000, 3: 20,000 = 20000000000, 4: 15,000 = 15000000000, 5: 10,000 = 10000000000 --- places 6-10: 5,000 each = 5000000000 -SET @promo_id = LAST_INSERT_ID(); -INSERT INTO promotions_rewards (promo_id, place, reward) VALUES -(@promo_id, 1, 50000000000), -(@promo_id, 2, 30000000000), -(@promo_id, 3, 20000000000), -(@promo_id, 4, 15000000000), -(@promo_id, 5, 10000000000), -(@promo_id, 6, 5000000000), -(@promo_id, 7, 5000000000), -(@promo_id, 8, 5000000000), -(@promo_id, 9, 5000000000), -(@promo_id, 10, 5000000000); +-- Promotions: no seeds (tables created in V56). diff --git a/src/main/resources/db/migration/V58__add_promotions_total_reward.sql b/src/main/resources/db/migration/V58__add_promotions_total_reward.sql index a1ba390..3a232f6 100644 --- a/src/main/resources/db/migration/V58__add_promotions_total_reward.sql +++ b/src/main/resources/db/migration/V58__add_promotions_total_reward.sql @@ -1,9 +1,3 @@ -- total_reward in tickets (BIGINT: 1 ticket = 1_000_000) ALTER TABLE promotions ADD COLUMN total_reward BIGINT NULL DEFAULT NULL COMMENT 'Total prize fund in bigint (1 ticket = 1000000)' AFTER status; - --- First promo: 150 000 tickets = 150_000_000_000 -UPDATE promotions SET total_reward = 150000000000 WHERE id = 1; - --- Index for filtering by status (already have idx_promotions_status) --- total_reward is for display only, no extra index needed diff --git a/src/main/resources/db/migration/V60__update_promotion_1_rewards.sql b/src/main/resources/db/migration/V60__update_promotion_1_rewards.sql deleted file mode 100644 index 1389e00..0000000 --- a/src/main/resources/db/migration/V60__update_promotion_1_rewards.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Update promotion id=1 rewards (place -> tickets; stored as tickets * 1_000_000) --- 1->300k, 2->200k, 3->150k, 4->100k, 5->80k, 6->55k, 7->40k, 8->30k, 9->25k, 10->20k -UPDATE promotions_rewards SET reward = 300000000000 WHERE promo_id = 1 AND place = 1; -UPDATE promotions_rewards SET reward = 200000000000 WHERE promo_id = 1 AND place = 2; -UPDATE promotions_rewards SET reward = 150000000000 WHERE promo_id = 1 AND place = 3; -UPDATE promotions_rewards SET reward = 100000000000 WHERE promo_id = 1 AND place = 4; -UPDATE promotions_rewards SET reward = 80000000000 WHERE promo_id = 1 AND place = 5; -UPDATE promotions_rewards SET reward = 55000000000 WHERE promo_id = 1 AND place = 6; -UPDATE promotions_rewards SET reward = 40000000000 WHERE promo_id = 1 AND place = 7; -UPDATE promotions_rewards SET reward = 30000000000 WHERE promo_id = 1 AND place = 8; -UPDATE promotions_rewards SET reward = 25000000000 WHERE promo_id = 1 AND place = 9; -UPDATE promotions_rewards SET reward = 20000000000 WHERE promo_id = 1 AND place = 10; - --- Total prize fund: 1_000_000 tickets = 1_000_000_000_000 -UPDATE promotions SET total_reward = 1000000000000 WHERE id = 1; diff --git a/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql b/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql index 1e8d948..80589fe 100644 --- a/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql +++ b/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql @@ -1,9 +1,5 @@ --- Configurations: key-value store for app-wide settings (e.g. lottery bot scheduler). +-- Configurations: key-value store for app-wide settings. CREATE TABLE IF NOT EXISTS configurations ( `key` VARCHAR(128) NOT NULL PRIMARY KEY, value VARCHAR(512) NOT NULL DEFAULT '' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- Bots may join a round only when participant count <= this value (default 1 = join when 0 or 1 participant). -INSERT INTO configurations (`key`, value) VALUES ('lottery_bot_max_participants_before_join', '1') -ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); diff --git a/src/main/resources/db/migration/V64__game_rounds_room_phase_started_at_index.sql b/src/main/resources/db/migration/V64__game_rounds_room_phase_started_at_index.sql deleted file mode 100644 index e65ac00..0000000 --- a/src/main/resources/db/migration/V64__game_rounds_room_phase_started_at_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Index for admin online-users and other flows that fetch "most recent active round" per room. --- Query: WHERE room_id = ? AND phase IN (...) ORDER BY started_at DESC LIMIT 1 --- Covers filter by room_id and phase, and sort by started_at without filesort. -CREATE INDEX idx_game_rounds_room_phase_started_at ON game_rounds (room_id, phase, started_at DESC); diff --git a/src/main/resources/db/migration/V65__feature_switch_manual_pay_for_all_payouts.sql b/src/main/resources/db/migration/V65__feature_switch_manual_pay_for_all_payouts.sql index 544ae6a..ced6038 100644 --- a/src/main/resources/db/migration/V65__feature_switch_manual_pay_for_all_payouts.sql +++ b/src/main/resources/db/migration/V65__feature_switch_manual_pay_for_all_payouts.sql @@ -1,4 +1 @@ --- When enabled (1), send manual_pay=1 for all crypto payouts. When disabled (0), send manual_pay=1 only for users who completed 50 or 100 referrals (first withdrawal). Default on. -INSERT INTO `feature_switches` (`key`, `enabled`) VALUES - ('manual_pay_for_all_payouts', 1) -ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); +-- Feature switches: no seeds (kept empty). diff --git a/src/main/resources/db/migration/V69__users_b_withdrawals_disabled.sql b/src/main/resources/db/migration/V69__users_b_withdrawals_disabled.sql index f8c1cde..3093b71 100644 --- a/src/main/resources/db/migration/V69__users_b_withdrawals_disabled.sql +++ b/src/main/resources/db/migration/V69__users_b_withdrawals_disabled.sql @@ -1,2 +1,2 @@ -- Per-user withdrawal restriction. When 1, the user cannot create any payout request (STARS, GIFT, CRYPTO). -ALTER TABLE `db_users_b` ADD COLUMN `withdrawals_disabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `total_win_after_deposit`; +ALTER TABLE `db_users_b` ADD COLUMN `withdrawals_disabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `withdraw_count`; diff --git a/src/main/resources/db/migration/V70__seed_net_win_max_bet_and_ref_count_promotions.sql b/src/main/resources/db/migration/V70__seed_net_win_max_bet_and_ref_count_promotions.sql deleted file mode 100644 index 6e47c93..0000000 --- a/src/main/resources/db/migration/V70__seed_net_win_max_bet_and_ref_count_promotions.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Two new promotions: NET_WIN_MAX_BET and REF_COUNT, start 4 March 2026 14:00 UTC, same rewards as promotion 1 (V60) --- End time: 11 March 2026 14:00 UTC (1 week) -INSERT INTO promotions (type, start_time, end_time, status, total_reward) VALUES -('NET_WIN_MAX_BET', '2026-03-04 14:00:00', '2026-03-11 14:00:00', 'PLANNED', 1000000000000), -('REF_COUNT', '2026-03-04 14:00:00', '2026-03-11 14:00:00', 'PLANNED', 1000000000000); - --- NET_WIN_MAX_BET rewards (same as promotion 1) -SET @promo_max_bet = (SELECT id FROM promotions WHERE type = 'NET_WIN_MAX_BET' AND start_time = '2026-03-04 14:00:00' LIMIT 1); -INSERT INTO promotions_rewards (promo_id, place, reward) VALUES -(@promo_max_bet, 1, 300000000000), -(@promo_max_bet, 2, 200000000000), -(@promo_max_bet, 3, 150000000000), -(@promo_max_bet, 4, 100000000000), -(@promo_max_bet, 5, 80000000000), -(@promo_max_bet, 6, 55000000000), -(@promo_max_bet, 7, 40000000000), -(@promo_max_bet, 8, 30000000000), -(@promo_max_bet, 9, 25000000000), -(@promo_max_bet, 10, 20000000000); - --- REF_COUNT rewards (same structure) -SET @promo_ref = (SELECT id FROM promotions WHERE type = 'REF_COUNT' AND start_time = '2026-03-04 14:00:00' LIMIT 1); -INSERT INTO promotions_rewards (promo_id, place, reward) VALUES -(@promo_ref, 1, 300000000000), -(@promo_ref, 2, 200000000000), -(@promo_ref, 3, 150000000000), -(@promo_ref, 4, 100000000000), -(@promo_ref, 5, 80000000000), -(@promo_ref, 6, 55000000000), -(@promo_ref, 7, 40000000000), -(@promo_ref, 8, 30000000000), -(@promo_ref, 9, 25000000000), -(@promo_ref, 10, 20000000000);