merge: 合并 SkinBridge 皮肤兼容功能

This commit is contained in:
2026-07-21 23:45:55 +08:00
16 changed files with 979 additions and 6 deletions
+5
View File
@@ -67,6 +67,7 @@
| `jei-sync` | 开启 | Fabric / NeoForge JEI 配方同步修复 |
| `mob-drops` | 关闭 | 末影人掉落控制,默认关闭以保留过去标准版行为 |
| `maintenance` | 开启 | 维护模式命令、MOTD 替换、登录拦截、白名单和拦截通知 |
| `skin-bridge` | 关闭 | 查询外置 Yggdrasil profile,并通过 MineSkin 与 Paper Profile API 同步皮肤 |
修改模块开关后可先使用 `/essc reload` 刷新运行期服务与监听器状态。由于 Bukkit 命令表不适合在运行期完整热增删,若模块是在启动时关闭的,对应直连命令可能仍需重启后才会注册;通过 `/essc <子命令>` 入口通常可立即按新的模块状态执行。
@@ -82,12 +83,15 @@
当前配置结构以“行为配置”和“文本配置”分离为原则:
SkinBridge 默认关闭。使用前需同时将 `modules.yml` 中的 `modules.skin-bridge.enabled``config.yml` 中的 `skin-bridge.enabled` 设为 `true`,在 `skin-bridge.mineskin.api-key` 中配置 MineSkin API Key,并至少启用一个 Provider。真实密钥只应填写在服务器运行目录的 `config.yml` 中,不要写入源码或提交到公开仓库。该模块不再要求安装 SkinsRestorer。`skin-bridge.send-player-message` 控制是否向玩家发送检测、匹配和同步结果提示。`skin-bridge.providers` 下的键名可自由命名,会显示在日志和 `/essc skin status <玩家>` 中;建议使用小写英文、数字和连字符,避免使用点号。控制台可使用 `/essc skin status <玩家>``/essc skin refresh <玩家>` 进行验证。
- `config.yml`
- 语言选择
- 管理模式行为
- JEI 同步开关
- 掉落控制
- TPSBar 模式
- SkinBridge Provider、缓存和超时配置
- 便捷菜单布局
- `modules.yml`
- 功能模块开关
@@ -127,6 +131,7 @@ essentialsc.command.seen
essentialsc.command.admin
essentialsc.command.tpsbar
essentialsc.command.maintenance
essentialsc.command.skin
essentialsc.maintenance.bypass
essentialsc.maintenance.notify
essentialsc.shulkerbox.open
+2 -1
View File
@@ -8,7 +8,7 @@ plugins {
}
group = 'cn.infstar'
version = '1.6.0'
version = '1.7.0'
repositories {
mavenCentral()
@@ -21,6 +21,7 @@ repositories {
dependencies {
paperweight.paperDevBundle('1.21.11-R0.1-SNAPSHOT')
implementation project(':compat-api')
implementation 'com.google.code.gson:gson:2.11.0'
}
java {
@@ -13,6 +13,7 @@ import cn.infstar.essentialsC.listeners.ShulkerBoxListener;
import cn.infstar.essentialsC.listeners.VanishListener;
import cn.infstar.essentialsC.maintenance.MaintenanceListener;
import cn.infstar.essentialsC.maintenance.MaintenanceManager;
import cn.infstar.essentialsC.skinbridge.SkinBridgeManager;
import cn.infstar.essentialsC.teleport.TeleportRequestManager;
import cn.infstar.essentialsC.tpsbar.TpsBarManager;
import cn.infstar.essentialsC.tpsbar.TpsBarService;
@@ -45,6 +46,7 @@ public final class EssentialsC extends JavaPlugin {
private MobDropListener mobDropListener;
private MobDropMenuListener mobDropMenuListener;
private VanishListener vanishListener;
private SkinBridgeManager skinBridgeManager;
private boolean commandsRegistered;
private final Map<String, String> moduleStatus = new LinkedHashMap<>();
@@ -73,6 +75,9 @@ public final class EssentialsC extends JavaPlugin {
if (teleportRequestManager != null) {
teleportRequestManager.shutdown();
}
if (skinBridgeManager != null) {
skinBridgeManager.shutdown();
}
VanishCommand.clearAll(this);
unregisterRuntimeListeners();
unregisterPluginChannels();
@@ -103,6 +108,10 @@ public final class EssentialsC extends JavaPlugin {
return tpsBarManager;
}
public SkinBridgeManager getSkinBridgeManager() {
return skinBridgeManager;
}
public void reloadRuntimeModules() {
moduleStatus.clear();
refreshPlayer();
@@ -112,6 +121,7 @@ public final class EssentialsC extends JavaPlugin {
refreshBlocks();
refreshJeiSync();
refreshMobDrops();
refreshSkinBridge();
}
private void refreshPlayer() {
@@ -278,6 +288,26 @@ public final class EssentialsC extends JavaPlugin {
setModuleStatus("生物掉落", true, "末影人掉落控制已启用");
}
private void refreshSkinBridge() {
if (!moduleManager.isEnabled(ModuleManager.SKIN_BRIDGE)) {
if (skinBridgeManager != null) {
skinBridgeManager.shutdown();
HandlerList.unregisterAll(skinBridgeManager);
skinBridgeManager = null;
}
setModuleStatus("皮肤桥接", false, "已禁用");
return;
}
if (skinBridgeManager == null) {
skinBridgeManager = new SkinBridgeManager(this);
getServer().getPluginManager().registerEvents(skinBridgeManager, this);
} else {
skinBridgeManager.reload();
}
setModuleStatus("皮肤桥接", true, skinBridgeManager.getModuleDetail());
}
private void registerPluginChannels() {
Messenger messenger = getServer().getMessenger();
messenger.registerOutgoingPluginChannel(this, "fabric:recipe_sync");
@@ -299,6 +329,7 @@ public final class EssentialsC extends JavaPlugin {
unregisterListener(mobDropMenuListener);
unregisterListener(vanishListener);
unregisterListener(teleportRequestManager);
unregisterListener(skinBridgeManager);
if (tpsBarManager instanceof Listener listener) {
unregisterListener(listener);
}
@@ -152,15 +152,24 @@ public class LangManager {
}
private void loadDefaultLanguageFallback() {
InputStream defaultLangStream = plugin.getResource("lang/en_US.yml");
if (defaultLangStream == null) {
InputStream selectedLangStream = plugin.getResource("lang/" + currentLanguage + ".yml");
if (selectedLangStream == null) {
return;
}
YamlConfiguration defaultLang = YamlConfiguration.loadConfiguration(
new InputStreamReader(defaultLangStream, StandardCharsets.UTF_8)
YamlConfiguration selectedDefaults = YamlConfiguration.loadConfiguration(
new InputStreamReader(selectedLangStream, StandardCharsets.UTF_8)
);
langFile.setDefaults(defaultLang);
if (!"en_US".equalsIgnoreCase(currentLanguage)) {
InputStream englishLangStream = plugin.getResource("lang/en_US.yml");
if (englishLangStream != null) {
YamlConfiguration englishDefaults = YamlConfiguration.loadConfiguration(
new InputStreamReader(englishLangStream, StandardCharsets.UTF_8)
);
selectedDefaults.setDefaults(englishDefaults);
}
}
langFile.setDefaults(selectedDefaults);
}
private String applyPlaceholders(String value, Map<String, String> placeholders) {
@@ -20,6 +20,7 @@ public final class ModuleManager {
public static final String JEI_SYNC = "jei-sync";
public static final String MOB_DROPS = "mob-drops";
public static final String MAINTENANCE = "maintenance";
public static final String SKIN_BRIDGE = "skin-bridge";
private static final Map<String, Boolean> DEFAULT_MODULES = new LinkedHashMap<>();
@@ -31,6 +32,7 @@ public final class ModuleManager {
DEFAULT_MODULES.put(JEI_SYNC, true);
DEFAULT_MODULES.put(MOB_DROPS, false);
DEFAULT_MODULES.put(MAINTENANCE, true);
DEFAULT_MODULES.put(SKIN_BRIDGE, false);
}
private final JavaPlugin plugin;
@@ -50,6 +50,7 @@ public final class CommandRegistry {
register("mobdrops", "essentialsc.mobdrops.enderman", ModuleManager.MOB_DROPS, "cn.infstar.essentialsC.commands.MobDropCommand");
register("maintenance", "essentialsc.command.maintenance", ModuleManager.MAINTENANCE, "cn.infstar.essentialsC.commands.MaintenanceCommand", "maint");
registerSubCommand("admin", "essentialsc.command.admin", ModuleManager.ADMIN_MODE, "cn.infstar.essentialsC.commands.AdminCommand");
registerSubCommand("skin", "essentialsc.command.skin", ModuleManager.SKIN_BRIDGE, "cn.infstar.essentialsC.commands.SkinBridgeCommand");
}
private CommandRegistry() {
@@ -202,6 +202,10 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
otherCommands.append(lang.getString("help.commands.tpignore")).append("\n");
hasOtherCommands = true;
}
if (CommandRegistry.isAvailable("skin") && player.hasPermission("essentialsc.command.skin")) {
otherCommands.append(lang.getString("help.commands.skin")).append("\n");
hasOtherCommands = true;
}
if (CommandRegistry.isAvailable("admin") && player.hasPermission("essentialsc.command.admin")) {
otherCommands.append(lang.getString("help.commands.admin")).append("\n");
hasOtherCommands = true;
@@ -293,6 +297,7 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
{"tpdecline", "essentialsc.command.tpdeny"},
{"tpno", "essentialsc.command.tpdeny"},
{"tpignore", "essentialsc.command.tpignore"},
{"skin", "essentialsc.command.skin"},
{"tpsbar", "essentialsc.command.tpsbar"},
{"maintenance", "essentialsc.command.maintenance"},
{"maint", "essentialsc.command.maintenance"},
@@ -345,6 +350,13 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
return manager == null ? List.of() : manager.getIncomingRequesterNames(completionPlayer, args[1]);
}
if (subCmd.equals("skin") && sender.hasPermission("essentialsc.command.skin")) {
String partial = args[1].toLowerCase();
return List.of("status", "refresh").stream()
.filter(option -> option.startsWith(partial))
.toList();
}
if ((subCmd.equals("nightvision") || subCmd.equals("nv")) && sender.hasPermission("essentialsc.command.nightvision")) {
return completeToggleArgs(args[1]);
}
@@ -370,6 +382,19 @@ public class HelpCommand extends BaseCommand implements TabCompleter {
}
}
if (args.length == 3 && args[0].equalsIgnoreCase("skin")
&& (args[1].equalsIgnoreCase("status") || args[1].equalsIgnoreCase("refresh"))
&& sender.hasPermission("essentialsc.command.skin")) {
List<String> players = new ArrayList<>();
String partial = args[2].toLowerCase();
for (Player player : Bukkit.getOnlinePlayers()) {
if (player.getName().toLowerCase().startsWith(partial)) {
players.add(player.getName());
}
}
return players;
}
return new ArrayList<>();
}
@@ -0,0 +1,127 @@
package cn.infstar.essentialsC.commands;
import cn.infstar.essentialsC.skinbridge.SkinBridgeManager;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class SkinBridgeCommand extends BaseCommand implements TabCompleter {
public SkinBridgeCommand() {
super("essentialsc.command.skin");
}
@Override
protected boolean execute(Player player, String[] args) {
return executeCommand(player, args, false);
}
@Override
protected boolean executeConsole(CommandSender sender, String[] args) {
return executeCommand(sender, args, true);
}
private boolean executeCommand(CommandSender sender, String[] args, boolean console) {
if (args.length < 1 || args.length > 2) {
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage"));
return true;
}
SkinBridgeManager manager = plugin.getSkinBridgeManager();
if (manager == null) {
sender.sendMessage(getLang().getPrefixedString("messages.module-disabled"));
return true;
}
Player target = console ? null : (Player) sender;
if (args.length == 2) {
target = Bukkit.getPlayerExact(args[1]);
if (target == null || !target.isOnline()) {
sender.sendMessage(getLang().getPrefixedString("messages.player-not-found", Map.of("player", args[1])));
return true;
}
}
if (target == null) {
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage"));
return true;
}
String action = args[0].toLowerCase(Locale.ROOT);
if (action.equals("status")) {
sendStatus(sender, target, manager);
return true;
}
if (action.equals("refresh")) {
sendRefreshResult(sender, target, manager.queueSync(target, true));
return true;
}
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage"));
return true;
}
private void sendStatus(CommandSender sender, Player target, SkinBridgeManager manager) {
if (!manager.isEnabled()) {
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.feature-disabled"));
return;
}
if (!manager.isSkinGatewayAvailable()) {
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.dependency-missing"));
return;
}
if (manager.getProviderCount() == 0) {
sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.no-providers"));
return;
}
SkinBridgeManager.Status status = manager.getStatus(target);
Map<String, String> placeholders = Map.of("player", target.getName(), "provider", String.valueOf(status.providerId()));
switch (status.state()) {
case EXTERNAL -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-external", placeholders));
case NOT_EXTERNAL -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-not-external", placeholders));
case PENDING -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-pending", placeholders));
case UNKNOWN -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-unknown", placeholders));
}
}
private void sendRefreshResult(CommandSender sender, Player target, SkinBridgeManager.SyncResult result) {
Map<String, String> placeholders = Map.of("player", target.getName());
String messagePath = switch (result) {
case QUEUED -> "skin-bridge.messages.refresh-queued";
case CACHED -> "skin-bridge.messages.refresh-cached";
case ALREADY_RUNNING -> "skin-bridge.messages.refresh-running";
case FEATURE_DISABLED -> "skin-bridge.messages.feature-disabled";
case DEPENDENCY_MISSING -> "skin-bridge.messages.dependency-missing";
case NO_PROVIDERS -> "skin-bridge.messages.no-providers";
};
sender.sendMessage(getLang().getPrefixedString(messagePath, placeholders));
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 1) {
String partial = args[0].toLowerCase(Locale.ROOT);
return List.of("status", "refresh").stream()
.filter(option -> option.startsWith(partial))
.toList();
}
if (args.length == 2 && (args[0].equalsIgnoreCase("status") || args[0].equalsIgnoreCase("refresh"))) {
String partial = args[1].toLowerCase(Locale.ROOT);
List<String> players = new ArrayList<>();
for (Player onlinePlayer : Bukkit.getOnlinePlayers()) {
if (onlinePlayer.getName().toLowerCase(Locale.ROOT).startsWith(partial)) {
players.add(onlinePlayer.getName());
}
}
return players;
}
return List.of();
}
}
@@ -0,0 +1,179 @@
package cn.infstar.essentialsC.skinbridge;
import com.destroystokyo.paper.profile.PlayerProfile;
import com.destroystokyo.paper.profile.ProfileProperty;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.bukkit.entity.Player;
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;
public final class MineSkinGateway implements SkinBridgeGateway {
private static final String TEXTURES_PROPERTY = "textures";
private static final String QUEUE_PATH = "/v2/queue";
private static final long POLL_INTERVAL_MILLIS = 1000L;
private final HttpClient httpClient;
private final URI queueUri;
private final String apiKey;
private final String visibility;
private final int timeoutSeconds;
private final String userAgent;
public MineSkinGateway(HttpClient httpClient, String endpoint, String apiKey, String visibility, int timeoutSeconds, String userAgent) {
this.httpClient = httpClient;
this.queueUri = URI.create(normalizeEndpoint(endpoint) + QUEUE_PATH);
this.apiKey = apiKey;
this.visibility = normalizeVisibility(visibility);
this.timeoutSeconds = timeoutSeconds;
this.userAgent = userAgent;
}
@Override
public GeneratedSkin generateSkin(String skinUrl, SkinModel model) throws Exception {
JsonObject requestBody = new JsonObject();
requestBody.addProperty("url", skinUrl);
requestBody.addProperty("variant", model == SkinModel.SLIM ? "slim" : "classic");
requestBody.addProperty("visibility", visibility);
HttpResponse<String> response = send(HttpRequest.newBuilder(queueUri)
.POST(HttpRequest.BodyPublishers.ofString(requestBody.toString(), StandardCharsets.UTF_8))
.header("Content-Type", "application/json"));
JsonObject body = parseResponse(response);
if (response.statusCode() == 200) {
return parseGeneratedSkin(body);
}
if (response.statusCode() != 202) {
throw apiError("提交 MineSkin 生成请求", response, body);
}
JsonObject job = object(body, "job");
String jobId = string(job, "id");
return pollJob(jobId);
}
@Override
public void applySkin(Player player, GeneratedSkin skin) {
PlayerProfile profile = player.getPlayerProfile();
profile.removeProperty(TEXTURES_PROPERTY);
profile.setProperty(new ProfileProperty(TEXTURES_PROPERTY, skin.value(), skin.signature()));
player.setPlayerProfile(profile);
}
private GeneratedSkin pollJob(String jobId) throws Exception {
long deadline = System.nanoTime() + Duration.ofSeconds(timeoutSeconds).toNanos();
URI jobUri = URI.create(queueUri + "/" + jobId);
while (System.nanoTime() < deadline) {
HttpResponse<String> response = send(HttpRequest.newBuilder(jobUri).GET());
JsonObject body = parseResponse(response);
if (response.statusCode() != 200) {
throw apiError("查询 MineSkin 生成任务", response, body);
}
JsonObject job = object(body, "job");
String status = string(job, "status");
if ("completed".equalsIgnoreCase(status)) {
return parseGeneratedSkin(body);
}
if ("failed".equalsIgnoreCase(status)) {
throw new IllegalStateException("MineSkin 生成任务失败: " + errorMessage(body));
}
if (!"waiting".equalsIgnoreCase(status) && !"active".equalsIgnoreCase(status)) {
throw new IllegalStateException("MineSkin 返回未知任务状态: " + status);
}
Thread.sleep(POLL_INTERVAL_MILLIS);
}
throw new IllegalStateException("MineSkin 生成任务超时,请增大 skin-bridge.mineskin.request-timeout-seconds。");
}
private HttpResponse<String> send(HttpRequest.Builder builder) throws Exception {
HttpRequest request = builder
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + apiKey)
.header("User-Agent", userAgent)
.build();
return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
private JsonObject parseResponse(HttpResponse<String> response) {
if (response.body() == null || response.body().isBlank()) {
throw new IllegalStateException("MineSkin 返回空响应,HTTP " + response.statusCode());
}
if (response.body().length() > 1_048_576) {
throw new IllegalStateException("MineSkin 响应超过 1 MiB 限制。");
}
try {
return JsonParser.parseString(response.body()).getAsJsonObject();
} catch (RuntimeException exception) {
throw new IllegalStateException("MineSkin 返回了无效 JSONHTTP " + response.statusCode(), exception);
}
}
private GeneratedSkin parseGeneratedSkin(JsonObject body) {
JsonObject skin = object(body, "skin");
JsonObject texture = object(skin, "texture");
JsonObject data = object(texture, "data");
return new GeneratedSkin(string(data, "value"), string(data, "signature"));
}
private IllegalStateException apiError(String action, HttpResponse<String> response, JsonObject body) {
return new IllegalStateException(action + "失败,HTTP " + response.statusCode() + ": " + errorMessage(body));
}
private String errorMessage(JsonObject body) {
for (String key : new String[]{"message", "error", "code"}) {
if (body.has(key) && body.get(key).isJsonPrimitive()) {
return body.get(key).getAsString();
}
}
return "未提供错误信息";
}
private static JsonObject object(JsonObject parent, String key) {
if (!parent.has(key) || !parent.get(key).isJsonObject()) {
throw new IllegalStateException("MineSkin 响应缺少对象字段: " + key);
}
return parent.getAsJsonObject(key);
}
private static String string(JsonObject parent, String key) {
if (!parent.has(key) || !parent.get(key).isJsonPrimitive()) {
throw new IllegalStateException("MineSkin 响应缺少字符串字段: " + key);
}
String value = parent.get(key).getAsString();
if (value.isBlank()) {
throw new IllegalStateException("MineSkin 响应字符串字段为空: " + key);
}
return value;
}
private static String normalizeEndpoint(String endpoint) {
String normalized = endpoint == null ? "" : endpoint.trim();
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if (normalized.isBlank()) {
throw new IllegalArgumentException("MineSkin API 地址不能为空。");
}
URI uri = URI.create(normalized);
if (!"https".equalsIgnoreCase(uri.getScheme())) {
throw new IllegalArgumentException("MineSkin API 必须使用 HTTPS。");
}
return normalized;
}
private static String normalizeVisibility(String visibility) {
String normalized = visibility == null ? "" : visibility.trim().toLowerCase(java.util.Locale.ROOT);
if (!normalized.equals("public") && !normalized.equals("unlisted") && !normalized.equals("private")) {
throw new IllegalArgumentException("MineSkin visibility 必须是 public、unlisted 或 private。");
}
return normalized;
}
}
@@ -0,0 +1,18 @@
package cn.infstar.essentialsC.skinbridge;
import org.bukkit.entity.Player;
interface SkinBridgeGateway {
GeneratedSkin generateSkin(String skinUrl, SkinModel model) throws Exception;
void applySkin(Player player, GeneratedSkin skin) throws Exception;
}
enum SkinModel {
CLASSIC,
SLIM
}
record GeneratedSkin(String value, String signature) {
}
@@ -0,0 +1,491 @@
package cn.infstar.essentialsC.skinbridge;
import cn.infstar.essentialsC.EssentialsC;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.bukkit.Bukkit;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
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.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;
public final class SkinBridgeManager implements Listener {
private final EssentialsC plugin;
private final HttpClient httpClient;
private final ExecutorService executor;
private final ConcurrentMap<UUID, CachedLookup> cache = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Long> pendingLookups = new ConcurrentHashMap<>();
private final AtomicLong configurationGeneration = new AtomicLong();
private volatile List<SkinProvider> providers = List.of();
private volatile SkinBridgeGateway gateway;
private volatile boolean enabled;
private volatile boolean debug;
private volatile boolean sendPlayerMessage;
private volatile int requestTimeoutSeconds;
private volatile int cacheMinutes;
private volatile long joinDelayTicks;
public SkinBridgeManager(EssentialsC plugin) {
this.plugin = plugin;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NEVER)
.build();
this.executor = Executors.newFixedThreadPool(2, new SkinBridgeThreadFactory());
reload();
}
public void reload() {
configurationGeneration.incrementAndGet();
FileConfiguration config = plugin.getConfig();
addDefaults(config);
config.options().copyDefaults(true);
plugin.saveConfig();
enabled = config.getBoolean("skin-bridge.enabled", false);
debug = config.getBoolean("skin-bridge.debug", false);
sendPlayerMessage = config.getBoolean("skin-bridge.send-player-message", true);
requestTimeoutSeconds = clamp(config.getInt("skin-bridge.profile-request-timeout-seconds", 5), 1, 30);
String mineSkinEndpoint = config.getString("skin-bridge.mineskin.endpoint", "https://api.mineskin.org");
String mineSkinApiKey = config.getString("skin-bridge.mineskin.api-key", "").trim();
String mineSkinVisibility = config.getString("skin-bridge.mineskin.visibility", "unlisted");
int mineSkinTimeoutSeconds = clamp(config.getInt("skin-bridge.mineskin.request-timeout-seconds", 30), 10, 180);
cacheMinutes = clamp(config.getInt("skin-bridge.cache-minutes", 120), 5, 10080);
joinDelayTicks = clamp(config.getLong("skin-bridge.join-delay-ticks", 20L), 0, 200);
providers = loadProviders(config);
cache.clear();
gateway = loadGateway(mineSkinEndpoint, mineSkinApiKey, mineSkinVisibility, mineSkinTimeoutSeconds);
if (enabled && gateway == null) {
plugin.getLogger().warning("SkinBridge 已启用,但未配置有效的 MineSkin API Key,皮肤同步不会执行。");
}
}
public void shutdown() {
configurationGeneration.incrementAndGet();
cache.clear();
pendingLookups.clear();
executor.shutdownNow();
}
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
queueSync(event.getPlayer(), false);
}
public SyncResult queueSync(Player player, boolean force) {
if (!enabled) {
return SyncResult.FEATURE_DISABLED;
}
if (gateway == null) {
return SyncResult.DEPENDENCY_MISSING;
}
if (providers.isEmpty()) {
return SyncResult.NO_PROVIDERS;
}
UUID playerId = player.getUniqueId();
if (force) {
cache.remove(playerId);
}
CachedLookup cached = cache.get(playerId);
if (!force && cached != null && !cached.hasExpired()) {
if (cached.skin() != null) {
applySkin(playerId, cached, configurationGeneration.get());
}
return SyncResult.CACHED;
}
long lookupGeneration = configurationGeneration.get();
if (!registerPendingLookup(playerId, lookupGeneration)) {
return SyncResult.ALREADY_RUNNING;
}
String playerName = player.getName();
Bukkit.getScheduler().runTaskLater(plugin, () -> startLookup(playerId, playerName, lookupGeneration), joinDelayTicks);
sendPlayerNotification(playerId, "skin-bridge.notifications.detecting", Map.of());
return SyncResult.QUEUED;
}
public Status getStatus(Player player) {
CachedLookup cached = cache.get(player.getUniqueId());
if (cached != null && !cached.hasExpired()) {
return new Status(cached.state(), cached.providerId());
}
if (pendingLookups.containsKey(player.getUniqueId())) {
return new Status(State.PENDING, null);
}
return new Status(State.UNKNOWN, null);
}
public boolean isEnabled() {
return enabled;
}
public boolean isSkinGatewayAvailable() {
return gateway != null;
}
public int getProviderCount() {
return providers.size();
}
public String getModuleDetail() {
if (!enabled) {
return "配置未启用";
}
if (gateway == null) {
return "缺少 MineSkin API Key 或配置无效";
}
if (providers.isEmpty()) {
return "未配置有效 Provider";
}
return providers.size() + " 个 Provider 已就绪";
}
private void startLookup(UUID playerId, String playerName, long lookupGeneration) {
if (executor.isShutdown() || lookupGeneration != configurationGeneration.get()) {
pendingLookups.remove(playerId, lookupGeneration);
return;
}
executor.execute(() -> {
try {
CachedLookup resolved = resolve(playerId, playerName);
if (lookupGeneration != configurationGeneration.get()) {
return;
}
cache.put(playerId, resolved);
if (resolved.skin() != null) {
applySkin(playerId, resolved, lookupGeneration);
} else {
sendPlayerNotification(playerId, "skin-bridge.notifications.not-external", Map.of());
if (debug) {
plugin.getLogger().info("SkinBridge 未识别到外置登录玩家: " + playerName);
}
}
} catch (Exception exception) {
plugin.getLogger().warning("SkinBridge 查询 " + playerName + " 的皮肤资料失败: " + exception.getMessage());
sendPlayerNotification(playerId, "skin-bridge.notifications.failed", Map.of());
if (debug) {
plugin.getLogger().warning("SkinBridge 异常类型: " + exception.getClass().getName());
}
} finally {
pendingLookups.remove(playerId, lookupGeneration);
}
});
}
private boolean registerPendingLookup(UUID playerId, long lookupGeneration) {
while (true) {
Long runningGeneration = pendingLookups.putIfAbsent(playerId, lookupGeneration);
if (runningGeneration == null) {
return true;
}
if (runningGeneration == lookupGeneration) {
return false;
}
if (pendingLookups.replace(playerId, runningGeneration, lookupGeneration)) {
return true;
}
}
}
private CachedLookup resolve(UUID playerId, String playerName) throws Exception {
Exception lastFailure = null;
for (SkinProvider provider : providers) {
Optional<ProviderProfile> profile;
try {
profile = queryProfile(provider, playerId, playerName);
} catch (Exception exception) {
lastFailure = exception;
plugin.getLogger().warning("SkinBridge Provider " + provider.id() + " 查询失败: " + exception.getMessage());
continue;
}
if (profile.isEmpty()) {
continue;
}
SkinBridgeGateway currentGateway = gateway;
if (currentGateway == null) {
throw new IllegalStateException("MineSkin 网关在查询期间不可用。");
}
ProviderProfile matchedProfile = profile.get();
GeneratedSkin generatedSkin = currentGateway.generateSkin(matchedProfile.skinUrl(), matchedProfile.model());
return cached(provider.id(), generatedSkin, State.EXTERNAL);
}
if (lastFailure != null) {
throw new IllegalStateException("所有可用 Provider 均未能完成确认。", lastFailure);
}
return cached(null, null, State.NOT_EXTERNAL);
}
private Optional<ProviderProfile> queryProfile(SkinProvider provider, UUID playerId, String playerName) throws Exception {
URI requestUri = URI.create(provider.resolveProfileUrl(playerId));
HttpRequest request = HttpRequest.newBuilder(requestUri)
.timeout(Duration.ofSeconds(requestTimeoutSeconds))
.header("Accept", "application/json")
.header("User-Agent", "EssentialsC/" + plugin.getDescription().getVersion() + " SkinBridge")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 204 || response.statusCode() == 404) {
return Optional.empty();
}
if (response.statusCode() != 200) {
throw new IllegalStateException(provider.id() + " 返回 HTTP " + response.statusCode());
}
if (response.body().length() > 1_048_576) {
throw new IllegalStateException(provider.id() + " 返回的 profile 超过 1 MiB 限制。");
}
JsonObject profile = JsonParser.parseString(response.body()).getAsJsonObject();
String profileId = requireString(profile, "id");
String profileName = requireString(profile, "name");
if (!normalizeUuid(profileId).equals(normalizeUuid(playerId.toString())) || !profileName.equalsIgnoreCase(playerName)) {
if (debug) {
plugin.getLogger().warning("SkinBridge 忽略 " + provider.id() + " 的不匹配 profile: " + profileName + " / " + profileId);
}
return Optional.empty();
}
JsonObject textureData = findTextureData(profile);
JsonObject skin = textureData.getAsJsonObject("textures").getAsJsonObject("SKIN");
String skinUrl = requireString(skin, "url");
URI skinUri = URI.create(skinUrl);
if (!"https".equalsIgnoreCase(skinUri.getScheme()) && !"http".equalsIgnoreCase(skinUri.getScheme())) {
throw new IllegalStateException(provider.id() + " 返回了不支持的皮肤 URL 协议。");
}
SkinModel model = SkinModel.CLASSIC;
JsonObject metadata = skin.has("metadata") && skin.get("metadata").isJsonObject()
? skin.getAsJsonObject("metadata")
: null;
if (metadata != null && "slim".equalsIgnoreCase(metadata.has("model") ? metadata.get("model").getAsString() : "")) {
model = SkinModel.SLIM;
}
return Optional.of(new ProviderProfile(skinUrl, model));
}
private JsonObject findTextureData(JsonObject profile) {
JsonArray properties = profile.has("properties") && profile.get("properties").isJsonArray()
? profile.getAsJsonArray("properties")
: new JsonArray();
for (JsonElement element : properties) {
if (!element.isJsonObject()) {
continue;
}
JsonObject property = element.getAsJsonObject();
if (!"textures".equals(property.has("name") ? property.get("name").getAsString() : "")) {
continue;
}
String encodedValue = requireString(property, "value");
String decodedValue = new String(Base64.getDecoder().decode(encodedValue), StandardCharsets.UTF_8);
JsonObject textureData = JsonParser.parseString(decodedValue).getAsJsonObject();
if (textureData.has("textures")
&& textureData.get("textures").isJsonObject()
&& textureData.getAsJsonObject("textures").has("SKIN")
&& textureData.getAsJsonObject("textures").get("SKIN").isJsonObject()) {
return textureData;
}
}
throw new IllegalStateException("profile 不包含有效的皮肤 textures 属性。");
}
private List<SkinProvider> loadProviders(FileConfiguration config) {
ConfigurationSection providersSection = config.getConfigurationSection("skin-bridge.providers");
if (providersSection == null) {
return List.of();
}
List<SkinProvider> loadedProviders = new ArrayList<>();
for (String key : providersSection.getKeys(false)) {
ConfigurationSection providerSection = providersSection.getConfigurationSection(key);
if (providerSection == null || !providerSection.getBoolean("enabled", false)) {
continue;
}
String profileUrl = providerSection.getString("profile-url", "").trim();
if (!profileUrl.contains("{uuid}") && !profileUrl.contains("{uuid-dashed}")) {
plugin.getLogger().warning("SkinBridge Provider " + key + " 缺少 {uuid} 或 {uuid-dashed} 占位符,已跳过。");
continue;
}
try {
URI.create(profileUrl.replace("{uuid}", "00000000000000000000000000000000")
.replace("{uuid-dashed}", "00000000-0000-0000-0000-000000000000"));
loadedProviders.add(new SkinProvider(key, profileUrl, providerSection.getInt("priority", 100)));
} catch (IllegalArgumentException exception) {
plugin.getLogger().warning("SkinBridge Provider " + key + " 的 profile-url 无效,已跳过。");
}
}
loadedProviders.sort(Comparator.comparingInt(SkinProvider::priority).thenComparing(SkinProvider::id));
return List.copyOf(loadedProviders);
}
private SkinBridgeGateway loadGateway(String endpoint, String apiKey, String visibility, int timeoutSeconds) {
if (apiKey.isBlank()) {
return null;
}
try {
return new MineSkinGateway(httpClient, endpoint, apiKey, visibility, timeoutSeconds,
"EssentialsC/" + plugin.getDescription().getVersion() + " SkinBridge");
} catch (Exception | LinkageError exception) {
plugin.getLogger().warning("加载 MineSkin SkinBridge 适配器失败: " + exception.getMessage());
return null;
}
}
private void addDefaults(FileConfiguration config) {
config.addDefault("skin-bridge.enabled", false);
config.addDefault("skin-bridge.debug", false);
config.addDefault("skin-bridge.send-player-message", true);
config.addDefault("skin-bridge.profile-request-timeout-seconds", 5);
config.addDefault("skin-bridge.mineskin.endpoint", "https://api.mineskin.org");
config.addDefault("skin-bridge.mineskin.api-key", "");
config.addDefault("skin-bridge.mineskin.visibility", "unlisted");
config.addDefault("skin-bridge.mineskin.request-timeout-seconds", 30);
config.addDefault("skin-bridge.cache-minutes", 120);
config.addDefault("skin-bridge.join-delay-ticks", 20);
}
private void applySkin(UUID playerId, CachedLookup resolved, long lookupGeneration) {
Bukkit.getScheduler().runTask(plugin, () -> {
if (lookupGeneration != configurationGeneration.get()) {
return;
}
Player player = Bukkit.getPlayer(playerId);
SkinBridgeGateway currentGateway = gateway;
if (player == null || !player.isOnline() || currentGateway == null || resolved.skin() == null) {
return;
}
try {
currentGateway.applySkin(player, resolved.skin());
sendPlayerNotification(playerId, "skin-bridge.notifications.synced",
Map.of("provider", resolved.providerId()));
if (debug) {
plugin.getLogger().info("SkinBridge 已应用 " + player.getName() + "" + resolved.providerId() + " 皮肤。");
}
} catch (Exception exception) {
plugin.getLogger().warning("SkinBridge 应用 " + player.getName() + " 的皮肤失败: " + exception.getMessage());
sendPlayerNotification(playerId, "skin-bridge.notifications.failed", Map.of());
}
});
}
private void sendPlayerNotification(UUID playerId, String messagePath, Map<String, String> placeholders) {
if (!sendPlayerMessage) {
return;
}
Runnable notification = () -> {
Player player = Bukkit.getPlayer(playerId);
if (player != null && player.isOnline()) {
player.sendMessage(EssentialsC.getLangManager().getPrefixedString(messagePath, placeholders));
}
};
if (Bukkit.isPrimaryThread()) {
notification.run();
} else {
Bukkit.getScheduler().runTask(plugin, notification);
}
}
private CachedLookup cached(String providerId, GeneratedSkin skin, State state) {
return new CachedLookup(providerId, skin, state, System.currentTimeMillis() + Duration.ofMinutes(cacheMinutes).toMillis());
}
private static String requireString(JsonObject object, String key) {
if (!object.has(key) || !object.get(key).isJsonPrimitive()) {
throw new IllegalStateException("缺少字符串字段: " + key);
}
String value = object.get(key).getAsString();
if (value.isBlank()) {
throw new IllegalStateException("字符串字段为空: " + key);
}
return value;
}
private static String normalizeUuid(String value) {
return value.replace("-", "").toLowerCase(java.util.Locale.ROOT);
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private static long clamp(long value, long min, long max) {
return Math.max(min, Math.min(max, value));
}
private record SkinProvider(String id, String profileUrl, int priority) {
private String resolveProfileUrl(UUID playerId) {
return profileUrl.replace("{uuid}", playerId.toString().replace("-", ""))
.replace("{uuid-dashed}", playerId.toString());
}
}
private record ProviderProfile(String skinUrl, SkinModel model) {
}
private record CachedLookup(String providerId, GeneratedSkin skin, State state, long expiresAtMillis) {
private boolean hasExpired() {
return System.currentTimeMillis() >= expiresAtMillis;
}
}
private static final class SkinBridgeThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, "EssentialsC-SkinBridge");
thread.setDaemon(true);
return thread;
}
}
public enum SyncResult {
QUEUED,
CACHED,
ALREADY_RUNNING,
FEATURE_DISABLED,
DEPENDENCY_MISSING,
NO_PROVIDERS
}
public record Status(State state, String providerId) {
}
public enum State {
EXTERNAL,
NOT_EXTERNAL,
PENDING,
UNKNOWN
}
}
+37
View File
@@ -75,6 +75,43 @@ tpa:
# 传送完成时播放的音效。
complete: "entity.enderman.teleport"
skin-bridge:
# 是否启用外置皮肤站桥接。还需要在 modules.yml 启用 skin-bridge 模块。
enabled: false
# 是否输出 Provider 查询和皮肤应用的调试日志。
debug: false
# 是否像 JEI 修复一样向玩家发送检测与同步提示。
send-player-message: true
# 查询 Yggdrasil profile 的超时时间,单位为秒。
profile-request-timeout-seconds: 5
mineskin:
# MineSkin v2 API 地址,一般不需要修改。
endpoint: "https://api.mineskin.org"
# MineSkin API Key。请仅在服务器运行目录中填写,不要提交到公开仓库。
api-key: ""
# 生成结果的可见性:public、unlisted 或 private。默认使用 unlisted。
visibility: "unlisted"
# MineSkin 生成队列的总等待时间,单位为秒。
request-timeout-seconds: 30
# 玩家登录来源与生成皮肤的内存缓存时间,单位为分钟。
cache-minutes: 120
# 玩家加入后延迟多少 tick 再查询,避免登录资料尚未完全稳定。
join-delay-ticks: 20
providers:
# 每个子节点都是一个 Provider,键名可自由命名,并会显示在日志和 /essc skin status 中。
# 建议使用小写英文、数字和连字符;不要使用点号,因为点号会被 Bukkit 视为配置路径分隔符。
littleskin:
# LittleSkin 示例 Provider。键名 littleskin 可按需改名。
enabled: false
priority: 10
# {uuid} 为无连字符 UUID{uuid-dashed} 为标准 UUID。
profile-url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false"
my-blessing-skin:
# 自建 Blessing Skin 示例 Provider。my-blessing-skin 可替换为皮肤站名称。
enabled: false
priority: 20
profile-url: "https://skin.example.com/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false"
blocks-menu:
# 菜单布局版本,用于后续自动迁移槽位布局
layout-version: 2
+20
View File
@@ -88,6 +88,7 @@ help:
tpaccept: " &f/tpaccept [player] &7- Accept a teleport request"
tpdeny: " &f/tpdeny [player] &7- Deny a teleport request"
tpignore: " &f/tpignore &7- Toggle ignoring teleport requests"
skin: " &f/essc skin <status|refresh> [player] &7- View or refresh SkinBridge status"
admin: " &f/essc admin &7- Toggle admin mode"
tpsbar: " &f/tpsbar [player] &7- Toggle TPS boss bar"
maintenance: " &f/maintenance <on|off|status|reload|add|remove|list> &7- Manage maintenance mode"
@@ -132,6 +133,25 @@ tpa:
tpaall-sent: "&aSent teleport requests to all players."
tpaall-no-targets: "&cThere are no online players who can receive the request."
skin-bridge:
notifications:
detecting: "&6SKIN-FIX&8: &eDetecting your skin source..."
synced: "&6SKIN-FIX&8(&b{provider}&8): &aSkin synchronization completed."
not-external: "&6SKIN-FIX&8: &7No external skin provider matched; your current skin was kept."
failed: "&6SKIN-FIX&8: &cSkin detection or synchronization failed. Please try again later."
messages:
usage: "&cUsage: /essc skin <status|refresh> [player]"
feature-disabled: "&cSkinBridge is disabled in config.yml."
dependency-missing: "&cNo valid MineSkin API key is configured in config.yml."
no-providers: "&cNo valid SkinBridge provider is enabled."
status-external: "&a{player} is identified as an external skin provider player. Provider: &f{provider}"
status-not-external: "&7{player} does not match any configured external skin provider."
status-pending: "&eSkinBridge is still detecting {player}'s login source."
status-unknown: "&7{player} has not been checked by SkinBridge yet."
refresh-queued: "&aSkinBridge is detecting and refreshing {player}'s skin."
refresh-cached: "&7{player} is using a valid skin cache entry."
refresh-running: "&eSkinBridge is already processing {player}."
blocks-menu:
title: "&6&lEssentialsC &8- &e&lShortcut Menu"
items:
+20
View File
@@ -88,6 +88,7 @@ help:
tpaccept: " &f/tpaccept [玩家] &7- 接受传送请求"
tpdeny: " &f/tpdeny [玩家] &7- 拒绝传送请求"
tpignore: " &f/tpignore &7- 切换是否忽略传送请求"
skin: " &f/essc skin <status|refresh> [玩家] &7- 查看或刷新皮肤桥接状态"
admin: " &f/essc admin &7- 切换管理模式"
tpsbar: " &f/tpsbar [玩家] &7- 切换 TPS 状态栏"
maintenance: " &f/maintenance <on|off|status|reload|add|remove|list> &7- 管理维护模式"
@@ -132,6 +133,25 @@ tpa:
tpaall-sent: "&a已向所有玩家发送传送请求。"
tpaall-no-targets: "&c没有可接收请求的在线玩家。"
skin-bridge:
notifications:
detecting: "&6SKIN-FIX&8: &e正在检测皮肤来源..."
synced: "&6SKIN-FIX&8(&b{provider}&8): &a皮肤同步完成。"
not-external: "&6SKIN-FIX&8: &7未匹配外置皮肤站,已保留当前皮肤。"
failed: "&6SKIN-FIX&8: &c皮肤检测或同步失败,请稍后重试。"
messages:
usage: "&c用法:/essc skin <status|refresh> [玩家]"
feature-disabled: "&cSkinBridge 在 config.yml 中未启用。"
dependency-missing: "&c未在 config.yml 中配置有效的 MineSkin API Key。"
no-providers: "&c没有启用有效的 SkinBridge Provider。"
status-external: "&a{player} 已识别为外置皮肤站玩家,Provider&f{provider}"
status-not-external: "&7{player} 未匹配任何已配置的外置皮肤站。"
status-pending: "&e{player} 的皮肤站来源正在检测中。"
status-unknown: "&7{player} 尚未进行 SkinBridge 检测。"
refresh-queued: "&a已开始重新检测并刷新 {player} 的皮肤。"
refresh-cached: "&7{player} 正在使用有效的皮肤缓存。"
refresh-running: "&e{player} 的皮肤检测任务仍在运行。"
blocks-menu:
title: "&6&lEssentialsC &8- &e&l便捷菜单"
items:
+3
View File
@@ -30,3 +30,6 @@ modules:
maintenance:
# 维护模式。开启维护后可替换 MOTD,并阻止无绕过权限的玩家进入。
enabled: true
skin-bridge:
# 查询外置 Yggdrasil profile,并通过 MineSkin 与 Paper Profile API 同步皮肤。
enabled: false
+4
View File
@@ -119,6 +119,9 @@ permissions:
essentialsc.command.maintenance:
description: 允许管理维护模式
default: op
essentialsc.command.skin:
description: 允许管理 SkinBridge 状态与刷新
default: op
essentialsc.maintenance.bypass:
description: 允许在维护模式下进入服务器
default: op
@@ -171,6 +174,7 @@ permissions:
essentialsc.command.tpsbar: true
essentialsc.command.tpsbar.others: true
essentialsc.command.maintenance: true
essentialsc.command.skin: true
essentialsc.maintenance.bypass: true
essentialsc.maintenance.notify: true
essentialsc.shulkerbox.open: true