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

This commit is contained in:
Tihon
2026-03-19 15:47:21 +02:00
parent 5855678447
commit 05f16c97b4
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.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@@ -26,102 +28,72 @@ import java.util.Map;
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController { public class AdminAnalyticsController {
private static final BigDecimal ZERO = BigDecimal.ZERO;
private final UserARepository userARepository; private final UserARepository userARepository;
private final PaymentRepository paymentRepository; private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository; private final PayoutRepository payoutRepository;
/** /**
* Get revenue and payout time series data for charts. * Bounds for admin statistics-by-day UI: UTC day start of first user's registration (min id) and
* @param range Time range: 7d, 30d, 90d, 1y, all * exclusive end of the latest 30-day window (start of tomorrow UTC).
* @return Time series data with daily/weekly/monthly aggregation
*/ */
@GetMapping("/revenue") @GetMapping("/meta")
public ResponseEntity<Map<String, Object>> getRevenueAnalytics( public ResponseEntity<Map<String, Object>> getAnalyticsMeta() {
@RequestParam(defaultValue = "30d") String range) { Instant latestWindowEndExclusive = Instant.now()
.atZone(ZoneOffset.UTC)
.toLocalDate()
.plusDays(1)
.atStartOfDay(ZoneOffset.UTC)
.toInstant();
Instant now = Instant.now(); long firstProjectDayStartEpoch = latestWindowEndExclusive.getEpochSecond() - 30L * 24 * 3600;
Instant startDate; var regOpt = userARepository.findDateRegOfUserWithMinId();
String granularity; if (regOpt.isPresent() && regOpt.get() != null) {
Instant reg = Instant.ofEpochSecond(regOpt.get());
// Determine start date and granularity based on range firstProjectDayStartEpoch = reg.atZone(ZoneOffset.UTC)
switch (range.toLowerCase()) { .toLocalDate()
case "7d": .atStartOfDay(ZoneOffset.UTC)
startDate = now.minus(7, ChronoUnit.DAYS); .toEpochSecond();
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0); // All time
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
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<>(); Map<String, Object> response = new HashMap<>();
response.put("range", range); response.put("latestWindowEndExclusiveEpoch", latestWindowEndExclusive.getEpochSecond());
response.put("granularity", granularity); response.put("firstProjectDayStartEpoch", firstProjectDayStartEpoch);
response.put("data", dataPoints);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
/** /**
* Get user activity time series data (registrations, active players, rounds). * Revenue / deposits and payouts time series.
* @param range Time range: 7d, 30d, 90d, 1y, all * Optional {@code from} + {@code toExclusive} (epoch seconds, UTC instants): daily buckets in [from, toExclusive).
* @return Time series data * Otherwise {@code range}: 7d, 30d, 90d, 1y, all (legacy).
*/ */
@GetMapping("/activity") @GetMapping("/revenue")
public ResponseEntity<Map<String, Object>> getActivityAnalytics( public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
@RequestParam(required = false) Long from,
@RequestParam(required = false) Long toExclusive,
@RequestParam(defaultValue = "30d") String range) { @RequestParam(defaultValue = "30d") String range) {
if (from != null && toExclusive != null) {
Instant start = Instant.ofEpochSecond(from);
Instant end = Instant.ofEpochSecond(toExclusive);
if (!end.isAfter(start)) {
return ResponseEntity.badRequest().build();
}
long maxSpanSeconds = 366L * 24 * 3600;
if (end.getEpochSecond() - start.getEpochSecond() > maxSpanSeconds) {
return ResponseEntity.badRequest().build();
}
List<Map<String, Object>> dataPoints = buildRevenueDailySeries(start, end);
Map<String, Object> response = new HashMap<>();
response.put("range", "custom");
response.put("granularity", "daily");
response.put("from", from);
response.put("toExclusive", toExclusive);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
Instant now = Instant.now(); Instant now = Instant.now();
Instant startDate; Instant startDate;
String granularity; String granularity;
@@ -169,24 +141,7 @@ public class AdminAnalyticsController {
periodEnd = now; periodEnd = now;
} }
// Convert to Unix timestamps for UserA queries dataPoints.add(buildRevenuePoint(current, periodEnd));
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
// Count new registrations in this period (between current and periodEnd)
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
// Count active players (logged in) in this period
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers);
point.put("rounds", 0L);
dataPoints.add(point);
current = periodEnd; current = periodEnd;
} }
@@ -197,5 +152,156 @@ public class AdminAnalyticsController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
}
/**
* User activity time series.
* Optional {@code from} + {@code toExclusive} for daily buckets in [from, toExclusive).
*/
@GetMapping("/activity")
public ResponseEntity<Map<String, Object>> getActivityAnalytics(
@RequestParam(required = false) Long from,
@RequestParam(required = false) Long toExclusive,
@RequestParam(defaultValue = "30d") String range) {
if (from != null && toExclusive != null) {
Instant start = Instant.ofEpochSecond(from);
Instant end = Instant.ofEpochSecond(toExclusive);
if (!end.isAfter(start)) {
return ResponseEntity.badRequest().build();
}
long maxSpanSeconds = 366L * 24 * 3600;
if (end.getEpochSecond() - start.getEpochSecond() > maxSpanSeconds) {
return ResponseEntity.badRequest().build();
}
List<Map<String, Object>> dataPoints = buildActivityDailySeries(start, end);
Map<String, Object> response = new HashMap<>();
response.put("range", "custom");
response.put("granularity", "daily");
response.put("from", from);
response.put("toExclusive", toExclusive);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
Instant now = Instant.now();
Instant startDate;
String granularity;
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0);
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
dataPoints.add(buildActivityPoint(current, periodEnd));
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
private List<Map<String, Object>> buildActivityDailySeries(Instant startInclusive, Instant endExclusive) {
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startInclusive;
while (current.isBefore(endExclusive)) {
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
if (periodEnd.isAfter(endExclusive)) {
periodEnd = endExclusive;
}
dataPoints.add(buildActivityPoint(current, periodEnd));
current = periodEnd;
}
return dataPoints;
}
private Map<String, Object> buildActivityPoint(Instant current, Instant periodEnd) {
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers);
point.put("rounds", 0L);
return point;
}
private List<Map<String, Object>> buildRevenueDailySeries(Instant startInclusive, Instant endExclusive) {
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startInclusive;
while (current.isBefore(endExclusive)) {
Instant periodEnd = current.plus(1, ChronoUnit.DAYS);
if (periodEnd.isAfter(endExclusive)) {
periodEnd = endExclusive;
}
dataPoints.add(buildRevenuePoint(current, periodEnd));
current = periodEnd;
}
return dataPoints;
}
private Map<String, Object> buildRevenuePoint(Instant current, Instant periodEnd) {
BigDecimal revenueUsd = paymentRepository
.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd)
.orElse(ZERO);
BigDecimal payoutsUsd = payoutRepository
.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
Payout.PayoutStatus.COMPLETED, current, periodEnd)
.orElse(ZERO);
BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
long firstTimeDeposits = paymentRepository.countByStatusAndFtdTrueAndUsdNotNullAndCompletedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("revenue", revenueUsd);
point.put("payouts", payoutsUsd);
point.put("netRevenue", netRevenueUsd);
point.put("firstTimeDeposits", firstTimeDeposits);
return point;
}
}

View File

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

View File

@@ -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). */ /** 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") @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p WHERE p.status = :status")
Optional<BigDecimal> sumUsdAmountByStatus(@Param("status") Payment.PaymentStatus 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") @Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u")
int getMaxId(); 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<Integer> findDateRegOfUserWithMinId();
Optional<UserA> findByTelegramId(Long telegramId); Optional<UserA> findByTelegramId(Long telegramId);
/** /**

View File

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

View File

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