Compare commits
15 Commits
65d4de46a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd6e78664 | ||
|
|
b2415acdcf | ||
|
|
26515ab621 | ||
|
|
31768fcc07 | ||
|
|
2bf2125c3e | ||
|
|
5855678447 | ||
|
|
83c2757701 | ||
|
|
90efdf1f59 | ||
|
|
955c6d1c01 | ||
|
|
0c0bb5a5bc | ||
|
|
2779e7a1c1 | ||
|
|
284fd07bea | ||
|
|
bd260497f9 | ||
|
|
fb92993e39 | ||
|
|
7d60ebacda |
@@ -70,13 +70,14 @@ location ~ ^/dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/(a
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Admin panel: allow embedding in Chatwoot iframe (frame-ancestors); do not set X-Frame-Options here so CSP applies
|
||||
location /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/ {
|
||||
alias /opt/app/admin-panel/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/index.html;
|
||||
expires 0;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' https://honey-support.online;" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
|
||||
@@ -103,14 +103,14 @@ public class AdminSecurityConfig {
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/admin/login").permitAll()
|
||||
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "PAYOUT_SUPPORT", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/login", "/api/admin/chatwoot-session").permitAll()
|
||||
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "SUPPORT")
|
||||
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "SUPPORT")
|
||||
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "SUPPORT")
|
||||
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN")
|
||||
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN")
|
||||
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN")
|
||||
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN")
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().denyAll()
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -18,12 +19,23 @@ import java.util.Map;
|
||||
public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
|
||||
|
||||
public static final String EXAMPLE_CURRENT_USER_RESPONSE = "CurrentUserResponse";
|
||||
public static final String EXAMPLE_LANGUAGE_REQUEST = "LanguageRequest";
|
||||
public static final String EXAMPLE_DEPOSIT_METHODS_RESPONSE = "DepositMethodsResponse";
|
||||
public static final String EXAMPLE_DEPOSIT_ADDRESS_REQUEST = "DepositAddressRequest";
|
||||
public static final String EXAMPLE_DEPOSIT_ADDRESS_RESPONSE = "DepositAddressResponse";
|
||||
|
||||
@Override
|
||||
public void customise(OpenAPI openAPI) {
|
||||
ensureComponents(openAPI);
|
||||
addCurrentUserResponseExample(openAPI);
|
||||
attachCurrentUserExampleToEndpoint(openAPI);
|
||||
addLanguageRequestExample(openAPI);
|
||||
attachLanguageExampleToEndpoint(openAPI);
|
||||
addDepositMethodsResponseExample(openAPI);
|
||||
attachDepositMethodsExampleToEndpoint(openAPI);
|
||||
addDepositAddressRequestExample(openAPI);
|
||||
addDepositAddressResponseExample(openAPI);
|
||||
attachDepositAddressExamplesToEndpoint(openAPI);
|
||||
}
|
||||
|
||||
private void ensureComponents(OpenAPI openAPI) {
|
||||
@@ -43,8 +55,8 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
|
||||
value.put("screenName", "The Boy");
|
||||
value.put("dateReg", 1772897273);
|
||||
value.put("ip", "165.165.165.165");
|
||||
value.put("balanceA", 100000);
|
||||
value.put("balanceB", 100000);
|
||||
value.put("balanceA", 10000);
|
||||
value.put("balanceB", 10000);
|
||||
value.put("avatarUrl", "/avatars/0/0/1/06c98267.png?v=1772897273");
|
||||
value.put("languageCode", "EN");
|
||||
value.put("paymentEnabled", true);
|
||||
@@ -74,10 +86,138 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
|
||||
MediaType json = response200.getContent().get("application/json");
|
||||
if (json == null) return;
|
||||
|
||||
// Reuse the component example so Swagger UI shows it (same instance as in components)
|
||||
Example example = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_CURRENT_USER_RESPONSE);
|
||||
if (example != null) {
|
||||
json.setExamples(Map.of("success", example));
|
||||
}
|
||||
}
|
||||
|
||||
private void addLanguageRequestExample(OpenAPI openAPI) {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("languageCode", "PL");
|
||||
Example example = new Example();
|
||||
example.setSummary("Update language request");
|
||||
example.setValue(value);
|
||||
openAPI.getComponents().getExamples().put(EXAMPLE_LANGUAGE_REQUEST, example);
|
||||
}
|
||||
|
||||
private void attachLanguageExampleToEndpoint(OpenAPI openAPI) {
|
||||
var paths = openAPI.getPaths();
|
||||
if (paths == null) return;
|
||||
var pathItem = paths.get("/api/users/language");
|
||||
if (pathItem == null || pathItem.getPut() == null) return;
|
||||
var requestBody = pathItem.getPut().getRequestBody();
|
||||
if (requestBody == null || requestBody.getContent() == null) return;
|
||||
MediaType json = requestBody.getContent().get("application/json");
|
||||
if (json == null) return;
|
||||
Example example = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_LANGUAGE_REQUEST);
|
||||
if (example != null) {
|
||||
json.setExamples(Map.of("PL", example));
|
||||
}
|
||||
}
|
||||
|
||||
private void addDepositMethodsResponseExample(OpenAPI openAPI) {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("minimumDeposit", 2.50);
|
||||
value.put("activeMethods", List.of(
|
||||
Map.of(
|
||||
"pid", 235,
|
||||
"name", "TON",
|
||||
"network", "TON",
|
||||
"example", "UQAm3JwwV_wMgmJ05AzHqtlHAdkyJt58N-JHV2Uhf80hOEKD",
|
||||
"minDepositSum", 2.50
|
||||
),
|
||||
Map.of(
|
||||
"pid", 90,
|
||||
"name", "TRON",
|
||||
"network", "TRC20",
|
||||
"example", "TMQqX43PAMZPXPxX6Qj1fCyeiMWLgW35yF",
|
||||
"minDepositSum", 2.50
|
||||
),
|
||||
Map.of(
|
||||
"pid", 10,
|
||||
"name", "Bitcoin",
|
||||
"network", "BTC",
|
||||
"example", "131qX9kauDpCGyn2MfAFwHcrrVk7JTLAYj",
|
||||
"minDepositSum", 2.50
|
||||
)
|
||||
));
|
||||
Example example = new Example();
|
||||
example.setSummary("Deposit methods response");
|
||||
example.setValue(value);
|
||||
openAPI.getComponents().getExamples().put(EXAMPLE_DEPOSIT_METHODS_RESPONSE, example);
|
||||
}
|
||||
|
||||
private void attachDepositMethodsExampleToEndpoint(OpenAPI openAPI) {
|
||||
var paths = openAPI.getPaths();
|
||||
if (paths == null) return;
|
||||
var pathItem = paths.get("/api/payments/deposit-methods");
|
||||
if (pathItem == null || pathItem.getGet() == null) return;
|
||||
var responses = pathItem.getGet().getResponses();
|
||||
if (responses == null) return;
|
||||
var response200 = responses.get("200");
|
||||
if (response200 == null || response200.getContent() == null) return;
|
||||
MediaType json = response200.getContent().get("application/json");
|
||||
if (json == null) return;
|
||||
Example example = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_DEPOSIT_METHODS_RESPONSE);
|
||||
if (example != null) {
|
||||
json.setExamples(Map.of("success", example));
|
||||
}
|
||||
}
|
||||
|
||||
private void addDepositAddressRequestExample(OpenAPI openAPI) {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("pid", 235);
|
||||
value.put("usdAmount", 3);
|
||||
Example example = new Example();
|
||||
example.setSummary("Deposit address request");
|
||||
example.setValue(value);
|
||||
openAPI.getComponents().getExamples().put(EXAMPLE_DEPOSIT_ADDRESS_REQUEST, example);
|
||||
}
|
||||
|
||||
private void addDepositAddressResponseExample(OpenAPI openAPI) {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("address", "UQAY95OcdK3Cu-SEflKsaeN7y463BASTmnY9OUdRLJK-FDxc");
|
||||
value.put("amountCoins", "2.234549");
|
||||
value.put("name", "TON");
|
||||
value.put("network", "-");
|
||||
value.put("psId", 235);
|
||||
value.put("minAmount", 2.5);
|
||||
Example example = new Example();
|
||||
example.setSummary("Deposit address response");
|
||||
example.setValue(value);
|
||||
openAPI.getComponents().getExamples().put(EXAMPLE_DEPOSIT_ADDRESS_RESPONSE, example);
|
||||
}
|
||||
|
||||
private void attachDepositAddressExamplesToEndpoint(OpenAPI openAPI) {
|
||||
var paths = openAPI.getPaths();
|
||||
if (paths == null) return;
|
||||
var pathItem = paths.get("/api/payments/deposit-address");
|
||||
if (pathItem == null || pathItem.getPost() == null) return;
|
||||
|
||||
var requestBody = pathItem.getPost().getRequestBody();
|
||||
if (requestBody != null && requestBody.getContent() != null) {
|
||||
MediaType reqJson = requestBody.getContent().get("application/json");
|
||||
if (reqJson != null) {
|
||||
Example reqExample = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_DEPOSIT_ADDRESS_REQUEST);
|
||||
if (reqExample != null) {
|
||||
reqJson.setExamples(Map.of("example", reqExample));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var responses = pathItem.getPost().getResponses();
|
||||
if (responses != null) {
|
||||
var response200 = responses.get("200");
|
||||
if (response200 != null && response200.getContent() != null) {
|
||||
MediaType resJson = response200.getContent().get("application/json");
|
||||
if (resJson != null) {
|
||||
Example resExample = (Example) openAPI.getComponents().getExamples().get(EXAMPLE_DEPOSIT_ADDRESS_RESPONSE);
|
||||
if (resExample != null) {
|
||||
resJson.setExamples(Map.of("success", resExample));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.honey.honey.model.Payout;
|
||||
import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.PayoutRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import com.honey.honey.repository.UserDRepository;
|
||||
import com.honey.honey.service.AdminStatisticsTableService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -13,7 +15,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@@ -26,102 +30,116 @@ import java.util.Map;
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminAnalyticsController {
|
||||
|
||||
private static final BigDecimal ZERO = BigDecimal.ZERO;
|
||||
|
||||
private final UserARepository userARepository;
|
||||
private final UserDRepository userDRepository;
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final PayoutRepository payoutRepository;
|
||||
private final AdminStatisticsTableService adminStatisticsTableService;
|
||||
|
||||
/**
|
||||
* Get revenue and payout time series data for charts.
|
||||
* @param range Time range: 7d, 30d, 90d, 1y, all
|
||||
* @return Time series data with daily/weekly/monthly aggregation
|
||||
* Paginated daily / monthly statistics table (admin).
|
||||
*/
|
||||
@GetMapping("/revenue")
|
||||
public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
|
||||
@RequestParam(defaultValue = "30d") String range) {
|
||||
@GetMapping("/statistics-table")
|
||||
public ResponseEntity<Map<String, Object>> getStatisticsTable(
|
||||
@RequestParam String mode,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size,
|
||||
@RequestParam(defaultValue = "desc") String sortDir) {
|
||||
|
||||
Instant now = Instant.now();
|
||||
Instant startDate;
|
||||
String granularity;
|
||||
|
||||
// Determine start date and granularity based on range
|
||||
switch (range.toLowerCase()) {
|
||||
case "7d":
|
||||
startDate = now.minus(7, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
break;
|
||||
case "30d":
|
||||
startDate = now.minus(30, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
break;
|
||||
case "90d":
|
||||
startDate = now.minus(90, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
break;
|
||||
case "1y":
|
||||
startDate = now.minus(365, ChronoUnit.DAYS);
|
||||
granularity = "weekly";
|
||||
break;
|
||||
case "all":
|
||||
startDate = Instant.ofEpochSecond(0); // All time
|
||||
granularity = "monthly";
|
||||
break;
|
||||
default:
|
||||
startDate = now.minus(30, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
AdminStatisticsTableService.TableMode tableMode;
|
||||
try {
|
||||
tableMode = AdminStatisticsTableService.TableMode.valueOf(mode.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
List<Map<String, Object>> dataPoints = new ArrayList<>();
|
||||
Instant current = startDate;
|
||||
|
||||
while (current.isBefore(now)) {
|
||||
Instant periodEnd;
|
||||
if (granularity.equals("daily")) {
|
||||
periodEnd = current.plus(1, ChronoUnit.DAYS);
|
||||
} else if (granularity.equals("weekly")) {
|
||||
periodEnd = current.plus(7, ChronoUnit.DAYS);
|
||||
} else {
|
||||
periodEnd = current.plus(30, ChronoUnit.DAYS);
|
||||
String dir = sortDir != null ? sortDir.trim().toLowerCase() : "desc";
|
||||
if (!dir.equals("asc") && !dir.equals("desc")) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
boolean sortAscending = dir.equals("asc");
|
||||
|
||||
if (periodEnd.isAfter(now)) {
|
||||
periodEnd = now;
|
||||
}
|
||||
int cappedSize = Math.min(200, Math.max(1, size));
|
||||
int safePage = Math.max(0, page);
|
||||
|
||||
// CRYPTO only: revenue and payouts in USD for this period
|
||||
java.math.BigDecimal revenueUsd = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
|
||||
Payment.PaymentStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
|
||||
java.math.BigDecimal payoutsUsd = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
|
||||
Payout.PayoutStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
|
||||
java.math.BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
|
||||
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.getEpochSecond());
|
||||
point.put("revenue", revenueUsd);
|
||||
point.put("payouts", payoutsUsd);
|
||||
point.put("netRevenue", netRevenueUsd);
|
||||
|
||||
dataPoints.add(point);
|
||||
|
||||
current = periodEnd;
|
||||
}
|
||||
var dtoPage = adminStatisticsTableService.getStatisticsTable(tableMode, safePage, cappedSize, sortAscending);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("range", range);
|
||||
response.put("granularity", granularity);
|
||||
response.put("data", dataPoints);
|
||||
response.put("content", dtoPage.getContent());
|
||||
response.put("totalElements", dtoPage.getTotalElements());
|
||||
response.put("totalPages", dtoPage.getTotalPages());
|
||||
response.put("currentPage", dtoPage.getNumber());
|
||||
response.put("size", dtoPage.getSize());
|
||||
response.put("hasNext", dtoPage.hasNext());
|
||||
response.put("hasPrevious", dtoPage.hasPrevious());
|
||||
response.put("mode", tableMode.name());
|
||||
response.put("sortDir", dir);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user activity time series data (registrations, active players, rounds).
|
||||
* @param range Time range: 7d, 30d, 90d, 1y, all
|
||||
* @return Time series data
|
||||
* Bounds for admin statistics-by-day UI: UTC day start of the earliest {@code db_users_d.date_reg}
|
||||
* and exclusive end of the latest 30-day window (start of tomorrow UTC).
|
||||
*/
|
||||
@GetMapping("/activity")
|
||||
public ResponseEntity<Map<String, Object>> getActivityAnalytics(
|
||||
@GetMapping("/meta")
|
||||
public ResponseEntity<Map<String, Object>> getAnalyticsMeta() {
|
||||
Instant latestWindowEndExclusive = Instant.now()
|
||||
.atZone(ZoneOffset.UTC)
|
||||
.toLocalDate()
|
||||
.plusDays(1)
|
||||
.atStartOfDay(ZoneOffset.UTC)
|
||||
.toInstant();
|
||||
|
||||
long firstProjectDayStartEpoch = latestWindowEndExclusive.getEpochSecond() - 30L * 24 * 3600;
|
||||
var regOpt = userDRepository.findMinDateReg();
|
||||
if (regOpt.isPresent() && regOpt.get() != null && regOpt.get() > 0) {
|
||||
Instant reg = Instant.ofEpochSecond(regOpt.get());
|
||||
firstProjectDayStartEpoch = reg.atZone(ZoneOffset.UTC)
|
||||
.toLocalDate()
|
||||
.atStartOfDay(ZoneOffset.UTC)
|
||||
.toEpochSecond();
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("latestWindowEndExclusiveEpoch", latestWindowEndExclusive.getEpochSecond());
|
||||
response.put("firstProjectDayStartEpoch", firstProjectDayStartEpoch);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revenue / deposits and payouts time series.
|
||||
* Optional {@code from} + {@code toExclusive} (epoch seconds, UTC instants): daily buckets in [from, toExclusive).
|
||||
* Otherwise {@code range}: 7d, 30d, 90d, 1y, all (legacy).
|
||||
*/
|
||||
@GetMapping("/revenue")
|
||||
public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
|
||||
@RequestParam(required = false) Long from,
|
||||
@RequestParam(required = false) Long toExclusive,
|
||||
@RequestParam(defaultValue = "30d") String range) {
|
||||
|
||||
if (from != null && toExclusive != null) {
|
||||
Instant start = Instant.ofEpochSecond(from);
|
||||
Instant end = Instant.ofEpochSecond(toExclusive);
|
||||
if (!end.isAfter(start)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
long maxSpanSeconds = 366L * 24 * 3600;
|
||||
if (end.getEpochSecond() - start.getEpochSecond() > maxSpanSeconds) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
List<Map<String, Object>> dataPoints = buildRevenueDailySeries(start, end);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("range", "custom");
|
||||
response.put("granularity", "daily");
|
||||
response.put("from", from);
|
||||
response.put("toExclusive", toExclusive);
|
||||
response.put("data", dataPoints);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
Instant now = Instant.now();
|
||||
Instant startDate;
|
||||
String granularity;
|
||||
@@ -169,24 +187,7 @@ public class AdminAnalyticsController {
|
||||
periodEnd = now;
|
||||
}
|
||||
|
||||
// Convert to Unix timestamps for UserA queries
|
||||
int periodStartTs = (int) current.getEpochSecond();
|
||||
int periodEndTs = (int) periodEnd.getEpochSecond();
|
||||
|
||||
// Count new registrations in this period (between current and periodEnd)
|
||||
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
|
||||
|
||||
// Count active players (logged in) in this period
|
||||
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
|
||||
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.getEpochSecond());
|
||||
point.put("newUsers", newUsers);
|
||||
point.put("activePlayers", activePlayers);
|
||||
point.put("rounds", 0L);
|
||||
|
||||
dataPoints.add(point);
|
||||
|
||||
dataPoints.add(buildRevenuePoint(current, periodEnd));
|
||||
current = periodEnd;
|
||||
}
|
||||
|
||||
@@ -197,5 +198,156 @@ public class AdminAnalyticsController {
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User activity time series.
|
||||
* Optional {@code from} + {@code toExclusive} for daily buckets in [from, toExclusive).
|
||||
*/
|
||||
@GetMapping("/activity")
|
||||
public ResponseEntity<Map<String, Object>> getActivityAnalytics(
|
||||
@RequestParam(required = false) Long from,
|
||||
@RequestParam(required = false) Long toExclusive,
|
||||
@RequestParam(defaultValue = "30d") String range) {
|
||||
|
||||
if (from != null && toExclusive != null) {
|
||||
Instant start = Instant.ofEpochSecond(from);
|
||||
Instant end = Instant.ofEpochSecond(toExclusive);
|
||||
if (!end.isAfter(start)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
long maxSpanSeconds = 366L * 24 * 3600;
|
||||
if (end.getEpochSecond() - start.getEpochSecond() > maxSpanSeconds) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
List<Map<String, Object>> dataPoints = buildActivityDailySeries(start, end);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("range", "custom");
|
||||
response.put("granularity", "daily");
|
||||
response.put("from", from);
|
||||
response.put("toExclusive", toExclusive);
|
||||
response.put("data", dataPoints);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
Instant now = Instant.now();
|
||||
Instant startDate;
|
||||
String granularity;
|
||||
|
||||
switch (range.toLowerCase()) {
|
||||
case "7d":
|
||||
startDate = now.minus(7, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
break;
|
||||
case "30d":
|
||||
startDate = now.minus(30, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
break;
|
||||
case "90d":
|
||||
startDate = now.minus(90, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
break;
|
||||
case "1y":
|
||||
startDate = now.minus(365, ChronoUnit.DAYS);
|
||||
granularity = "weekly";
|
||||
break;
|
||||
case "all":
|
||||
startDate = Instant.ofEpochSecond(0);
|
||||
granularity = "monthly";
|
||||
break;
|
||||
default:
|
||||
startDate = now.minus(30, ChronoUnit.DAYS);
|
||||
granularity = "daily";
|
||||
}
|
||||
|
||||
List<Map<String, Object>> dataPoints = new ArrayList<>();
|
||||
Instant current = startDate;
|
||||
|
||||
while (current.isBefore(now)) {
|
||||
Instant periodEnd;
|
||||
if (granularity.equals("daily")) {
|
||||
periodEnd = current.plus(1, ChronoUnit.DAYS);
|
||||
} else if (granularity.equals("weekly")) {
|
||||
periodEnd = current.plus(7, ChronoUnit.DAYS);
|
||||
} else {
|
||||
periodEnd = current.plus(30, ChronoUnit.DAYS);
|
||||
}
|
||||
|
||||
if (periodEnd.isAfter(now)) {
|
||||
periodEnd = now;
|
||||
}
|
||||
|
||||
dataPoints.add(buildActivityPoint(current, periodEnd));
|
||||
current = periodEnd;
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("range", range);
|
||||
response.put("granularity", granularity);
|
||||
response.put("data", dataPoints);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildActivityDailySeries(Instant startInclusive, Instant endExclusive) {
|
||||
List<Map<String, Object>> dataPoints = new ArrayList<>();
|
||||
Instant current = startInclusive;
|
||||
while (current.isBefore(endExclusive)) {
|
||||
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
|
||||
if (periodEnd.isAfter(endExclusive)) {
|
||||
periodEnd = endExclusive;
|
||||
}
|
||||
dataPoints.add(buildActivityPoint(current, periodEnd));
|
||||
current = periodEnd;
|
||||
}
|
||||
return dataPoints;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildActivityPoint(Instant current, Instant periodEnd) {
|
||||
int periodStartTs = (int) current.getEpochSecond();
|
||||
int periodEndTs = (int) periodEnd.getEpochSecond();
|
||||
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
|
||||
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.getEpochSecond());
|
||||
point.put("newUsers", newUsers);
|
||||
point.put("activePlayers", activePlayers);
|
||||
point.put("rounds", 0L);
|
||||
return point;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildRevenueDailySeries(Instant startInclusive, Instant endExclusive) {
|
||||
List<Map<String, Object>> dataPoints = new ArrayList<>();
|
||||
Instant current = startInclusive;
|
||||
while (current.isBefore(endExclusive)) {
|
||||
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
|
||||
if (periodEnd.isAfter(endExclusive)) {
|
||||
periodEnd = endExclusive;
|
||||
}
|
||||
dataPoints.add(buildRevenuePoint(current, periodEnd));
|
||||
current = periodEnd;
|
||||
}
|
||||
return dataPoints;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildRevenuePoint(Instant current, Instant periodEnd) {
|
||||
BigDecimal revenueUsd = paymentRepository
|
||||
.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
|
||||
Payment.PaymentStatus.COMPLETED, current, periodEnd)
|
||||
.orElse(ZERO);
|
||||
BigDecimal payoutsUsd = payoutRepository
|
||||
.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
|
||||
Payout.PayoutStatus.COMPLETED, current, periodEnd)
|
||||
.orElse(ZERO);
|
||||
BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
|
||||
long firstTimeDeposits = paymentRepository.countByStatusAndFtdTrueAndUsdNotNullAndCompletedAtBetween(
|
||||
Payment.PaymentStatus.COMPLETED, current, periodEnd);
|
||||
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.getEpochSecond());
|
||||
point.put("revenue", revenueUsd);
|
||||
point.put("payouts", payoutsUsd);
|
||||
point.put("netRevenue", netRevenueUsd);
|
||||
point.put("firstTimeDeposits", firstTimeDeposits);
|
||||
return point;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.model.Payment;
|
||||
import com.honey.honey.model.Payout;
|
||||
import com.honey.honey.model.SupportTicket;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.PayoutRepository;
|
||||
import com.honey.honey.repository.SupportTicketRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -30,7 +28,6 @@ public class AdminDashboardController {
|
||||
private final UserARepository userARepository;
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final PayoutRepository payoutRepository;
|
||||
private final SupportTicketRepository supportTicketRepository;
|
||||
|
||||
@GetMapping("/stats")
|
||||
public ResponseEntity<Map<String, Object>> getDashboardStats() {
|
||||
@@ -103,15 +100,6 @@ public class AdminDashboardController {
|
||||
BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek);
|
||||
BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth);
|
||||
|
||||
// Support Tickets
|
||||
long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED);
|
||||
// Count tickets closed today
|
||||
long ticketsResolvedToday = supportTicketRepository.findAll().stream()
|
||||
.filter(t -> t.getStatus() == SupportTicket.TicketStatus.CLOSED &&
|
||||
t.getUpdatedAt() != null &&
|
||||
t.getUpdatedAt().isAfter(todayStart))
|
||||
.count();
|
||||
|
||||
// Build response
|
||||
stats.put("users", Map.of(
|
||||
"total", totalUsers,
|
||||
@@ -162,19 +150,6 @@ public class AdminDashboardController {
|
||||
crypto.put("profitUsdMonth", cryptoNetRevenueMonth);
|
||||
stats.put("crypto", crypto);
|
||||
|
||||
stats.put("rounds", Map.of(
|
||||
"total", 0L,
|
||||
"today", 0L,
|
||||
"week", 0L,
|
||||
"month", 0L,
|
||||
"avgPool", 0
|
||||
));
|
||||
|
||||
stats.put("supportTickets", Map.of(
|
||||
"open", openTickets,
|
||||
"resolvedToday", ticketsResolvedToday
|
||||
));
|
||||
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.AdminLoginRequest;
|
||||
import com.honey.honey.dto.AdminLoginResponse;
|
||||
import com.honey.honey.dto.ChatwootSessionRequest;
|
||||
import com.honey.honey.security.admin.JwtUtil;
|
||||
import com.honey.honey.service.AdminService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -19,6 +22,11 @@ import java.util.Optional;
|
||||
public class AdminLoginController {
|
||||
|
||||
private final AdminService adminService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/** Shared secret with Chatwoot. Set via env CHATWOOT_INTEGRATION_SECRET (e.g. from VPS secret file). */
|
||||
@Value("${CHATWOOT_INTEGRATION_SECRET:}")
|
||||
private String chatwootIntegrationSecret;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) {
|
||||
@@ -47,5 +55,30 @@ public class AdminLoginController {
|
||||
role
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a Chatwoot integration API key for an admin JWT with ROLE_SUPPORT.
|
||||
* Used when the admin panel is embedded in Chatwoot as a Dashboard App iframe.
|
||||
* API key must match CHATWOOT_INTEGRATION_SECRET on the server.
|
||||
*/
|
||||
@PostMapping("/chatwoot-session")
|
||||
public ResponseEntity<?> chatwootSession(@RequestBody ChatwootSessionRequest request) {
|
||||
if (request.getApiKey() == null || request.getApiKey().isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("apiKey is required");
|
||||
}
|
||||
if (chatwootIntegrationSecret == null || chatwootIntegrationSecret.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Chatwoot integration is not configured (CHATWOOT_INTEGRATION_SECRET)");
|
||||
}
|
||||
if (!chatwootIntegrationSecret.equals(request.getApiKey().trim())) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid apiKey");
|
||||
}
|
||||
String token = jwtUtil.generateTokenWithRole("__chatwoot__", "ROLE_SUPPORT");
|
||||
return ResponseEntity.ok(new AdminLoginResponse(
|
||||
token,
|
||||
"__chatwoot__",
|
||||
"ROLE_SUPPORT"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import com.honey.honey.repository.UserDRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -16,6 +14,8 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -31,18 +31,18 @@ import java.util.stream.Collectors;
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/payments")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public class AdminPaymentController {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final UserARepository userARepository;
|
||||
private final UserDRepository userDRepository;
|
||||
|
||||
private boolean isGameAdmin() {
|
||||
private static boolean isSupport() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getAuthorities() == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
|
||||
.anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -70,7 +70,7 @@ public class AdminPaymentController {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
|
||||
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
|
||||
List<Integer> masterIds = isSupport() ? userDRepository.findMasterUserIds() : List.of();
|
||||
|
||||
// Build specification
|
||||
Specification<Payment> spec = (root, query, cb) -> {
|
||||
@@ -124,6 +124,7 @@ public class AdminPaymentController {
|
||||
.orderId(payment.getOrderId())
|
||||
.starsAmount(payment.getStarsAmount())
|
||||
.ticketsAmount(payment.getTicketsAmount())
|
||||
.usdAmount(payment.getUsdAmount())
|
||||
.status(payment.getStatus().name())
|
||||
.telegramPaymentChargeId(payment.getTelegramPaymentChargeId())
|
||||
.telegramProviderPaymentChargeId(payment.getTelegramProviderPaymentChargeId())
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.util.stream.Collectors;
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/payouts")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public class AdminPayoutController {
|
||||
|
||||
private final PayoutRepository payoutRepository;
|
||||
@@ -45,11 +45,11 @@ public class AdminPayoutController {
|
||||
private final PayoutService payoutService;
|
||||
private final LocalizationService localizationService;
|
||||
|
||||
private boolean isGameAdmin() {
|
||||
private static boolean isSupport() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getAuthorities() == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
|
||||
.anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -74,7 +74,7 @@ public class AdminPayoutController {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
|
||||
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
|
||||
List<Integer> masterIds = isSupport() ? userDRepository.findMasterUserIds() : List.of();
|
||||
|
||||
// Build specification
|
||||
Specification<Payout> spec = (root, query, cb) -> {
|
||||
@@ -135,6 +135,7 @@ public class AdminPayoutController {
|
||||
.type(payout.getType().name())
|
||||
.giftName(payout.getGiftName() != null ? payout.getGiftName().name() : null)
|
||||
.total(payout.getTotal())
|
||||
.usdAmount(payout.getUsdAmount())
|
||||
.starsAmount(payout.getStarsAmount())
|
||||
.quantity(payout.getQuantity())
|
||||
.status(payout.getStatus().name())
|
||||
@@ -156,7 +157,7 @@ public class AdminPayoutController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/complete")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<?> completePayout(@PathVariable Long id) {
|
||||
try {
|
||||
Optional<Payout> payoutOpt = payoutRepository.findById(id);
|
||||
@@ -179,7 +180,7 @@ public class AdminPayoutController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<?> cancelPayout(@PathVariable Long id) {
|
||||
try {
|
||||
Optional<Payout> payoutOpt = payoutRepository.findById(id);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import java.util.stream.Collectors;
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/tickets")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN')")
|
||||
public class AdminSupportTicketController {
|
||||
|
||||
private final SupportTicketRepository supportTicketRepository;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.*;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.service.AdminUserService;
|
||||
import com.honey.honey.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -23,26 +25,28 @@ import java.util.Set;
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserController {
|
||||
|
||||
private static boolean isSupport() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getAuthorities() == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_SUPPORT".equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
/** Sortable fields: UserA properties plus UserB/UserD (handled via custom query in service). */
|
||||
private static final Set<String> SORTABLE_FIELDS = Set.of(
|
||||
"id", "screenName", "telegramId", "telegramName", "isPremium",
|
||||
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
|
||||
"balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit"
|
||||
"balanceA", "balanceB", "depositTotal", "withdrawTotal", "referralCount", "profit",
|
||||
"referrerTelegramName", "profitPercent"
|
||||
);
|
||||
private static final Set<String> DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt");
|
||||
private static final Set<String> WITHDRAWAL_SORT_FIELDS = Set.of("id", "usdAmount", "cryptoName", "amountToSend", "txhash", "status", "paymentId", "createdAt", "resolvedAt");
|
||||
|
||||
private final AdminUserService adminUserService;
|
||||
|
||||
private boolean isGameAdmin() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getAuthorities() == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
|
||||
}
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<Map<String, Object>> getUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size,
|
||||
@@ -55,16 +59,23 @@ public class AdminUserController {
|
||||
@RequestParam(required = false) String languageCode,
|
||||
@RequestParam(required = false) Integer dateRegFrom,
|
||||
@RequestParam(required = false) Integer dateRegTo,
|
||||
@RequestParam(required = false) Long balanceMin,
|
||||
@RequestParam(required = false) Long balanceMax,
|
||||
@RequestParam(required = false) Long balanceAMin,
|
||||
@RequestParam(required = false) Long balanceAMax,
|
||||
@RequestParam(required = false) Long balanceBMin,
|
||||
@RequestParam(required = false) Long balanceBMax,
|
||||
@RequestParam(required = false) Integer referralCountMin,
|
||||
@RequestParam(required = false) Integer referralCountMax,
|
||||
@RequestParam(required = false) Integer referrerId,
|
||||
@RequestParam(required = false) Integer referralLevel,
|
||||
@RequestParam(required = false) String ip) {
|
||||
@RequestParam(required = false) String ip,
|
||||
@RequestParam(required = false) Boolean botActive,
|
||||
@RequestParam(required = false) Integer depositCountMin,
|
||||
@RequestParam(required = false) Boolean hideSubAndBanned) {
|
||||
|
||||
// Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, referralCount) are handled in service via custom query.
|
||||
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
|
||||
// Build sort. Fields on UserB/UserD (balanceA, balanceB, depositTotal, etc.) are handled in service via custom query.
|
||||
Set<String> sortRequiresJoin = Set.of(
|
||||
"balanceA", "balanceB", "depositTotal", "withdrawTotal", "referralCount", "profit",
|
||||
"referrerTelegramName", "profitPercent");
|
||||
String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null);
|
||||
if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) {
|
||||
// Pass through; service will use custom ordered query
|
||||
@@ -78,11 +89,13 @@ public class AdminUserController {
|
||||
}
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
|
||||
// Convert balance filters from tickets (divide by 1000000) to bigint format
|
||||
Long balanceMinBigint = balanceMin != null ? balanceMin * 1000000L : null;
|
||||
Long balanceMaxBigint = balanceMax != null ? balanceMax * 1000000L : null;
|
||||
// Convert balance filters from display value to bigint format
|
||||
Long balanceAMinBigint = balanceAMin != null ? balanceAMin * 1000000L : null;
|
||||
Long balanceAMaxBigint = balanceAMax != null ? balanceAMax * 1000000L : null;
|
||||
Long balanceBMinBigint = balanceBMin != null ? balanceBMin * 1000000L : null;
|
||||
Long balanceBMaxBigint = balanceBMax != null ? balanceBMax * 1000000L : null;
|
||||
|
||||
boolean excludeMasters = isGameAdmin();
|
||||
boolean excludeMasters = isSupport();
|
||||
Page<AdminUserDto> dtoPage = adminUserService.getUsers(
|
||||
pageable,
|
||||
search,
|
||||
@@ -91,16 +104,21 @@ public class AdminUserController {
|
||||
languageCode,
|
||||
dateRegFrom,
|
||||
dateRegTo,
|
||||
balanceMinBigint,
|
||||
balanceMaxBigint,
|
||||
balanceAMinBigint,
|
||||
balanceAMaxBigint,
|
||||
balanceBMinBigint,
|
||||
balanceBMaxBigint,
|
||||
referralCountMin,
|
||||
referralCountMax,
|
||||
referrerId,
|
||||
referralLevel,
|
||||
ip,
|
||||
botActive,
|
||||
depositCountMin,
|
||||
effectiveSortBy,
|
||||
sortDir,
|
||||
excludeMasters
|
||||
excludeMasters,
|
||||
Boolean.TRUE.equals(hideSubAndBanned)
|
||||
);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
@@ -115,10 +133,26 @@ public class AdminUserController {
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Honey user ID from Telegram user ID.
|
||||
* Used by the Chatwoot embed when the contact has no honey_user_id set:
|
||||
* Chatwoot (Telegram channel) often uses Telegram user ID as contact identifier.
|
||||
*/
|
||||
@GetMapping("/by-telegram-id")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<Map<String, Integer>> getUserByTelegramId(@RequestParam("telegram_id") Long telegramId) {
|
||||
if (telegramId == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return userService.getUserByTelegramId(telegramId)
|
||||
.map(user -> ResponseEntity.ok(Map.of("id", user.getId())))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<AdminUserDetailDto> getUserDetail(@PathVariable Integer id) {
|
||||
AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isGameAdmin());
|
||||
AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isSupport());
|
||||
if (userDetail == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
@@ -126,7 +160,7 @@ public class AdminUserController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/transactions")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<Map<String, Object>> getUserTransactions(
|
||||
@PathVariable Integer id,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@@ -148,7 +182,7 @@ public class AdminUserController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/payments")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<Map<String, Object>> getUserPayments(
|
||||
@PathVariable Integer id,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@@ -171,7 +205,7 @@ public class AdminUserController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/payouts")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<Map<String, Object>> getUserPayouts(
|
||||
@PathVariable Integer id,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@@ -194,14 +228,14 @@ public class AdminUserController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/tasks")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<Map<String, Object>> getUserTasks(@PathVariable Integer id) {
|
||||
Map<String, Object> tasks = adminUserService.getUserTasks(id);
|
||||
return ResponseEntity.ok(tasks);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/ban")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<?> setUserBanned(
|
||||
@PathVariable Integer id,
|
||||
@RequestBody Map<String, Boolean> body) {
|
||||
@@ -218,7 +252,7 @@ public class AdminUserController {
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/withdrawals-enabled")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
|
||||
public ResponseEntity<?> setWithdrawalsEnabled(
|
||||
@PathVariable Integer id,
|
||||
@RequestBody Map<String, Boolean> body) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.util.stream.Collectors;
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/quick-answers")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN')")
|
||||
public class QuickAnswerController {
|
||||
|
||||
private final QuickAnswerRepository quickAnswerRepository;
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.telegram.telegrambots.meta.api.objects.Update;
|
||||
import org.telegram.telegrambots.meta.api.objects.Message;
|
||||
import org.telegram.telegrambots.meta.api.objects.User;
|
||||
import org.telegram.telegrambots.meta.api.objects.ChatMemberUpdated;
|
||||
import org.telegram.telegrambots.meta.api.objects.CallbackQuery;
|
||||
import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery;
|
||||
import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment;
|
||||
@@ -81,6 +82,12 @@ public class TelegramWebhookController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
try {
|
||||
// Bot blocked / unblocked in private chat (Telegram my_chat_member)
|
||||
if (update.hasMyChatMember()) {
|
||||
handleMyChatMember(update.getMyChatMember());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
// Handle callback queries (button clicks)
|
||||
if (update.hasCallbackQuery()) {
|
||||
handleCallbackQuery(update.getCallbackQuery());
|
||||
@@ -112,6 +119,27 @@ public class TelegramWebhookController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates {@code bot_active} when the user blocks/unblocks the bot (private chats only).
|
||||
*/
|
||||
private void handleMyChatMember(ChatMemberUpdated cmu) {
|
||||
if (cmu.getChat() == null || cmu.getNewChatMember() == null) {
|
||||
return;
|
||||
}
|
||||
if (!"private".equalsIgnoreCase(cmu.getChat().getType())) {
|
||||
return;
|
||||
}
|
||||
Long telegramUserId = cmu.getChat().getId();
|
||||
String status = cmu.getNewChatMember().getStatus();
|
||||
if ("kicked".equalsIgnoreCase(status) || "left".equalsIgnoreCase(status)) {
|
||||
userService.setBotActiveByTelegramId(telegramUserId, false);
|
||||
log.info("Private chat bot status: user {} set bot_active=false (status={})", telegramUserId, status);
|
||||
} else if ("member".equalsIgnoreCase(status) || "administrator".equalsIgnoreCase(status)) {
|
||||
userService.setBotActiveByTelegramId(telegramUserId, true);
|
||||
log.info("Private chat bot status: user {} set bot_active=true (status={})", telegramUserId, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles /start command with optional referral parameter, and Reply Keyboard button clicks.
|
||||
* Format: /start or /start 123 (where 123 is the referral user ID)
|
||||
@@ -221,6 +249,7 @@ public class TelegramWebhookController {
|
||||
try {
|
||||
// Get or create user (handles registration, login update, and referral system)
|
||||
UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
|
||||
userService.setBotActiveByTelegramId(telegramId, true);
|
||||
log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}",
|
||||
user.getId(), user.getTelegramId(), isNewUser);
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
/** Divisor for converting balance bigint to display value (backend sends display value only). */
|
||||
private static final double BALANCE_DISPLAY_DIVISOR = 1_000_000.0;
|
||||
|
||||
private final UserService userService;
|
||||
private final UserBRepository userBRepository;
|
||||
private final AvatarService avatarService;
|
||||
@@ -37,10 +40,12 @@ public class UserController {
|
||||
// Convert IP from byte[] to string for display
|
||||
String ipAddress = IpUtils.bytesToIp(user.getIp());
|
||||
|
||||
// Get balances from UserB
|
||||
// Get balances from UserB; convert to display value (divide by 1_000_000)
|
||||
var userBOpt = userBRepository.findById(user.getId());
|
||||
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
|
||||
Long balanceB = userBOpt.map(UserB::getBalanceB).orElse(0L);
|
||||
long rawBalanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
|
||||
long rawBalanceB = userBOpt.map(UserB::getBalanceB).orElse(0L);
|
||||
Double balanceA = rawBalanceA / BALANCE_DISPLAY_DIVISOR;
|
||||
Double balanceB = rawBalanceB / BALANCE_DISPLAY_DIVISOR;
|
||||
Integer depositCount = userBOpt.map(UserB::getDepositCount).orElse(0);
|
||||
|
||||
// Generate avatar URL on-the-fly (deterministic from userId)
|
||||
@@ -113,7 +118,7 @@ public class UserController {
|
||||
|
||||
/**
|
||||
* Gets referrals for a specific level with pagination.
|
||||
* Always returns 50 results per page.
|
||||
* Returns 10 results per page, ordered by commission DESC, then id DESC.
|
||||
*
|
||||
* @param level The referral level (1, 2, or 3)
|
||||
* @param page Page number (0-indexed, defaults to 0)
|
||||
@@ -126,7 +131,6 @@ public class UserController {
|
||||
UserA user = UserContext.get();
|
||||
|
||||
Page<ReferralDto> referralsPage = userService.getReferrals(user.getId(), level, page);
|
||||
|
||||
return new ReferralsResponse(
|
||||
referralsPage.getContent(),
|
||||
referralsPage.getNumber(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@@ -18,6 +19,8 @@ public class AdminPaymentDto {
|
||||
private String orderId;
|
||||
private Integer starsAmount;
|
||||
private Long ticketsAmount;
|
||||
/** USD amount (e.g. 1.25). */
|
||||
private BigDecimal usdAmount;
|
||||
private String status;
|
||||
private String telegramPaymentChargeId;
|
||||
private String telegramProviderPaymentChargeId;
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@@ -19,6 +20,8 @@ public class AdminPayoutDto {
|
||||
private String type;
|
||||
private String giftName;
|
||||
private Long total;
|
||||
/** USD amount (e.g. 1.25). */
|
||||
private BigDecimal usdAmount;
|
||||
private Integer starsAmount;
|
||||
private Integer quantity;
|
||||
private String status;
|
||||
|
||||
@@ -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;
|
||||
/** 0–100, two decimals; 0 if no registered users. */
|
||||
private BigDecimal depositUserPercent;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -27,9 +27,12 @@ public class AdminUserDetailDto {
|
||||
private Integer banned;
|
||||
/** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */
|
||||
private String ipAddress;
|
||||
/** Number of users sharing the same IP (including this user). */
|
||||
private Long ipAddressUserCount;
|
||||
|
||||
// Balance Info
|
||||
private Long balanceA;
|
||||
private Long balanceB;
|
||||
private Long depositTotal;
|
||||
private Integer depositCount;
|
||||
private Long withdrawTotal;
|
||||
@@ -44,8 +47,10 @@ public class AdminUserDetailDto {
|
||||
// Referral Info
|
||||
private Integer referralCount;
|
||||
private Long totalCommissionsEarned;
|
||||
/** Total commissions earned in USD (converted from tickets). */
|
||||
/** Total commissions earned in USD (Honey: bigint / 10_000_000_000). */
|
||||
private java.math.BigDecimal totalCommissionsEarnedUsd;
|
||||
/** Sum of COMPLETED payment USD across referral levels 1–3 for this user. */
|
||||
private java.math.BigDecimal totalReferralDepositsUsd;
|
||||
private Integer masterId;
|
||||
private List<ReferralLevelDto> referralLevels;
|
||||
}
|
||||
|
||||
@@ -31,11 +31,21 @@ public class AdminUserDto {
|
||||
private Long totalCommissionsEarned; // Total commissions earned from referrals
|
||||
/** Profit in tickets (bigint): depositTotal - withdrawTotal */
|
||||
private Long profit;
|
||||
/** USD from db_users_b: depositTotal (tickets/1000) */
|
||||
/** USD from db_users_b deposit_total (Honey scale). */
|
||||
private BigDecimal depositTotalUsd;
|
||||
/** USD from db_users_b: withdrawTotal (tickets/1000) */
|
||||
/** USD from db_users_b withdraw_total (Honey scale). */
|
||||
private BigDecimal withdrawTotalUsd;
|
||||
/** USD from db_users_b: profit (tickets/1000) */
|
||||
/** USD from db_users_b: profit (Honey: bigint / 10_000_000_000). */
|
||||
private BigDecimal profitUsd;
|
||||
/** True when user has not blocked the bot. */
|
||||
private Boolean botActive;
|
||||
/** True when user has a session with created_at <= now and expires_at >= 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 > 0; otherwise null. */
|
||||
private BigDecimal profitPercent;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@@ -17,13 +16,12 @@ public class BalanceAdjustmentRequest {
|
||||
private BalanceType balanceType; // A or B
|
||||
|
||||
@NotNull(message = "Amount is required")
|
||||
private Long amount; // In bigint format (tickets * 1,000,000)
|
||||
private Long amount; // In bigint format (display value * 1,000,000)
|
||||
|
||||
@NotNull(message = "Operation is required")
|
||||
private OperationType operation; // ADD or SUBTRACT
|
||||
|
||||
@NotBlank(message = "Reason is required")
|
||||
private String reason; // Reason for adjustment (for audit log)
|
||||
private String reason; // Optional reason for adjustment (for audit log)
|
||||
|
||||
public enum BalanceType {
|
||||
A, B
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** API response DTO for referral list. Built from {@link ReferralProjection} in service. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ReferralDto {
|
||||
private String name; // screen_name from db_users_a
|
||||
private Long commission; // to_referer_1/2/3 from db_users_d (bigint, needs to be divided by 1,000,000 on frontend)
|
||||
private String name;
|
||||
private Double commission; // display value (raw from DB / 1_000_000)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,14 +12,16 @@ import java.math.BigDecimal;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ReferralLevelDto {
|
||||
private Integer level; // 1-5
|
||||
private Integer level; // 1–3 in admin UI; legacy rows may exist in DB
|
||||
private Integer refererId;
|
||||
private Integer referralCount;
|
||||
private Long commissionsEarned;
|
||||
private Long commissionsPaid;
|
||||
/** Commissions earned in USD (converted from tickets: 1000 tickets = 1 USD). */
|
||||
/** Commissions earned in USD (Honey: bigint / 10_000_000_000). */
|
||||
private BigDecimal commissionsEarnedUsd;
|
||||
/** Commissions paid in USD (converted from tickets). */
|
||||
/** Commissions paid in USD (Honey: bigint / 10_000_000_000). */
|
||||
private BigDecimal commissionsPaidUsd;
|
||||
/** Sum of COMPLETED payment USD from referrals at this level. */
|
||||
private BigDecimal depositsUsd;
|
||||
}
|
||||
|
||||
|
||||
10
src/main/java/com/honey/honey/dto/ReferralProjection.java
Normal file
10
src/main/java/com/honey/honey/dto/ReferralProjection.java
Normal 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();
|
||||
}
|
||||
@@ -16,8 +16,8 @@ public class UserDto {
|
||||
private String screenName; // User's screen name
|
||||
private Integer dateReg; // Registration date (Unix timestamp in seconds)
|
||||
private String ip;
|
||||
private Long balanceA; // Balance (stored as bigint, represents number with 6 decimal places)
|
||||
private Long balanceB; // Second balance (stored as bigint)
|
||||
private Double balanceA; // Balance for display (raw bigint / 1_000_000)
|
||||
private Double balanceB; // Second balance for display (raw bigint / 1_000_000)
|
||||
private String avatarUrl; // Public URL of user's avatar
|
||||
private String languageCode; // User's language preference (EN, RU, DE, IT, NL, PL, FR, ES, ID, TR)
|
||||
private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed
|
||||
|
||||
@@ -55,6 +55,11 @@ public class Payment {
|
||||
@Column(name = "completed_at")
|
||||
private Instant completedAt;
|
||||
|
||||
/** True when this completed payment is the user's first deposit (crypto external completion). */
|
||||
@Column(name = "ftd", nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean ftd = false;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
|
||||
@@ -64,6 +64,11 @@ public class UserA {
|
||||
|
||||
@Column(name = "last_telegram_file_id", length = 255)
|
||||
private String lastTelegramFileId;
|
||||
|
||||
/** When false, user blocked the bot or send failed with 403; used for notification broadcast skip. */
|
||||
@Column(name = "bot_active", nullable = false)
|
||||
@Builder.Default
|
||||
private boolean botActive = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ public class UserD {
|
||||
@Builder.Default
|
||||
private String screenName = "-";
|
||||
|
||||
/** Unix registration time (seconds); kept in sync with db_users_a.date_reg on signup. */
|
||||
@Column(name = "date_reg", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer dateReg = 0;
|
||||
|
||||
@Column(name = "referer_id_1", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer refererId1 = 0;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -87,5 +87,27 @@ public interface PaymentRepository extends JpaRepository<Payment, Long>, JpaSpec
|
||||
@Param("start") Instant start,
|
||||
@Param("end") Instant end
|
||||
);
|
||||
|
||||
/** Sum usd_amount for all completed payments (null usd rows are ignored by SUM). */
|
||||
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p WHERE p.status = :status")
|
||||
Optional<BigDecimal> sumUsdAmountByStatus(@Param("status") Payment.PaymentStatus status);
|
||||
|
||||
/** Count completed FTD crypto (usd) deposits completed in [start, end). */
|
||||
@Query("SELECT COUNT(p) FROM Payment p WHERE p.status = :status AND p.ftd = true AND p.usdAmount IS NOT NULL AND p.completedAt >= :start AND p.completedAt < :end")
|
||||
long countByStatusAndFtdTrueAndUsdNotNullAndCompletedAtBetween(
|
||||
@Param("status") Payment.PaymentStatus status,
|
||||
@Param("start") Instant start,
|
||||
@Param("end") Instant end
|
||||
);
|
||||
|
||||
/** Sum COMPLETED payment USD for users whose level-1 referrer is {@code userId}. */
|
||||
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p, UserD d WHERE p.userId = d.id AND p.status = 'COMPLETED' AND p.usdAmount IS NOT NULL AND d.refererId1 = :userId")
|
||||
java.math.BigDecimal sumCompletedUsdForReferralsLevel1(@Param("userId") Integer userId);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p, UserD d WHERE p.userId = d.id AND p.status = 'COMPLETED' AND p.usdAmount IS NOT NULL AND d.refererId2 = :userId")
|
||||
java.math.BigDecimal sumCompletedUsdForReferralsLevel2(@Param("userId") Integer userId);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p, UserD d WHERE p.userId = d.id AND p.status = 'COMPLETED' AND p.usdAmount IS NOT NULL AND d.refererId3 = :userId")
|
||||
java.math.BigDecimal sumCompletedUsdForReferralsLevel3(@Param("userId") Integer userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,5 +107,9 @@ public interface PayoutRepository extends JpaRepository<Payout, Long>, JpaSpecif
|
||||
@Param("start") Instant start,
|
||||
@Param("end") Instant end
|
||||
);
|
||||
|
||||
/** Sum usd_amount for all payouts with given status (null usd ignored by SUM). */
|
||||
@Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payout p WHERE p.status = :status")
|
||||
Optional<BigDecimal> sumUsdAmountByStatus(@Param("status") Payout.PayoutStatus status);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Repository
|
||||
public interface SessionRepository extends JpaRepository<Session, Long> {
|
||||
@@ -46,6 +48,13 @@ public interface SessionRepository extends JpaRepository<Session, Long> {
|
||||
* Returns the number of deleted rows.
|
||||
* Note: MySQL requires LIMIT to be a literal or bound parameter, so we use a native query.
|
||||
*/
|
||||
/**
|
||||
* User IDs in {@code userIds} that have at least one session valid at {@code now}
|
||||
* ({@code created_at <= now} and {@code expires_at >= now}).
|
||||
*/
|
||||
@Query("SELECT DISTINCT s.userId FROM Session s WHERE s.userId IN :userIds AND s.createdAt <= :now AND s.expiresAt >= :now")
|
||||
Set<Integer> findOnlineUserIdsAmong(@Param("userIds") Collection<Integer> userIds, @Param("now") LocalDateTime now);
|
||||
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(value = "DELETE FROM sessions WHERE expires_at < :now LIMIT :batchSize", nativeQuery = true)
|
||||
int deleteExpiredSessionsBatch(@Param("now") LocalDateTime now, @Param("batchSize") int batchSize);
|
||||
|
||||
@@ -24,6 +24,7 @@ public interface UserARepository extends JpaRepository<UserA, Integer>, JpaSpeci
|
||||
*/
|
||||
@Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u")
|
||||
int getMaxId();
|
||||
|
||||
Optional<UserA> findByTelegramId(Long telegramId);
|
||||
|
||||
/**
|
||||
@@ -49,6 +50,20 @@ public interface UserARepository extends JpaRepository<UserA, Integer>, JpaSpeci
|
||||
*/
|
||||
@Query("SELECT COUNT(u) FROM UserA u WHERE u.dateLogin >= :start AND u.dateLogin < :end")
|
||||
long countByDateLoginBetween(@Param("start") Integer start, @Param("end") Integer end);
|
||||
|
||||
long countByBotActiveIsFalse();
|
||||
|
||||
long countByBotActiveIsTrue();
|
||||
|
||||
/**
|
||||
* Paged users in id range for broadcast when skipping inactive-bot users.
|
||||
*/
|
||||
@Query("SELECT u FROM UserA u WHERE u.id >= :fromId AND u.id <= :toId AND u.botActive = true ORDER BY u.id")
|
||||
Page<UserA> findByIdBetweenAndBotActiveTrue(@Param("fromId") int fromId, @Param("toId") int toId, Pageable pageable);
|
||||
|
||||
/** Count users sharing the same IP (varbinary); returns 0 if {@code ip} is null. */
|
||||
@Query("SELECT COUNT(u) FROM UserA u WHERE u.ip IS NOT NULL AND u.ip = :ip")
|
||||
long countByIpEqual(@Param("ip") byte[] ip);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ public interface UserBRepository extends JpaRepository<UserB, Integer> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT b FROM UserB b WHERE b.id = :id")
|
||||
Optional<UserB> findByIdForUpdate(@Param("id") Integer id);
|
||||
|
||||
@Query("SELECT COUNT(b) FROM UserB b WHERE b.depositCount > 0")
|
||||
long countUsersWithDeposit();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.honey.honey.repository;
|
||||
|
||||
import com.honey.honey.dto.ReferralDto;
|
||||
import com.honey.honey.dto.ReferralProjection;
|
||||
import com.honey.honey.model.UserD;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -11,10 +11,17 @@ import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserDRepository extends JpaRepository<UserD, Integer> {
|
||||
|
||||
/**
|
||||
* Earliest {@code date_reg} on {@code db_users_d} (registration time), ignoring unset rows.
|
||||
*/
|
||||
@Query("SELECT MIN(d.dateReg) FROM UserD d WHERE d.dateReg > 0")
|
||||
Optional<Integer> findMinDateReg();
|
||||
|
||||
/**
|
||||
* Increments referals_1 for a user.
|
||||
*/
|
||||
@@ -52,36 +59,33 @@ public interface UserDRepository extends JpaRepository<UserD, Integer> {
|
||||
|
||||
/**
|
||||
* Finds referrals for level 1 (where referer_id_1 = userId).
|
||||
* Returns referrals with their screen_name and to_referer_1 commission.
|
||||
* Returns raw rows (name, commission bigint). Map to ReferralDto in service.
|
||||
*/
|
||||
@Query("SELECT new com.honey.honey.dto.ReferralDto(" +
|
||||
"ud.screenName, ud.toReferer1) " +
|
||||
@Query("SELECT ud.screenName as name, ud.toReferer1 as commission " +
|
||||
"FROM UserD ud " +
|
||||
"WHERE ud.refererId1 = :userId AND ud.refererId1 > 0 " +
|
||||
"ORDER BY ud.toReferer1 DESC")
|
||||
Page<ReferralDto> findReferralsLevel1(@Param("userId") Integer userId, Pageable pageable);
|
||||
"ORDER BY ud.toReferer1 DESC, ud.id DESC")
|
||||
Page<ReferralProjection> findReferralsLevel1(@Param("userId") Integer userId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Finds referrals for level 2 (where referer_id_2 = userId).
|
||||
* Returns referrals with their screen_name and to_referer_2 commission.
|
||||
* Returns raw rows (name, commission bigint). Map to ReferralDto in service.
|
||||
*/
|
||||
@Query("SELECT new com.honey.honey.dto.ReferralDto(" +
|
||||
"ud.screenName, ud.toReferer2) " +
|
||||
@Query("SELECT ud.screenName as name, ud.toReferer2 as commission " +
|
||||
"FROM UserD ud " +
|
||||
"WHERE ud.refererId2 = :userId AND ud.refererId2 > 0 " +
|
||||
"ORDER BY ud.toReferer2 DESC")
|
||||
Page<ReferralDto> findReferralsLevel2(@Param("userId") Integer userId, Pageable pageable);
|
||||
"ORDER BY ud.toReferer2 DESC, ud.id DESC")
|
||||
Page<ReferralProjection> findReferralsLevel2(@Param("userId") Integer userId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Finds referrals for level 3 (where referer_id_3 = userId).
|
||||
* Returns referrals with their screen_name and to_referer_3 commission.
|
||||
* Returns raw rows (name, commission bigint). Map to ReferralDto in service.
|
||||
*/
|
||||
@Query("SELECT new com.honey.honey.dto.ReferralDto(" +
|
||||
"ud.screenName, ud.toReferer3) " +
|
||||
@Query("SELECT ud.screenName as name, ud.toReferer3 as commission " +
|
||||
"FROM UserD ud " +
|
||||
"WHERE ud.refererId3 = :userId AND ud.refererId3 > 0 " +
|
||||
"ORDER BY ud.toReferer3 DESC")
|
||||
Page<ReferralDto> findReferralsLevel3(@Param("userId") Integer userId, Pageable pageable);
|
||||
"ORDER BY ud.toReferer3 DESC, ud.id DESC")
|
||||
Page<ReferralProjection> findReferralsLevel3(@Param("userId") Integer userId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Masters: users whose id equals their master_id (and master_id > 0).
|
||||
@@ -91,7 +95,7 @@ public interface UserDRepository extends JpaRepository<UserD, Integer> {
|
||||
List<UserD> findAllMasters();
|
||||
|
||||
/**
|
||||
* IDs of users who are Masters (id = master_id and master_id > 0). Used to exclude them from GAME_ADMIN views.
|
||||
* IDs of users who are Masters (id = master_id and master_id > 0).
|
||||
*/
|
||||
@Query("SELECT d.id FROM UserD d WHERE d.id = d.masterId AND d.masterId > 0")
|
||||
List<Integer> findMasterUserIds();
|
||||
|
||||
@@ -43,11 +43,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
if (jwtUtil.validateToken(jwt, username)) {
|
||||
// Get admin from database to retrieve actual role
|
||||
String role = adminRepository.findByUsername(username)
|
||||
// If token has explicit role (e.g. Chatwoot integration), use it; otherwise load from DB
|
||||
String role = jwtUtil.getRoleFromToken(jwt);
|
||||
if (role == null || role.isBlank()) {
|
||||
role = adminRepository.findByUsername(username)
|
||||
.map(Admin::getRole)
|
||||
.orElse("ROLE_ADMIN"); // Fallback to ROLE_ADMIN if not found
|
||||
|
||||
.orElse("ROLE_ADMIN");
|
||||
}
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
username,
|
||||
null,
|
||||
|
||||
@@ -33,6 +33,27 @@ public class JwtUtil {
|
||||
return createToken(claims, username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a token with an explicit role (e.g. for Chatwoot integration).
|
||||
* Used when the subject is not a DB admin; the filter will use this role from the token.
|
||||
*/
|
||||
public String generateTokenWithRole(String subject, String role) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("role", role);
|
||||
return createToken(claims, subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the role from token claims, or null if not present.
|
||||
*/
|
||||
public String getRoleFromToken(String token) {
|
||||
try {
|
||||
return getClaimFromToken(token, claims -> claims.get("role", String.class));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String createToken(Map<String, Object> claims, String subject) {
|
||||
return Jwts.builder()
|
||||
.claims(claims)
|
||||
|
||||
@@ -17,7 +17,8 @@ import java.util.stream.Collectors;
|
||||
@RequiredArgsConstructor
|
||||
public class AdminMasterService {
|
||||
|
||||
private static final BigDecimal USD_DIVISOR = new BigDecimal("1000000000");
|
||||
/** Honey admin: balance bigint → USD (same scale as AdminUserService). */
|
||||
private static final BigDecimal USD_DIVISOR = new BigDecimal("10000000000");
|
||||
|
||||
private final UserDRepository userDRepository;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import jakarta.persistence.criteria.Subquery;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -36,7 +37,10 @@ public class AdminUserService {
|
||||
|
||||
private static final long TICKETS_MULTIPLIER = 1_000_000L;
|
||||
|
||||
private static final BigDecimal TICKETS_TO_USD = new BigDecimal("0.001"); // 1000 tickets = 1 USD
|
||||
/**
|
||||
* Honey: display balance = DB bigint / 1_000_000; 1 USD = 10_000 display units → USD = DB / 10_000_000_000.
|
||||
*/
|
||||
private static final BigDecimal HONEY_DB_UNITS_PER_USD = new BigDecimal("10000000000");
|
||||
|
||||
private final UserARepository userARepository;
|
||||
private final UserBRepository userBRepository;
|
||||
@@ -46,6 +50,7 @@ public class AdminUserService {
|
||||
private final PayoutRepository payoutRepository;
|
||||
private final UserTaskClaimRepository userTaskClaimRepository;
|
||||
private final TaskRepository taskRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public Page<AdminUserDto> getUsers(
|
||||
@@ -56,16 +61,21 @@ public class AdminUserService {
|
||||
String languageCode,
|
||||
Integer dateRegFrom,
|
||||
Integer dateRegTo,
|
||||
Long balanceMin,
|
||||
Long balanceMax,
|
||||
Long balanceAMin,
|
||||
Long balanceAMax,
|
||||
Long balanceBMin,
|
||||
Long balanceBMax,
|
||||
Integer referralCountMin,
|
||||
Integer referralCountMax,
|
||||
Integer referrerId,
|
||||
Integer referralLevel,
|
||||
String ipFilter,
|
||||
Boolean botActive,
|
||||
Integer depositCountMin,
|
||||
String sortBy,
|
||||
String sortDir,
|
||||
boolean excludeMasters) {
|
||||
boolean excludeMasters,
|
||||
Boolean hideSubAndBanned) {
|
||||
|
||||
List<Integer> masterIds = excludeMasters ? userDRepository.findMasterUserIds() : List.of();
|
||||
|
||||
@@ -137,20 +147,16 @@ public class AdminUserService {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp));
|
||||
}
|
||||
|
||||
// Balance / referral filters via subqueries so DB handles pagination
|
||||
if (balanceMin != null || balanceMax != null) {
|
||||
// Balance A / Balance B filters via subqueries
|
||||
if (balanceAMin != null || balanceAMax != null || balanceBMin != null || balanceBMax != null) {
|
||||
Subquery<Integer> subB = query.subquery(Integer.class);
|
||||
Root<UserB> br = subB.from(UserB.class);
|
||||
subB.select(br.get("id"));
|
||||
List<Predicate> subPreds = new ArrayList<>();
|
||||
if (balanceMin != null) {
|
||||
subPreds.add(cb.greaterThanOrEqualTo(
|
||||
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMin));
|
||||
}
|
||||
if (balanceMax != null) {
|
||||
subPreds.add(cb.lessThanOrEqualTo(
|
||||
cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMax));
|
||||
}
|
||||
if (balanceAMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("balanceA"), balanceAMin));
|
||||
if (balanceAMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("balanceA"), balanceAMax));
|
||||
if (balanceBMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("balanceB"), balanceBMin));
|
||||
if (balanceBMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("balanceB"), balanceBMax));
|
||||
subB.where(cb.and(subPreds.toArray(new Predicate[0])));
|
||||
predicates.add(cb.in(root.get("id")).value(subB));
|
||||
}
|
||||
@@ -183,10 +189,35 @@ public class AdminUserService {
|
||||
predicates.add(cb.in(root.get("id")).value(subRef));
|
||||
}
|
||||
|
||||
if (botActive != null) {
|
||||
predicates.add(cb.equal(root.get("botActive"), botActive));
|
||||
}
|
||||
|
||||
if (depositCountMin != null && depositCountMin > 0) {
|
||||
Subquery<Integer> subDep = query.subquery(Integer.class);
|
||||
Root<UserB> depRoot = subDep.from(UserB.class);
|
||||
subDep.select(depRoot.get("id"));
|
||||
subDep.where(cb.greaterThanOrEqualTo(depRoot.get("depositCount"), depositCountMin));
|
||||
predicates.add(cb.in(root.get("id")).value(subDep));
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(hideSubAndBanned)) {
|
||||
predicates.add(cb.equal(root.get("banned"), 0));
|
||||
Subquery<Integer> subMasterSelf = query.subquery(Integer.class);
|
||||
Root<UserD> dm = subMasterSelf.from(UserD.class);
|
||||
subMasterSelf.select(dm.get("id"));
|
||||
subMasterSelf.where(cb.and(
|
||||
cb.equal(dm.get("id"), dm.get("masterId")),
|
||||
cb.gt(dm.get("masterId"), 0)));
|
||||
predicates.add(cb.not(root.get("id").in(subMasterSelf)));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
|
||||
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
|
||||
Set<String> sortRequiresJoin = Set.of(
|
||||
"balanceA", "balanceB", "depositTotal", "withdrawTotal", "referralCount", "profit",
|
||||
"referrerTelegramName", "profitPercent");
|
||||
boolean useJoinSort = sortBy != null && sortRequiresJoin.contains(sortBy);
|
||||
List<UserA> userList;
|
||||
long totalElements;
|
||||
@@ -194,9 +225,11 @@ public class AdminUserService {
|
||||
if (useJoinSort) {
|
||||
List<Integer> orderedIds = getOrderedUserIdsForAdminList(
|
||||
search, banned, countryCode, languageCode,
|
||||
dateRegFrom, dateRegTo, balanceMin, balanceMax,
|
||||
dateRegFrom, dateRegTo, balanceAMin, balanceAMax, balanceBMin, balanceBMax,
|
||||
referralCountMin, referralCountMax,
|
||||
referrerId, referralLevel, ipFilter,
|
||||
botActive, depositCountMin,
|
||||
hideSubAndBanned,
|
||||
sortBy, sortDir != null ? sortDir : "desc",
|
||||
pageable.getPageSize(), (int) pageable.getOffset(),
|
||||
masterIds);
|
||||
@@ -223,6 +256,20 @@ public class AdminUserService {
|
||||
Map<Integer, UserD> userDMap = userDRepository.findAllById(userIds).stream()
|
||||
.collect(Collectors.toMap(UserD::getId, ud -> ud));
|
||||
|
||||
LocalDateTime sessionNow = LocalDateTime.now();
|
||||
Set<Integer> onlineIds = userIds.isEmpty()
|
||||
? Set.of()
|
||||
: sessionRepository.findOnlineUserIdsAmong(userIds, sessionNow);
|
||||
|
||||
Set<Integer> refererIds = userDMap.values().stream()
|
||||
.map(UserD::getRefererId1)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Integer, UserA> referrersById = refererIds.isEmpty()
|
||||
? Map.of()
|
||||
: userARepository.findAllById(refererIds).stream()
|
||||
.collect(Collectors.toMap(UserA::getId, u -> u));
|
||||
|
||||
// Map to DTOs (filtering is done in DB via specification subqueries)
|
||||
List<AdminUserDto> pageContent = userList.stream()
|
||||
.map(userA -> {
|
||||
@@ -260,6 +307,21 @@ public class AdminUserService {
|
||||
BigDecimal depositTotalUsd = ticketsToUsd(userB.getDepositTotal());
|
||||
BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal());
|
||||
BigDecimal profitUsd = ticketsToUsd(profit);
|
||||
BigDecimal profitPercent = computeProfitPercent(depositTotalUsd, withdrawTotalUsd);
|
||||
|
||||
Integer referrerIdVal = null;
|
||||
String referrerTelegramNameVal = null;
|
||||
int rid = userD.getRefererId1();
|
||||
if (rid > 0) {
|
||||
referrerIdVal = rid;
|
||||
UserA refA = referrersById.get(rid);
|
||||
String tn = refA != null ? refA.getTelegramName() : null;
|
||||
if (tn == null || tn.isBlank() || "-".equals(tn)) {
|
||||
referrerTelegramNameVal = "-";
|
||||
} else {
|
||||
referrerTelegramNameVal = tn;
|
||||
}
|
||||
}
|
||||
|
||||
return AdminUserDto.builder()
|
||||
.id(userA.getId())
|
||||
@@ -283,6 +345,11 @@ public class AdminUserService {
|
||||
.depositTotalUsd(depositTotalUsd)
|
||||
.withdrawTotalUsd(withdrawTotalUsd)
|
||||
.profitUsd(profitUsd)
|
||||
.profitPercent(profitPercent)
|
||||
.botActive(userA.isBotActive())
|
||||
.online(onlineIds.contains(userA.getId()))
|
||||
.referrerId(referrerIdVal)
|
||||
.referrerTelegramName(referrerTelegramNameVal)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
@@ -301,13 +368,18 @@ public class AdminUserService {
|
||||
String languageCode,
|
||||
Integer dateRegFrom,
|
||||
Integer dateRegTo,
|
||||
Long balanceMin,
|
||||
Long balanceMax,
|
||||
Long balanceAMin,
|
||||
Long balanceAMax,
|
||||
Long balanceBMin,
|
||||
Long balanceBMax,
|
||||
Integer referralCountMin,
|
||||
Integer referralCountMax,
|
||||
Integer referrerId,
|
||||
Integer referralLevel,
|
||||
String ipFilter,
|
||||
Boolean botActive,
|
||||
Integer depositCountMin,
|
||||
Boolean hideSubAndBanned,
|
||||
String sortBy,
|
||||
String sortDir,
|
||||
int limit,
|
||||
@@ -316,7 +388,8 @@ public class AdminUserService {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT a.id FROM db_users_a a " +
|
||||
"INNER JOIN db_users_b b ON a.id = b.id " +
|
||||
"INNER JOIN db_users_d d ON a.id = d.id WHERE 1=1");
|
||||
"INNER JOIN db_users_d d ON a.id = d.id " +
|
||||
"LEFT JOIN db_users_a ref ON d.referer_id_1 = ref.id WHERE 1=1");
|
||||
List<Object> params = new ArrayList<>();
|
||||
int paramIndex = 1;
|
||||
|
||||
@@ -374,14 +447,24 @@ public class AdminUserService {
|
||||
params.add(dateRegTo);
|
||||
paramIndex++;
|
||||
}
|
||||
if (balanceMin != null) {
|
||||
sql.append(" AND (b.balance_a + b.balance_b) >= ?");
|
||||
params.add(balanceMin);
|
||||
if (balanceAMin != null) {
|
||||
sql.append(" AND b.balance_a >= ?");
|
||||
params.add(balanceAMin);
|
||||
paramIndex++;
|
||||
}
|
||||
if (balanceMax != null) {
|
||||
sql.append(" AND (b.balance_a + b.balance_b) <= ?");
|
||||
params.add(balanceMax);
|
||||
if (balanceAMax != null) {
|
||||
sql.append(" AND b.balance_a <= ?");
|
||||
params.add(balanceAMax);
|
||||
paramIndex++;
|
||||
}
|
||||
if (balanceBMin != null) {
|
||||
sql.append(" AND b.balance_b >= ?");
|
||||
params.add(balanceBMin);
|
||||
paramIndex++;
|
||||
}
|
||||
if (balanceBMax != null) {
|
||||
sql.append(" AND b.balance_b <= ?");
|
||||
params.add(balanceBMax);
|
||||
paramIndex++;
|
||||
}
|
||||
if (referralCountMin != null || referralCountMax != null) {
|
||||
@@ -414,13 +497,31 @@ public class AdminUserService {
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
if (botActive != null) {
|
||||
sql.append(" AND a.bot_active = ?");
|
||||
params.add(Boolean.TRUE.equals(botActive) ? 1 : 0);
|
||||
paramIndex++;
|
||||
}
|
||||
if (depositCountMin != null && depositCountMin > 0) {
|
||||
sql.append(" AND b.deposit_count >= ?");
|
||||
params.add(depositCountMin);
|
||||
paramIndex++;
|
||||
}
|
||||
if (Boolean.TRUE.equals(hideSubAndBanned)) {
|
||||
sql.append(" AND a.banned = 0");
|
||||
sql.append(" AND NOT (d.id = d.master_id AND d.master_id > 0)");
|
||||
}
|
||||
|
||||
String orderColumn = switch (sortBy != null ? sortBy : "") {
|
||||
case "balanceA" -> "b.balance_a";
|
||||
case "balanceB" -> "b.balance_b";
|
||||
case "depositTotal" -> "b.deposit_total";
|
||||
case "withdrawTotal" -> "b.withdraw_total";
|
||||
case "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)";
|
||||
case "profit" -> "(b.deposit_total - b.withdraw_total)";
|
||||
case "referrerTelegramName" -> "ref.telegram_name";
|
||||
// Same as (1 - withdraw/deposit)*100 when deposit > 0 (Honey linear USD scale)
|
||||
case "profitPercent" -> "(CASE WHEN b.deposit_total > 0 THEN 100.0 * (b.deposit_total - b.withdraw_total) / b.deposit_total ELSE NULL END)";
|
||||
default -> "a.id";
|
||||
};
|
||||
String direction = "asc".equalsIgnoreCase(sortDir) ? " ASC" : " DESC";
|
||||
@@ -513,41 +614,47 @@ public class AdminUserService {
|
||||
long totalCommissions = userD.getFromReferals1() + userD.getFromReferals2() +
|
||||
userD.getFromReferals3() + userD.getFromReferals4() + userD.getFromReferals5();
|
||||
|
||||
// Build referral levels
|
||||
BigDecimal refDep1 = paymentRepository.sumCompletedUsdForReferralsLevel1(userId);
|
||||
BigDecimal refDep2 = paymentRepository.sumCompletedUsdForReferralsLevel2(userId);
|
||||
BigDecimal refDep3 = paymentRepository.sumCompletedUsdForReferralsLevel3(userId);
|
||||
if (refDep1 == null) refDep1 = BigDecimal.ZERO;
|
||||
if (refDep2 == null) refDep2 = BigDecimal.ZERO;
|
||||
if (refDep3 == null) refDep3 = BigDecimal.ZERO;
|
||||
BigDecimal totalReferralDepositsUsd = refDep1.add(refDep2).add(refDep3);
|
||||
|
||||
// Build referral levels (admin shows 1–3 only)
|
||||
List<ReferralLevelDto> referralLevels = new ArrayList<>();
|
||||
for (int level = 1; level <= 5; level++) {
|
||||
for (int level = 1; level <= 3; level++) {
|
||||
int refererId = switch (level) {
|
||||
case 1 -> userD.getRefererId1();
|
||||
case 2 -> userD.getRefererId2();
|
||||
case 3 -> userD.getRefererId3();
|
||||
case 4 -> userD.getRefererId4();
|
||||
case 5 -> userD.getRefererId5();
|
||||
default -> 0;
|
||||
};
|
||||
int referralCount = switch (level) {
|
||||
case 1 -> userD.getReferals1();
|
||||
case 2 -> userD.getReferals2();
|
||||
case 3 -> userD.getReferals3();
|
||||
case 4 -> userD.getReferals4();
|
||||
case 5 -> userD.getReferals5();
|
||||
default -> 0;
|
||||
};
|
||||
long commissionsEarned = switch (level) {
|
||||
case 1 -> userD.getFromReferals1();
|
||||
case 2 -> userD.getFromReferals2();
|
||||
case 3 -> userD.getFromReferals3();
|
||||
case 4 -> userD.getFromReferals4();
|
||||
case 5 -> userD.getFromReferals5();
|
||||
default -> 0L;
|
||||
};
|
||||
long commissionsPaid = switch (level) {
|
||||
case 1 -> userD.getToReferer1();
|
||||
case 2 -> userD.getToReferer2();
|
||||
case 3 -> userD.getToReferer3();
|
||||
case 4 -> userD.getToReferer4();
|
||||
case 5 -> userD.getToReferer5();
|
||||
default -> 0L;
|
||||
};
|
||||
BigDecimal depositsUsd = switch (level) {
|
||||
case 1 -> refDep1;
|
||||
case 2 -> refDep2;
|
||||
case 3 -> refDep3;
|
||||
default -> BigDecimal.ZERO;
|
||||
};
|
||||
|
||||
referralLevels.add(ReferralLevelDto.builder()
|
||||
.level(level)
|
||||
@@ -557,6 +664,7 @@ public class AdminUserService {
|
||||
.commissionsPaid(commissionsPaid)
|
||||
.commissionsEarnedUsd(ticketsToUsd(commissionsEarned))
|
||||
.commissionsPaidUsd(ticketsToUsd(commissionsPaid))
|
||||
.depositsUsd(depositsUsd)
|
||||
.build());
|
||||
}
|
||||
|
||||
@@ -564,6 +672,11 @@ public class AdminUserService {
|
||||
BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal());
|
||||
BigDecimal totalCommissionsEarnedUsd = ticketsToUsd(totalCommissions);
|
||||
|
||||
long ipAddressUserCount = 0L;
|
||||
if (userA.getIp() != null) {
|
||||
ipAddressUserCount = userARepository.countByIpEqual(userA.getIp());
|
||||
}
|
||||
|
||||
return AdminUserDetailDto.builder()
|
||||
.id(userA.getId())
|
||||
.screenName(userA.getScreenName())
|
||||
@@ -578,7 +691,9 @@ public class AdminUserService {
|
||||
.dateLogin(userA.getDateLogin())
|
||||
.banned(userA.getBanned())
|
||||
.ipAddress(IpUtils.bytesToIp(userA.getIp()))
|
||||
.ipAddressUserCount(ipAddressUserCount)
|
||||
.balanceA(userB.getBalanceA())
|
||||
.balanceB(userB.getBalanceB())
|
||||
.depositTotal(userB.getDepositTotal())
|
||||
.depositCount(userB.getDepositCount())
|
||||
.withdrawTotal(userB.getWithdrawTotal())
|
||||
@@ -589,6 +704,7 @@ public class AdminUserService {
|
||||
.referralCount(totalReferrals)
|
||||
.totalCommissionsEarned(totalCommissions)
|
||||
.totalCommissionsEarnedUsd(totalCommissionsEarnedUsd)
|
||||
.totalReferralDepositsUsd(totalReferralDepositsUsd)
|
||||
.masterId(userD.getMasterId() > 0 ? userD.getMasterId() : null)
|
||||
.referralLevels(referralLevels)
|
||||
.build();
|
||||
@@ -596,7 +712,20 @@ public class AdminUserService {
|
||||
|
||||
private static BigDecimal ticketsToUsd(long ticketsBigint) {
|
||||
if (ticketsBigint == 0) return BigDecimal.ZERO;
|
||||
return BigDecimal.valueOf(ticketsBigint).divide(BigDecimal.valueOf(1_000_000L), 6, RoundingMode.HALF_UP).multiply(TICKETS_TO_USD).setScale(2, RoundingMode.HALF_UP);
|
||||
return BigDecimal.valueOf(ticketsBigint)
|
||||
.divide(HONEY_DB_UNITS_PER_USD, 8, RoundingMode.HALF_UP)
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static BigDecimal computeProfitPercent(BigDecimal depositUsd, BigDecimal withdrawUsd) {
|
||||
if (depositUsd == null || depositUsd.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
BigDecimal w = withdrawUsd != null ? withdrawUsd : BigDecimal.ZERO;
|
||||
return BigDecimal.ONE
|
||||
.subtract(w.divide(depositUsd, 8, RoundingMode.HALF_UP))
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
public Page<AdminTransactionDto> getUserTransactions(Integer userId, Pageable pageable) {
|
||||
|
||||
@@ -42,6 +42,7 @@ public class NotificationBroadcastService {
|
||||
private final UserARepository userARepository;
|
||||
private final NotificationAuditRepository notificationAuditRepository;
|
||||
private final FeatureSwitchService featureSwitchService;
|
||||
private final UserService userService;
|
||||
|
||||
private final AtomicBoolean stopRequested = new AtomicBoolean(false);
|
||||
|
||||
@@ -59,7 +60,7 @@ public class NotificationBroadcastService {
|
||||
* Run broadcast asynchronously. Uses userIdFrom/userIdTo (internal user ids); if null, uses 1 and max id.
|
||||
* Only one of imageUrl or videoUrl is used; video takes priority if both are set.
|
||||
* If buttonText is non-empty, each message gets an inline button with that text opening the mini app.
|
||||
* When ignoreBlocked is true, skips users whose latest notification_audit record has status FAILED (e.g. blocked the bot).
|
||||
* When ignoreBlocked is true, skips users with {@code bot_active = false} on {@code db_users_a}.
|
||||
*/
|
||||
@Async
|
||||
public void runBroadcast(String message, String imageUrl, String videoUrl,
|
||||
@@ -84,23 +85,16 @@ public class NotificationBroadcastService {
|
||||
boolean hasNext = true;
|
||||
long sent = 0;
|
||||
long failed = 0;
|
||||
long skippedBlocked = 0;
|
||||
|
||||
while (hasNext && !stopRequested.get()) {
|
||||
Pageable pageable = PageRequest.of(pageNumber, PAGE_SIZE);
|
||||
Page<UserA> page = userARepository.findByIdBetween(fromId, toId, pageable);
|
||||
Page<UserA> page = skipBlocked
|
||||
? userARepository.findByIdBetweenAndBotActiveTrue(fromId, toId, pageable)
|
||||
: userARepository.findByIdBetween(fromId, toId, pageable);
|
||||
for (UserA user : page.getContent()) {
|
||||
if (stopRequested.get()) break;
|
||||
if (user.getTelegramId() == null) continue;
|
||||
|
||||
if (skipBlocked) {
|
||||
var latest = notificationAuditRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId());
|
||||
if (latest.isPresent() && NotificationAudit.STATUS_FAILED.equals(latest.get().getStatus())) {
|
||||
skippedBlocked++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
TelegramSendResult result = sendOne(botToken, user.getTelegramId(), message, imageUrl, videoUrl, buttonText);
|
||||
int statusCode = result.getStatusCode();
|
||||
boolean success = result.isSuccess();
|
||||
@@ -114,6 +108,10 @@ public class NotificationBroadcastService {
|
||||
.build();
|
||||
notificationAuditRepository.save(audit);
|
||||
|
||||
if (!success && statusCode == 403) {
|
||||
userService.setBotActiveByUserId(user.getId(), false);
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(DELAY_MS_BETWEEN_SENDS);
|
||||
} catch (InterruptedException e) {
|
||||
@@ -127,9 +125,9 @@ public class NotificationBroadcastService {
|
||||
}
|
||||
|
||||
if (stopRequested.get()) {
|
||||
log.info("Notification broadcast stopped by request. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked);
|
||||
log.info("Notification broadcast stopped by request. Sent={}, Failed={}", sent, failed);
|
||||
} else {
|
||||
log.info("Notification broadcast finished. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked);
|
||||
log.info("Notification broadcast finished. Sent={}, Failed={}", sent, failed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ import com.honey.honey.model.CryptoDepositMethod;
|
||||
import com.honey.honey.model.Payment;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.model.UserB;
|
||||
import com.honey.honey.model.UserD;
|
||||
import com.honey.honey.repository.CryptoDepositMethodRepository;
|
||||
import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import com.honey.honey.repository.UserBRepository;
|
||||
import com.honey.honey.repository.UserDRepository;
|
||||
import com.honey.honey.util.IpUtils;
|
||||
import com.honey.honey.util.TelegramTokenRedactor;
|
||||
import lombok.Data;
|
||||
@@ -47,6 +49,7 @@ public class PaymentService {
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final UserARepository userARepository;
|
||||
private final UserBRepository userBRepository;
|
||||
private final UserDRepository userDRepository;
|
||||
private final CryptoDepositMethodRepository cryptoDepositMethodRepository;
|
||||
private final TelegramProperties telegramProperties;
|
||||
private final TransactionService transactionService;
|
||||
@@ -74,6 +77,11 @@ public class PaymentService {
|
||||
/** When false, Telegram Stars payment webhooks are ignored (no balance credited). Invoice creation for Stars is already rejected. */
|
||||
private static final boolean TELEGRAM_STARS_PAYMENTS_ENABLED = false;
|
||||
|
||||
/** Referral commission as fraction of base deposit (no bonus). Level 1 = referer_1, level 2 = referer_2, level 3 = referer_3. */
|
||||
private static final double REFERRER_LEVEL1_PERCENT = 0.12;
|
||||
private static final double REFERRER_LEVEL2_PERCENT = 0.06;
|
||||
private static final double REFERRER_LEVEL3_PERCENT = 0.02;
|
||||
|
||||
/**
|
||||
* Creates a payment invoice for the user.
|
||||
* Validates the stars amount and creates a pending payment record.
|
||||
@@ -394,6 +402,7 @@ public class PaymentService {
|
||||
.playBalance(playBalance)
|
||||
.status(Payment.PaymentStatus.COMPLETED)
|
||||
.completedAt(now)
|
||||
.ftd(firstDeposit)
|
||||
.build();
|
||||
paymentRepository.save(payment);
|
||||
|
||||
@@ -409,9 +418,54 @@ public class PaymentService {
|
||||
log.error("Error creating deposit transaction: userId={}, amount={}", userId, playBalance, e);
|
||||
}
|
||||
|
||||
// Referral commissions: % of base deposit (no bonus), credited to referer balanceB and tracked in UserD
|
||||
applyReferralCommissions(userId, basePlayBalance);
|
||||
|
||||
log.info("External deposit completed: orderId={}, userId={}, usdAmount={}, playBalance={}, firstDeposit={}", orderId, userId, usdAmountBd, playBalance, firstDeposit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies referral commissions on deposit: referer_1 gets REFERRER_LEVEL1_PERCENT, referer_2 gets REFERRER_LEVEL2_PERCENT, referer_3 gets REFERRER_LEVEL3_PERCENT of base play balance (no bonus).
|
||||
* Updates: referer's balanceB, depositor's to_referer_N, referer's from_referals_N.
|
||||
*/
|
||||
private void applyReferralCommissions(Integer depositorUserId, long basePlayBalance) {
|
||||
if (basePlayBalance <= 0) return;
|
||||
UserD depositorUserD = userDRepository.findById(depositorUserId).orElse(null);
|
||||
if (depositorUserD == null) return;
|
||||
|
||||
long commission1 = Math.round(basePlayBalance * REFERRER_LEVEL1_PERCENT);
|
||||
long commission2 = Math.round(basePlayBalance * REFERRER_LEVEL2_PERCENT);
|
||||
long commission3 = Math.round(basePlayBalance * REFERRER_LEVEL3_PERCENT);
|
||||
|
||||
applyReferralLevel(depositorUserD, depositorUserId, 1, depositorUserD.getRefererId1(), commission1,
|
||||
UserD::getToReferer1, UserD::setToReferer1, UserD::getFromReferals1, UserD::setFromReferals1);
|
||||
applyReferralLevel(depositorUserD, depositorUserId, 2, depositorUserD.getRefererId2(), commission2,
|
||||
UserD::getToReferer2, UserD::setToReferer2, UserD::getFromReferals2, UserD::setFromReferals2);
|
||||
applyReferralLevel(depositorUserD, depositorUserId, 3, depositorUserD.getRefererId3(), commission3,
|
||||
UserD::getToReferer3, UserD::setToReferer3, UserD::getFromReferals3, UserD::setFromReferals3);
|
||||
|
||||
userDRepository.save(depositorUserD);
|
||||
}
|
||||
|
||||
private void applyReferralLevel(UserD depositorUserD, Integer depositorUserId, int level, Integer refererId, long commission,
|
||||
java.util.function.Function<UserD, Long> getToReferer, java.util.function.BiConsumer<UserD, Long> setToReferer,
|
||||
java.util.function.Function<UserD, Long> getFromReferals, java.util.function.BiConsumer<UserD, Long> setFromReferals) {
|
||||
if (refererId == null || refererId <= 0 || commission <= 0) return;
|
||||
|
||||
setToReferer.accept(depositorUserD, (getToReferer.apply(depositorUserD) != null ? getToReferer.apply(depositorUserD) : 0L) + commission);
|
||||
|
||||
userBRepository.findById(refererId).ifPresent(refererB -> {
|
||||
refererB.setBalanceB(refererB.getBalanceB() + commission);
|
||||
userBRepository.save(refererB);
|
||||
});
|
||||
userDRepository.findById(refererId).ifPresent(refererD -> {
|
||||
setFromReferals.accept(refererD, (getFromReferals.apply(refererD) != null ? getFromReferals.apply(refererD) : 0L) + commission);
|
||||
userDRepository.save(refererD);
|
||||
});
|
||||
|
||||
log.debug("Referral commission applied: depositor={}, refererLevel={}, refererId={}, commission={}", depositorUserId, level, refererId, commission);
|
||||
}
|
||||
|
||||
/** USD range 3–20000 and at most 2 decimal places. */
|
||||
private void validateUsd(double usdAmount) {
|
||||
if (usdAmount < MIN_USD || usdAmount > MAX_USD) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.honey.honey.service;
|
||||
|
||||
import com.honey.honey.dto.ReferralDto;
|
||||
import com.honey.honey.dto.ReferralProjection;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.model.UserB;
|
||||
import com.honey.honey.model.UserD;
|
||||
@@ -13,13 +14,16 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service for user management with sharded tables.
|
||||
@@ -178,6 +182,7 @@ public class UserService {
|
||||
}
|
||||
userA.setIp(ipBytes);
|
||||
userA.setDateLogin((int) nowSeconds);
|
||||
userA.setBotActive(true);
|
||||
|
||||
userARepository.save(userA);
|
||||
log.debug("Updated user data on login: userId={}", userA.getId());
|
||||
@@ -261,7 +266,7 @@ public class UserService {
|
||||
userBRepository.save(userB);
|
||||
|
||||
// Create UserD with referral handling
|
||||
UserD userD = createUserDWithReferral(userId, screenName, start);
|
||||
UserD userD = createUserDWithReferral(userId, screenName, start, (int) nowSeconds);
|
||||
userDRepository.save(userD);
|
||||
|
||||
return userA;
|
||||
@@ -276,7 +281,7 @@ public class UserService {
|
||||
* @param screenName User's screen name (from db_users_a)
|
||||
* @param start Referral parameter (from bot registration, not WebApp)
|
||||
*/
|
||||
private UserD createUserDWithReferral(Integer userId, String screenName, String start) {
|
||||
private UserD createUserDWithReferral(Integer userId, String screenName, String start, int dateReg) {
|
||||
log.debug("Creating UserD with referral: userId={}, start={}", userId, start);
|
||||
|
||||
// Defensive check: Ensure UserD doesn't already exist (should never happen, but safety check)
|
||||
@@ -289,6 +294,7 @@ public class UserService {
|
||||
UserD.UserDBuilder builder = UserD.builder()
|
||||
.id(userId)
|
||||
.screenName(screenName != null ? screenName : "-")
|
||||
.dateReg(dateReg)
|
||||
.refererId1(0)
|
||||
.refererId2(0)
|
||||
.refererId3(0)
|
||||
@@ -453,26 +459,60 @@ public class UserService {
|
||||
return userARepository.findByTelegramId(telegramId);
|
||||
}
|
||||
|
||||
private static final double COMMISSION_DISPLAY_DIVISOR = 1_000_000.0;
|
||||
|
||||
/**
|
||||
* Gets referrals for a specific level with pagination.
|
||||
* Always returns 50 results per page.
|
||||
* Fetches raw rows (ReferralProjection), maps to API DTO (ReferralDto) with display commission.
|
||||
*
|
||||
* @param userId The user ID to get referrals for
|
||||
* @param level The referral level (1, 2, or 3)
|
||||
* @param page Page number (0-indexed)
|
||||
* @return Page of referrals with name and commission
|
||||
* @return Page of ReferralDto (name, commission as display value)
|
||||
*/
|
||||
public Page<ReferralDto> getReferrals(Integer userId, Integer level, Integer page) {
|
||||
// Fixed page size of 50 to prevent database overload
|
||||
Pageable pageable = PageRequest.of(page, 50);
|
||||
|
||||
return switch (level) {
|
||||
Pageable pageable = PageRequest.of(page, 10);
|
||||
Page<ReferralProjection> projectionPage = switch (level) {
|
||||
case 1 -> userDRepository.findReferralsLevel1(userId, pageable);
|
||||
case 2 -> userDRepository.findReferralsLevel2(userId, pageable);
|
||||
case 3 -> userDRepository.findReferralsLevel3(userId, pageable);
|
||||
default -> throw new IllegalArgumentException(
|
||||
localizationService.getMessage("user.error.referralLevelInvalid", String.valueOf(level)));
|
||||
};
|
||||
List<ReferralDto> content = projectionPage.getContent().stream()
|
||||
.map(p -> ReferralDto.builder()
|
||||
.name(p.getName())
|
||||
.commission(p.getCommission() != null ? p.getCommission() / COMMISSION_DISPLAY_DIVISOR : 0.0)
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(content, projectionPage.getPageable(), projectionPage.getTotalElements());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setBotActiveByTelegramId(Long telegramId, boolean active) {
|
||||
Optional<UserA> opt = userARepository.findByTelegramId(telegramId);
|
||||
if (opt.isEmpty()) {
|
||||
log.debug("setBotActiveByTelegramId: no user for telegramId={}", telegramId);
|
||||
return;
|
||||
}
|
||||
UserA u = opt.get();
|
||||
if (u.isBotActive() == active) {
|
||||
return;
|
||||
}
|
||||
u.setBotActive(active);
|
||||
userARepository.save(u);
|
||||
log.debug("setBotActiveByTelegramId: userId={}, telegramId={}, active={}", u.getId(), telegramId, active);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setBotActiveByUserId(Integer userId, boolean active) {
|
||||
userARepository.findById(userId).ifPresent(u -> {
|
||||
if (u.isBotActive() == active) {
|
||||
return;
|
||||
}
|
||||
u.setBotActive(active);
|
||||
userARepository.save(u);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ app:
|
||||
secret: ${APP_ADMIN_JWT_SECRET:change-this-to-a-secure-random-string-in-production-min-32-characters}
|
||||
# JWT expiration time in milliseconds (default: 24 hours)
|
||||
expiration: ${APP_ADMIN_JWT_EXPIRATION:86400000}
|
||||
chatwoot-integration-secret: ${CHATWOOT_INTEGRATION_SECRET:}
|
||||
|
||||
# GeoIP configuration
|
||||
# Set GEOIP_DB_PATH environment variable to use external file (recommended for production)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Extend referral commission indexes to include id DESC so ORDER BY commission DESC, id DESC
|
||||
-- is fully satisfied by the index. Avoids expensive filesort when many referrals share the same
|
||||
-- commission (e.g. 20k with 0 commission).
|
||||
DROP INDEX idx_users_d_referer1_commission ON db_users_d;
|
||||
DROP INDEX idx_users_d_referer2_commission ON db_users_d;
|
||||
DROP INDEX idx_users_d_referer3_commission ON db_users_d;
|
||||
|
||||
CREATE INDEX idx_users_d_referer1_commission ON db_users_d (referer_id_1, to_referer_1 DESC, id DESC);
|
||||
CREATE INDEX idx_users_d_referer2_commission ON db_users_d (referer_id_2, to_referer_2 DESC, id DESC);
|
||||
CREATE INDEX idx_users_d_referer3_commission ON db_users_d (referer_id_3, to_referer_3 DESC, id DESC);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user