Создайте отдельную учебную базу
Скачайте SQLite CLI с официальной страницы: на Windows нужен комплект tools, на Linux можно использовать пакет sqlite3 вашего дистрибутива. В новой пустой папке выполните sqlite3 rewards-test.db; на Windows с распакованным exe в текущей папке — .\sqlite3.exe rewards-test.db из PowerShell. Интерактивная консоль покажет приглашение sqlite>.
Это отдельный тест схемы, не прямое редактирование БД рабочего плагина. SQLite хранит UUID текстом; у PostgreSQL можно выбрать тип uuid. Синтаксис ниже относится именно к SQLite и использует PRIMARY KEY, CHECK и внешние ключи.
Создайте две таблицы
Вставьте SQL в консоль или сохраните как 001_rewards.sql и выполните sqlite3 rewards-test.db ".read 001_rewards.sql". Включайте foreign_keys для каждого соединения. Запись разблокировки и намерение награды используют один составной ключ: UUID, стабильное правило и период. Для постоянной цели период может быть permanent.
PRAGMA foreign_keys = ON;
CREATE TABLE unlocks (
player_uuid TEXT NOT NULL,
rule_id TEXT NOT NULL,
period_key TEXT NOT NULL,
PRIMARY KEY (player_uuid, rule_id, period_key)
);
CREATE TABLE reward_outbox (
player_uuid TEXT NOT NULL,
rule_id TEXT NOT NULL,
period_key TEXT NOT NULL,
amount INTEGER NOT NULL CHECK (amount > 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paying', 'paid', 'review')),
PRIMARY KEY (player_uuid, rule_id, period_key),
FOREIGN KEY (player_uuid, rule_id, period_key)
REFERENCES unlocks(player_uuid, rule_id, period_key)
);Повторите одну транзакцию два раза
Сохраните следующий SQL как unlock.sql и запустите sqlite3 rewards-test.db ".read unlock.sql" дважды. UUID ниже учебный. В ответ каждый раз должно быть 1 для каждой таблицы. ON CONFLICT DO NOTHING обрабатывает повтор известного ключа, не скрывая произвольные CHECK-ошибки через общий INSERT OR IGNORE.
Обе записи создаются одной транзакцией. Если outbox-INSERT не проходит, в Java обработайте исключение и выполните rollback; затем не сообщайте игроку об успехе. Уникальность не зависит от HashSet и сохраняется при закрытии/повторном открытии SQLite.
PRAGMA foreign_keys = ON;
BEGIN IMMEDIATE;
INSERT INTO unlocks VALUES
('00000000-0000-4000-8000-000000000001', 'first_cave', 'permanent')
ON CONFLICT DO NOTHING;
INSERT INTO reward_outbox(player_uuid, rule_id, period_key, amount) VALUES
('00000000-0000-4000-8000-000000000001', 'first_cave', 'permanent', 50)
ON CONFLICT DO NOTHING;
COMMIT;
SELECT count(*) FROM unlocks;
SELECT count(*) FROM reward_outbox;Подключите модель к плагину через JDBC
В Java-плагине используйте PreparedStatement и setString/setInt для параметров, try-with-resources для соединений/запросов и явный rollback в обработке ошибки. Начало подключения и драйверы разобраны в Paper Using databases; SQLite JDBC выпускает Xerial. Сам CLI-пример уже проверяет схему, но не подменяет этот интеграционный код.
Outbox не делает внешний Vault-вызов частью SQLite-транзакции. До внешней выдачи сохраните paying, после подтверждения — paid. Если сервер упал между вызовом и записью, переводите неоднозначную операцию в review и сверяйте историю провайдера, когда API не принимает идемпотентный ключ. SQL-запросы выполняйте вне игрового потока с ограниченной очередью.
Сначала сформулируйте факты, которые нельзя потерять
Профиль хранит устойчивую идентичность, прогресс — измеряемое состояние, а разблокировка — подтверждённый результат конкретного правила. Разделите их до выбора таблиц. Для ежедневной награды ключ должен содержать период; для постоянного достижения — стабильный идентификатор правила. Отображаемое русское название можно менять без повторной выдачи.
У события есть отдельный eventId. Запрос от GUI или внешней интеграции может повториться из-за сетевого сбоя. Уникальное ограничение на этот идентификатор позволяет распознать уже принятую операцию. Проверка «SELECT, затем INSERT» без транзакции и ограничения оставляет окно, в котором два конкурирующих запроса увидят отсутствие записи.
Пример схемы награды
Таблица unlocks связывает player_uuid, rule_id и period_key, а их комбинация имеет UNIQUE. Таблица reward_outbox хранит id, ссылку на разблокировку, тип награды, величину и состояние обработки. Изменение фактов, создание разблокировки и намерения награды происходит одной транзакцией. Если транзакция не завершилась, интерфейс не сообщает о заработанном результате.
Это пример архитектуры, а не универсальная SQL-схема для любой БД. Тип UUID, синтаксис конфликтов и уровень изоляции выбираются по конкретному движку. Важно сохранить инвариант: одной разблокировке соответствует один набор намерений награды. Список в памяти может ускорять чтение, но устойчивое ограничение остаётся в хранилище.
Слушатель передаёт событие успеха, а хранилище решает одноразовую разблокировку. Подтверждённые игровые факты.
Внешний Vault не входит в вашу SQL-транзакцию
Если деньги находятся в другом плагине, запись собственной БД и Vault-вызов не становятся одной атомарной операцией. Сбой после начисления, но до отметки успеха оставляет неопределённость. Повтор «на всякий случай» способен выдать деньги дважды. Нужно явно выбрать политику такой ситуации и сохранить сведения для сверки.
Один вариант журнала использует pending, paying и paid. Перед внешним вызовом устойчиво записывается paying, после успешного ответа — paid. Незавершённое paying после аварии требует review, если внешняя система не умеет идемпотентный ключ. Администратор сверяет конкретную операцию с историей экономики. Не обещайте exactly once, если используемый API не предоставляет необходимый контракт.
Цена, списание и выдача должны сохранять смысл при повторном запросе и рестарте. Покупки и устойчивый журнал.
Запросы выполняйте с ограниченным ресурсом
Используйте параметры PreparedStatement для пользовательских значений. Имя таблицы и другие структурные части SQL выбирайте из фиксированного набора, а не принимайте строкой от игрока. Закрывайте Statement и ResultSet, задавайте предел получения соединения и размер пула. Сотня кликов не должна создать сотню необслуживаемых соединений.
Долгую работу с БД выносите из игрового потока. Возврат результата требует актуальной сессии, если он меняет GUI или игрока. Для покупки сам факт записи может завершиться после отключения; доставка сообщения является отдельным действием. Уточните это в контракте сервиса, чтобы выход пользователя не отменил уже подтверждённое списание без возврата.
Миграции проверяйте на прежних данных
Добавление столбца с осмысленным default отличается от пересоздания таблицы с потерей истории. При удалении или переименовании поля учитывайте старый бинарник: возможность вернуть JAR не означает совместимость обновлённой схемы. Для крупных переходов планируйте миграцию и откат как отдельную работу.
- Храните номер применённой схемы.
- Сделайте согласованную копию перед изменением.
- Выполните миграцию существующего набора, включая пустые и редкие записи.
- Проверьте количество фактов и уникальные ограничения.
- Убедитесь, что повторный запуск не применяет изменение второй раз.
- Зафиксируйте совместимость и способ возврата прежнего приложения.
Тестируйте сбой между шагами
Два одновременных завершения должны создать одну разблокировку. Повтор того же eventId не меняет итог. Рестарт после записи outbox сохраняет намерение, а после внешнего неопределённого вызова не делает слепую повторную выдачу. Эти тесты проверяют смысл механики лучше, чем один успешный INSERT в пустую БД.
Используйте реальный движок для ограничений и транзакций, а не только mock репозитория. Для SQLite предусмотрите поддерживаемый backup, учитывающий WAL. Журнал ошибок содержит идентификатор операции и безопасную причину; пароль соединения не нужен для диагностики дубликата. После восстановления копии повторите игровой сценарий и чтение прежних наград.



