VulCode
Blog

Jak zrobić itemshop dla serwera Minecraft: płatność i automatyczne nadanie

Dwa sposoby nadania przedmiotu po zapłacie: komenda przez RCON i kolejka, z której plugin sam pobiera dostawy. Pokazujemy oba, z tym, czego każdy nie załatwi, na kodzie naszego sklepu w Flaem.

VulCodeVulCode22 min czytania
Jak zrobić itemshop dla serwera Minecraft: płatność i automatyczne nadanie - VulCode
Co zbudujeszJava, 6 kroków
  1. /sklep vip-30W grzePlugin otwiera zamówienie dla tego gracza.
  2. PłatnośćPrzeglądarkaJednorazowy link, bramka, weryfikacja.
  3. Kolejka dostawSklepOpłacone zamówienie czeka na serwer.
  4. NadanieSerwerPlugin nadaje, gdy gracz jest online.

Poradnik w skrócie

Gracz wpisuje /sklep vip-30, klika link, płaci, a po kilkunastu sekundach dostaje rangę na serwerze. Jeśli w tym czasie wyszedł z gry, ranga czeka, aż wróci. Nikt nie wpisuje komend ręcznie i nikt nie dostaje rangi dwa razy.

Itemshop dla serwera Minecraft

Grupa
Gry i Discord
Poziom
zaawansowany
Czas czytania
około 22 min
Kod
Java

Pliki: config.yml, SklepCommand.java, platnosc.sql, rcon.py, dostawy.sql, Dostawy.java.

Kroki
6 kroków

W 6 z nich kod albo polecenia do skopiowania.

Sprawdzenie
Paper API 26.3.build.157-beta, Java 25, testowy serwer RCON

Obie klasy skompilowane, pakiet logowania RCON w formacie z minecraft.wiki

Dokumentacja
3 oficjalne dokumenty

Minecraft Wiki: RCON; Minecraft Usage Guidelines, część o serwerach; PaperMC: BasicCommand

Z naszego kodu
Flaem

frozcore/src/shop/queue.rs

Zanim zaczniesz

Pokazujemy dwa sposoby nadania. RCON jest najprostszy, ale ma ograniczenia, o których trzeba wiedzieć. Kolejka, z której plugin sam pobiera dostawy, wymaga więcej kodu i rozwiązuje te ograniczenia. Tak działa sklep w naszym Flaem.

Co musisz mieć
Itemshop dla serwera Minecraft
Serwer Paper 26.3 i plugin
Kod pluginu z tego poradnika kompiluje się z Paper API 26.3 i Javą 25. Jeśli nie pisałeś jeszcze pluginu, zacznij od poradnika o pierwszym pluginie na Paper.
Bramka płatności
Rejestracja, powiadomienie i weryfikacja płatności. Na przykładzie Przelewy24 opisujemy to w osobnym poradniku. Zamówienie jest opłacone dopiero po weryfikacji, nie po powrocie gracza ze strony płatności.
Baza danych dla sklepu
Zamówienia, ich stan płatności i stan dostawy w jednym wierszu. Przykłady SQL są dla PostgreSQL.
Zasady sprzedaży na serwerze
Wytyczne Mojang pozwalają pobierać opłaty za dostęp do serwera i sprzedawać kosmetyki oraz uprawnienia, które nie psują gry innym i nie dają przewagi w rywalizacji. Sprawdź je, zanim ułożysz ofertę.

RCON czy kolejka z pluginem

PytanieRCONKolejka z pluginem
Otwarty port na serwerze gryTak, TCP, połączenie nieszyfrowaneNie, plugin łączy się na zewnątrz przez HTTPS
Gracz offline w chwili zapłatyKomenda wykona się bez graczaDostawa czeka, aż gracz wejdzie
Ochrona przed podwójnym nadaniemBrak, trzeba ją dopisaćDzierżawa i potwierdzenie
Ilość koduMałaPlugin i dwa endpointy

Krok po kroku

6 kroków. Okno obok pokazuje plik kroku, który właśnie czytasz.

Opisz przedmioty kluczami, nie komendami

W bazie sklepu przedmiot ma klucz dostawy, na przykład vip-30, a nie gotową komendę. Co klucz znaczy, wie dopiero plugin na serwerze, z własnego config.yml. Ktoś, kto edytuje ceny w panelu sklepu, nie dostaje w ten sposób dostępu do konsoli serwera.

Otwieraj zamówienie z gry, nie z formularza

Nick nie jest tożsamością: można wpisać cudzy, a na wielu serwerach nicki się zmieniają. Dlatego to plugin, który zna UUID gracza, otwiera zamówienie i dostaje jednorazowy link do płatności. Przeglądarka gracza nigdy nie mówi sklepowi, kto kupuje.

Zapytanie HTTP idzie asynchronicznie, a odpowiedź do gracza wraca na wątek serwera przez runTask. Link wygasa po 60 minutach, żeby porzucone zamówienie nie było ofertą do wykorzystania za miesiąc po starej cenie.

Po weryfikacji płatności dopisz dostawę do kolejki

Kiedy bramka potwierdzi płatność, w tej samej transakcji bazy oznaczasz zamówienie jako opłacone i ustawiasz stan dostawy w_kolejce. Pieniądze przyjęte i nic do nadania to stan, którego baza nie powinna umieć zapisać.

Wariant prosty: komenda przez RCON

RCON włączasz w server.properties: enable-rcon=true, rcon.password i rcon.port, domyślnie 25575. Pakiet ma długość, identyfikator żądania, typ i treść, liczby są little-endian. Typ 3 to logowanie, typ 2 to komenda. Błędne hasło serwer sygnalizuje identyfikatorem -1.

RCON nie jest szyfrowany, więc port nigdy nie może być otwarty na internet: tylko localhost albo sieć prywatna między sklepem a serwerem. I RCON nie wie, czy gracz jest online, więc /give dla gracza offline się nie uda, a sklep, który nie czyta odpowiedzi serwera, uzna dostawę za zrobioną.

Wariant pełny: kolejka z dzierżawą

Plugin co kilkanaście sekund pyta sklep o dostawy. Sklep nie oddaje ich po prostu, tylko wydziela w dzierżawę: wiersze dostają token i termin, a do jego upływu żadne inne zapytanie ich nie zobaczy. Plugin, który zapyta dwa razy, nie nada więc przedmiotu dwa razy.

Potwierdzenie to jedno warunkowe UPDATE, które przechodzi tylko z aktualnym tokenem. Powtórzone potwierdzenie niczego nie zmienia, a dzierżawa, która wygasła, wraca do kolejki.

Plugin, który nadaje dostawy

Pobieranie z sieci idzie w zadaniu asynchronicznym co 15 sekund. Sprawdzenie gracza i komendy wracają na wątek główny serwera, bo tylko tam wolno wywołać dispatchCommand. Gracz offline to zwykły przypadek: plugin nie potwierdza dostawy, dzierżawa wygasa po 10 minutach i sklep proponuje ją ponownie.

Uwaga
To dostarczanie co najmniej raz, nie dokładnie raz. Plugin, który nada przedmiot i padnie przed potwierdzeniem, dostanie tę dostawę ponownie. Pole proby mówi mu, że to nie pierwsza próba: zapamiętuj identyfikatory nadanych zamówień, zanim nadasz coś drugi raz.
Okno kodukrok 1 z 6
plugins/Sklep/config.ymlNowy plik, 7 linii
# config.yml pluginu: co oznacza każdy klucz dostawyprzedmioty:  vip-30:    - "lp user {gracz} parent addtemp vip 30d"    - "tellraw {gracz} {\"text\":\"Dziękujemy za VIP-a!\",\"color\":\"gold\"}"  diamenty-64:    - "give {gracz} minecraft:diamond 64"
SklepCommand.javaNowy plik, 40 linii
public final class SklepCommand implements BasicCommand {     private final JavaPlugin plugin;    private final HttpClient http = HttpClient.newHttpClient();    private final String api;    private final String token;     public SklepCommand(JavaPlugin plugin, String api, String token) {        this.plugin = plugin;        this.api = api;        this.token = token;    }     @Override    public void execute(CommandSourceStack source, String[] args) {        if (!(source.getExecutor() instanceof Player player) || args.length != 1) {            source.getSender().sendRichMessage("<gray>Użycie: /sklep <przedmiot>");            return;        }        JsonObject body = new JsonObject();        body.addProperty("gracz_uuid", player.getUniqueId().toString());        body.addProperty("gracz_nick", player.getName());        body.addProperty("przedmiot", args[0]);        HttpRequest request = HttpRequest.newBuilder(URI.create(api + "/zamowienia"))            .header("Authorization", "Bearer " + token)            .header("Content-Type", "application/json")            .POST(HttpRequest.BodyPublishers.ofString(body.toString()))            .build();        http.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenAccept(response ->            Bukkit.getScheduler().runTask(plugin, () -> {                if (response.statusCode() != 201) {                    player.sendRichMessage("<red>Nie udało się otworzyć zamówienia. Spróbuj za chwilę.");                    return;                }                String link = JsonParser.parseString(response.body()).getAsJsonObject().get("link").getAsString();                player.sendMessage(Component.text("Kliknij, żeby zapłacić (link działa 60 minut)", NamedTextColor.GREEN)                    .clickEvent(ClickEvent.openUrl(link)));            }));    }}
platnosc.sqlNowy plik, 4 linie
-- w tej samej transakcji, w której weryfikujesz płatność w bramceUPDATE zamowienia   SET oplacone_at = now(), stan_dostawy = 'w_kolejce' WHERE id = $1 AND oplacone_at IS NULL;
rcon.pyNowy plik, 42 linie
import socketimport struct  class Rcon:    """Klient RCON Minecrafta: pakiety little-endian, typ 3 to logowanie, typ 2 to komenda."""     def __init__(self, host: str, port: int, password: str) -> None:        self.sock = socket.create_connection((host, port), timeout=5)        self.next_id = 0        request_id = self._send(3, password)        reply_id, _ = self._read()        if reply_id == -1 or reply_id != request_id:            raise PermissionError("RCON: złe hasło")     def _send(self, kind: int, payload: str) -> int:        self.next_id += 1        body = struct.pack("<ii", self.next_id, kind) + payload.encode("utf-8") + b"\x00\x00"        self.sock.sendall(struct.pack("<i", len(body)) + body)        return self.next_id     def _read(self) -> tuple[int, str]:        (length,) = struct.unpack("<i", self._exact(4))        data = self._exact(length)        request_id, _kind = struct.unpack("<ii", data[:8])        return request_id, data[8:-2].decode("utf-8", "replace")     def _exact(self, size: int) -> bytes:        chunks = b""        while len(chunks) < size:            chunk = self.sock.recv(size - len(chunks))            if not chunk:                raise ConnectionError("RCON: połączenie zamknięte")            chunks += chunk        return chunks     def command(self, command: str) -> str:        request_id = self._send(2, command)        reply_id, text = self._read()        if reply_id != request_id:            raise RuntimeError("RCON: odpowiedź na inne żądanie")        return text
dostawy.sqlNowy plik, 21 linii
-- POST /dostawy/dzierzawa: maksymalnie 50 dostaw, które ten serwer jest winnyWITH due AS (    SELECT id FROM zamowienia     WHERE serwer_id = $1       AND (stan_dostawy = 'w_kolejce'            OR (stan_dostawy = 'wydzierzawione' AND dzierzawa_do < now()))     ORDER BY oplacone_at     LIMIT 50     FOR UPDATE SKIP LOCKED)UPDATE zamowienia z   SET stan_dostawy = 'wydzierzawione', dzierzawa_token = $2,       dzierzawa_do = now() + interval '10 minutes', proby = z.proby + 1  FROM due WHERE z.id = due.idRETURNING z.id, z.gracz_uuid, z.klucz, z.proby; -- POST /dostawy/{id}/potwierdz: zalicza tylko ten, kto trzyma aktualną dzierżawęUPDATE zamowienia   SET stan_dostawy = 'dostarczone', dostarczone_at = now() WHERE id = $1 AND serwer_id = $2 AND dzierzawa_token = $3   AND stan_dostawy = 'wydzierzawione';
Dostawy.javaNowy plik, 61 linii
public final class Dostawy {     private final JavaPlugin plugin;    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();    private final String api;    // https://sklep.example/api/serwer    private final String token;  // token tego serwera z panelu sklepu     public Dostawy(JavaPlugin plugin, String api, String token) {        this.plugin = plugin;        this.api = api;        this.token = token;    }     public void start() {        Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::pobierz, 20L, 20L * 15);    }     private void pobierz() {        JsonObject lease = post("/dostawy/dzierzawa", "{}");        if (lease == null) return;        String leaseToken = lease.get("lease_token").getAsString();        for (JsonElement element : lease.getAsJsonArray("dostawy")) {            JsonObject dostawa = element.getAsJsonObject();            UUID gracz = UUID.fromString(dostawa.get("gracz_uuid").getAsString());            String klucz = dostawa.get("klucz").getAsString();            String id = dostawa.get("id").getAsString();            Bukkit.getScheduler().runTask(plugin, () -> nadaj(leaseToken, id, gracz, klucz));        }    }     /** Wątek główny serwera: tylko tu wolno sprawdzić gracza i wykonać komendę. */    private void nadaj(String leaseToken, String id, UUID uuid, String klucz) {        Player player = Bukkit.getPlayer(uuid);        if (player == null) return;  // offline: dzierżawa wygaśnie, sklep zaproponuje dostawę ponownie        List<String> komendy = plugin.getConfig().getStringList("przedmioty." + klucz);        boolean wszystkie = !komendy.isEmpty();        for (String komenda : komendy) {            wszystkie &= Bukkit.dispatchCommand(Bukkit.getConsoleSender(), komenda.replace("{gracz}", player.getName()));        }        String uwaga = wszystkie ? "nadano" : "nieznany klucz albo komenda";        Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->            post("/dostawy/" + id + "/potwierdz", "{\"lease_token\":\"" + leaseToken + "\",\"uwaga\":\"" + uwaga + "\"}"));    }     private JsonObject post(String path, String body) {        try {            HttpRequest request = HttpRequest.newBuilder(URI.create(api + path))                .timeout(Duration.ofSeconds(15))                .header("Authorization", "Bearer " + token)                .header("Content-Type", "application/json")                .POST(HttpRequest.BodyPublishers.ofString(body))                .build();            HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());            if (response.statusCode() != 200) return null;            return JsonParser.parseString(response.body()).getAsJsonObject();        } catch (Exception e) {            plugin.getLogger().fine("Sklep niedostępny, spróbuję za 15 s: " + e.getMessage());            return null;        }    }}

Sprawdź, czy działa

Obie klasy pluginu, SklepCommand i Dostawy, skompilowaliśmy 6 października 2026 r. z Paper API 26.3.build.157-beta i Javą 25. Klienta RCON sprawdziliśmy na serwerze testowym napisanym według formatu pakietów z minecraft.wiki, nie na prawdziwym serwerze Minecraft. Pakiet logowania z hasłem "tajne" wygląda tak jak niżej.

terminal6 października 2026Paper API 26.3.build.157-beta, Java 25, testowy serwer RCON
$ gradle build
com/example/sklep/Dostawy.class
com/example/sklep/SklepCommand.class
$ python rcon_test.py
pakiet logowania (typ 3, id 1, "tajne"):
0f 00 00 00 01 00 00 00 03 00 00 00 74 61 6a 6e 65 00 00
Obie klasy skompilowane, pakiet logowania RCON w formacie z minecraft.wiki
Co znaczą linie wyniku
  1. linie 1-3Klasy pluginu

    SklepCommand i Dostawy skompilowane z Paper API 26.3.build.157-beta i Javą 25.

  2. linie 4-5Klient RCON

    Sprawdzony na serwerze testowym napisanym według formatu pakietów z minecraft.wiki, nie na prawdziwym serwerze Minecraft.

  3. linie 6Pakiet bajt po bajcie

    Długość 15, identyfikator 1, typ 3, hasło "tajne" i dwa bajty zerowe na końcu; liczby zapisane od najmłodszego bajtu.

Po czym poznasz, że działa u Ciebie

  • /sklep vip-30 odpowiada w grze klikalnym linkiem.
  • Po weryfikacji płatności zamówienie ma stan w_kolejce.
  • Gracz online dostaje przedmiot w ciągu 15 sekund, a zamówienie przechodzi w dostarczone.
  • Gracz offline dostaje przedmiot po wejściu, a dwa równoległe zapytania pluginu nie dają dwóch dostaw.

Najczęstsze błędy

Ta sama droga co w "Co zbudujesz" na górze strony, z miejscami, w których praca staje najczęściej. Przy każdym błędzie: objaw, przyczyna i co zrobić.

Gdzie staje i dlaczego

/sklep vip-30

W grze

Plugin otwiera zamówienie dla tego gracza.

    Płatność

    Przeglądarka

    Jednorazowy link, bramka, weryfikacja.

      Kolejka dostaw

      Sklep

      Opłacone zamówienie czeka na serwer.

      • Zatrzymuje się: Ranga nadana dwa razy

        Plugin zapytał ponownie po przekroczeniu limitu czasu i sklep oddał te same dostawy. Co zrobić: Wydzielaj dostawy w dzierżawę z tokenem i potwierdzaj warunkowym UPDATE.

      Nadanie

      Serwer

      Plugin nadaje, gdy gracz jest online.

      • Zatrzymuje się: Connection refused na porcie RCON

        enable-rcon=false albo inny rcon.port niż w sklepie. Co zrobić: Włącz RCON w server.properties i zrestartuj serwer.

      • Zatrzymuje się: Odpowiedź RCON z identyfikatorem -1

        Złe hasło albo komenda wysłana przed zalogowaniem. Co zrobić: Zaloguj się pakietem typu 3 z rcon.password przed pierwszą komendą.

      • Zatrzymuje się: Gracz zapłacił, a nic nie dostał

        Komenda przez RCON wykonana, gdy gracz był offline. Co zrobić: Sprawdzaj obecność gracza przed nadaniem albo przejdź na kolejkę z pluginem.

      • Zatrzymuje się: Wyjątek przy dispatchCommand w zadaniu asynchronicznym

        Komenda wywołana poza wątkiem głównym serwera. Co zrobić: Przekaż nadanie na wątek główny przez Bukkit.getScheduler().runTask.

      Jak to robimy u siebie

      Ten sam temat w kodzie, który utrzymujemy: Flaem.

      Panel sklepu serwera z kolejką nagród czekających na graczy

      Sklep z przedmiotami w Flaem

      Ten poradnik to uproszczona wersja sklepu, który zbudowaliśmy w Flaem: frozcore/src/shop. Gracz nie ma tam konta ani hasła, jest identyfikowany przez to, co zgłasza sama gra, na przykład UUID konta Minecraft, i to plugin potwierdza tę tożsamość tokenem serwera. Przedmiot nie może nieść komendy konsoli, właśnie po to, żeby uprawnienie do edycji sklepu nie było dostępem do konsoli.

      Flaem przyjmuje też zakupy z gotowych sklepów: Tebex, CraftingStore i ItemShop.pl. Ich webhooki z podpisem HMAC zamieniamy na tę samą kolejkę dostaw, a plugin frozcraft pobiera je tak jak dostawy z własnego sklepu.

      frozcore/src/shop/queue.rsrust, 18 linii
      //! The queue the plugin works from: what a server owes its players, leased out,//! handed over and acknowledged.//!//! A plugin will ask twice. It restarts, its HTTP call times out after the answer//! was already written, an operator starts a second copy of the server by mistake.//! A queue that answered the second poll with the same rows would have the plugin//! hand the same rank over twice.//!//! So a poll LEASES what it returns: the rows move to `leased` with a lease token//! and an expiry, and no second poll sees them while the lease is live. Delivery is//! acknowledged against that token, and the acknowledgement is one conditional//! `UPDATE`, so a duplicated acknowledgement is a no-op rather than a second//! delivery. /// How long a lease holds a delivery. Generous, because it covers a plugin that has/// taken the item list and is waiting for the player to be online; bounded, because/// a server that dies holding a lease must not owe the player forever.pub const LEASE_TTL_MINUTES: i64 = 10;

      Opis kolejki dostaw ze sklepu Flaem, z kodu źródłowego.

      Powiązane strony i źródła

      Usługa, narzędzia i hasła, do których odsyła ten poradnik, i kolejne poradniki.

      Oficjalna dokumentacja

      Wolisz, żeby zrobił to ktoś, kto już to robił?

      Opisz swój system. Odpiszemy w godzinę z planem integracji i wyceną.

      Pytania