Refactor card management to support asynchronous operations and improve error handling
Java CI / build (push) Successful in 47s
.NET+Docker CI/CD / Unit and Integration tests (push) Failing after 23s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Has been skipped

Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 3 minutes
This commit is contained in:
Dmitrii
2026-06-29 03:03:59 +03:00
parent 25929286c6
commit 77e525b890
9 changed files with 279 additions and 260 deletions
+3 -3
View File
@@ -20,11 +20,11 @@ jobs:
dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore
run: dotnet restore --project backend/SpMega.Backend/SpMega.Backend.csproj
- name: Build
run: dotnet build --no-restore
run: dotnet build --no-restore --project backend/SpMega.Backend/SpMega.Backend.csproj
- name: Test
run: dotnet test --no-build --verbosity normal
run: dotnet test --no-build --verbosity normal --project backend/SpMega.Backend/SpMega.Backend.csproj
compose:
name: Push Docker image to ghcr.io
@@ -9,7 +9,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
public class ChatListener {
@@ -57,8 +56,7 @@ public class ChatListener {
if (client.player != null) {
String playerUuid = client.player.getUuidAsString();
CompletableFuture
.supplyAsync(() -> BankUiService.instance().addCard(cardId, tokenId, playerUuid))
BankUiService.instance().addCardAsync(cardId, tokenId, playerUuid)
.thenAccept(msg -> {
if (client == null) {
return;
@@ -26,6 +26,7 @@ import net.minecraft.text.Text;
import net.minecraft.util.ActionResult;
import org.lwjgl.glfw.GLFW;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -166,25 +167,13 @@ public class SPMegaClient implements ClientModInitializer {
}
UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight);
});
// CompletableFuture.supplyAsync(() -> BackendAuthenticator.authenticate(client))
// .thenAccept(authResult -> {
// if (authResult) {
// System.out.println("SPMega: Authenticated on backend successfully.");
// } else {
// System.err.println("SPMega: Authentication on backend failed");
// }
// })
// .exceptionally(ex -> {
// System.err.println("SPMega: Authentication error: " + ex.getMessage());
// return null;
// });
}
);
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
if (client.player != null) {
BankUiService.instance().refreshOnServerJoin(client.player.getUuidAsString());
BankUiService.instance().refreshOnServerJoinAsync(client.player.getUuidAsString());
}
});
@@ -238,8 +227,13 @@ public class SPMegaClient implements ClientModInitializer {
});
new ChatListener().register();
BackendAuthenticator.authenticate(MinecraftClient.getInstance());
CompletableFuture.runAsync(() -> BackendAuthenticator.authenticate(MinecraftClient.getInstance()))
.exceptionally(throwable -> {
System.err.println("[SPMEGA] Error during async authenticate: " + throwable.getMessage());
return null;
});
System.out.println("Author of SPMega make it with 4 cans of monster");
System.out.println("If u want to see more updates - give me like 10 shekels for monster plzzz");
System.out.println("Initialized beshalom! Tieie tovim!");
}
}
@@ -7,7 +7,6 @@ import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.gui.widget.TextFieldWidget;
import net.minecraft.text.Text;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
public class AddCardScreen extends Screen {
@@ -85,8 +84,7 @@ public class AddCardScreen extends Screen {
addButton.active = false;
cancelButton.active = false;
CompletableFuture
.supplyAsync(() -> bankUiService.addCard(cardId, cardToken, playerUuid))
bankUiService.addCardAsync(cardId, cardToken, playerUuid)
.thenAccept(message -> {
if (this.client == null) {
return;
@@ -9,7 +9,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class CardScreen extends Screen {
private final Screen parent;
@@ -46,25 +45,30 @@ public class CardScreen extends Screen {
}
this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> {
CompletableFuture.supplyAsync(() -> {
bankUiService.removeSelectedCard();
return true;
});
notifications.showMessage(bankUiService.getLastMessage());
bankUiService.removeSelectedCardAsync()
.thenAccept(message -> {
if (this.client != null) {
this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
});
}
});
}).dimensions(rightX, startY, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Обновить"), button -> {
String playerUuid = this.client != null && this.client.player != null
? this.client.player.getUuidAsString()
: "";
CompletableFuture.supplyAsync(() -> {
bankUiService.refreshSelectedCard(playerUuid);
return true;
});
String message = bankUiService.getLastMessage().isBlank() ? "Карта обновлена" : bankUiService.getLastMessage();
bankUiService.refreshSelectedCardAsync(playerUuid)
.thenAccept(message -> {
if (this.client != null) {
this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
});
}
});
}).dimensions(rightX, startY + 24, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> {
@@ -12,7 +12,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -382,8 +381,7 @@ public class PaymentScreen extends Screen {
}
lastRecipientLookup = username;
CompletableFuture
.supplyAsync(() -> bankUiService.loadRecipientCards(username))
bankUiService.loadRecipientCardsAsync(username)
.thenAccept(cards -> {
if (this.client == null) {
return;
@@ -84,12 +84,11 @@ public class BackendAuthenticator {
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("sessionId")) {
return json.get("sessionId").getAsString();
} else {
if (!json.has("sessionId")) {
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
throw new IOException("Invalid response from start endpoint: " + response.body());
}
return json.get("sessionId").getAsString();
}
private static boolean sendAuthRequestToBackend(UUID uuid, String serverId) throws IOException, InterruptedException {
@@ -123,9 +122,16 @@ public class BackendAuthenticator {
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("token")) {
if (!json.has("token")) {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new IOException("Invalid response from validate endpoint: " + response.body());
}
if (config == null) {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new IOException("Config is null, cannot save token.");
}
String token = json.get("token").getAsString();
if (config != null) {
ModConfig updated = new ModConfig(
config.apiDomain(),
token,
@@ -137,14 +143,6 @@ public class BackendAuthenticator {
SPMega.setConfig(updated);
System.out.println("[SPMEGA] Backend auth successful, saved token.");
return true;
} else {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new IOException("Config is null, cannot save token.");
}
} else {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new IOException("Invalid response from validate endpoint: " + response.body());
}
}
public static void sendCardToBackend(String cardId, String cardToken) {
@@ -223,19 +221,13 @@ public class BackendAuthenticator {
List<CardCredentials> cards = new ArrayList<>();
for (com.google.gson.JsonElement el : cardsArray) {
JsonObject cardJson = el.getAsJsonObject();
String cardId = "";
if (cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()) {
cardId = cardJson.get("cardId").getAsString();
} else if (cardJson.has("id") && !cardJson.get("id").isJsonNull()) {
cardId = cardJson.get("id").getAsString();
}
String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
? cardJson.get("cardId").getAsString()
: (cardJson.has("id") && !cardJson.get("id").isJsonNull() ? cardJson.get("id").getAsString() : "");
String cardToken = "";
if (cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()) {
cardToken = cardJson.get("cardToken").getAsString();
} else if (cardJson.has("token") && !cardJson.get("token").isJsonNull()) {
cardToken = cardJson.get("token").getAsString();
}
String cardToken = cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()
? cardJson.get("cardToken").getAsString()
: (cardJson.has("token") && !cardJson.get("token").isJsonNull() ? cardJson.get("token").getAsString() : "");
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken));
@@ -299,10 +291,10 @@ public class BackendAuthenticator {
String url = apiDomain + "/api/v1/transactions";
JsonObject jsonPayload = new JsonObject();
jsonPayload.addProperty("senderCardId", senderCardId);
jsonPayload.addProperty("cardToUse", senderCardId);
jsonPayload.addProperty("cardId", senderCardId);
jsonPayload.addProperty("receiver", receiver);
jsonPayload.addProperty("recipient", receiver);
jsonPayload.addProperty("receiverCard", receiver);
jsonPayload.addProperty("receiverName", receiver);
jsonPayload.addProperty("amount", amount);
jsonPayload.addProperty("comment", comment);
String requestBody = jsonPayload.toString();
@@ -316,11 +308,10 @@ public class BackendAuthenticator {
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 || response.statusCode() == 201 || response.statusCode() == 204) {
return true;
} else {
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) {
throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body());
}
return true;
}
public static List<BankDatabase.LocalTransaction> fetchTransactionsFromBackend(String cardId) throws IOException, InterruptedException {
@@ -5,6 +5,8 @@ import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.api.SPWorldsApiClient;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.MinecraftClient;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@@ -17,7 +19,7 @@ public final class BankUiService {
private final String apiDomain = "https://spworlds.ru";
private int selectedCardIndex;
private String lastMessage = "";
private volatile String lastMessage = "";
private BankUiService() {
this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db"));
@@ -54,11 +56,20 @@ public final class BankUiService {
return normalized.substring(normalized.length() - 5);
}
public synchronized List<CardViewModel> getCards() {
private void runOnMainThread(Runnable runnable) {
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || client.isOnThread()) {
runnable.run();
} else {
client.execute(runnable);
}
}
public List<CardViewModel> getCards() {
return Collections.unmodifiableList(cards);
}
public synchronized CardViewModel getSelectedCard() {
public CardViewModel getSelectedCard() {
if (cards.isEmpty()) {
return null;
}
@@ -66,7 +77,7 @@ public final class BankUiService {
return cards.get(selectedCardIndex);
}
public synchronized int getSelectedCardIndex() {
public int getSelectedCardIndex() {
if (cards.isEmpty()) {
return 0;
}
@@ -74,7 +85,7 @@ public final class BankUiService {
return selectedCardIndex;
}
public synchronized void setSelectedCardIndex(int index) {
public void setSelectedCardIndex(int index) {
if (cards.isEmpty()) {
selectedCardIndex = 0;
return;
@@ -82,7 +93,7 @@ public final class BankUiService {
selectedCardIndex = Math.floorMod(index, cards.size());
}
public synchronized CardViewModel cycleSelectedCard(int direction) {
public CardViewModel cycleSelectedCard(int direction) {
if (cards.isEmpty()) {
return null;
}
@@ -90,18 +101,19 @@ public final class BankUiService {
return cards.get(selectedCardIndex);
}
public synchronized String getLastMessage() {
public String getLastMessage() {
return lastMessage;
}
public synchronized void syncCardsWithBackend(String playerUuid) {
public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) {
return CompletableFuture.runAsync(() -> {
try {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
boolean changed = false;
for (CardCredentials creds : backendCards) {
if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCard(creds.cardId(), creds.cardToken(), playerUuid, false, false);
refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false);
changed = true;
}
}
@@ -111,24 +123,38 @@ public final class BankUiService {
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
});
}
public synchronized void refreshOnServerJoin(String playerUuid) {
syncCardsWithBackend(playerUuid);
public CompletableFuture<Void> refreshOnServerJoinAsync(String playerUuid) {
return CompletableFuture.runAsync(() -> {
try {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
for (CardCredentials creds : backendCards) {
if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false);
}
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
List<StoredCard> storedCards = database.loadCards();
for (StoredCard card : storedCards) {
refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false);
refreshCardSync(card.cardId(), card.cardToken(), playerUuid, false, false);
}
reloadCardsFromDb();
});
}
public synchronized List<String> loadRecipientCards(String username) {
public CompletableFuture<List<String>> loadRecipientCardsAsync(String username) {
CardCredentials credentials = getSelectedCredentials();
if (credentials == null || username == null || username.isBlank()) {
return List.of();
return CompletableFuture.completedFuture(List.of());
}
return CompletableFuture.supplyAsync(() -> {
try {
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials));
List<String> numbers = new ArrayList<>();
@@ -143,75 +169,82 @@ public final class BankUiService {
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
return List.of();
}
});
}
public synchronized String addCard(String cardIdRaw, String cardTokenRaw, String playerUuid) {
public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) {
lastMessage = "Укажи cardId и cardToken";
return lastMessage;
return CompletableFuture.completedFuture("Укажи cardId и cardToken");
}
try {
UUID.fromString(cardId);
} catch (IllegalArgumentException exception) {
lastMessage = "cardId должен быть UUID";
return lastMessage;
return CompletableFuture.completedFuture("cardId должен быть UUID");
}
return CompletableFuture.supplyAsync(() -> {
database.upsertCardCredentials(cardId, cardToken);
boolean refreshed = refreshCard(cardId, cardToken, playerUuid, true, true);
boolean refreshed = refreshCardSync(cardId, cardToken, playerUuid, true, true);
if (!refreshed) {
database.deleteCard(cardId);
}
reloadCardsFromDb();
if (refreshed && lastMessage.isBlank()) {
lastMessage = "Карта добавлена";
return "Карта добавлена";
}
return lastMessage;
});
}
public synchronized void removeSelectedCard() {
public CompletableFuture<String> removeSelectedCardAsync() {
CardViewModel selected = getSelectedCard();
if (selected == null) {
lastMessage = "Нет карты для удаления";
return;
return CompletableFuture.completedFuture("Нет карты для удаления");
}
String cardId = selected.id();
return CompletableFuture.supplyAsync(() -> {
database.deleteCard(cardId);
ModConfig config = SPMega.getConfig();
if (config.allowBackend())
if (config != null && config.allowBackend()) {
BackendAuthenticator.deleteCardOnBackend(cardId);
}
reloadCardsFromDb();
runOnMainThread(() -> {
if (selectedCardIndex >= cards.size()) {
selectedCardIndex = Math.max(0, cards.size() - 1);
}
lastMessage = "Карта удалена";
});
return "Карта удалена";
});
}
public synchronized void refreshSelectedCard(String playerUuid) {
public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) {
CardViewModel selected = getSelectedCard();
if (selected == null) {
lastMessage = "Нет карты для обновления";
return;
return CompletableFuture.completedFuture("Нет карты для обновления");
}
return CompletableFuture.supplyAsync(() -> {
CardCredentials credentials = database.getCredentials(selected.id());
if (credentials == null) {
lastMessage = "Не найдены креды карты";
return;
return "Не найдены креды карты";
}
refreshCard(credentials.cardId(), credentials.cardToken(), playerUuid, false, false);
refreshCardSync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false);
reloadCardsFromDb();
return lastMessage.isBlank() ? "Карта обновлена" : lastMessage;
});
}
public synchronized boolean submitPayment(PaymentDraft draft) {
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> {
CardCredentials credentials = database.getCredentials(draft.senderCardId());
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
@@ -231,7 +264,7 @@ public final class BankUiService {
draft.comment()
);
try {
refreshCard(credentials.cardId(), credentials.cardToken(), "", false, false);
refreshCardSync(credentials.cardId(), credentials.cardToken(), "", false, false);
StoredCard updatedCard = database.loadCards().stream()
.filter(c -> c.cardId().equals(credentials.cardId()))
.findFirst()
@@ -277,13 +310,10 @@ public final class BankUiService {
lastMessage = "Ошибка перевода: " + exception.getMessage();
return false;
}
});
}
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> submitPayment(draft));
}
private boolean refreshCard(
private boolean refreshCardSync(
String cardId,
String cardToken,
String playerUuid,
@@ -319,12 +349,9 @@ public final class BankUiService {
}
}
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
if (config.allowBackend())
CompletableFuture.supplyAsync(() -> {
if (config != null && config.allowBackend()) {
BackendAuthenticator.sendCardToBackend(cardId, cardToken);
return true;
}
);
lastMessage = "";
return true;
@@ -336,8 +363,8 @@ public final class BankUiService {
private void reloadCardsFromDb() {
List<StoredCard> storedCards = database.loadCards();
runOnMainThread(() -> {
cards.clear();
for (StoredCard stored : storedCards) {
String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank()
? extractLastDigits(stored.cardId())
@@ -352,6 +379,7 @@ public final class BankUiService {
} else {
selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size());
}
});
}
private CardCredentials getSelectedCredentials() {
+11 -3
View File
@@ -3,12 +3,20 @@
"id": "spmega",
"version": "${version}",
"name": "SPMega",
"description": "yawaflua`s SPRadar+SPMHelper mod, thats make a lot!",
"description": "yawaflua's SPRadar+SPMHelper mod, that's make a lot!",
"authors": [
"Dmitri 'yawaflua' Shimanski aka DOLBAYEB"
{
"name": "Dmitri 'yawaflua' Shimanski aka DOLBAYEB",
"contact": {
"homepage": "https://yawaflua.tech",
"email": "spmega@yawaflua.tech"
}
}
],
"contact": {
"homepage": "https://yawaflua.tech",
"issues": "https://git.yawaflua.tech/spmega",
"sources": "https://git.yawaflua.tech/spmega"
},
"license": "CC BY-NC-ND 4.0",
"icon": "icon.png",