From 01e67d7f120f587f53f10814ef359b280cb3165f Mon Sep 17 00:00:00 2001 From: MufHead <1244894362@qq.com> Date: Fri, 27 Mar 2026 12:19:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BD=91=E6=98=93=E5=95=86?= =?UTF-8?q?=E5=9F=8E=20=E6=94=AF=E6=8C=81=E7=9B=91=E5=90=AC=E7=8E=A9?= =?UTF-8?q?=E5=AE=B6=E8=B4=AD=E4=B9=B0=E5=92=8C=E5=82=AC=E4=BF=83=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服主需自行写插件对接商城购买和发货后执行的操作 --- build.gradle.kts | 6 +- .../java/org/allaymc/netallay/NetAllay.java | 20 ++ .../allaymc/netallay/codec/PyRpcCodec.java | 79 +++++--- .../allaymc/netallay/shop/ShopManager.java | 114 +++++++++++- .../org/allaymc/netallay/shop/WebUtil.java | 176 ++++++++++++++++++ 5 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 src/main/java/org/allaymc/netallay/shop/WebUtil.java diff --git a/build.gradle.kts b/build.gradle.kts index 2ee222e..ae607a3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,14 +14,14 @@ java { } allay { - api = "0.19.0" + api = "0.26.0" apiOnly = false plugin { name = "NetAllay" entrance = ".NetAllay" authors += "YiRanKuma" - website = "https://github.com/YiRanKuma/NetAllay" + website = "https://github.com/AllayMC/NetAllay" } } @@ -33,6 +33,6 @@ dependencies { compileOnly("org.projectlombok:lombok:1.18.34") annotationProcessor("org.projectlombok:lombok:1.18.34") implementation("org.msgpack:msgpack-core:0.9.8") - compileOnly("org.allaymc:protocol-extension:0.1.2") + compileOnly("org.allaymc:protocol-extension:0.1.4-") compileOnly("com.google.code.gson:gson:2.10.1") } diff --git a/src/main/java/org/allaymc/netallay/NetAllay.java b/src/main/java/org/allaymc/netallay/NetAllay.java index 66f5675..2e2e643 100644 --- a/src/main/java/org/allaymc/netallay/NetAllay.java +++ b/src/main/java/org/allaymc/netallay/NetAllay.java @@ -86,6 +86,9 @@ public void onEnable() { config = NetAllayConfig.load(pluginContainer.dataFolder()); pluginLogger.info("Configuration loaded."); + // Start HTTP client for order API + org.allaymc.netallay.shop.WebUtil.startHttpClient(); + // Initialize shop manager shopManager = new ShopManager(this); @@ -110,6 +113,9 @@ public void onDisable() { shopManager = null; } + // Stop HTTP client + org.allaymc.netallay.shop.WebUtil.stopHttpClient(); + pluginLogger.info("NetAllay disabled."); instance = null; } @@ -132,6 +138,20 @@ public void listenForEvent(String namespace, String systemName, String eventName pluginLogger.debug("Registered listener for {}:{}:{}", namespace, systemName, eventName); } + /** + * Registers a listener for client engine callbacks. + *

+ * Engine callbacks use a different format: [eventName, [], null] + * Common engine callbacks: StoreBuySuccServerEvent, UrgeShipEvent + * + * @param eventName the engine callback name + * @param handler the callback function to handle the event + */ + public void listenForClientEngineCall(String eventName, PyRpcHandler handler) { + listenerRegistry.register("engine", "callback", eventName, handler); + pluginLogger.debug("Registered engine callback listener for: {}", eventName); + } + /** * Unregisters a specific listener for an event. * diff --git a/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java b/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java index cfa3c5c..3df7cf6 100644 --- a/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java +++ b/src/main/java/org/allaymc/netallay/codec/PyRpcCodec.java @@ -6,6 +6,9 @@ import org.msgpack.value.Value; import org.msgpack.value.ValueType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; @@ -24,6 +27,8 @@ */ public final class PyRpcCodec { + private static final Logger log = LoggerFactory.getLogger(PyRpcCodec.class); + /** * Server to Client event type identifier. */ @@ -88,50 +93,76 @@ public static ParsedEvent decode(byte[] data) { Value value = unpacker.unpackValue(); if (!value.isArrayValue()) { + log.warn("[Decode] Root is not array: {}", value.getValueType()); return null; } List array = value.asArrayValue().list(); if (array.size() < 2) { + log.warn("[Decode] Root array too short: size={}", array.size()); return null; } // Parse event type String eventType = valueToString(array.get(0)); - // Parse event details + // Parse event details - can be array or map depending on packet type Value detailsValue = array.get(1); - if (!detailsValue.isArrayValue()) { - return null; - } - List details = detailsValue.asArrayValue().list(); - if (details.size() < 4) { - return null; - } + // Standard format: [eventType, [namespace, system, event, data], ...] + if (detailsValue.isArrayValue()) { + List details = detailsValue.asArrayValue().list(); + if (details.isEmpty()) { + // Engine callback format: [eventType, [], null] + // eventType IS the event name (e.g. StoreBuySuccServerEvent, UrgeShipEvent) + return new ParsedEvent(eventType, "engine", "callback", eventType, new HashMap<>()); + } - String namespace = valueToString(details.get(0)); - String systemName = valueToString(details.get(1)); - String eventName = valueToString(details.get(2)); - Object eventData = valueToObject(details.get(3)); - - Map dataMap; - if (eventData instanceof Map) { - @SuppressWarnings("unchecked") - Map temp = (Map) eventData; - dataMap = temp; - } else { - dataMap = new HashMap<>(); - if (eventData != null) { - dataMap.put("rawData", eventData); + if (details.size() < 3) { + log.warn("[Decode] Details array too short: size={}, eventType={}", details.size(), eventType); + return null; } + + String namespace = valueToString(details.get(0)); + String systemName = valueToString(details.get(1)); + String eventName = valueToString(details.get(2)); + Object eventData = details.size() > 3 ? valueToObject(details.get(3)) : null; + + Map dataMap = toDataMap(eventData); + return new ParsedEvent(eventType, namespace, systemName, eventName, dataMap); } - return new ParsedEvent(eventType, namespace, systemName, eventName, dataMap); + // Alternative format: [eventType, {data}, ...] (engine callbacks etc.) + if (detailsValue.isMapValue() || detailsValue.isBinaryValue() || detailsValue.isStringValue()) { + Object eventData = valueToObject(detailsValue); + Map dataMap = toDataMap(eventData); + // Use eventType as both namespace and event name for non-standard formats + return new ParsedEvent(eventType, "engine", "callback", eventType, dataMap); + } - } catch (IOException e) { + log.warn("[Decode] Unexpected details type: {}, eventType={}", detailsValue.getValueType(), eventType); return null; + + } catch (Exception e) { + log.warn("[Decode] Failed to decode PyRpc packet: {}", e.getMessage()); + return null; + } + } + + /** + * Converts event data to a Map. + */ + private static Map toDataMap(Object eventData) { + if (eventData instanceof Map) { + @SuppressWarnings("unchecked") + Map temp = (Map) eventData; + return temp; + } + Map dataMap = new HashMap<>(); + if (eventData != null) { + dataMap.put("rawData", eventData); } + return dataMap; } /** diff --git a/src/main/java/org/allaymc/netallay/shop/ShopManager.java b/src/main/java/org/allaymc/netallay/shop/ShopManager.java index f48f15a..8b7f4f6 100644 --- a/src/main/java/org/allaymc/netallay/shop/ShopManager.java +++ b/src/main/java/org/allaymc/netallay/shop/ShopManager.java @@ -7,8 +7,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; /** * Manager for NetEase shop functionality. @@ -242,6 +247,92 @@ public boolean showHint(Player player, String head, String tail) { ); } + // ==================== Order API ==================== + + /** + * Gets the active signing key based on test/production mode. + */ + private String getActiveSignKey() { + return testServer ? testGameKey : gameKey; + } + + /** + * Gets the shop server base URL. + */ + private String getShopBaseUrl() { + return WebUtil.getShopBaseUrl(shopServerUrl, testServer); + } + + /** + * Retrieves the player's order list from the NetEase order server. + *

+ * This is an async operation. The callback receives the JSON response on success, + * or an exception on failure. + *

+ * Usage Example: + *

{@code
+     * shop.getPlayerOrderList(player, (result, error) -> {
+     *     if (error != null) {
+     *         log.error("Failed to get orders", error);
+     *         return;
+     *     }
+     *     JsonArray entities = result.getAsJsonArray("entities");
+     *     // Process each order...
+     * });
+     * }
+ * + * @param player the player + * @param callback callback with (JsonObject result, Throwable error) + */ + public void getPlayerOrderList(Player player, BiConsumer callback) { + String uuid = player.getControlledEntity().getUniqueId().toString(); + + WebUtil.getPlayerOrderList(gameId, uuid, getActiveSignKey(), getShopBaseUrl()) + .whenComplete((result, error) -> { + if (error != null) { + log.error("Failed to get order list for {}", player.getOriginName(), error); + } else { + log.debug("Got order list for {}: {}", player.getOriginName(), result); + } + callback.accept(result, error); + }); + } + + /** + * Notifies the NetEase order server that orders have been shipped/completed. + *

+ * Call this after you have delivered the items to the player. + *

+ * Usage Example: + *

{@code
+     * List finishedOrderIds = List.of("order123", "order456");
+     * shop.finishPlayerOrder(player, finishedOrderIds, (result, error) -> {
+     *     if (error != null) {
+     *         log.error("Failed to finish orders", error);
+     *         return;
+     *     }
+     *     log.info("Orders completed successfully");
+     * });
+     * }
+ * + * @param player the player + * @param orderIds list of order IDs that have been delivered + * @param callback callback with (JsonObject result, Throwable error) + */ + public void finishPlayerOrder(Player player, List orderIds, BiConsumer callback) { + String uuid = player.getControlledEntity().getUniqueId().toString(); + + WebUtil.finishPlayerOrder(gameId, uuid, orderIds, getActiveSignKey(), getShopBaseUrl()) + .whenComplete((result, error) -> { + if (error != null) { + log.error("Failed to finish orders for {}", player.getOriginName(), error); + } else { + log.info("Orders completed for {}: {}", player.getOriginName(), orderIds); + } + callback.accept(result, error); + }); + } + // ==================== Per-Player Configuration ==================== /** @@ -304,7 +395,7 @@ private void registerInternalListeners() { (player, data) -> fireShopEvent(ShopEvent.PLAYER_URGE_SHIP, player, data) ); - // Listen for client buy success + // Listen for client buy success (mod event) plugin.listenForEvent( ShopConstants.SHOP_NAMESPACE, ShopConstants.SHOP_CLIENT_SYSTEM, @@ -312,6 +403,16 @@ private void registerInternalListeners() { (player, data) -> fireShopEvent(ShopEvent.PLAYER_BUY_ITEM_SUCCESS, player, data) ); + // Listen for engine callbacks (alternative event format from client) + plugin.listenForClientEngineCall( + "StoreBuySuccServerEvent", + (player, data) -> fireShopEvent(ShopEvent.PLAYER_BUY_ITEM_SUCCESS, player, data) + ); + plugin.listenForClientEngineCall( + "UrgeShipEvent", + (player, data) -> fireShopEvent(ShopEvent.PLAYER_URGE_SHIP, player, data) + ); + log.debug("Shop internal listeners registered"); } @@ -342,7 +443,16 @@ private void handleClientEnter(Player player, Map data) { responseData.put("isTestServer", testServer); responseData.put("useCustomShop", useCustomShop); responseData.put("cacheTime", cacheTime); - responseData.put("uid", 0L); + + // Get NetEase UID from player's login data + long uid = 0L; + if (player.isNetEasePlayer()) { + var neteaseData = player.getLoginData().getNetEaseData(); + if (neteaseData != null) { + uid = neteaseData.uid(); + } + } + responseData.put("uid", uid); responseData.put("platformUid", ""); } diff --git a/src/main/java/org/allaymc/netallay/shop/WebUtil.java b/src/main/java/org/allaymc/netallay/shop/WebUtil.java new file mode 100644 index 0000000..fb06ee3 --- /dev/null +++ b/src/main/java/org/allaymc/netallay/shop/WebUtil.java @@ -0,0 +1,176 @@ +package org.allaymc.netallay.shop; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Utility class for communicating with NetEase shop order servers. + *

+ * Handles HMAC-SHA256 signed HTTP requests for order retrieval and completion. + * + * @author YiRanKuma + */ +public class WebUtil { + + private static final Logger log = LoggerFactory.getLogger(WebUtil.class); + private static final Gson GSON = new Gson(); + + public static final String GAS_SERVER_BASE_URL = "http://gasproxy.mc.netease.com:60002"; + public static final String TEST_GAS_SERVER_BASE_URL = "http://gasproxy.mc.netease.com:60001"; + + public static final String GET_ITEM_ORDER_LIST_PATH = "/get-mc-item-order-list"; + public static final String SHIP_ITEM_ORDER_PATH = "/ship-mc-item-order"; + + private static HttpClient httpClient; + + /** + * Starts the HTTP client. + */ + public static void startHttpClient() { + httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + log.info("HTTP client started"); + } + + /** + * Stops the HTTP client. + */ + public static void stopHttpClient() { + httpClient = null; + log.info("HTTP client stopped"); + } + + /** + * Selects the appropriate base URL for shop order requests. + */ + public static String getShopBaseUrl(String customUrl, boolean isTestServer) { + if (customUrl != null && !customUrl.isEmpty()) { + return customUrl; + } + return isTestServer ? TEST_GAS_SERVER_BASE_URL : GAS_SERVER_BASE_URL; + } + + /** + * Gets the player's order list from NetEase order server. + * + * @param gameId the game ID + * @param playerUuid the player's UUID string + * @param signKey the game key for signing (testGameKey or gameKey) + * @param shopBaseUrl the base URL for the order server + * @return CompletableFuture with the parsed JSON response + */ + public static CompletableFuture getPlayerOrderList( + String gameId, String playerUuid, String signKey, String shopBaseUrl) { + + JsonObject payload = new JsonObject(); + payload.addProperty("gameid", gameId); + payload.addProperty("uuid", playerUuid); + String json = GSON.toJson(payload); + + return postSigned(shopBaseUrl + GET_ITEM_ORDER_LIST_PATH, GET_ITEM_ORDER_LIST_PATH, json, signKey); + } + + /** + * Notifies the NetEase order server that orders have been shipped. + * + * @param gameId the game ID + * @param playerUuid the player's UUID string + * @param orderIds list of completed order IDs + * @param signKey the game key for signing + * @param shopBaseUrl the base URL for the order server + * @return CompletableFuture with the parsed JSON response + */ + public static CompletableFuture finishPlayerOrder( + String gameId, String playerUuid, List orderIds, String signKey, String shopBaseUrl) { + + JsonObject payload = new JsonObject(); + payload.addProperty("gameid", gameId); + payload.addProperty("uuid", playerUuid); + payload.add("orderid_list", GSON.toJsonTree(orderIds)); + String json = GSON.toJson(payload); + + return postSigned(shopBaseUrl + SHIP_ITEM_ORDER_PATH, SHIP_ITEM_ORDER_PATH, json, signKey); + } + + /** + * Sends a signed POST request to the NetEase server. + */ + private static CompletableFuture postSigned(String url, String path, String jsonBody, String signKey) { + if (httpClient == null) { + return CompletableFuture.failedFuture(new IllegalStateException("HTTP client not started")); + } + + String signature; + try { + signature = getServerSign(signKey, "POST", path, jsonBody); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(15)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("Netease-Server-Sign", signature) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + log.debug("Sending POST to {}", url); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(response -> { + if (response.statusCode() != 200) { + throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body()); + } + JsonObject jsonResult = JsonParser.parseString(response.body()).getAsJsonObject(); + int code = jsonResult.has("code") ? jsonResult.get("code").getAsInt() : -1; + if (code != 0) { + throw new RuntimeException("API error code: " + code + ", response: " + response.body()); + } + return jsonResult; + }); + } + + /** + * Generates HMAC-SHA256 signature for NetEase API requests. + *

+ * Sign string format: METHOD + PATH + BODY + */ + public static String getServerSign(String signKey, String method, String path, String httpBody) throws Exception { + String str2sign = method + path + httpBody; + String signStr = hmacSHA256(str2sign, signKey); + // Pad to 64 characters with leading zeros if needed + if (signStr.length() < 64) { + int padLen = 64 - signStr.length(); + signStr = String.join("", Collections.nCopies(padLen, "0")) + signStr; + } + return signStr; + } + + private static String hmacSHA256(String data, String key) throws Exception { + SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(secretKey); + mac.update(data.getBytes(StandardCharsets.UTF_8)); + byte[] result = mac.doFinal(); + return new BigInteger(1, result).toString(16); + } +}