feat: remove AnimalApi cache + !ratingoverview for super-admin

- AnimalApi: drop Caffeine URL cache (30s TTL made !neko return the same photo)
- RatingsRepository.getExtremeRating(guild, type, excluded, highest) with NOT IN
- !ratingoverview (super-admin only): per-category max/min, excluding bots + super-admin
- RatingsRepositoryTest: 7 new tests for getExtremeRating
This commit is contained in:
loki5512344 2026-08-19 14:36:12 +02:00
parent bf14e03b21
commit ec25d9e8d3
Signed by: boba
GPG key ID: 253067914055423B
6 changed files with 242 additions and 35 deletions

32
TODO.md
View file

@ -253,7 +253,7 @@ feature/economy/
- [x] Красивые level-up embed с прогресс-баром
### Приоритет 3
- [ ] Статистика сервера — графики, CSV export
- [x] Статистика сервера — графики, CSV export — ✅ `!serverstats [csv|chart]`
---
@ -263,15 +263,15 @@ feature/economy/
- [x] `!catperson` / `!dogperson` / `!foxperson` — «Какой ты кот/пёс/лис»: % + роль + картинка (детерминировано ID)
- [x] `!shibe` / `!birb` — картинки шиба-ину и птичек
- [x] `!neko` / `!waifu` / `!catboy` — аниме-картинки (неко, вайфу, кошко-мальчик)
- [ ] Факты: `!catfact` / `!dogfact` — факт + картинка (catfact.ninja, dog facts API)
- [ ] `!joke` — анекдоты (jokeapi.dev, категории Any/Programming/Misc)
- [ ] `!chuck` — цитаты Чака Норриса (api.chucknorris.io)
- [ ] `!inspire` — мотивационные цитаты (zenquotes.io)
- [ ] `!apod` — астрономическая картинка дня NASA (api.nasa.gov, бесплатный ключ)
- [ ] `!iss` — где сейчас МКС (api.wheretheiss.at)
- [ ] `!space` — кто сейчас в космосе (open-notify.org)
- [ ] `!xkcd` — свежий стрип (xkcd.com/api)
- [ ] `!meme <subreddit>` — расширить мем по сабреддиту
- [x] Факты: `!catfact` / `!dogfact` — факт + картинка (catfact.ninja, dog facts API)
- [x] `!joke` — анекдоты (jokeapi.dev, категории Any/Programming/Misc)
- [x] `!chuck` — цитаты Чака Норриса (api.chucknorris.io)
- [x] `!inspire` — мотивационные цитаты (zenquotes.io)
- [x] `!apod` — астрономическая картинка дня NASA (api.nasa.gov, DEMO_KEY)
- [x] `!iss` — где сейчас МКС (api.wheretheiss.at)
- [x] `!space` — кто сейчас в космосе (open-notify.org)
- [x] `!xkcd` — свежий стрип (xkcd.com/api)
- [x] `!meme <subreddit>` — расширить мем по сабреддиту
---
@ -296,6 +296,7 @@ feature/economy/
### Оптимизации (2026-08-19)
- [x] `AnimalApi` — Caffeine-кэш успешных URL (TTL 30 с): повторные `!neko`/`!catboy`/зоо-викторины отвечают мгновенно
- [x] `AnimalApi` — кэш полностью убран (2026-08-19): из-за него `!neko` 30с отдавал одну и ту же фотку
- [x] `MemeCommand` — HttpURLConnection (без таймаутов, мог висеть) → OkHttp 6с/8с
- [ ] `Database` — включить `PRAGMA journal_mode = WAL` (сейчас нет — блокировки при конкурентной записи)
- [ ] NekosLifeApi — таймауты/ретраи вынести в конфиг (10с → 8с, retry 3 → 2)
@ -462,15 +463,16 @@ feature/economy/
- [ ] F14: MetricsCollector.java — разбить по типам метрик
- [ ] F15: WarnCommand.java — вынести auto-mute в отдельный сервис
- [x] F16: BotInitializer.java — вынести фабрики ботов — ✅ 162→135 строк
- [x] F02-04: QueueSlashHandler, MusicBotControlHandler, QueueCommands — вынести общие части — ✅ вынесены при реорге music
- [ ] Остальные: разбить по SOLID
### Этап 11: ≤6 файлов в папке
- [ ] P1: Entertainment команды — группировать по смыслу (coin/dice → `random/`, gay/iq/pp → `rating/`)
- [ ] P2: Social команды — объединить в `SocialCommand` с параметром
- [ ] P4: Level — разбить на commands/, service/, repository/
- [ ] P5: Moderation — разбить по поддоменам (commands/punish, commands/utils, warn)
- [ ] P6: Music — разбить на commands/, player/, lavalink/
- [x] P1: Entertainment команды — уже сгруппированы (choice/, random/; rating-команды в feature/rating/)
- [x] P2: Social команды — уже объединены (SocialCommand + 3 отдельных), 4 файла
- [x] P4: Level — уже разбит на commands/, service/, repository/, domain/
- [x] P5: Moderation — уже разбит по поддоменам (punish, utils, warn, caselookup, log, repository)
- [x] P6: Music — ✅ commands/filter|control|queue, listener/bot|select, manage/player|filter, util/embed|voice|query (все папки ≤6)
### Этап 12: Тесты

View file

@ -4,8 +4,6 @@ import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.inject.Inject;
@ -20,18 +18,15 @@ import org.slf4j.LoggerFactory;
/**
* Клиент к публичным API животных и аниме-картинок. Для каждого вида пробует эндпоинты по порядку и
* возвращает первый успешный URL картинки. Все запросы проходят через {@link ApiRequestQueue} —
* ответы изолированы и не могут подмешаться друг к другу. Успешные URL кэшируются на короткое
* время, чтобы повторные команды отвечали мгновенно и не перегружали внешние API.
* ответы изолированы и не могут подмешаться друг к другу.
*/
@Singleton
public class AnimalApi {
private static final Logger logger = LoggerFactory.getLogger(AnimalApi.class);
private static final String NEKOS_BASE = "https://nekos.life/api/v2/img/";
private static final long CACHE_TTL_SECONDS = 30;
private final ApiRequestQueue queue;
private final OkHttpClient client;
private final Cache<String, String> cache;
@Inject
public AnimalApi(final ApiRequestQueue queue) {
@ -41,24 +36,11 @@ public class AnimalApi {
AnimalApi(final ApiRequestQueue queue, final OkHttpClient client) {
this.queue = queue;
this.client = client;
this.cache =
Caffeine.newBuilder()
.maximumSize(64)
.expireAfterWrite(CACHE_TTL_SECONDS, TimeUnit.SECONDS)
.build();
}
/** Возвращает URL картинки для вида или null, если все эндпоинты недоступны. */
public String fetchImage(final AnimalKind kind) {
String cached = cache.getIfPresent(kind.name());
if (cached != null) {
return cached;
}
String url = queue.submit(() -> fetchImageBlocking(kind));
if (url != null) {
cache.put(kind.name(), url);
}
return url;
return queue.submit(() -> fetchImageBlocking(kind));
}
private String fetchImageBlocking(final AnimalKind kind) {

View file

@ -0,0 +1,114 @@
package creator.loki.feature.rating.commands.board;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import creator.loki.command.Command;
import creator.loki.command.CommandCategories;
import creator.loki.command.CommandContext;
import creator.loki.config.BotConfig;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.rating.repository.RatingsRepository;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
/** Максимум и минимум по каждой категории рейтинга (только для супер-админа, без ботов). */
public class RatingOverviewCommand implements Command {
private static final List<String> TYPES =
List.of("ship", "gay", "iq", "pp", "mustache", "fat", "boykisser");
private final RatingsRepository ratingsRepository;
private final BotConfig config;
public RatingOverviewCommand(RatingsRepository ratingsRepository, BotConfig config) {
this.ratingsRepository = ratingsRepository;
this.config = config;
}
@Override
public String getName() {
return "ratingoverview";
}
@Override
public String getDescription() {
return "Максимумы и минимумы рейтингов по всем категориям (только для супер-админа)";
}
@Override
public String getUsage() {
return "ratingoverview";
}
@Override
public String getCategory() {
return CommandCategories.SUPER_ADMIN;
}
@Override
public void execute(final CommandContext context) {
if (!config.getSuperAdminId().equals(context.getAuthor().getUser().getId())) {
context
.getChannel()
.sendMessage("❌ Команда доступна только супер-администратору бота.")
.queue();
return;
}
Guild guild = context.getGuild();
if (guild == null) {
context.getChannel().sendMessage("❌ Команда работает только на сервере.").queue();
return;
}
Set<String> excluded = new HashSet<>();
excluded.add(config.getSuperAdminId());
for (Member member : guild.getMembers()) {
if (member.getUser().isBot()) {
excluded.add(member.getUser().getId());
}
}
StringBuilder description = new StringBuilder();
for (String type : TYPES) {
RatingsRepository.RatingEntry max =
ratingsRepository.getExtremeRating(guild.getId(), type, excluded, true);
RatingsRepository.RatingEntry min =
ratingsRepository.getExtremeRating(guild.getId(), type, excluded, false);
if (max == null || min == null) {
continue;
}
description
.append("**")
.append(type)
.append("**\n🏆 <@")
.append(max.userId())
.append("> — **")
.append(max.value())
.append("**\n📉 <@")
.append(min.userId())
.append("> — **")
.append(min.value())
.append("**\n\n");
}
if (description.isEmpty()) {
context
.getChannel()
.sendMessage("❌ Пока никто не имеет рейтингов. Сначала `!generateratings`.")
.queue();
return;
}
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("📊 Сводка рейтингов")
.withDescription(description.toString())
.withFooter("Без учёта ботов и супер-админа")
.build())
.queue();
}
}

View file

@ -4,6 +4,8 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -152,6 +154,40 @@ public class RatingsRepository {
return getRatingBoard(guildId, ratingType, limit, false);
}
/** Крайнее значение рейтинга (максимум или минимум) без учёта исключённых пользователей. */
public RatingEntry getExtremeRating(
final String guildId,
final String ratingType,
final Collection<String> excludedUserIds,
final boolean highest) {
StringBuilder sql =
new StringBuilder(
"SELECT user_id, value FROM ratings WHERE guild_id = ? AND rating_type = ?");
List<String> params = new ArrayList<>(List.of(guildId, ratingType));
if (excludedUserIds != null && !excludedUserIds.isEmpty()) {
sql.append(" AND user_id NOT IN (")
.append(String.join(",", Collections.nCopies(excludedUserIds.size(), "?")))
.append(")");
params.addAll(excludedUserIds);
}
sql.append(" ORDER BY value ").append(highest ? "DESC" : "ASC").append(" LIMIT 1");
try (PreparedStatement stmt = database.prepare(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
stmt.setString(i + 1, params.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return new RatingEntry(rs.getString("user_id"), rs.getInt("value"));
}
}
} catch (SQLException e) {
logger.error("Ошибка получения крайнего рейтинга {} для сервера {}", ratingType, guildId, e);
}
return null;
}
private List<RatingEntry> getRatingBoard(
final String guildId, final String ratingType, final int limit, final boolean descending) {
String sql =

View file

@ -18,6 +18,7 @@ import creator.loki.feature.info.commands.ServerStatsCommand;
import creator.loki.feature.metrics.MetricsCollector;
import creator.loki.feature.metrics.commands.MetricsCommand;
import creator.loki.feature.rating.commands.board.RatingBoardCommand;
import creator.loki.feature.rating.commands.board.RatingOverviewCommand;
import creator.loki.feature.rating.repository.RatingsRepository;
import creator.loki.feature.reputation.MinusRepCommand;
import creator.loki.feature.reputation.RepCommand;
@ -81,6 +82,7 @@ public class UtilityCommandRegistrar implements CommandRegistrar {
registry.register(new GenerateRatingsCommand(ratingsRepository, config));
registry.register(new RatingBoardCommand(RatingBoardCommand.Flavor.TOP, ratingsRepository));
registry.register(new RatingBoardCommand(RatingBoardCommand.Flavor.BOTTOM, ratingsRepository));
registry.register(new RatingOverviewCommand(ratingsRepository, config));
registry.register(new RepCommand(reputationRepository, cooldownManager));
registry.register(new MinusRepCommand(reputationRepository, cooldownManager));

View file

@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import java.util.Map;
import creator.loki.database.Database;
@ -259,4 +260,74 @@ class RatingsRepositoryTest {
assertTrue(repository.getBottomRatings("guild1", "iq", 10).isEmpty());
}
}
@Nested
class Extreme {
@Test
void highestWithoutExclusions() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild1", "user2", "iq", 150);
var max = repository.getExtremeRating("guild1", "iq", List.of(), true);
assertEquals("user2", max.userId());
assertEquals(150, max.value());
}
@Test
void lowestWithoutExclusions() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild1", "user2", "iq", 50);
var min = repository.getExtremeRating("guild1", "iq", List.of(), false);
assertEquals("user2", min.userId());
assertEquals(50, min.value());
}
@Test
void highestSkipsExcludedUsers() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild1", "bot1", "iq", 200);
repository.setRating("guild1", "admin", "iq", 180);
var max = repository.getExtremeRating("guild1", "iq", List.of("bot1", "admin"), true);
assertEquals("user1", max.userId());
assertEquals(100, max.value());
}
@Test
void lowestSkipsExcludedUsers() {
repository.setRating("guild1", "user1", "iq", 5);
repository.setRating("guild1", "bot1", "iq", 1);
repository.setRating("guild1", "admin", "iq", 3);
var min = repository.getExtremeRating("guild1", "iq", List.of("bot1", "admin"), false);
assertEquals("user1", min.userId());
assertEquals(5, min.value());
}
@Test
void returnsNullWhenAllExcluded() {
repository.setRating("guild1", "user1", "iq", 100);
var max = repository.getExtremeRating("guild1", "iq", List.of("user1"), true);
assertNull(max);
}
@Test
void returnsNullWhenNoRatings() {
assertNull(repository.getExtremeRating("guild1", "iq", List.of(), true));
assertNull(repository.getExtremeRating("guild1", "iq", List.of("user1"), false));
}
@Test
void filtersByGuildAndType() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild2", "user1", "iq", 200);
repository.setRating("guild1", "user1", "gay", 90);
assertEquals(100, repository.getExtremeRating("guild1", "iq", List.of(), true).value());
assertEquals(200, repository.getExtremeRating("guild2", "iq", List.of(), true).value());
assertEquals(90, repository.getExtremeRating("guild1", "gay", List.of(), true).value());
}
}
}