Initial setup, cleanup, VPS setup
All checks were successful
Deploy to VPS / deploy (push) Successful in 52s

This commit is contained in:
Tihon
2026-03-07 23:10:41 +02:00
commit 15498c8337
305 changed files with 27812 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
package com.honey.honey;
import com.honey.honey.config.ConfigLoader;
import com.honey.honey.config.TelegramProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
@EnableConfigurationProperties({TelegramProperties.class})
public class HoneyBackendApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(HoneyBackendApplication.class);
app.addListeners(new ConfigLoader());
app.run(args);
}
}

View File

@@ -0,0 +1,138 @@
package com.honey.honey.config;
import com.honey.honey.security.admin.AdminDetailsService;
import com.honey.honey.security.admin.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class AdminSecurityConfig {
@Value("${FRONTEND_URL:}")
private String frontendUrl;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AdminDetailsService adminDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider adminAuthenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(adminDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager adminAuthenticationManager() {
return new ProviderManager(adminAuthenticationProvider());
}
/**
* Swagger/OpenAPI docs: permitAll with highest precedence so the default Spring Boot chain
* (which requires auth for /**) never handles these paths. Includes webjars and resources
* so the UI can load CSS/JS.
*/
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception {
RequestMatcher swaggerMatcher = new OrRequestMatcher(
new AntPathRequestMatcher("/swagger-ui/**"),
new AntPathRequestMatcher("/swagger-ui.html"),
new AntPathRequestMatcher("/v3/api-docs"),
new AntPathRequestMatcher("/v3/api-docs/**"),
new AntPathRequestMatcher("/webjars/**"),
new AntPathRequestMatcher("/swagger-resources/**"),
new AntPathRequestMatcher("/configuration/**")
);
http
.securityMatcher(swaggerMatcher)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/admin/**")
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/login").permitAll()
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "PAYOUT_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().denyAll()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> allowedOrigins = Stream.concat(
Stream.of(
"http://localhost:5173",
"http://localhost:3000"
),
frontendUrl != null && !frontendUrl.isBlank()
? Arrays.stream(frontendUrl.split("\\s*,\\s*")).filter(s -> !s.isBlank())
: Stream.empty()
).distinct().collect(Collectors.toList());
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(allowedOrigins);
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/admin/**", configuration);
return source;
}
}

View File

@@ -0,0 +1,82 @@
package com.honey.honey.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
/**
* Loads configuration from a mounted secret file (tmpfs) with fallback to environment variables.
* This allows switching between Railway (env vars) and Inferno (mounted file) deployments.
*
* Priority:
* 1. Mounted file at /run/secrets/honey-config.properties (Inferno)
* 2. Environment variables (Railway)
*/
@Slf4j
public class ConfigLoader implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
private static final String SECRET_FILE_PATH = "/run/secrets/honey-config.properties";
@Override
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
ConfigurableEnvironment environment = event.getEnvironment();
MutablePropertySources propertySources = environment.getPropertySources();
Map<String, Object> configProperties = new HashMap<>();
// Try to load from mounted file first (Inferno deployment)
File secretFile = new File(SECRET_FILE_PATH);
if (secretFile.exists() && secretFile.isFile() && secretFile.canRead()) {
log.info("📁 Loading configuration from mounted secret file: {}", SECRET_FILE_PATH);
try {
Properties props = new Properties();
try (FileInputStream fis = new FileInputStream(secretFile)) {
props.load(fis);
}
for (String key : props.stringPropertyNames()) {
String value = props.getProperty(key);
configProperties.put(key, value);
log.debug("Loaded from file: {} = {}", key, maskSensitiveValue(key, value));
}
log.info("✅ Successfully loaded {} properties from secret file", configProperties.size());
} catch (IOException e) {
log.warn("⚠️ Failed to read secret file, falling back to environment variables: {}", e.getMessage());
}
} else {
log.info("📝 Secret file not found at {}, using environment variables", SECRET_FILE_PATH);
}
// Environment variables are already loaded by Spring Boot by default
// We just add file-based config as a higher priority source if it exists
if (!configProperties.isEmpty()) {
propertySources.addFirst(new MapPropertySource("secretFileConfig", configProperties));
log.info("✅ Configuration loaded: {} properties from file, environment variables as fallback",
configProperties.size());
} else {
log.info("✅ Using environment variables for configuration");
}
}
private String maskSensitiveValue(String key, String value) {
if (value == null) return "null";
if (key.toLowerCase().contains("password") ||
key.toLowerCase().contains("token") ||
key.toLowerCase().contains("secret") ||
key.toLowerCase().contains("key")) {
return value.length() > 4 ? value.substring(0, 2) + "***" + value.substring(value.length() - 2) : "***";
}
return value;
}
}

View File

@@ -0,0 +1,53 @@
package com.honey.honey.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
public class CorsConfig {
@Value("${FRONTEND_URL:}")
private String frontendUrl;
private static final List<String> ADDITIONAL_ORIGINS = Arrays.asList(
"https://honey-test-fe-production.up.railway.app",
"https://web.telegram.org",
"https://webk.telegram.org",
"https://t.me"
);
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
List<String> origins = Stream.concat(
Arrays.stream(frontendUrl != null && !frontendUrl.isBlank()
? frontendUrl.split("\\s*,\\s*")
: new String[]{}),
ADDITIONAL_ORIGINS.stream()
).filter(s -> s != null && !s.isBlank()).distinct().collect(Collectors.toList());
if (origins.isEmpty()) {
origins = ADDITIONAL_ORIGINS;
}
registry.addMapping("/**")
.allowedOrigins(origins.toArray(new String[0]))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
};
}
}

View File

@@ -0,0 +1,61 @@
package com.honey.honey.config;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
@Configuration
public class LocaleConfig {
// Supported languages
public static final List<String> SUPPORTED_LANGUAGES = Arrays.asList(
"EN", "RU", "DE", "IT", "NL", "PL", "FR", "ES", "ID", "TR"
);
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setFallbackToSystemLocale(false);
return messageSource;
}
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.ENGLISH);
return resolver;
}
/**
* Converts language code (EN, RU, etc.) to Locale.
*/
public static Locale languageCodeToLocale(String languageCode) {
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
return Locale.ENGLISH;
}
String upperCode = languageCode.toUpperCase();
// Handle special cases
switch (upperCode) {
case "ID":
return new Locale("id"); // Indonesian
default:
try {
return new Locale(upperCode.toLowerCase());
} catch (Exception e) {
return Locale.ENGLISH; // Default fallback
}
}
}
}

View File

@@ -0,0 +1,33 @@
package com.honey.honey.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springdoc.core.models.GroupedOpenApi;
/**
* OpenAPI / Swagger configuration for the public API only.
* Admin endpoints (/api/admin/**) are excluded from the documentation.
*/
@Configuration
public class OpenApiConfig {
@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/**")
.pathsToExclude("/api/admin/**")
.build();
}
@Bean
public OpenAPI honeyOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Honey Public API")
.description("API for the Honey frontend. Admin panel endpoints are not included.")
.version("1.0"));
}
}

View File

@@ -0,0 +1,35 @@
package com.honey.honey.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "telegram")
@Data
public class TelegramProperties {
private String botToken;
/**
* Bot token for checking channel membership.
* Can be set via environment variable TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN
* or in mounted file at /run/secrets/honey-config.properties as telegram.channel-checker-bot-token
*/
private String channelCheckerBotToken;
/**
* Channel ID for follow tasks (e.g., "@win_spin_news" or numeric ID).
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID
* or in mounted file at /run/secrets/honey-config.properties as telegram.follow-task-channel-id
*/
private String followTaskChannelId;
/**
* Channel ID for follow withdrawals channel task (e.g., "@win_spin_withdrawals" or numeric ID).
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID_2
* or in mounted file at /run/secrets/honey-config.properties as telegram.follow-task-channel-id-2
*/
private String followTaskChannelId2;
}

View File

@@ -0,0 +1,51 @@
package com.honey.honey.config;
import com.honey.honey.security.AuthInterceptor;
import com.honey.honey.security.RateLimitInterceptor;
import com.honey.honey.security.UserRateLimitInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final RateLimitInterceptor rateLimitInterceptor;
private final UserRateLimitInterceptor userRateLimitInterceptor;
@Override
public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) {
// NOTE: Rate limiting is NOT applied to Telegram webhook endpoint
// Telegram sends webhooks from multiple IPs and we need to process all updates, especially payments
// Rate limiting interceptor is only for bot registration endpoint (if needed elsewhere)
// User session interceptor for all other authenticated endpoints
registry.addInterceptor(authInterceptor)
.excludePathPatterns(
"/ping",
"/actuator/**",
"/api/auth/tma/session", // Session creation endpoint doesn't require auth
"/api/telegram/webhook/**", // Telegram webhook (token in path, validated in controller)
"/avatars/**", // Avatar static files don't require auth (served by Nginx in production)
"/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/admin/**", // Admin endpoints are handled by Spring Security
// Swagger / OpenAPI docs (no auth required for documentation)
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs",
"/v3/api-docs/**",
"/webjars/**",
"/swagger-resources/**",
"/configuration/**"
);
// User-based rate limiting for payment creation and payout creation (applied after auth interceptor)
registry.addInterceptor(userRateLimitInterceptor)
.addPathPatterns("/api/payments/create", "/api/payouts");
}
}

View File

@@ -0,0 +1,201 @@
package com.honey.honey.controller;
import com.honey.honey.model.Payment;
import com.honey.honey.model.Payout;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/analytics")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController {
private final UserARepository userARepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
/**
* Get revenue and payout time series data for charts.
* @param range Time range: 7d, 30d, 90d, 1y, all
* @return Time series data with daily/weekly/monthly aggregation
*/
@GetMapping("/revenue")
public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
@RequestParam(defaultValue = "30d") String range) {
Instant now = Instant.now();
Instant startDate;
String granularity;
// Determine start date and granularity based on range
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0); // All time
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
// CRYPTO only: revenue and payouts in USD for this period
java.math.BigDecimal revenueUsd = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal payoutsUsd = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
Payout.PayoutStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("revenue", revenueUsd);
point.put("payouts", payoutsUsd);
point.put("netRevenue", netRevenueUsd);
dataPoints.add(point);
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
/**
* Get user activity time series data (registrations, active players, rounds).
* @param range Time range: 7d, 30d, 90d, 1y, all
* @return Time series data
*/
@GetMapping("/activity")
public ResponseEntity<Map<String, Object>> getActivityAnalytics(
@RequestParam(defaultValue = "30d") String range) {
Instant now = Instant.now();
Instant startDate;
String granularity;
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0);
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
// Convert to Unix timestamps for UserA queries
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
// Count new registrations in this period (between current and periodEnd)
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
// Count active players (logged in) in this period
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers);
point.put("rounds", 0L);
dataPoints.add(point);
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,181 @@
package com.honey.honey.controller;
import com.honey.honey.model.Payment;
import com.honey.honey.model.Payout;
import com.honey.honey.model.SupportTicket;
import com.honey.honey.model.UserA;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.SupportTicketRepository;
import com.honey.honey.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/dashboard")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminDashboardController {
private final UserARepository userARepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final SupportTicketRepository supportTicketRepository;
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getDashboardStats() {
Instant now = Instant.now();
Instant todayStart = now.truncatedTo(ChronoUnit.DAYS);
Instant weekStart = now.minus(7, ChronoUnit.DAYS);
Instant monthStart = now.minus(30, ChronoUnit.DAYS);
Instant dayAgo = now.minus(24, ChronoUnit.HOURS);
Instant weekAgo = now.minus(7, ChronoUnit.DAYS);
Instant monthAgo = now.minus(30, ChronoUnit.DAYS);
// Convert to Unix timestamps (seconds) for UserA date fields
int todayStartTs = (int) todayStart.getEpochSecond();
int weekStartTs = (int) weekStart.getEpochSecond();
int monthStartTs = (int) monthStart.getEpochSecond();
int dayAgoTs = (int) dayAgo.getEpochSecond();
int weekAgoTs = (int) weekAgo.getEpochSecond();
int monthAgoTs = (int) monthAgo.getEpochSecond();
Map<String, Object> stats = new HashMap<>();
// Total Users
long totalUsers = userARepository.count();
long newUsersToday = userARepository.countByDateRegAfter(todayStartTs);
long newUsersWeek = userARepository.countByDateRegAfter(weekStartTs);
long newUsersMonth = userARepository.countByDateRegAfter(monthStartTs);
// Active Players (users who logged in recently)
long activePlayers24h = userARepository.countByDateLoginAfter(dayAgoTs);
long activePlayers7d = userARepository.countByDateLoginAfter(weekAgoTs);
long activePlayers30d = userARepository.countByDateLoginAfter(monthAgoTs);
// Revenue (from completed payments) - in Stars
int totalRevenue = paymentRepository.sumStarsAmountByStatus(Payment.PaymentStatus.COMPLETED)
.orElse(0);
int revenueToday = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, todayStart).orElse(0);
int revenueWeek = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, weekStart).orElse(0);
int revenueMonth = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, monthStart).orElse(0);
// Payouts (from completed payouts) - in Stars
int totalPayouts = payoutRepository.sumStarsAmountByStatus(Payout.PayoutStatus.COMPLETED)
.orElse(0);
int payoutsToday = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, todayStart).orElse(0);
int payoutsWeek = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, weekStart).orElse(0);
int payoutsMonth = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, monthStart).orElse(0);
// Net Revenue (in Stars)
int netRevenue = totalRevenue - totalPayouts;
int netRevenueToday = revenueToday - payoutsToday;
int netRevenueWeek = revenueWeek - payoutsWeek;
int netRevenueMonth = revenueMonth - payoutsMonth;
// CRYPTO only: revenue and payouts in USD (for dashboard / financial analytics)
BigDecimal cryptoRevenueTotal = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNull(Payment.PaymentStatus.COMPLETED).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueToday = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueWeek = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueMonth = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsTotal = payoutRepository.sumUsdAmountByTypeCryptoAndStatus(Payout.PayoutStatus.COMPLETED).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsToday = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsWeek = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsMonth = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoNetRevenueTotal = cryptoRevenueTotal.subtract(cryptoPayoutsTotal);
BigDecimal cryptoNetRevenueToday = cryptoRevenueToday.subtract(cryptoPayoutsToday);
BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek);
BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth);
// Support Tickets
long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED);
// Count tickets closed today
long ticketsResolvedToday = supportTicketRepository.findAll().stream()
.filter(t -> t.getStatus() == SupportTicket.TicketStatus.CLOSED &&
t.getUpdatedAt() != null &&
t.getUpdatedAt().isAfter(todayStart))
.count();
// Build response
stats.put("users", Map.of(
"total", totalUsers,
"newToday", newUsersToday,
"newWeek", newUsersWeek,
"newMonth", newUsersMonth
));
stats.put("activePlayers", Map.of(
"last24h", activePlayers24h,
"last7d", activePlayers7d,
"last30d", activePlayers30d
));
stats.put("revenue", Map.of(
"total", totalRevenue,
"today", revenueToday,
"week", revenueWeek,
"month", revenueMonth
));
stats.put("payouts", Map.of(
"total", totalPayouts,
"today", payoutsToday,
"week", payoutsWeek,
"month", payoutsMonth
));
stats.put("netRevenue", Map.of(
"total", netRevenue,
"today", netRevenueToday,
"week", netRevenueWeek,
"month", netRevenueMonth
));
Map<String, Object> crypto = new HashMap<>();
crypto.put("revenueUsd", cryptoRevenueTotal);
crypto.put("revenueUsdToday", cryptoRevenueToday);
crypto.put("revenueUsdWeek", cryptoRevenueWeek);
crypto.put("revenueUsdMonth", cryptoRevenueMonth);
crypto.put("payoutsUsd", cryptoPayoutsTotal);
crypto.put("payoutsUsdToday", cryptoPayoutsToday);
crypto.put("payoutsUsdWeek", cryptoPayoutsWeek);
crypto.put("payoutsUsdMonth", cryptoPayoutsMonth);
crypto.put("profitUsd", cryptoNetRevenueTotal);
crypto.put("profitUsdToday", cryptoNetRevenueToday);
crypto.put("profitUsdWeek", cryptoNetRevenueWeek);
crypto.put("profitUsdMonth", cryptoNetRevenueMonth);
stats.put("crypto", crypto);
stats.put("rounds", Map.of(
"total", 0L,
"today", 0L,
"week", 0L,
"month", 0L,
"avgPool", 0
));
stats.put("supportTickets", Map.of(
"open", openTickets,
"resolvedToday", ticketsResolvedToday
));
return ResponseEntity.ok(stats);
}
}

View File

@@ -0,0 +1,36 @@
package com.honey.honey.controller;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.FeatureSwitchService.FeatureSwitchDto;
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/feature-switches")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminFeatureSwitchController {
private final FeatureSwitchService featureSwitchService;
@GetMapping
public ResponseEntity<List<FeatureSwitchDto>> getAll() {
return ResponseEntity.ok(featureSwitchService.getAll());
}
@PatchMapping("/{key}")
public ResponseEntity<FeatureSwitchDto> update(
@PathVariable String key,
@RequestBody Map<String, Boolean> body) {
Boolean enabled = body != null ? body.get("enabled") : null;
if (enabled == null) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(featureSwitchService.setEnabled(key, enabled));
}
}

View File

@@ -0,0 +1,51 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminLoginRequest;
import com.honey.honey.dto.AdminLoginResponse;
import com.honey.honey.service.AdminService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminLoginController {
private final AdminService adminService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) {
if (request.getUsername() == null || request.getPassword() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Username and password are required");
}
Optional<String> tokenOpt = adminService.authenticate(
request.getUsername(),
request.getPassword()
);
if (tokenOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
// Get admin to retrieve role
var adminOpt = adminService.getAdminByUsername(request.getUsername());
String role = adminOpt.map(admin -> admin.getRole()).orElse("ROLE_ADMIN");
return ResponseEntity.ok(new AdminLoginResponse(
tokenOpt.get(),
request.getUsername(),
role
));
}
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminMasterDto;
import com.honey.honey.service.AdminMasterService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/masters")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminMasterController {
private final AdminMasterService adminMasterService;
@GetMapping
public ResponseEntity<List<AdminMasterDto>> getMasters() {
return ResponseEntity.ok(adminMasterService.getMasters());
}
}

View File

@@ -0,0 +1,42 @@
package com.honey.honey.controller;
import com.honey.honey.dto.NotifyBroadcastRequest;
import com.honey.honey.service.NotificationBroadcastService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* Admin API to trigger or stop notification broadcast (ADMIN only).
*/
@Slf4j
@RestController
@RequestMapping("/api/admin/notifications")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminNotificationController {
private final NotificationBroadcastService notificationBroadcastService;
@PostMapping("/send")
public ResponseEntity<Void> send(@RequestBody(required = false) NotifyBroadcastRequest body) {
NotifyBroadcastRequest req = body != null ? body : new NotifyBroadcastRequest();
notificationBroadcastService.runBroadcast(
req.getMessage(),
req.getImageUrl(),
req.getVideoUrl(),
req.getUserIdFrom(),
req.getUserIdTo(),
req.getButtonText(),
req.getIgnoreBlocked());
return ResponseEntity.accepted().build();
}
@PostMapping("/stop")
public ResponseEntity<Void> stop() {
notificationBroadcastService.requestStop();
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,147 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminPaymentDto;
import com.honey.honey.model.Payment;
import com.honey.honey.model.UserA;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserDRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payments")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public class AdminPaymentController {
private final PaymentRepository paymentRepository;
private final UserARepository userARepository;
private final UserDRepository userDRepository;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
public ResponseEntity<Map<String, Object>> getPayments(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
// Filters
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer userId,
@RequestParam(required = false) String dateFrom,
@RequestParam(required = false) String dateTo,
@RequestParam(required = false) Long amountMin,
@RequestParam(required = false) Long amountMax) {
// Build sort
Sort sort = Sort.by("createdAt").descending(); // Default sort
if (sortBy != null && !sortBy.trim().isEmpty()) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir)
? Sort.Direction.ASC
: Sort.Direction.DESC;
sort = Sort.by(direction, sortBy);
}
Pageable pageable = PageRequest.of(page, size, sort);
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payment> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (!masterIds.isEmpty()) {
predicates.add(cb.not(root.get("userId").in(masterIds)));
}
if (status != null && !status.trim().isEmpty()) {
try {
Payment.PaymentStatus paymentStatus = Payment.PaymentStatus.valueOf(status.toUpperCase());
predicates.add(cb.equal(root.get("status"), paymentStatus));
} catch (IllegalArgumentException e) {
// Invalid status, ignore
}
}
if (userId != null) {
predicates.add(cb.equal(root.get("userId"), userId));
}
// Date range filters would need Instant conversion
// For now, we'll skip them or implement if needed
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<Payment> paymentPage = paymentRepository.findAll(spec, pageable);
// Fetch user names
List<Integer> userIds = paymentPage.getContent().stream()
.map(Payment::getUserId)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> userNameMap = userARepository.findAllById(userIds).stream()
.collect(Collectors.toMap(
UserA::getId,
u -> u.getTelegramName() != null && !u.getTelegramName().equals("-")
? u.getTelegramName()
: u.getScreenName()
));
// Convert to DTOs
Page<AdminPaymentDto> dtoPage = paymentPage.map(payment -> {
String userName = userNameMap.getOrDefault(payment.getUserId(), "Unknown");
return AdminPaymentDto.builder()
.id(payment.getId())
.userId(payment.getUserId())
.userName(userName)
.orderId(payment.getOrderId())
.starsAmount(payment.getStarsAmount())
.ticketsAmount(payment.getTicketsAmount())
.status(payment.getStatus().name())
.telegramPaymentChargeId(payment.getTelegramPaymentChargeId())
.telegramProviderPaymentChargeId(payment.getTelegramProviderPaymentChargeId())
.createdAt(payment.getCreatedAt())
.completedAt(payment.getCompletedAt())
.build();
});
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,204 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminPayoutDto;
import com.honey.honey.model.Payout;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserBRepository;
import com.honey.honey.repository.UserDRepository;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.PayoutService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.persistence.criteria.Predicate;
import org.springframework.http.HttpStatus;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payouts")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT', 'GAME_ADMIN')")
public class AdminPayoutController {
private final PayoutRepository payoutRepository;
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final PayoutService payoutService;
private final LocalizationService localizationService;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
public ResponseEntity<Map<String, Object>> getPayouts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
// Filters
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer userId,
@RequestParam(required = false) String type) {
// Build sort
Sort sort = Sort.by("createdAt").descending(); // Default sort
if (sortBy != null && !sortBy.trim().isEmpty()) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir)
? Sort.Direction.ASC
: Sort.Direction.DESC;
sort = Sort.by(direction, sortBy);
}
Pageable pageable = PageRequest.of(page, size, sort);
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payout> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (!masterIds.isEmpty()) {
predicates.add(cb.not(root.get("userId").in(masterIds)));
}
if (status != null && !status.trim().isEmpty()) {
try {
Payout.PayoutStatus payoutStatus = Payout.PayoutStatus.valueOf(status.toUpperCase());
predicates.add(cb.equal(root.get("status"), payoutStatus));
} catch (IllegalArgumentException e) {
// Invalid status, ignore
}
}
if (userId != null) {
predicates.add(cb.equal(root.get("userId"), userId));
}
if (type != null && !type.trim().isEmpty()) {
try {
Payout.PayoutType payoutType = Payout.PayoutType.valueOf(type.toUpperCase());
predicates.add(cb.equal(root.get("type"), payoutType));
} catch (IllegalArgumentException e) {
// Invalid type, ignore
}
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<Payout> payoutPage = payoutRepository.findAll(spec, pageable);
// Fetch user names
List<Integer> userIds = payoutPage.getContent().stream()
.map(Payout::getUserId)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> userNameMap = userARepository.findAllById(userIds).stream()
.collect(Collectors.toMap(
UserA::getId,
u -> u.getTelegramName() != null && !u.getTelegramName().equals("-")
? u.getTelegramName()
: u.getScreenName()
));
// Convert to DTOs
Page<AdminPayoutDto> dtoPage = payoutPage.map(payout -> {
String userName = userNameMap.getOrDefault(payout.getUserId(), "Unknown");
return AdminPayoutDto.builder()
.id(payout.getId())
.userId(payout.getUserId())
.userName(userName)
.username(payout.getUsername())
.type(payout.getType().name())
.giftName(payout.getGiftName() != null ? payout.getGiftName().name() : null)
.total(payout.getTotal())
.starsAmount(payout.getStarsAmount())
.quantity(payout.getQuantity())
.status(payout.getStatus().name())
.createdAt(payout.getCreatedAt())
.resolvedAt(payout.getResolvedAt())
.build();
});
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@PostMapping("/{id}/complete")
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
public ResponseEntity<?> completePayout(@PathVariable Long id) {
try {
Optional<Payout> payoutOpt = payoutRepository.findById(id);
if (payoutOpt.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.notFound", String.valueOf(id))));
}
Payout payout = payoutOpt.get();
if (payout.getStatus() != Payout.PayoutStatus.PROCESSING) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.onlyProcessingCanComplete", payout.getStatus().name())));
}
payoutService.markPayoutCompleted(id);
return ResponseEntity.ok(Map.of("message", "Payout marked as completed"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
public ResponseEntity<?> cancelPayout(@PathVariable Long id) {
try {
Optional<Payout> payoutOpt = payoutRepository.findById(id);
if (payoutOpt.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.notFound", String.valueOf(id))));
}
Payout payout = payoutOpt.get();
if (payout.getStatus() != Payout.PayoutStatus.PROCESSING) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.onlyProcessingCanCancel", payout.getStatus().name())));
}
payoutService.markPayoutCancelled(id);
return ResponseEntity.ok(Map.of("message", "Payout cancelled"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,139 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.service.AdminPromotionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin/promotions")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminPromotionController {
private final AdminPromotionService adminPromotionService;
@GetMapping
public ResponseEntity<List<AdminPromotionDto>> listPromotions() {
return ResponseEntity.ok(adminPromotionService.listPromotions());
}
@GetMapping("/{id}")
public ResponseEntity<AdminPromotionDto> getPromotion(@PathVariable int id) {
return adminPromotionService.getPromotion(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<AdminPromotionDto> createPromotion(@Valid @RequestBody AdminPromotionRequest request) {
try {
AdminPromotionDto dto = adminPromotionService.createPromotion(request);
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PutMapping("/{id}")
public ResponseEntity<AdminPromotionDto> updatePromotion(
@PathVariable int id,
@Valid @RequestBody AdminPromotionRequest request) {
try {
return adminPromotionService.updatePromotion(id, request)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePromotion(@PathVariable int id) {
return adminPromotionService.deletePromotion(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
// --- Rewards ---
@GetMapping("/{promoId}/rewards")
public ResponseEntity<List<AdminPromotionRewardDto>> listRewards(@PathVariable int promoId) {
return ResponseEntity.ok(adminPromotionService.listRewards(promoId));
}
@PostMapping("/{promoId}/rewards")
public ResponseEntity<AdminPromotionRewardDto> createReward(
@PathVariable int promoId,
@Valid @RequestBody AdminPromotionRewardRequest request) {
try {
AdminPromotionRewardDto dto = adminPromotionService.createReward(promoId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PutMapping("/rewards/{rewardId}")
public ResponseEntity<AdminPromotionRewardDto> updateReward(
@PathVariable int rewardId,
@Valid @RequestBody AdminPromotionRewardRequest request) {
try {
return adminPromotionService.updateReward(rewardId, request)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@DeleteMapping("/rewards/{rewardId}")
public ResponseEntity<Void> deleteReward(@PathVariable int rewardId) {
return adminPromotionService.deleteReward(rewardId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
// --- Promotion users (leaderboard / results) ---
@GetMapping("/{promoId}/users")
public ResponseEntity<Map<String, Object>> getPromotionUsers(
@PathVariable int promoId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
@RequestParam(required = false) Integer userId) {
Page<AdminPromotionUserDto> dtoPage = adminPromotionService.getPromotionUsers(
promoId, page, size, sortBy, sortDir, userId);
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@PatchMapping("/{promoId}/users/{userId}/points")
public ResponseEntity<AdminPromotionUserDto> updatePromotionUserPoints(
@PathVariable int promoId,
@PathVariable int userId,
@Valid @RequestBody AdminPromotionUserPointsRequest request) {
Optional<AdminPromotionUserDto> updated = adminPromotionService.updatePromotionUserPoints(
promoId, userId, request.getPoints());
return updated.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,316 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.model.Admin;
import com.honey.honey.model.SupportMessage;
import com.honey.honey.model.SupportTicket;
import com.honey.honey.model.UserA;
import com.honey.honey.repository.AdminRepository;
import com.honey.honey.repository.SupportMessageRepository;
import com.honey.honey.repository.SupportTicketRepository;
import com.honey.honey.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.persistence.criteria.Predicate;
import jakarta.validation.Valid;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/tickets")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
public class AdminSupportTicketController {
private final SupportTicketRepository supportTicketRepository;
private final SupportMessageRepository supportMessageRepository;
private final UserARepository userARepository;
private final AdminRepository adminRepository;
@GetMapping
public ResponseEntity<Map<String, Object>> getTickets(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer userId,
@RequestParam(required = false) String search) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
// Build specification
Specification<SupportTicket> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (status != null && !status.trim().isEmpty()) {
try {
SupportTicket.TicketStatus ticketStatus = SupportTicket.TicketStatus.valueOf(status.toUpperCase());
predicates.add(cb.equal(root.get("status"), ticketStatus));
} catch (IllegalArgumentException e) {
// Invalid status, ignore
}
}
if (userId != null) {
predicates.add(cb.equal(root.get("user").get("id"), userId));
}
if (search != null && !search.trim().isEmpty()) {
String searchPattern = "%" + search.trim() + "%";
predicates.add(cb.or(
cb.like(cb.lower(root.get("subject")), searchPattern.toLowerCase())
));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<SupportTicket> ticketPage = supportTicketRepository.findAll(spec, pageable);
// Fetch user names and message counts
List<Integer> userIds = ticketPage.getContent().stream()
.map(t -> t.getUser().getId())
.distinct()
.collect(Collectors.toList());
Map<Integer, String> userNameMap = userARepository.findAllById(userIds).stream()
.collect(Collectors.toMap(
UserA::getId,
u -> u.getTelegramName() != null && !u.getTelegramName().equals("-")
? u.getTelegramName()
: u.getScreenName()
));
// Convert to DTOs
Page<AdminSupportTicketDto> dtoPage = ticketPage.map(ticket -> {
String userName = userNameMap.getOrDefault(ticket.getUser().getId(), "Unknown");
long messageCount = supportMessageRepository.countByTicketId(ticket.getId());
// Get last message preview
List<SupportMessage> lastMessages = supportMessageRepository.findByTicketIdOrderByCreatedAtAsc(ticket.getId());
String lastMessagePreview = "";
Instant lastMessageAt = null;
if (!lastMessages.isEmpty()) {
SupportMessage lastMsg = lastMessages.get(lastMessages.size() - 1);
lastMessagePreview = lastMsg.getMessage().length() > 100
? lastMsg.getMessage().substring(0, 100) + "..."
: lastMsg.getMessage();
lastMessageAt = lastMsg.getCreatedAt();
}
return AdminSupportTicketDto.builder()
.id(ticket.getId())
.userId(ticket.getUser().getId())
.userName(userName)
.subject(ticket.getSubject())
.status(ticket.getStatus().name())
.createdAt(ticket.getCreatedAt())
.updatedAt(ticket.getUpdatedAt())
.messageCount(messageCount)
.lastMessagePreview(lastMessagePreview)
.lastMessageAt(lastMessageAt)
.build();
});
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<AdminSupportTicketDetailDto> getTicketDetail(@PathVariable Long id) {
return supportTicketRepository.findById(id)
.map(ticket -> {
String userName = ticket.getUser().getTelegramName() != null && !ticket.getUser().getTelegramName().equals("-")
? ticket.getUser().getTelegramName()
: ticket.getUser().getScreenName();
List<SupportMessage> messages = supportMessageRepository.findByTicketIdOrderByCreatedAtAsc(id);
// Get all admin user IDs from admins table
List<Integer> adminUserIds = adminRepository.findAll().stream()
.filter(admin -> admin.getUserId() != null)
.map(Admin::getUserId)
.collect(Collectors.toList());
List<AdminSupportMessageDto> messageDtos = messages.stream()
.map(msg -> {
// Check if message is from admin by checking if user_id is in admins table
boolean isAdmin = adminUserIds.contains(msg.getUser().getId());
String msgUserName = msg.getUser().getTelegramName() != null && !msg.getUser().getTelegramName().equals("-")
? msg.getUser().getTelegramName()
: msg.getUser().getScreenName();
return AdminSupportMessageDto.builder()
.id(msg.getId())
.userId(msg.getUser().getId())
.userName(msgUserName)
.message(msg.getMessage())
.createdAt(msg.getCreatedAt())
.isAdmin(isAdmin)
.build();
})
.collect(Collectors.toList());
return AdminSupportTicketDetailDto.builder()
.id(ticket.getId())
.userId(ticket.getUser().getId())
.userName(userName)
.subject(ticket.getSubject())
.status(ticket.getStatus().name())
.createdAt(ticket.getCreatedAt())
.updatedAt(ticket.getUpdatedAt())
.messages(messageDtos)
.build();
})
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/reply")
public ResponseEntity<?> replyToTicket(
@PathVariable Long id,
@Valid @RequestBody SupportTicketReplyRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String adminUsername;
if (authentication.getPrincipal() instanceof UserDetails) {
adminUsername = ((UserDetails) authentication.getPrincipal()).getUsername();
} else {
// Fallback if principal is a String
adminUsername = authentication.getName();
}
// Get admin entity to find user_id
Admin admin = adminRepository.findByUsername(adminUsername)
.orElseThrow(() -> new RuntimeException("Admin not found: " + adminUsername));
if (admin.getUserId() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "Admin account is not linked to a user account"));
}
// Get the admin's UserA entity
UserA adminUser = userARepository.findById(admin.getUserId())
.orElseThrow(() -> new RuntimeException("Admin user account not found: " + admin.getUserId()));
return supportTicketRepository.findById(id)
.map(ticket -> {
// Create message without prefix, using admin's user_id
SupportMessage message = SupportMessage.builder()
.ticket(ticket)
.user(adminUser) // Use admin's UserA entity
.message(request.getMessage()) // Save message as-is, no prefix
.build();
supportMessageRepository.save(message);
// Update ticket updated_at
ticket.setUpdatedAt(java.time.Instant.now());
if (ticket.getStatus() == SupportTicket.TicketStatus.CLOSED) {
ticket.setStatus(SupportTicket.TicketStatus.OPENED); // Reopen if admin replies
}
supportTicketRepository.save(ticket);
return ResponseEntity.ok(Map.of("message", "Reply sent successfully"));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/close")
public ResponseEntity<?> closeTicket(@PathVariable Long id) {
return supportTicketRepository.findById(id)
.map(ticket -> {
ticket.setStatus(SupportTicket.TicketStatus.CLOSED);
ticket.setUpdatedAt(java.time.Instant.now());
supportTicketRepository.save(ticket);
return ResponseEntity.ok(Map.of("message", "Ticket closed"));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/reopen")
public ResponseEntity<?> reopenTicket(@PathVariable Long id) {
return supportTicketRepository.findById(id)
.map(ticket -> {
ticket.setStatus(SupportTicket.TicketStatus.OPENED);
ticket.setUpdatedAt(java.time.Instant.now());
supportTicketRepository.save(ticket);
return ResponseEntity.ok(Map.of("message", "Ticket reopened"));
})
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/messages/{messageId}")
public ResponseEntity<?> editMessage(
@PathVariable Long messageId,
@Valid @RequestBody SupportTicketReplyRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String adminUsername;
if (authentication.getPrincipal() instanceof UserDetails) {
adminUsername = ((UserDetails) authentication.getPrincipal()).getUsername();
} else {
adminUsername = authentication.getName();
}
// Get admin entity to find user_id
Admin admin = adminRepository.findByUsername(adminUsername)
.orElseThrow(() -> new RuntimeException("Admin not found: " + adminUsername));
if (admin.getUserId() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "Admin account is not linked to a user account"));
}
return supportMessageRepository.findById(messageId)
.map(message -> {
// Check if message is from this admin
if (!message.getUser().getId().equals(admin.getUserId())) {
return ResponseEntity.badRequest().body(Map.of("error", "You can only edit your own messages"));
}
// Check if user is an admin (verify in admins table)
boolean isAdmin = adminRepository.findAll().stream()
.anyMatch(a -> a.getUserId() != null && a.getUserId().equals(message.getUser().getId()));
if (!isAdmin) {
return ResponseEntity.badRequest().body(Map.of("error", "Only admin messages can be edited"));
}
// Update message
message.setMessage(request.getMessage());
supportMessageRepository.save(message);
// Update ticket updated_at
message.getTicket().setUpdatedAt(java.time.Instant.now());
supportTicketRepository.save(message.getTicket());
return ResponseEntity.ok(Map.of("message", "Message updated successfully"));
})
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,250 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.service.AdminUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
public class AdminUserController {
/** Sortable fields: UserA properties plus UserB/UserD (handled via custom query in service). */
private static final Set<String> SORTABLE_FIELDS = Set.of(
"id", "screenName", "telegramId", "telegramName", "isPremium",
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
"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");
private final AdminUserService adminUserService;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
// Filters
@RequestParam(required = false) String search,
@RequestParam(required = false) Integer banned,
@RequestParam(required = false) String countryCode,
@RequestParam(required = false) String languageCode,
@RequestParam(required = false) Integer dateRegFrom,
@RequestParam(required = false) Integer dateRegTo,
@RequestParam(required = false) Long balanceMin,
@RequestParam(required = false) Long balanceMax,
@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, 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
} else if (effectiveSortBy != null && !SORTABLE_FIELDS.contains(effectiveSortBy)) {
effectiveSortBy = null;
}
Sort sort = Sort.by("id").descending();
if (effectiveSortBy != null && !sortRequiresJoin.contains(effectiveSortBy)) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
sort = Sort.by(direction, effectiveSortBy);
}
Pageable pageable = PageRequest.of(page, size, sort);
// Convert balance filters from tickets (divide by 1000000) to bigint format
Long balanceMinBigint = balanceMin != null ? balanceMin * 1000000L : null;
Long balanceMaxBigint = balanceMax != null ? balanceMax * 1000000L : null;
boolean excludeMasters = isGameAdmin();
Page<AdminUserDto> dtoPage = adminUserService.getUsers(
pageable,
search,
banned,
countryCode,
languageCode,
dateRegFrom,
dateRegTo,
balanceMinBigint,
balanceMaxBigint,
referralCountMin,
referralCountMax,
referrerId,
referralLevel,
ip,
effectiveSortBy,
sortDir,
excludeMasters
);
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<AdminUserDetailDto> getUserDetail(@PathVariable Integer id) {
AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isGameAdmin());
if (userDetail == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(userDetail);
}
@GetMapping("/{id}/transactions")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserTransactions(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
Page<AdminTransactionDto> transactions = adminUserService.getUserTransactions(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", transactions.getContent());
response.put("totalElements", transactions.getTotalElements());
response.put("totalPages", transactions.getTotalPages());
response.put("currentPage", transactions.getNumber());
response.put("size", transactions.getSize());
response.put("hasNext", transactions.hasNext());
response.put("hasPrevious", transactions.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/payments")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserPayments(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir) {
String field = sortBy != null && DEPOSIT_SORT_FIELDS.contains(sortBy.trim()) ? sortBy.trim() : "createdAt";
Sort.Direction dir = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, field));
Page<UserDepositDto> deposits = adminUserService.getUserPayments(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", deposits.getContent());
response.put("totalElements", deposits.getTotalElements());
response.put("totalPages", deposits.getTotalPages());
response.put("currentPage", deposits.getNumber());
response.put("size", deposits.getSize());
response.put("hasNext", deposits.hasNext());
response.put("hasPrevious", deposits.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/payouts")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserPayouts(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir) {
String field = sortBy != null && WITHDRAWAL_SORT_FIELDS.contains(sortBy.trim()) ? sortBy.trim() : "createdAt";
Sort.Direction dir = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, field));
Page<UserWithdrawalDto> payouts = adminUserService.getUserPayouts(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", payouts.getContent());
response.put("totalElements", payouts.getTotalElements());
response.put("totalPages", payouts.getTotalPages());
response.put("currentPage", payouts.getNumber());
response.put("size", payouts.getSize());
response.put("hasNext", payouts.hasNext());
response.put("hasPrevious", payouts.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/tasks")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserTasks(@PathVariable Integer id) {
Map<String, Object> tasks = adminUserService.getUserTasks(id);
return ResponseEntity.ok(tasks);
}
@PatchMapping("/{id}/ban")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> setUserBanned(
@PathVariable Integer id,
@RequestBody Map<String, Boolean> body) {
Boolean banned = body != null ? body.get("banned") : null;
if (banned == null) {
return ResponseEntity.badRequest().body(Map.of("error", "banned is required (true/false)"));
}
try {
adminUserService.setBanned(id, banned);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PatchMapping("/{id}/withdrawals-enabled")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<?> setWithdrawalsEnabled(
@PathVariable Integer id,
@RequestBody Map<String, Boolean> body) {
Boolean enabled = body != null ? body.get("enabled") : null;
if (enabled == null) {
return ResponseEntity.badRequest().body(Map.of("error", "enabled is required (true/false)"));
}
try {
adminUserService.setWithdrawalsEnabled(id, enabled);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{id}/balance/adjust")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> adjustBalance(
@PathVariable Integer id,
@Valid @RequestBody com.honey.honey.dto.BalanceAdjustmentRequest request) {
try {
com.honey.honey.dto.BalanceAdjustmentResponse response = adminUserService.adjustBalance(id, request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,99 @@
package com.honey.honey.controller;
import com.honey.honey.dto.CreateSessionRequest;
import com.honey.honey.dto.CreateSessionResponse;
import com.honey.honey.exception.BannedUserException;
import com.honey.honey.model.UserA;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.SessionService;
import com.honey.honey.service.TelegramAuthService;
import com.honey.honey.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final TelegramAuthService telegramAuthService;
private final SessionService sessionService;
private final UserService userService;
private final LocalizationService localizationService;
/**
* Creates a session by validating Telegram initData.
* This is the only endpoint that accepts initData.
* Handles user registration/login and referral system.
*/
@PostMapping("/tma/session")
public CreateSessionResponse createSession(
@RequestBody CreateSessionRequest request,
HttpServletRequest httpRequest) {
String initData = request.getInitData();
if (initData == null || initData.isBlank()) {
throw new IllegalArgumentException(localizationService.getMessage("auth.error.initDataRequired"));
}
// Validate Telegram initData signature and parse data
Map<String, Object> tgUserData = telegramAuthService.validateAndParseInitData(initData);
// Get or create user (handles registration, login update, and referral system)
// Note: Referral handling is done via bot registration endpoint, not through WebApp initData
UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
if (user.getBanned() != null && user.getBanned() == 1) {
String message = localizationService.getMessageForUser(user.getId(), "auth.error.accessRestricted");
throw new BannedUserException(message);
}
// Create session
String sessionId = sessionService.createSession(user);
log.debug("Session created: userId={}", user.getId());
return CreateSessionResponse.builder()
.access_token(sessionId)
.expires_in(sessionService.getSessionTtlSeconds())
.build();
}
/**
* Logs out by invalidating the session.
* This endpoint requires authentication (Bearer token).
*/
@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void logout(@RequestHeader(value = "Authorization", required = false) String authHeader) {
if (authHeader == null) {
log.warn("Logout called without Authorization header");
return;
}
String sessionId = extractBearerToken(authHeader);
if (sessionId != null) {
sessionService.invalidateSession(sessionId);
log.debug("Session invalidated via logout");
}
}
/**
* Extracts Bearer token from Authorization header.
*/
private String extractBearerToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,56 @@
package com.honey.honey.controller;
import com.honey.honey.dto.ExternalDepositWebhookRequest;
import com.honey.honey.service.PaymentService;
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.*;
/**
* Controller for 3rd party deposit completion webhook.
* Path: POST /api/deposit_webhook/{token}. Token must match app.deposit-webhook.token (APP_DEPOSIT_WEBHOOK_TOKEN).
* No session auth; token in path only. Set the token on VPS via environment variable.
*/
@Slf4j
@RestController
@RequestMapping("/api/deposit_webhook")
@RequiredArgsConstructor
public class DepositWebhookController {
@Value("${app.deposit-webhook.token:}")
private String expectedToken;
private final PaymentService paymentService;
/**
* Called by 3rd party when a user's crypto deposit was successful.
* Body: user_id (internal id from db_users_a), usd_amount (decimal, e.g. 1.45).
*/
@PostMapping("/{token}")
public ResponseEntity<Void> onDepositCompleted(
@PathVariable String token,
@RequestBody ExternalDepositWebhookRequest request) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
log.warn("Deposit webhook rejected: invalid token");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (request == null || request.getUserId() == null || request.getUsdAmount() == null) {
return ResponseEntity.badRequest().build();
}
try {
paymentService.processExternalDepositCompletion(
request.getUserId(),
request.getUsdAmount());
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
log.warn("Deposit webhook rejected: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Deposit webhook error: userId={}, usdAmount={}", request.getUserId(), request.getUsdAmount(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -0,0 +1,56 @@
package com.honey.honey.controller;
import com.honey.honey.dto.NotifyBroadcastRequest;
import com.honey.honey.service.NotificationBroadcastService;
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.*;
/**
* Public API to trigger or stop notification broadcast. Token in path; no secrets in codebase.
* Set APP_NOTIFY_BROADCAST_TOKEN on VPS.
*/
@Slf4j
@RestController
@RequestMapping("/api/notify_broadcast")
@RequiredArgsConstructor
public class NotifyBroadcastController {
@Value("${app.notify-broadcast.token:}")
private String expectedToken;
private final NotificationBroadcastService notificationBroadcastService;
@PostMapping("/{token}")
public ResponseEntity<Void> start(
@PathVariable String token,
@RequestBody(required = false) NotifyBroadcastRequest body) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
log.warn("Notify broadcast rejected: invalid token");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
NotifyBroadcastRequest req = body != null ? body : new NotifyBroadcastRequest();
notificationBroadcastService.runBroadcast(
req.getMessage(),
req.getImageUrl(),
req.getVideoUrl(),
req.getUserIdFrom(),
req.getUserIdTo(),
req.getButtonText(),
req.getIgnoreBlocked());
return ResponseEntity.accepted().build();
}
@PostMapping("/{token}/stop")
public ResponseEntity<Void> stop(@PathVariable String token) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
log.warn("Notify broadcast stop rejected: invalid token");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
notificationBroadcastService.requestStop();
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,237 @@
package com.honey.honey.controller;
import com.honey.honey.dto.CryptoWithdrawalResponse;
import com.honey.honey.dto.CreateCryptoWithdrawalRequest;
import com.honey.honey.dto.CreatePaymentRequest;
import com.honey.honey.dto.DepositAddressRequest;
import com.honey.honey.dto.DepositAddressResultDto;
import com.honey.honey.dto.DepositMethodsDto;
import com.honey.honey.dto.ErrorResponse;
import com.honey.honey.dto.PaymentInvoiceResponse;
import com.honey.honey.model.Payout;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.dto.WithdrawalMethodDetailsDto;
import com.honey.honey.dto.WithdrawalMethodsDto;
import com.honey.honey.service.CryptoDepositService;
import com.honey.honey.service.CryptoWithdrawalService;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.PaymentService;
import com.honey.honey.service.PayoutService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
private final CryptoDepositService cryptoDepositService;
private final CryptoWithdrawalService cryptoWithdrawalService;
private final PayoutService payoutService;
private final FeatureSwitchService featureSwitchService;
private final LocalizationService localizationService;
/**
* Returns minimum deposit from DB only (no sync). Used by Store screen for validation.
*/
@GetMapping("/minimum-deposit")
public ResponseEntity<?> getMinimumDeposit() {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
return ResponseEntity.ok(java.util.Map.of("minimumDeposit", cryptoDepositService.getMinimumDeposit()));
}
/**
* Returns crypto deposit methods and minimum_deposit from DB only (sync is done every 10 min).
* Called when user opens Payment Options screen.
*/
@GetMapping("/deposit-methods")
public ResponseEntity<?> getDepositMethods() {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
DepositMethodsDto dto = cryptoDepositService.getDepositMethodsFromDb();
return ResponseEntity.ok(dto);
}
/**
* Returns crypto withdrawal methods from DB only (sync is done every 30 min).
* Called when user opens Payout screen.
*/
@GetMapping("/withdrawal-methods")
public ResponseEntity<?> getWithdrawalMethods() {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
WithdrawalMethodsDto dto = cryptoWithdrawalService.getWithdrawalMethodsFromDb();
return ResponseEntity.ok(dto);
}
/**
* Returns withdrawal method details (rate_usd, misha_fee_usd) from external API for the given pid.
* Called when user opens Payout Confirmation screen to show network fee and compute "You will receive".
*/
@GetMapping("/withdrawal-method-details")
public ResponseEntity<?> getWithdrawalMethodDetails(@RequestParam("pid") int pid) {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
return cryptoWithdrawalService.getWithdrawalMethodDetails(pid)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/**
* Creates a crypto withdrawal: calls external API, then on success creates payout and deducts balance.
* Uses in-memory lock to prevent double-submit. Validates deposit total and maxWinAfterDeposit.
*/
@PostMapping("/crypto-withdrawal")
public ResponseEntity<?> createCryptoWithdrawal(@RequestBody CreateCryptoWithdrawalRequest request) {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
try {
UserA user = UserContext.get();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
}
Payout payout = payoutService.createCryptoPayout(user.getId(), request);
return ResponseEntity.ok(CryptoWithdrawalResponse.builder()
.id(payout.getId())
.status(payout.getStatus().name())
.build());
} catch (IllegalArgumentException e) {
log.warn("Crypto withdrawal validation failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (IllegalStateException e) {
log.warn("Crypto withdrawal failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Crypto withdrawal error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Withdrawal failed. Please try again later."));
}
}
/**
* Creates a payment invoice for the current user.
* Returns invoice data that frontend will use to open Telegram payment UI.
*/
@PostMapping("/create")
public ResponseEntity<?> createPaymentInvoice(@RequestBody CreatePaymentRequest request) {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
try {
UserA user = UserContext.get();
PaymentInvoiceResponse response = paymentService.createPaymentInvoice(user.getId(), request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Payment invoice creation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Payment invoice creation error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Failed to create payment invoice: " + e.getMessage()));
}
}
/**
* Gets a crypto deposit address from the external API (no payment record is created).
* Call when user selects a payment method on Payment Options screen.
* Returns address, amount_coins, name, network for the Payment Confirmation screen.
*/
@PostMapping("/deposit-address")
public ResponseEntity<?> getDepositAddress(@RequestBody DepositAddressRequest request) {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
try {
UserA user = UserContext.get();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
}
DepositAddressResultDto result = paymentService.requestCryptoDepositAddress(
user.getId(), request.getPid(), request.getUsdAmount());
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
log.warn("Deposit address request failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Deposit address error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(e.getMessage() != null ? e.getMessage() : "Failed to get deposit address"));
}
}
/**
* Cancels a payment (e.g., when user cancels in Telegram UI).
*/
@PostMapping("/cancel")
public ResponseEntity<?> cancelPayment(@RequestBody CancelPaymentRequest request) {
try {
String orderId = request.getOrderId();
UserA caller = UserContext.get();
log.info("Payment cancel requested: orderId={}, callerUserId={}", orderId, caller != null ? caller.getId() : null);
paymentService.cancelPayment(orderId);
return ResponseEntity.ok().body(new PaymentWebhookResponse(true, "Payment cancelled"));
} catch (IllegalArgumentException e) {
log.warn("Payment cancellation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Payment cancellation error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Failed to cancel payment: " + e.getMessage()));
}
}
// Response DTOs
private static class PaymentWebhookResponse {
private final boolean success;
private final String message;
public PaymentWebhookResponse(boolean success, String message) {
this.success = success;
this.message = message;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
}
private static class CancelPaymentRequest {
private String orderId;
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
}
}

View File

@@ -0,0 +1,66 @@
package com.honey.honey.controller;
import com.honey.honey.dto.CreatePayoutRequest;
import com.honey.honey.dto.ErrorResponse;
import com.honey.honey.dto.PayoutHistoryEntryDto;
import com.honey.honey.dto.PayoutResponse;
import com.honey.honey.model.Payout;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.PayoutService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/payouts")
@RequiredArgsConstructor
public class PayoutController {
private final PayoutService payoutService;
/**
* Creates a payout request for the current user.
* Validates input and deducts balance if validation passes.
*/
@PostMapping
public ResponseEntity<?> createPayout(@RequestBody CreatePayoutRequest request) {
try {
UserA user = UserContext.get();
Payout payout = payoutService.createPayout(user.getId(), request);
PayoutResponse response = payoutService.toResponse(payout);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Payout validation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (IllegalStateException e) {
log.error("Payout creation failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(e.getMessage()));
}
}
/**
* Gets the last 20 payout history entries for the current user.
*
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
*/
@GetMapping("/history")
public List<PayoutHistoryEntryDto> getUserPayoutHistory(
@RequestParam(required = false) String timezone) {
UserA user = UserContext.get();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
return payoutService.getUserPayoutHistory(user.getId(), timezone, languageCode);
}
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class PingController {
@GetMapping("/ping")
public Map<String, String> ping() {
Map<String, String> response = new HashMap<>();
response.put("status", "ok");
return response;
}
}

View File

@@ -0,0 +1,43 @@
package com.honey.honey.controller;
import com.honey.honey.dto.PromotionDetailDto;
import com.honey.honey.dto.PromotionListItemDto;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.PublicPromotionService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* Public API: list and view promotion details (leaderboard, user progress).
* Excludes INACTIVE promotions. Requires Bearer auth (app user).
* When promotions feature switch is false, all endpoints return 404.
*/
@RestController
@RequestMapping("/api/promotions")
@RequiredArgsConstructor
public class PromotionController {
private final PublicPromotionService publicPromotionService;
private final FeatureSwitchService featureSwitchService;
@GetMapping
public ResponseEntity<List<PromotionListItemDto>> list() {
if (!featureSwitchService.isPromotionsEnabled()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(publicPromotionService.listForApp());
}
@GetMapping("/{id}")
public ResponseEntity<PromotionDetailDto> getDetail(@PathVariable int id) {
if (!featureSwitchService.isPromotionsEnabled()) {
return ResponseEntity.notFound().build();
}
return publicPromotionService.getDetailForApp(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,134 @@
package com.honey.honey.controller;
import com.honey.honey.dto.QuickAnswerCreateRequest;
import com.honey.honey.dto.QuickAnswerDto;
import com.honey.honey.model.Admin;
import com.honey.honey.model.QuickAnswer;
import com.honey.honey.repository.AdminRepository;
import com.honey.honey.repository.QuickAnswerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/quick-answers")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
public class QuickAnswerController {
private final QuickAnswerRepository quickAnswerRepository;
private final AdminRepository adminRepository;
/**
* Get current admin from authentication context
*/
private Admin getCurrentAdmin() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
return adminRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("Admin not found: " + username));
}
/**
* Get all quick answers for the current admin
*/
@GetMapping
public ResponseEntity<List<QuickAnswerDto>> getQuickAnswers() {
Admin admin = getCurrentAdmin();
List<QuickAnswer> quickAnswers = quickAnswerRepository.findByAdminIdOrderByCreatedAtDesc(admin.getId());
List<QuickAnswerDto> dtos = quickAnswers.stream()
.map(qa -> new QuickAnswerDto(
qa.getId(),
qa.getText(),
qa.getCreatedAt(),
qa.getUpdatedAt()
))
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
/**
* Create a new quick answer for the current admin
*/
@PostMapping
public ResponseEntity<QuickAnswerDto> createQuickAnswer(@Valid @RequestBody QuickAnswerCreateRequest request) {
Admin admin = getCurrentAdmin();
if (request.getText() == null || request.getText().trim().isEmpty()) {
return ResponseEntity.badRequest().build();
}
QuickAnswer quickAnswer = QuickAnswer.builder()
.admin(admin)
.text(request.getText().trim())
.build();
QuickAnswer saved = quickAnswerRepository.save(quickAnswer);
QuickAnswerDto dto = new QuickAnswerDto(
saved.getId(),
saved.getText(),
saved.getCreatedAt(),
saved.getUpdatedAt()
);
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
}
/**
* Update a quick answer (only if it belongs to the current admin)
*/
@PutMapping("/{id}")
public ResponseEntity<QuickAnswerDto> updateQuickAnswer(
@PathVariable Integer id,
@Valid @RequestBody QuickAnswerCreateRequest request) {
Admin admin = getCurrentAdmin();
QuickAnswer quickAnswer = quickAnswerRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Quick answer not found"));
// Verify that the quick answer belongs to the current admin
if (!quickAnswer.getAdmin().getId().equals(admin.getId())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (request.getText() == null || request.getText().trim().isEmpty()) {
return ResponseEntity.badRequest().build();
}
quickAnswer.setText(request.getText().trim());
QuickAnswer saved = quickAnswerRepository.save(quickAnswer);
QuickAnswerDto dto = new QuickAnswerDto(
saved.getId(),
saved.getText(),
saved.getCreatedAt(),
saved.getUpdatedAt()
);
return ResponseEntity.ok(dto);
}
/**
* Delete a quick answer (only if it belongs to the current admin)
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteQuickAnswer(@PathVariable Integer id) {
Admin admin = getCurrentAdmin();
QuickAnswer quickAnswer = quickAnswerRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Quick answer not found"));
// Verify that the quick answer belongs to the current admin
if (!quickAnswer.getAdmin().getId().equals(admin.getId())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
quickAnswerRepository.delete(quickAnswer);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,104 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.SupportTicketService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/support")
@RequiredArgsConstructor
public class SupportController {
private final SupportTicketService supportTicketService;
/**
* Creates a new support ticket with the first message.
*/
@PostMapping("/tickets")
public ResponseEntity<TicketDto> createTicket(
@Valid @RequestBody CreateTicketRequest request) {
UserA user = UserContext.get();
TicketDto ticket = supportTicketService.createTicket(
user.getId(),
request
);
return ResponseEntity.status(HttpStatus.CREATED).body(ticket);
}
/**
* Gets ticket history for the authenticated user (last 20 tickets).
*/
@GetMapping("/tickets")
public ResponseEntity<List<TicketDto>> getTicketHistory() {
UserA user = UserContext.get();
List<TicketDto> tickets = supportTicketService.getTicketHistory(
user.getId()
);
return ResponseEntity.ok(tickets);
}
/**
* Gets ticket details with all messages.
*/
@GetMapping("/tickets/{ticketId}")
public ResponseEntity<TicketDetailDto> getTicketDetail(
@PathVariable Long ticketId) {
UserA user = UserContext.get();
TicketDetailDto ticket = supportTicketService.getTicketDetail(
user.getId(),
ticketId
);
return ResponseEntity.ok(ticket);
}
/**
* Adds a message to an existing ticket.
*/
@PostMapping("/tickets/{ticketId}/messages")
public ResponseEntity<MessageDto> addMessage(
@PathVariable Long ticketId,
@Valid @RequestBody CreateMessageRequest request) {
UserA user = UserContext.get();
MessageDto message = supportTicketService.addMessage(
user.getId(),
ticketId,
request
);
return ResponseEntity.status(HttpStatus.CREATED).body(message);
}
/**
* Closes a ticket.
*/
@PostMapping("/tickets/{ticketId}/close")
public ResponseEntity<Void> closeTicket(
@PathVariable Long ticketId) {
UserA user = UserContext.get();
supportTicketService.closeTicket(
user.getId(),
ticketId
);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,100 @@
package com.honey.honey.controller;
import com.honey.honey.dto.ClaimTaskResponse;
import com.honey.honey.dto.DailyBonusStatusDto;
import com.honey.honey.dto.RecentBonusClaimDto;
import com.honey.honey.dto.TaskDto;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.TaskService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
private final LocalizationService localizationService;
/**
* Gets all tasks for a specific type (referral, follow, other).
* Includes user progress and claim status.
*/
@GetMapping
public List<TaskDto> getTasks(@RequestParam String type) {
UserA user = UserContext.get();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
return taskService.getTasksByType(user.getId(), type, languageCode);
}
/**
* Gets daily bonus status for the current user.
* Returns availability status and cooldown time if on cooldown.
*/
@GetMapping("/daily-bonus")
public ResponseEntity<DailyBonusStatusDto> getDailyBonusStatus() {
UserA user = UserContext.get();
DailyBonusStatusDto status = taskService.getDailyBonusStatus(user.getId());
return ResponseEntity.ok(status);
}
/**
* Gets the 50 most recent daily bonus claims.
* Returns claims ordered by claimed_at DESC (most recent first).
* Includes user avatar URL, screen name, and formatted claim timestamp with timezone and localized "at" word.
*
* @param timezone Optional timezone (e.g., "Europe/Kiev"). If not provided, uses UTC.
*/
@GetMapping("/daily-bonus/recent-claims")
public ResponseEntity<List<RecentBonusClaimDto>> getRecentDailyBonusClaims(
@RequestParam(required = false) String timezone) {
UserA user = UserContext.get();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
List<RecentBonusClaimDto> claims = taskService.getRecentDailyBonusClaims(timezone, languageCode);
return ResponseEntity.ok(claims);
}
/**
* Claims a task for the current user.
* Checks if task is completed and gives reward if applicable.
* Returns 200 with success status and message.
*/
@PostMapping("/claim")
public ResponseEntity<ClaimTaskResponse> claimTask(@RequestBody ClaimTaskRequest request) {
UserA user = UserContext.get();
boolean claimed = taskService.claimTask(user.getId(), request.getTaskId());
if (claimed) {
return ResponseEntity.ok(ClaimTaskResponse.builder()
.success(true)
.message(localizationService.getMessage("task.message.claimed"))
.build());
} else {
return ResponseEntity.ok(ClaimTaskResponse.builder()
.success(false)
.message(localizationService.getMessage("task.message.notCompleted"))
.build());
}
}
@Data
public static class ClaimTaskRequest {
private Integer taskId;
}
}

View File

@@ -0,0 +1,865 @@
package com.honey.honey.controller;
import com.honey.honey.config.TelegramProperties;
import com.honey.honey.dto.TelegramApiResponse;
import com.honey.honey.dto.PaymentWebhookRequest;
import com.honey.honey.model.UserA;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.PaymentService;
import com.honey.honey.service.TelegramBotApiService;
import com.honey.honey.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.HttpClientErrorException;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.api.objects.CallbackQuery;
import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery;
import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow;
import org.telegram.telegrambots.meta.api.objects.webapp.WebAppInfo;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.config.LocaleConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
import java.util.ArrayList;
import java.util.Locale;
import java.util.HashMap;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.StreamUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.core.io.ByteArrayResource;
/**
* Webhook controller for receiving Telegram updates directly.
* Path: POST /api/telegram/webhook/{token}. Token must match APP_TELEGRAM_WEBHOOK_TOKEN.
*/
@Slf4j
@RestController
@RequestMapping("/api/telegram/webhook")
@RequiredArgsConstructor
public class TelegramWebhookController {
private static final String MINI_APP_URL = "https://testforapp.website/test/auth";
@Value("${app.telegram-webhook.token:}")
private String expectedWebhookToken;
private final UserService userService;
private final PaymentService paymentService;
private final TelegramProperties telegramProperties;
private final LocalizationService localizationService;
private final TelegramBotApiService telegramBotApiService;
private final FeatureSwitchService featureSwitchService;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Webhook endpoint for receiving updates from Telegram.
* Path token must match app.telegram-webhook.token (APP_TELEGRAM_WEBHOOK_TOKEN).
*/
@PostMapping("/{token}")
public ResponseEntity<?> handleWebhook(@PathVariable String token, @RequestBody Update update, HttpServletRequest httpRequest) {
if (expectedWebhookToken.isEmpty() || !expectedWebhookToken.equals(token)) {
log.warn("Webhook rejected: invalid or missing token (possible misconfiguration or wrong URL); update dropped");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
// Handle callback queries (button clicks)
if (update.hasCallbackQuery()) {
handleCallbackQuery(update.getCallbackQuery());
}
// Handle message updates (e.g., /start command or Reply Keyboard button clicks)
if (update.hasMessage() && update.getMessage().hasText()) {
handleMessage(update.getMessage(), httpRequest);
}
// Handle pre-checkout query (before payment confirmation)
if (update.hasPreCheckoutQuery()) {
handlePreCheckoutQuery(update.getPreCheckoutQuery());
}
// Handle successful payment
if (update.hasMessage() && update.getMessage().hasSuccessfulPayment()) {
handleSuccessfulPayment(update.getMessage().getSuccessfulPayment(), update.getMessage().getFrom().getId());
}
return ResponseEntity.ok().build();
} catch (Exception e) {
log.error("Error processing Telegram webhook: {}", e.getMessage(), e);
if (update.hasMessage() && update.getMessage().hasText() && update.getMessage().getText().startsWith("/start")) {
Long telegramId = update.getMessage().getFrom() != null ? update.getMessage().getFrom().getId() : null;
log.warn("Registration attempt failed (webhook error), update dropped: telegramId={}", telegramId);
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Handles /start command with optional referral parameter, and Reply Keyboard button clicks.
* Format: /start or /start 123 (where 123 is the referral user ID)
*/
private void handleMessage(Message message, HttpServletRequest httpRequest) {
String messageText = message.getText();
if (messageText == null) {
return;
}
User telegramUser = message.getFrom();
Long telegramId = telegramUser.getId();
Long chatId = message.getChatId();
// Handle /start command
if (messageText.startsWith("/start")) {
handleStartCommand(message, httpRequest, telegramUser, telegramId);
return;
}
// Handle Reply Keyboard button clicks
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
String languageCode = "EN"; // Default
try {
var userOpt = userService.getUserByTelegramId(telegramId);
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
languageCode = userOpt.get().getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
} catch (Exception e) {
log.warn("Could not get user language, using default: {}", e.getMessage());
// Fallback to Telegram user's language_code if available
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
// Check if message matches Reply Keyboard button text
String startSpinningText = "🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning");
String usersPayoutsText = "💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts");
String infoChannelText = " " + localizationService.getMessage(locale, "bot.button.infoChannel");
if (messageText.equals(startSpinningText)) {
sendStartSpinningMessage(chatId, locale);
} else if (messageText.equals(usersPayoutsText)) {
sendUsersPayoutsMessage(chatId, locale);
} else if (messageText.equals(infoChannelText)) {
sendInfoChannelMessage(chatId, locale);
} else {
// Unknown message (e.g. old "Start Spinning" button or free text): reply and refresh keyboard
sendUnrecognizedMessageAndUpdateKeyboard(chatId, locale);
}
}
/**
* Handles /start command with optional referral parameter.
* Format: /start or /start 123 (where 123 is the referral user ID)
*/
private void handleStartCommand(Message message, HttpServletRequest httpRequest, User telegramUser, Long telegramId) {
String messageText = message.getText();
log.debug("Received /start command: telegramId={}", telegramId);
Integer referralUserId = null;
// Parse referral parameter from /start command
// Format: /start or /start 123
String[] parts = messageText.split("\\s+", 2);
if (parts.length > 1 && !parts[1].trim().isEmpty()) {
try {
referralUserId = Integer.parseInt(parts[1].trim());
log.debug("Parsed referral ID: {}", referralUserId);
} catch (NumberFormatException e) {
log.warn("Invalid referral parameter format: '{}'", parts[1]);
return;
}
}
// Check if user already exists
boolean isNewUser = userService.getUserByTelegramId(telegramId).isEmpty();
if (isNewUser) {
log.info("New user registration via bot - telegramId={}, referralUserId={}",
telegramId, referralUserId);
}
// Build tgUserData map similar to what TelegramAuthService.parseInitData returns
Map<String, Object> tgUser = new HashMap<>();
tgUser.put("id", telegramId);
tgUser.put("first_name", telegramUser.getFirstName());
tgUser.put("last_name", telegramUser.getLastName());
tgUser.put("username", telegramUser.getUserName());
tgUser.put("is_premium", telegramUser.getIsPremium() != null && telegramUser.getIsPremium());
tgUser.put("language_code", telegramUser.getLanguageCode());
// Note: Telegram Bot API User object doesn't have photo_url (only available in WebApp initData)
// AvatarService will fetch avatar from Bot API first, photo_url is only used as fallback
tgUser.put("photo_url", null);
Map<String, Object> tgUserData = new HashMap<>();
tgUserData.put("user", tgUser);
// Convert referralUserId to start parameter string (as expected by UserService)
String start = referralUserId != null ? String.valueOf(referralUserId) : null;
tgUserData.put("start", start);
try {
// Get or create user (handles registration, login update, and referral system)
UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}",
user.getId(), user.getTelegramId(), isNewUser);
// Send welcome message with buttons
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
String languageCode = "EN"; // Default
if (user.getLanguageCode() != null && !user.getLanguageCode().isEmpty()) {
languageCode = user.getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
sendWelcomeMessage(telegramId, languageCode);
} catch (Exception e) {
log.warn("Registration failed for telegramId={}, user may not receive welcome message: {}", telegramId, e.getMessage());
log.error("Error registering user via bot: telegramId={}", telegramId, e);
}
}
/**
* Handles callback queries from inline keyboard buttons.
*/
private void handleCallbackQuery(CallbackQuery callbackQuery) {
String data = callbackQuery.getData();
Long telegramUserId = callbackQuery.getFrom().getId();
Long chatId = callbackQuery.getMessage().getChatId();
log.debug("Received callback query: data={}, telegramUserId={}", data, telegramUserId);
// Get user's language for localization
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
User telegramUser = callbackQuery.getFrom();
String languageCode = "EN"; // Default
try {
var userOpt = userService.getUserByTelegramId(telegramUserId);
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
languageCode = userOpt.get().getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
} catch (Exception e) {
log.warn("Could not get user language, using default: {}", e.getMessage());
// Fallback to Telegram user's language_code if available
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
try {
switch (data) {
case "start_spinning":
sendStartSpinningMessage(chatId, locale);
answerCallbackQuery(callbackQuery.getId(), null);
break;
case "users_payouts":
sendUsersPayoutsMessage(chatId, locale);
answerCallbackQuery(callbackQuery.getId(), null);
break;
case "info_channel":
sendInfoChannelMessage(chatId, locale);
answerCallbackQuery(callbackQuery.getId(), null);
break;
default:
log.warn("Unknown callback data: {}", data);
answerCallbackQuery(callbackQuery.getId(), "Unknown action");
}
} catch (Exception e) {
log.error("Error handling callback query: data={}", data, e);
answerCallbackQuery(callbackQuery.getId(), "Error processing request");
}
}
/**
* Builds the current Reply Keyboard (Start Game, Users payouts, Info channel) for the given locale.
*/
private ReplyKeyboardMarkup buildReplyKeyboard(Locale locale) {
ReplyKeyboardMarkup replyKeyboard = new ReplyKeyboardMarkup();
replyKeyboard.setResizeKeyboard(true);
replyKeyboard.setOneTimeKeyboard(false);
replyKeyboard.setSelective(false);
List<KeyboardRow> keyboardRows = new ArrayList<>();
KeyboardRow row1 = new KeyboardRow();
KeyboardButton startButton = new KeyboardButton();
startButton.setText("🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning"));
row1.add(startButton);
keyboardRows.add(row1);
KeyboardRow row2 = new KeyboardRow();
KeyboardButton payoutsButton = new KeyboardButton();
payoutsButton.setText("💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts"));
row2.add(payoutsButton);
KeyboardButton infoButton = new KeyboardButton();
infoButton.setText(" " + localizationService.getMessage(locale, "bot.button.infoChannel"));
row2.add(infoButton);
keyboardRows.add(row2);
replyKeyboard.setKeyboard(keyboardRows);
return replyKeyboard;
}
/**
* Sends welcome messages: first message with reply keyboard, second message with inline button.
*/
private void sendWelcomeMessage(Long chatId, String languageCode) {
if (telegramProperties.getBotToken() == null || telegramProperties.getBotToken().isEmpty()) {
log.warn("Bot token not configured; welcome message not sent for chatId={} (registration flow affected)", chatId);
return;
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode != null ? languageCode : "EN");
ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale);
String firstMessage = localizationService.getMessage(locale, "bot.welcome.firstMessage");
sendAnimationWithReplyKeyboard(chatId, firstMessage, replyKeyboard);
String welcomeText = localizationService.getMessage(locale, "bot.welcome.message");
if (featureSwitchService.isStartGameButtonEnabled()) {
InlineKeyboardMarkup inlineKeyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> inlineRows = new ArrayList<>();
List<InlineKeyboardButton> inlineRow = new ArrayList<>();
InlineKeyboardButton startInlineButton = new InlineKeyboardButton();
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
startInlineButton.setText(startSpinningButtonText);
WebAppInfo webAppInfo = new WebAppInfo();
webAppInfo.setUrl(MINI_APP_URL);
startInlineButton.setWebApp(webAppInfo);
inlineRow.add(startInlineButton);
inlineRows.add(inlineRow);
inlineKeyboard.setKeyboard(inlineRows);
sendMessage(chatId, welcomeText, inlineKeyboard);
} else {
sendMessage(chatId, welcomeText, null);
}
}
/**
* Sends message with Start Spinning button.
*/
private void sendStartSpinningMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.startSpinning");
if (!featureSwitchService.isStartGameButtonEnabled()) {
sendMessage(chatId, message, null);
return;
}
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> row = new ArrayList<>();
InlineKeyboardButton button = new InlineKeyboardButton();
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
button.setText(startSpinningButtonText);
WebAppInfo webAppInfo = new WebAppInfo();
webAppInfo.setUrl(MINI_APP_URL);
button.setWebApp(webAppInfo);
row.add(button);
rows.add(row);
keyboard.setKeyboard(rows);
sendMessage(chatId, message, keyboard);
}
/**
* Sends a friendly "unrecognized message" reply and updates the user's reply keyboard to the current one.
* Used when the user sends unknown text (e.g. old "Start Spinning" button) so they get the new keyboard.
*/
private void sendUnrecognizedMessageAndUpdateKeyboard(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.unrecognized");
ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale);
sendMessageWithReplyKeyboard(chatId, message, replyKeyboard);
}
/**
* Sends message with Users payouts button.
*/
private void sendUsersPayoutsMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.usersPayouts");
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> row = new ArrayList<>();
InlineKeyboardButton button = new InlineKeyboardButton();
button.setText(localizationService.getMessage(locale, "bot.button.openChannel"));
button.setUrl("https://t.me/win_spin_withdrawals");
row.add(button);
rows.add(row);
keyboard.setKeyboard(rows);
sendMessage(chatId, message, keyboard);
}
/**
* Handles /paysupport command.
*/
private void handlePaySupportCommand(Long chatId, User telegramUser, Long telegramId) {
// Get user's language for localization
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
String languageCode = "EN"; // Default
try {
var userOpt = userService.getUserByTelegramId(telegramId);
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
languageCode = userOpt.get().getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
} catch (Exception e) {
log.warn("Could not get user language for /paysupport, using default: {}", e.getMessage());
// Fallback to Telegram user's language_code if available
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
String message = localizationService.getMessage(locale, "bot.message.paySupport");
sendMessage(chatId, message, null);
}
/**
* Sends message with Info channel button.
*/
private void sendInfoChannelMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.infoChannel");
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> row = new ArrayList<>();
InlineKeyboardButton button = new InlineKeyboardButton();
button.setText(localizationService.getMessage(locale, "bot.button.goToChannel"));
button.setUrl("https://t.me/win_spin_news");
row.add(button);
rows.add(row);
keyboard.setKeyboard(rows);
sendMessage(chatId, message, keyboard);
}
/**
* Sends a message to a chat with inline keyboard.
*/
private void sendMessage(Long chatId, String text, InlineKeyboardMarkup keyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("chat_id", chatId);
requestBody.put("text", text);
if (keyboard != null) {
// Convert InlineKeyboardMarkup to Map for JSON serialization
try {
String keyboardJson = objectMapper.writeValueAsString(keyboard);
Map<String, Object> keyboardMap = objectMapper.readValue(keyboardJson, Map.class);
requestBody.put("reply_markup", keyboardMap);
} catch (Exception e) {
log.error("Error serializing keyboard: {}", e.getMessage(), e);
return;
}
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
log.warn("Failed to send message: chatId={}, error={}",
chatId, response.getBody().getDescription());
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send message: chatId={}, status={}", chatId, response.getStatusCode());
} else if (response == null) {
log.warn("Message not sent (Telegram 429, retry scheduled): chatId={} may affect registration welcome", chatId);
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending message: chatId={}", chatId, e);
}
}
}
/**
* Returns true if the failure is due to user blocking the bot or chat being unavailable.
* These are expected and should be logged at WARN without stack trace.
*/
private boolean isTelegramUserUnavailable(Throwable t) {
if (t instanceof HttpClientErrorException e) {
if (e.getStatusCode().value() == 403) {
return true;
}
String body = e.getResponseBodyAsString();
return body != null && (
body.contains("blocked by the user") ||
body.contains("user is deactivated") ||
body.contains("chat not found")
);
}
return false;
}
private boolean isTelegramUserUnavailableDescription(String description) {
return description != null && (
description.contains("blocked by the user") ||
description.contains("user is deactivated") ||
description.contains("chat not found")
);
}
/**
* Sends a message with text and reply keyboard.
*/
private void sendMessageWithReplyKeyboard(Long chatId, String text, ReplyKeyboardMarkup replyKeyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("chat_id", chatId);
requestBody.put("text", text);
try {
String keyboardJson = objectMapper.writeValueAsString(replyKeyboard);
Map<String, Object> keyboardMap = objectMapper.readValue(keyboardJson, Map.class);
requestBody.put("reply_markup", keyboardMap);
} catch (Exception e) {
log.error("Error serializing reply keyboard: {}", e.getMessage(), e);
return;
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
String desc = response.getBody().getDescription();
if (isTelegramUserUnavailableDescription(desc)) {
log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Failed to send message with reply keyboard: chatId={}, error={}", chatId, desc);
}
} else {
log.info("Message with reply keyboard sent successfully: chatId={}", chatId);
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send message with reply keyboard: chatId={}, status={}",
chatId, response.getStatusCode());
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending message with reply keyboard: chatId={}", chatId, e);
}
}
}
/**
* Sends an animation (MP4 video) with caption text and reply keyboard.
* Uses MP4 format as Telegram handles silent MP4s better than GIF files.
*/
private void sendAnimationWithReplyKeyboard(Long chatId, String caption, ReplyKeyboardMarkup replyKeyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendAnimation";
try {
// Load MP4 from resources (Telegram "GIFs" are actually silent MP4 videos)
Resource resource = new ClassPathResource("assets/winspin_5.mp4");
if (!resource.exists()) {
log.error("MP4 file not found: assets/winspin_5.mp4");
// Fallback to text message if MP4 not found
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
return;
}
byte[] videoBytes = StreamUtils.copyToByteArray(resource.getInputStream());
ByteArrayResource videoResource = new ByteArrayResource(videoBytes) {
@Override
public String getFilename() {
return "winspin_5.mp4";
}
};
// Create multipart form data
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("chat_id", chatId.toString());
body.add("caption", caption);
// EXPLICITLY SET MIME TYPE FOR THE ANIMATION PART
// This is crucial - Telegram needs to know it's a video/mp4
HttpHeaders fileHeaders = new HttpHeaders();
fileHeaders.setContentType(MediaType.parseMediaType("video/mp4"));
HttpEntity<ByteArrayResource> filePart = new HttpEntity<>(videoResource, fileHeaders);
body.add("animation", filePart);
// Add reply keyboard if provided
if (replyKeyboard != null) {
try {
String keyboardJson = objectMapper.writeValueAsString(replyKeyboard);
body.add("reply_markup", keyboardJson);
} catch (Exception e) {
log.error("Error serializing reply keyboard: {}", e.getMessage(), e);
}
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
String desc = response.getBody().getDescription();
if (isTelegramUserUnavailableDescription(desc)) {
log.warn("Cannot send animation: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Failed to send animation with reply keyboard: chatId={}, error={}", chatId, desc);
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
}
} else {
log.info("Animation with reply keyboard sent successfully: chatId={}", chatId);
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send animation with reply keyboard: chatId={}, status={}",
chatId, response.getStatusCode());
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
} else if (response == null) {
log.warn("Welcome animation delayed (Telegram 429, retry scheduled): chatId={} registration flow may appear incomplete", chatId);
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send animation: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending animation with reply keyboard: chatId={}", chatId, e);
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
}
}
}
/**
* Sends a message with only reply keyboard (for setting up persistent keyboard).
*/
private void sendReplyKeyboardOnly(Long chatId, ReplyKeyboardMarkup replyKeyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("chat_id", chatId);
// Telegram requires non-empty text for messages with reply keyboard
// Sending with a minimal message - this message won't be visible to users
// but is required to set up the persistent keyboard
requestBody.put("text", ".");
try {
String keyboardJson = objectMapper.writeValueAsString(replyKeyboard);
Map<String, Object> keyboardMap = objectMapper.readValue(keyboardJson, Map.class);
requestBody.put("reply_markup", keyboardMap);
} catch (Exception e) {
log.error("Error serializing reply keyboard: {}", e.getMessage(), e);
return;
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
String desc = response.getBody().getDescription();
if (isTelegramUserUnavailableDescription(desc)) {
log.warn("Cannot send reply keyboard: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Failed to send reply keyboard: chatId={}, error={}", chatId, desc);
}
} else {
log.info("Reply keyboard sent successfully: chatId={}", chatId);
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send reply keyboard: chatId={}, status={}",
chatId, response.getStatusCode());
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send reply keyboard: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending reply keyboard: chatId={}", chatId, e);
}
}
}
/**
* Answers a callback query.
*/
private void answerCallbackQuery(String queryId, String text) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/answerCallbackQuery";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("callback_query_id", queryId);
if (text != null) {
requestBody.put("text", text);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null && !Boolean.TRUE.equals(response.getBody().getOk())) {
log.warn("Failed to answer callback query: queryId={}, error={}",
queryId, response.getBody().getDescription());
}
} catch (Exception e) {
log.error("Error answering callback query: queryId={}", queryId, e);
}
}
/**
* Handles pre-checkout query (before payment confirmation).
* Telegram sends this to verify the payment before the user confirms.
* We must answer it to approve the payment, otherwise it will expire.
*/
private void handlePreCheckoutQuery(PreCheckoutQuery preCheckoutQuery) {
String queryId = preCheckoutQuery.getId();
String invoicePayload = preCheckoutQuery.getInvoicePayload(); // This is our orderId
log.debug("Pre-checkout query: queryId={}, orderId={}", queryId, invoicePayload);
// Answer the pre-checkout query to approve the payment
// We always approve since we validate on successful payment
answerPreCheckoutQuery(queryId, true, null);
}
/**
* Answers a pre-checkout query to approve or reject the payment.
*
* @param queryId The pre-checkout query ID
* @param ok True to approve, false to reject
* @param errorMessage Error message if rejecting (null if approving)
*/
private void answerPreCheckoutQuery(String queryId, boolean ok, String errorMessage) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/answerPreCheckoutQuery";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("pre_checkout_query_id", queryId);
requestBody.put("ok", ok);
if (!ok && errorMessage != null) {
requestBody.put("error_message", errorMessage);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
log.debug("Answering pre-checkout query: queryId={}, ok={}", queryId, ok);
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null && !Boolean.TRUE.equals(response.getBody().getOk())) {
log.warn("Failed to answer pre-checkout query: queryId={}, error={}",
queryId, response.getBody().getDescription());
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to answer pre-checkout query: queryId={}, status={}", queryId, response.getStatusCode());
}
} catch (Exception e) {
log.error("Error answering pre-checkout query: queryId={}", queryId, e);
}
}
/**
* Handles successful payment from Telegram.
* Processes the payment and credits the user's balance.
*/
private void handleSuccessfulPayment(SuccessfulPayment successfulPayment, Long telegramUserId) {
String invoicePayload = successfulPayment.getInvoicePayload(); // This is our orderId
// Extract stars amount from total amount
// Telegram sends amount in the smallest currency unit (for Stars, it's 1:1)
Integer starsAmount = successfulPayment.getTotalAmount().intValue();
log.info("Payment webhook received: orderId={}, telegramUserId={}, starsAmount={}", invoicePayload, telegramUserId, starsAmount);
try {
// Create webhook request and process payment
PaymentWebhookRequest request = new PaymentWebhookRequest();
request.setOrderId(invoicePayload);
request.setTelegramUserId(telegramUserId);
request.setTelegramPaymentChargeId(successfulPayment.getTelegramPaymentChargeId());
request.setTelegramProviderPaymentChargeId(successfulPayment.getProviderPaymentChargeId());
request.setStarsAmount(starsAmount);
boolean processed = paymentService.processPaymentWebhook(request);
if (!processed) {
log.warn("Payment already processed: orderId={}", invoicePayload);
}
} catch (Exception e) {
log.error("Error processing payment webhook: orderId={}", invoicePayload, e);
}
}
}

View File

@@ -0,0 +1,50 @@
package com.honey.honey.controller;
import com.honey.honey.dto.TransactionDto;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.TransactionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Controller for transaction history operations.
*/
@Slf4j
@RestController
@RequestMapping("/api/transactions")
@RequiredArgsConstructor
public class TransactionController {
private final TransactionService transactionService;
/**
* Gets transaction history for the current user.
* Returns 50 transactions per page, ordered by creation time descending (newest first).
*
* @param page Page number (0-indexed, defaults to 0)
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
* @return Page of transactions
*/
@GetMapping
public ResponseEntity<Page<TransactionDto>> getTransactions(
@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) String timezone) {
UserA user = UserContext.get();
Integer userId = user.getId();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
// Note: languageCode is still used for date formatting (localized "at" word)
// Transaction type localization is now handled in the frontend
Page<TransactionDto> transactions = transactionService.getUserTransactions(userId, page, timezone, languageCode);
return ResponseEntity.ok(transactions);
}
}

View File

@@ -0,0 +1,101 @@
package com.honey.honey.controller;
import com.honey.honey.dto.UserCheckDto;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.model.UserD;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserBRepository;
import com.honey.honey.repository.UserDRepository;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* Controller for user check endpoint (open endpoint for external applications).
* Path token is validated against app.check-user.token (APP_CHECK_USER_TOKEN). No user auth.
*/
@Slf4j
@RestController
@RequestMapping("/api/check_user")
@RequiredArgsConstructor
public class UserCheckController {
@Value("${app.check-user.token:}")
private String expectedToken;
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final PaymentRepository paymentRepository;
/**
* Gets user information by Telegram ID.
* Path: /api/check_user/{token}/{telegramId}. Token must match APP_CHECK_USER_TOKEN.
*
* @param token Secret token from path (must match config)
* @param telegramId The Telegram ID of the user
* @return 200 with user info (found=true) or 200 with found=false when user not found; 403 if token invalid
*/
@GetMapping("/{token}/{telegramId}")
public ResponseEntity<UserCheckDto> checkUser(@PathVariable String token, @PathVariable Long telegramId) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
// Find user by telegram_id
Optional<UserA> userAOpt = userARepository.findByTelegramId(telegramId);
if (userAOpt.isEmpty()) {
log.debug("User not found for telegramId={}", telegramId);
UserCheckDto notFoundResponse = UserCheckDto.builder().found(false).build();
return ResponseEntity.ok(notFoundResponse);
}
UserA userA = userAOpt.get();
Integer userId = userA.getId();
// Get balance_a from db_users_b
Optional<UserB> userBOpt = userBRepository.findById(userId);
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
// Convert to tickets (balance_a / 1,000,000)
Double tickets = balanceA / 1_000_000.0;
// Get referer_id_1 from db_users_d
Optional<UserD> userDOpt = userDRepository.findById(userId);
Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0);
// Return 0 if refererId is 0 or negative (not set)
if (refererId <= 0) {
refererId = 0;
}
// Sum completed payments stars_amount
Integer depositTotal = paymentRepository.sumCompletedStarsAmountByUserId(userId);
// Build response
UserCheckDto response = UserCheckDto.builder()
.found(true)
.dateReg(userA.getDateReg())
.tickets(tickets)
.depositTotal(depositTotal)
.refererId(refererId)
.build();
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Error checking user for telegramId={}", telegramId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -0,0 +1,146 @@
package com.honey.honey.controller;
import com.honey.honey.dto.ReferralDto;
import com.honey.honey.dto.UserDto;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.repository.UserBRepository;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.AvatarService;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.UserService;
import com.honey.honey.util.IpUtils;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserBRepository userBRepository;
private final AvatarService avatarService;
private final LocalizationService localizationService;
private final FeatureSwitchService featureSwitchService;
@GetMapping("/current")
public UserDto getCurrentUser() {
UserA user = UserContext.get();
// Convert IP from byte[] to string for display
String ipAddress = IpUtils.bytesToIp(user.getIp());
// Get balance
Long balanceA = userBRepository.findById(user.getId())
.map(UserB::getBalanceA)
.orElse(0L);
// Generate avatar URL on-the-fly (deterministic from userId)
String avatarUrl = avatarService.getAvatarUrl(user.getId());
return UserDto.builder()
.id(user.getId())
.telegram_id(user.getTelegramId())
.username(user.getTelegramName())
.screenName(user.getScreenName())
.dateReg(user.getDateReg())
.ip(ipAddress)
.balanceA(balanceA)
.avatarUrl(avatarUrl)
.languageCode(user.getLanguageCode())
.paymentEnabled(featureSwitchService.isPaymentEnabled())
.payoutEnabled(featureSwitchService.isPayoutEnabled())
.promotionsEnabled(featureSwitchService.isPromotionsEnabled())
.build();
}
/**
* Updates user's language code.
* Called when user changes language in app header.
*/
@PutMapping("/language")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateLanguage(@RequestBody UpdateLanguageRequest request) {
UserA user = UserContext.get();
userService.updateLanguageCode(user.getId(), request.getLanguageCode());
}
/**
* Adds deposit amount to user's balance_a.
* For now, this is a mock implementation that directly adds to balance.
* Will be replaced with payment integration later.
*/
@PostMapping("/deposit")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deposit(@RequestBody DepositRequest request) {
UserA user = UserContext.get();
// Frontend sends amount already in bigint format (no conversion needed)
Long depositAmount = request.getAmount();
if (depositAmount == null || depositAmount <= 0) {
throw new IllegalArgumentException(localizationService.getMessage("user.error.depositAmountInvalid"));
}
UserB userB = userBRepository.findById(user.getId())
.orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound")));
// Add to balance
userB.setBalanceA(userB.getBalanceA() + depositAmount);
// Update deposit statistics
userB.setDepositTotal(userB.getDepositTotal() + depositAmount);
userB.setDepositCount(userB.getDepositCount() + 1);
userBRepository.save(userB);
}
@Data
public static class UpdateLanguageRequest {
private String languageCode;
}
/**
* Gets referrals for a specific level with pagination.
* Always returns 50 results per page.
*
* @param level The referral level (1, 2, or 3)
* @param page Page number (0-indexed, defaults to 0)
* @return Page of referrals with name and commission
*/
@GetMapping("/referrals")
public ReferralsResponse getReferrals(
@RequestParam Integer level,
@RequestParam(defaultValue = "0") Integer page) {
UserA user = UserContext.get();
Page<ReferralDto> referralsPage = userService.getReferrals(user.getId(), level, page);
return new ReferralsResponse(
referralsPage.getContent(),
referralsPage.getNumber(),
referralsPage.getTotalPages(),
referralsPage.getTotalElements()
);
}
@Data
public static class DepositRequest {
private Long amount; // Amount in bigint format (frontend converts before sending)
}
@Data
@RequiredArgsConstructor
public static class ReferralsResponse {
private final java.util.List<ReferralDto> referrals;
private final Integer currentPage;
private final Integer totalPages;
private final Long totalElements;
}
}

View File

@@ -0,0 +1,10 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class AdminLoginRequest {
private String username;
private String password;
}

View File

@@ -0,0 +1,13 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AdminLoginResponse {
private String token;
private String username;
private String role;
}

View File

@@ -0,0 +1,31 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminMasterDto {
private Integer id;
private String screenName;
/** Level 1 referrals count (from master's UserD row). */
private Integer referals1;
/** Level 2 referrals count. */
private Integer referals2;
/** Level 3 referrals count. */
private Integer referals3;
/** Total users with master_id = this master's id. */
private Long totalReferrals;
/** Sum of deposit_total of all referrals, in USD (divided by 1e9). */
private BigDecimal depositTotalUsd;
/** Sum of withdraw_total of all referrals, in USD (divided by 1e9). */
private BigDecimal withdrawTotalUsd;
/** depositTotalUsd - withdrawTotalUsd. */
private BigDecimal profitUsd;
}

View File

@@ -0,0 +1,27 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPaymentDto {
private Long id;
private Integer userId;
private String userName;
private String orderId;
private Integer starsAmount;
private Long ticketsAmount;
private String status;
private String telegramPaymentChargeId;
private String telegramProviderPaymentChargeId;
private Instant createdAt;
private Instant completedAt;
}

View File

@@ -0,0 +1,28 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPayoutDto {
private Long id;
private Integer userId;
private String userName;
private String username;
private String type;
private String giftName;
private Long total;
private Integer starsAmount;
private Integer quantity;
private String status;
private Instant createdAt;
private Instant resolvedAt;
}

View File

@@ -0,0 +1,23 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionDto {
private Integer id;
private String type;
private Instant startTime;
private Instant endTime;
private String status;
private Long totalReward;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRequest {
@NotNull
private String type;
@NotNull
private Instant startTime;
@NotNull
private Instant endTime;
@NotNull
private String status;
private Long totalReward;
}

View File

@@ -0,0 +1,22 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRewardDto {
private Integer id;
private Integer promoId;
private Integer place;
/** Reward in bigint (1 ticket = 1_000_000). */
private Long reward;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,18 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRewardRequest {
@NotNull
private Integer place;
@NotNull
private Long reward; // bigint, 1 ticket = 1_000_000
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionUserDto {
private Integer promoId;
private Integer userId;
/** Points as ticket count, 2 decimal places (e.g. 100.25). */
private BigDecimal points;
private Instant updatedAt;
}

View File

@@ -0,0 +1,18 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionUserPointsRequest {
@NotNull
private BigDecimal points; // ticket count, 2 decimal places
}

View File

@@ -0,0 +1,22 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminSupportMessageDto {
private Long id;
private Integer userId;
private String userName;
private String message;
private Instant createdAt;
private Boolean isAdmin; // true if sent by admin
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminSupportTicketDetailDto {
private Long id;
private Integer userId;
private String userName;
private String subject;
private String status;
private Instant createdAt;
private Instant updatedAt;
private List<AdminSupportMessageDto> messages;
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminSupportTicketDto {
private Long id;
private Integer userId;
private String userName;
private String subject;
private String status;
private Instant createdAt;
private Instant updatedAt;
private Long messageCount;
private String lastMessagePreview;
private Instant lastMessageAt;
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminTaskClaimDto {
private Long id;
private Integer taskId;
private String taskTitle;
private String taskType;
private Instant claimedAt;
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminTransactionDto {
private Long id;
private Long amount; // In bigint format
private String type;
private Integer taskId;
private Instant createdAt;
}

View File

@@ -0,0 +1,52 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminUserDetailDto {
// Basic Info
private Integer id;
private String screenName;
private Long telegramId;
private String telegramName;
private Integer isPremium;
private String languageCode;
private String countryCode;
private String deviceCode;
private String avatarUrl;
private Integer dateReg;
private Integer dateLogin;
private Integer banned;
/** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */
private String ipAddress;
// Balance Info
private Long balanceA;
private Long depositTotal;
private Integer depositCount;
private Long withdrawTotal;
private Integer withdrawCount;
/** Total deposits in USD (CRYPTO only). */
private java.math.BigDecimal depositTotalUsd;
/** Total withdrawals in USD (CRYPTO only). */
private java.math.BigDecimal withdrawTotalUsd;
/** When true, the user cannot create any payout request. */
private Boolean withdrawalsDisabled;
// Referral Info
private Integer referralCount;
private Long totalCommissionsEarned;
/** Total commissions earned in USD (converted from tickets). */
private java.math.BigDecimal totalCommissionsEarnedUsd;
private Integer masterId;
private List<ReferralLevelDto> referralLevels;
}

View File

@@ -0,0 +1,41 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminUserDto {
private Integer id;
private String screenName;
private Long telegramId;
private String telegramName;
private Long balanceA;
private Long balanceB;
private Long depositTotal;
private Integer depositCount;
private Long withdrawTotal;
private Integer withdrawCount;
private Integer dateReg;
private Integer dateLogin;
private Integer banned;
private String countryCode;
private String languageCode;
private Integer referralCount; // Total referrals across all levels
private Long totalCommissionsEarned; // Total commissions earned from referrals
/** Profit in tickets (bigint): depositTotal - withdrawTotal */
private Long profit;
/** USD from db_users_b: depositTotal (tickets/1000) */
private BigDecimal depositTotalUsd;
/** USD from db_users_b: withdrawTotal (tickets/1000) */
private BigDecimal withdrawTotalUsd;
/** USD from db_users_b: profit (tickets/1000) */
private BigDecimal profitUsd;
}

View File

@@ -0,0 +1,36 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BalanceAdjustmentRequest {
@NotNull(message = "Balance type is required")
private BalanceType balanceType; // A or B
@NotNull(message = "Amount is required")
private Long amount; // In bigint format (tickets * 1,000,000)
@NotNull(message = "Operation is required")
private OperationType operation; // ADD or SUBTRACT
@NotBlank(message = "Reason is required")
private String reason; // Reason for adjustment (for audit log)
public enum BalanceType {
A, B
}
public enum OperationType {
ADD, SUBTRACT
}
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BalanceAdjustmentResponse {
private Long newBalanceA;
private Long newBalanceB;
private Long previousBalanceA;
private Long previousBalanceB;
private Long adjustmentAmount;
private String message;
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BalanceUpdateDto {
private Long balanceA; // Balance in bigint format (database format)
}

View File

@@ -0,0 +1,37 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BotRegisterRequest {
@JsonProperty("telegram_id")
private Long telegramId;
@JsonProperty("first_name")
private String firstName;
@JsonProperty("last_name")
private String lastName;
private String username;
@JsonProperty("is_premium")
private Boolean isPremium;
@JsonProperty("language_code")
private String languageCode;
@JsonProperty("photo_url")
private String photoUrl;
@JsonProperty("referral_user_id")
private Integer referralUserId;
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BotRegisterResponse {
@JsonProperty("user_id")
private Integer userId;
@JsonProperty("is_new_user")
private Boolean isNewUser;
private String message;
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ClaimTaskResponse {
private boolean success;
private String message;
}

View File

@@ -0,0 +1,23 @@
package com.honey.honey.dto;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request body for POST /api/payments/crypto-withdrawal.
*/
@Data
public class CreateCryptoWithdrawalRequest {
@NotNull(message = "pid is required")
private Integer pid;
@NotBlank(message = "wallet is required")
private String wallet;
/** Tickets amount in bigint format (tickets * 1_000_000). */
@NotNull(message = "total is required")
private Long total;
}

View File

@@ -0,0 +1,22 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateMessageRequest {
@NotBlank(message = "Message is required")
@Size(min = 3, max = 2000, message = "Message must be between 3 and 2000 characters")
private String message;
}

View File

@@ -0,0 +1,13 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class CreatePaymentRequest {
private Integer starsAmount; // Amount in Stars (legacy)
private Double usdAmount; // USD as decimal, e.g. 3.25 (crypto)
}

View File

@@ -0,0 +1,14 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class CreatePayoutRequest {
private String username;
private Long total; // Tickets amount in bigint format
private Integer starsAmount; // Stars amount (for STARS type)
private String type; // "STARS" or "GIFT"
private String giftName; // Gift name (for GIFT type): "HEART", "BEAR", etc.
private Integer quantity; // Quantity of gifts/stars (1-100, default 1)
}

View File

@@ -0,0 +1,9 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class CreateSessionRequest {
private String initData;
}

View File

@@ -0,0 +1,16 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateSessionResponse {
private String access_token;
private Integer expires_in;
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateTicketRequest {
@NotBlank(message = "Subject is required")
@Size(min = 5, max = 100, message = "Subject must be between 5 and 100 characters")
private String subject;
@NotBlank(message = "Message is required")
@Size(min = 3, max = 2000, message = "Message must be between 3 and 2000 characters")
private String message;
}

View File

@@ -0,0 +1,52 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
/** Response from external GET /api/v1/deposit-methods */
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CryptoDepositMethodsResponse {
@JsonProperty("request_info")
private RequestInfo requestInfo;
@JsonProperty("result")
private Result result;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class RequestInfo {
@JsonProperty("error_code")
private Integer errorCode;
@JsonProperty("error_message")
private String errorMessage;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Result {
@JsonProperty("active_methods")
private List<ActiveMethod> activeMethods;
@JsonProperty("hash")
private String hash;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ActiveMethod {
@JsonProperty("pid")
private Integer pid;
@JsonProperty("name")
private String name;
@JsonProperty("network")
private String network;
@JsonProperty("example")
private String example;
@JsonProperty("min_deposit_sum")
private Double minDepositSum;
}
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Minimal response for POST /api/payments/crypto-withdrawal.
* Exposes only id and status; no internal or PII fields.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CryptoWithdrawalResponse {
private Long id;
private String status;
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DailyBonusStatusDto {
private Integer taskId;
private Boolean available; // true if bonus can be claimed, false if on cooldown
private Long cooldownSeconds; // Remaining cooldown time in seconds (null if available)
private Long rewardAmount; // Reward amount in bigint format (1 ticket = 1000000)
}

View File

@@ -0,0 +1,43 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
/**
* Request body for external POST api/v1/deposit-address.
*/
@Data
@Builder
public class DepositAddressApiRequest {
@JsonProperty("pid")
private Integer pid;
@JsonProperty("amount_usd")
private Double amountUsd;
@JsonProperty("user_data")
private UserData userData;
@Data
@Builder
public static class UserData {
@JsonProperty("internal_id")
private Integer internalId;
@JsonProperty("screen_name")
private String screenName;
@JsonProperty("tg_username")
private String tgUsername;
@JsonProperty("tg_id")
private String tgId;
@JsonProperty("country_code")
private String countryCode;
@JsonProperty("device_code")
private String deviceCode;
@JsonProperty("language_code")
private String languageCode;
@JsonProperty("user_ip")
private String userIp;
}
}

View File

@@ -0,0 +1,13 @@
package com.honey.honey.dto;
import lombok.Data;
/**
* Request from frontend to get a crypto deposit address.
* usdAmount: decimal, e.g. 3.25 USD.
*/
@Data
public class DepositAddressRequest {
private Integer pid; // PID from deposit-methods
private Double usdAmount; // USD as decimal, e.g. 3.25
}

View File

@@ -0,0 +1,43 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Response from external POST api/v1/deposit-address (and returned to frontend).
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class DepositAddressResponse {
@JsonProperty("request_info")
private RequestInfo requestInfo;
@JsonProperty("result")
private Result result;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class RequestInfo {
@JsonProperty("error_code")
private Integer errorCode;
@JsonProperty("error_message")
private String errorMessage;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Result {
@JsonProperty("ps_id")
private Integer psId;
@JsonProperty("name")
private String name;
@JsonProperty("network")
private String network;
@JsonProperty("address")
private String address;
@JsonProperty("amount_coins")
private String amountCoins;
}
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.dto;
import lombok.Builder;
import lombok.Data;
/**
* Result returned to frontend after getting deposit address from crypto API.
* No payment record is created at this step.
*/
@Data
@Builder
public class DepositAddressResultDto {
private String address;
private String amountCoins;
private String name;
private String network;
private Integer psId;
/** Minimum deposit for this method (from crypto_deposit_methods.min_deposit_sum), for display on Payment Confirmation. Value as in DB, e.g. 2.50, 40.00. */
private Double minAmount;
}

View File

@@ -0,0 +1,32 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
/** Response for GET /api/payments/deposit-methods */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DepositMethodsDto {
private BigDecimal minimumDeposit;
private List<DepositMethodItemDto> activeMethods;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DepositMethodItemDto {
private Integer pid;
private String name;
private String network;
private String example;
private BigDecimal minDepositSum;
}
}

View File

@@ -0,0 +1,16 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
private String message;
}

View File

@@ -0,0 +1,18 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Request body for 3rd party deposit webhook: POST /api/deposit_webhook/{token}.
* usd_amount: decimal, e.g. 1.45 (3rd party sends as number).
*/
@Data
public class ExternalDepositWebhookRequest {
@JsonProperty("user_id")
private Integer userId;
@JsonProperty("usd_amount")
private Double usdAmount;
}

View File

@@ -0,0 +1,24 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JoinRoundRequest {
@NotNull(message = "Room number is required")
@Min(value = 1, message = "Room number must be between 1 and 3")
@Max(value = 3, message = "Room number must be between 1 and 3")
private Integer roomNumber;
@NotNull(message = "Bet amount is required")
@Positive(message = "Bet amount must be a positive integer")
private Long betAmount;
}

View File

@@ -0,0 +1,24 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageDto {
private Long id;
private Long ticketId;
private Integer userId;
private String message;
private Instant createdAt;
private Boolean isFromSupport; // true if message is from support agent (different user_id than ticket owner)
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class NotifyBroadcastRequest {
/** HTML/text message. */
private String message;
/** Optional image URL (ignored if videoUrl is set). */
private String imageUrl;
/** Optional video URL (takes priority over imageUrl). */
private String videoUrl;
/** Internal user id range start (default 1). */
private Integer userIdFrom;
/** Internal user id range end (default max id). */
private Integer userIdTo;
/** Optional button text; if set, adds an inline button that opens the mini app. */
private String buttonText;
/** When true, skip users whose latest notification_audit record has status FAILED (e.g. blocked the bot). When false or null, send to all in range. */
private Boolean ignoreBlocked;
}

View File

@@ -0,0 +1,23 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantDto {
@JsonProperty("uI")
private Integer userId;
@JsonProperty("b")
private Long bet; // In tickets (not bigint)
@JsonProperty("aU")
private String avatarUrl;
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentInvoiceResponse {
private String invoiceId; // Order ID to be used in Telegram invoice
private String invoiceUrl; // Invoice URL to open in Telegram (null for crypto)
private Integer starsAmount; // Amount in Stars (legacy)
private Double usdAmount; // USD as decimal, e.g. 3.25 (crypto)
private Long ticketsAmount; // Tickets amount in bigint format
}

View File

@@ -0,0 +1,16 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class PaymentWebhookRequest {
private String orderId; // Order ID from Telegram invoice
private Long telegramUserId; // Telegram user ID
private String telegramPaymentChargeId; // Telegram payment charge ID
private String telegramProviderPaymentChargeId; // Telegram provider payment charge ID
private Integer starsAmount; // Amount in Stars
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for payout history table entries.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayoutHistoryEntryDto {
private Long amount; // Total in bigint format (will be converted to tickets on frontend)
private String date; // Formatted as dd.MM at HH:mm (e.g., "13.01 at 22:29")
private String status; // PROCESSING, COMPLETED, CANCELLED
}

View File

@@ -0,0 +1,24 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PayoutResponse {
private Long id;
private String username;
private String type;
private String giftName;
private Long total;
private Integer starsAmount;
private Integer quantity;
private String status;
private Long createdAt; // Unix timestamp in milliseconds
private Long resolvedAt; // Unix timestamp in milliseconds, null if not resolved
}

View File

@@ -0,0 +1,29 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PromotionDetailDto {
private Integer id;
private String type;
private String status;
private Instant startTime;
private Instant endTime;
private Long totalReward;
private List<PromotionLeaderboardEntryDto> leaderboard;
/** 1-based position of current user (0 if not in leaderboard). */
private int userPosition;
/** Total number of participants. */
private int userTotal;
private BigDecimal userPoints;
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PromotionLeaderboardEntryDto {
private int place;
private String screenName;
private BigDecimal points;
/** Reward for this place in tickets (null if no reward for this place). */
private Long rewardTickets;
}

View File

@@ -0,0 +1,22 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PromotionListItemDto {
private Integer id;
private String type;
private String status;
private Instant startTime;
private Instant endTime;
/** Total prize fund in bigint (1 ticket = 1_000_000). */
private Long totalReward;
}

View File

@@ -0,0 +1,13 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class QuickAnswerCreateRequest {
private String text;
}

View File

@@ -0,0 +1,18 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class QuickAnswerDto {
private Integer id;
private String text;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* DTO for recent daily bonus claims.
* Contains user information and claim timestamp.
* The claimedAt field contains the raw timestamp, but the date field contains the formatted string.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RecentBonusClaimDto {
private String avatarUrl;
private String screenName;
private LocalDateTime claimedAt;
private String date; // Formatted date string (dd.MM 'at' HH:mm) with timezone
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReferralDto {
private String name; // screen_name from db_users_a
private Long commission; // to_referer_1/2/3 from db_users_d (bigint, needs to be divided by 1,000,000 on frontend)
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReferralLevelDto {
private Integer level; // 1-5
private Integer refererId;
private Integer referralCount;
private Long commissionsEarned;
private Long commissionsPaid;
/** Commissions earned in USD (converted from tickets: 1000 tickets = 1 USD). */
private BigDecimal commissionsEarnedUsd;
/** Commissions paid in USD (converted from tickets). */
private BigDecimal commissionsPaidUsd;
}

View File

@@ -0,0 +1,18 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SupportTicketReplyRequest {
@NotBlank(message = "Message is required")
private String message;
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TaskDto {
private Integer id;
private String type; // referral, follow, other
private Long requirement; // For referral tasks: number of friends. For other tasks: deposit_total threshold in bigint (frontend converts)
private Long rewardAmount; // bigint format
private String rewardType; // Tickets (all tasks use Tickets as reward type)
private String title;
private String description;
private Integer displayOrder;
private Boolean claimed; // Whether user has claimed this task
private String progress; // Progress string like "1008 / 30" or "CLAIMED" (for referral) or null (for follow/other - frontend handles)
private Long currentValue; // Current value in bigint format (referals1 for referral, depositTotal for other). Frontend converts for display.
private String localizedRewardText; // Localized reward text like "+2 Билеты" or "+5 Билетов" (for proper grammar)
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Telegram Bot API response wrapper.
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class TelegramApiResponse {
@JsonProperty("ok")
private Boolean ok;
@JsonProperty("description")
private String description;
}

View File

@@ -0,0 +1,17 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Result of a single Telegram send call for broadcast audit.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TelegramSendResult {
private boolean success;
/** HTTP status code from Telegram API (e.g. 200, 403, 429). */
private int statusCode;
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.dto;
import com.honey.honey.model.SupportTicket.TicketStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TicketDetailDto {
private Long id;
private String subject;
private TicketStatus status;
private Instant createdAt;
private Instant updatedAt;
private List<MessageDto> messages;
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import com.honey.honey.model.SupportTicket.TicketStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TicketDto {
private Long id;
private String subject;
private TicketStatus status;
private Instant createdAt;
private Instant updatedAt;
private Integer messageCount;
}

View File

@@ -0,0 +1,39 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for a transaction entry in transaction history.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TransactionDto {
/**
* Amount in bigint format (positive for credits, negative for debits).
* Example: +900000000 means +900.0000 (credit)
* Example: -100000000 means -100.0000 (debit)
*/
private Long amount;
/**
* Date formatted as dd.MM at HH:mm (e.g., "13.01 at 22:29")
*/
private String date;
/**
* Transaction type: DEPOSIT, WITHDRAWAL, TASK_BONUS, CANCELLATION_OF_WITHDRAWAL
*/
private String type;
/**
* Task ID for TASK_BONUS type (null for other types)
*/
private Integer taskId;
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for user check endpoint response.
* Contains user information for external applications.
* Always returned with HTTP 200; use {@code found} to distinguish user-not-found from success.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserCheckDto {
/** When false, user was not found by telegramId; other fields are null. */
private Boolean found;
private Integer dateReg;
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
}

View File

@@ -0,0 +1,23 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant;
/** Deposit (payment) row for admin user detail Deposits tab. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDepositDto {
private Long id;
private BigDecimal usdAmount;
private String status;
private String orderId;
private Instant createdAt;
private Instant completedAt;
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {
private Integer id; // User ID
private Long telegram_id;
private String username;
private String screenName; // User's screen name
private Integer dateReg; // Registration date (Unix timestamp in seconds)
private String ip;
private Long balanceA; // Balance (stored as bigint, represents number with 6 decimal places)
private String avatarUrl; // Public URL of user's avatar
private String languageCode; // User's language preference (EN, RU, DE, IT, NL, PL, FR, ES, ID, TR)
private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed
private Boolean payoutEnabled; // Runtime toggle: withdrawals (Payout, crypto withdrawal) allowed
private Boolean promotionsEnabled; // Runtime toggle: Promotions button and /api/promotions endpoints
}

Some files were not shown because too many files have changed in this diff Show More