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

This commit is contained in:
Tihon
2026-03-20 13:39:38 +02:00
parent 2bf2125c3e
commit 31768fcc07
11 changed files with 604 additions and 9 deletions

View File

@@ -5,6 +5,8 @@ import com.honey.honey.model.Payout;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserDRepository;
import com.honey.honey.service.AdminStatisticsTableService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -31,11 +33,47 @@ public class AdminAnalyticsController {
private static final BigDecimal ZERO = BigDecimal.ZERO;
private final UserARepository userARepository;
private final UserDRepository userDRepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final AdminStatisticsTableService adminStatisticsTableService;
/**
* Bounds for admin statistics-by-day UI: UTC day start of the earliest {@code date_reg} in the system
* Paginated daily / monthly statistics table (admin).
*/
@GetMapping("/statistics-table")
public ResponseEntity<Map<String, Object>> getStatisticsTable(
@RequestParam String mode,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
AdminStatisticsTableService.TableMode tableMode;
try {
tableMode = AdminStatisticsTableService.TableMode.valueOf(mode.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
int cappedSize = Math.min(200, Math.max(1, size));
int safePage = Math.max(0, page);
var dtoPage = adminStatisticsTableService.getStatisticsTable(tableMode, safePage, cappedSize);
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
response.put("mode", tableMode.name());
return ResponseEntity.ok(response);
}
/**
* Bounds for admin statistics-by-day UI: UTC day start of the earliest {@code db_users_d.date_reg}
* and exclusive end of the latest 30-day window (start of tomorrow UTC).
*/
@GetMapping("/meta")
@@ -48,8 +86,8 @@ public class AdminAnalyticsController {
.toInstant();
long firstProjectDayStartEpoch = latestWindowEndExclusive.getEpochSecond() - 30L * 24 * 3600;
var regOpt = userARepository.findMinDateReg();
if (regOpt.isPresent() && regOpt.get() != null) {
var regOpt = userDRepository.findMinDateReg();
if (regOpt.isPresent() && regOpt.get() != null && regOpt.get() > 0) {
Instant reg = Instant.ofEpochSecond(regOpt.get());
firstProjectDayStartEpoch = reg.atZone(ZoneOffset.UTC)
.toLocalDate()

View File

@@ -0,0 +1,36 @@
package com.honey.honey.dto;
import lombok.Builder;
import lombok.Value;
import java.math.BigDecimal;
@Value
@Builder
public class AdminStatisticsTableRowDto {
/** ISO date yyyy-MM-dd (day mode) or yyyy-MM (month mode). */
String periodKey;
/** Human-readable label for the period. */
String periodLabel;
long regs;
long regsOurs;
long regsNonOurs;
/** Placeholder for future use; null = not implemented. */
Long deadOurs;
Long deadNonOurs;
long depositsCount;
BigDecimal depositsSumUsd;
long ftdCount;
/** Average USD per payment where usd_amount is present; null if none. */
BigDecimal avgPaymentUsd;
long withdrawalsCount;
BigDecimal withdrawalsSumUsd;
/** (1 - withdrawals/deposits) * 100; null if deposits sum is zero. */
BigDecimal profitPercent;
BigDecimal profitUsd;
}

View File

@@ -20,6 +20,11 @@ public class UserD {
@Builder.Default
private String screenName = "-";
/** Unix registration time (seconds); kept in sync with db_users_a.date_reg on signup. */
@Column(name = "date_reg", nullable = false)
@Builder.Default
private Integer dateReg = 0;
@Column(name = "referer_id_1", nullable = false)
@Builder.Default
private Integer refererId1 = 0;

View File

@@ -0,0 +1,302 @@
package com.honey.honey.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* Native aggregate queries for the admin statistics-by-day/month table.
* Registration metrics use {@code db_users_d.date_reg} only (no join to {@code db_users_a}).
*/
@Repository
public class AdminStatisticsTableRepository {
private static final BigDecimal ZERO = new BigDecimal("0.00");
@PersistenceContext
private EntityManager entityManager;
/**
* Mutable per-period aggregates filled by this repository.
*/
public static final class StatisticsTableBucket {
public long regs;
public long regsOurs;
public long depositsCount;
public long ftdCount;
public BigDecimal depositsSumUsd = ZERO;
public BigDecimal avgPaymentUsd;
public long withdrawalsCount;
public BigDecimal withdrawalsSumUsd = ZERO;
}
public void loadRegistrationBucketsByDay(long startSec, long endSec, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(FROM_UNIXTIME(d.date_reg)) AS b, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()).regs = toLong(row[1]);
}
}
public void loadRegistrationOursBucketsByDay(long startSec, long endSec, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(FROM_UNIXTIME(d.date_reg)) AS b, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
AND d.referer_id_1 = d.master_id
AND d.id <> d.master_id
AND d.master_id > 0
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()).regsOurs = toLong(row[1]);
}
}
public void loadPaymentBucketsByDay(Instant start, Instant end, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(p.completed_at) AS b,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd,
AVG(p.usd_amount) AS avg_usd,
SUM(CASE WHEN p.ftd = 1 THEN 1 ELSE 0 END) AS ftd
FROM payments p
WHERE p.status = 'COMPLETED'
AND p.completed_at IS NOT NULL
AND p.completed_at >= :startTs AND p.completed_at < :endTs
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(d, x -> new StatisticsTableBucket());
b.depositsCount = toLong(row[1]);
b.depositsSumUsd = readBd(row[2]);
b.avgPaymentUsd = readBdNullable(row[3]);
b.ftdCount = toLong(row[4]);
}
}
public void loadPayoutBucketsByDay(Instant start, Instant end, Map<LocalDate, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE(COALESCE(p.resolved_at, p.updated_at, p.created_at)) AS b,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd
FROM payouts p
WHERE p.status = 'COMPLETED'
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) >= :startTs
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) < :endTs
GROUP BY b
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
LocalDate d = toLocalDate(row[0]);
if (d == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(d, x -> new StatisticsTableBucket());
b.withdrawalsCount = toLong(row[1]);
b.withdrawalsSumUsd = readBd(row[2]);
}
}
public void loadRegistrationBucketsByMonth(long startSec, long endSec, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(FROM_UNIXTIME(d.date_reg), '%Y-%m') AS ym, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()).regs = toLong(row[1]);
}
}
public void loadRegistrationOursBucketsByMonth(long startSec, long endSec, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(FROM_UNIXTIME(d.date_reg), '%Y-%m') AS ym, COUNT(*) AS c
FROM db_users_d d
WHERE d.date_reg >= :startSec AND d.date_reg < :endSec
AND d.referer_id_1 = d.master_id
AND d.id <> d.master_id
AND d.master_id > 0
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startSec", startSec);
q.setParameter("endSec", endSec);
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()).regsOurs = toLong(row[1]);
}
}
public void loadPaymentBucketsByMonth(Instant start, Instant end, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(p.completed_at, '%Y-%m') AS ym,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd,
AVG(p.usd_amount) AS avg_usd,
SUM(CASE WHEN p.ftd = 1 THEN 1 ELSE 0 END) AS ftd
FROM payments p
WHERE p.status = 'COMPLETED'
AND p.completed_at IS NOT NULL
AND p.completed_at >= :startTs AND p.completed_at < :endTs
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket());
b.depositsCount = toLong(row[1]);
b.depositsSumUsd = readBd(row[2]);
b.avgPaymentUsd = readBdNullable(row[3]);
b.ftdCount = toLong(row[4]);
}
}
public void loadPayoutBucketsByMonth(Instant start, Instant end, Map<String, StatisticsTableBucket> buckets) {
String sql = """
SELECT DATE_FORMAT(COALESCE(p.resolved_at, p.updated_at, p.created_at), '%Y-%m') AS ym,
COUNT(*) AS cnt,
COALESCE(SUM(p.usd_amount), 0) AS sum_usd
FROM payouts p
WHERE p.status = 'COMPLETED'
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) >= :startTs
AND COALESCE(p.resolved_at, p.updated_at, p.created_at) < :endTs
GROUP BY ym
""";
Query q = entityManager.createNativeQuery(sql);
q.setParameter("startTs", Timestamp.from(start));
q.setParameter("endTs", Timestamp.from(end));
@SuppressWarnings("unchecked")
List<Object[]> rows = q.getResultList();
for (Object[] row : rows) {
String ym = row[0] != null ? row[0].toString() : null;
if (ym == null) {
continue;
}
StatisticsTableBucket b = buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket());
b.withdrawalsCount = toLong(row[1]);
b.withdrawalsSumUsd = readBd(row[2]);
}
}
private static LocalDate toLocalDate(Object o) {
if (o == null) {
return null;
}
if (o instanceof LocalDate ld) {
return ld;
}
if (o instanceof Date sd) {
return sd.toLocalDate();
}
if (o instanceof Timestamp ts) {
return ts.toLocalDateTime().toLocalDate();
}
if (o instanceof java.util.Date ud) {
return new Timestamp(ud.getTime()).toLocalDateTime().toLocalDate();
}
return null;
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
if (o instanceof Number n) {
return n.longValue();
}
return 0L;
}
private static BigDecimal readBd(Object o) {
if (o == null) {
return ZERO;
}
if (o instanceof BigDecimal bd) {
return bd.setScale(2, RoundingMode.HALF_UP);
}
if (o instanceof Number n) {
return BigDecimal.valueOf(n.doubleValue()).setScale(2, RoundingMode.HALF_UP);
}
return ZERO;
}
private static BigDecimal readBdNullable(Object o) {
if (o == null) {
return null;
}
if (o instanceof BigDecimal bd) {
return bd.setScale(2, RoundingMode.HALF_UP);
}
if (o instanceof Number n) {
return BigDecimal.valueOf(n.doubleValue()).setScale(2, RoundingMode.HALF_UP);
}
return null;
}
}

View File

@@ -25,9 +25,6 @@ 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

@@ -11,10 +11,17 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserDRepository extends JpaRepository<UserD, Integer> {
/**
* Earliest {@code date_reg} on {@code db_users_d} (registration time), ignoring unset rows.
*/
@Query("SELECT MIN(d.dateReg) FROM UserD d WHERE d.dateReg > 0")
Optional<Integer> findMinDateReg();
/**
* Increments referals_1 for a user.
*/

View File

@@ -0,0 +1,196 @@
package com.honey.honey.service;
import com.honey.honey.dto.AdminStatisticsTableRowDto;
import com.honey.honey.repository.AdminStatisticsTableRepository;
import com.honey.honey.repository.AdminStatisticsTableRepository.StatisticsTableBucket;
import com.honey.honey.repository.UserDRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AdminStatisticsTableService {
private static final BigDecimal ZERO = new BigDecimal("0.00");
private static final DateTimeFormatter MONTH_LABEL = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH);
private final AdminStatisticsTableRepository adminStatisticsTableRepository;
private final UserDRepository userDRepository;
public enum TableMode {
DAY,
MONTH
}
public Page<AdminStatisticsTableRowDto> getStatisticsTable(TableMode mode, int page, int size) {
LocalDate todayUtc = LocalDate.now(ZoneOffset.UTC);
LocalDate firstDay = resolveFirstProjectDay(todayUtc);
if (mode == TableMode.DAY) {
return buildDayPage(todayUtc, firstDay, page, size);
}
return buildMonthPage(todayUtc, firstDay, page, size);
}
private LocalDate resolveFirstProjectDay(LocalDate todayUtc) {
Optional<Integer> minReg = userDRepository.findMinDateReg();
if (minReg.isEmpty() || minReg.get() == null || minReg.get() <= 0) {
return todayUtc;
}
LocalDate d = Instant.ofEpochSecond(minReg.get()).atZone(ZoneOffset.UTC).toLocalDate();
if (d.isAfter(todayUtc)) {
return todayUtc;
}
return d;
}
private Page<AdminStatisticsTableRowDto> buildDayPage(LocalDate todayUtc, LocalDate firstDay, int page, int size) {
long totalPeriods = ChronoUnit.DAYS.between(firstDay, todayUtc) + 1;
if (totalPeriods < 1) {
totalPeriods = 1;
}
int offset = page * size;
if (offset >= totalPeriods) {
return new PageImpl<>(List.of(), PageRequest.of(page, size), totalPeriods);
}
int pageLen = (int) Math.min(size, totalPeriods - offset);
LocalDate newestOnPage = todayUtc.minusDays(offset);
LocalDate oldestOnPage = todayUtc.minusDays(offset + pageLen - 1);
long startSec = oldestOnPage.atStartOfDay(ZoneOffset.UTC).toEpochSecond();
long endSec = newestOnPage.plusDays(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond();
Instant instStart = Instant.ofEpochSecond(startSec);
Instant instEnd = Instant.ofEpochSecond(endSec);
Map<LocalDate, StatisticsTableBucket> buckets = new HashMap<>();
adminStatisticsTableRepository.loadRegistrationBucketsByDay(startSec, endSec, buckets);
adminStatisticsTableRepository.loadRegistrationOursBucketsByDay(startSec, endSec, buckets);
adminStatisticsTableRepository.loadPaymentBucketsByDay(instStart, instEnd, buckets);
adminStatisticsTableRepository.loadPayoutBucketsByDay(instStart, instEnd, buckets);
List<AdminStatisticsTableRowDto> rows = new ArrayList<>(pageLen);
for (int i = 0; i < pageLen; i++) {
int k = offset + i;
LocalDate day = todayUtc.minusDays(k);
rows.add(toRowDay(day, buckets.getOrDefault(day, new StatisticsTableBucket())));
}
return new PageImpl<>(rows, PageRequest.of(page, size), totalPeriods);
}
private Page<AdminStatisticsTableRowDto> buildMonthPage(LocalDate todayUtc, LocalDate firstDay, int page, int size) {
YearMonth nowYm = YearMonth.from(todayUtc);
YearMonth firstYm = YearMonth.from(firstDay);
long totalMonths = (nowYm.getYear() - firstYm.getYear()) * 12L
+ (nowYm.getMonthValue() - firstYm.getMonthValue()) + 1;
if (totalMonths < 1) {
totalMonths = 1;
}
int offset = page * size;
if (offset >= totalMonths) {
return new PageImpl<>(List.of(), PageRequest.of(page, size), totalMonths);
}
int pageLen = (int) Math.min(size, totalMonths - offset);
YearMonth newestOnPage = nowYm.minusMonths(offset);
YearMonth oldestOnPage = nowYm.minusMonths(offset + pageLen - 1);
long startSec = oldestOnPage.atDay(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond();
long endSec = newestOnPage.plusMonths(1).atDay(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond();
Instant instStart = Instant.ofEpochSecond(startSec);
Instant instEnd = Instant.ofEpochSecond(endSec);
Map<String, StatisticsTableBucket> buckets = new HashMap<>();
adminStatisticsTableRepository.loadRegistrationBucketsByMonth(startSec, endSec, buckets);
adminStatisticsTableRepository.loadRegistrationOursBucketsByMonth(startSec, endSec, buckets);
adminStatisticsTableRepository.loadPaymentBucketsByMonth(instStart, instEnd, buckets);
adminStatisticsTableRepository.loadPayoutBucketsByMonth(instStart, instEnd, buckets);
List<AdminStatisticsTableRowDto> rows = new ArrayList<>(pageLen);
for (int i = 0; i < pageLen; i++) {
int k = offset + i;
YearMonth ym = nowYm.minusMonths(k);
rows.add(toRowMonth(ym, buckets.getOrDefault(ym.toString(), new StatisticsTableBucket())));
}
return new PageImpl<>(rows, PageRequest.of(page, size), totalMonths);
}
private AdminStatisticsTableRowDto toRowDay(LocalDate day, StatisticsTableBucket b) {
long nonOurs = Math.max(0, b.regs - b.regsOurs);
BigDecimal profitUsd = b.depositsSumUsd.subtract(b.withdrawalsSumUsd).setScale(2, RoundingMode.HALF_UP);
BigDecimal profitPct = null;
if (b.depositsSumUsd.compareTo(ZERO) > 0) {
BigDecimal ratio = b.withdrawalsSumUsd.divide(b.depositsSumUsd, 8, RoundingMode.HALF_UP);
profitPct = BigDecimal.ONE.subtract(ratio).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP);
}
return AdminStatisticsTableRowDto.builder()
.periodKey(day.toString())
.periodLabel(day.toString())
.regs(b.regs)
.regsOurs(b.regsOurs)
.regsNonOurs(nonOurs)
.deadOurs(null)
.deadNonOurs(null)
.depositsCount(b.depositsCount)
.depositsSumUsd(b.depositsSumUsd)
.ftdCount(b.ftdCount)
.avgPaymentUsd(b.avgPaymentUsd)
.withdrawalsCount(b.withdrawalsCount)
.withdrawalsSumUsd(b.withdrawalsSumUsd)
.profitPercent(profitPct)
.profitUsd(profitUsd)
.build();
}
private AdminStatisticsTableRowDto toRowMonth(YearMonth ym, StatisticsTableBucket b) {
long nonOurs = Math.max(0, b.regs - b.regsOurs);
BigDecimal profitUsd = b.depositsSumUsd.subtract(b.withdrawalsSumUsd).setScale(2, RoundingMode.HALF_UP);
BigDecimal profitPct = null;
if (b.depositsSumUsd.compareTo(ZERO) > 0) {
BigDecimal ratio = b.withdrawalsSumUsd.divide(b.depositsSumUsd, 8, RoundingMode.HALF_UP);
profitPct = BigDecimal.ONE.subtract(ratio).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP);
}
return AdminStatisticsTableRowDto.builder()
.periodKey(ym.toString())
.periodLabel(ym.format(MONTH_LABEL))
.regs(b.regs)
.regsOurs(b.regsOurs)
.regsNonOurs(nonOurs)
.deadOurs(null)
.deadNonOurs(null)
.depositsCount(b.depositsCount)
.depositsSumUsd(b.depositsSumUsd)
.ftdCount(b.ftdCount)
.avgPaymentUsd(b.avgPaymentUsd)
.withdrawalsCount(b.withdrawalsCount)
.withdrawalsSumUsd(b.withdrawalsSumUsd)
.profitPercent(profitPct)
.profitUsd(profitUsd)
.build();
}
}

View File

@@ -266,7 +266,7 @@ public class UserService {
userBRepository.save(userB);
// Create UserD with referral handling
UserD userD = createUserDWithReferral(userId, screenName, start);
UserD userD = createUserDWithReferral(userId, screenName, start, (int) nowSeconds);
userDRepository.save(userD);
return userA;
@@ -281,7 +281,7 @@ public class UserService {
* @param screenName User's screen name (from db_users_a)
* @param start Referral parameter (from bot registration, not WebApp)
*/
private UserD createUserDWithReferral(Integer userId, String screenName, String start) {
private UserD createUserDWithReferral(Integer userId, String screenName, String start, int dateReg) {
log.debug("Creating UserD with referral: userId={}, start={}", userId, start);
// Defensive check: Ensure UserD doesn't already exist (should never happen, but safety check)
@@ -294,6 +294,7 @@ public class UserService {
UserD.UserDBuilder builder = UserD.builder()
.id(userId)
.screenName(screenName != null ? screenName : "-")
.dateReg(dateReg)
.refererId1(0)
.refererId2(0)
.refererId3(0)

View File

@@ -0,0 +1,2 @@
-- Admin statistics table & analytics: range scans on COMPLETED payments by completion time
CREATE INDEX idx_payments_status_completed_at ON payments (status, completed_at);

View File

@@ -0,0 +1,2 @@
-- Admin statistics: registrations "ours" (referer_id_1 = master_id, id <> master_id, master_id > 0)
CREATE INDEX idx_users_d_master_referer1 ON db_users_d (master_id, referer_id_1);

View File

@@ -0,0 +1,9 @@
-- Mirror registration time on db_users_d for analytics without joining db_users_a
ALTER TABLE db_users_d
ADD COLUMN date_reg INT NOT NULL DEFAULT 0 COMMENT 'Registration unix time (seconds); same as db_users_a.date_reg' AFTER screen_name;
UPDATE db_users_d d
INNER JOIN db_users_a a ON a.id = d.id
SET d.date_reg = a.date_reg;
CREATE INDEX idx_users_d_date_reg ON db_users_d (date_reg);