cleanup 1

This commit is contained in:
Mykhailo Svishchov
2026-03-04 23:36:26 +02:00
parent 313bd13ef9
commit da1649d3cd
96 changed files with 59 additions and 5679 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -79,12 +79,6 @@
<version>4.2.0</version> <version>4.2.0</version>
</dependency> </dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Telegram Bot API --> <!-- Telegram Bot API -->
<dependency> <dependency>
<groupId>org.telegram</groupId> <groupId>org.telegram</groupId>

View File

@@ -32,7 +32,6 @@ public class WebConfig implements WebMvcConfigurer {
"/api/check_user/**", // User check endpoint for external applications (open endpoint) "/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/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/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 "/api/admin/**" // Admin endpoints are handled by Spring Security
); );

View File

@@ -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<String> 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);
}
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -2,7 +2,6 @@ package com.lottery.lottery.controller;
import com.lottery.lottery.model.Payment; import com.lottery.lottery.model.Payment;
import com.lottery.lottery.model.Payout; import com.lottery.lottery.model.Payout;
import com.lottery.lottery.repository.GameRoundRepository;
import com.lottery.lottery.repository.PaymentRepository; import com.lottery.lottery.repository.PaymentRepository;
import com.lottery.lottery.repository.PayoutRepository; import com.lottery.lottery.repository.PayoutRepository;
import com.lottery.lottery.repository.UserARepository; import com.lottery.lottery.repository.UserARepository;
@@ -30,7 +29,6 @@ public class AdminAnalyticsController {
private final UserARepository userARepository; private final UserARepository userARepository;
private final PaymentRepository paymentRepository; private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository; private final PayoutRepository payoutRepository;
private final GameRoundRepository gameRoundRepository;
/** /**
* Get revenue and payout time series data for charts. * Get revenue and payout time series data for charts.
@@ -181,14 +179,11 @@ public class AdminAnalyticsController {
// Count active players (logged in) in this period // Count active players (logged in) in this period
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs); long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
// Count rounds resolved in this period
long rounds = gameRoundRepository.countByResolvedAtBetween(current, periodEnd);
Map<String, Object> point = new HashMap<>(); Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond()); point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers); point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers); point.put("activePlayers", activePlayers);
point.put("rounds", rounds); point.put("rounds", 0L);
dataPoints.add(point); dataPoints.add(point);

View File

@@ -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<AdminBotConfigDto>> list() {
return ResponseEntity.ok(adminBotConfigService.listAll());
}
@GetMapping("/{id}")
public ResponseEntity<AdminBotConfigDto> getById(@PathVariable Integer id) {
Optional<AdminBotConfigDto> 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<AdminBotConfigDto> 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<Void> 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<Map<String, Integer>> getBotSettings() {
return ResponseEntity.ok(Map.of("maxParticipantsBeforeBotJoin", configurationService.getMaxParticipantsBeforeBotJoin()));
}
@PatchMapping("/settings")
public ResponseEntity<?> updateBotSettings(@RequestBody Map<String, Integer> 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));
}
}

View File

@@ -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<BotConfigService.BotConfigDto> getConfig() {
return ResponseEntity.ok(botConfigService.getConfig());
}
@PutMapping
public ResponseEntity<BotConfigService.BotConfigDto> updateConfig(
@RequestBody AdminConfigurationsRequest request
) {
List<Integer> safeIds = request.getSafeBotUserIds() != null
? request.getSafeBotUserIds()
: Collections.emptyList();
List<BotConfigService.FlexibleBotEntryDto> 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());
}
}

View File

@@ -4,7 +4,6 @@ import com.lottery.lottery.model.Payment;
import com.lottery.lottery.model.Payout; import com.lottery.lottery.model.Payout;
import com.lottery.lottery.model.SupportTicket; import com.lottery.lottery.model.SupportTicket;
import com.lottery.lottery.model.UserA; import com.lottery.lottery.model.UserA;
import com.lottery.lottery.repository.GameRoundRepository;
import com.lottery.lottery.repository.PaymentRepository; import com.lottery.lottery.repository.PaymentRepository;
import com.lottery.lottery.repository.PayoutRepository; import com.lottery.lottery.repository.PayoutRepository;
import com.lottery.lottery.repository.SupportTicketRepository; import com.lottery.lottery.repository.SupportTicketRepository;
@@ -31,7 +30,6 @@ public class AdminDashboardController {
private final UserARepository userARepository; private final UserARepository userARepository;
private final PaymentRepository paymentRepository; private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository; private final PayoutRepository payoutRepository;
private final GameRoundRepository gameRoundRepository;
private final SupportTicketRepository supportTicketRepository; private final SupportTicketRepository supportTicketRepository;
@GetMapping("/stats") @GetMapping("/stats")
@@ -105,17 +103,6 @@ public class AdminDashboardController {
BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek); BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek);
BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth); 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 // Support Tickets
long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED); long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED);
// Count tickets closed today // Count tickets closed today
@@ -176,11 +163,11 @@ public class AdminDashboardController {
stats.put("crypto", crypto); stats.put("crypto", crypto);
stats.put("rounds", Map.of( stats.put("rounds", Map.of(
"total", totalRounds, "total", 0L,
"today", roundsToday, "today", 0L,
"week", roundsWeek, "week", 0L,
"month", roundsMonth, "month", 0L,
"avgPool", avgPool "avgPool", 0
)); ));
stats.put("supportTickets", Map.of( stats.put("supportTickets", Map.of(

View File

@@ -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<List<AdminRoomSummaryDto>> listRooms() {
return ResponseEntity.ok(gameRoomService.getAdminRoomSummaries());
}
@GetMapping("/online-users")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<List<AdminRoomOnlineUserDto>> getOnlineUsers() {
return ResponseEntity.ok(gameRoomService.getAdminOnlineUsersAcrossRooms());
}
@GetMapping("/{roomNumber}")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<AdminRoomDetailDto> 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<Map<String, Object>> 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"));
}
}
}

View File

@@ -27,7 +27,7 @@ public class AdminUserController {
private static final Set<String> SORTABLE_FIELDS = Set.of( private static final Set<String> SORTABLE_FIELDS = Set.of(
"id", "screenName", "telegramId", "telegramName", "isPremium", "id", "screenName", "telegramId", "telegramName", "isPremium",
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned", "languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
"balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit" "balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit"
); );
private static final Set<String> DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt"); private static final Set<String> DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt");
private static final Set<String> WITHDRAWAL_SORT_FIELDS = Set.of("id", "usdAmount", "cryptoName", "amountToSend", "txhash", "status", "paymentId", "createdAt", "resolvedAt"); private static final Set<String> 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) Integer dateRegTo,
@RequestParam(required = false) Long balanceMin, @RequestParam(required = false) Long balanceMin,
@RequestParam(required = false) Long balanceMax, @RequestParam(required = false) Long balanceMax,
@RequestParam(required = false) Integer roundsPlayedMin,
@RequestParam(required = false) Integer roundsPlayedMax,
@RequestParam(required = false) Integer referralCountMin, @RequestParam(required = false) Integer referralCountMin,
@RequestParam(required = false) Integer referralCountMax, @RequestParam(required = false) Integer referralCountMax,
@RequestParam(required = false) Integer referrerId, @RequestParam(required = false) Integer referrerId,
@RequestParam(required = false) Integer referralLevel, @RequestParam(required = false) Integer referralLevel,
@RequestParam(required = false) String ip) { @RequestParam(required = false) String ip) {
// Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, roundsPlayed, referralCount) // Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, referralCount) are handled in service via custom query.
// are handled in service via custom query; others are applied to UserA. Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit");
String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null); String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null);
if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) { if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) {
// Pass through; service will use custom ordered query // Pass through; service will use custom ordered query
@@ -96,8 +93,6 @@ public class AdminUserController {
dateRegTo, dateRegTo,
balanceMinBigint, balanceMinBigint,
balanceMaxBigint, balanceMaxBigint,
roundsPlayedMin,
roundsPlayedMax,
referralCountMin, referralCountMin,
referralCountMax, referralCountMax,
referrerId, referrerId,
@@ -152,28 +147,6 @@ public class AdminUserController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@GetMapping("/{id}/game-rounds")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserGameRounds(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<AdminGameRoundDto> rounds = adminUserService.getUserGameRounds(id, pageable);
Map<String, Object> 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") @GetMapping("/{id}/payments")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserPayments( public ResponseEntity<Map<String, Object>> getUserPayments(

View File

@@ -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<List<CompletedRoundDto>> getCompletedRounds(
@PathVariable Integer roomNumber
) {
List<GameRound> rounds = gameRoundRepository.findLastCompletedRoundsByRoomNumber(
roomNumber,
PageRequest.of(0, 10)
);
List<CompletedRoundDto> 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<org.springframework.data.domain.Page<GameHistoryEntryDto>> 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<GameHistoryEntryDto> history = gameHistoryService.getUserGameHistory(userId, page, timezone, languageCode);
return ResponseEntity.ok(history);
}
}

View File

@@ -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<Integer, Integer> userRoomSubscriptions = new ConcurrentHashMap<>();
// Track winners who have already received balance updates (to avoid duplicates)
private final Map<Integer, Integer> 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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -70,9 +70,6 @@ public class UserCheckController {
// Convert to tickets (balance_a / 1,000,000) // Convert to tickets (balance_a / 1,000,000)
Double tickets = balanceA / 1_000_000.0; 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 // Get referer_id_1 from db_users_d
Optional<UserD> userDOpt = userDRepository.findById(userId); Optional<UserD> userDOpt = userDRepository.findById(userId);
Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0); Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0);
@@ -91,7 +88,6 @@ public class UserCheckController {
.tickets(tickets) .tickets(tickets)
.depositTotal(depositTotal) .depositTotal(depositTotal)
.refererId(refererId) .refererId(refererId)
.roundsPlayed(roundsPlayed)
.build(); .build();
return ResponseEntity.ok(response); return ResponseEntity.ok(response);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<Integer> safeBotUserIds = new ArrayList<>();
private List<FlexibleBotEntry> flexibleBots = new ArrayList<>();
@Data
public static class FlexibleBotEntry {
private Integer userId;
private Double winRate;
}
}

View File

@@ -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;
}

View File

@@ -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<AdminRoomParticipantDto> participants;
/** Viewers: same as participants section format but without tickets/chances (screen name + id). */
private List<AdminRoomViewerDto> connectedViewers;
private AdminRoomWinnerDto winner; // when phase is SPINNING or RESOLUTION
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -41,9 +41,6 @@ public class AdminUserDetailDto {
/** When true, the user cannot create any payout request. */ /** When true, the user cannot create any payout request. */
private Boolean withdrawalsDisabled; private Boolean withdrawalsDisabled;
// Game Stats
private Integer roundsPlayed;
// Referral Info // Referral Info
private Integer referralCount; private Integer referralCount;
private Long totalCommissionsEarned; private Long totalCommissionsEarned;

View File

@@ -22,7 +22,6 @@ public class AdminUserDto {
private Integer depositCount; private Integer depositCount;
private Long withdrawTotal; private Long withdrawTotal;
private Integer withdrawCount; private Integer withdrawCount;
private Integer roundsPlayed;
private Integer dateReg; private Integer dateReg;
private Integer dateLogin; private Integer dateLogin;
private Integer banned; private Integer banned;

View File

@@ -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
}

View File

@@ -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<Integer, Integer> 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<ParticipantDto> participants;
@JsonProperty("w")
private WinnerDto winner;
@JsonProperty("sD")
private Long spinDuration; // milliseconds
@JsonProperty("sI")
private Long stopIndex; // for spin animation
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -26,19 +26,14 @@ public class TransactionDto {
private String date; 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; 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; private Integer taskId;
/**
* Round ID for WIN/BET type (null for other types)
*/
private Long roundId;
} }

View File

@@ -21,6 +21,5 @@ public class UserCheckDto {
private Double tickets; // balance_a / 1,000,000 private Double tickets; // balance_a / 1,000,000
private Integer depositTotal; // Sum of completed payments stars_amount private Integer depositTotal; // Sum of completed payments stars_amount
private Integer refererId; // referer_id_1 from db_users_d private Integer refererId; // referer_id_1 from db_users_d
private Integer roundsPlayed; // rounds_played from db_users_b
} }

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -32,9 +32,6 @@ public class Transaction {
@Column(name = "task_id") @Column(name = "task_id")
private Integer taskId; // Task ID for TASK_BONUS type 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) @Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt; private Instant createdAt;
@@ -49,12 +46,7 @@ public class Transaction {
public enum TransactionType { public enum TransactionType {
DEPOSIT, // Payment/deposit DEPOSIT, // Payment/deposit
WITHDRAWAL, // Payout/withdrawal 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 TASK_BONUS, // Task reward
DAILY_BONUS, // Daily bonus reward (no taskId)
CANCELLATION_OF_WITHDRAWAL // Cancellation of withdrawal (payout cancelled by admin) CANCELLATION_OF_WITHDRAWAL // Cancellation of withdrawal (payout cancelled by admin)
} }
} }

View File

@@ -40,15 +40,6 @@ public class UserB {
@Builder.Default @Builder.Default
private Integer withdrawCount = 0; 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). */ /** When true, the user cannot create any payout request (blocked on backend). */
@Column(name = "withdrawals_disabled", nullable = false) @Column(name = "withdrawals_disabled", nullable = false)
@Builder.Default @Builder.Default

View File

@@ -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();
}
}
}

View File

@@ -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<FlexibleBotConfig, Integer> {
List<FlexibleBotConfig> findAllByOrderByUserIdAsc();
}

View File

@@ -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<GameRoom, Integer> {
Optional<GameRoom> findByRoomNumber(Integer roomNumber);
// Efficient query for rooms in specific phase (uses index on current_phase)
List<GameRoom> 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<GameRoom> findByRoomNumberWithLock(@Param("roomNumber") Integer roomNumber);
}

View File

@@ -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<GameRoundParticipant, Long> {
List<GameRoundParticipant> findByRoundId(Long roundId);
@Query("SELECT p FROM GameRoundParticipant p WHERE p.round.id = :roundId AND p.userId = :userId")
List<GameRoundParticipant> 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<GameRoundParticipant> 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<GameRoundParticipant> 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);
}

View File

@@ -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<GameRound, Long> {
/** 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<GameRound> findAllByIdWithRoom(@Param("ids") Set<Long> 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<GameRound> findMostRecentActiveRoundsByRoomId(
@Param("roomId") Integer roomId,
@Param("phases") List<GamePhase> 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<GameRound> 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<Double> avgTotalBetByResolvedAtAfter(@Param("after") Instant after);
/**
* Counts rounds resolved between two dates.
*/
long countByResolvedAtBetween(Instant start, Instant end);
}

View File

@@ -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<LotteryBotConfig, Integer> {
List<LotteryBotConfig> findAllByOrderByIdAsc();
List<LotteryBotConfig> findAllByActiveTrue();
List<LotteryBotConfig> findAllByRoom2True();
List<LotteryBotConfig> findAllByRoom3True();
Optional<LotteryBotConfig> findByUserId(Integer userId);
boolean existsByUserId(Integer userId);
}

View File

@@ -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<SafeBotUser, Integer> {
List<SafeBotUser> findAllByOrderByUserIdAsc();
}

View File

@@ -3,8 +3,6 @@ package com.lottery.lottery.repository;
import com.lottery.lottery.model.Transaction; import com.lottery.lottery.model.Transaction;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; 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.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
@@ -12,7 +10,6 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Set;
import java.time.Instant; import java.time.Instant;
@Repository @Repository
@@ -24,13 +21,6 @@ public interface TransactionRepository extends JpaRepository<Transaction, Long>
*/ */
Page<Transaction> findByUserIdOrderByCreatedAtDesc(Integer userId, Pageable pageable); Page<Transaction> 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<Transaction> findByUserIdAndTypeAndCreatedAtAfterOrderByCreatedAtDesc(
Integer userId, Transaction.TransactionType type, Instant createdAfter, Pageable pageable);
/** /**
* Batch deletes all transactions older than the specified date (up to batchSize). * Batch deletes all transactions older than the specified date (up to batchSize).
* Returns the number of deleted rows. * Returns the number of deleted rows.
@@ -42,7 +32,6 @@ public interface TransactionRepository extends JpaRepository<Transaction, Long>
/** /**
* Counts transactions of a specific type for a user. * 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); long countByUserIdAndType(Integer userId, Transaction.TransactionType type);
@@ -52,11 +41,5 @@ public interface TransactionRepository extends JpaRepository<Transaction, Long>
@Query("SELECT t.userId, COALESCE(SUM(t.amount), 0) FROM Transaction t WHERE t.userId IN :userIds GROUP BY t.userId") @Query("SELECT t.userId, COALESCE(SUM(t.amount), 0) FROM Transaction t WHERE t.userId IN :userIds GROUP BY t.userId")
List<Object[]> sumAmountByUserIdIn(@Param("userIds") List<Integer> userIds); List<Object[]> sumAmountByUserIdIn(@Param("userIds") List<Integer> userIds);
/** BET transactions for a user, ordered by createdAt desc (for game history). */
Page<Transaction> 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<Transaction> findByUserIdAndTypeWinAndRoundIdIn(@Param("userId") Integer userId, @Param("roundIds") Set<Long> roundIds);
} }

View File

@@ -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<UserDailyBonusClaim, Long> {
/**
* Finds the most recent daily bonus claim for a user.
* Used to check if user can claim (24h cooldown).
*/
Optional<UserDailyBonusClaim> 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<UserDailyBonusClaim> findTop50ByOrderByClaimedAtDesc();
/**
* Finds all daily bonus claims for a user, ordered by claimed_at DESC.
*/
List<UserDailyBonusClaim> findByUserIdOrderByClaimedAtDesc(Integer userId);
}

View File

@@ -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<AdminBotConfigDto> listAll() {
List<LotteryBotConfig> configs = lotteryBotConfigRepository.findAllByOrderByIdAsc();
if (configs.isEmpty()) {
return List.of();
}
List<Integer> userIds = configs.stream().map(LotteryBotConfig::getUserId).distinct().toList();
Map<Integer, String> 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<AdminBotConfigDto> 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<AdminBotConfigDto> 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<AdminBotConfigDto> update(Integer id, AdminBotConfigRequest request) {
Optional<LotteryBotConfig> 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<LotteryBotConfig> 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<LotteryBotConfig> configs) {
Map<TimeWindow, List<LotteryBotConfig>> 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<TimeWindow> windowSlots = new ArrayList<>();
for (Map.Entry<TimeWindow, List<LotteryBotConfig>> 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)");
}
}
}

View File

@@ -42,14 +42,11 @@ public class AdminUserService {
private final UserBRepository userBRepository; private final UserBRepository userBRepository;
private final UserDRepository userDRepository; private final UserDRepository userDRepository;
private final TransactionRepository transactionRepository; private final TransactionRepository transactionRepository;
private final GameRoundParticipantRepository gameRoundParticipantRepository;
private final PaymentRepository paymentRepository; private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository; private final PayoutRepository payoutRepository;
private final UserTaskClaimRepository userTaskClaimRepository; private final UserTaskClaimRepository userTaskClaimRepository;
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final UserDailyBonusClaimRepository userDailyBonusClaimRepository;
private final EntityManager entityManager; private final EntityManager entityManager;
private final GameRoundRepository gameRoundRepository;
public Page<AdminUserDto> getUsers( public Page<AdminUserDto> getUsers(
Pageable pageable, Pageable pageable,
@@ -61,8 +58,6 @@ public class AdminUserService {
Integer dateRegTo, Integer dateRegTo,
Long balanceMin, Long balanceMin,
Long balanceMax, Long balanceMax,
Integer roundsPlayedMin,
Integer roundsPlayedMax,
Integer referralCountMin, Integer referralCountMin,
Integer referralCountMax, Integer referralCountMax,
Integer referrerId, Integer referrerId,
@@ -142,8 +137,8 @@ public class AdminUserService {
predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp)); predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp));
} }
// Balance / rounds / referral filters via subqueries so DB handles pagination // Balance / referral filters via subqueries so DB handles pagination
if (balanceMin != null || balanceMax != null || roundsPlayedMin != null || roundsPlayedMax != null) { if (balanceMin != null || balanceMax != null) {
Subquery<Integer> subB = query.subquery(Integer.class); Subquery<Integer> subB = query.subquery(Integer.class);
Root<UserB> br = subB.from(UserB.class); Root<UserB> br = subB.from(UserB.class);
subB.select(br.get("id")); subB.select(br.get("id"));
@@ -156,8 +151,6 @@ public class AdminUserService {
subPreds.add(cb.lessThanOrEqualTo( subPreds.add(cb.lessThanOrEqualTo(
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMax)); 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]))); subB.where(cb.and(subPreds.toArray(new Predicate[0])));
predicates.add(cb.in(root.get("id")).value(subB)); predicates.add(cb.in(root.get("id")).value(subB));
} }
@@ -193,7 +186,7 @@ public class AdminUserService {
return cb.and(predicates.toArray(new Predicate[0])); return cb.and(predicates.toArray(new Predicate[0]));
}; };
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit"); Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
boolean useJoinSort = sortBy != null && sortRequiresJoin.contains(sortBy); boolean useJoinSort = sortBy != null && sortRequiresJoin.contains(sortBy);
List<UserA> userList; List<UserA> userList;
long totalElements; long totalElements;
@@ -202,7 +195,7 @@ public class AdminUserService {
List<Integer> orderedIds = getOrderedUserIdsForAdminList( List<Integer> orderedIds = getOrderedUserIdsForAdminList(
search, banned, countryCode, languageCode, search, banned, countryCode, languageCode,
dateRegFrom, dateRegTo, balanceMin, balanceMax, dateRegFrom, dateRegTo, balanceMin, balanceMax,
roundsPlayedMin, roundsPlayedMax, referralCountMin, referralCountMax, referralCountMin, referralCountMax,
referrerId, referralLevel, ipFilter, referrerId, referralLevel, ipFilter,
sortBy, sortDir != null ? sortDir : "desc", sortBy, sortDir != null ? sortDir : "desc",
pageable.getPageSize(), (int) pageable.getOffset(), pageable.getPageSize(), (int) pageable.getOffset(),
@@ -242,7 +235,6 @@ public class AdminUserService {
.depositCount(0) .depositCount(0)
.withdrawTotal(0L) .withdrawTotal(0L)
.withdrawCount(0) .withdrawCount(0)
.roundsPlayed(0)
.build()); .build());
UserD userD = userDMap.getOrDefault(userA.getId(), UserD userD = userDMap.getOrDefault(userA.getId(),
@@ -280,7 +272,6 @@ public class AdminUserService {
.depositCount(userB.getDepositCount()) .depositCount(userB.getDepositCount())
.withdrawTotal(userB.getWithdrawTotal()) .withdrawTotal(userB.getWithdrawTotal())
.withdrawCount(userB.getWithdrawCount()) .withdrawCount(userB.getWithdrawCount())
.roundsPlayed(userB.getRoundsPlayed())
.dateReg(userA.getDateReg()) .dateReg(userA.getDateReg())
.dateLogin(userA.getDateLogin()) .dateLogin(userA.getDateLogin())
.banned(userA.getBanned()) .banned(userA.getBanned())
@@ -312,8 +303,6 @@ public class AdminUserService {
Integer dateRegTo, Integer dateRegTo,
Long balanceMin, Long balanceMin,
Long balanceMax, Long balanceMax,
Integer roundsPlayedMin,
Integer roundsPlayedMax,
Integer referralCountMin, Integer referralCountMin,
Integer referralCountMax, Integer referralCountMax,
Integer referrerId, Integer referrerId,
@@ -395,16 +384,6 @@ public class AdminUserService {
params.add(balanceMax); params.add(balanceMax);
paramIndex++; 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) { if (referralCountMin != null || referralCountMax != null) {
sql.append(" AND (d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)"); sql.append(" AND (d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)");
if (referralCountMin != null && referralCountMax != null) { 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 "balanceA" -> "b.balance_a";
case "depositTotal" -> "b.deposit_total"; case "depositTotal" -> "b.deposit_total";
case "withdrawTotal" -> "b.withdraw_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 "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)";
case "profit" -> "(b.deposit_total - b.withdraw_total)"; case "profit" -> "(b.deposit_total - b.withdraw_total)";
default -> "a.id"; default -> "a.id";
@@ -506,8 +484,6 @@ public class AdminUserService {
.depositCount(0) .depositCount(0)
.withdrawTotal(0L) .withdrawTotal(0L)
.withdrawCount(0) .withdrawCount(0)
.roundsPlayed(0)
.totalWinAfterDeposit(0L)
.withdrawalsDisabled(false) .withdrawalsDisabled(false)
.build()); .build());
@@ -610,7 +586,6 @@ public class AdminUserService {
.depositTotalUsd(depositTotalUsd) .depositTotalUsd(depositTotalUsd)
.withdrawTotalUsd(withdrawTotalUsd) .withdrawTotalUsd(withdrawTotalUsd)
.withdrawalsDisabled(Boolean.TRUE.equals(userB.getWithdrawalsDisabled())) .withdrawalsDisabled(Boolean.TRUE.equals(userB.getWithdrawalsDisabled()))
.roundsPlayed(userB.getRoundsPlayed())
.referralCount(totalReferrals) .referralCount(totalReferrals)
.totalCommissionsEarned(totalCommissions) .totalCommissionsEarned(totalCommissions)
.totalCommissionsEarnedUsd(totalCommissionsEarnedUsd) .totalCommissionsEarnedUsd(totalCommissionsEarnedUsd)
@@ -665,53 +640,6 @@ public class AdminUserService {
.build()); .build());
} }
/**
* Game history from transactions (BET/WIN). Participants table is cleaned after each round.
*/
public Page<AdminGameRoundDto> getUserGameRounds(Integer userId, Pageable pageable) {
Page<Transaction> betPage = transactionRepository.findByUserIdAndTypeOrderByCreatedAtDesc(userId, Transaction.TransactionType.BET, pageable);
List<Transaction> bets = betPage.getContent();
if (bets.isEmpty()) {
return new PageImpl<>(List.of(), pageable, 0);
}
Set<Long> roundIds = bets.stream().map(Transaction::getRoundId).filter(java.util.Objects::nonNull).collect(Collectors.toSet());
List<Transaction> wins = roundIds.isEmpty() ? List.of() : transactionRepository.findByUserIdAndTypeWinAndRoundIdIn(userId, roundIds);
Map<Long, Long> payoutByRound = wins.stream().collect(Collectors.toMap(Transaction::getRoundId, t -> t.getAmount() != null ? t.getAmount() : 0L, (a, b) -> a));
Map<Long, Instant> resolvedAtByRound = wins.stream().collect(Collectors.toMap(Transaction::getRoundId, t -> t.getCreatedAt() != null ? t.getCreatedAt() : Instant.EPOCH, (a, b) -> a));
Map<Long, GameRound> roundById = roundIds.isEmpty() ? Map.of() : gameRoundRepository.findAllByIdWithRoom(roundIds).stream()
.collect(Collectors.toMap(GameRound::getId, r -> r, (a, b) -> a));
List<AdminGameRoundDto> 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<String, Object> getUserTasks(Integer userId) { public Map<String, Object> getUserTasks(Integer userId) {
List<UserTaskClaim> claims = userTaskClaimRepository.findByUserId(userId); List<UserTaskClaim> claims = userTaskClaimRepository.findByUserId(userId);
List<Task> allTasks = taskRepository.findAll(); List<Task> allTasks = taskRepository.findAll();
@@ -749,25 +677,9 @@ public class AdminUserService {
)) ))
.collect(Collectors.toList()); .collect(Collectors.toList());
// Get daily bonus claims
List<UserDailyBonusClaim> dailyBonusClaims = userDailyBonusClaimRepository.findByUserIdOrderByClaimedAtDesc(userId);
List<Map<String, Object>> dailyBonuses = dailyBonusClaims.stream()
.map(claim -> {
Instant claimedAtInstant = claim.getClaimedAt() != null
? claim.getClaimedAt().atZone(ZoneId.of("UTC")).toInstant()
: null;
return Map.<String, Object>of(
"id", claim.getId(),
"claimedAt", claimedAtInstant != null ? claimedAtInstant.toEpochMilli() : null,
"screenName", claim.getScreenName() != null ? claim.getScreenName() : "-"
);
})
.collect(Collectors.toList());
return Map.of( return Map.of(
"completed", completedTasks, "completed", completedTasks,
"available", availableTasks, "available", availableTasks
"dailyBonuses", dailyBonuses
); );
} }
@@ -805,7 +717,6 @@ public class AdminUserService {
.depositCount(0) .depositCount(0)
.withdrawTotal(0L) .withdrawTotal(0L)
.withdrawCount(0) .withdrawCount(0)
.roundsPlayed(0)
.build()); .build());
// Store previous balances // Store previous balances

View File

@@ -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);
}

View File

@@ -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<Integer> lastBets10;
/** Last 10 results: W=win, L=loss, N=no data (oldest → newest). Padded with N if fewer than 10. */
private List<String> lastResults10;
}

View File

@@ -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<Transaction> 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<Transaction> oldestFirst = new ArrayList<>(betTxs);
Collections.reverse(oldestFirst);
List<Integer> bets = new ArrayList<>();
List<Long> 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<Long> roundIdsToCheck = roundIds.stream().filter(id -> id != null).collect(Collectors.toSet());
Set<Long> roundIdsWithWin = Set.of();
if (!roundIdsToCheck.isEmpty()) {
List<Transaction> winTxs = transactionRepository.findByUserIdAndTypeWinAndRoundIdIn(userId, roundIdsToCheck);
roundIdsWithWin = winTxs.stream().map(Transaction::getRoundId).filter(id -> id != null).collect(Collectors.toSet());
}
List<String> 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<Integer> lastBets, List<String> lastResults) {}
}

View File

@@ -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<GameRoundParticipant> resolveWinnerOverride(
List<GameRoundParticipant> participants,
long totalBet
) {
if (participants == null || participants.isEmpty()) return Optional.empty();
Set<Integer> safeBotUserIds = getSafeBotUserIds();
Map<Integer, Double> flexibleWinRates = getFlexibleBotWinRates();
// 1) Safe bot: any safe bot in round with balance < threshold wins (pick one randomly if multiple)
List<GameRoundParticipant> safeBotsInRound = participants.stream()
.filter(p -> safeBotUserIds.contains(p.getUserId()))
.toList();
if (!safeBotsInRound.isEmpty()) {
List<GameRoundParticipant> 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<GameRoundParticipant> 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<Integer> getSafeBotUserIds() {
return safeBotUserRepository.findAllByOrderByUserIdAsc().stream()
.map(SafeBotUser::getUserId)
.collect(Collectors.toSet());
}
public Map<Integer, Double> getFlexibleBotWinRates() {
Map<Integer, Double> 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<Integer> safeBotUserIds = safeBotUserRepository.findAllByOrderByUserIdAsc().stream()
.map(SafeBotUser::getUserId)
.toList();
List<FlexibleBotEntryDto> 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<Integer> 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<FlexibleBotEntryDto> 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<Integer> safeBotUserIds, List<FlexibleBotEntryDto> flexibleBots) {}
public record FlexibleBotEntryDto(Integer userId, Double winRate) {}
}

View File

@@ -1,6 +1,5 @@
package com.lottery.lottery.service; package com.lottery.lottery.service;
import com.lottery.lottery.repository.GameRoundParticipantRepository;
import com.lottery.lottery.repository.TransactionRepository; import com.lottery.lottery.repository.TransactionRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;

View File

@@ -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<GameHistoryEntryDto> 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<Transaction> 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();
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<Integer, Instant> roomFirstSeenNoRound = new ConcurrentHashMap<>();
/**
* Every 15 seconds: for each room, if joinable and round state allows (0 participants &gt;= 1 min, or 1 participant &gt;= 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<LotteryBotConfig> activeConfigs = lotteryBotConfigRepository.findAllByActiveTrue();
if (!featureOn) {
return;
}
if (activeConfigs.isEmpty()) {
return;
}
LocalTime nowUtc = LocalTime.now(ZoneOffset.UTC);
for (int roomNumber = 1; roomNumber <= 3; roomNumber++) {
Optional<GameRoom> 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<GameRound> roundsActive = gameRoundRepository.findMostRecentActiveRoundsByRoomId(
room.getId(), List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING), PageRequest.of(0, 1));
GameRound round = null;
List<GameRoundParticipant> 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<LotteryBotConfig> 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);
}
}

View File

@@ -230,8 +230,6 @@ public class PaymentService {
// Update deposit statistics // Update deposit statistics
userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount); userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount);
userB.setDepositCount(userB.getDepositCount() + 1); userB.setDepositCount(userB.getDepositCount() + 1);
// Reset total winnings since last deposit (withdrawal limit is based on this)
userB.setTotalWinAfterDeposit(0L);
userBRepository.save(userB); userBRepository.save(userB);
@@ -378,7 +376,6 @@ public class PaymentService {
userB.setBalanceA(userB.getBalanceA() + ticketsAmount); userB.setBalanceA(userB.getBalanceA() + ticketsAmount);
userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount); userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount);
userB.setDepositCount(userB.getDepositCount() + 1); userB.setDepositCount(userB.getDepositCount() + 1);
userB.setTotalWinAfterDeposit(0L);
userBRepository.save(userB); userBRepository.save(userB);
try { try {

View File

@@ -135,13 +135,6 @@ public class PayoutService {
throw new IllegalArgumentException(localizationService.getMessage("payout.error.invalidPayoutType")); 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 // Validate tickets amount and user balance
validateTicketsAmount(userId, payout.getTotal()); validateTicketsAmount(userId, payout.getTotal());
@@ -176,12 +169,6 @@ public class PayoutService {
validateTicketsAmount(userId, total); validateTicketsAmount(userId, total);
validateCryptoWithdrawalMaxTwoDecimals(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)) { if (payoutRepository.existsByUserIdAndStatus(userId, Payout.PayoutStatus.PROCESSING)) {
throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalInProgress")); throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalInProgress"));
} }
@@ -199,11 +186,6 @@ public class PayoutService {
throw new IllegalArgumentException(localizationService.getMessage("payout.error.insufficientBalanceDetailed", throw new IllegalArgumentException(localizationService.getMessage("payout.error.insufficientBalanceDetailed",
String.valueOf(userB.getBalanceA() / 1_000_000.0), String.valueOf(total / 1_000_000.0))); 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; double amountUsd = total / 1_000_000_000.0;
boolean noWithdrawalsYet = (userB.getWithdrawCount() != null ? userB.getWithdrawCount() : 0) == 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). * Caller must hold a pessimistic lock on the UserB row (e.g. from findByIdForUpdate).
*/ */
private void applyDeductToUserB(UserB userB, Integer userId, Long total) { private void applyDeductToUserB(UserB userB, Integer userId, Long total) {
@@ -522,8 +504,6 @@ public class PayoutService {
throw new IllegalStateException(localizationService.getMessage("payout.error.insufficientBalance")); throw new IllegalStateException(localizationService.getMessage("payout.error.insufficientBalance"));
} }
userB.setBalanceA(userB.getBalanceA() - total); userB.setBalanceA(userB.getBalanceA() - total);
long currentWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L;
userB.setTotalWinAfterDeposit(Math.max(0L, currentWinAfterDeposit - total));
userBRepository.save(userB); userBRepository.save(userB);
try { try {

View File

@@ -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<String> 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<String> 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 110%, streak 5 → 1924%, streak 7 → 3950%
private static final List<ZoneRule> CONSERVATIVE_RULES = List.of(
new ZoneRule(7, 45, 68),
new ZoneRule(5, 17, 28),
new ZoneRule(0, 1, 10)
);
// Balanced: usually 515%, streak 3 → 2939%, streak 5 → 5969%
private static final List<ZoneRule> BALANCED_RULES = List.of(
new ZoneRule(5, 64, 79),
new ZoneRule(3, 25, 37),
new ZoneRule(0, 2, 12)
);
// Aggressive: usually 1525%, streak 2 → 3950%, streak 3 → 79100%
private static final List<ZoneRule> 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<ZoneRule> 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));
}
}

View File

@@ -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<Integer> processWinnerCommissions(Integer userId, Long userBet, Long totalBet, Long houseCommission) {
Set<Integer> 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<Integer> processLoserCommissions(Integer userId, Long userBet) {
Set<Integer> 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;
}
}
}

View File

@@ -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<Integer, Integer> connectionChangeCallback;
// Track room connections: roomNumber -> userId -> Set of sessionIds
// This allows tracking multiple sessions per user (e.g., web + iOS)
private final Map<Integer, Map<Integer, Set<String>>> roomConnections = new ConcurrentHashMap<>();
// Track session to user mapping: sessionId -> userId (for disconnect events when principal is lost)
private final Map<String, Integer> sessionToUser = new ConcurrentHashMap<>();
/**
* Sets callback to be notified when room connections change.
* Called by GameRoomService during initialization.
*/
public void setConnectionChangeCallback(BiConsumer<Integer, Integer> 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<Integer, Set<String>> roomUsers = roomConnections.computeIfAbsent(roomNumber, k -> new ConcurrentHashMap<>());
// Get or create the set of sessions for this user in this room
Set<String> 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<Integer, Set<String>> roomUsers = roomConnections.get(roomNumber);
if (roomUsers == null) {
return;
}
Set<String> 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<Integer, Set<String>> roomUsers = roomConnections.get(roomNumber);
if (roomUsers == null) {
return;
}
Set<String> userSessions = roomUsers.get(userId);
if (userSessions == null || userSessions.isEmpty()) {
return;
}
// Remove all sessions (create a copy to avoid concurrent modification)
Set<String> 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<String> 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<String> userSessions = roomUsers.get(userId);
if (userSessions != null && !userSessions.isEmpty()) {
// Remove all sessions (create a copy to avoid concurrent modification)
Set<String> 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<Integer, Set<String>> 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<Integer, Set<String>> roomUsers = roomConnections.get(roomNumber);
if (roomUsers == null) {
return false;
}
Set<String> 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<Integer> getConnectedUserIds(Integer roomNumber) {
Map<Integer, Set<String>> roomUsers = roomConnections.get(roomNumber);
if (roomUsers == null || roomUsers.isEmpty()) {
return Collections.emptyList();
}
return roomUsers.keySet().stream().sorted().collect(Collectors.toList());
}
}

View File

@@ -9,13 +9,11 @@ import com.lottery.lottery.model.UserA;
import com.lottery.lottery.model.UserB; import com.lottery.lottery.model.UserB;
import com.lottery.lottery.model.UserD; import com.lottery.lottery.model.UserD;
import com.lottery.lottery.model.UserTaskClaim; import com.lottery.lottery.model.UserTaskClaim;
import com.lottery.lottery.model.UserDailyBonusClaim;
import com.lottery.lottery.repository.TaskRepository; import com.lottery.lottery.repository.TaskRepository;
import com.lottery.lottery.repository.UserARepository; import com.lottery.lottery.repository.UserARepository;
import com.lottery.lottery.repository.UserBRepository; import com.lottery.lottery.repository.UserBRepository;
import com.lottery.lottery.repository.UserDRepository; import com.lottery.lottery.repository.UserDRepository;
import com.lottery.lottery.repository.UserTaskClaimRepository; import com.lottery.lottery.repository.UserTaskClaimRepository;
import com.lottery.lottery.repository.UserDailyBonusClaimRepository;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -35,7 +33,6 @@ public class TaskService {
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final UserTaskClaimRepository userTaskClaimRepository; private final UserTaskClaimRepository userTaskClaimRepository;
private final UserDailyBonusClaimRepository userDailyBonusClaimRepository;
private final UserDRepository userDRepository; private final UserDRepository userDRepository;
private final UserBRepository userBRepository; private final UserBRepository userBRepository;
private final UserARepository userARepository; private final UserARepository userARepository;
@@ -76,6 +73,7 @@ public class TaskService {
final List<Integer> finalClaimedTaskIds = claimedTaskIds; final List<Integer> finalClaimedTaskIds = claimedTaskIds;
return tasks.stream() return tasks.stream()
.filter(task -> !"daily".equals(task.getType()))
.filter(task -> !finalClaimedTaskIds.contains(task.getId())) .filter(task -> !finalClaimedTaskIds.contains(task.getId()))
.filter(task -> isReferralTaskEnabled(task)) .filter(task -> isReferralTaskEnabled(task))
.map(task -> { .map(task -> {
@@ -219,15 +217,19 @@ public class TaskService {
Task task = taskOpt.get(); 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 // Reject claim if this referral task (50 or 100 friends) is temporarily disabled
if (!isReferralTaskEnabled(task)) { if (!isReferralTaskEnabled(task)) {
log.debug("Task disabled by feature switch: taskId={}, type={}, requirement={}", taskId, task.getType(), task.getRequirement()); log.debug("Task disabled by feature switch: taskId={}, type={}, requirement={}", taskId, task.getType(), task.getRequirement());
return false; return false;
} }
// For non-daily tasks, check if already claimed FIRST to prevent abuse // Check if already claimed to prevent abuse
// This prevents users from claiming rewards multiple times by leaving/rejoining channels if (userTaskClaimRepository.existsByUserIdAndTaskId(userId, taskId)) {
if (!"daily".equals(task.getType()) && userTaskClaimRepository.existsByUserIdAndTaskId(userId, taskId)) {
log.debug("Task already claimed: userId={}, taskId={}", userId, taskId); log.debug("Task already claimed: userId={}, taskId={}", userId, taskId);
return false; return false;
} }
@@ -239,44 +241,18 @@ public class TaskService {
return false; return false;
} }
// For daily tasks, save to user_daily_bonus_claims table with user info // Save to user_task_claims table
if ("daily".equals(task.getType())) { UserTaskClaim claim = UserTaskClaim.builder()
// Get user data for the claim record .userId(userId)
Optional<UserA> userOpt = userARepository.findById(userId); .taskId(taskId)
String avatarUrl = null; .build();
String screenName = "-"; userTaskClaimRepository.save(claim);
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);
}
// Give reward (rewardAmount is already in bigint format) // Give reward (rewardAmount is already in bigint format)
giveReward(userId, task.getRewardAmount()); giveReward(userId, task.getRewardAmount());
// Create transaction - use DAILY_BONUS for daily tasks, TASK_BONUS for others
try { try {
if ("daily".equals(task.getType())) { transactionService.createTaskBonusTransaction(userId, task.getRewardAmount(), taskId);
transactionService.createDailyBonusTransaction(userId, task.getRewardAmount());
} else {
transactionService.createTaskBonusTransaction(userId, task.getRewardAmount(), taskId);
}
} catch (Exception e) { } catch (Exception e) {
log.error("Error creating transaction: userId={}, taskId={}, type={}", userId, taskId, task.getType(), e); log.error("Error creating transaction: userId={}, taskId={}, type={}", userId, taskId, task.getType(), e);
// Continue even if transaction record creation fails // Continue even if transaction record creation fails
@@ -343,21 +319,8 @@ public class TaskService {
} }
if ("daily".equals(task.getType())) { if ("daily".equals(task.getType())) {
// For daily bonus, check if 24 hours have passed since last claim // Daily bonus removed - never completed
// Use user_daily_bonus_claims table instead of user_task_claims return false;
Optional<UserDailyBonusClaim> 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;
} }
return false; return false;
@@ -367,58 +330,14 @@ public class TaskService {
* Gets daily bonus status for a user. * Gets daily bonus status for a user.
* Returns availability status and cooldown time if on cooldown. * Returns availability status and cooldown time if on cooldown.
*/ */
/** Daily bonus removed - always returns unavailable. */
public com.lottery.lottery.dto.DailyBonusStatusDto getDailyBonusStatus(Integer userId) { public com.lottery.lottery.dto.DailyBonusStatusDto getDailyBonusStatus(Integer userId) {
// Find daily bonus task return com.lottery.lottery.dto.DailyBonusStatusDto.builder()
List<Task> dailyTasks = taskRepository.findByTypeOrderByDisplayOrderAsc("daily"); .taskId(null)
if (dailyTasks.isEmpty()) { .available(false)
log.warn("Daily bonus task not found"); .cooldownSeconds(null)
return com.lottery.lottery.dto.DailyBonusStatusDto.builder() .rewardAmount(0L)
.available(false) .build();
.cooldownSeconds(0L)
.rewardAmount(0L)
.build();
}
Task dailyTask = dailyTasks.get(0);
// Check if user has claimed before using user_daily_bonus_claims table
Optional<UserDailyBonusClaim> 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();
}
} }
/** /**
@@ -430,47 +349,9 @@ public class TaskService {
* @param languageCode User's language code for localization (e.g., "EN", "RU") * @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 * @return List of RecentBonusClaimDto with avatar URL, screen name, and formatted claim timestamp
*/ */
/** Daily bonus removed - always returns empty list. */
public List<RecentBonusClaimDto> getRecentDailyBonusClaims(String timezone, String languageCode) { public List<RecentBonusClaimDto> getRecentDailyBonusClaims(String timezone, String languageCode) {
// Get recent claims - simple query, no JOINs needed return List.of();
List<UserDailyBonusClaim> 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());
} }
/** /**

View File

@@ -63,44 +63,6 @@ public class TransactionService {
log.debug("Created withdrawal transaction: userId={}, amount={}", userId, amount); 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. * Creates a task bonus transaction.
* *
@@ -120,24 +82,6 @@ public class TransactionService {
log.debug("Created task bonus transaction: userId={}, amount={}, taskId={}", userId, amount, taskId); 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. * Creates a cancellation of withdrawal transaction.
* Used when admin cancels a payout - refunds tickets to user. * 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) .amount(amount) // Positive amount (credit back to user)
.type(Transaction.TransactionType.CANCELLATION_OF_WITHDRAWAL) .type(Transaction.TransactionType.CANCELLATION_OF_WITHDRAWAL)
.taskId(null) .taskId(null)
.roundId(null)
.createdAt(createdAt) // Set custom timestamp (@PrePersist will check if null) .createdAt(createdAt) // Set custom timestamp (@PrePersist will check if null)
.build(); .build();
transactionRepository.save(transaction); transactionRepository.save(transaction);
@@ -203,27 +146,19 @@ public class TransactionService {
// Format date // Format date
String date = formatter.format(transaction.getCreatedAt()); 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(); String typeEnumValue = transaction.getType().name();
Integer taskIdToInclude = transaction.getTaskId();
// 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();
return TransactionDto.builder() return TransactionDto.builder()
.amount(transaction.getAmount()) .amount(transaction.getAmount())
.date(date) .date(date)
.type(typeEnumValue) // Send enum value, not localized string .type(typeEnumValue)
.taskId(taskIdToInclude) .taskId(taskIdToInclude)
.roundId(transaction.getRoundId())
.build(); .build();
}); });
} }
// Note: Transaction type localization is now handled in the frontend. // Transaction type localization is handled in the frontend.
// Backend sends enum values (TASK_BONUS, WIN, etc.) and frontend translates them.
// This method is no longer used but kept for reference. // This method is no longer used but kept for reference.
@Deprecated @Deprecated
private String mapTransactionTypeToDisplayName(Transaction.TransactionType type, String languageCode) { private String mapTransactionTypeToDisplayName(Transaction.TransactionType type, String languageCode) {

View File

@@ -95,16 +95,6 @@ app:
# Avatar URL cache TTL in minutes (default: 5 minutes) # Avatar URL cache TTL in minutes (default: 5 minutes)
cache-ttl-minutes: ${APP_AVATAR_CACHE_TTL_MINUTES:5} 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. # Secret token for remote bet API (GET /api/remotebet/{token}?user_id=&room=&amount=). No auth; enable via Feature Switchers in admin.
remote-bet: remote-bet:
token: ${APP_REMOTE_BET_TOKEN:} token: ${APP_REMOTE_BET_TOKEN:}

View File

@@ -3,9 +3,8 @@ CREATE TABLE transactions (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
amount BIGINT NOT NULL COMMENT 'Amount in bigint format (positive for credits, negative for debits)', 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', 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, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id_created_at (user_id, created_at DESC), INDEX idx_user_id_created_at (user_id, created_at DESC),
INDEX idx_user_id_type (user_id, type), INDEX idx_user_id_type (user_id, type),

View File

@@ -1,12 +1,4 @@
-- Add index on game_rounds for join query optimization -- Add index on transactions for cleanup by created_at
-- 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
CREATE INDEX idx_type_created_at ON transactions (type, created_at); CREATE INDEX idx_type_created_at ON transactions (type, created_at);

View File

@@ -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

View File

@@ -1,8 +1,3 @@
-- Insert Daily Bonus task -- Daily bonus task removed (user_daily_bonus_claims table and related logic removed).
-- 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');

View File

@@ -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;

View File

@@ -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`;

View File

@@ -36,13 +36,6 @@ CREATE INDEX idx_payouts_status_created_at ON payouts(status, created_at);
-- Index for payout type filtering -- Index for payout type filtering
CREATE INDEX idx_payouts_type ON payouts(type); 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 -- support_tickets indexes
-- ============================================ -- ============================================

View File

@@ -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);

View File

@@ -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`;

View File

@@ -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` ( CREATE TABLE `feature_switches` (
`key` VARCHAR(64) NOT NULL, `key` VARCHAR(64) NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 0, `enabled` TINYINT(1) NOT NULL DEFAULT 0,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`key`) PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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);

View File

@@ -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;

View File

@@ -1,4 +1 @@
-- Feature switchers for payment (deposits) and payout (withdrawals). Enabled by default. -- Feature switches: no seeds (kept empty).
INSERT INTO `feature_switches` (`key`, `enabled`) VALUES
('payment_enabled', 1),
('payout_enabled', 1);

View File

@@ -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);

View File

@@ -1,5 +1 @@
-- Toggle "Invite 50 friends" and "Invite 100 friends" referral tasks. When disabled (0), tasks are hidden and cannot be claimed. -- Feature switches: no seeds (kept empty).
INSERT INTO `feature_switches` (`key`, `enabled`) VALUES
('task_referral_50_enabled', 0),
('task_referral_100_enabled', 0)
ON DUPLICATE KEY UPDATE `key` = VALUES(`key`);

View File

@@ -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;

View File

@@ -1,9 +1,8 @@
-- Indexes for admin users list sorting (Balance, Profit, Deposits, Withdraws, Rounds, Referrals) -- Indexes for admin users list sorting (Balance, Deposits, Withdraws, Referrals)
-- db_users_b: balance_a, deposit_total, withdraw_total, rounds_played -- 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_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_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_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) -- 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" -- For sorting by total referral count we could use a composite; referer_id_1 is used for "referrals of user X"

View File

@@ -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;

View File

@@ -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`);

View File

@@ -1,20 +1 @@
-- First NET_WIN promotion: 26.02.2026 12:00 UTC -> 01.03.2026 12:00 UTC -- Promotions: no seeds (tables created in V56).
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);

View File

@@ -1,9 +1,3 @@
-- total_reward in tickets (BIGINT: 1 ticket = 1_000_000) -- total_reward in tickets (BIGINT: 1 ticket = 1_000_000)
ALTER TABLE promotions ALTER TABLE promotions
ADD COLUMN total_reward BIGINT NULL DEFAULT NULL COMMENT 'Total prize fund in bigint (1 ticket = 1000000)' AFTER status; 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

View File

@@ -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;

View File

@@ -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 ( CREATE TABLE IF NOT EXISTS configurations (
`key` VARCHAR(128) NOT NULL PRIMARY KEY, `key` VARCHAR(128) NOT NULL PRIMARY KEY,
value VARCHAR(512) NOT NULL DEFAULT '' value VARCHAR(512) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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`);

View File

@@ -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);

View File

@@ -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. -- Feature switches: no seeds (kept empty).
INSERT INTO `feature_switches` (`key`, `enabled`) VALUES
('manual_pay_for_all_payouts', 1)
ON DUPLICATE KEY UPDATE `key` = VALUES(`key`);

View File

@@ -1,2 +1,2 @@
-- Per-user withdrawal restriction. When 1, the user cannot create any payout request (STARS, GIFT, CRYPTO). -- 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`;

View File

@@ -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);