admin statistics part2
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m17s

This commit is contained in:
Tihon
2026-03-19 15:47:21 +02:00
parent 5855678447
commit 2bf2125c3e
6 changed files with 238 additions and 108 deletions

View File

@@ -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 the earliest {@code date_reg} in the system
* and exclusive end of the latest 30-day window (start of tomorrow UTC).
*/
@GetMapping("/revenue")
public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
@RequestParam(defaultValue = "30d") String range) {
Instant now = Instant.now();
Instant startDate;
String granularity;
// Determine start date and granularity based on range
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0); // All time
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
@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 = userARepository.findMinDateReg();
if (regOpt.isPresent() && regOpt.get() != null) {
Instant reg = Instant.ofEpochSecond(regOpt.get());
firstProjectDayStartEpoch = reg.atZone(ZoneOffset.UTC)
.toLocalDate()
.atStartOfDay(ZoneOffset.UTC)
.toEpochSecond();
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
// CRYPTO only: revenue and payouts in USD for this period
java.math.BigDecimal revenueUsd = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal payoutsUsd = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
Payout.PayoutStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("revenue", revenueUsd);
point.put("payouts", payoutsUsd);
point.put("netRevenue", netRevenueUsd);
dataPoints.add(point);
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
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<Map<String, Object>> getActivityAnalytics(
@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;
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<Map<String, Object>> 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<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;
}
Map<String, Object> 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<Map<String, Object>> getActivityAnalytics(
@RequestParam(required = false) Long from,
@RequestParam(required = false) Long toExclusive,
@RequestParam(defaultValue = "30d") String range) {
if (from != null && toExclusive != null) {
Instant start = Instant.ofEpochSecond(from);
Instant end = Instant.ofEpochSecond(toExclusive);
if (!end.isAfter(start)) {
return ResponseEntity.badRequest().build();
}
long maxSpanSeconds = 366L * 24 * 3600;
if (end.getEpochSecond() - start.getEpochSecond() > maxSpanSeconds) {
return ResponseEntity.badRequest().build();
}
List<Map<String, Object>> dataPoints = buildActivityDailySeries(start, end);
Map<String, Object> response = new HashMap<>();
response.put("range", "custom");
response.put("granularity", "daily");
response.put("from", from);
response.put("toExclusive", toExclusive);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
Instant now = Instant.now();
Instant startDate;
String granularity;
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0);
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
dataPoints.add(buildActivityPoint(current, periodEnd));
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
private List<Map<String, Object>> buildActivityDailySeries(Instant startInclusive, Instant endExclusive) {
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startInclusive;
while (current.isBefore(endExclusive)) {
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
if (periodEnd.isAfter(endExclusive)) {
periodEnd = endExclusive;
}
dataPoints.add(buildActivityPoint(current, periodEnd));
current = periodEnd;
}
return dataPoints;
}
private Map<String, Object> buildActivityPoint(Instant current, Instant periodEnd) {
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers);
point.put("rounds", 0L);
return point;
}
private List<Map<String, Object>> buildRevenueDailySeries(Instant startInclusive, Instant endExclusive) {
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startInclusive;
while (current.isBefore(endExclusive)) {
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
if (periodEnd.isAfter(endExclusive)) {
periodEnd = endExclusive;
}
dataPoints.add(buildRevenuePoint(current, periodEnd));
current = periodEnd;
}
return dataPoints;
}
private Map<String, Object> buildRevenuePoint(Instant current, Instant periodEnd) {
BigDecimal revenueUsd = paymentRepository
.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd)
.orElse(ZERO);
BigDecimal payoutsUsd = payoutRepository
.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
Payout.PayoutStatus.COMPLETED, current, periodEnd)
.orElse(ZERO);
BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
long firstTimeDeposits = paymentRepository.countByStatusAndFtdTrueAndUsdNotNullAndCompletedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("revenue", revenueUsd);
point.put("payouts", payoutsUsd);
point.put("netRevenue", netRevenueUsd);
point.put("firstTimeDeposits", firstTimeDeposits);
return point;
}
}

View File

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

View File

@@ -91,5 +91,13 @@ public interface PaymentRepository extends JpaRepository<Payment, Long>, 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<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
);
}

View File

@@ -24,6 +24,10 @@ public interface UserARepository extends JpaRepository<UserA, Integer>, JpaSpeci
*/
@Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u")
int getMaxId();
/** Earliest registration timestamp (seconds) among all users. */
@Query("SELECT MIN(u.dateReg) FROM UserA u")
Optional<Integer> findMinDateReg();
Optional<UserA> findByTelegramId(Long telegramId);
/**

View File

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

View File

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