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

This commit is contained in:
Tihon
2026-03-19 12:27:14 +02:00
parent 90efdf1f59
commit 83c2757701
11 changed files with 181 additions and 13 deletions

View File

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

View File

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

View File

@@ -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;
/** 0100, two decimals; 0 if no registered users. */
private BigDecimal depositUserPercent;
}

View File

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

View File

@@ -87,5 +87,9 @@ public interface PaymentRepository extends JpaRepository<Payment, Long>, 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<BigDecimal> sumUsdAmountByStatus(@Param("status") Payment.PaymentStatus status);
}

View File

@@ -107,5 +107,9 @@ public interface PayoutRepository extends JpaRepository<Payout, Long>, 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<BigDecimal> sumUsdAmountByStatus(@Param("status") Payout.PayoutStatus status);
}

View File

@@ -49,6 +49,14 @@ public interface UserARepository extends JpaRepository<UserA, Integer>, 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<UserA> findByIdBetweenAndBotActiveTrue(@Param("fromId") int fromId, @Param("toId") int toId, Pageable pageable);
}

View File

@@ -20,6 +20,9 @@ public interface UserBRepository extends JpaRepository<UserB, Integer> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM UserB b WHERE b.id = :id")
Optional<UserB> findByIdForUpdate(@Param("id") Integer id);
@Query("SELECT COUNT(b) FROM UserB b WHERE b.depositCount > 0")
long countUsersWithDeposit();
}

View File

@@ -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<UserA> page = userARepository.findByIdBetween(fromId, toId, pageable);
Page<UserA> 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);
}
}

View File

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

View File

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