This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user