Compare commits

...

15 Commits

Author SHA1 Message Date
Tihon
dfd6e78664 admin statistics part4
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m19s
2026-03-22 18:00:11 +02:00
Tihon
b2415acdcf admin statistics part4
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m21s
2026-03-22 16:43:20 +02:00
Tihon
26515ab621 admin statistics part3
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m17s
2026-03-20 14:06:39 +02:00
Tihon
31768fcc07 admin statistics part3
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m23s
2026-03-20 13:39:38 +02:00
Tihon
2bf2125c3e admin statistics part2
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m17s
2026-03-19 15:57:20 +02:00
Tihon
5855678447 admin statistics part1
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m20s
2026-03-19 13:36:51 +02:00
Tihon
83c2757701 admin statistics part1
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m21s
2026-03-19 12:27:14 +02:00
Tihon
90efdf1f59 admin panel cleanup
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m26s
2026-03-18 13:15:08 +02:00
Tihon
955c6d1c01 added swagger examples
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m27s
2026-03-17 16:48:36 +02:00
Tihon
0c0bb5a5bc chatwoot admin panel fixes
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m16s
2026-03-16 18:48:46 +02:00
Tihon
2779e7a1c1 chatwoot admin panel fixes
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m19s
2026-03-16 18:15:48 +02:00
Tihon
284fd07bea chatwoot admin panel integration
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m23s
2026-03-16 17:00:55 +02:00
Tihon
bd260497f9 bigint conversion fixes
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m18s
2026-03-12 15:23:52 +02:00
Tihon
fb92993e39 added referrals deposit logic
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m20s
2026-03-12 13:59:26 +02:00
Tihon
7d60ebacda Referral screen 2026-03-11 18:01:32 +02:00
56 changed files with 1726 additions and 292 deletions

View File

@@ -70,13 +70,14 @@ location ~ ^/dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/(a
access_log off;
}
# Admin panel: allow embedding in Chatwoot iframe (frame-ancestors); do not set X-Frame-Options here so CSP applies
location /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/ {
alias /opt/app/admin-panel/;
index index.html;
try_files $uri $uri/ /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/index.html;
expires 0;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "frame-ancestors 'self' https://honey-support.online;" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@@ -103,14 +103,14 @@ public class AdminSecurityConfig {
.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/login", "/api/admin/chatwoot-session").permitAll()
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "SUPPORT")
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "SUPPORT")
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "SUPPORT")
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().denyAll()
)

View File

@@ -7,6 +7,7 @@ import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -18,12 +19,23 @@ import java.util.Map;
public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
public static final String EXAMPLE_CURRENT_USER_RESPONSE = "CurrentUserResponse";
public static final String EXAMPLE_LANGUAGE_REQUEST = "LanguageRequest";
public static final String EXAMPLE_DEPOSIT_METHODS_RESPONSE = "DepositMethodsResponse";
public static final String EXAMPLE_DEPOSIT_ADDRESS_REQUEST = "DepositAddressRequest";
public static final String EXAMPLE_DEPOSIT_ADDRESS_RESPONSE = "DepositAddressResponse";
@Override
public void customise(OpenAPI openAPI) {
ensureComponents(openAPI);
addCurrentUserResponseExample(openAPI);
attachCurrentUserExampleToEndpoint(openAPI);
addLanguageRequestExample(openAPI);
attachLanguageExampleToEndpoint(openAPI);
addDepositMethodsResponseExample(openAPI);
attachDepositMethodsExampleToEndpoint(openAPI);
addDepositAddressRequestExample(openAPI);
addDepositAddressResponseExample(openAPI);
attachDepositAddressExamplesToEndpoint(openAPI);
}
private void ensureComponents(OpenAPI openAPI) {
@@ -43,8 +55,8 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
value.put("screenName", "The Boy");
value.put("dateReg", 1772897273);
value.put("ip", "165.165.165.165");
value.put("balanceA", 100000);
value.put("balanceB", 100000);
value.put("balanceA", 10000);
value.put("balanceB", 10000);
value.put("avatarUrl", "/avatars/0/0/1/06c98267.png?v=1772897273");
value.put("languageCode", "EN");
value.put("paymentEnabled", true);
@@ -74,10 +86,138 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
MediaType json = response200.getContent().get("application/json");
if (json == null) return;
// Reuse the component example so Swagger UI shows it (same instance as in components)
Example example = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_CURRENT_USER_RESPONSE);
if (example != null) {
json.setExamples(Map.of("success", example));
}
}
private void addLanguageRequestExample(OpenAPI openAPI) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("languageCode", "PL");
Example example = new Example();
example.setSummary("Update language request");
example.setValue(value);
openAPI.getComponents().getExamples().put(EXAMPLE_LANGUAGE_REQUEST, example);
}
private void attachLanguageExampleToEndpoint(OpenAPI openAPI) {
var paths = openAPI.getPaths();
if (paths == null) return;
var pathItem = paths.get("/api/users/language");
if (pathItem == null || pathItem.getPut() == null) return;
var requestBody = pathItem.getPut().getRequestBody();
if (requestBody == null || requestBody.getContent() == null) return;
MediaType json = requestBody.getContent().get("application/json");
if (json == null) return;
Example example = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_LANGUAGE_REQUEST);
if (example != null) {
json.setExamples(Map.of("PL", example));
}
}
private void addDepositMethodsResponseExample(OpenAPI openAPI) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("minimumDeposit", 2.50);
value.put("activeMethods", List.of(
Map.of(
"pid", 235,
"name", "TON",
"network", "TON",
"example", "UQAm3JwwV_wMgmJ05AzHqtlHAdkyJt58N-JHV2Uhf80hOEKD",
"minDepositSum", 2.50
),
Map.of(
"pid", 90,
"name", "TRON",
"network", "TRC20",
"example", "TMQqX43PAMZPXPxX6Qj1fCyeiMWLgW35yF",
"minDepositSum", 2.50
),
Map.of(
"pid", 10,
"name", "Bitcoin",
"network", "BTC",
"example", "131qX9kauDpCGyn2MfAFwHcrrVk7JTLAYj",
"minDepositSum", 2.50
)
));
Example example = new Example();
example.setSummary("Deposit methods response");
example.setValue(value);
openAPI.getComponents().getExamples().put(EXAMPLE_DEPOSIT_METHODS_RESPONSE, example);
}
private void attachDepositMethodsExampleToEndpoint(OpenAPI openAPI) {
var paths = openAPI.getPaths();
if (paths == null) return;
var pathItem = paths.get("/api/payments/deposit-methods");
if (pathItem == null || pathItem.getGet() == null) return;
var responses = pathItem.getGet().getResponses();
if (responses == null) return;
var response200 = responses.get("200");
if (response200 == null || response200.getContent() == null) return;
MediaType json = response200.getContent().get("application/json");
if (json == null) return;
Example example = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_DEPOSIT_METHODS_RESPONSE);
if (example != null) {
json.setExamples(Map.of("success", example));
}
}
private void addDepositAddressRequestExample(OpenAPI openAPI) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("pid", 235);
value.put("usdAmount", 3);
Example example = new Example();
example.setSummary("Deposit address request");
example.setValue(value);
openAPI.getComponents().getExamples().put(EXAMPLE_DEPOSIT_ADDRESS_REQUEST, example);
}
private void addDepositAddressResponseExample(OpenAPI openAPI) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("address", "UQAY95OcdK3Cu-SEflKsaeN7y463BASTmnY9OUdRLJK-FDxc");
value.put("amountCoins", "2.234549");
value.put("name", "TON");
value.put("network", "-");
value.put("psId", 235);
value.put("minAmount", 2.5);
Example example = new Example();
example.setSummary("Deposit address response");
example.setValue(value);
openAPI.getComponents().getExamples().put(EXAMPLE_DEPOSIT_ADDRESS_RESPONSE, example);
}
private void attachDepositAddressExamplesToEndpoint(OpenAPI openAPI) {
var paths = openAPI.getPaths();
if (paths == null) return;
var pathItem = paths.get("/api/payments/deposit-address");
if (pathItem == null || pathItem.getPost() == null) return;
var requestBody = pathItem.getPost().getRequestBody();
if (requestBody != null && requestBody.getContent() != null) {
MediaType reqJson = requestBody.getContent().get("application/json");
if (reqJson != null) {
Example reqExample = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_DEPOSIT_ADDRESS_REQUEST);
if (reqExample != null) {
reqJson.setExamples(Map.of("example", reqExample));
}
}
}
var responses = pathItem.getPost().getResponses();
if (responses != null) {
var response200 = responses.get("200");
if (response200 != null && response200.getContent() != null) {
MediaType resJson = response200.getContent().get("application/json");
if (resJson != null) {
Example resExample = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_DEPOSIT_ADDRESS_RESPONSE);
if (resExample != null) {
resJson.setExamples(Map.of("success", resExample));
}
}
}
}
}
}

View File

@@ -5,6 +5,8 @@ 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 com.honey.honey.repository.UserDRepository;
import com.honey.honey.service.AdminStatisticsTableService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -13,7 +15,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
@@ -26,102 +30,116 @@ import java.util.Map;
@PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController {
private static final BigDecimal ZERO = BigDecimal.ZERO;
private final UserARepository userARepository;
private final UserDRepository userDRepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final AdminStatisticsTableService adminStatisticsTableService;
/**
* 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
* Paginated daily / monthly statistics table (admin).
*/
@GetMapping("/revenue")
public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
@RequestParam(defaultValue = "30d") String range) {
@GetMapping("/statistics-table")
public ResponseEntity<Map<String, Object>> getStatisticsTable(
@RequestParam String mode,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(defaultValue = "desc") String sortDir) {
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";
AdminStatisticsTableService.TableMode tableMode;
try {
tableMode = AdminStatisticsTableService.TableMode.valueOf(mode.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
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);
String dir = sortDir != null ? sortDir.trim().toLowerCase() : "desc";
if (!dir.equals("asc") && !dir.equals("desc")) {
return ResponseEntity.badRequest().build();
}
boolean sortAscending = dir.equals("asc");
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
int cappedSize = Math.min(200, Math.max(1, size));
int safePage = Math.max(0, page);
// 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;
}
var dtoPage = adminStatisticsTableService.getStatisticsTable(tableMode, safePage, cappedSize, sortAscending);
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
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());
response.put("mode", tableMode.name());
response.put("sortDir", dir);
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
* Bounds for admin statistics-by-day UI: UTC day start of the earliest {@code db_users_d.date_reg}
* and exclusive end of the latest 30-day window (start of tomorrow UTC).
*/
@GetMapping("/activity")
public ResponseEntity<Map<String, Object>> getActivityAnalytics(
@GetMapping("/meta")
public ResponseEntity<Map<String, Object>> getAnalyticsMeta() {
Instant latestWindowEndExclusive = Instant.now()
.atZone(ZoneOffset.UTC)
.toLocalDate()
.plusDays(1)
.atStartOfDay(ZoneOffset.UTC)
.toInstant();
long firstProjectDayStartEpoch = latestWindowEndExclusive.getEpochSecond() - 30L * 24 * 3600;
var regOpt = userDRepository.findMinDateReg();
if (regOpt.isPresent() && regOpt.get() != null && regOpt.get() > 0) {
Instant reg = Instant.ofEpochSecond(regOpt.get());
firstProjectDayStartEpoch = reg.atZone(ZoneOffset.UTC)
.toLocalDate()
.atStartOfDay(ZoneOffset.UTC)
.toEpochSecond();
}
Map<String, Object> response = new HashMap<>();
response.put("latestWindowEndExclusiveEpoch", latestWindowEndExclusive.getEpochSecond());
response.put("firstProjectDayStartEpoch", firstProjectDayStartEpoch);
return ResponseEntity.ok(response);
}
/**
* Revenue / deposits and payouts time series.
* Optional {@code from} + {@code toExclusive} (epoch seconds, UTC instants): daily buckets in [from, toExclusive).
* Otherwise {@code range}: 7d, 30d, 90d, 1y, all (legacy).
*/
@GetMapping("/revenue")
public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
@RequestParam(required = false) Long from,
@RequestParam(required = false) Long toExclusive,
@RequestParam(defaultValue = "30d") String range) {
if (from != null && toExclusive != null) {
Instant start = Instant.ofEpochSecond(from);
Instant end = Instant.ofEpochSecond(toExclusive);
if (!end.isAfter(start)) {
return ResponseEntity.badRequest().build();
}
long maxSpanSeconds = 366L * 24 * 3600;
if (end.getEpochSecond() - start.getEpochSecond() > maxSpanSeconds) {
return ResponseEntity.badRequest().build();
}
List<Map<String, Object>> dataPoints = buildRevenueDailySeries(start, end);
Map<String, Object> response = new HashMap<>();
response.put("range", "custom");
response.put("granularity", "daily");
response.put("from", from);
response.put("toExclusive", toExclusive);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
Instant now = Instant.now();
Instant startDate;
String granularity;
@@ -169,24 +187,7 @@ public class AdminAnalyticsController {
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);
dataPoints.add(buildRevenuePoint(current, periodEnd));
current = periodEnd;
}
@@ -197,5 +198,156 @@ public class AdminAnalyticsController {
return ResponseEntity.ok(response);
}
}
/**
* User activity time series.
* Optional {@code from} + {@code toExclusive} for daily buckets in [from, toExclusive).
*/
@GetMapping("/activity")
public ResponseEntity<Map<String, Object>> getActivityAnalytics(
@RequestParam(required = false) Long from,
@RequestParam(required = false) Long toExclusive,
@RequestParam(defaultValue = "30d") String range) {
if (from != null && toExclusive != null) {
Instant start = Instant.ofEpochSecond(from);
Instant end = Instant.ofEpochSecond(toExclusive);
if (!end.isAfter(start)) {
return ResponseEntity.badRequest().build();
}
long maxSpanSeconds = 366L * 24 * 3600;
if (end.getEpochSecond() - start.getEpochSecond() > maxSpanSeconds) {
return ResponseEntity.badRequest().build();
}
List<Map<String, Object>> dataPoints = buildActivityDailySeries(start, end);
Map<String, Object> response = new HashMap<>();
response.put("range", "custom");
response.put("granularity", "daily");
response.put("from", from);
response.put("toExclusive", toExclusive);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
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;
}
dataPoints.add(buildActivityPoint(current, periodEnd));
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
private List<Map<String, Object>> buildActivityDailySeries(Instant startInclusive, Instant endExclusive) {
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startInclusive;
while (current.isBefore(endExclusive)) {
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
if (periodEnd.isAfter(endExclusive)) {
periodEnd = endExclusive;
}
dataPoints.add(buildActivityPoint(current, periodEnd));
current = periodEnd;
}
return dataPoints;
}
private Map<String, Object> buildActivityPoint(Instant current, Instant periodEnd) {
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
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);
return point;
}
private List<Map<String, Object>> buildRevenueDailySeries(Instant startInclusive, Instant endExclusive) {
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startInclusive;
while (current.isBefore(endExclusive)) {
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
if (periodEnd.isAfter(endExclusive)) {
periodEnd = endExclusive;
}
dataPoints.add(buildRevenuePoint(current, periodEnd));
current = periodEnd;
}
return dataPoints;
}
private Map<String, Object> buildRevenuePoint(Instant current, Instant periodEnd) {
BigDecimal revenueUsd = paymentRepository
.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd)
.orElse(ZERO);
BigDecimal payoutsUsd = payoutRepository
.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
Payout.PayoutStatus.COMPLETED, current, periodEnd)
.orElse(ZERO);
BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
long firstTimeDeposits = paymentRepository.countByStatusAndFtdTrueAndUsdNotNullAndCompletedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("revenue", revenueUsd);
point.put("payouts", payoutsUsd);
point.put("netRevenue", netRevenueUsd);
point.put("firstTimeDeposits", firstTimeDeposits);
return point;
}
}

View File

@@ -2,11 +2,9 @@ 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;
@@ -30,7 +28,6 @@ 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() {
@@ -103,15 +100,6 @@ public class AdminDashboardController {
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,
@@ -162,19 +150,6 @@ public class AdminDashboardController {
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

@@ -2,8 +2,11 @@ package com.honey.honey.controller;
import com.honey.honey.dto.AdminLoginRequest;
import com.honey.honey.dto.AdminLoginResponse;
import com.honey.honey.dto.ChatwootSessionRequest;
import com.honey.honey.security.admin.JwtUtil;
import com.honey.honey.service.AdminService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@@ -19,6 +22,11 @@ import java.util.Optional;
public class AdminLoginController {
private final AdminService adminService;
private final JwtUtil jwtUtil;
/** Shared secret with Chatwoot. Set via env CHATWOOT_INTEGRATION_SECRET (e.g. from VPS secret file). */
@Value("${CHATWOOT_INTEGRATION_SECRET:}")
private String chatwootIntegrationSecret;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) {
@@ -47,5 +55,30 @@ public class AdminLoginController {
role
));
}
/**
* Exchanges a Chatwoot integration API key for an admin JWT with ROLE_SUPPORT.
* Used when the admin panel is embedded in Chatwoot as a Dashboard App iframe.
* API key must match CHATWOOT_INTEGRATION_SECRET on the server.
*/
@PostMapping("/chatwoot-session")
public ResponseEntity<?> chatwootSession(@RequestBody ChatwootSessionRequest request) {
if (request.getApiKey() == null || request.getApiKey().isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("apiKey is required");
}
if (chatwootIntegrationSecret == null || chatwootIntegrationSecret.isBlank()) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Chatwoot integration is not configured (CHATWOOT_INTEGRATION_SECRET)");
}
if (!chatwootIntegrationSecret.equals(request.getApiKey().trim())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid apiKey");
}
String token = jwtUtil.generateTokenWithRole("__chatwoot__", "ROLE_SUPPORT");
return ResponseEntity.ok(new AdminLoginResponse(
token,
"__chatwoot__",
"ROLE_SUPPORT"
));
}
}

View File

@@ -7,8 +7,6 @@ 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;
@@ -16,6 +14,8 @@ 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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -31,18 +31,18 @@ import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payments")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public class AdminPaymentController {
private final PaymentRepository paymentRepository;
private final UserARepository userARepository;
private final UserDRepository userDRepository;
private boolean isGameAdmin() {
private static boolean isSupport() {
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()));
.anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
}
@GetMapping
@@ -70,7 +70,7 @@ public class AdminPaymentController {
Pageable pageable = PageRequest.of(page, size, sort);
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
List<Integer> masterIds = isSupport() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payment> spec = (root, query, cb) -> {
@@ -124,6 +124,7 @@ public class AdminPaymentController {
.orderId(payment.getOrderId())
.starsAmount(payment.getStarsAmount())
.ticketsAmount(payment.getTicketsAmount())
.usdAmount(payment.getUsdAmount())
.status(payment.getStatus().name())
.telegramPaymentChargeId(payment.getTelegramPaymentChargeId())
.telegramProviderPaymentChargeId(payment.getTelegramProviderPaymentChargeId())

View File

@@ -35,7 +35,7 @@ import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payouts")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public class AdminPayoutController {
private final PayoutRepository payoutRepository;
@@ -45,11 +45,11 @@ public class AdminPayoutController {
private final PayoutService payoutService;
private final LocalizationService localizationService;
private boolean isGameAdmin() {
private static boolean isSupport() {
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()));
.anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
}
@GetMapping
@@ -74,7 +74,7 @@ public class AdminPayoutController {
Pageable pageable = PageRequest.of(page, size, sort);
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
List<Integer> masterIds = isSupport() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payout> spec = (root, query, cb) -> {
@@ -135,6 +135,7 @@ public class AdminPayoutController {
.type(payout.getType().name())
.giftName(payout.getGiftName() != null ? payout.getGiftName().name() : null)
.total(payout.getTotal())
.usdAmount(payout.getUsdAmount())
.starsAmount(payout.getStarsAmount())
.quantity(payout.getQuantity())
.status(payout.getStatus().name())
@@ -156,7 +157,7 @@ public class AdminPayoutController {
}
@PostMapping("/{id}/complete")
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> completePayout(@PathVariable Long id) {
try {
Optional<Payout> payoutOpt = payoutRepository.findById(id);
@@ -179,7 +180,7 @@ public class AdminPayoutController {
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> cancelPayout(@PathVariable Long id) {
try {
Optional<Payout> payoutOpt = payoutRepository.findById(id);

View File

@@ -0,0 +1,60 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminProjectStatisticsDto;
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 com.honey.honey.repository.UserBRepository;
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.math.RoundingMode;
@RestController
@RequestMapping("/api/admin/statistics")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminStatisticsController {
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
@GetMapping("/project")
public ResponseEntity<AdminProjectStatisticsDto> getProjectStatistics() {
long registered = userARepository.count();
long subscribed = userARepository.countByBotActiveIsTrue();
long blocked = userARepository.countByBotActiveIsFalse();
BigDecimal totalDeposits = paymentRepository.sumUsdAmountByStatus(Payment.PaymentStatus.COMPLETED)
.orElse(BigDecimal.ZERO);
BigDecimal totalWithdrawals = payoutRepository.sumUsdAmountByStatus(Payout.PayoutStatus.COMPLETED)
.orElse(BigDecimal.ZERO);
long withDeposit = userBRepository.countUsersWithDeposit();
BigDecimal percent = BigDecimal.ZERO;
if (registered > 0) {
percent = BigDecimal.valueOf(withDeposit)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(registered), 2, RoundingMode.HALF_UP);
}
AdminProjectStatisticsDto dto = AdminProjectStatisticsDto.builder()
.registeredUsers(registered)
.subscribedToBot(subscribed)
.blockedBot(blocked)
.totalDepositsUsd(totalDeposits)
.totalWithdrawalsUsd(totalWithdrawals)
.usersWithDeposit(withDeposit)
.depositUserPercent(percent)
.build();
return ResponseEntity.ok(dto);
}
}

View File

@@ -35,7 +35,7 @@ import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/tickets")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN')")
public class AdminSupportTicketController {
private final SupportTicketRepository supportTicketRepository;

View File

@@ -1,7 +1,9 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.model.UserA;
import com.honey.honey.service.AdminUserService;
import com.honey.honey.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -23,26 +25,28 @@ import java.util.Set;
@RequiredArgsConstructor
public class AdminUserController {
private static boolean isSupport() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
}
/** 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"
"balanceA", "balanceB", "depositTotal", "withdrawTotal", "referralCount", "profit",
"referrerTelegramName", "profitPercent"
);
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()));
}
private final UserService userService;
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@@ -55,16 +59,23 @@ public class AdminUserController {
@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) Long balanceAMin,
@RequestParam(required = false) Long balanceAMax,
@RequestParam(required = false) Long balanceBMin,
@RequestParam(required = false) Long balanceBMax,
@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) {
@RequestParam(required = false) String ip,
@RequestParam(required = false) Boolean botActive,
@RequestParam(required = false) Integer depositCountMin,
@RequestParam(required = false) Boolean hideSubAndBanned) {
// 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");
// Build sort. Fields on UserB/UserD (balanceA, balanceB, depositTotal, etc.) are handled in service via custom query.
Set<String> sortRequiresJoin = Set.of(
"balanceA", "balanceB", "depositTotal", "withdrawTotal", "referralCount", "profit",
"referrerTelegramName", "profitPercent");
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
@@ -78,11 +89,13 @@ public class AdminUserController {
}
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;
// Convert balance filters from display value to bigint format
Long balanceAMinBigint = balanceAMin != null ? balanceAMin * 1000000L : null;
Long balanceAMaxBigint = balanceAMax != null ? balanceAMax * 1000000L : null;
Long balanceBMinBigint = balanceBMin != null ? balanceBMin * 1000000L : null;
Long balanceBMaxBigint = balanceBMax != null ? balanceBMax * 1000000L : null;
boolean excludeMasters = isGameAdmin();
boolean excludeMasters = isSupport();
Page<AdminUserDto> dtoPage = adminUserService.getUsers(
pageable,
search,
@@ -91,16 +104,21 @@ public class AdminUserController {
languageCode,
dateRegFrom,
dateRegTo,
balanceMinBigint,
balanceMaxBigint,
balanceAMinBigint,
balanceAMaxBigint,
balanceBMinBigint,
balanceBMaxBigint,
referralCountMin,
referralCountMax,
referrerId,
referralLevel,
ip,
botActive,
depositCountMin,
effectiveSortBy,
sortDir,
excludeMasters
excludeMasters,
Boolean.TRUE.equals(hideSubAndBanned)
);
Map<String, Object> response = new HashMap<>();
@@ -115,10 +133,26 @@ public class AdminUserController {
return ResponseEntity.ok(response);
}
/**
* Resolve Honey user ID from Telegram user ID.
* Used by the Chatwoot embed when the contact has no honey_user_id set:
* Chatwoot (Telegram channel) often uses Telegram user ID as contact identifier.
*/
@GetMapping("/by-telegram-id")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Integer>> getUserByTelegramId(@RequestParam("telegram_id") Long telegramId) {
if (telegramId == null) {
return ResponseEntity.badRequest().build();
}
return userService.getUserByTelegramId(telegramId)
.map(user -> ResponseEntity.ok(Map.of("id", user.getId())))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<AdminUserDetailDto> getUserDetail(@PathVariable Integer id) {
AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isGameAdmin());
AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isSupport());
if (userDetail == null) {
return ResponseEntity.notFound().build();
}
@@ -126,7 +160,7 @@ public class AdminUserController {
}
@GetMapping("/{id}/transactions")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUserTransactions(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@@ -148,7 +182,7 @@ public class AdminUserController {
}
@GetMapping("/{id}/payments")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUserPayments(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@@ -171,7 +205,7 @@ public class AdminUserController {
}
@GetMapping("/{id}/payouts")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUserPayouts(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@@ -194,14 +228,14 @@ public class AdminUserController {
}
@GetMapping("/{id}/tasks")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
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')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<?> setUserBanned(
@PathVariable Integer id,
@RequestBody Map<String, Boolean> body) {
@@ -218,7 +252,7 @@ public class AdminUserController {
}
@PatchMapping("/{id}/withdrawals-enabled")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<?> setWithdrawalsEnabled(
@PathVariable Integer id,
@RequestBody Map<String, Boolean> body) {

View File

@@ -21,7 +21,7 @@ import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/quick-answers")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN')")
public class QuickAnswerController {
private final QuickAnswerRepository quickAnswerRepository;

View File

@@ -21,6 +21,7 @@ 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.ChatMemberUpdated;
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;
@@ -81,6 +82,12 @@ public class TelegramWebhookController {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
// Bot blocked / unblocked in private chat (Telegram my_chat_member)
if (update.hasMyChatMember()) {
handleMyChatMember(update.getMyChatMember());
return ResponseEntity.ok().build();
}
// Handle callback queries (button clicks)
if (update.hasCallbackQuery()) {
handleCallbackQuery(update.getCallbackQuery());
@@ -112,6 +119,27 @@ public class TelegramWebhookController {
}
}
/**
* Updates {@code bot_active} when the user blocks/unblocks the bot (private chats only).
*/
private void handleMyChatMember(ChatMemberUpdated cmu) {
if (cmu.getChat() == null || cmu.getNewChatMember() == null) {
return;
}
if (!"private".equalsIgnoreCase(cmu.getChat().getType())) {
return;
}
Long telegramUserId = cmu.getChat().getId();
String status = cmu.getNewChatMember().getStatus();
if ("kicked".equalsIgnoreCase(status) || "left".equalsIgnoreCase(status)) {
userService.setBotActiveByTelegramId(telegramUserId, false);
log.info("Private chat bot status: user {} set bot_active=false (status={})", telegramUserId, status);
} else if ("member".equalsIgnoreCase(status) || "administrator".equalsIgnoreCase(status)) {
userService.setBotActiveByTelegramId(telegramUserId, true);
log.info("Private chat bot status: user {} set bot_active=true (status={})", telegramUserId, status);
}
}
/**
* Handles /start command with optional referral parameter, and Reply Keyboard button clicks.
* Format: /start or /start 123 (where 123 is the referral user ID)
@@ -221,6 +249,7 @@ public class TelegramWebhookController {
try {
// Get or create user (handles registration, login update, and referral system)
UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
userService.setBotActiveByTelegramId(telegramId, true);
log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}",
user.getId(), user.getTelegramId(), isNewUser);

View File

@@ -24,6 +24,9 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class UserController {
/** Divisor for converting balance bigint to display value (backend sends display value only). */
private static final double BALANCE_DISPLAY_DIVISOR = 1_000_000.0;
private final UserService userService;
private final UserBRepository userBRepository;
private final AvatarService avatarService;
@@ -37,10 +40,12 @@ public class UserController {
// Convert IP from byte[] to string for display
String ipAddress = IpUtils.bytesToIp(user.getIp());
// Get balances from UserB
// Get balances from UserB; convert to display value (divide by 1_000_000)
var userBOpt = userBRepository.findById(user.getId());
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
Long balanceB = userBOpt.map(UserB::getBalanceB).orElse(0L);
long rawBalanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
long rawBalanceB = userBOpt.map(UserB::getBalanceB).orElse(0L);
Double balanceA = rawBalanceA / BALANCE_DISPLAY_DIVISOR;
Double balanceB = rawBalanceB / BALANCE_DISPLAY_DIVISOR;
Integer depositCount = userBOpt.map(UserB::getDepositCount).orElse(0);
// Generate avatar URL on-the-fly (deterministic from userId)
@@ -113,7 +118,7 @@ public class UserController {
/**
* Gets referrals for a specific level with pagination.
* Always returns 50 results per page.
* Returns 10 results per page, ordered by commission DESC, then id DESC.
*
* @param level The referral level (1, 2, or 3)
* @param page Page number (0-indexed, defaults to 0)
@@ -126,7 +131,6 @@ public class UserController {
UserA user = UserContext.get();
Page<ReferralDto> referralsPage = userService.getReferrals(user.getId(), level, page);
return new ReferralsResponse(
referralsPage.getContent(),
referralsPage.getNumber(),

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant;
@Data
@@ -18,6 +19,8 @@ public class AdminPaymentDto {
private String orderId;
private Integer starsAmount;
private Long ticketsAmount;
/** USD amount (e.g. 1.25). */
private BigDecimal usdAmount;
private String status;
private String telegramPaymentChargeId;
private String telegramProviderPaymentChargeId;

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant;
@Data
@@ -19,6 +20,8 @@ public class AdminPayoutDto {
private String type;
private String giftName;
private Long total;
/** USD amount (e.g. 1.25). */
private BigDecimal usdAmount;
private Integer starsAmount;
private Integer quantity;
private String status;

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.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminProjectStatisticsDto {
private long registeredUsers;
/** Same as registered for now; reserved for future definition. */
private long subscribedToBot;
private long blockedBot;
private BigDecimal totalDepositsUsd;
private BigDecimal totalWithdrawalsUsd;
private long usersWithDeposit;
/** 0100, two decimals; 0 if no registered users. */
private BigDecimal depositUserPercent;
}

View File

@@ -0,0 +1,36 @@
package com.honey.honey.dto;
import lombok.Builder;
import lombok.Value;
import java.math.BigDecimal;
@Value
@Builder
public class AdminStatisticsTableRowDto {
/** ISO date yyyy-MM-dd (day mode) or yyyy-MM (month mode). */
String periodKey;
/** Human-readable label for the period. */
String periodLabel;
long regs;
long regsOurs;
long regsNonOurs;
/** Placeholder for future use; null = not implemented. */
Long deadOurs;
Long deadNonOurs;
long depositsCount;
BigDecimal depositsSumUsd;
long ftdCount;
/** Average USD per payment where usd_amount is present; null if none. */
BigDecimal avgPaymentUsd;
long withdrawalsCount;
BigDecimal withdrawalsSumUsd;
/** (1 - withdrawals/deposits) * 100; null if deposits sum is zero. */
BigDecimal profitPercent;
BigDecimal profitUsd;
}

View File

@@ -27,9 +27,12 @@ public class AdminUserDetailDto {
private Integer banned;
/** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */
private String ipAddress;
/** Number of users sharing the same IP (including this user). */
private Long ipAddressUserCount;
// Balance Info
private Long balanceA;
private Long balanceB;
private Long depositTotal;
private Integer depositCount;
private Long withdrawTotal;
@@ -44,8 +47,10 @@ public class AdminUserDetailDto {
// Referral Info
private Integer referralCount;
private Long totalCommissionsEarned;
/** Total commissions earned in USD (converted from tickets). */
/** Total commissions earned in USD (Honey: bigint / 10_000_000_000). */
private java.math.BigDecimal totalCommissionsEarnedUsd;
/** Sum of COMPLETED payment USD across referral levels 13 for this user. */
private java.math.BigDecimal totalReferralDepositsUsd;
private Integer masterId;
private List<ReferralLevelDto> referralLevels;
}

View File

@@ -31,11 +31,21 @@ public class AdminUserDto {
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) */
/** USD from db_users_b deposit_total (Honey scale). */
private BigDecimal depositTotalUsd;
/** USD from db_users_b: withdrawTotal (tickets/1000) */
/** USD from db_users_b withdraw_total (Honey scale). */
private BigDecimal withdrawTotalUsd;
/** USD from db_users_b: profit (tickets/1000) */
/** USD from db_users_b: profit (Honey: bigint / 10_000_000_000). */
private BigDecimal profitUsd;
/** True when user has not blocked the bot. */
private Boolean botActive;
/** True when user has a session with created_at &lt;= now and expires_at &gt;= now. */
private Boolean online;
/** Direct referrer (db_users_d.referer_id_1), if any. */
private Integer referrerId;
/** Telegram name of direct referrer; "-" when absent. */
private String referrerTelegramName;
/** (1 - withdrawUsd/depositUsd) * 100 when depositUsd &gt; 0; otherwise null. */
private BigDecimal profitPercent;
}

View File

@@ -6,7 +6,6 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
@Data
@Builder
@@ -17,13 +16,12 @@ public class BalanceAdjustmentRequest {
private BalanceType balanceType; // A or B
@NotNull(message = "Amount is required")
private Long amount; // In bigint format (tickets * 1,000,000)
private Long amount; // In bigint format (display value * 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)
private String reason; // Optional reason for adjustment (for audit log)
public enum BalanceType {
A, B

View File

@@ -0,0 +1,9 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class ChatwootSessionRequest {
/** API key shared with Chatwoot (Dashboard App). Must match CHATWOOT_INTEGRATION_SECRET on server. */
private String apiKey;
}

View File

@@ -5,13 +5,14 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** API response DTO for referral list. Built from {@link ReferralProjection} in service. */
@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)
private String name;
private Double commission; // display value (raw from DB / 1_000_000)
}

View File

@@ -12,14 +12,16 @@ import java.math.BigDecimal;
@NoArgsConstructor
@AllArgsConstructor
public class ReferralLevelDto {
private Integer level; // 1-5
private Integer level; // 13 in admin UI; legacy rows may exist in DB
private Integer refererId;
private Integer referralCount;
private Long commissionsEarned;
private Long commissionsPaid;
/** Commissions earned in USD (converted from tickets: 1000 tickets = 1 USD). */
/** Commissions earned in USD (Honey: bigint / 10_000_000_000). */
private BigDecimal commissionsEarnedUsd;
/** Commissions paid in USD (converted from tickets). */
/** Commissions paid in USD (Honey: bigint / 10_000_000_000). */
private BigDecimal commissionsPaidUsd;
/** Sum of COMPLETED payment USD from referrals at this level. */
private BigDecimal depositsUsd;
}

View File

@@ -0,0 +1,10 @@
package com.honey.honey.dto;
/**
* Persistence/read model: raw referral row from DB (used only between repository and service).
* Not sent to frontend. Map to {@link ReferralDto} for API response.
*/
public interface ReferralProjection {
String getName();
Long getCommission();
}

View File

@@ -16,8 +16,8 @@ public class UserDto {
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 Long balanceB; // Second balance (stored as bigint)
private Double balanceA; // Balance for display (raw bigint / 1_000_000)
private Double balanceB; // Second balance for display (raw bigint / 1_000_000)
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

View File

@@ -55,6 +55,11 @@ public class Payment {
@Column(name = "completed_at")
private Instant completedAt;
/** True when this completed payment is the user's first deposit (crypto external completion). */
@Column(name = "ftd", nullable = false)
@Builder.Default
private Boolean ftd = false;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();

View File

@@ -64,6 +64,11 @@ public class UserA {
@Column(name = "last_telegram_file_id", length = 255)
private String lastTelegramFileId;
/** When false, user blocked the bot or send failed with 403; used for notification broadcast skip. */
@Column(name = "bot_active", nullable = false)
@Builder.Default
private boolean botActive = true;
}

View File

@@ -20,6 +20,11 @@ public class UserD {
@Builder.Default
private String screenName = "-";
/** Unix registration time (seconds); kept in sync with db_users_a.date_reg on signup. */
@Column(name = "date_reg", nullable = false)
@Builder.Default
private Integer dateReg = 0;
@Column(name = "referer_id_1", nullable = false)
@Builder.Default
private Integer refererId1 = 0;

View File

@@ -0,0 +1,311 @@
package com.honey.honey.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* Native aggregate queries for the admin statistics-by-day/month table.
* Registration metrics use {@code db_users_d.date_reg} only (no join to {@code db_users_a}).
* <p>
* Row order (newest vs oldest) is applied in the service when building pages, not via SQL {@code ORDER BY}.
* Range filters use columns that should stay indexed for performance:
* <ul>
* <li>{@code db_users_d.date_reg} — {@code idx_users_d_date_reg} (Flyway V80)</li>
* <li>{@code payments(status, completed_at)} — {@code idx_payments_status_completed_at} (V78)</li>
* <li>{@code payments(status, ftd, completed_at)} — {@code idx_payments_status_ftd_completed_at} (V77)</li>
* <li>{@code db_users_d(master_id, referer_id_1)} — {@code idx_users_d_master_referer1} (V79) for “ours” counts</li>
* </ul>
*/
@Repository
public class AdminStatisticsTableRepository {
private static final BigDecimal ZERO = new BigDecimal("0.00");
@PersistenceContext
private EntityManager entityManager;
/**
* Mutable per-period aggregates filled by this repository.
*/
public static final class StatisticsTableBucket {
public long regs;
public long regsOurs;
public long depositsCount;
public long ftdCount;
public BigDecimal depositsSumUsd = ZERO;
public BigDecimal avgPaymentUsd;
public long withdrawalsCount;
public BigDecimal withdrawalsSumUsd = ZERO;
}
public void loadRegistrationBucketsByDay(long startSec, long endSec, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(FROM_UNIXTIME(d.date_reg)) AS b, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()).regs = toLong(row[1]);
}
}
public void loadRegistrationOursBucketsByDay(long startSec, long endSec, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(FROM_UNIXTIME(d.date_reg)) AS b, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
AND d.referer_id_1 = d.master_id
AND d.id <> d.master_id
AND d.master_id > 0
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()).regsOurs = toLong(row[1]);
}
}
public void loadPaymentBucketsByDay(Instant start, Instant end, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(p.completed_at) AS b,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd,
AVG(p.usd_amount) AS avg_usd,
SUM(CASE WHEN p.ftd = 1 THEN 1 ELSE 0 END) AS ftd
FROM payments p
WHERE p.status = 'COMPLETED'
AND p.completed_at IS NOT NULL
AND p.completed_at >= :startTs AND p.completed_at < :endTs
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(d, x -> new StatisticsTableBucket());
b.depositsCount = toLong(row[1]);
b.depositsSumUsd = readBd(row[2]);
b.avgPaymentUsd = readBdNullable(row[3]);
b.ftdCount = toLong(row[4]);
}
}
public void loadPayoutBucketsByDay(Instant start, Instant end, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(COALESCE(p.resolved_at, p.updated_at, p.created_at)) AS b,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd
FROM payouts p
WHERE p.status = 'COMPLETED'
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) >= :startTs
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) < :endTs
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(d, x -> new StatisticsTableBucket());
b.withdrawalsCount = toLong(row[1]);
b.withdrawalsSumUsd = readBd(row[2]);
}
}
public void loadRegistrationBucketsByMonth(long startSec, long endSec, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(FROM_UNIXTIME(d.date_reg), '%Y-%m') AS ym, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()).regs = toLong(row[1]);
}
}
public void loadRegistrationOursBucketsByMonth(long startSec, long endSec, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(FROM_UNIXTIME(d.date_reg), '%Y-%m') AS ym, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
AND d.referer_id_1 = d.master_id
AND d.id <> d.master_id
AND d.master_id > 0
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()).regsOurs = toLong(row[1]);
}
}
public void loadPaymentBucketsByMonth(Instant start, Instant end, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(p.completed_at, '%Y-%m') AS ym,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd,
AVG(p.usd_amount) AS avg_usd,
SUM(CASE WHEN p.ftd = 1 THEN 1 ELSE 0 END) AS ftd
FROM payments p
WHERE p.status = 'COMPLETED'
AND p.completed_at IS NOT NULL
AND p.completed_at >= :startTs AND p.completed_at < :endTs
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket());
b.depositsCount = toLong(row[1]);
b.depositsSumUsd = readBd(row[2]);
b.avgPaymentUsd = readBdNullable(row[3]);
b.ftdCount = toLong(row[4]);
}
}
public void loadPayoutBucketsByMonth(Instant start, Instant end, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(COALESCE(p.resolved_at, p.updated_at, p.created_at), '%Y-%m') AS ym,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd
FROM payouts p
WHERE p.status = 'COMPLETED'
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) >= :startTs
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) < :endTs
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket());
b.withdrawalsCount = toLong(row[1]);
b.withdrawalsSumUsd = readBd(row[2]);
}
}
private static LocalDate toLocalDate(Object o) {
if (o == null) {
return null;
}
if (o instanceof LocalDate ld) {
return ld;
}
if (o instanceof Date sd) {
return sd.toLocalDate();
}
if (o instanceof Timestamp ts) {
return ts.toLocalDateTime().toLocalDate();
}
if (o instanceof java.util.Date ud) {
return new Timestamp(ud.getTime()).toLocalDateTime().toLocalDate();
}
return null;
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
if (o instanceof Number n) {
return n.longValue();
}
return 0L;
}
private static BigDecimal readBd(Object o) {
if (o == null) {
return ZERO;
}
if (o instanceof BigDecimal bd) {
return bd.setScale(2, RoundingMode.HALF_UP);
}
if (o instanceof Number n) {
return BigDecimal.valueOf(n.doubleValue()).setScale(2, RoundingMode.HALF_UP);
}
return ZERO;
}
private static BigDecimal readBdNullable(Object o) {
if (o == null) {
return null;
}
if (o instanceof BigDecimal bd) {
return bd.setScale(2, RoundingMode.HALF_UP);
}
if (o instanceof Number n) {
return BigDecimal.valueOf(n.doubleValue()).setScale(2, RoundingMode.HALF_UP);
}
return null;
}
}

View File

@@ -87,5 +87,27 @@ public interface PaymentRepository extends JpaRepository<Payment, Long>, JpaSpec
@Param("start") Instant start,
@Param("end") Instant end
);
/** Sum usd_amount for all completed payments (null usd rows are ignored by SUM). */
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p WHERE p.status = :status")
Optional<BigDecimal> sumUsdAmountByStatus(@Param("status") Payment.PaymentStatus status);
/** Count completed FTD crypto (usd) deposits completed in [start, end). */
@Query("SELECT COUNT(p) FROM Payment p WHERE p.status = :status AND p.ftd = true AND p.usdAmount IS NOT NULL AND p.completedAt >= :start AND p.completedAt < :end")
long countByStatusAndFtdTrueAndUsdNotNullAndCompletedAtBetween(
@Param("status") Payment.PaymentStatus status,
@Param("start") Instant start,
@Param("end") Instant end
);
/** Sum COMPLETED payment USD for users whose level-1 referrer is {@code userId}. */
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p, UserD d WHERE p.userId = d.id AND p.status = 'COMPLETED' AND p.usdAmount IS NOT NULL AND d.refererId1 = :userId")
java.math.BigDecimal sumCompletedUsdForReferralsLevel1(@Param("userId") Integer userId);
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p, UserD d WHERE p.userId = d.id AND p.status = 'COMPLETED' AND p.usdAmount IS NOT NULL AND d.refererId2 = :userId")
java.math.BigDecimal sumCompletedUsdForReferralsLevel2(@Param("userId") Integer userId);
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p, UserD d WHERE p.userId = d.id AND p.status = 'COMPLETED' AND p.usdAmount IS NOT NULL AND d.refererId3 = :userId")
java.math.BigDecimal sumCompletedUsdForReferralsLevel3(@Param("userId") Integer userId);
}

View File

@@ -107,5 +107,9 @@ public interface PayoutRepository extends JpaRepository<Payout, Long>, JpaSpecif
@Param("start") Instant start,
@Param("end") Instant end
);
/** Sum usd_amount for all payouts with given status (null usd ignored by SUM). */
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payout p WHERE p.status = :status")
Optional<BigDecimal> sumUsdAmountByStatus(@Param("status") Payout.PayoutStatus status);
}

View File

@@ -9,8 +9,10 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Repository
public interface SessionRepository extends JpaRepository<Session, Long> {
@@ -46,6 +48,13 @@ public interface SessionRepository extends JpaRepository<Session, Long> {
* Returns the number of deleted rows.
* Note: MySQL requires LIMIT to be a literal or bound parameter, so we use a native query.
*/
/**
* User IDs in {@code userIds} that have at least one session valid at {@code now}
* ({@code created_at <= now} and {@code expires_at >= now}).
*/
@Query("SELECT DISTINCT s.userId FROM Session s WHERE s.userId IN :userIds AND s.createdAt <= :now AND s.expiresAt >= :now")
Set<Integer> findOnlineUserIdsAmong(@Param("userIds") Collection<Integer> userIds, @Param("now") LocalDateTime now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = "DELETE FROM sessions WHERE expires_at < :now LIMIT :batchSize", nativeQuery = true)
int deleteExpiredSessionsBatch(@Param("now") LocalDateTime now, @Param("batchSize") int batchSize);

View File

@@ -24,6 +24,7 @@ public interface UserARepository extends JpaRepository<UserA, Integer>, JpaSpeci
*/
@Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u")
int getMaxId();
Optional<UserA> findByTelegramId(Long telegramId);
/**
@@ -49,6 +50,20 @@ public interface UserARepository extends JpaRepository<UserA, Integer>, JpaSpeci
*/
@Query("SELECT COUNT(u) FROM UserA u WHERE u.dateLogin >= :start AND u.dateLogin < :end")
long countByDateLoginBetween(@Param("start") Integer start, @Param("end") Integer end);
long countByBotActiveIsFalse();
long countByBotActiveIsTrue();
/**
* Paged users in id range for broadcast when skipping inactive-bot users.
*/
@Query("SELECT u FROM UserA u WHERE u.id >= :fromId AND u.id <= :toId AND u.botActive = true ORDER BY u.id")
Page<UserA> findByIdBetweenAndBotActiveTrue(@Param("fromId") int fromId, @Param("toId") int toId, Pageable pageable);
/** Count users sharing the same IP (varbinary); returns 0 if {@code ip} is null. */
@Query("SELECT COUNT(u) FROM UserA u WHERE u.ip IS NOT NULL AND u.ip = :ip")
long countByIpEqual(@Param("ip") byte[] ip);
}

View File

@@ -20,6 +20,9 @@ public interface UserBRepository extends JpaRepository<UserB, Integer> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM UserB b WHERE b.id = :id")
Optional<UserB> findByIdForUpdate(@Param("id") Integer id);
@Query("SELECT COUNT(b) FROM UserB b WHERE b.depositCount > 0")
long countUsersWithDeposit();
}

View File

@@ -1,6 +1,6 @@
package com.honey.honey.repository;
import com.honey.honey.dto.ReferralDto;
import com.honey.honey.dto.ReferralProjection;
import com.honey.honey.model.UserD;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -11,10 +11,17 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserDRepository extends JpaRepository<UserD, Integer> {
/**
* Earliest {@code date_reg} on {@code db_users_d} (registration time), ignoring unset rows.
*/
@Query("SELECT MIN(d.dateReg) FROM UserD d WHERE d.dateReg > 0")
Optional<Integer> findMinDateReg();
/**
* Increments referals_1 for a user.
*/
@@ -52,36 +59,33 @@ public interface UserDRepository extends JpaRepository<UserD, Integer> {
/**
* Finds referrals for level 1 (where referer_id_1 = userId).
* Returns referrals with their screen_name and to_referer_1 commission.
* Returns raw rows (name, commission bigint). Map to ReferralDto in service.
*/
@Query("SELECT new com.honey.honey.dto.ReferralDto(" +
"ud.screenName, ud.toReferer1) " +
@Query("SELECT ud.screenName as name, ud.toReferer1 as commission " +
"FROM UserD ud " +
"WHERE ud.refererId1 = :userId AND ud.refererId1 > 0 " +
"ORDER BY ud.toReferer1 DESC")
Page<ReferralDto> findReferralsLevel1(@Param("userId") Integer userId, Pageable pageable);
"ORDER BY ud.toReferer1 DESC, ud.id DESC")
Page<ReferralProjection> findReferralsLevel1(@Param("userId") Integer userId, Pageable pageable);
/**
* Finds referrals for level 2 (where referer_id_2 = userId).
* Returns referrals with their screen_name and to_referer_2 commission.
* Returns raw rows (name, commission bigint). Map to ReferralDto in service.
*/
@Query("SELECT new com.honey.honey.dto.ReferralDto(" +
"ud.screenName, ud.toReferer2) " +
@Query("SELECT ud.screenName as name, ud.toReferer2 as commission " +
"FROM UserD ud " +
"WHERE ud.refererId2 = :userId AND ud.refererId2 > 0 " +
"ORDER BY ud.toReferer2 DESC")
Page<ReferralDto> findReferralsLevel2(@Param("userId") Integer userId, Pageable pageable);
"ORDER BY ud.toReferer2 DESC, ud.id DESC")
Page<ReferralProjection> findReferralsLevel2(@Param("userId") Integer userId, Pageable pageable);
/**
* Finds referrals for level 3 (where referer_id_3 = userId).
* Returns referrals with their screen_name and to_referer_3 commission.
* Returns raw rows (name, commission bigint). Map to ReferralDto in service.
*/
@Query("SELECT new com.honey.honey.dto.ReferralDto(" +
"ud.screenName, ud.toReferer3) " +
@Query("SELECT ud.screenName as name, ud.toReferer3 as commission " +
"FROM UserD ud " +
"WHERE ud.refererId3 = :userId AND ud.refererId3 > 0 " +
"ORDER BY ud.toReferer3 DESC")
Page<ReferralDto> findReferralsLevel3(@Param("userId") Integer userId, Pageable pageable);
"ORDER BY ud.toReferer3 DESC, ud.id DESC")
Page<ReferralProjection> findReferralsLevel3(@Param("userId") Integer userId, Pageable pageable);
/**
* Masters: users whose id equals their master_id (and master_id > 0).
@@ -91,7 +95,7 @@ public interface UserDRepository extends JpaRepository<UserD, Integer> {
List<UserD> findAllMasters();
/**
* IDs of users who are Masters (id = master_id and master_id > 0). Used to exclude them from GAME_ADMIN views.
* IDs of users who are Masters (id = master_id and master_id > 0).
*/
@Query("SELECT d.id FROM UserD d WHERE d.id = d.masterId AND d.masterId > 0")
List<Integer> findMasterUserIds();

View File

@@ -43,11 +43,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
if (jwtUtil.validateToken(jwt, username)) {
// Get admin from database to retrieve actual role
String role = adminRepository.findByUsername(username)
// If token has explicit role (e.g. Chatwoot integration), use it; otherwise load from DB
String role = jwtUtil.getRoleFromToken(jwt);
if (role == null || role.isBlank()) {
role = adminRepository.findByUsername(username)
.map(Admin::getRole)
.orElse("ROLE_ADMIN"); // Fallback to ROLE_ADMIN if not found
.orElse("ROLE_ADMIN");
}
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
username,
null,

View File

@@ -33,6 +33,27 @@ public class JwtUtil {
return createToken(claims, username);
}
/**
* Generates a token with an explicit role (e.g. for Chatwoot integration).
* Used when the subject is not a DB admin; the filter will use this role from the token.
*/
public String generateTokenWithRole(String subject, String role) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", role);
return createToken(claims, subject);
}
/**
* Returns the role from token claims, or null if not present.
*/
public String getRoleFromToken(String token) {
try {
return getClaimFromToken(token, claims -> claims.get("role", String.class));
} catch (Exception e) {
return null;
}
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.claims(claims)

View File

@@ -17,7 +17,8 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class AdminMasterService {
private static final BigDecimal USD_DIVISOR = new BigDecimal("1000000000");
/** Honey admin: balance bigint → USD (same scale as AdminUserService). */
private static final BigDecimal USD_DIVISOR = new BigDecimal("10000000000");
private final UserDRepository userDRepository;

View File

@@ -0,0 +1,219 @@
package com.honey.honey.service;
import com.honey.honey.dto.AdminStatisticsTableRowDto;
import com.honey.honey.repository.AdminStatisticsTableRepository;
import com.honey.honey.repository.AdminStatisticsTableRepository.StatisticsTableBucket;
import com.honey.honey.repository.UserDRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AdminStatisticsTableService {
private static final BigDecimal ZERO = new BigDecimal("0.00");
private static final DateTimeFormatter MONTH_LABEL = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH);
private final AdminStatisticsTableRepository adminStatisticsTableRepository;
private final UserDRepository userDRepository;
public enum TableMode {
DAY,
MONTH
}
/**
* @param sortAscending when {@code false} (default), newest periods first; when {@code true}, oldest first.
*/
public Page<AdminStatisticsTableRowDto> getStatisticsTable(TableMode mode, int page, int size, boolean sortAscending) {
LocalDate todayUtc = LocalDate.now(ZoneOffset.UTC);
LocalDate firstDay = resolveFirstProjectDay(todayUtc);
if (mode == TableMode.DAY) {
return buildDayPage(todayUtc, firstDay, page, size, sortAscending);
}
return buildMonthPage(todayUtc, firstDay, page, size, sortAscending);
}
private LocalDate resolveFirstProjectDay(LocalDate todayUtc) {
Optional<Integer> minReg = userDRepository.findMinDateReg();
if (minReg.isEmpty() || minReg.get() == null || minReg.get() <= 0) {
return todayUtc;
}
LocalDate d = Instant.ofEpochSecond(minReg.get()).atZone(ZoneOffset.UTC).toLocalDate();
if (d.isAfter(todayUtc)) {
return todayUtc;
}
return d;
}
private Page<AdminStatisticsTableRowDto> buildDayPage(
LocalDate todayUtc, LocalDate firstDay, int page, int size, boolean sortAscending) {
long totalPeriods = ChronoUnit.DAYS.between(firstDay, todayUtc) + 1;
if (totalPeriods < 1) {
totalPeriods = 1;
}
int offset = page * size;
if (offset >= totalPeriods) {
return new PageImpl<>(List.of(), PageRequest.of(page, size), totalPeriods);
}
int pageLen = (int) Math.min(size, totalPeriods - offset);
LocalDate oldestOnPage;
LocalDate newestOnPage;
if (sortAscending) {
oldestOnPage = firstDay.plusDays(offset);
newestOnPage = firstDay.plusDays(offset + pageLen - 1L);
} else {
newestOnPage = todayUtc.minusDays(offset);
oldestOnPage = todayUtc.minusDays(offset + pageLen - 1L);
}
long startSec = oldestOnPage.atStartOfDay(ZoneOffset.UTC).toEpochSecond();
long endSec = newestOnPage.plusDays(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond();
Instant instStart = Instant.ofEpochSecond(startSec);
Instant instEnd = Instant.ofEpochSecond(endSec);
Map<LocalDate, StatisticsTableBucket> buckets = new HashMap<>();
adminStatisticsTableRepository.loadRegistrationBucketsByDay(startSec, endSec, buckets);
adminStatisticsTableRepository.loadRegistrationOursBucketsByDay(startSec, endSec, buckets);
adminStatisticsTableRepository.loadPaymentBucketsByDay(instStart, instEnd, buckets);
adminStatisticsTableRepository.loadPayoutBucketsByDay(instStart, instEnd, buckets);
List<AdminStatisticsTableRowDto> rows = new ArrayList<>(pageLen);
for (int i = 0; i < pageLen; i++) {
LocalDate day = sortAscending
? firstDay.plusDays(offset + (long) i)
: todayUtc.minusDays(offset + (long) i);
rows.add(toRowDay(day, buckets.getOrDefault(day, new StatisticsTableBucket())));
}
return new PageImpl<>(rows, PageRequest.of(page, size), totalPeriods);
}
private Page<AdminStatisticsTableRowDto> buildMonthPage(
LocalDate todayUtc, LocalDate firstDay, int page, int size, boolean sortAscending) {
YearMonth nowYm = YearMonth.from(todayUtc);
YearMonth firstYm = YearMonth.from(firstDay);
long totalMonths = (nowYm.getYear() - firstYm.getYear()) * 12L
+ (nowYm.getMonthValue() - firstYm.getMonthValue()) + 1;
if (totalMonths < 1) {
totalMonths = 1;
}
int offset = page * size;
if (offset >= totalMonths) {
return new PageImpl<>(List.of(), PageRequest.of(page, size), totalMonths);
}
int pageLen = (int) Math.min(size, totalMonths - offset);
YearMonth oldestOnPage;
YearMonth newestOnPage;
if (sortAscending) {
oldestOnPage = firstYm.plusMonths(offset);
newestOnPage = firstYm.plusMonths(offset + pageLen - 1L);
} else {
newestOnPage = nowYm.minusMonths(offset);
oldestOnPage = nowYm.minusMonths(offset + pageLen - 1L);
}
long startSec = oldestOnPage.atDay(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond();
long endSec = newestOnPage.plusMonths(1).atDay(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond();
Instant instStart = Instant.ofEpochSecond(startSec);
Instant instEnd = Instant.ofEpochSecond(endSec);
Map<String, StatisticsTableBucket> buckets = new HashMap<>();
adminStatisticsTableRepository.loadRegistrationBucketsByMonth(startSec, endSec, buckets);
adminStatisticsTableRepository.loadRegistrationOursBucketsByMonth(startSec, endSec, buckets);
adminStatisticsTableRepository.loadPaymentBucketsByMonth(instStart, instEnd, buckets);
adminStatisticsTableRepository.loadPayoutBucketsByMonth(instStart, instEnd, buckets);
List<AdminStatisticsTableRowDto> rows = new ArrayList<>(pageLen);
for (int i = 0; i < pageLen; i++) {
YearMonth ym = sortAscending
? firstYm.plusMonths(offset + (long) i)
: nowYm.minusMonths(offset + (long) i);
rows.add(toRowMonth(ym, buckets.getOrDefault(ym.toString(), new StatisticsTableBucket())));
}
return new PageImpl<>(rows, PageRequest.of(page, size), totalMonths);
}
private AdminStatisticsTableRowDto toRowDay(LocalDate day, StatisticsTableBucket b) {
long nonOurs = Math.max(0, b.regs - b.regsOurs);
BigDecimal profitUsd = b.depositsSumUsd.subtract(b.withdrawalsSumUsd).setScale(2, RoundingMode.HALF_UP);
BigDecimal profitPct = null;
if (b.depositsSumUsd.compareTo(ZERO) > 0) {
BigDecimal ratio = b.withdrawalsSumUsd.divide(b.depositsSumUsd, 8, RoundingMode.HALF_UP);
profitPct = BigDecimal.ONE.subtract(ratio).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP);
}
return AdminStatisticsTableRowDto.builder()
.periodKey(day.toString())
.periodLabel(day.toString())
.regs(b.regs)
.regsOurs(b.regsOurs)
.regsNonOurs(nonOurs)
.deadOurs(null)
.deadNonOurs(null)
.depositsCount(b.depositsCount)
.depositsSumUsd(b.depositsSumUsd)
.ftdCount(b.ftdCount)
.avgPaymentUsd(b.avgPaymentUsd)
.withdrawalsCount(b.withdrawalsCount)
.withdrawalsSumUsd(b.withdrawalsSumUsd)
.profitPercent(profitPct)
.profitUsd(profitUsd)
.build();
}
private AdminStatisticsTableRowDto toRowMonth(YearMonth ym, StatisticsTableBucket b) {
long nonOurs = Math.max(0, b.regs - b.regsOurs);
BigDecimal profitUsd = b.depositsSumUsd.subtract(b.withdrawalsSumUsd).setScale(2, RoundingMode.HALF_UP);
BigDecimal profitPct = null;
if (b.depositsSumUsd.compareTo(ZERO) > 0) {
BigDecimal ratio = b.withdrawalsSumUsd.divide(b.depositsSumUsd, 8, RoundingMode.HALF_UP);
profitPct = BigDecimal.ONE.subtract(ratio).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP);
}
return AdminStatisticsTableRowDto.builder()
.periodKey(ym.toString())
.periodLabel(ym.format(MONTH_LABEL))
.regs(b.regs)
.regsOurs(b.regsOurs)
.regsNonOurs(nonOurs)
.deadOurs(null)
.deadNonOurs(null)
.depositsCount(b.depositsCount)
.depositsSumUsd(b.depositsSumUsd)
.ftdCount(b.ftdCount)
.avgPaymentUsd(b.avgPaymentUsd)
.withdrawalsCount(b.withdrawalsCount)
.withdrawalsSumUsd(b.withdrawalsSumUsd)
.profitPercent(profitPct)
.profitUsd(profitUsd)
.build();
}
}

View File

@@ -20,6 +20,7 @@ import jakarta.persistence.criteria.Subquery;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
@@ -36,7 +37,10 @@ public class AdminUserService {
private static final long TICKETS_MULTIPLIER = 1_000_000L;
private static final BigDecimal TICKETS_TO_USD = new BigDecimal("0.001"); // 1000 tickets = 1 USD
/**
* Honey: display balance = DB bigint / 1_000_000; 1 USD = 10_000 display units → USD = DB / 10_000_000_000.
*/
private static final BigDecimal HONEY_DB_UNITS_PER_USD = new BigDecimal("10000000000");
private final UserARepository userARepository;
private final UserBRepository userBRepository;
@@ -46,6 +50,7 @@ public class AdminUserService {
private final PayoutRepository payoutRepository;
private final UserTaskClaimRepository userTaskClaimRepository;
private final TaskRepository taskRepository;
private final SessionRepository sessionRepository;
private final EntityManager entityManager;
public Page<AdminUserDto> getUsers(
@@ -56,16 +61,21 @@ public class AdminUserService {
String languageCode,
Integer dateRegFrom,
Integer dateRegTo,
Long balanceMin,
Long balanceMax,
Long balanceAMin,
Long balanceAMax,
Long balanceBMin,
Long balanceBMax,
Integer referralCountMin,
Integer referralCountMax,
Integer referrerId,
Integer referralLevel,
String ipFilter,
Boolean botActive,
Integer depositCountMin,
String sortBy,
String sortDir,
boolean excludeMasters) {
boolean excludeMasters,
Boolean hideSubAndBanned) {
List<Integer> masterIds = excludeMasters ? userDRepository.findMasterUserIds() : List.of();
@@ -137,20 +147,16 @@ public class AdminUserService {
predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp));
}
// Balance / referral filters via subqueries so DB handles pagination
if (balanceMin != null || balanceMax != null) {
// Balance A / Balance B filters via subqueries
if (balanceAMin != null || balanceAMax != null || balanceBMin != null || balanceBMax != null) {
Subquery<Integer> subB = query.subquery(Integer.class);
Root<UserB> br = subB.from(UserB.class);
subB.select(br.get("id"));
List<Predicate> subPreds = new ArrayList<>();
if (balanceMin != null) {
subPreds.add(cb.greaterThanOrEqualTo(
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMin));
}
if (balanceMax != null) {
subPreds.add(cb.lessThanOrEqualTo(
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMax));
}
if (balanceAMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("balanceA"), balanceAMin));
if (balanceAMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("balanceA"), balanceAMax));
if (balanceBMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("balanceB"), balanceBMin));
if (balanceBMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("balanceB"), balanceBMax));
subB.where(cb.and(subPreds.toArray(new Predicate[0])));
predicates.add(cb.in(root.get("id")).value(subB));
}
@@ -183,10 +189,35 @@ public class AdminUserService {
predicates.add(cb.in(root.get("id")).value(subRef));
}
if (botActive != null) {
predicates.add(cb.equal(root.get("botActive"), botActive));
}
if (depositCountMin != null && depositCountMin > 0) {
Subquery<Integer> subDep = query.subquery(Integer.class);
Root<UserB> depRoot = subDep.from(UserB.class);
subDep.select(depRoot.get("id"));
subDep.where(cb.greaterThanOrEqualTo(depRoot.get("depositCount"), depositCountMin));
predicates.add(cb.in(root.get("id")).value(subDep));
}
if (Boolean.TRUE.equals(hideSubAndBanned)) {
predicates.add(cb.equal(root.get("banned"), 0));
Subquery<Integer> subMasterSelf = query.subquery(Integer.class);
Root<UserD> dm = subMasterSelf.from(UserD.class);
subMasterSelf.select(dm.get("id"));
subMasterSelf.where(cb.and(
cb.equal(dm.get("id"), dm.get("masterId")),
cb.gt(dm.get("masterId"), 0)));
predicates.add(cb.not(root.get("id").in(subMasterSelf)));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
Set<String> sortRequiresJoin = Set.of(
"balanceA", "balanceB", "depositTotal", "withdrawTotal", "referralCount", "profit",
"referrerTelegramName", "profitPercent");
boolean useJoinSort = sortBy != null && sortRequiresJoin.contains(sortBy);
List<UserA> userList;
long totalElements;
@@ -194,9 +225,11 @@ public class AdminUserService {
if (useJoinSort) {
List<Integer> orderedIds = getOrderedUserIdsForAdminList(
search, banned, countryCode, languageCode,
dateRegFrom, dateRegTo, balanceMin, balanceMax,
dateRegFrom, dateRegTo, balanceAMin, balanceAMax, balanceBMin, balanceBMax,
referralCountMin, referralCountMax,
referrerId, referralLevel, ipFilter,
botActive, depositCountMin,
hideSubAndBanned,
sortBy, sortDir != null ? sortDir : "desc",
pageable.getPageSize(), (int) pageable.getOffset(),
masterIds);
@@ -223,6 +256,20 @@ public class AdminUserService {
Map<Integer, UserD> userDMap = userDRepository.findAllById(userIds).stream()
.collect(Collectors.toMap(UserD::getId, ud -> ud));
LocalDateTime sessionNow = LocalDateTime.now();
Set<Integer> onlineIds = userIds.isEmpty()
? Set.of()
: sessionRepository.findOnlineUserIdsAmong(userIds, sessionNow);
Set<Integer> refererIds = userDMap.values().stream()
.map(UserD::getRefererId1)
.filter(id -> id != null && id > 0)
.collect(Collectors.toSet());
Map<Integer, UserA> referrersById = refererIds.isEmpty()
? Map.of()
: userARepository.findAllById(refererIds).stream()
.collect(Collectors.toMap(UserA::getId, u -> u));
// Map to DTOs (filtering is done in DB via specification subqueries)
List<AdminUserDto> pageContent = userList.stream()
.map(userA -> {
@@ -260,6 +307,21 @@ public class AdminUserService {
BigDecimal depositTotalUsd = ticketsToUsd(userB.getDepositTotal());
BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal());
BigDecimal profitUsd = ticketsToUsd(profit);
BigDecimal profitPercent = computeProfitPercent(depositTotalUsd, withdrawTotalUsd);
Integer referrerIdVal = null;
String referrerTelegramNameVal = null;
int rid = userD.getRefererId1();
if (rid > 0) {
referrerIdVal = rid;
UserA refA = referrersById.get(rid);
String tn = refA != null ? refA.getTelegramName() : null;
if (tn == null || tn.isBlank() || "-".equals(tn)) {
referrerTelegramNameVal = "-";
} else {
referrerTelegramNameVal = tn;
}
}
return AdminUserDto.builder()
.id(userA.getId())
@@ -283,6 +345,11 @@ public class AdminUserService {
.depositTotalUsd(depositTotalUsd)
.withdrawTotalUsd(withdrawTotalUsd)
.profitUsd(profitUsd)
.profitPercent(profitPercent)
.botActive(userA.isBotActive())
.online(onlineIds.contains(userA.getId()))
.referrerId(referrerIdVal)
.referrerTelegramName(referrerTelegramNameVal)
.build();
})
.collect(Collectors.toList());
@@ -301,13 +368,18 @@ public class AdminUserService {
String languageCode,
Integer dateRegFrom,
Integer dateRegTo,
Long balanceMin,
Long balanceMax,
Long balanceAMin,
Long balanceAMax,
Long balanceBMin,
Long balanceBMax,
Integer referralCountMin,
Integer referralCountMax,
Integer referrerId,
Integer referralLevel,
String ipFilter,
Boolean botActive,
Integer depositCountMin,
Boolean hideSubAndBanned,
String sortBy,
String sortDir,
int limit,
@@ -316,7 +388,8 @@ public class AdminUserService {
StringBuilder sql = new StringBuilder(
"SELECT a.id FROM db_users_a a " +
"INNER JOIN db_users_b b ON a.id = b.id " +
"INNER JOIN db_users_d d ON a.id = d.id WHERE 1=1");
"INNER JOIN db_users_d d ON a.id = d.id " +
"LEFT JOIN db_users_a ref ON d.referer_id_1 = ref.id WHERE 1=1");
List<Object> params = new ArrayList<>();
int paramIndex = 1;
@@ -374,14 +447,24 @@ public class AdminUserService {
params.add(dateRegTo);
paramIndex++;
}
if (balanceMin != null) {
sql.append(" AND (b.balance_a + b.balance_b) >= ?");
params.add(balanceMin);
if (balanceAMin != null) {
sql.append(" AND b.balance_a >= ?");
params.add(balanceAMin);
paramIndex++;
}
if (balanceMax != null) {
sql.append(" AND (b.balance_a + b.balance_b) <= ?");
params.add(balanceMax);
if (balanceAMax != null) {
sql.append(" AND b.balance_a <= ?");
params.add(balanceAMax);
paramIndex++;
}
if (balanceBMin != null) {
sql.append(" AND b.balance_b >= ?");
params.add(balanceBMin);
paramIndex++;
}
if (balanceBMax != null) {
sql.append(" AND b.balance_b <= ?");
params.add(balanceBMax);
paramIndex++;
}
if (referralCountMin != null || referralCountMax != null) {
@@ -414,13 +497,31 @@ public class AdminUserService {
paramIndex++;
}
}
if (botActive != null) {
sql.append(" AND a.bot_active = ?");
params.add(Boolean.TRUE.equals(botActive) ? 1 : 0);
paramIndex++;
}
if (depositCountMin != null && depositCountMin > 0) {
sql.append(" AND b.deposit_count >= ?");
params.add(depositCountMin);
paramIndex++;
}
if (Boolean.TRUE.equals(hideSubAndBanned)) {
sql.append(" AND a.banned = 0");
sql.append(" AND NOT (d.id = d.master_id AND d.master_id > 0)");
}
String orderColumn = switch (sortBy != null ? sortBy : "") {
case "balanceA" -> "b.balance_a";
case "balanceB" -> "b.balance_b";
case "depositTotal" -> "b.deposit_total";
case "withdrawTotal" -> "b.withdraw_total";
case "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)";
case "profit" -> "(b.deposit_total - b.withdraw_total)";
case "referrerTelegramName" -> "ref.telegram_name";
// Same as (1 - withdraw/deposit)*100 when deposit > 0 (Honey linear USD scale)
case "profitPercent" -> "(CASE WHEN b.deposit_total > 0 THEN 100.0 * (b.deposit_total - b.withdraw_total) / b.deposit_total ELSE NULL END)";
default -> "a.id";
};
String direction = "asc".equalsIgnoreCase(sortDir) ? " ASC" : " DESC";
@@ -513,41 +614,47 @@ public class AdminUserService {
long totalCommissions = userD.getFromReferals1() + userD.getFromReferals2() +
userD.getFromReferals3() + userD.getFromReferals4() + userD.getFromReferals5();
// Build referral levels
BigDecimal refDep1 = paymentRepository.sumCompletedUsdForReferralsLevel1(userId);
BigDecimal refDep2 = paymentRepository.sumCompletedUsdForReferralsLevel2(userId);
BigDecimal refDep3 = paymentRepository.sumCompletedUsdForReferralsLevel3(userId);
if (refDep1 == null) refDep1 = BigDecimal.ZERO;
if (refDep2 == null) refDep2 = BigDecimal.ZERO;
if (refDep3 == null) refDep3 = BigDecimal.ZERO;
BigDecimal totalReferralDepositsUsd = refDep1.add(refDep2).add(refDep3);
// Build referral levels (admin shows 13 only)
List<ReferralLevelDto> referralLevels = new ArrayList<>();
for (int level = 1; level <= 5; level++) {
for (int level = 1; level <= 3; level++) {
int refererId = switch (level) {
case 1 -> userD.getRefererId1();
case 2 -> userD.getRefererId2();
case 3 -> userD.getRefererId3();
case 4 -> userD.getRefererId4();
case 5 -> userD.getRefererId5();
default -> 0;
};
int referralCount = switch (level) {
case 1 -> userD.getReferals1();
case 2 -> userD.getReferals2();
case 3 -> userD.getReferals3();
case 4 -> userD.getReferals4();
case 5 -> userD.getReferals5();
default -> 0;
};
long commissionsEarned = switch (level) {
case 1 -> userD.getFromReferals1();
case 2 -> userD.getFromReferals2();
case 3 -> userD.getFromReferals3();
case 4 -> userD.getFromReferals4();
case 5 -> userD.getFromReferals5();
default -> 0L;
};
long commissionsPaid = switch (level) {
case 1 -> userD.getToReferer1();
case 2 -> userD.getToReferer2();
case 3 -> userD.getToReferer3();
case 4 -> userD.getToReferer4();
case 5 -> userD.getToReferer5();
default -> 0L;
};
BigDecimal depositsUsd = switch (level) {
case 1 -> refDep1;
case 2 -> refDep2;
case 3 -> refDep3;
default -> BigDecimal.ZERO;
};
referralLevels.add(ReferralLevelDto.builder()
.level(level)
@@ -557,6 +664,7 @@ public class AdminUserService {
.commissionsPaid(commissionsPaid)
.commissionsEarnedUsd(ticketsToUsd(commissionsEarned))
.commissionsPaidUsd(ticketsToUsd(commissionsPaid))
.depositsUsd(depositsUsd)
.build());
}
@@ -564,6 +672,11 @@ public class AdminUserService {
BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal());
BigDecimal totalCommissionsEarnedUsd = ticketsToUsd(totalCommissions);
long ipAddressUserCount = 0L;
if (userA.getIp() != null) {
ipAddressUserCount = userARepository.countByIpEqual(userA.getIp());
}
return AdminUserDetailDto.builder()
.id(userA.getId())
.screenName(userA.getScreenName())
@@ -578,7 +691,9 @@ public class AdminUserService {
.dateLogin(userA.getDateLogin())
.banned(userA.getBanned())
.ipAddress(IpUtils.bytesToIp(userA.getIp()))
.ipAddressUserCount(ipAddressUserCount)
.balanceA(userB.getBalanceA())
.balanceB(userB.getBalanceB())
.depositTotal(userB.getDepositTotal())
.depositCount(userB.getDepositCount())
.withdrawTotal(userB.getWithdrawTotal())
@@ -589,6 +704,7 @@ public class AdminUserService {
.referralCount(totalReferrals)
.totalCommissionsEarned(totalCommissions)
.totalCommissionsEarnedUsd(totalCommissionsEarnedUsd)
.totalReferralDepositsUsd(totalReferralDepositsUsd)
.masterId(userD.getMasterId() > 0 ? userD.getMasterId() : null)
.referralLevels(referralLevels)
.build();
@@ -596,7 +712,20 @@ public class AdminUserService {
private static BigDecimal ticketsToUsd(long ticketsBigint) {
if (ticketsBigint == 0) return BigDecimal.ZERO;
return BigDecimal.valueOf(ticketsBigint).divide(BigDecimal.valueOf(1_000_000L), 6, RoundingMode.HALF_UP).multiply(TICKETS_TO_USD).setScale(2, RoundingMode.HALF_UP);
return BigDecimal.valueOf(ticketsBigint)
.divide(HONEY_DB_UNITS_PER_USD, 8, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP);
}
private static BigDecimal computeProfitPercent(BigDecimal depositUsd, BigDecimal withdrawUsd) {
if (depositUsd == null || depositUsd.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
BigDecimal w = withdrawUsd != null ? withdrawUsd : BigDecimal.ZERO;
return BigDecimal.ONE
.subtract(w.divide(depositUsd, 8, RoundingMode.HALF_UP))
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP);
}
public Page<AdminTransactionDto> getUserTransactions(Integer userId, Pageable pageable) {

View File

@@ -42,6 +42,7 @@ public class NotificationBroadcastService {
private final UserARepository userARepository;
private final NotificationAuditRepository notificationAuditRepository;
private final FeatureSwitchService featureSwitchService;
private final UserService userService;
private final AtomicBoolean stopRequested = new AtomicBoolean(false);
@@ -59,7 +60,7 @@ public class NotificationBroadcastService {
* Run broadcast asynchronously. Uses userIdFrom/userIdTo (internal user ids); if null, uses 1 and max id.
* Only one of imageUrl or videoUrl is used; video takes priority if both are set.
* If buttonText is non-empty, each message gets an inline button with that text opening the mini app.
* When ignoreBlocked is true, skips users whose latest notification_audit record has status FAILED (e.g. blocked the bot).
* When ignoreBlocked is true, skips users with {@code bot_active = false} on {@code db_users_a}.
*/
@Async
public void runBroadcast(String message, String imageUrl, String videoUrl,
@@ -84,23 +85,16 @@ public class NotificationBroadcastService {
boolean hasNext = true;
long sent = 0;
long failed = 0;
long skippedBlocked = 0;
while (hasNext && !stopRequested.get()) {
Pageable pageable = PageRequest.of(pageNumber, PAGE_SIZE);
Page<UserA> page = userARepository.findByIdBetween(fromId, toId, pageable);
Page<UserA> page = skipBlocked
? userARepository.findByIdBetweenAndBotActiveTrue(fromId, toId, pageable)
: userARepository.findByIdBetween(fromId, toId, pageable);
for (UserA user : page.getContent()) {
if (stopRequested.get()) break;
if (user.getTelegramId() == null) continue;
if (skipBlocked) {
var latest = notificationAuditRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId());
if (latest.isPresent() && NotificationAudit.STATUS_FAILED.equals(latest.get().getStatus())) {
skippedBlocked++;
continue;
}
}
TelegramSendResult result = sendOne(botToken, user.getTelegramId(), message, imageUrl, videoUrl, buttonText);
int statusCode = result.getStatusCode();
boolean success = result.isSuccess();
@@ -114,6 +108,10 @@ public class NotificationBroadcastService {
.build();
notificationAuditRepository.save(audit);
if (!success && statusCode == 403) {
userService.setBotActiveByUserId(user.getId(), false);
}
try {
Thread.sleep(DELAY_MS_BETWEEN_SENDS);
} catch (InterruptedException e) {
@@ -127,9 +125,9 @@ public class NotificationBroadcastService {
}
if (stopRequested.get()) {
log.info("Notification broadcast stopped by request. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked);
log.info("Notification broadcast stopped by request. Sent={}, Failed={}", sent, failed);
} else {
log.info("Notification broadcast finished. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked);
log.info("Notification broadcast finished. Sent={}, Failed={}", sent, failed);
}
}

View File

@@ -13,10 +13,12 @@ import com.honey.honey.model.CryptoDepositMethod;
import com.honey.honey.model.Payment;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.model.UserD;
import com.honey.honey.repository.CryptoDepositMethodRepository;
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 com.honey.honey.util.IpUtils;
import com.honey.honey.util.TelegramTokenRedactor;
import lombok.Data;
@@ -47,6 +49,7 @@ public class PaymentService {
private final PaymentRepository paymentRepository;
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final CryptoDepositMethodRepository cryptoDepositMethodRepository;
private final TelegramProperties telegramProperties;
private final TransactionService transactionService;
@@ -74,6 +77,11 @@ public class PaymentService {
/** When false, Telegram Stars payment webhooks are ignored (no balance credited). Invoice creation for Stars is already rejected. */
private static final boolean TELEGRAM_STARS_PAYMENTS_ENABLED = false;
/** Referral commission as fraction of base deposit (no bonus). Level 1 = referer_1, level 2 = referer_2, level 3 = referer_3. */
private static final double REFERRER_LEVEL1_PERCENT = 0.12;
private static final double REFERRER_LEVEL2_PERCENT = 0.06;
private static final double REFERRER_LEVEL3_PERCENT = 0.02;
/**
* Creates a payment invoice for the user.
* Validates the stars amount and creates a pending payment record.
@@ -394,6 +402,7 @@ public class PaymentService {
.playBalance(playBalance)
.status(Payment.PaymentStatus.COMPLETED)
.completedAt(now)
.ftd(firstDeposit)
.build();
paymentRepository.save(payment);
@@ -409,9 +418,54 @@ public class PaymentService {
log.error("Error creating deposit transaction: userId={}, amount={}", userId, playBalance, e);
}
// Referral commissions: % of base deposit (no bonus), credited to referer balanceB and tracked in UserD
applyReferralCommissions(userId, basePlayBalance);
log.info("External deposit completed: orderId={}, userId={}, usdAmount={}, playBalance={}, firstDeposit={}", orderId, userId, usdAmountBd, playBalance, firstDeposit);
}
/**
* Applies referral commissions on deposit: referer_1 gets REFERRER_LEVEL1_PERCENT, referer_2 gets REFERRER_LEVEL2_PERCENT, referer_3 gets REFERRER_LEVEL3_PERCENT of base play balance (no bonus).
* Updates: referer's balanceB, depositor's to_referer_N, referer's from_referals_N.
*/
private void applyReferralCommissions(Integer depositorUserId, long basePlayBalance) {
if (basePlayBalance <= 0) return;
UserD depositorUserD = userDRepository.findById(depositorUserId).orElse(null);
if (depositorUserD == null) return;
long commission1 = Math.round(basePlayBalance * REFERRER_LEVEL1_PERCENT);
long commission2 = Math.round(basePlayBalance * REFERRER_LEVEL2_PERCENT);
long commission3 = Math.round(basePlayBalance * REFERRER_LEVEL3_PERCENT);
applyReferralLevel(depositorUserD, depositorUserId, 1, depositorUserD.getRefererId1(), commission1,
UserD::getToReferer1, UserD::setToReferer1, UserD::getFromReferals1, UserD::setFromReferals1);
applyReferralLevel(depositorUserD, depositorUserId, 2, depositorUserD.getRefererId2(), commission2,
UserD::getToReferer2, UserD::setToReferer2, UserD::getFromReferals2, UserD::setFromReferals2);
applyReferralLevel(depositorUserD, depositorUserId, 3, depositorUserD.getRefererId3(), commission3,
UserD::getToReferer3, UserD::setToReferer3, UserD::getFromReferals3, UserD::setFromReferals3);
userDRepository.save(depositorUserD);
}
private void applyReferralLevel(UserD depositorUserD, Integer depositorUserId, int level, Integer refererId, long commission,
java.util.function.Function<UserD, Long> getToReferer, java.util.function.BiConsumer<UserD, Long> setToReferer,
java.util.function.Function<UserD, Long> getFromReferals, java.util.function.BiConsumer<UserD, Long> setFromReferals) {
if (refererId == null || refererId <= 0 || commission <= 0) return;
setToReferer.accept(depositorUserD, (getToReferer.apply(depositorUserD) != null ? getToReferer.apply(depositorUserD) : 0L) + commission);
userBRepository.findById(refererId).ifPresent(refererB -> {
refererB.setBalanceB(refererB.getBalanceB() + commission);
userBRepository.save(refererB);
});
userDRepository.findById(refererId).ifPresent(refererD -> {
setFromReferals.accept(refererD, (getFromReferals.apply(refererD) != null ? getFromReferals.apply(refererD) : 0L) + commission);
userDRepository.save(refererD);
});
log.debug("Referral commission applied: depositor={}, refererLevel={}, refererId={}, commission={}", depositorUserId, level, refererId, commission);
}
/** USD range 320000 and at most 2 decimal places. */
private void validateUsd(double usdAmount) {
if (usdAmount < MIN_USD || usdAmount > MAX_USD) {

View File

@@ -1,6 +1,7 @@
package com.honey.honey.service;
import com.honey.honey.dto.ReferralDto;
import com.honey.honey.dto.ReferralProjection;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.model.UserD;
@@ -13,13 +14,16 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Service for user management with sharded tables.
@@ -178,6 +182,7 @@ public class UserService {
}
userA.setIp(ipBytes);
userA.setDateLogin((int) nowSeconds);
userA.setBotActive(true);
userARepository.save(userA);
log.debug("Updated user data on login: userId={}", userA.getId());
@@ -261,7 +266,7 @@ public class UserService {
userBRepository.save(userB);
// Create UserD with referral handling
UserD userD = createUserDWithReferral(userId, screenName, start);
UserD userD = createUserDWithReferral(userId, screenName, start, (int) nowSeconds);
userDRepository.save(userD);
return userA;
@@ -276,7 +281,7 @@ public class UserService {
* @param screenName User's screen name (from db_users_a)
* @param start Referral parameter (from bot registration, not WebApp)
*/
private UserD createUserDWithReferral(Integer userId, String screenName, String start) {
private UserD createUserDWithReferral(Integer userId, String screenName, String start, int dateReg) {
log.debug("Creating UserD with referral: userId={}, start={}", userId, start);
// Defensive check: Ensure UserD doesn't already exist (should never happen, but safety check)
@@ -289,6 +294,7 @@ public class UserService {
UserD.UserDBuilder builder = UserD.builder()
.id(userId)
.screenName(screenName != null ? screenName : "-")
.dateReg(dateReg)
.refererId1(0)
.refererId2(0)
.refererId3(0)
@@ -453,26 +459,60 @@ public class UserService {
return userARepository.findByTelegramId(telegramId);
}
private static final double COMMISSION_DISPLAY_DIVISOR = 1_000_000.0;
/**
* Gets referrals for a specific level with pagination.
* Always returns 50 results per page.
* Fetches raw rows (ReferralProjection), maps to API DTO (ReferralDto) with display commission.
*
* @param userId The user ID to get referrals for
* @param level The referral level (1, 2, or 3)
* @param page Page number (0-indexed)
* @return Page of referrals with name and commission
* @return Page of ReferralDto (name, commission as display value)
*/
public Page<ReferralDto> getReferrals(Integer userId, Integer level, Integer page) {
// Fixed page size of 50 to prevent database overload
Pageable pageable = PageRequest.of(page, 50);
return switch (level) {
Pageable pageable = PageRequest.of(page, 10);
Page<ReferralProjection> projectionPage = switch (level) {
case 1 -> userDRepository.findReferralsLevel1(userId, pageable);
case 2 -> userDRepository.findReferralsLevel2(userId, pageable);
case 3 -> userDRepository.findReferralsLevel3(userId, pageable);
default -> throw new IllegalArgumentException(
localizationService.getMessage("user.error.referralLevelInvalid", String.valueOf(level)));
};
List<ReferralDto> content = projectionPage.getContent().stream()
.map(p -> ReferralDto.builder()
.name(p.getName())
.commission(p.getCommission() != null ? p.getCommission() / COMMISSION_DISPLAY_DIVISOR : 0.0)
.build())
.collect(Collectors.toList());
return new PageImpl<>(content, projectionPage.getPageable(), projectionPage.getTotalElements());
}
@Transactional
public void setBotActiveByTelegramId(Long telegramId, boolean active) {
Optional<UserA> opt = userARepository.findByTelegramId(telegramId);
if (opt.isEmpty()) {
log.debug("setBotActiveByTelegramId: no user for telegramId={}", telegramId);
return;
}
UserA u = opt.get();
if (u.isBotActive() == active) {
return;
}
u.setBotActive(active);
userARepository.save(u);
log.debug("setBotActiveByTelegramId: userId={}, telegramId={}, active={}", u.getId(), telegramId, active);
}
@Transactional
public void setBotActiveByUserId(Integer userId, boolean active) {
userARepository.findById(userId).ifPresent(u -> {
if (u.isBotActive() == active) {
return;
}
u.setBotActive(active);
userARepository.save(u);
});
}
}

View File

@@ -121,6 +121,7 @@ app:
secret: ${APP_ADMIN_JWT_SECRET:change-this-to-a-secure-random-string-in-production-min-32-characters}
# JWT expiration time in milliseconds (default: 24 hours)
expiration: ${APP_ADMIN_JWT_EXPIRATION:86400000}
chatwoot-integration-secret: ${CHATWOOT_INTEGRATION_SECRET:}
# GeoIP configuration
# Set GEOIP_DB_PATH environment variable to use external file (recommended for production)

View File

@@ -0,0 +1,10 @@
-- Extend referral commission indexes to include id DESC so ORDER BY commission DESC, id DESC
-- is fully satisfied by the index. Avoids expensive filesort when many referrals share the same
-- commission (e.g. 20k with 0 commission).
DROP INDEX idx_users_d_referer1_commission ON db_users_d;
DROP INDEX idx_users_d_referer2_commission ON db_users_d;
DROP INDEX idx_users_d_referer3_commission ON db_users_d;
CREATE INDEX idx_users_d_referer1_commission ON db_users_d (referer_id_1, to_referer_1 DESC, id DESC);
CREATE INDEX idx_users_d_referer2_commission ON db_users_d (referer_id_2, to_referer_2 DESC, id DESC);
CREATE INDEX idx_users_d_referer3_commission ON db_users_d (referer_id_3, to_referer_3 DESC, id DESC);

View File

@@ -0,0 +1,4 @@
-- Ensure payment_enabled and payout_enabled feature switches exist (for admin panel and runtime).
INSERT INTO feature_switches (`key`, `enabled`, `updated_at`)
VALUES ('payment_enabled', 1, CURRENT_TIMESTAMP), ('payout_enabled', 1, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,2 @@
-- Index for admin users list: filter and sort by Balance B
CREATE INDEX idx_users_b_balance_b ON db_users_b(balance_b);

View File

@@ -0,0 +1,6 @@
-- Whether the user can receive messages from the bot (false after block / 403; true after /start or my_chat_member member).
ALTER TABLE db_users_a
ADD COLUMN bot_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1 = bot can message user, 0 = blocked/unreachable';
-- Composite index: COUNT(bot_active=0), and id-range queries with bot_active=true (notification broadcast).
CREATE INDEX idx_users_a_bot_active_id ON db_users_a(bot_active, id);

View File

@@ -0,0 +1,2 @@
-- Admin users list filter: deposit_count >= N (e.g. users with at least one deposit)
CREATE INDEX idx_users_b_deposit_count ON db_users_b(deposit_count);

View File

@@ -0,0 +1,6 @@
-- First-time deposit flag (set on first completed crypto deposit for user)
ALTER TABLE payments
ADD COLUMN ftd TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'First-time deposit (FTD)' AFTER completed_at;
-- Supports admin analytics: COUNT FTD payments per day by completion time
CREATE INDEX idx_payments_status_ftd_completed_at ON payments (status, ftd, completed_at);

View File

@@ -0,0 +1,2 @@
-- Admin statistics table & analytics: range scans on COMPLETED payments by completion time
CREATE INDEX idx_payments_status_completed_at ON payments (status, completed_at);

View File

@@ -0,0 +1,2 @@
-- Admin statistics: registrations "ours" (referer_id_1 = master_id, id <> master_id, master_id > 0)
CREATE INDEX idx_users_d_master_referer1 ON db_users_d (master_id, referer_id_1);

View File

@@ -0,0 +1,9 @@
-- Mirror registration time on db_users_d for analytics without joining db_users_a
ALTER TABLE db_users_d
ADD COLUMN date_reg INT NOT NULL DEFAULT 0 COMMENT 'Registration unix time (seconds); same as db_users_a.date_reg' AFTER screen_name;
UPDATE db_users_d d
INNER JOIN db_users_a a ON a.id = d.id
SET d.date_reg = a.date_reg;
CREATE INDEX idx_users_d_date_reg ON db_users_d (date_reg);

View File

@@ -0,0 +1,2 @@
-- Speed up admin "online" batch lookup: sessions active at a point in time by user_id
CREATE INDEX idx_sessions_user_expires ON sessions (user_id, expires_at);

View File

@@ -0,0 +1,10 @@
-- Admin users list: sort by profitPercent matches AdminUserService native ORDER BY on db_users_b:
-- CASE WHEN deposit_total > 0 THEN 100.0 * (deposit_total - withdraw_total) / deposit_total ELSE NULL END
-- MySQL 8.0.13+ functional index so ORDER BY can use an index (when planner chooses it).
CREATE INDEX idx_users_b_admin_profit_pct ON db_users_b (
(CASE
WHEN deposit_total > 0
THEN (100.0 * (deposit_total - withdraw_total) / deposit_total)
ELSE NULL
END)
);