From 05f16c97b4c56f152295af53dd33c2c62bbf3bd9 Mon Sep 17 00:00:00 2001 From: Tihon Date: Thu, 19 Mar 2026 15:47:21 +0200 Subject: [PATCH] admin statistics part2 --- .../controller/AdminAnalyticsController.java | 322 ++++++++++++------ .../java/com/honey/honey/model/Payment.java | 5 + .../honey/repository/PaymentRepository.java | 8 + .../honey/repository/UserARepository.java | 4 + .../honey/honey/service/PaymentService.java | 1 + .../migration/V77__payments_add_ftd_index.sql | 6 + 6 files changed, 238 insertions(+), 108 deletions(-) create mode 100644 src/main/resources/db/migration/V77__payments_add_ftd_index.sql diff --git a/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java b/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java index c64be77..9037052 100644 --- a/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java +++ b/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java @@ -13,7 +13,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,106 +28,76 @@ import java.util.Map; @PreAuthorize("hasRole('ADMIN')") public class AdminAnalyticsController { + private static final BigDecimal ZERO = BigDecimal.ZERO; + private final UserARepository userARepository; private final PaymentRepository paymentRepository; private final PayoutRepository payoutRepository; /** - * Get revenue and payout time series data for charts. - * @param range Time range: 7d, 30d, 90d, 1y, all - * @return Time series data with daily/weekly/monthly aggregation + * Bounds for admin statistics-by-day UI: UTC day start of first user's registration (min id) and + * exclusive end of the latest 30-day window (start of tomorrow UTC). */ - @GetMapping("/revenue") - public ResponseEntity> getRevenueAnalytics( - @RequestParam(defaultValue = "30d") String range) { - - Instant now = Instant.now(); - Instant startDate; - String granularity; - - // Determine start date and granularity based on range - switch (range.toLowerCase()) { - case "7d": - startDate = now.minus(7, ChronoUnit.DAYS); - granularity = "daily"; - break; - case "30d": - startDate = now.minus(30, ChronoUnit.DAYS); - granularity = "daily"; - break; - case "90d": - startDate = now.minus(90, ChronoUnit.DAYS); - granularity = "daily"; - break; - case "1y": - startDate = now.minus(365, ChronoUnit.DAYS); - granularity = "weekly"; - break; - case "all": - startDate = Instant.ofEpochSecond(0); // All time - granularity = "monthly"; - break; - default: - startDate = now.minus(30, ChronoUnit.DAYS); - granularity = "daily"; + @GetMapping("/meta") + public ResponseEntity> getAnalyticsMeta() { + Instant latestWindowEndExclusive = Instant.now() + .atZone(ZoneOffset.UTC) + .toLocalDate() + .plusDays(1) + .atStartOfDay(ZoneOffset.UTC) + .toInstant(); + + long firstProjectDayStartEpoch = latestWindowEndExclusive.getEpochSecond() - 30L * 24 * 3600; + var regOpt = userARepository.findDateRegOfUserWithMinId(); + if (regOpt.isPresent() && regOpt.get() != null) { + Instant reg = Instant.ofEpochSecond(regOpt.get()); + firstProjectDayStartEpoch = reg.atZone(ZoneOffset.UTC) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toEpochSecond(); } - - List> dataPoints = new ArrayList<>(); - Instant current = startDate; - - while (current.isBefore(now)) { - Instant periodEnd; - if (granularity.equals("daily")) { - periodEnd = current.plus(1, ChronoUnit.DAYS); - } else if (granularity.equals("weekly")) { - periodEnd = current.plus(7, ChronoUnit.DAYS); - } else { - periodEnd = current.plus(30, ChronoUnit.DAYS); - } - - if (periodEnd.isAfter(now)) { - periodEnd = now; - } - - // CRYPTO only: revenue and payouts in USD for this period - java.math.BigDecimal revenueUsd = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween( - Payment.PaymentStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO); - java.math.BigDecimal payoutsUsd = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween( - Payout.PayoutStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO); - java.math.BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd); - - Map point = new HashMap<>(); - point.put("date", current.getEpochSecond()); - point.put("revenue", revenueUsd); - point.put("payouts", payoutsUsd); - point.put("netRevenue", netRevenueUsd); - - dataPoints.add(point); - - current = periodEnd; - } - + Map response = new HashMap<>(); - response.put("range", range); - response.put("granularity", granularity); - response.put("data", dataPoints); - + response.put("latestWindowEndExclusiveEpoch", latestWindowEndExclusive.getEpochSecond()); + response.put("firstProjectDayStartEpoch", firstProjectDayStartEpoch); 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 + * 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("/activity") - public ResponseEntity> getActivityAnalytics( + @GetMapping("/revenue") + public ResponseEntity> 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> dataPoints = buildRevenueDailySeries(start, end); + Map 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); @@ -151,10 +123,10 @@ public class AdminAnalyticsController { startDate = now.minus(30, ChronoUnit.DAYS); granularity = "daily"; } - + List> dataPoints = new ArrayList<>(); Instant current = startDate; - + while (current.isBefore(now)) { Instant periodEnd; if (granularity.equals("daily")) { @@ -164,38 +136,172 @@ public class AdminAnalyticsController { } else { periodEnd = current.plus(30, ChronoUnit.DAYS); } - + if (periodEnd.isAfter(now)) { periodEnd = now; } - - // Convert to Unix timestamps for UserA queries - int periodStartTs = (int) current.getEpochSecond(); - int periodEndTs = (int) periodEnd.getEpochSecond(); - - // Count new registrations in this period (between current and periodEnd) - long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs); - - // Count active players (logged in) in this period - long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs); - - Map 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; } - + Map response = new HashMap<>(); response.put("range", range); response.put("granularity", granularity); response.put("data", dataPoints); - + return ResponseEntity.ok(response); } -} + /** + * User activity time series. + * Optional {@code from} + {@code toExclusive} for daily buckets in [from, toExclusive). + */ + @GetMapping("/activity") + public ResponseEntity> 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> dataPoints = buildActivityDailySeries(start, end); + Map 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> 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 response = new HashMap<>(); + response.put("range", range); + response.put("granularity", granularity); + response.put("data", dataPoints); + + return ResponseEntity.ok(response); + } + + private List> buildActivityDailySeries(Instant startInclusive, Instant endExclusive) { + List> 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 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 point = new HashMap<>(); + point.put("date", current.getEpochSecond()); + point.put("newUsers", newUsers); + point.put("activePlayers", activePlayers); + point.put("rounds", 0L); + return point; + } + + private List> buildRevenueDailySeries(Instant startInclusive, Instant endExclusive) { + List> 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 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 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; + } +} diff --git a/src/main/java/com/honey/honey/model/Payment.java b/src/main/java/com/honey/honey/model/Payment.java index 338740a..3b5256b 100644 --- a/src/main/java/com/honey/honey/model/Payment.java +++ b/src/main/java/com/honey/honey/model/Payment.java @@ -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(); diff --git a/src/main/java/com/honey/honey/repository/PaymentRepository.java b/src/main/java/com/honey/honey/repository/PaymentRepository.java index 095a274..93d4448 100644 --- a/src/main/java/com/honey/honey/repository/PaymentRepository.java +++ b/src/main/java/com/honey/honey/repository/PaymentRepository.java @@ -91,5 +91,13 @@ public interface PaymentRepository extends JpaRepository, JpaSpec /** 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 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 + ); } diff --git a/src/main/java/com/honey/honey/repository/UserARepository.java b/src/main/java/com/honey/honey/repository/UserARepository.java index 4406a37..56d6f52 100644 --- a/src/main/java/com/honey/honey/repository/UserARepository.java +++ b/src/main/java/com/honey/honey/repository/UserARepository.java @@ -24,6 +24,10 @@ public interface UserARepository extends JpaRepository, JpaSpeci */ @Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u") int getMaxId(); + + /** Registration timestamp (seconds) of the user with the smallest id (first registered row). */ + @Query("SELECT u.dateReg FROM UserA u WHERE u.id = (SELECT MIN(u2.id) FROM UserA u2)") + Optional findDateRegOfUserWithMinId(); Optional findByTelegramId(Long telegramId); /** diff --git a/src/main/java/com/honey/honey/service/PaymentService.java b/src/main/java/com/honey/honey/service/PaymentService.java index 8764ecf..4b60381 100644 --- a/src/main/java/com/honey/honey/service/PaymentService.java +++ b/src/main/java/com/honey/honey/service/PaymentService.java @@ -402,6 +402,7 @@ public class PaymentService { .playBalance(playBalance) .status(Payment.PaymentStatus.COMPLETED) .completedAt(now) + .ftd(firstDeposit) .build(); paymentRepository.save(payment); diff --git a/src/main/resources/db/migration/V77__payments_add_ftd_index.sql b/src/main/resources/db/migration/V77__payments_add_ftd_index.sql new file mode 100644 index 0000000..69df1f1 --- /dev/null +++ b/src/main/resources/db/migration/V77__payments_add_ftd_index.sql @@ -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);