|
|
|
|
@@ -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<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.findDateRegOfUserWithMinId();
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|