Compare commits

..

13 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
55 changed files with 1654 additions and 282 deletions

View File

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

View File

@@ -103,14 +103,14 @@ public class AdminSecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/login").permitAll() .requestMatchers("/api/admin/login", "/api/admin/chatwoot-session").permitAll()
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "GAME_ADMIN") .requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "SUPPORT")
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "GAME_ADMIN") .requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "SUPPORT")
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "PAYOUT_SUPPORT", "GAME_ADMIN") .requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "SUPPORT")
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN", "GAME_ADMIN") .requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN", "GAME_ADMIN") .requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN") .requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN") .requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().denyAll() .anyRequest().denyAll()
) )

View File

@@ -7,6 +7,7 @@ import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -18,12 +19,23 @@ import java.util.Map;
public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer { public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
public static final String EXAMPLE_CURRENT_USER_RESPONSE = "CurrentUserResponse"; 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 @Override
public void customise(OpenAPI openAPI) { public void customise(OpenAPI openAPI) {
ensureComponents(openAPI); ensureComponents(openAPI);
addCurrentUserResponseExample(openAPI); addCurrentUserResponseExample(openAPI);
attachCurrentUserExampleToEndpoint(openAPI); attachCurrentUserExampleToEndpoint(openAPI);
addLanguageRequestExample(openAPI);
attachLanguageExampleToEndpoint(openAPI);
addDepositMethodsResponseExample(openAPI);
attachDepositMethodsExampleToEndpoint(openAPI);
addDepositAddressRequestExample(openAPI);
addDepositAddressResponseExample(openAPI);
attachDepositAddressExamplesToEndpoint(openAPI);
} }
private void ensureComponents(OpenAPI openAPI) { private void ensureComponents(OpenAPI openAPI) {
@@ -43,8 +55,8 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
value.put("screenName", "The Boy"); value.put("screenName", "The Boy");
value.put("dateReg", 1772897273); value.put("dateReg", 1772897273);
value.put("ip", "165.165.165.165"); value.put("ip", "165.165.165.165");
value.put("balanceA", 100000); value.put("balanceA", 10000);
value.put("balanceB", 100000); value.put("balanceB", 10000);
value.put("avatarUrl", "/avatars/0/0/1/06c98267.png?v=1772897273"); value.put("avatarUrl", "/avatars/0/0/1/06c98267.png?v=1772897273");
value.put("languageCode", "EN"); value.put("languageCode", "EN");
value.put("paymentEnabled", true); value.put("paymentEnabled", true);
@@ -74,10 +86,138 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
MediaType json = response200.getContent().get("application/json"); MediaType json = response200.getContent().get("application/json");
if (json == null) return; 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); Example example = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_CURRENT_USER_RESPONSE);
if (example != null) { if (example != null) {
json.setExamples(Map.of("success", example)); 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.PaymentRepository;
import com.honey.honey.repository.PayoutRepository; import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.UserARepository; import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserDRepository;
import com.honey.honey.service.AdminStatisticsTableService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; 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.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@@ -26,102 +30,116 @@ import java.util.Map;
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController { public class AdminAnalyticsController {
private static final BigDecimal ZERO = BigDecimal.ZERO;
private final UserARepository userARepository; private final UserARepository userARepository;
private final UserDRepository userDRepository;
private final PaymentRepository paymentRepository; private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository; private final PayoutRepository payoutRepository;
private final AdminStatisticsTableService adminStatisticsTableService;
/** /**
* Get revenue and payout time series data for charts. * Paginated daily / monthly statistics table (admin).
* @param range Time range: 7d, 30d, 90d, 1y, all
* @return Time series data with daily/weekly/monthly aggregation
*/ */
@GetMapping("/revenue") @GetMapping("/statistics-table")
public ResponseEntity<Map<String, Object>> getRevenueAnalytics( public ResponseEntity<Map<String, Object>> getStatisticsTable(
@RequestParam(defaultValue = "30d") String range) { @RequestParam String mode,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(defaultValue = "desc") String sortDir) {
Instant now = Instant.now(); AdminStatisticsTableService.TableMode tableMode;
Instant startDate; try {
String granularity; tableMode = AdminStatisticsTableService.TableMode.valueOf(mode.trim().toUpperCase());
} catch (IllegalArgumentException e) {
// Determine start date and granularity based on range return ResponseEntity.badRequest().build();
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0); // All time
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
} }
List<Map<String, Object>> dataPoints = new ArrayList<>(); String dir = sortDir != null ? sortDir.trim().toLowerCase() : "desc";
Instant current = startDate; if (!dir.equals("asc") && !dir.equals("desc")) {
return ResponseEntity.badRequest().build();
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
// CRYPTO only: revenue and payouts in USD for this period
java.math.BigDecimal revenueUsd = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal payoutsUsd = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
Payout.PayoutStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("revenue", revenueUsd);
point.put("payouts", payoutsUsd);
point.put("netRevenue", netRevenueUsd);
dataPoints.add(point);
current = periodEnd;
} }
boolean sortAscending = dir.equals("asc");
int cappedSize = Math.min(200, Math.max(1, size));
int safePage = Math.max(0, page);
var dtoPage = adminStatisticsTableService.getStatisticsTable(tableMode, safePage, cappedSize, sortAscending);
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("range", range); response.put("content", dtoPage.getContent());
response.put("granularity", granularity); response.put("totalElements", dtoPage.getTotalElements());
response.put("data", dataPoints); 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); return ResponseEntity.ok(response);
} }
/** /**
* Get user activity time series data (registrations, active players, rounds). * Bounds for admin statistics-by-day UI: UTC day start of the earliest {@code db_users_d.date_reg}
* @param range Time range: 7d, 30d, 90d, 1y, all * and exclusive end of the latest 30-day window (start of tomorrow UTC).
* @return Time series data
*/ */
@GetMapping("/activity") @GetMapping("/meta")
public ResponseEntity<Map<String, Object>> getActivityAnalytics( 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) { @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 now = Instant.now();
Instant startDate; Instant startDate;
String granularity; String granularity;
@@ -169,24 +187,7 @@ public class AdminAnalyticsController {
periodEnd = now; periodEnd = now;
} }
// Convert to Unix timestamps for UserA queries dataPoints.add(buildRevenuePoint(current, periodEnd));
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
// Count new registrations in this period (between current and periodEnd)
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
// Count active players (logged in) in this period
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers);
point.put("rounds", 0L);
dataPoints.add(point);
current = periodEnd; current = periodEnd;
} }
@@ -197,5 +198,156 @@ public class AdminAnalyticsController {
return ResponseEntity.ok(response); 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.Payment;
import com.honey.honey.model.Payout; import com.honey.honey.model.Payout;
import com.honey.honey.model.SupportTicket;
import com.honey.honey.model.UserA; import com.honey.honey.model.UserA;
import com.honey.honey.repository.PaymentRepository; import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.PayoutRepository; import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.SupportTicketRepository;
import com.honey.honey.repository.UserARepository; import com.honey.honey.repository.UserARepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -30,7 +28,6 @@ public class AdminDashboardController {
private final UserARepository userARepository; private final UserARepository userARepository;
private final PaymentRepository paymentRepository; private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository; private final PayoutRepository payoutRepository;
private final SupportTicketRepository supportTicketRepository;
@GetMapping("/stats") @GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getDashboardStats() { public ResponseEntity<Map<String, Object>> getDashboardStats() {
@@ -103,15 +100,6 @@ public class AdminDashboardController {
BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek); BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek);
BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth); 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 // Build response
stats.put("users", Map.of( stats.put("users", Map.of(
"total", totalUsers, "total", totalUsers,
@@ -162,19 +150,6 @@ public class AdminDashboardController {
crypto.put("profitUsdMonth", cryptoNetRevenueMonth); crypto.put("profitUsdMonth", cryptoNetRevenueMonth);
stats.put("crypto", crypto); 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); 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.AdminLoginRequest;
import com.honey.honey.dto.AdminLoginResponse; 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 com.honey.honey.service.AdminService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -19,6 +22,11 @@ import java.util.Optional;
public class AdminLoginController { public class AdminLoginController {
private final AdminService adminService; 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") @PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) { public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) {
@@ -47,5 +55,30 @@ public class AdminLoginController {
role 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.UserARepository;
import com.honey.honey.repository.UserDRepository; import com.honey.honey.repository.UserDRepository;
import lombok.RequiredArgsConstructor; 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.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; 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.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; 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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -31,18 +31,18 @@ import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/admin/payments") @RequestMapping("/api/admin/payments")
@RequiredArgsConstructor @RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public class AdminPaymentController { public class AdminPaymentController {
private final PaymentRepository paymentRepository; private final PaymentRepository paymentRepository;
private final UserARepository userARepository; private final UserARepository userARepository;
private final UserDRepository userDRepository; private final UserDRepository userDRepository;
private boolean isGameAdmin() { private static boolean isSupport() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false; if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream() return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority())); .anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
} }
@GetMapping @GetMapping
@@ -70,7 +70,7 @@ public class AdminPaymentController {
Pageable pageable = PageRequest.of(page, size, sort); 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 // Build specification
Specification<Payment> spec = (root, query, cb) -> { Specification<Payment> spec = (root, query, cb) -> {
@@ -124,6 +124,7 @@ public class AdminPaymentController {
.orderId(payment.getOrderId()) .orderId(payment.getOrderId())
.starsAmount(payment.getStarsAmount()) .starsAmount(payment.getStarsAmount())
.ticketsAmount(payment.getTicketsAmount()) .ticketsAmount(payment.getTicketsAmount())
.usdAmount(payment.getUsdAmount())
.status(payment.getStatus().name()) .status(payment.getStatus().name())
.telegramPaymentChargeId(payment.getTelegramPaymentChargeId()) .telegramPaymentChargeId(payment.getTelegramPaymentChargeId())
.telegramProviderPaymentChargeId(payment.getTelegramProviderPaymentChargeId()) .telegramProviderPaymentChargeId(payment.getTelegramProviderPaymentChargeId())

View File

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

View File

@@ -1,7 +1,9 @@
package com.honey.honey.controller; package com.honey.honey.controller;
import com.honey.honey.dto.*; import com.honey.honey.dto.*;
import com.honey.honey.model.UserA;
import com.honey.honey.service.AdminUserService; import com.honey.honey.service.AdminUserService;
import com.honey.honey.service.UserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -23,26 +25,28 @@ import java.util.Set;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminUserController { 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). */ /** Sortable fields: UserA properties plus UserB/UserD (handled via custom query in service). */
private static final Set<String> SORTABLE_FIELDS = Set.of( private static final Set<String> SORTABLE_FIELDS = Set.of(
"id", "screenName", "telegramId", "telegramName", "isPremium", "id", "screenName", "telegramId", "telegramName", "isPremium",
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned", "languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
"balanceA", "depositTotal", "withdrawTotal", "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> DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt");
private static final Set<String> WITHDRAWAL_SORT_FIELDS = Set.of("id", "usdAmount", "cryptoName", "amountToSend", "txhash", "status", "paymentId", "createdAt", "resolvedAt"); private static final Set<String> WITHDRAWAL_SORT_FIELDS = Set.of("id", "usdAmount", "cryptoName", "amountToSend", "txhash", "status", "paymentId", "createdAt", "resolvedAt");
private final AdminUserService adminUserService; private final AdminUserService adminUserService;
private final UserService userService;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping @GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUsers( public ResponseEntity<Map<String, Object>> getUsers(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size, @RequestParam(defaultValue = "50") int size,
@@ -55,16 +59,23 @@ public class AdminUserController {
@RequestParam(required = false) String languageCode, @RequestParam(required = false) String languageCode,
@RequestParam(required = false) Integer dateRegFrom, @RequestParam(required = false) Integer dateRegFrom,
@RequestParam(required = false) Integer dateRegTo, @RequestParam(required = false) Integer dateRegTo,
@RequestParam(required = false) Long balanceMin, @RequestParam(required = false) Long balanceAMin,
@RequestParam(required = false) Long balanceMax, @RequestParam(required = false) Long balanceAMax,
@RequestParam(required = false) Long balanceBMin,
@RequestParam(required = false) Long balanceBMax,
@RequestParam(required = false) Integer referralCountMin, @RequestParam(required = false) Integer referralCountMin,
@RequestParam(required = false) Integer referralCountMax, @RequestParam(required = false) Integer referralCountMax,
@RequestParam(required = false) Integer referrerId, @RequestParam(required = false) Integer referrerId,
@RequestParam(required = false) Integer referralLevel, @RequestParam(required = false) Integer referralLevel,
@RequestParam(required = false) String ip) { @RequestParam(required = false) String ip,
@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. // Build sort. Fields on UserB/UserD (balanceA, balanceB, depositTotal, etc.) are handled in service via custom query.
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit"); 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); String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null);
if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) { if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) {
// Pass through; service will use custom ordered query // Pass through; service will use custom ordered query
@@ -78,11 +89,13 @@ public class AdminUserController {
} }
Pageable pageable = PageRequest.of(page, size, sort); Pageable pageable = PageRequest.of(page, size, sort);
// Convert balance filters from tickets (divide by 1000000) to bigint format // Convert balance filters from display value to bigint format
Long balanceMinBigint = balanceMin != null ? balanceMin * 1000000L : null; Long balanceAMinBigint = balanceAMin != null ? balanceAMin * 1000000L : null;
Long balanceMaxBigint = balanceMax != null ? balanceMax * 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( Page<AdminUserDto> dtoPage = adminUserService.getUsers(
pageable, pageable,
search, search,
@@ -91,16 +104,21 @@ public class AdminUserController {
languageCode, languageCode,
dateRegFrom, dateRegFrom,
dateRegTo, dateRegTo,
balanceMinBigint, balanceAMinBigint,
balanceMaxBigint, balanceAMaxBigint,
balanceBMinBigint,
balanceBMaxBigint,
referralCountMin, referralCountMin,
referralCountMax, referralCountMax,
referrerId, referrerId,
referralLevel, referralLevel,
ip, ip,
botActive,
depositCountMin,
effectiveSortBy, effectiveSortBy,
sortDir, sortDir,
excludeMasters excludeMasters,
Boolean.TRUE.equals(hideSubAndBanned)
); );
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
@@ -115,10 +133,26 @@ public class AdminUserController {
return ResponseEntity.ok(response); 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}") @GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<AdminUserDetailDto> getUserDetail(@PathVariable Integer id) { public ResponseEntity<AdminUserDetailDto> getUserDetail(@PathVariable Integer id) {
AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isGameAdmin()); AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isSupport());
if (userDetail == null) { if (userDetail == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
@@ -126,7 +160,7 @@ public class AdminUserController {
} }
@GetMapping("/{id}/transactions") @GetMapping("/{id}/transactions")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUserTransactions( public ResponseEntity<Map<String, Object>> getUserTransactions(
@PathVariable Integer id, @PathVariable Integer id,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@@ -148,7 +182,7 @@ public class AdminUserController {
} }
@GetMapping("/{id}/payments") @GetMapping("/{id}/payments")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUserPayments( public ResponseEntity<Map<String, Object>> getUserPayments(
@PathVariable Integer id, @PathVariable Integer id,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@@ -171,7 +205,7 @@ public class AdminUserController {
} }
@GetMapping("/{id}/payouts") @GetMapping("/{id}/payouts")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUserPayouts( public ResponseEntity<Map<String, Object>> getUserPayouts(
@PathVariable Integer id, @PathVariable Integer id,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@@ -194,14 +228,14 @@ public class AdminUserController {
} }
@GetMapping("/{id}/tasks") @GetMapping("/{id}/tasks")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<Map<String, Object>> getUserTasks(@PathVariable Integer id) { public ResponseEntity<Map<String, Object>> getUserTasks(@PathVariable Integer id) {
Map<String, Object> tasks = adminUserService.getUserTasks(id); Map<String, Object> tasks = adminUserService.getUserTasks(id);
return ResponseEntity.ok(tasks); return ResponseEntity.ok(tasks);
} }
@PatchMapping("/{id}/ban") @PatchMapping("/{id}/ban")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<?> setUserBanned( public ResponseEntity<?> setUserBanned(
@PathVariable Integer id, @PathVariable Integer id,
@RequestBody Map<String, Boolean> body) { @RequestBody Map<String, Boolean> body) {
@@ -218,7 +252,7 @@ public class AdminUserController {
} }
@PatchMapping("/{id}/withdrawals-enabled") @PatchMapping("/{id}/withdrawals-enabled")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public ResponseEntity<?> setWithdrawalsEnabled( public ResponseEntity<?> setWithdrawalsEnabled(
@PathVariable Integer id, @PathVariable Integer id,
@RequestBody Map<String, Boolean> body) { @RequestBody Map<String, Boolean> body) {

View File

@@ -21,7 +21,7 @@ import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/admin/quick-answers") @RequestMapping("/api/admin/quick-answers")
@RequiredArgsConstructor @RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN')")
public class QuickAnswerController { public class QuickAnswerController {
private final QuickAnswerRepository quickAnswerRepository; 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.Update;
import org.telegram.telegrambots.meta.api.objects.Message; import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.User; 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.CallbackQuery;
import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery; import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery;
import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment; import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment;
@@ -81,6 +82,12 @@ public class TelegramWebhookController {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} }
try { 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) // Handle callback queries (button clicks)
if (update.hasCallbackQuery()) { if (update.hasCallbackQuery()) {
handleCallbackQuery(update.getCallbackQuery()); 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. * Handles /start command with optional referral parameter, and Reply Keyboard button clicks.
* Format: /start or /start 123 (where 123 is the referral user ID) * Format: /start or /start 123 (where 123 is the referral user ID)
@@ -221,6 +249,7 @@ public class TelegramWebhookController {
try { try {
// Get or create user (handles registration, login update, and referral system) // Get or create user (handles registration, login update, and referral system)
UserA user = userService.getOrCreateUser(tgUserData, httpRequest); UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
userService.setBotActiveByTelegramId(telegramId, true);
log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}", log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}",
user.getId(), user.getTelegramId(), isNewUser); user.getId(), user.getTelegramId(), isNewUser);

View File

@@ -24,6 +24,9 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserController { 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 UserService userService;
private final UserBRepository userBRepository; private final UserBRepository userBRepository;
private final AvatarService avatarService; private final AvatarService avatarService;
@@ -37,10 +40,12 @@ public class UserController {
// Convert IP from byte[] to string for display // Convert IP from byte[] to string for display
String ipAddress = IpUtils.bytesToIp(user.getIp()); 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()); var userBOpt = userBRepository.findById(user.getId());
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L); long rawBalanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
Long balanceB = userBOpt.map(UserB::getBalanceB).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); Integer depositCount = userBOpt.map(UserB::getDepositCount).orElse(0);
// Generate avatar URL on-the-fly (deterministic from userId) // Generate avatar URL on-the-fly (deterministic from userId)
@@ -126,7 +131,6 @@ public class UserController {
UserA user = UserContext.get(); UserA user = UserContext.get();
Page<ReferralDto> referralsPage = userService.getReferrals(user.getId(), level, page); Page<ReferralDto> referralsPage = userService.getReferrals(user.getId(), level, page);
return new ReferralsResponse( return new ReferralsResponse(
referralsPage.getContent(), referralsPage.getContent(),
referralsPage.getNumber(), referralsPage.getNumber(),

View File

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

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
@Data @Data
@@ -19,6 +20,8 @@ public class AdminPayoutDto {
private String type; private String type;
private String giftName; private String giftName;
private Long total; private Long total;
/** USD amount (e.g. 1.25). */
private BigDecimal usdAmount;
private Integer starsAmount; private Integer starsAmount;
private Integer quantity; private Integer quantity;
private String status; 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; private Integer banned;
/** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */ /** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */
private String ipAddress; private String ipAddress;
/** Number of users sharing the same IP (including this user). */
private Long ipAddressUserCount;
// Balance Info // Balance Info
private Long balanceA; private Long balanceA;
private Long balanceB;
private Long depositTotal; private Long depositTotal;
private Integer depositCount; private Integer depositCount;
private Long withdrawTotal; private Long withdrawTotal;
@@ -44,8 +47,10 @@ public class AdminUserDetailDto {
// Referral Info // Referral Info
private Integer referralCount; private Integer referralCount;
private Long totalCommissionsEarned; 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; 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 Integer masterId;
private List<ReferralLevelDto> referralLevels; private List<ReferralLevelDto> referralLevels;
} }

View File

@@ -31,11 +31,21 @@ public class AdminUserDto {
private Long totalCommissionsEarned; // Total commissions earned from referrals private Long totalCommissionsEarned; // Total commissions earned from referrals
/** Profit in tickets (bigint): depositTotal - withdrawTotal */ /** Profit in tickets (bigint): depositTotal - withdrawTotal */
private Long profit; private Long profit;
/** USD from db_users_b: depositTotal (tickets/1000) */ /** USD from db_users_b deposit_total (Honey scale). */
private BigDecimal depositTotalUsd; private BigDecimal depositTotalUsd;
/** USD from db_users_b: withdrawTotal (tickets/1000) */ /** USD from db_users_b withdraw_total (Honey scale). */
private BigDecimal withdrawTotalUsd; 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; 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 lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
@Data @Data
@Builder @Builder
@@ -17,13 +16,12 @@ public class BalanceAdjustmentRequest {
private BalanceType balanceType; // A or B private BalanceType balanceType; // A or B
@NotNull(message = "Amount is required") @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") @NotNull(message = "Operation is required")
private OperationType operation; // ADD or SUBTRACT private OperationType operation; // ADD or SUBTRACT
@NotBlank(message = "Reason is required") private String reason; // Optional reason for adjustment (for audit log)
private String reason; // Reason for adjustment (for audit log)
public enum BalanceType { public enum BalanceType {
A, B 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.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** API response DTO for referral list. Built from {@link ReferralProjection} in service. */
@Data @Data
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class ReferralDto { public class ReferralDto {
private String name; // screen_name from db_users_a private String name;
private Long commission; // to_referer_1/2/3 from db_users_d (bigint, needs to be divided by 1,000,000 on frontend) private Double commission; // display value (raw from DB / 1_000_000)
} }

View File

@@ -12,14 +12,16 @@ import java.math.BigDecimal;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class ReferralLevelDto { 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 refererId;
private Integer referralCount; private Integer referralCount;
private Long commissionsEarned; private Long commissionsEarned;
private Long commissionsPaid; 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; private BigDecimal commissionsEarnedUsd;
/** Commissions paid in USD (converted from tickets). */ /** Commissions paid in USD (Honey: bigint / 10_000_000_000). */
private BigDecimal commissionsPaidUsd; 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 String screenName; // User's screen name
private Integer dateReg; // Registration date (Unix timestamp in seconds) private Integer dateReg; // Registration date (Unix timestamp in seconds)
private String ip; private String ip;
private Long balanceA; // Balance (stored as bigint, represents number with 6 decimal places) private Double balanceA; // Balance for display (raw bigint / 1_000_000)
private Long balanceB; // Second balance (stored as bigint) private Double balanceB; // Second balance for display (raw bigint / 1_000_000)
private String avatarUrl; // Public URL of user's avatar 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 String languageCode; // User's language preference (EN, RU, DE, IT, NL, PL, FR, ES, ID, TR)
private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed

View File

@@ -55,6 +55,11 @@ public class Payment {
@Column(name = "completed_at") @Column(name = "completed_at")
private Instant completedAt; 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 @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = Instant.now(); createdAt = Instant.now();

View File

@@ -64,6 +64,11 @@ public class UserA {
@Column(name = "last_telegram_file_id", length = 255) @Column(name = "last_telegram_file_id", length = 255)
private String lastTelegramFileId; 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 @Builder.Default
private String screenName = "-"; 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) @Column(name = "referer_id_1", nullable = false)
@Builder.Default @Builder.Default
private Integer refererId1 = 0; 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("start") Instant start,
@Param("end") Instant end @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("start") Instant start,
@Param("end") Instant end @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 org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
@Repository @Repository
public interface SessionRepository extends JpaRepository<Session, Long> { public interface SessionRepository extends JpaRepository<Session, Long> {
@@ -46,6 +48,13 @@ public interface SessionRepository extends JpaRepository<Session, Long> {
* Returns the number of deleted rows. * Returns the number of deleted rows.
* Note: MySQL requires LIMIT to be a literal or bound parameter, so we use a native query. * 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) @Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = "DELETE FROM sessions WHERE expires_at < :now LIMIT :batchSize", nativeQuery = true) @Query(value = "DELETE FROM sessions WHERE expires_at < :now LIMIT :batchSize", nativeQuery = true)
int deleteExpiredSessionsBatch(@Param("now") LocalDateTime now, @Param("batchSize") int batchSize); 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") @Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u")
int getMaxId(); int getMaxId();
Optional<UserA> findByTelegramId(Long telegramId); 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") @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 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) @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM UserB b WHERE b.id = :id") @Query("SELECT b FROM UserB b WHERE b.id = :id")
Optional<UserB> findByIdForUpdate(@Param("id") Integer 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; package com.honey.honey.repository;
import com.honey.honey.dto.ReferralDto; import com.honey.honey.dto.ReferralProjection;
import com.honey.honey.model.UserD; import com.honey.honey.model.UserD;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -11,10 +11,17 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
public interface UserDRepository extends JpaRepository<UserD, Integer> { 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. * 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). * 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.
* Ordered by commission DESC, then id DESC.
*/ */
@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 " + "FROM UserD ud " +
"WHERE ud.refererId1 = :userId AND ud.refererId1 > 0 " + "WHERE ud.refererId1 = :userId AND ud.refererId1 > 0 " +
"ORDER BY ud.toReferer1 DESC, ud.id DESC") "ORDER BY ud.toReferer1 DESC, ud.id DESC")
Page<ReferralDto> findReferralsLevel1(@Param("userId") Integer userId, Pageable pageable); Page<ReferralProjection> findReferralsLevel1(@Param("userId") Integer userId, Pageable pageable);
/** /**
* Finds referrals for level 2 (where referer_id_2 = userId). * 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.
* Ordered by commission DESC, then id DESC.
*/ */
@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 " + "FROM UserD ud " +
"WHERE ud.refererId2 = :userId AND ud.refererId2 > 0 " + "WHERE ud.refererId2 = :userId AND ud.refererId2 > 0 " +
"ORDER BY ud.toReferer2 DESC, ud.id DESC") "ORDER BY ud.toReferer2 DESC, ud.id DESC")
Page<ReferralDto> findReferralsLevel2(@Param("userId") Integer userId, Pageable pageable); Page<ReferralProjection> findReferralsLevel2(@Param("userId") Integer userId, Pageable pageable);
/** /**
* Finds referrals for level 3 (where referer_id_3 = userId). * 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.
* Ordered by commission DESC, then id DESC.
*/ */
@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 " + "FROM UserD ud " +
"WHERE ud.refererId3 = :userId AND ud.refererId3 > 0 " + "WHERE ud.refererId3 = :userId AND ud.refererId3 > 0 " +
"ORDER BY ud.toReferer3 DESC, ud.id DESC") "ORDER BY ud.toReferer3 DESC, ud.id DESC")
Page<ReferralDto> findReferralsLevel3(@Param("userId") Integer userId, Pageable pageable); Page<ReferralProjection> findReferralsLevel3(@Param("userId") Integer userId, Pageable pageable);
/** /**
* Masters: users whose id equals their master_id (and master_id > 0). * 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(); 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") @Query("SELECT d.id FROM UserD d WHERE d.id = d.masterId AND d.masterId > 0")
List<Integer> findMasterUserIds(); List<Integer> findMasterUserIds();

View File

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

View File

@@ -33,6 +33,27 @@ public class JwtUtil {
return createToken(claims, username); 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) { private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder() return Jwts.builder()
.claims(claims) .claims(claims)

View File

@@ -17,7 +17,8 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminMasterService { 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; 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.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@@ -36,7 +37,10 @@ public class AdminUserService {
private static final long TICKETS_MULTIPLIER = 1_000_000L; 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 UserARepository userARepository;
private final UserBRepository userBRepository; private final UserBRepository userBRepository;
@@ -46,6 +50,7 @@ public class AdminUserService {
private final PayoutRepository payoutRepository; private final PayoutRepository payoutRepository;
private final UserTaskClaimRepository userTaskClaimRepository; private final UserTaskClaimRepository userTaskClaimRepository;
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final SessionRepository sessionRepository;
private final EntityManager entityManager; private final EntityManager entityManager;
public Page<AdminUserDto> getUsers( public Page<AdminUserDto> getUsers(
@@ -56,16 +61,21 @@ public class AdminUserService {
String languageCode, String languageCode,
Integer dateRegFrom, Integer dateRegFrom,
Integer dateRegTo, Integer dateRegTo,
Long balanceMin, Long balanceAMin,
Long balanceMax, Long balanceAMax,
Long balanceBMin,
Long balanceBMax,
Integer referralCountMin, Integer referralCountMin,
Integer referralCountMax, Integer referralCountMax,
Integer referrerId, Integer referrerId,
Integer referralLevel, Integer referralLevel,
String ipFilter, String ipFilter,
Boolean botActive,
Integer depositCountMin,
String sortBy, String sortBy,
String sortDir, String sortDir,
boolean excludeMasters) { boolean excludeMasters,
Boolean hideSubAndBanned) {
List<Integer> masterIds = excludeMasters ? userDRepository.findMasterUserIds() : List.of(); List<Integer> masterIds = excludeMasters ? userDRepository.findMasterUserIds() : List.of();
@@ -137,20 +147,16 @@ public class AdminUserService {
predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp)); predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp));
} }
// Balance / referral filters via subqueries so DB handles pagination // Balance A / Balance B filters via subqueries
if (balanceMin != null || balanceMax != null) { if (balanceAMin != null || balanceAMax != null || balanceBMin != null || balanceBMax != null) {
Subquery<Integer> subB = query.subquery(Integer.class); Subquery<Integer> subB = query.subquery(Integer.class);
Root<UserB> br = subB.from(UserB.class); Root<UserB> br = subB.from(UserB.class);
subB.select(br.get("id")); subB.select(br.get("id"));
List<Predicate> subPreds = new ArrayList<>(); List<Predicate> subPreds = new ArrayList<>();
if (balanceMin != null) { if (balanceAMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("balanceA"), balanceAMin));
subPreds.add(cb.greaterThanOrEqualTo( if (balanceAMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("balanceA"), balanceAMax));
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMin)); if (balanceBMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("balanceB"), balanceBMin));
} if (balanceBMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("balanceB"), balanceBMax));
if (balanceMax != null) {
subPreds.add(cb.lessThanOrEqualTo(
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMax));
}
subB.where(cb.and(subPreds.toArray(new Predicate[0]))); subB.where(cb.and(subPreds.toArray(new Predicate[0])));
predicates.add(cb.in(root.get("id")).value(subB)); predicates.add(cb.in(root.get("id")).value(subB));
} }
@@ -183,10 +189,35 @@ public class AdminUserService {
predicates.add(cb.in(root.get("id")).value(subRef)); 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])); 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); boolean useJoinSort = sortBy != null && sortRequiresJoin.contains(sortBy);
List<UserA> userList; List<UserA> userList;
long totalElements; long totalElements;
@@ -194,9 +225,11 @@ public class AdminUserService {
if (useJoinSort) { if (useJoinSort) {
List<Integer> orderedIds = getOrderedUserIdsForAdminList( List<Integer> orderedIds = getOrderedUserIdsForAdminList(
search, banned, countryCode, languageCode, search, banned, countryCode, languageCode,
dateRegFrom, dateRegTo, balanceMin, balanceMax, dateRegFrom, dateRegTo, balanceAMin, balanceAMax, balanceBMin, balanceBMax,
referralCountMin, referralCountMax, referralCountMin, referralCountMax,
referrerId, referralLevel, ipFilter, referrerId, referralLevel, ipFilter,
botActive, depositCountMin,
hideSubAndBanned,
sortBy, sortDir != null ? sortDir : "desc", sortBy, sortDir != null ? sortDir : "desc",
pageable.getPageSize(), (int) pageable.getOffset(), pageable.getPageSize(), (int) pageable.getOffset(),
masterIds); masterIds);
@@ -223,6 +256,20 @@ public class AdminUserService {
Map<Integer, UserD> userDMap = userDRepository.findAllById(userIds).stream() Map<Integer, UserD> userDMap = userDRepository.findAllById(userIds).stream()
.collect(Collectors.toMap(UserD::getId, ud -> ud)); .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) // Map to DTOs (filtering is done in DB via specification subqueries)
List<AdminUserDto> pageContent = userList.stream() List<AdminUserDto> pageContent = userList.stream()
.map(userA -> { .map(userA -> {
@@ -260,6 +307,21 @@ public class AdminUserService {
BigDecimal depositTotalUsd = ticketsToUsd(userB.getDepositTotal()); BigDecimal depositTotalUsd = ticketsToUsd(userB.getDepositTotal());
BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal()); BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal());
BigDecimal profitUsd = ticketsToUsd(profit); 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() return AdminUserDto.builder()
.id(userA.getId()) .id(userA.getId())
@@ -283,6 +345,11 @@ public class AdminUserService {
.depositTotalUsd(depositTotalUsd) .depositTotalUsd(depositTotalUsd)
.withdrawTotalUsd(withdrawTotalUsd) .withdrawTotalUsd(withdrawTotalUsd)
.profitUsd(profitUsd) .profitUsd(profitUsd)
.profitPercent(profitPercent)
.botActive(userA.isBotActive())
.online(onlineIds.contains(userA.getId()))
.referrerId(referrerIdVal)
.referrerTelegramName(referrerTelegramNameVal)
.build(); .build();
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -301,13 +368,18 @@ public class AdminUserService {
String languageCode, String languageCode,
Integer dateRegFrom, Integer dateRegFrom,
Integer dateRegTo, Integer dateRegTo,
Long balanceMin, Long balanceAMin,
Long balanceMax, Long balanceAMax,
Long balanceBMin,
Long balanceBMax,
Integer referralCountMin, Integer referralCountMin,
Integer referralCountMax, Integer referralCountMax,
Integer referrerId, Integer referrerId,
Integer referralLevel, Integer referralLevel,
String ipFilter, String ipFilter,
Boolean botActive,
Integer depositCountMin,
Boolean hideSubAndBanned,
String sortBy, String sortBy,
String sortDir, String sortDir,
int limit, int limit,
@@ -316,7 +388,8 @@ public class AdminUserService {
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder(
"SELECT a.id FROM db_users_a a " + "SELECT a.id FROM db_users_a a " +
"INNER JOIN db_users_b b ON a.id = b.id " + "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<>(); List<Object> params = new ArrayList<>();
int paramIndex = 1; int paramIndex = 1;
@@ -374,14 +447,24 @@ public class AdminUserService {
params.add(dateRegTo); params.add(dateRegTo);
paramIndex++; paramIndex++;
} }
if (balanceMin != null) { if (balanceAMin != null) {
sql.append(" AND (b.balance_a + b.balance_b) >= ?"); sql.append(" AND b.balance_a >= ?");
params.add(balanceMin); params.add(balanceAMin);
paramIndex++; paramIndex++;
} }
if (balanceMax != null) { if (balanceAMax != null) {
sql.append(" AND (b.balance_a + b.balance_b) <= ?"); sql.append(" AND b.balance_a <= ?");
params.add(balanceMax); 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++; paramIndex++;
} }
if (referralCountMin != null || referralCountMax != null) { if (referralCountMin != null || referralCountMax != null) {
@@ -414,13 +497,31 @@ public class AdminUserService {
paramIndex++; 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 : "") { String orderColumn = switch (sortBy != null ? sortBy : "") {
case "balanceA" -> "b.balance_a"; case "balanceA" -> "b.balance_a";
case "balanceB" -> "b.balance_b";
case "depositTotal" -> "b.deposit_total"; case "depositTotal" -> "b.deposit_total";
case "withdrawTotal" -> "b.withdraw_total"; case "withdrawTotal" -> "b.withdraw_total";
case "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)"; case "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)";
case "profit" -> "(b.deposit_total - b.withdraw_total)"; case "profit" -> "(b.deposit_total - b.withdraw_total)";
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"; default -> "a.id";
}; };
String direction = "asc".equalsIgnoreCase(sortDir) ? " ASC" : " DESC"; String direction = "asc".equalsIgnoreCase(sortDir) ? " ASC" : " DESC";
@@ -513,41 +614,47 @@ public class AdminUserService {
long totalCommissions = userD.getFromReferals1() + userD.getFromReferals2() + long totalCommissions = userD.getFromReferals1() + userD.getFromReferals2() +
userD.getFromReferals3() + userD.getFromReferals4() + userD.getFromReferals5(); 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<>(); List<ReferralLevelDto> referralLevels = new ArrayList<>();
for (int level = 1; level <= 5; level++) { for (int level = 1; level <= 3; level++) {
int refererId = switch (level) { int refererId = switch (level) {
case 1 -> userD.getRefererId1(); case 1 -> userD.getRefererId1();
case 2 -> userD.getRefererId2(); case 2 -> userD.getRefererId2();
case 3 -> userD.getRefererId3(); case 3 -> userD.getRefererId3();
case 4 -> userD.getRefererId4();
case 5 -> userD.getRefererId5();
default -> 0; default -> 0;
}; };
int referralCount = switch (level) { int referralCount = switch (level) {
case 1 -> userD.getReferals1(); case 1 -> userD.getReferals1();
case 2 -> userD.getReferals2(); case 2 -> userD.getReferals2();
case 3 -> userD.getReferals3(); case 3 -> userD.getReferals3();
case 4 -> userD.getReferals4();
case 5 -> userD.getReferals5();
default -> 0; default -> 0;
}; };
long commissionsEarned = switch (level) { long commissionsEarned = switch (level) {
case 1 -> userD.getFromReferals1(); case 1 -> userD.getFromReferals1();
case 2 -> userD.getFromReferals2(); case 2 -> userD.getFromReferals2();
case 3 -> userD.getFromReferals3(); case 3 -> userD.getFromReferals3();
case 4 -> userD.getFromReferals4();
case 5 -> userD.getFromReferals5();
default -> 0L; default -> 0L;
}; };
long commissionsPaid = switch (level) { long commissionsPaid = switch (level) {
case 1 -> userD.getToReferer1(); case 1 -> userD.getToReferer1();
case 2 -> userD.getToReferer2(); case 2 -> userD.getToReferer2();
case 3 -> userD.getToReferer3(); case 3 -> userD.getToReferer3();
case 4 -> userD.getToReferer4();
case 5 -> userD.getToReferer5();
default -> 0L; default -> 0L;
}; };
BigDecimal depositsUsd = switch (level) {
case 1 -> refDep1;
case 2 -> refDep2;
case 3 -> refDep3;
default -> BigDecimal.ZERO;
};
referralLevels.add(ReferralLevelDto.builder() referralLevels.add(ReferralLevelDto.builder()
.level(level) .level(level)
@@ -557,6 +664,7 @@ public class AdminUserService {
.commissionsPaid(commissionsPaid) .commissionsPaid(commissionsPaid)
.commissionsEarnedUsd(ticketsToUsd(commissionsEarned)) .commissionsEarnedUsd(ticketsToUsd(commissionsEarned))
.commissionsPaidUsd(ticketsToUsd(commissionsPaid)) .commissionsPaidUsd(ticketsToUsd(commissionsPaid))
.depositsUsd(depositsUsd)
.build()); .build());
} }
@@ -564,6 +672,11 @@ public class AdminUserService {
BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal()); BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal());
BigDecimal totalCommissionsEarnedUsd = ticketsToUsd(totalCommissions); BigDecimal totalCommissionsEarnedUsd = ticketsToUsd(totalCommissions);
long ipAddressUserCount = 0L;
if (userA.getIp() != null) {
ipAddressUserCount = userARepository.countByIpEqual(userA.getIp());
}
return AdminUserDetailDto.builder() return AdminUserDetailDto.builder()
.id(userA.getId()) .id(userA.getId())
.screenName(userA.getScreenName()) .screenName(userA.getScreenName())
@@ -578,7 +691,9 @@ public class AdminUserService {
.dateLogin(userA.getDateLogin()) .dateLogin(userA.getDateLogin())
.banned(userA.getBanned()) .banned(userA.getBanned())
.ipAddress(IpUtils.bytesToIp(userA.getIp())) .ipAddress(IpUtils.bytesToIp(userA.getIp()))
.ipAddressUserCount(ipAddressUserCount)
.balanceA(userB.getBalanceA()) .balanceA(userB.getBalanceA())
.balanceB(userB.getBalanceB())
.depositTotal(userB.getDepositTotal()) .depositTotal(userB.getDepositTotal())
.depositCount(userB.getDepositCount()) .depositCount(userB.getDepositCount())
.withdrawTotal(userB.getWithdrawTotal()) .withdrawTotal(userB.getWithdrawTotal())
@@ -589,6 +704,7 @@ public class AdminUserService {
.referralCount(totalReferrals) .referralCount(totalReferrals)
.totalCommissionsEarned(totalCommissions) .totalCommissionsEarned(totalCommissions)
.totalCommissionsEarnedUsd(totalCommissionsEarnedUsd) .totalCommissionsEarnedUsd(totalCommissionsEarnedUsd)
.totalReferralDepositsUsd(totalReferralDepositsUsd)
.masterId(userD.getMasterId() > 0 ? userD.getMasterId() : null) .masterId(userD.getMasterId() > 0 ? userD.getMasterId() : null)
.referralLevels(referralLevels) .referralLevels(referralLevels)
.build(); .build();
@@ -596,7 +712,20 @@ public class AdminUserService {
private static BigDecimal ticketsToUsd(long ticketsBigint) { private static BigDecimal ticketsToUsd(long ticketsBigint) {
if (ticketsBigint == 0) return BigDecimal.ZERO; 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) { public Page<AdminTransactionDto> getUserTransactions(Integer userId, Pageable pageable) {

View File

@@ -42,6 +42,7 @@ public class NotificationBroadcastService {
private final UserARepository userARepository; private final UserARepository userARepository;
private final NotificationAuditRepository notificationAuditRepository; private final NotificationAuditRepository notificationAuditRepository;
private final FeatureSwitchService featureSwitchService; private final FeatureSwitchService featureSwitchService;
private final UserService userService;
private final AtomicBoolean stopRequested = new AtomicBoolean(false); 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. * 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. * 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. * 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 @Async
public void runBroadcast(String message, String imageUrl, String videoUrl, public void runBroadcast(String message, String imageUrl, String videoUrl,
@@ -84,23 +85,16 @@ public class NotificationBroadcastService {
boolean hasNext = true; boolean hasNext = true;
long sent = 0; long sent = 0;
long failed = 0; long failed = 0;
long skippedBlocked = 0;
while (hasNext && !stopRequested.get()) { while (hasNext && !stopRequested.get()) {
Pageable pageable = PageRequest.of(pageNumber, PAGE_SIZE); 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()) { for (UserA user : page.getContent()) {
if (stopRequested.get()) break; if (stopRequested.get()) break;
if (user.getTelegramId() == null) continue; 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); TelegramSendResult result = sendOne(botToken, user.getTelegramId(), message, imageUrl, videoUrl, buttonText);
int statusCode = result.getStatusCode(); int statusCode = result.getStatusCode();
boolean success = result.isSuccess(); boolean success = result.isSuccess();
@@ -114,6 +108,10 @@ public class NotificationBroadcastService {
.build(); .build();
notificationAuditRepository.save(audit); notificationAuditRepository.save(audit);
if (!success && statusCode == 403) {
userService.setBotActiveByUserId(user.getId(), false);
}
try { try {
Thread.sleep(DELAY_MS_BETWEEN_SENDS); Thread.sleep(DELAY_MS_BETWEEN_SENDS);
} catch (InterruptedException e) { } catch (InterruptedException e) {
@@ -127,9 +125,9 @@ public class NotificationBroadcastService {
} }
if (stopRequested.get()) { 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 { } else {
log.info("Notification broadcast finished. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked); log.info("Notification broadcast finished. Sent={}, Failed={}", sent, failed);
} }
} }

View File

@@ -402,6 +402,7 @@ public class PaymentService {
.playBalance(playBalance) .playBalance(playBalance)
.status(Payment.PaymentStatus.COMPLETED) .status(Payment.PaymentStatus.COMPLETED)
.completedAt(now) .completedAt(now)
.ftd(firstDeposit)
.build(); .build();
paymentRepository.save(payment); paymentRepository.save(payment);

View File

@@ -1,6 +1,7 @@
package com.honey.honey.service; package com.honey.honey.service;
import com.honey.honey.dto.ReferralDto; import com.honey.honey.dto.ReferralDto;
import com.honey.honey.dto.ReferralProjection;
import com.honey.honey.model.UserA; import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB; import com.honey.honey.model.UserB;
import com.honey.honey.model.UserD; import com.honey.honey.model.UserD;
@@ -13,13 +14,16 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Service for user management with sharded tables. * Service for user management with sharded tables.
@@ -178,6 +182,7 @@ public class UserService {
} }
userA.setIp(ipBytes); userA.setIp(ipBytes);
userA.setDateLogin((int) nowSeconds); userA.setDateLogin((int) nowSeconds);
userA.setBotActive(true);
userARepository.save(userA); userARepository.save(userA);
log.debug("Updated user data on login: userId={}", userA.getId()); log.debug("Updated user data on login: userId={}", userA.getId());
@@ -261,7 +266,7 @@ public class UserService {
userBRepository.save(userB); userBRepository.save(userB);
// Create UserD with referral handling // Create UserD with referral handling
UserD userD = createUserDWithReferral(userId, screenName, start); UserD userD = createUserDWithReferral(userId, screenName, start, (int) nowSeconds);
userDRepository.save(userD); userDRepository.save(userD);
return userA; return userA;
@@ -276,7 +281,7 @@ public class UserService {
* @param screenName User's screen name (from db_users_a) * @param screenName User's screen name (from db_users_a)
* @param start Referral parameter (from bot registration, not WebApp) * @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); log.debug("Creating UserD with referral: userId={}, start={}", userId, start);
// Defensive check: Ensure UserD doesn't already exist (should never happen, but safety check) // 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() UserD.UserDBuilder builder = UserD.builder()
.id(userId) .id(userId)
.screenName(screenName != null ? screenName : "-") .screenName(screenName != null ? screenName : "-")
.dateReg(dateReg)
.refererId1(0) .refererId1(0)
.refererId2(0) .refererId2(0)
.refererId3(0) .refererId3(0)
@@ -453,25 +459,60 @@ public class UserService {
return userARepository.findByTelegramId(telegramId); return userARepository.findByTelegramId(telegramId);
} }
private static final double COMMISSION_DISPLAY_DIVISOR = 1_000_000.0;
/** /**
* Gets referrals for a specific level with pagination. * Gets referrals for a specific level with pagination.
* Returns 10 results per page, ordered by commission DESC, then id DESC. * Fetches raw rows (ReferralProjection), maps to API DTO (ReferralDto) with display commission.
* *
* @param userId The user ID to get referrals for * @param userId The user ID to get referrals for
* @param level The referral level (1, 2, or 3) * @param level The referral level (1, 2, or 3)
* @param page Page number (0-indexed) * @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) { public Page<ReferralDto> getReferrals(Integer userId, Integer level, Integer page) {
Pageable pageable = PageRequest.of(page, 10); Pageable pageable = PageRequest.of(page, 10);
Page<ReferralProjection> projectionPage = switch (level) {
return switch (level) {
case 1 -> userDRepository.findReferralsLevel1(userId, pageable); case 1 -> userDRepository.findReferralsLevel1(userId, pageable);
case 2 -> userDRepository.findReferralsLevel2(userId, pageable); case 2 -> userDRepository.findReferralsLevel2(userId, pageable);
case 3 -> userDRepository.findReferralsLevel3(userId, pageable); case 3 -> userDRepository.findReferralsLevel3(userId, pageable);
default -> throw new IllegalArgumentException( default -> throw new IllegalArgumentException(
localizationService.getMessage("user.error.referralLevelInvalid", String.valueOf(level))); 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} 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) # JWT expiration time in milliseconds (default: 24 hours)
expiration: ${APP_ADMIN_JWT_EXPIRATION:86400000} expiration: ${APP_ADMIN_JWT_EXPIRATION:86400000}
chatwoot-integration-secret: ${CHATWOOT_INTEGRATION_SECRET:}
# GeoIP configuration # GeoIP configuration
# Set GEOIP_DB_PATH environment variable to use external file (recommended for production) # Set GEOIP_DB_PATH environment variable to use external file (recommended for production)

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