From 83c275770133b96af506c0b8337d39f399540dc9 Mon Sep 17 00:00:00 2001 From: Tihon Date: Thu, 19 Mar 2026 12:27:14 +0200 Subject: [PATCH] admin statistics part1 --- .../controller/AdminStatisticsController.java | 59 +++++++++++++++++++ .../controller/TelegramWebhookController.java | 29 +++++++++ .../honey/dto/AdminProjectStatisticsDto.java | 24 ++++++++ .../java/com/honey/honey/model/UserA.java | 5 ++ .../honey/repository/PaymentRepository.java | 4 ++ .../honey/repository/PayoutRepository.java | 4 ++ .../honey/repository/UserARepository.java | 8 +++ .../honey/repository/UserBRepository.java | 3 + .../service/NotificationBroadcastService.java | 24 ++++---- .../com/honey/honey/service/UserService.java | 28 +++++++++ .../db/migration/V75__users_a_bot_active.sql | 6 ++ 11 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/honey/honey/controller/AdminStatisticsController.java create mode 100644 src/main/java/com/honey/honey/dto/AdminProjectStatisticsDto.java create mode 100644 src/main/resources/db/migration/V75__users_a_bot_active.sql diff --git a/src/main/java/com/honey/honey/controller/AdminStatisticsController.java b/src/main/java/com/honey/honey/controller/AdminStatisticsController.java new file mode 100644 index 0000000..0e1df1b --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminStatisticsController.java @@ -0,0 +1,59 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.AdminProjectStatisticsDto; +import com.honey.honey.model.Payment; +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.UserBRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@RestController +@RequestMapping("/api/admin/statistics") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminStatisticsController { + + private final UserARepository userARepository; + private final UserBRepository userBRepository; + private final PaymentRepository paymentRepository; + private final PayoutRepository payoutRepository; + + @GetMapping("/project") + public ResponseEntity getProjectStatistics() { + long registered = userARepository.count(); + long blocked = userARepository.countByBotActiveIsFalse(); + BigDecimal totalDeposits = paymentRepository.sumUsdAmountByStatus(Payment.PaymentStatus.COMPLETED) + .orElse(BigDecimal.ZERO); + BigDecimal totalWithdrawals = payoutRepository.sumUsdAmountByStatus(Payout.PayoutStatus.COMPLETED) + .orElse(BigDecimal.ZERO); + long withDeposit = userBRepository.countUsersWithDeposit(); + + BigDecimal percent = BigDecimal.ZERO; + if (registered > 0) { + percent = BigDecimal.valueOf(withDeposit) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(registered), 2, RoundingMode.HALF_UP); + } + + AdminProjectStatisticsDto dto = AdminProjectStatisticsDto.builder() + .registeredUsers(registered) + .subscribedToBot(registered) + .blockedBot(blocked) + .totalDepositsUsd(totalDeposits) + .totalWithdrawalsUsd(totalWithdrawals) + .usersWithDeposit(withDeposit) + .depositUserPercent(percent) + .build(); + return ResponseEntity.ok(dto); + } +} diff --git a/src/main/java/com/honey/honey/controller/TelegramWebhookController.java b/src/main/java/com/honey/honey/controller/TelegramWebhookController.java index 9885b26..7582c9c 100644 --- a/src/main/java/com/honey/honey/controller/TelegramWebhookController.java +++ b/src/main/java/com/honey/honey/controller/TelegramWebhookController.java @@ -21,6 +21,7 @@ import org.springframework.web.client.HttpClientErrorException; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.Message; import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.ChatMemberUpdated; import org.telegram.telegrambots.meta.api.objects.CallbackQuery; import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery; import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment; @@ -81,6 +82,12 @@ public class TelegramWebhookController { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } try { + // Bot blocked / unblocked in private chat (Telegram my_chat_member) + if (update.hasMyChatMember()) { + handleMyChatMember(update.getMyChatMember()); + return ResponseEntity.ok().build(); + } + // Handle callback queries (button clicks) if (update.hasCallbackQuery()) { handleCallbackQuery(update.getCallbackQuery()); @@ -112,6 +119,27 @@ public class TelegramWebhookController { } } + /** + * Updates {@code bot_active} when the user blocks/unblocks the bot (private chats only). + */ + private void handleMyChatMember(ChatMemberUpdated cmu) { + if (cmu.getChat() == null || cmu.getNewChatMember() == null) { + return; + } + if (!"private".equalsIgnoreCase(cmu.getChat().getType())) { + return; + } + Long telegramUserId = cmu.getChat().getId(); + String status = cmu.getNewChatMember().getStatus(); + if ("kicked".equalsIgnoreCase(status) || "left".equalsIgnoreCase(status)) { + userService.setBotActiveByTelegramId(telegramUserId, false); + log.info("Private chat bot status: user {} set bot_active=false (status={})", telegramUserId, status); + } else if ("member".equalsIgnoreCase(status) || "administrator".equalsIgnoreCase(status)) { + userService.setBotActiveByTelegramId(telegramUserId, true); + log.info("Private chat bot status: user {} set bot_active=true (status={})", telegramUserId, status); + } + } + /** * Handles /start command with optional referral parameter, and Reply Keyboard button clicks. * Format: /start or /start 123 (where 123 is the referral user ID) @@ -221,6 +249,7 @@ public class TelegramWebhookController { try { // Get or create user (handles registration, login update, and referral system) UserA user = userService.getOrCreateUser(tgUserData, httpRequest); + userService.setBotActiveByTelegramId(telegramId, true); log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}", user.getId(), user.getTelegramId(), isNewUser); diff --git a/src/main/java/com/honey/honey/dto/AdminProjectStatisticsDto.java b/src/main/java/com/honey/honey/dto/AdminProjectStatisticsDto.java new file mode 100644 index 0000000..e448940 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminProjectStatisticsDto.java @@ -0,0 +1,24 @@ +package com.honey.honey.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminProjectStatisticsDto { + private long registeredUsers; + /** Same as registered for now; reserved for future definition. */ + private long subscribedToBot; + private long blockedBot; + private BigDecimal totalDepositsUsd; + private BigDecimal totalWithdrawalsUsd; + private long usersWithDeposit; + /** 0–100, two decimals; 0 if no registered users. */ + private BigDecimal depositUserPercent; +} diff --git a/src/main/java/com/honey/honey/model/UserA.java b/src/main/java/com/honey/honey/model/UserA.java index 2c1ea2d..173b32d 100644 --- a/src/main/java/com/honey/honey/model/UserA.java +++ b/src/main/java/com/honey/honey/model/UserA.java @@ -64,6 +64,11 @@ public class UserA { @Column(name = "last_telegram_file_id", length = 255) private String lastTelegramFileId; + + /** When false, user blocked the bot or send failed with 403; used for notification broadcast skip. */ + @Column(name = "bot_active", nullable = false) + @Builder.Default + private boolean botActive = true; } diff --git a/src/main/java/com/honey/honey/repository/PaymentRepository.java b/src/main/java/com/honey/honey/repository/PaymentRepository.java index 92c3d1e..095a274 100644 --- a/src/main/java/com/honey/honey/repository/PaymentRepository.java +++ b/src/main/java/com/honey/honey/repository/PaymentRepository.java @@ -87,5 +87,9 @@ public interface PaymentRepository extends JpaRepository, JpaSpec @Param("start") Instant start, @Param("end") Instant end ); + + /** 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 sumUsdAmountByStatus(@Param("status") Payment.PaymentStatus status); } diff --git a/src/main/java/com/honey/honey/repository/PayoutRepository.java b/src/main/java/com/honey/honey/repository/PayoutRepository.java index e459ec7..4dfca89 100644 --- a/src/main/java/com/honey/honey/repository/PayoutRepository.java +++ b/src/main/java/com/honey/honey/repository/PayoutRepository.java @@ -107,5 +107,9 @@ public interface PayoutRepository extends JpaRepository, JpaSpecif @Param("start") Instant start, @Param("end") Instant end ); + + /** Sum usd_amount for all payouts with given status (null usd ignored by SUM). */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payout p WHERE p.status = :status") + Optional sumUsdAmountByStatus(@Param("status") Payout.PayoutStatus status); } diff --git a/src/main/java/com/honey/honey/repository/UserARepository.java b/src/main/java/com/honey/honey/repository/UserARepository.java index f2917c3..81dcef9 100644 --- a/src/main/java/com/honey/honey/repository/UserARepository.java +++ b/src/main/java/com/honey/honey/repository/UserARepository.java @@ -49,6 +49,14 @@ public interface UserARepository extends JpaRepository, JpaSpeci */ @Query("SELECT COUNT(u) FROM UserA u WHERE u.dateLogin >= :start AND u.dateLogin < :end") long countByDateLoginBetween(@Param("start") Integer start, @Param("end") Integer end); + + long countByBotActiveIsFalse(); + + /** + * Paged users in id range for broadcast when skipping inactive-bot users. + */ + @Query("SELECT u FROM UserA u WHERE u.id >= :fromId AND u.id <= :toId AND u.botActive = true ORDER BY u.id") + Page findByIdBetweenAndBotActiveTrue(@Param("fromId") int fromId, @Param("toId") int toId, Pageable pageable); } diff --git a/src/main/java/com/honey/honey/repository/UserBRepository.java b/src/main/java/com/honey/honey/repository/UserBRepository.java index 6e4cfbf..5eede13 100644 --- a/src/main/java/com/honey/honey/repository/UserBRepository.java +++ b/src/main/java/com/honey/honey/repository/UserBRepository.java @@ -20,6 +20,9 @@ public interface UserBRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT b FROM UserB b WHERE b.id = :id") Optional findByIdForUpdate(@Param("id") Integer id); + + @Query("SELECT COUNT(b) FROM UserB b WHERE b.depositCount > 0") + long countUsersWithDeposit(); } diff --git a/src/main/java/com/honey/honey/service/NotificationBroadcastService.java b/src/main/java/com/honey/honey/service/NotificationBroadcastService.java index 0bf4bfb..dfa14f2 100644 --- a/src/main/java/com/honey/honey/service/NotificationBroadcastService.java +++ b/src/main/java/com/honey/honey/service/NotificationBroadcastService.java @@ -42,6 +42,7 @@ public class NotificationBroadcastService { private final UserARepository userARepository; private final NotificationAuditRepository notificationAuditRepository; private final FeatureSwitchService featureSwitchService; + private final UserService userService; private final AtomicBoolean stopRequested = new AtomicBoolean(false); @@ -59,7 +60,7 @@ public class NotificationBroadcastService { * Run broadcast asynchronously. Uses userIdFrom/userIdTo (internal user ids); if null, uses 1 and max id. * Only one of imageUrl or videoUrl is used; video takes priority if both are set. * If buttonText is non-empty, each message gets an inline button with that text opening the mini app. - * When ignoreBlocked is true, skips users whose latest notification_audit record has status FAILED (e.g. blocked the bot). + * When ignoreBlocked is true, skips users with {@code bot_active = false} on {@code db_users_a}. */ @Async public void runBroadcast(String message, String imageUrl, String videoUrl, @@ -84,23 +85,16 @@ public class NotificationBroadcastService { boolean hasNext = true; long sent = 0; long failed = 0; - long skippedBlocked = 0; while (hasNext && !stopRequested.get()) { Pageable pageable = PageRequest.of(pageNumber, PAGE_SIZE); - Page page = userARepository.findByIdBetween(fromId, toId, pageable); + Page page = skipBlocked + ? userARepository.findByIdBetweenAndBotActiveTrue(fromId, toId, pageable) + : userARepository.findByIdBetween(fromId, toId, pageable); for (UserA user : page.getContent()) { if (stopRequested.get()) break; if (user.getTelegramId() == null) continue; - if (skipBlocked) { - var latest = notificationAuditRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()); - if (latest.isPresent() && NotificationAudit.STATUS_FAILED.equals(latest.get().getStatus())) { - skippedBlocked++; - continue; - } - } - TelegramSendResult result = sendOne(botToken, user.getTelegramId(), message, imageUrl, videoUrl, buttonText); int statusCode = result.getStatusCode(); boolean success = result.isSuccess(); @@ -114,6 +108,10 @@ public class NotificationBroadcastService { .build(); notificationAuditRepository.save(audit); + if (!success && statusCode == 403) { + userService.setBotActiveByUserId(user.getId(), false); + } + try { Thread.sleep(DELAY_MS_BETWEEN_SENDS); } catch (InterruptedException e) { @@ -127,9 +125,9 @@ public class NotificationBroadcastService { } if (stopRequested.get()) { - log.info("Notification broadcast stopped by request. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked); + log.info("Notification broadcast stopped by request. Sent={}, Failed={}", sent, failed); } else { - log.info("Notification broadcast finished. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked); + log.info("Notification broadcast finished. Sent={}, Failed={}", sent, failed); } } diff --git a/src/main/java/com/honey/honey/service/UserService.java b/src/main/java/com/honey/honey/service/UserService.java index 43f315b..2a065e8 100644 --- a/src/main/java/com/honey/honey/service/UserService.java +++ b/src/main/java/com/honey/honey/service/UserService.java @@ -182,6 +182,7 @@ public class UserService { } userA.setIp(ipBytes); userA.setDateLogin((int) nowSeconds); + userA.setBotActive(true); userARepository.save(userA); log.debug("Updated user data on login: userId={}", userA.getId()); @@ -485,5 +486,32 @@ public class UserService { .collect(Collectors.toList()); return new PageImpl<>(content, projectionPage.getPageable(), projectionPage.getTotalElements()); } + + @Transactional + public void setBotActiveByTelegramId(Long telegramId, boolean active) { + Optional opt = userARepository.findByTelegramId(telegramId); + if (opt.isEmpty()) { + log.debug("setBotActiveByTelegramId: no user for telegramId={}", telegramId); + return; + } + UserA u = opt.get(); + if (u.isBotActive() == active) { + return; + } + u.setBotActive(active); + userARepository.save(u); + log.debug("setBotActiveByTelegramId: userId={}, telegramId={}, active={}", u.getId(), telegramId, active); + } + + @Transactional + public void setBotActiveByUserId(Integer userId, boolean active) { + userARepository.findById(userId).ifPresent(u -> { + if (u.isBotActive() == active) { + return; + } + u.setBotActive(active); + userARepository.save(u); + }); + } } diff --git a/src/main/resources/db/migration/V75__users_a_bot_active.sql b/src/main/resources/db/migration/V75__users_a_bot_active.sql new file mode 100644 index 0000000..d956e59 --- /dev/null +++ b/src/main/resources/db/migration/V75__users_a_bot_active.sql @@ -0,0 +1,6 @@ +-- Whether the user can receive messages from the bot (false after block / 403; true after /start or my_chat_member member). +ALTER TABLE db_users_a + ADD COLUMN bot_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1 = bot can message user, 0 = blocked/unreachable'; + +-- Composite index: COUNT(bot_active=0), and id-range queries with bot_active=true (notification broadcast). +CREATE INDEX idx_users_a_bot_active_id ON db_users_a(bot_active, id);