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>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Telegram Bot API -->
<dependency>
<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/deposit_webhook/**", // 3rd party deposit completion webhook (token in path, no auth)
"/api/notify_broadcast/**", // Notify broadcast start/stop (token in path, no auth)
"/api/remotebet/**", // Remote bet: token + feature switch protected, no user auth
"/api/admin/**" // Admin endpoints are handled by Spring Security
);

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

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.SupportTicket;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.repository.GameRoundRepository;
import com.lottery.lottery.repository.PaymentRepository;
import com.lottery.lottery.repository.PayoutRepository;
import com.lottery.lottery.repository.SupportTicketRepository;
@@ -31,7 +30,6 @@ public class AdminDashboardController {
private final UserARepository userARepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final GameRoundRepository gameRoundRepository;
private final SupportTicketRepository supportTicketRepository;
@GetMapping("/stats")
@@ -105,17 +103,6 @@ public class AdminDashboardController {
BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek);
BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth);
// Game Rounds
long totalRounds = gameRoundRepository.count();
long roundsToday = gameRoundRepository.countByResolvedAtAfter(todayStart);
long roundsWeek = gameRoundRepository.countByResolvedAtAfter(weekStart);
long roundsMonth = gameRoundRepository.countByResolvedAtAfter(monthStart);
// Average Round Pool (from resolved rounds) - round to int
Double avgPoolDouble = gameRoundRepository.avgTotalBetByResolvedAtAfter(monthStart)
.orElse(0.0);
int avgPool = (int) Math.round(avgPoolDouble);
// Support Tickets
long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED);
// Count tickets closed today
@@ -176,11 +163,11 @@ public class AdminDashboardController {
stats.put("crypto", crypto);
stats.put("rounds", Map.of(
"total", totalRounds,
"today", roundsToday,
"week", roundsWeek,
"month", roundsMonth,
"avgPool", avgPool
"total", 0L,
"today", 0L,
"week", 0L,
"month", 0L,
"avgPool", 0
));
stats.put("supportTickets", Map.of(

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(
"id", "screenName", "telegramId", "telegramName", "isPremium",
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
"balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit"
"balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit"
);
private static final Set<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");
@@ -57,17 +57,14 @@ public class AdminUserController {
@RequestParam(required = false) Integer dateRegTo,
@RequestParam(required = false) Long balanceMin,
@RequestParam(required = false) Long balanceMax,
@RequestParam(required = false) Integer roundsPlayedMin,
@RequestParam(required = false) Integer roundsPlayedMax,
@RequestParam(required = false) Integer referralCountMin,
@RequestParam(required = false) Integer referralCountMax,
@RequestParam(required = false) Integer referrerId,
@RequestParam(required = false) Integer referralLevel,
@RequestParam(required = false) String ip) {
// Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, roundsPlayed, referralCount)
// are handled in service via custom query; others are applied to UserA.
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit");
// Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, referralCount) are handled in service via custom query.
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null);
if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) {
// Pass through; service will use custom ordered query
@@ -96,8 +93,6 @@ public class AdminUserController {
dateRegTo,
balanceMinBigint,
balanceMaxBigint,
roundsPlayedMin,
roundsPlayedMax,
referralCountMin,
referralCountMax,
referrerId,
@@ -152,28 +147,6 @@ public class AdminUserController {
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/game-rounds")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<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")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
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)
Double tickets = balanceA / 1_000_000.0;
// Get rounds_played from db_users_b
Integer roundsPlayed = userBOpt.map(UserB::getRoundsPlayed).orElse(0);
// Get referer_id_1 from db_users_d
Optional<UserD> userDOpt = userDRepository.findById(userId);
Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0);
@@ -91,7 +88,6 @@ public class UserCheckController {
.tickets(tickets)
.depositTotal(depositTotal)
.refererId(refererId)
.roundsPlayed(roundsPlayed)
.build();
return ResponseEntity.ok(response);

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. */
private Boolean withdrawalsDisabled;
// Game Stats
private Integer roundsPlayed;
// Referral Info
private Integer referralCount;
private Long totalCommissionsEarned;

View File

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

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;
/**
* Transaction type: DEPOSIT, WITHDRAWAL, WIN, BET, TASK_BONUS, DAILY_BONUS
* Transaction type: DEPOSIT, WITHDRAWAL, TASK_BONUS, CANCELLATION_OF_WITHDRAWAL
*/
private String type;
/**
* Task ID for TASK_BONUS type (null for DAILY_BONUS and other types)
* Task ID for TASK_BONUS type (null for other types)
*/
private Integer taskId;
/**
* Round ID for WIN/BET type (null for other types)
*/
private Long roundId;
}

View File

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

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")
private Integer taskId; // Task ID for TASK_BONUS type
@Column(name = "round_id")
private Long roundId; // Round ID for WIN/BET type
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@@ -49,12 +46,7 @@ public class Transaction {
public enum TransactionType {
DEPOSIT, // Payment/deposit
WITHDRAWAL, // Payout/withdrawal
WIN, // Game round win (total payout)
BET, // Game round bet (for all participants, winners and losers)
@Deprecated
LOSS, // Legacy: Old bet type, replaced by BET (kept for backward compatibility with old database records)
TASK_BONUS, // Task reward
DAILY_BONUS, // Daily bonus reward (no taskId)
CANCELLATION_OF_WITHDRAWAL // Cancellation of withdrawal (payout cancelled by admin)
}
}

View File

@@ -40,15 +40,6 @@ public class UserB {
@Builder.Default
private Integer withdrawCount = 0;
@Column(name = "rounds_played", nullable = false)
@Builder.Default
private Integer roundsPlayed = 0;
/** Total winnings since last deposit (bigint: 1 ticket = 1_000_000). Reset to 0 on deposit; incremented on round win; reduced when payout is created. */
@Column(name = "total_win_after_deposit", nullable = false)
@Builder.Default
private Long totalWinAfterDeposit = 0L;
/** When true, the user cannot create any payout request (blocked on backend). */
@Column(name = "withdrawals_disabled", nullable = false)
@Builder.Default

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 org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -12,7 +10,6 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Set;
import java.time.Instant;
@Repository
@@ -24,13 +21,6 @@ public interface TransactionRepository extends JpaRepository<Transaction, Long>
*/
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).
* 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.
* Used to check if this is the user's 3rd bet for referral bonus.
*/
long countByUserIdAndType(Integer userId, Transaction.TransactionType type);
@@ -52,11 +41,5 @@ public interface TransactionRepository extends JpaRepository<Transaction, Long>
@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);
/** 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 UserDRepository userDRepository;
private final TransactionRepository transactionRepository;
private final GameRoundParticipantRepository gameRoundParticipantRepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final UserTaskClaimRepository userTaskClaimRepository;
private final TaskRepository taskRepository;
private final UserDailyBonusClaimRepository userDailyBonusClaimRepository;
private final EntityManager entityManager;
private final GameRoundRepository gameRoundRepository;
public Page<AdminUserDto> getUsers(
Pageable pageable,
@@ -61,8 +58,6 @@ public class AdminUserService {
Integer dateRegTo,
Long balanceMin,
Long balanceMax,
Integer roundsPlayedMin,
Integer roundsPlayedMax,
Integer referralCountMin,
Integer referralCountMax,
Integer referrerId,
@@ -142,8 +137,8 @@ public class AdminUserService {
predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp));
}
// Balance / rounds / referral filters via subqueries so DB handles pagination
if (balanceMin != null || balanceMax != null || roundsPlayedMin != null || roundsPlayedMax != null) {
// Balance / referral filters via subqueries so DB handles pagination
if (balanceMin != null || balanceMax != null) {
Subquery<Integer> subB = query.subquery(Integer.class);
Root<UserB> br = subB.from(UserB.class);
subB.select(br.get("id"));
@@ -156,8 +151,6 @@ public class AdminUserService {
subPreds.add(cb.lessThanOrEqualTo(
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMax));
}
if (roundsPlayedMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("roundsPlayed"), roundsPlayedMin));
if (roundsPlayedMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("roundsPlayed"), roundsPlayedMax));
subB.where(cb.and(subPreds.toArray(new Predicate[0])));
predicates.add(cb.in(root.get("id")).value(subB));
}
@@ -193,7 +186,7 @@ public class AdminUserService {
return cb.and(predicates.toArray(new Predicate[0]));
};
Set<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);
List<UserA> userList;
long totalElements;
@@ -202,7 +195,7 @@ public class AdminUserService {
List<Integer> orderedIds = getOrderedUserIdsForAdminList(
search, banned, countryCode, languageCode,
dateRegFrom, dateRegTo, balanceMin, balanceMax,
roundsPlayedMin, roundsPlayedMax, referralCountMin, referralCountMax,
referralCountMin, referralCountMax,
referrerId, referralLevel, ipFilter,
sortBy, sortDir != null ? sortDir : "desc",
pageable.getPageSize(), (int) pageable.getOffset(),
@@ -242,7 +235,6 @@ public class AdminUserService {
.depositCount(0)
.withdrawTotal(0L)
.withdrawCount(0)
.roundsPlayed(0)
.build());
UserD userD = userDMap.getOrDefault(userA.getId(),
@@ -280,7 +272,6 @@ public class AdminUserService {
.depositCount(userB.getDepositCount())
.withdrawTotal(userB.getWithdrawTotal())
.withdrawCount(userB.getWithdrawCount())
.roundsPlayed(userB.getRoundsPlayed())
.dateReg(userA.getDateReg())
.dateLogin(userA.getDateLogin())
.banned(userA.getBanned())
@@ -312,8 +303,6 @@ public class AdminUserService {
Integer dateRegTo,
Long balanceMin,
Long balanceMax,
Integer roundsPlayedMin,
Integer roundsPlayedMax,
Integer referralCountMin,
Integer referralCountMax,
Integer referrerId,
@@ -395,16 +384,6 @@ public class AdminUserService {
params.add(balanceMax);
paramIndex++;
}
if (roundsPlayedMin != null) {
sql.append(" AND b.rounds_played >= ?");
params.add(roundsPlayedMin);
paramIndex++;
}
if (roundsPlayedMax != null) {
sql.append(" AND b.rounds_played <= ?");
params.add(roundsPlayedMax);
paramIndex++;
}
if (referralCountMin != null || referralCountMax != null) {
sql.append(" AND (d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)");
if (referralCountMin != null && referralCountMax != null) {
@@ -436,11 +415,10 @@ public class AdminUserService {
}
}
String orderColumn = switch (sortBy) {
String orderColumn = switch (sortBy != null ? sortBy : "") {
case "balanceA" -> "b.balance_a";
case "depositTotal" -> "b.deposit_total";
case "withdrawTotal" -> "b.withdraw_total";
case "roundsPlayed" -> "b.rounds_played";
case "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)";
case "profit" -> "(b.deposit_total - b.withdraw_total)";
default -> "a.id";
@@ -506,8 +484,6 @@ public class AdminUserService {
.depositCount(0)
.withdrawTotal(0L)
.withdrawCount(0)
.roundsPlayed(0)
.totalWinAfterDeposit(0L)
.withdrawalsDisabled(false)
.build());
@@ -610,7 +586,6 @@ public class AdminUserService {
.depositTotalUsd(depositTotalUsd)
.withdrawTotalUsd(withdrawTotalUsd)
.withdrawalsDisabled(Boolean.TRUE.equals(userB.getWithdrawalsDisabled()))
.roundsPlayed(userB.getRoundsPlayed())
.referralCount(totalReferrals)
.totalCommissionsEarned(totalCommissions)
.totalCommissionsEarnedUsd(totalCommissionsEarnedUsd)
@@ -665,53 +640,6 @@ public class AdminUserService {
.build());
}
/**
* Game history from transactions (BET/WIN). Participants table is cleaned after each round.
*/
public Page<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) {
List<UserTaskClaim> claims = userTaskClaimRepository.findByUserId(userId);
List<Task> allTasks = taskRepository.findAll();
@@ -749,25 +677,9 @@ public class AdminUserService {
))
.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(
"completed", completedTasks,
"available", availableTasks,
"dailyBonuses", dailyBonuses
"available", availableTasks
);
}
@@ -805,7 +717,6 @@ public class AdminUserService {
.depositCount(0)
.withdrawTotal(0L)
.withdrawCount(0)
.roundsPlayed(0)
.build());
// Store previous balances

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;
import com.lottery.lottery.repository.GameRoundParticipantRepository;
import com.lottery.lottery.repository.TransactionRepository;
import lombok.RequiredArgsConstructor;
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
userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount);
userB.setDepositCount(userB.getDepositCount() + 1);
// Reset total winnings since last deposit (withdrawal limit is based on this)
userB.setTotalWinAfterDeposit(0L);
userBRepository.save(userB);
@@ -378,7 +376,6 @@ public class PaymentService {
userB.setBalanceA(userB.getBalanceA() + ticketsAmount);
userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount);
userB.setDepositCount(userB.getDepositCount() + 1);
userB.setTotalWinAfterDeposit(0L);
userBRepository.save(userB);
try {

View File

@@ -135,13 +135,6 @@ public class PayoutService {
throw new IllegalArgumentException(localizationService.getMessage("payout.error.invalidPayoutType"));
}
// Withdrawal cannot exceed total winnings since last deposit
long maxWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L;
if (payout.getTotal() > maxWinAfterDeposit) {
long maxTickets = maxWinAfterDeposit / 1_000_000L;
throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets)));
}
// Validate tickets amount and user balance
validateTicketsAmount(userId, payout.getTotal());
@@ -176,12 +169,6 @@ public class PayoutService {
validateTicketsAmount(userId, total);
validateCryptoWithdrawalMaxTwoDecimals(total);
long maxWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L;
if (total > maxWinAfterDeposit) {
long maxTickets = maxWinAfterDeposit / 1_000_000L;
throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets)));
}
if (payoutRepository.existsByUserIdAndStatus(userId, Payout.PayoutStatus.PROCESSING)) {
throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalInProgress"));
}
@@ -199,11 +186,6 @@ public class PayoutService {
throw new IllegalArgumentException(localizationService.getMessage("payout.error.insufficientBalanceDetailed",
String.valueOf(userB.getBalanceA() / 1_000_000.0), String.valueOf(total / 1_000_000.0)));
}
long maxWin = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L;
if (total > maxWin) {
long maxTickets = maxWin / 1_000_000L;
throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets)));
}
double amountUsd = total / 1_000_000_000.0;
boolean noWithdrawalsYet = (userB.getWithdrawCount() != null ? userB.getWithdrawCount() : 0) == 0;
@@ -514,7 +496,7 @@ public class PayoutService {
}
/**
* Applies balance and totalWinAfterDeposit deduction to an already-loaded (and locked) UserB.
* Applies balance deduction to an already-loaded (and locked) UserB.
* Caller must hold a pessimistic lock on the UserB row (e.g. from findByIdForUpdate).
*/
private void applyDeductToUserB(UserB userB, Integer userId, Long total) {
@@ -522,8 +504,6 @@ public class PayoutService {
throw new IllegalStateException(localizationService.getMessage("payout.error.insufficientBalance"));
}
userB.setBalanceA(userB.getBalanceA() - total);
long currentWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L;
userB.setTotalWinAfterDeposit(Math.max(0L, currentWinAfterDeposit - total));
userBRepository.save(userB);
try {

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

View File

@@ -63,44 +63,6 @@ public class TransactionService {
log.debug("Created withdrawal transaction: userId={}, amount={}", userId, amount);
}
/**
* Creates a win transaction.
*
* @param userId User ID
* @param amount Amount in bigint format (positive, total payout)
* @param roundId Round ID
*/
@Transactional
public void createWinTransaction(Integer userId, Long amount, Long roundId) {
Transaction transaction = Transaction.builder()
.userId(userId)
.amount(amount)
.type(Transaction.TransactionType.WIN)
.roundId(roundId)
.build();
transactionRepository.save(transaction);
log.debug("Created win transaction: userId={}, amount={}, roundId={}", userId, amount, roundId);
}
/**
* Creates a bet transaction.
*
* @param userId User ID
* @param amount Amount in bigint format (positive, will be stored as negative)
* @param roundId Round ID
*/
@Transactional
public void createBetTransaction(Integer userId, Long amount, Long roundId) {
Transaction transaction = Transaction.builder()
.userId(userId)
.amount(-amount) // Store as negative
.type(Transaction.TransactionType.BET)
.roundId(roundId)
.build();
transactionRepository.save(transaction);
log.debug("Created bet transaction: userId={}, amount={}, roundId={}", userId, amount, roundId);
}
/**
* Creates a task bonus transaction.
*
@@ -120,24 +82,6 @@ public class TransactionService {
log.debug("Created task bonus transaction: userId={}, amount={}, taskId={}", userId, amount, taskId);
}
/**
* Creates a daily bonus transaction.
*
* @param userId User ID
* @param amount Amount in bigint format (positive)
*/
@Transactional
public void createDailyBonusTransaction(Integer userId, Long amount) {
Transaction transaction = Transaction.builder()
.userId(userId)
.amount(amount)
.type(Transaction.TransactionType.DAILY_BONUS)
.taskId(null) // Daily bonus doesn't have taskId
.build();
transactionRepository.save(transaction);
log.debug("Created daily bonus transaction: userId={}, amount={}", userId, amount);
}
/**
* Creates a cancellation of withdrawal transaction.
* Used when admin cancels a payout - refunds tickets to user.
@@ -153,7 +97,6 @@ public class TransactionService {
.amount(amount) // Positive amount (credit back to user)
.type(Transaction.TransactionType.CANCELLATION_OF_WITHDRAWAL)
.taskId(null)
.roundId(null)
.createdAt(createdAt) // Set custom timestamp (@PrePersist will check if null)
.build();
transactionRepository.save(transaction);
@@ -203,27 +146,19 @@ public class TransactionService {
// Format date
String date = formatter.format(transaction.getCreatedAt());
// Send enum value as string (e.g., "TASK_BONUS", "WIN") - frontend will handle localization
String typeEnumValue = transaction.getType().name();
// For DAILY_BONUS, don't include taskId (it should be null)
// For TASK_BONUS, include taskId
Integer taskIdToInclude = (transaction.getType() == Transaction.TransactionType.DAILY_BONUS)
? null
: transaction.getTaskId();
Integer taskIdToInclude = transaction.getTaskId();
return TransactionDto.builder()
.amount(transaction.getAmount())
.date(date)
.type(typeEnumValue) // Send enum value, not localized string
.type(typeEnumValue)
.taskId(taskIdToInclude)
.roundId(transaction.getRoundId())
.build();
});
}
// Note: Transaction type localization is now handled in the frontend.
// Backend sends enum values (TASK_BONUS, WIN, etc.) and frontend translates them.
// Transaction type localization is handled in the frontend.
// This method is no longer used but kept for reference.
@Deprecated
private String mapTransactionTypeToDisplayName(Transaction.TransactionType type, String languageCode) {

View File

@@ -95,16 +95,6 @@ app:
# Avatar URL cache TTL in minutes (default: 5 minutes)
cache-ttl-minutes: ${APP_AVATAR_CACHE_TTL_MINUTES:5}
websocket:
# Allowed origins for WebSocket CORS (comma-separated)
# Default includes production domain and Telegram WebView domains
allowed-origins: ${APP_WEBSOCKET_ALLOWED_ORIGINS:https://win-spin.live,https://lottery-fe-production.up.railway.app,https://web.telegram.org,https://webk.telegram.org,https://webz.telegram.org}
# Lottery bot scheduler: auto-joins bots from lottery_bot_configs into joinable rounds. Toggle via admin Feature Switches (lottery_bot_scheduler_enabled).
# Bet amount is decided in-process by persona + loss-streak and zone logic (no external API).
lottery-bot:
schedule-fixed-delay-ms: ${APP_LOTTERY_BOT_SCHEDULE_FIXED_DELAY_MS:5000}
# Secret token for remote bet API (GET /api/remotebet/{token}?user_id=&room=&amount=). No auth; enable via Feature Switchers in admin.
remote-bet:
token: ${APP_REMOTE_BET_TOKEN:}

View File

@@ -3,9 +3,8 @@ CREATE TABLE transactions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount BIGINT NOT NULL COMMENT 'Amount in bigint format (positive for credits, negative for debits)',
type VARCHAR(50) NOT NULL COMMENT 'Transaction type: DEPOSIT, WITHDRAWAL, WIN, LOSS, TASK_BONUS',
type VARCHAR(50) NOT NULL COMMENT 'Transaction type: DEPOSIT, WITHDRAWAL, TASK_BONUS, CANCELLATION_OF_WITHDRAWAL',
task_id INT NULL COMMENT 'Task ID for TASK_BONUS type',
round_id BIGINT NULL COMMENT 'Round ID for WIN/LOSS type',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id_created_at (user_id, created_at DESC),
INDEX idx_user_id_type (user_id, type),

View File

@@ -1,12 +1,4 @@
-- Add index on game_rounds for join query optimization
-- This helps with the query: SELECT p FROM GameRoundParticipant p WHERE p.userId = :userId
-- AND p.round.phase = 'RESOLUTION' AND p.round.resolvedAt IS NOT NULL ORDER BY p.round.resolvedAt DESC
CREATE INDEX idx_round_phase_resolved ON game_rounds (id, phase, resolved_at DESC);
-- Add index on game_round_participants for cleanup by joined_at
CREATE INDEX idx_joined_at ON game_round_participants (joined_at);
-- Add index on transactions for game history queries (filtering by WIN type) and cleanup by created_at
-- Add index on transactions for cleanup by created_at
CREATE INDEX idx_type_created_at ON transactions (type, created_at);

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
-- reward_amount is in bigint format (1 ticket = 1000000)
-- requirement is 24 hours in milliseconds (86400000), but we'll use 0 as placeholder since we check claimed_at timestamp
-- The actual 24h check is done in TaskService.isTaskCompleted() for "daily" type
INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) VALUES
('daily', 0, 1000000, 'Tickets', 1, 'Daily Bonus', 'Claim 1 free ticket every 24 hours');
-- Daily bonus task removed (user_daily_bonus_claims table and related logic removed).

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

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` (
`key` VARCHAR(64) NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default: remote bet endpoint disabled until explicitly enabled from admin
INSERT INTO `feature_switches` (`key`, `enabled`) VALUES ('remote_bet_enabled', 1);

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.
INSERT INTO `feature_switches` (`key`, `enabled`) VALUES
('payment_enabled', 1),
('payout_enabled', 1);
-- Feature switches: no seeds (kept empty).

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

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)
-- db_users_b: balance_a, deposit_total, withdraw_total, rounds_played
-- Indexes for admin users list sorting (Balance, Deposits, Withdraws, Referrals)
-- db_users_b: balance_a, deposit_total, withdraw_total
CREATE INDEX idx_users_b_balance_a ON db_users_b(balance_a);
CREATE INDEX idx_users_b_deposit_total ON db_users_b(deposit_total);
CREATE INDEX idx_users_b_withdraw_total ON db_users_b(withdraw_total);
CREATE INDEX idx_users_b_rounds_played ON db_users_b(rounds_played);
-- db_users_d: for referral count (sum of referals_1..5) we filter by referer_id_N; indexes already exist (V34)
-- For sorting by total referral count we could use a composite; referer_id_1 is used for "referrals of user X"

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
INSERT INTO promotions (type, start_time, end_time, status) VALUES
('NET_WIN', '2026-02-26 12:00:00', '2026-03-01 12:00:00', 'PLANNED');
-- Rewards: 1 ticket = 1,000,000 in bigint
-- place 1: 50,000 tickets = 50000000000
-- place 2: 30,000 = 30000000000, 3: 20,000 = 20000000000, 4: 15,000 = 15000000000, 5: 10,000 = 10000000000
-- places 6-10: 5,000 each = 5000000000
SET @promo_id = LAST_INSERT_ID();
INSERT INTO promotions_rewards (promo_id, place, reward) VALUES
(@promo_id, 1, 50000000000),
(@promo_id, 2, 30000000000),
(@promo_id, 3, 20000000000),
(@promo_id, 4, 15000000000),
(@promo_id, 5, 10000000000),
(@promo_id, 6, 5000000000),
(@promo_id, 7, 5000000000),
(@promo_id, 8, 5000000000),
(@promo_id, 9, 5000000000),
(@promo_id, 10, 5000000000);
-- Promotions: no seeds (tables created in V56).

View File

@@ -1,9 +1,3 @@
-- total_reward in tickets (BIGINT: 1 ticket = 1_000_000)
ALTER TABLE promotions
ADD COLUMN total_reward BIGINT NULL DEFAULT NULL COMMENT 'Total prize fund in bigint (1 ticket = 1000000)' AFTER status;
-- First promo: 150 000 tickets = 150_000_000_000
UPDATE promotions SET total_reward = 150000000000 WHERE id = 1;
-- Index for filtering by status (already have idx_promotions_status)
-- total_reward is for display only, no extra index needed

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 (
`key` VARCHAR(128) NOT NULL PRIMARY KEY,
value VARCHAR(512) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Bots may join a round only when participant count <= this value (default 1 = join when 0 or 1 participant).
INSERT INTO configurations (`key`, value) VALUES ('lottery_bot_max_participants_before_join', '1')
ON DUPLICATE KEY UPDATE `key` = VALUES(`key`);

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

View File

@@ -1,2 +1,2 @@
-- Per-user withdrawal restriction. When 1, the user cannot create any payout request (STARS, GIFT, CRYPTO).
ALTER TABLE `db_users_b` ADD COLUMN `withdrawals_disabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `total_win_after_deposit`;
ALTER TABLE `db_users_b` ADD COLUMN `withdrawals_disabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `withdraw_count`;

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