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