Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ee2eb515 | ||
|
|
d2835dd272 | ||
|
|
b6dfad4aed | ||
|
|
88e2dfa04c | ||
|
|
f5dcd5f455 | ||
|
|
fa0ea9c7e9 | ||
|
|
d159d2fb11 | ||
|
|
778bf5e4c2 | ||
|
|
a7018e1e4f | ||
|
|
d0323ea975 | ||
|
|
6892c47599 | ||
|
|
2da1f2be9e | ||
|
|
59bf992162 | ||
|
|
a0f84ee542 | ||
|
|
cf84426bf6 | ||
|
|
9443a5cf1b | ||
|
|
74db3d022b | ||
|
|
7b8aa4e194 | ||
|
|
7c649675d6 | ||
|
|
a44bc88bd0 | ||
|
|
49d86c9e85 | ||
|
|
2c006d35b5 | ||
|
|
687b592d69 | ||
|
|
a2c92d9150 | ||
|
|
325d363f4b | ||
|
|
74e2c60d60 | ||
|
|
ad17f9ccba | ||
|
|
b65562850f | ||
|
|
956ee0e940 | ||
|
|
f7f47e6178 | ||
|
|
7b62075dc7 | ||
|
|
d7d51cbb6b | ||
|
|
6eaa883849 | ||
|
|
8a693e6417 | ||
|
|
8718dc6e87 | ||
|
|
51f56b9e18 | ||
|
|
0b3a8debdd | ||
|
|
340726205c | ||
|
|
ba73ebfdb2 | ||
|
|
5e9bd5f6bf | ||
|
|
474b912afe | ||
|
|
5b705d0af2 | ||
|
|
7525b8f6b5 | ||
|
|
2090939e87 | ||
|
|
14c288a77c | ||
|
|
c92e79160c | ||
|
|
a21e95df68 | ||
|
|
0b5422395c | ||
|
|
b7b564a091 |
+5
-1
@@ -121,5 +121,9 @@ test.*
|
|||||||
*.md
|
*.md
|
||||||
# 过滤readme和配置文件
|
# 过滤readme和配置文件
|
||||||
!readme.md
|
!readme.md
|
||||||
!scr/main/resources
|
!src/main/resources
|
||||||
!src/main/resources/**/*.*
|
!src/main/resources/**/*.*
|
||||||
|
# openai资源文件已启用(第58批 AI聊天功能)
|
||||||
|
!src/main/resources/openai/
|
||||||
|
!src/main/resources/openai/*.json
|
||||||
|
!src/main/resources/openai/*.txt
|
||||||
|
|||||||
@@ -0,0 +1,928 @@
|
|||||||
|
package Sdf1_login;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 第58批功能2: AI聊天介入
|
||||||
|
*
|
||||||
|
* 核心逻辑:
|
||||||
|
* - 兼容 openai 协议,根据模型名取 api 地址和 key
|
||||||
|
* - @//模型名/ 前缀触发直接提问
|
||||||
|
* - 空闲时 1-10 分钟自动抓聊天互动
|
||||||
|
* - 黑名单拦截不浪费 token
|
||||||
|
* - 对话留档 issue.json
|
||||||
|
*/
|
||||||
|
public class AIChatManager {
|
||||||
|
|
||||||
|
private final Main plugin;
|
||||||
|
|
||||||
|
// 配置数据
|
||||||
|
private JsonObject modelsConfig;
|
||||||
|
private JsonObject providersConfig;
|
||||||
|
private final Map<String, String> aliasToFullModel = new HashMap<>(); // 简称 → 模型全名
|
||||||
|
private final Map<String, ModelEndpoint> modelEndpoints = new HashMap<>(); // 模型全名 → 端点
|
||||||
|
private final Map<String, String> providerToModel = new HashMap<>(); // 厂家名 → 默认模型全名(@//厂家名/)
|
||||||
|
private final List<String> blacklist = new ArrayList<>();
|
||||||
|
|
||||||
|
// ★ 互动指标跳过日志静默:首次跳过记一条,之后只在状态变化(从≥→<)时再记
|
||||||
|
private boolean prevOnlineSufficient = true;
|
||||||
|
|
||||||
|
// ★ 自动互动日志静默:只在状态变化(成功→失败 或 失败→成功)时记一条
|
||||||
|
// 正常触发不打印,失败只一行简要错误,避免日志被刷屏
|
||||||
|
private boolean prevAutoChatFailed = false;
|
||||||
|
|
||||||
|
// 默认模型
|
||||||
|
private String defaultModelFull = "";
|
||||||
|
|
||||||
|
// 人设
|
||||||
|
private String serverName = "";
|
||||||
|
private String assistantName = "";
|
||||||
|
private String writingStyle = "";
|
||||||
|
|
||||||
|
// 发言间隔(分钟)
|
||||||
|
private int minIntervalMin = 1;
|
||||||
|
private int maxIntervalMin = 10;
|
||||||
|
|
||||||
|
// 默认上下文长度
|
||||||
|
private String defaultContextLength = "200000";
|
||||||
|
|
||||||
|
// 自定义供应商 URL 是否自动拼接 chat/completions(自动补齐开关,默认 true)
|
||||||
|
private boolean autoAppendCompletions = true;
|
||||||
|
|
||||||
|
// 互动指标:在线玩家数量达到此值才触发自动互动(手动 @// 不受影响)
|
||||||
|
private int interactionMetric = 1;
|
||||||
|
|
||||||
|
// 空闲调度器
|
||||||
|
private BukkitTask autoChatTask = null;
|
||||||
|
private long lastAiReplyTime = 0;
|
||||||
|
private final AtomicBoolean autoChatRunning = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
// 最近聊天记录缓冲区
|
||||||
|
private final List<String> recentChatBuffer = new ArrayList<>();
|
||||||
|
private static final int MAX_BUFFER = 30;
|
||||||
|
|
||||||
|
// 对话留档
|
||||||
|
private JsonObject issueRecord = new JsonObject();
|
||||||
|
private File issueFile = null;
|
||||||
|
|
||||||
|
// HTTP Client
|
||||||
|
private HttpClient httpClient;
|
||||||
|
|
||||||
|
// 最近提问的玩家(防止频繁请求)
|
||||||
|
private final Map<String, Long> lastRequestTime = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// 模型端点
|
||||||
|
static class ModelEndpoint {
|
||||||
|
String baseUrl;
|
||||||
|
String apiKey;
|
||||||
|
String maxToken;
|
||||||
|
boolean isCustom; // true=自定义供应商(受自动补齐开关控制), false=内置供应商(总是拼接)
|
||||||
|
}
|
||||||
|
|
||||||
|
public AIChatManager(Main plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(30))
|
||||||
|
.build();
|
||||||
|
loadConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 配置加载 ====================
|
||||||
|
|
||||||
|
private void loadConfig() {
|
||||||
|
loadConfig(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载配置
|
||||||
|
* @param quiet true=静默模式(重读时不打印"配置加载完成"),false=初始加载(打印)
|
||||||
|
*/
|
||||||
|
private void loadConfig(boolean quiet) {
|
||||||
|
try {
|
||||||
|
// 确保 openai 目录存在
|
||||||
|
File openaiDir = new File(plugin.getDataFolder(), "openai");
|
||||||
|
if (!openaiDir.exists()) {
|
||||||
|
openaiDir.mkdirs();
|
||||||
|
copyResource("openai/models.json", new File(openaiDir, "models.json"));
|
||||||
|
copyResource("openai/供应商.json", new File(openaiDir, "供应商.json"));
|
||||||
|
copyResource("openai/提示词.txt", new File(openaiDir, "提示词.txt"));
|
||||||
|
copyResource("openai/issue.json", new File(openaiDir, "issue.json"));
|
||||||
|
plugin.getLogger().info("[AI聊天] 已复制默认配置文件到 " + openaiDir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取配置
|
||||||
|
File modelsFile = new File(openaiDir, "models.json");
|
||||||
|
File providersFile = new File(openaiDir, "供应商.json");
|
||||||
|
File promptFile = new File(openaiDir, "提示词.txt");
|
||||||
|
issueFile = new File(openaiDir, "issue.json");
|
||||||
|
|
||||||
|
if (modelsFile.exists()) {
|
||||||
|
modelsConfig = JsonParser.parseString(Files.readString(modelsFile.toPath(), StandardCharsets.UTF_8)).getAsJsonObject();
|
||||||
|
}
|
||||||
|
if (providersFile.exists()) {
|
||||||
|
providersConfig = JsonParser.parseString(Files.readString(providersFile.toPath(), StandardCharsets.UTF_8)).getAsJsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取人设
|
||||||
|
if (modelsConfig != null && modelsConfig.has("人设部分")) {
|
||||||
|
JsonObject persona = modelsConfig.getAsJsonObject("人设部分");
|
||||||
|
serverName = getStr(persona, "服务器名字");
|
||||||
|
assistantName = getStr(persona, "助理名字");
|
||||||
|
writingStyle = getStr(persona, "写法");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取配置部分
|
||||||
|
if (modelsConfig != null && modelsConfig.has("配置部分")) {
|
||||||
|
JsonObject config = modelsConfig.getAsJsonObject("配置部分");
|
||||||
|
|
||||||
|
// 解析模型配置(配置部分 → "1" → "模型配置")
|
||||||
|
if (config.has("1")) {
|
||||||
|
JsonObject section1 = config.getAsJsonObject("1");
|
||||||
|
if (section1.has("模型配置")) {
|
||||||
|
JsonObject modelConfig = section1.getAsJsonObject("模型配置");
|
||||||
|
parseModelConfig(modelConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析全局配置(配置部分 → "2")
|
||||||
|
if (config.has("2")) {
|
||||||
|
JsonObject section2 = config.getAsJsonObject("2");
|
||||||
|
// 自动补齐开关:自定义供应商 baseurl 是否自动拼接 chat/completions
|
||||||
|
if (section2.has("自动补齐")) {
|
||||||
|
JsonElement autoEl = section2.get("自动补齐");
|
||||||
|
if (autoEl != null && autoEl.isJsonPrimitive()) {
|
||||||
|
String av = autoEl.getAsString();
|
||||||
|
// 支持 true/false 字符串与布尔
|
||||||
|
autoAppendCompletions = "true".equalsIgnoreCase(av);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultContextLength = getStr(section2, "默认上下文长度");
|
||||||
|
if (defaultContextLength == null || defaultContextLength.isEmpty()) {
|
||||||
|
defaultContextLength = "200000";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发言间隔
|
||||||
|
if (section2.has("发言间隔")) {
|
||||||
|
JsonObject interval = section2.getAsJsonObject("发言间隔");
|
||||||
|
String minStr = getStr(interval, "最短");
|
||||||
|
String maxStr = getStr(interval, "最长");
|
||||||
|
if (minStr != null && !minStr.isEmpty()) {
|
||||||
|
try { minIntervalMin = Integer.parseInt(minStr); } catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
if (maxStr != null && !maxStr.isEmpty()) {
|
||||||
|
try { maxIntervalMin = Integer.parseInt(maxStr); } catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 黑名单
|
||||||
|
if (section2.has("黑名单")) {
|
||||||
|
JsonElement bl = section2.get("黑名单");
|
||||||
|
if (bl.isJsonArray()) {
|
||||||
|
for (JsonElement el : bl.getAsJsonArray()) {
|
||||||
|
if (el.isJsonPrimitive()) {
|
||||||
|
blacklist.add(el.getAsString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 互动指标:在线玩家数量达到此值才触发自动互动
|
||||||
|
if (section2.has("互动指标")) {
|
||||||
|
JsonObject metricObj = section2.getAsJsonObject("互动指标");
|
||||||
|
if (metricObj.has("指标")) {
|
||||||
|
try {
|
||||||
|
interactionMetric = metricObj.get("指标").getAsInt();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取提示词.txt — 解析 models=简称 格式
|
||||||
|
aliasToFullModel.clear();
|
||||||
|
if (promptFile.exists()) {
|
||||||
|
List<String> lines = Files.readAllLines(promptFile.toPath(), StandardCharsets.UTF_8);
|
||||||
|
for (String line : lines) {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.isEmpty() || line.startsWith("#")) continue;
|
||||||
|
if (line.startsWith("models=")) {
|
||||||
|
String content = line.substring("models=".length()).trim();
|
||||||
|
if (content.contains("=")) {
|
||||||
|
// models=模型全名=简称
|
||||||
|
String[] parts = content.split("=", 2);
|
||||||
|
aliasToFullModel.put(parts[1].trim(), parts[0].trim());
|
||||||
|
} else {
|
||||||
|
// models=简称 → 简称直接作为模型全名
|
||||||
|
aliasToFullModel.put(content, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取对话留档
|
||||||
|
if (issueFile.exists()) {
|
||||||
|
issueRecord = JsonParser.parseString(Files.readString(issueFile.toPath(), StandardCharsets.UTF_8)).getAsJsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quiet) {
|
||||||
|
plugin.getLogger().info("[AI聊天] 配置加载完成: " + modelEndpoints.size() + "个模型, "
|
||||||
|
+ aliasToFullModel.size() + "个别名, 黑名单" + blacklist.size() + "条");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[AI聊天] 配置加载失败: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyResource(String resourcePath, File target) {
|
||||||
|
try {
|
||||||
|
InputStream is = plugin.getResource(resourcePath);
|
||||||
|
if (is == null) return;
|
||||||
|
Files.copy(is, target.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
is.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[AI聊天] 复制资源文件失败: " + resourcePath + " - " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void parseModelConfig(JsonObject modelConfig) {
|
||||||
|
// 遍历所有供应商配置
|
||||||
|
for (Map.Entry<String, JsonElement> entry : modelConfig.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
if (!entry.getValue().isJsonObject()) continue;
|
||||||
|
JsonObject supplierConfig = entry.getValue().getAsJsonObject();
|
||||||
|
|
||||||
|
// 判断是内置还是自定义
|
||||||
|
boolean isCustom = supplierConfig.has("baseurl");
|
||||||
|
boolean isBuiltin = supplierConfig.has("供应商名字");
|
||||||
|
|
||||||
|
if (!isCustom && !isBuiltin) continue;
|
||||||
|
|
||||||
|
String baseUrl;
|
||||||
|
String apiKey = getStr(supplierConfig, "apikey");
|
||||||
|
String maxToken = getStr(supplierConfig, "max_token");
|
||||||
|
if (maxToken == null || maxToken.isEmpty()) maxToken = defaultContextLength;
|
||||||
|
|
||||||
|
if (isCustom) {
|
||||||
|
baseUrl = getStr(supplierConfig, "baseurl");
|
||||||
|
} else {
|
||||||
|
String providerName = getStr(supplierConfig, "供应商名字");
|
||||||
|
baseUrl = findProviderUrl(providerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl == null || baseUrl.isEmpty() || apiKey == null || apiKey.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储原始 baseUrl(尾斜杠统一在发送时按自动补齐开关处理)
|
||||||
|
ModelEndpoint endpoint = new ModelEndpoint();
|
||||||
|
endpoint.baseUrl = baseUrl;
|
||||||
|
endpoint.apiKey = apiKey;
|
||||||
|
endpoint.maxToken = maxToken;
|
||||||
|
endpoint.isCustom = isCustom;
|
||||||
|
|
||||||
|
// 解析可用模型列表
|
||||||
|
if (supplierConfig.has("可用模型")) {
|
||||||
|
JsonElement models = supplierConfig.get("可用模型");
|
||||||
|
if (models.isJsonArray()) {
|
||||||
|
for (JsonElement modelEl : models.getAsJsonArray()) {
|
||||||
|
if (modelEl.isJsonPrimitive()) {
|
||||||
|
String modelName = modelEl.getAsString();
|
||||||
|
modelEndpoints.put(modelName, endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 注册厂家名 → 默认模型全名(@//厂家名/ 快捷引用)
|
||||||
|
// 内置供应商:从「供应商名字」路径的每一段都注册为厂家名别名
|
||||||
|
// 如 "非国产/agnes/中国版" → 非国产/agnes/中国版 三个名字都能 @// 调用
|
||||||
|
// 如 "mimo/token-plan" → mimo/token-plan 都能调用
|
||||||
|
// 自定义供应商:别名走「提示词.txt」的 models=全名=别名 映射(aliasToFullModel),这里不重复注册
|
||||||
|
if (isBuiltin) {
|
||||||
|
String providerName = getStr(supplierConfig, "供应商名字");
|
||||||
|
if (providerName != null && !providerName.isEmpty()) {
|
||||||
|
String defaultUse = getStr(supplierConfig, "默认使用");
|
||||||
|
String firstModel = null;
|
||||||
|
if (defaultUse != null && !defaultUse.isEmpty()) {
|
||||||
|
firstModel = defaultUse;
|
||||||
|
} else if (supplierConfig.has("可用模型")) {
|
||||||
|
JsonElement models = supplierConfig.get("可用模型");
|
||||||
|
if (models.isJsonArray() && models.getAsJsonArray().size() > 0) {
|
||||||
|
firstModel = models.getAsJsonArray().get(0).getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstModel != null) {
|
||||||
|
// 第1段如果是供应商.json顶层分组名(如「国产」「非国产」)→ 屏蔽不注册
|
||||||
|
// 从第2段开始才是厂家名/子分组名,注册为 @// 参数
|
||||||
|
String[] segs = providerName.split("/");
|
||||||
|
int startIdx = 0;
|
||||||
|
if (segs.length >= 1 && providersConfig != null
|
||||||
|
&& providersConfig.has(segs[0].trim())) {
|
||||||
|
// 第1段是分组名,跳过
|
||||||
|
startIdx = 1;
|
||||||
|
}
|
||||||
|
for (int i = startIdx; i < segs.length; i++) {
|
||||||
|
String name = segs[i].trim();
|
||||||
|
if (!name.isEmpty()) {
|
||||||
|
providerToModel.putIfAbsent(name, firstModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认使用(全局默认模型)
|
||||||
|
String defaultUse = getStr(supplierConfig, "默认使用");
|
||||||
|
if (defaultUse != null && !defaultUse.isEmpty() && defaultModelFull.isEmpty()) {
|
||||||
|
defaultModelFull = defaultUse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 供应商URL查找 ====================
|
||||||
|
|
||||||
|
private String findProviderUrl(String providerPath) {
|
||||||
|
if (providerPath == null || providerPath.isEmpty()) return null;
|
||||||
|
String[] parts = providerPath.split("/");
|
||||||
|
String first = parts[0].trim();
|
||||||
|
|
||||||
|
// 第1段是顶层分组名(如「国产」「非国产」)→ 直接在该分组里找
|
||||||
|
if (providersConfig != null && providersConfig.has(first)) {
|
||||||
|
String[] rest = Arrays.copyOfRange(parts, 1, parts.length);
|
||||||
|
return findUrlInObject(providersConfig.getAsJsonObject(first), rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第1段不是分组名 → 在所有顶层分组里尝试匹配(支持省略组名配置)
|
||||||
|
// 如 "deepseek" → 在国产/非国产里找 "deepseek" 键
|
||||||
|
if (providersConfig != null) {
|
||||||
|
for (Map.Entry<String, JsonElement> entry : providersConfig.entrySet()) {
|
||||||
|
if (entry.getValue().isJsonObject()) {
|
||||||
|
String url = findUrlInObject(entry.getValue().getAsJsonObject(), parts);
|
||||||
|
if (url != null) return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findUrlInObject(JsonObject obj, String[] parts) {
|
||||||
|
JsonElement el = obj.get(parts[0].trim());
|
||||||
|
if (el == null) return null;
|
||||||
|
if (parts.length == 1) {
|
||||||
|
return el.isJsonPrimitive() ? el.getAsString() : null;
|
||||||
|
}
|
||||||
|
if (el.isJsonObject()) {
|
||||||
|
String[] rest = Arrays.copyOfRange(parts, 1, parts.length);
|
||||||
|
return findUrlInObject(el.getAsJsonObject(), rest);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 模型名解析 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将简称解析为模型全名
|
||||||
|
* 支持三种格式:
|
||||||
|
* 1. 提示词.txt 里定义的简称(models=全名=简称)
|
||||||
|
* 2. 厂家名快捷引用(@//agnes/ → 该供应商的默认模型)
|
||||||
|
* 3. 直接使用模型全名
|
||||||
|
*/
|
||||||
|
public String resolveModelAlias(String alias) {
|
||||||
|
if (alias == null || alias.isEmpty()) return null;
|
||||||
|
// 1. 简称 → 模型全名
|
||||||
|
if (aliasToFullModel.containsKey(alias)) {
|
||||||
|
return aliasToFullModel.get(alias);
|
||||||
|
}
|
||||||
|
// 2. 厂家名 → 默认模型全名
|
||||||
|
if (providerToModel.containsKey(alias)) {
|
||||||
|
return providerToModel.get(alias);
|
||||||
|
}
|
||||||
|
// 3. 直接作为模型全名
|
||||||
|
if (modelEndpoints.containsKey(alias)) {
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可用模型简称列表
|
||||||
|
*/
|
||||||
|
public List<String> getAvailableModels() {
|
||||||
|
return new ArrayList<>(aliasToFullModel.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HTTP 请求 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向指定模型发送请求(异步)
|
||||||
|
* @param modelAlias 模型简称
|
||||||
|
* @param prompt 用户输入
|
||||||
|
* @param player 发起请求的玩家(用于私聊回复)
|
||||||
|
* @param broadcast 是否广播回复(自动互动时为 true)
|
||||||
|
*/
|
||||||
|
public void sendToModel(String modelAlias, String prompt, Player player, boolean broadcast) {
|
||||||
|
// ★ 每次发请求前重读配置,获取最新的模型、人设、黑名单等信息
|
||||||
|
// 避免模型429、403等报错后还用旧配置继续发请求
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// 黑名单检查
|
||||||
|
if (isBlacklisted(prompt)) {
|
||||||
|
plugin.getLogger().info("[AI聊天] 黑名单拦截: " + prompt.substring(0, Math.min(50, prompt.length())));
|
||||||
|
if (player != null) {
|
||||||
|
player.sendMessage("§c[AI聊天] §f输入内容命中黑名单,请求已拦截");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频率限制(同一玩家10秒内只能请求一次)
|
||||||
|
if (player != null) {
|
||||||
|
Long last = lastRequestTime.get(player.getName());
|
||||||
|
if (last != null && System.currentTimeMillis() - last < 10000) {
|
||||||
|
player.sendMessage("§c[AI聊天] §f请等待10秒后再提问");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastRequestTime.put(player.getName(), System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析模型名
|
||||||
|
String modelFull = resolveModelAlias(modelAlias);
|
||||||
|
if (modelFull == null) {
|
||||||
|
if (player != null) {
|
||||||
|
player.sendMessage("§c[AI聊天] §f未知模型/厂家: " + modelAlias);
|
||||||
|
List<String> models = getAvailableModels();
|
||||||
|
List<String> providers = new ArrayList<>(providerToModel.keySet());
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("§7可用厂家: ").append(String.join(", ", providers));
|
||||||
|
if (!models.isEmpty()) {
|
||||||
|
sb.append("\n§7可用模型: ").append(String.join(", ", models));
|
||||||
|
} else {
|
||||||
|
sb.append("\n§7未配置模型,请先在 openai/models.json 和 openai/提示词.txt 中配置");
|
||||||
|
}
|
||||||
|
player.sendMessage(sb.toString());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelEndpoint endpoint = modelEndpoints.get(modelFull);
|
||||||
|
if (endpoint == null) {
|
||||||
|
if (player != null) {
|
||||||
|
player.sendMessage("§c[AI聊天] §f模型配置不存在: " + modelFull);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建请求体
|
||||||
|
String systemPrompt = buildSystemPrompt();
|
||||||
|
String jsonBody = buildChatRequest(modelFull, systemPrompt, prompt, endpoint.maxToken);
|
||||||
|
String requestUrl = buildRequestUrl(endpoint);
|
||||||
|
|
||||||
|
// ★ 400调试日志:请求发出前打印完整信息
|
||||||
|
plugin.getLogger().info("[AI聊天-调试] === 请求开始 ===");
|
||||||
|
plugin.getLogger().info("[AI聊天-调试] 模型全名: " + modelFull);
|
||||||
|
plugin.getLogger().info("[AI聊天-调试] 请求URL: " + requestUrl);
|
||||||
|
plugin.getLogger().info("[AI聊天-调试] 自定义供应商: " + endpoint.isCustom);
|
||||||
|
plugin.getLogger().info("[AI聊天-调试] 自动补齐开关: " + autoAppendCompletions);
|
||||||
|
plugin.getLogger().info("[AI聊天-调试] 请求体JSON: " + jsonBody);
|
||||||
|
plugin.getLogger().info("[AI聊天-调试] === 请求结束 ===");
|
||||||
|
|
||||||
|
// 异步发送请求
|
||||||
|
CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(requestUrl))
|
||||||
|
.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
.header("Authorization", "Bearer " + endpoint.apiKey)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
|
||||||
|
.timeout(Duration.ofSeconds(60))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[AI聊天] 请求异常: " + e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).thenAccept(response -> {
|
||||||
|
if (response == null) {
|
||||||
|
handleRequestFailure(prompt, "请求异常: 网络错误");
|
||||||
|
if (player != null) {
|
||||||
|
player.sendMessage("§c[AI聊天] §f请求失败,请稍后重试");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
String errBody = response.body();
|
||||||
|
// ★ 400调试日志:打印完整响应头和响应体
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] === 请求失败 ===");
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] HTTP状态码: " + response.statusCode());
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] 模型全名: " + modelFull);
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] 请求URL: " + requestUrl);
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] 请求体JSON: " + jsonBody);
|
||||||
|
// 打印完整响应头
|
||||||
|
response.headers().map().forEach((name, values) -> {
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] 响应头 " + name + ": " + String.join(", ", values));
|
||||||
|
});
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] 响应体: " + errBody);
|
||||||
|
plugin.getLogger().warning("[AI聊天-调试] === 请求失败结束 ===");
|
||||||
|
|
||||||
|
handleRequestFailure(prompt, "HTTP " + response.statusCode() + ": " + errBody);
|
||||||
|
if (player != null) {
|
||||||
|
player.sendMessage("§c[AI聊天] §f请求失败: HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析响应
|
||||||
|
String reply = parseChatResponse(response.body());
|
||||||
|
if (reply == null) {
|
||||||
|
handleRequestFailure(prompt, "解析失败: " + response.body());
|
||||||
|
if (player != null) {
|
||||||
|
player.sendMessage("§c[AI聊天] §f响应解析失败");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送回复
|
||||||
|
if (broadcast) {
|
||||||
|
Bukkit.broadcastMessage("§d§l[AI] §f" + reply);
|
||||||
|
} else if (player != null) {
|
||||||
|
player.sendMessage("§d§l[AI] §f" + reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录留档
|
||||||
|
recordIssue(prompt, reply);
|
||||||
|
lastAiReplyTime = System.currentTimeMillis();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSystemPrompt() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (!serverName.isEmpty()) {
|
||||||
|
sb.append("你在一个名为\"").append(serverName).append("\"的Minecraft服务器中。\n");
|
||||||
|
}
|
||||||
|
if (!assistantName.isEmpty()) {
|
||||||
|
sb.append("你是服务器助理\"").append(assistantName).append("\"。\n");
|
||||||
|
}
|
||||||
|
sb.append("你需要用简洁友好的语气回答玩家的问题。\n");
|
||||||
|
if (!writingStyle.isEmpty()) {
|
||||||
|
sb.append("写法要求: ").append(writingStyle).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建请求 URL
|
||||||
|
* 自动补齐=true:baseUrl 以 / 结尾后拼接 chat/completions(默认)
|
||||||
|
* 自动补齐=false:baseUrl 已是完整端点(如 .../v1/chat/completions),直接用
|
||||||
|
*/
|
||||||
|
private String buildRequestUrl(ModelEndpoint endpoint) {
|
||||||
|
String url = endpoint.baseUrl;
|
||||||
|
// 内置供应商的 baseUrl 总是基础地址,必须拼接;自定义供应商受自动补齐开关控制
|
||||||
|
boolean needAppend = !endpoint.isCustom || autoAppendCompletions;
|
||||||
|
// ★ 防御性:即使开关为开,若 URL 已含 chat/completions 也绝不再补(避免双重地址)
|
||||||
|
if (needAppend && !url.contains("chat/completions")) {
|
||||||
|
if (!url.endsWith("/")) url += "/";
|
||||||
|
url += "chat/completions";
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建聊天请求体(OpenAI 兼容格式)
|
||||||
|
* 注意:不发送 max_tokens 字段,让 API 使用默认输出上限
|
||||||
|
* maxToken 参数仅用于本地上下文长度阈值判断(在 checkAutoChat 中做压缩)
|
||||||
|
*/
|
||||||
|
private String buildChatRequest(String model, String systemPrompt, String userPrompt, String maxToken) {
|
||||||
|
Gson gson = new Gson();
|
||||||
|
JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("model", model);
|
||||||
|
|
||||||
|
JsonArray messages = new JsonArray();
|
||||||
|
JsonObject sysMsg = new JsonObject();
|
||||||
|
sysMsg.addProperty("role", "system");
|
||||||
|
sysMsg.addProperty("content", systemPrompt);
|
||||||
|
messages.add(sysMsg);
|
||||||
|
|
||||||
|
JsonObject userMsg = new JsonObject();
|
||||||
|
userMsg.addProperty("role", "user");
|
||||||
|
userMsg.addProperty("content", userPrompt);
|
||||||
|
messages.add(userMsg);
|
||||||
|
|
||||||
|
body.add("messages", messages);
|
||||||
|
|
||||||
|
return gson.toJson(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上下文压缩:当聊天记录总长度超过阈值时,截断旧消息只保留最近的
|
||||||
|
* 返回压缩后的聊天记录字符串
|
||||||
|
*/
|
||||||
|
private String compressChatBuffer(String maxToken) {
|
||||||
|
int threshold;
|
||||||
|
try {
|
||||||
|
threshold = Integer.parseInt(maxToken);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
threshold = 200000;
|
||||||
|
}
|
||||||
|
if (threshold <= 0) threshold = 200000;
|
||||||
|
|
||||||
|
String joined = String.join("\n", recentChatBuffer);
|
||||||
|
if (joined.length() <= threshold) {
|
||||||
|
return joined; // 未超阈值,直接返回
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超阈值:从尾部往前保留消息,直到达到阈值的 80%(留余量给提示词模板)
|
||||||
|
int targetLen = (int) (threshold * 0.8);
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int totalLen = 0;
|
||||||
|
// 从最新消息开始往前累加
|
||||||
|
for (int i = recentChatBuffer.size() - 1; i >= 0; i--) {
|
||||||
|
String line = recentChatBuffer.get(i);
|
||||||
|
int lineLen = line.length() + 1; // +1 for newline
|
||||||
|
if (totalLen + lineLen > targetLen) break;
|
||||||
|
if (sb.length() > 0) sb.insert(0, "\n");
|
||||||
|
sb.insert(0, line);
|
||||||
|
totalLen += lineLen;
|
||||||
|
}
|
||||||
|
plugin.getLogger().info("[AI聊天] 上下文压缩: 原始" + joined.length() + "字符 → 压缩至" + sb.length() + "字符 (阈值" + threshold + ")");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseChatResponse(String responseBody) {
|
||||||
|
try {
|
||||||
|
JsonObject resp = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||||
|
if (resp.has("choices")) {
|
||||||
|
JsonArray choices = resp.getAsJsonArray("choices");
|
||||||
|
if (choices.size() > 0) {
|
||||||
|
JsonObject first = choices.get(0).getAsJsonObject();
|
||||||
|
if (first.has("message")) {
|
||||||
|
JsonObject message = first.getAsJsonObject("message");
|
||||||
|
if (message.has("content")) {
|
||||||
|
return message.get("content").getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRequestFailure(String prompt, String errorDetail) {
|
||||||
|
plugin.getLogger().warning("[AI聊天] 请求失败: " + errorDetail);
|
||||||
|
recordIssue(prompt, "[请求失败] " + errorDetail);
|
||||||
|
// ★ 死循环防护:失败也更新时间戳,间隔内不会再触发
|
||||||
|
lastAiReplyTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 聊天处理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理玩家聊天消息
|
||||||
|
* 检测 @厂家名/内容 或 @//厂家名/内容 前缀触发直接提问
|
||||||
|
* 格式: @厂家名/内容 或 @//厂家名/内容 或 @厂家名 或 @//厂家名
|
||||||
|
*/
|
||||||
|
public void handlePlayerChat(Player p, String message) {
|
||||||
|
// 记录到聊天缓冲区
|
||||||
|
String chatLine = p.getDisplayName() + ": " + message;
|
||||||
|
recentChatBuffer.add(chatLine);
|
||||||
|
if (recentChatBuffer.size() > MAX_BUFFER) {
|
||||||
|
recentChatBuffer.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测 @// 或 @ 前缀(支持单 @ 和双 @//)
|
||||||
|
String prefix = "";
|
||||||
|
int prefixLen = 0;
|
||||||
|
if (message.startsWith("@//")) {
|
||||||
|
prefix = "@//";
|
||||||
|
prefixLen = 3;
|
||||||
|
} else if (message.startsWith("@")) {
|
||||||
|
prefix = "@";
|
||||||
|
prefixLen = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prefix.isEmpty()) {
|
||||||
|
// 去掉前缀后查找第一个斜杠或空格
|
||||||
|
String afterPrefix = message.substring(prefixLen);
|
||||||
|
int slashIdx = afterPrefix.indexOf('/');
|
||||||
|
int spaceIdx = afterPrefix.indexOf(' ');
|
||||||
|
|
||||||
|
String modelAlias;
|
||||||
|
String prompt;
|
||||||
|
|
||||||
|
if (slashIdx > 0) {
|
||||||
|
// 格式: @厂家名/内容
|
||||||
|
modelAlias = afterPrefix.substring(0, slashIdx).trim();
|
||||||
|
prompt = afterPrefix.substring(slashIdx + 1).trim();
|
||||||
|
} else if (spaceIdx > 0) {
|
||||||
|
// 格式: @厂家名 内容(空格分隔)
|
||||||
|
modelAlias = afterPrefix.substring(0, spaceIdx).trim();
|
||||||
|
prompt = afterPrefix.substring(spaceIdx + 1).trim();
|
||||||
|
} else {
|
||||||
|
// 格式: @厂家名(无内容)
|
||||||
|
modelAlias = afterPrefix.trim();
|
||||||
|
prompt = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelAlias.isEmpty()) {
|
||||||
|
p.sendMessage("§c[AI聊天] §f格式错误,应为 @厂家名/内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prompt.isEmpty()) {
|
||||||
|
// 不带内容时用默认提示:让模型根据人设自我介绍
|
||||||
|
prompt = "请用一句话介绍一下你自己,以及你能为服务器玩家做什么。";
|
||||||
|
}
|
||||||
|
sendToModel(modelAlias, prompt, p, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 空闲调度器 ====================
|
||||||
|
|
||||||
|
public void startAutoChatScheduler() {
|
||||||
|
if (autoChatTask != null) {
|
||||||
|
autoChatTask.cancel();
|
||||||
|
}
|
||||||
|
// 每1秒检查一次
|
||||||
|
autoChatTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
checkAutoChat();
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[AI聊天] 自动互动异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}, 20L, 20L);
|
||||||
|
plugin.getLogger().info("[AI聊天] 空闲自动互动调度器已启动");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopAutoChatScheduler() {
|
||||||
|
if (autoChatTask != null) {
|
||||||
|
autoChatTask.cancel();
|
||||||
|
autoChatTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkAutoChat() {
|
||||||
|
if (!autoChatRunning.compareAndSet(false, true)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查是否到了自动互动时间
|
||||||
|
long intervalMs = (minIntervalMin + (int)(Math.random() * (maxIntervalMin - minIntervalMin + 1))) * 60000L;
|
||||||
|
|
||||||
|
if (System.currentTimeMillis() - lastAiReplyTime < intervalMs) return;
|
||||||
|
if (recentChatBuffer.isEmpty()) return;
|
||||||
|
if (defaultModelFull.isEmpty()) return;
|
||||||
|
|
||||||
|
// ★ 每次发请求前重读配置(静默模式,不打印日志避免刷屏)
|
||||||
|
loadConfig(true);
|
||||||
|
|
||||||
|
// ★ 互动指标:在线玩家数量达标才触发自动互动
|
||||||
|
// ★ 日志静默:调度器每1秒跑一次,跳过时不再每秒刷屏。
|
||||||
|
// 仅在状态变化(在线玩家从达标→不达标)时记一条,避免日志被刷屏。
|
||||||
|
int onlineCount = Bukkit.getOnlinePlayers().size();
|
||||||
|
if (onlineCount < interactionMetric) {
|
||||||
|
if (prevOnlineSufficient) {
|
||||||
|
// 之前达标,现在不达标了:状态变化,记一条
|
||||||
|
plugin.getLogger().info("[AI聊天] 在线玩家 " + onlineCount + " < 互动指标 " + interactionMetric + ",停止自动互动");
|
||||||
|
}
|
||||||
|
prevOnlineSufficient = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 在线玩家刚从不足→达标:记一条恢复
|
||||||
|
if (!prevOnlineSufficient) {
|
||||||
|
plugin.getLogger().info("[AI聊天] 在线玩家 " + onlineCount + " ≥ 互动指标 " + interactionMetric + ",恢复自动互动");
|
||||||
|
}
|
||||||
|
prevOnlineSufficient = true;
|
||||||
|
|
||||||
|
ModelEndpoint endpoint = modelEndpoints.get(defaultModelFull);
|
||||||
|
if (endpoint == null) return;
|
||||||
|
|
||||||
|
// 构建自动互动提示词 — 用压缩方法(超阈值时截断旧消息)
|
||||||
|
String recentChat = compressChatBuffer(endpoint.maxToken);
|
||||||
|
String prompt = "以下是服务器最近的聊天记录:\n" + recentChat + "\n\n请根据聊天内容,自然地抛出一个话题或问题,引导大家参与讨论,带动服务器的活跃气氛。不要直接点名任何玩家,而是抛出一个开放性的话题让大家都能参与。";
|
||||||
|
|
||||||
|
// ★ 死循环防护:触发前先更新时间戳,请求挂起/超时/失败都不会被下一秒重复触发
|
||||||
|
lastAiReplyTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// ★ 日志静默:正常触发不打印"触发自动互动",只在失败时记一条简要错误
|
||||||
|
// 状态变化(失败→成功 或 成功→失败)时记一条恢复/告警
|
||||||
|
|
||||||
|
// 异步发送
|
||||||
|
String systemPrompt = buildSystemPrompt();
|
||||||
|
String jsonBody = buildChatRequest(defaultModelFull, systemPrompt, prompt, endpoint.maxToken);
|
||||||
|
String requestUrl = buildRequestUrl(endpoint);
|
||||||
|
|
||||||
|
CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(requestUrl))
|
||||||
|
.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
.header("Authorization", "Bearer " + endpoint.apiKey)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
|
||||||
|
.timeout(Duration.ofSeconds(60))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).thenAccept(response -> {
|
||||||
|
if (response == null) {
|
||||||
|
// ★ 日志静默:异常只记一行,不刷屏
|
||||||
|
plugin.getLogger().warning("[AI聊天] 自动互动请求异常(网络超时)");
|
||||||
|
handleRequestFailure("[自动] " + prompt.substring(0, Math.min(100, prompt.length())), "自动互动失败: 请求异常");
|
||||||
|
prevAutoChatFailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
// ★ 日志静默:非200只记一行状态码,不打印完整响应体
|
||||||
|
plugin.getLogger().warning("[AI聊天] 自动互动失败: HTTP " + response.statusCode());
|
||||||
|
handleRequestFailure("[自动] " + prompt.substring(0, Math.min(100, prompt.length())),
|
||||||
|
"自动互动失败: HTTP " + response.statusCode());
|
||||||
|
prevAutoChatFailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String reply = parseChatResponse(response.body());
|
||||||
|
if (reply != null) {
|
||||||
|
Bukkit.broadcastMessage("§d§l[AI] §f" + reply);
|
||||||
|
recordIssue("[自动] " + prompt.substring(0, Math.min(100, prompt.length())), reply);
|
||||||
|
lastAiReplyTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// ★ 状态变化:失败→成功,记一条恢复日志
|
||||||
|
if (prevAutoChatFailed) {
|
||||||
|
plugin.getLogger().info("[AI聊天] 自动互动恢复正常");
|
||||||
|
prevAutoChatFailed = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plugin.getLogger().warning("[AI聊天] 自动互动响应解析失败");
|
||||||
|
handleRequestFailure("[自动] " + prompt.substring(0, Math.min(100, prompt.length())),
|
||||||
|
"自动互动失败: 响应解析失败");
|
||||||
|
prevAutoChatFailed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
autoChatRunning.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 黑名单 ====================
|
||||||
|
|
||||||
|
public boolean isBlacklisted(String content) {
|
||||||
|
if (content == null || content.isEmpty()) return false;
|
||||||
|
for (String kw : blacklist) {
|
||||||
|
if (kw != null && !kw.isEmpty() && content.contains(kw)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 对话留档 ====================
|
||||||
|
|
||||||
|
private void recordIssue(String question, String answer) {
|
||||||
|
if (issueFile == null) return;
|
||||||
|
try {
|
||||||
|
issueRecord.addProperty(question, answer);
|
||||||
|
// 原子写入
|
||||||
|
File tmp = new File(issueFile.getParent(), "issue.tmp.json");
|
||||||
|
Files.write(tmp.toPath(), issueRecord.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
Files.move(tmp.toPath(), issueFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[AI聊天] 对话留档失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具方法 ====================
|
||||||
|
|
||||||
|
private String getStr(JsonObject obj, String key) {
|
||||||
|
if (obj == null || !obj.has(key)) return "";
|
||||||
|
JsonElement el = obj.get(key);
|
||||||
|
if (el == null || el.isJsonNull()) return "";
|
||||||
|
return el.isJsonPrimitive() ? el.getAsString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
public void onEnable() {
|
||||||
|
startAutoChatScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onDisable() {
|
||||||
|
stopAutoChatScheduler();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1259,6 +1259,7 @@ public class AreaProtection implements Listener {
|
|||||||
try { ac.badEffects = parseEffectsString(rs.getString("bad_effects")); } catch (Exception ignored) { ac.badEffects = new ArrayList<>(); }
|
try { ac.badEffects = parseEffectsString(rs.getString("bad_effects")); } catch (Exception ignored) { ac.badEffects = new ArrayList<>(); }
|
||||||
try { ac.confiscateCount = rs.getInt("confiscate_count"); } catch (Exception ignored) {}
|
try { ac.confiscateCount = rs.getInt("confiscate_count"); } catch (Exception ignored) {}
|
||||||
try { ac.returnOnLeave = rs.getInt("return_on_leave") == 1; } catch (Exception ignored) {}
|
try { ac.returnOnLeave = rs.getInt("return_on_leave") == 1; } catch (Exception ignored) {}
|
||||||
|
try { ac.parentId = rs.getInt("parent_id"); } catch (Exception ignored) { ac.parentId = 0; } // ★ 第57批续21
|
||||||
try { ac.warpX = rs.getDouble("warp_x"); } catch (Exception ignored) {}
|
try { ac.warpX = rs.getDouble("warp_x"); } catch (Exception ignored) {}
|
||||||
try { ac.warpY = rs.getDouble("warp_y"); } catch (Exception ignored) {}
|
try { ac.warpY = rs.getDouble("warp_y"); } catch (Exception ignored) {}
|
||||||
try { ac.warpZ = rs.getDouble("warp_z"); } catch (Exception ignored) {}
|
try { ac.warpZ = rs.getDouble("warp_z"); } catch (Exception ignored) {}
|
||||||
@@ -1483,7 +1484,8 @@ public class AreaProtection implements Listener {
|
|||||||
+ "enable_announce INTEGER DEFAULT 0,"
|
+ "enable_announce INTEGER DEFAULT 0,"
|
||||||
+ "announce_template TEXT DEFAULT '',"
|
+ "announce_template TEXT DEFAULT '',"
|
||||||
+ "txt_content TEXT DEFAULT '',"
|
+ "txt_content TEXT DEFAULT '',"
|
||||||
+ "created_at INTEGER NOT NULL)");
|
+ "created_at INTEGER NOT NULL,"
|
||||||
|
+ "parent_id INTEGER DEFAULT 0)");
|
||||||
|
|
||||||
// ★ 领地权限表(管理员/用户/访客三级权限)
|
// ★ 领地权限表(管理员/用户/访客三级权限)
|
||||||
stmt.executeUpdate(
|
stmt.executeUpdate(
|
||||||
@@ -1567,6 +1569,8 @@ public class AreaProtection implements Listener {
|
|||||||
// ★ 违禁品管理面板(2026-10-11):没收计数 + 退出是否归还
|
// ★ 违禁品管理面板(2026-10-11):没收计数 + 退出是否归还
|
||||||
try { stmt.executeUpdate("ALTER TABLE area_lands ADD COLUMN confiscate_count INTEGER DEFAULT 0"); } catch (Exception ignored) {}
|
try { stmt.executeUpdate("ALTER TABLE area_lands ADD COLUMN confiscate_count INTEGER DEFAULT 0"); } catch (Exception ignored) {}
|
||||||
try { stmt.executeUpdate("ALTER TABLE area_lands ADD COLUMN return_on_leave INTEGER DEFAULT 0"); } catch (Exception ignored) {}
|
try { stmt.executeUpdate("ALTER TABLE area_lands ADD COLUMN return_on_leave INTEGER DEFAULT 0"); } catch (Exception ignored) {}
|
||||||
|
// ★ 第57批续21 子领地列:旧库补列(已存在则忽略)
|
||||||
|
try { stmt.executeUpdate("ALTER TABLE area_lands ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch (Exception ignored) {}
|
||||||
|
|
||||||
// ★ 全局配置默认值
|
// ★ 全局配置默认值
|
||||||
try {
|
try {
|
||||||
@@ -2568,6 +2572,47 @@ public class AreaProtection implements Listener {
|
|||||||
&& z >= minZ && z <= maxZ;
|
&& z >= minZ && z <= maxZ;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 第57批续21:查找【完全包含】指定矩形区域(世界+归一化坐标)的主领地(parentId==0)。
|
||||||
|
* 子领地(有父)不视为"包含方"——部分重叠只算与父领地重叠,避免在子领地内再嵌套(仅1级)。
|
||||||
|
* 多个主领地都完全包含时返回第一个(子领地完全包含于唯一主领地,正常不会多)。
|
||||||
|
* @return 主领地的 AreaConfig;无则 null
|
||||||
|
*/
|
||||||
|
public AreaConfig findContainingMainLand(String world, int nx1, int nz1, int nx2, int nz2) {
|
||||||
|
if (world == null) return null;
|
||||||
|
int minX = Math.min(nx1, nx2), maxX = Math.max(nx1, nx2);
|
||||||
|
int minZ = Math.min(nz1, nz2), maxZ = Math.max(nz1, nz2);
|
||||||
|
for (AreaConfig other : areas.values()) {
|
||||||
|
if (other == null) continue;
|
||||||
|
if (other.parentId != 0) continue; // 只看主领地
|
||||||
|
if (!other.world.equalsIgnoreCase(world)) continue;
|
||||||
|
int oMinX = Math.min(other.x1, other.x2), oMaxX = Math.max(other.x1, other.x2);
|
||||||
|
int oMinZ = Math.min(other.z1, other.z2), oMaxZ = Math.max(other.z1, other.z2);
|
||||||
|
// 完全包含:other ⊇ new
|
||||||
|
if (oMinX <= minX && oMaxX >= maxX && oMinZ <= minZ && oMaxZ >= maxZ) {
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 第57批续21:创建子领地的身份闸门——判定玩家是否具备主领地的
|
||||||
|
* 领地主 / 领地管理员 / 插件管理员 任一身份(授权管理员=该领地里 role='admin' 的行)。
|
||||||
|
* 只有合格者才允许在主领地内创建子领地;不合格一律拒绝。
|
||||||
|
*/
|
||||||
|
public boolean isQualifiedLandManager(Player p, AreaConfig mainLand) {
|
||||||
|
if (p == null || mainLand == null) return false;
|
||||||
|
if (isAreaAdmin(p)) return true; // 插件管理员(OP/ScoreboardTag)
|
||||||
|
if (samePlayer(p.getName(), mainLand.owner)) return true; // 领地主
|
||||||
|
int lid = getLandIdFromDb(mainLand.name);
|
||||||
|
if (lid > 0) {
|
||||||
|
PermRow row = findPermRowExact(lid, p.getName());
|
||||||
|
if (row != null && "admin".equalsIgnoreCase(row.role)) return true; // 授权领地管理员
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取玩家在指定领地的权限级别
|
* 获取玩家在指定领地的权限级别
|
||||||
*/
|
*/
|
||||||
@@ -2584,6 +2629,21 @@ public class AreaProtection implements Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.5) ★ 第57批续21 子领地继承:玩家对本领地无直接角色,但父领地里是
|
||||||
|
// 领地主/授权管理员/插件管理员 → 在子领地内同样行使管理权(防越权绕过)。
|
||||||
|
// 子领地 owner=主领地主,上面第2步已覆盖 owner;这里补授权管理员/插件管理员继承。
|
||||||
|
if (ac != null && ac.parentId > 0 && dbConnection != null) {
|
||||||
|
String parentName = findLandNameById(ac.parentId);
|
||||||
|
if (parentName != null) {
|
||||||
|
AreaConfig parent = areas.get(parentName);
|
||||||
|
if (parent != null
|
||||||
|
&& !samePlayer(player.getName(), parent.owner)
|
||||||
|
&& isQualifiedLandManager(player, parent)) {
|
||||||
|
return PermissionLevel.ADMIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 领地角色(DB,仅限该领地;2026-10-11 精确匹配,一次查询判 admin/member 两态)
|
// 3. 领地角色(DB,仅限该领地;2026-10-11 精确匹配,一次查询判 admin/member 两态)
|
||||||
// admin → ADMIN(/protect setadmin 或领地所有者GUI设置)
|
// admin → ADMIN(/protect setadmin 或领地所有者GUI设置)
|
||||||
// member(未过期) → MEMBER:已授权成员,不受领地全局默认权限限制
|
// member(未过期) → MEMBER:已授权成员,不受领地全局默认权限限制
|
||||||
@@ -2962,43 +3022,81 @@ public class AreaProtection implements Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
||||||
* 拉黑时撤销该玩家在所有领地的管理员身份(admin -> visitor)。
|
* 拉黑时撤销该玩家在所有领地的管理员身份(admin -> visitor)。
|
||||||
|
|
||||||
* 走 normName 定位(历史数据大小写可能不一致),逐行 UPDATE。
|
* 走 normName 定位(历史数据大小写可能不一致),逐行 UPDATE。
|
||||||
|
|
||||||
* @return 撤销的管理员身份数量
|
* @return 撤销的管理员身份数量
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public int demoteBlacklistAdmins(String playerName) {
|
public int demoteBlacklistAdmins(String playerName) {
|
||||||
|
|
||||||
if (dbConnection == null || playerName == null || playerName.isEmpty()) return 0;
|
if (dbConnection == null || playerName == null || playerName.isEmpty()) return 0;
|
||||||
|
|
||||||
int count = 0;
|
int count = 0;
|
||||||
|
|
||||||
String target = normName(playerName);
|
String target = normName(playerName);
|
||||||
|
|
||||||
java.util.List<Integer> ids = new java.util.ArrayList<>();
|
java.util.List<Integer> ids = new java.util.ArrayList<>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
PreparedStatement sel = dbConnection.prepareStatement(
|
PreparedStatement sel = dbConnection.prepareStatement(
|
||||||
|
|
||||||
"SELECT id, player_name FROM area_land_permissions WHERE role = 'admin'");
|
"SELECT id, player_name FROM area_land_permissions WHERE role = 'admin'");
|
||||||
|
|
||||||
ResultSet rs = sel.executeQuery();
|
ResultSet rs = sel.executeQuery();
|
||||||
|
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|
||||||
String pn = rs.getString("player_name");
|
String pn = rs.getString("player_name");
|
||||||
|
|
||||||
if (pn != null && normName(pn).equals(target)) {
|
if (pn != null && normName(pn).equals(target)) {
|
||||||
|
|
||||||
ids.add(rs.getInt("id"));
|
ids.add(rs.getInt("id"));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rs.close();
|
rs.close();
|
||||||
|
|
||||||
sel.close();
|
sel.close();
|
||||||
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int id : ids) {
|
for (int id : ids) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
PreparedStatement up = dbConnection.prepareStatement(
|
PreparedStatement up = dbConnection.prepareStatement(
|
||||||
|
|
||||||
"UPDATE area_land_permissions SET role = 'visitor' WHERE id = ?");
|
"UPDATE area_land_permissions SET role = 'visitor' WHERE id = ?");
|
||||||
|
|
||||||
up.setInt(1, id);
|
up.setInt(1, id);
|
||||||
|
|
||||||
count += up.executeUpdate();
|
count += up.executeUpdate();
|
||||||
|
|
||||||
up.close();
|
up.close();
|
||||||
|
|
||||||
} catch (SQLException ignored) {
|
} catch (SQLException ignored) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插入一条领地权限记录(角色为 member/visitor),已存在则忽略
|
* 插入一条领地权限记录(角色为 member/visitor),已存在则忽略
|
||||||
* 用于 PHP 端添加成员时同步到 Java
|
* 用于 PHP 端添加成员时同步到 Java
|
||||||
@@ -3123,6 +3221,62 @@ public class AreaProtection implements Listener {
|
|||||||
return n.endsWith("_SIGN");
|
return n.endsWith("_SIGN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 第57批续22: 子领地创建时继承主领地权限一次。
|
||||||
|
* 后续子领地权限由子领地主/管理员/子领地授权管理员单独调配,
|
||||||
|
* 不再跟随主领地变化。
|
||||||
|
* 继承范围: deny* 权限开关、出入提示、游戏模式强制、公共建筑标记。
|
||||||
|
* 不继承: owner(创建者)、坐标、parentId、createCost。
|
||||||
|
*/
|
||||||
|
private void copyLandPermissions(AreaConfig src, AreaConfig dst) {
|
||||||
|
if (src == null || dst == null) return;
|
||||||
|
dst.denyBlockPlace = src.denyBlockPlace;
|
||||||
|
dst.denyBlockBreak = src.denyBlockBreak;
|
||||||
|
dst.denySignEdit = src.denySignEdit;
|
||||||
|
dst.denyContainer = src.denyContainer;
|
||||||
|
dst.denyPVP = src.denyPVP;
|
||||||
|
dst.denyFallDamage = src.denyFallDamage;
|
||||||
|
dst.denyHunger = src.denyHunger;
|
||||||
|
dst.denyAllDamage = src.denyAllDamage;
|
||||||
|
dst.denyMount = src.denyMount;
|
||||||
|
dst.allowDrop = src.allowDrop;
|
||||||
|
dst.denyEnderPearl = src.denyEnderPearl;
|
||||||
|
dst.denyBow = src.denyBow;
|
||||||
|
dst.denyPotion = src.denyPotion;
|
||||||
|
dst.denyExplosion = src.denyExplosion;
|
||||||
|
dst.denyMove = src.denyMove;
|
||||||
|
dst.allowPickup = src.allowPickup;
|
||||||
|
dst.denyFire = src.denyFire;
|
||||||
|
dst.denyThrownProjectiles = src.denyThrownProjectiles;
|
||||||
|
dst.denyGlowing = src.denyGlowing;
|
||||||
|
dst.denyRedstoneInteraction = src.denyRedstoneInteraction;
|
||||||
|
dst.denyDoorInteraction = src.denyDoorInteraction;
|
||||||
|
dst.denyNoteblockJukebox = src.denyNoteblockJukebox;
|
||||||
|
dst.denyLead = src.denyLead;
|
||||||
|
dst.denyCropHarvest = src.denyCropHarvest;
|
||||||
|
dst.denyFarmlandTrample = src.denyFarmlandTrample;
|
||||||
|
dst.denyEnderTeleport = src.denyEnderTeleport;
|
||||||
|
dst.denyWoolShear = src.denyWoolShear;
|
||||||
|
dst.denyAnimalFeeding = src.denyAnimalFeeding;
|
||||||
|
dst.denySpawnEgg = src.denySpawnEgg;
|
||||||
|
dst.denyWax = src.denyWax;
|
||||||
|
dst.denyMobAttack = src.denyMobAttack;
|
||||||
|
dst.denyVillagerTrade = src.denyVillagerTrade;
|
||||||
|
dst.denyRaid = src.denyRaid;
|
||||||
|
dst.denyFireSpread = src.denyFireSpread;
|
||||||
|
dst.denyAllEffects = src.denyAllEffects;
|
||||||
|
dst.denyItemFrame = src.denyItemFrame;
|
||||||
|
dst.denyEntityInteract = src.denyEntityInteract;
|
||||||
|
dst.enterMsg = src.enterMsg;
|
||||||
|
dst.leaveMsg = src.leaveMsg;
|
||||||
|
dst.enforceGameMode = src.enforceGameMode;
|
||||||
|
dst.isPublicBuilding = src.isPublicBuilding;
|
||||||
|
dst.allowVisitorTeleport = src.allowVisitorTeleport;
|
||||||
|
dst.peaceMode = src.peaceMode;
|
||||||
|
dst.peaceModeDuration = src.peaceModeDuration;
|
||||||
|
dst.clearAllBadEffects = src.clearAllBadEffects;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取领地默认deny状态(按字段名映射)
|
* 获取领地默认deny状态(按字段名映射)
|
||||||
*/
|
*/
|
||||||
@@ -3515,6 +3669,12 @@ public class AreaProtection implements Listener {
|
|||||||
String newArea = null;
|
String newArea = null;
|
||||||
String forcedMode = null;
|
String forcedMode = null;
|
||||||
|
|
||||||
|
// ★ 第57批续22: 收集所有匹配领地,选面积最小的。
|
||||||
|
// 子领地完全包含在主领地内,ConcurrentHashMap 遍历顺序不定,
|
||||||
|
// 先 break 可能命中主领地 → 子领地 deny_move=1 不生效。
|
||||||
|
// 面积最小 = 子领地(完全包含时面积必然更小),天然优先级正确。
|
||||||
|
AreaConfig bestAc = null;
|
||||||
|
String bestName = null;
|
||||||
for (Map.Entry<String, AreaConfig> entry
|
for (Map.Entry<String, AreaConfig> entry
|
||||||
: areas.entrySet()) {
|
: areas.entrySet()) {
|
||||||
AreaConfig ac = entry.getValue();
|
AreaConfig ac = entry.getValue();
|
||||||
@@ -3530,11 +3690,25 @@ public class AreaProtection implements Listener {
|
|||||||
if (px >= minX && px <= maxX
|
if (px >= minX && px <= maxX
|
||||||
&& pz >= minZ && pz <= maxZ
|
&& pz >= minZ && pz <= maxZ
|
||||||
&& py >= ac.yMin && py <= ac.yMax) {
|
&& py >= ac.yMin && py <= ac.yMax) {
|
||||||
newArea = entry.getKey();
|
if (bestAc == null) {
|
||||||
forcedMode = ac.enforceGameMode;
|
bestAc = ac;
|
||||||
break;
|
bestName = entry.getKey();
|
||||||
|
} else {
|
||||||
|
long bestArea = (long)(Math.abs(bestAc.x1 - bestAc.x2) + 1)
|
||||||
|
* (Math.abs(bestAc.z1 - bestAc.z2) + 1);
|
||||||
|
long curArea = (long)(Math.abs(ac.x1 - ac.x2) + 1)
|
||||||
|
* (Math.abs(ac.z1 - ac.z2) + 1);
|
||||||
|
if (curArea <= bestArea) {
|
||||||
|
bestAc = ac;
|
||||||
|
bestName = entry.getKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestName != null) {
|
||||||
|
newArea = bestName;
|
||||||
|
forcedMode = bestAc.enforceGameMode;
|
||||||
|
}
|
||||||
|
|
||||||
/* plugin.getLogger().info(
|
/* plugin.getLogger().info(
|
||||||
"[防护-移动] " + p.getName()
|
"[防护-移动] " + p.getName()
|
||||||
@@ -5896,6 +6070,35 @@ public class AreaProtection implements Listener {
|
|||||||
BondManager bnd = plugin.getBonds();
|
BondManager bnd = plugin.getBonds();
|
||||||
int bal = (bnd != null) ? bnd.getBonds(p.getName()) : 0;
|
int bal = (bnd != null) ? bnd.getBonds(p.getName()) : 0;
|
||||||
p.sendMessage("§e§l[防护] §f选区完成!面积: §a" + a + "㎡§7(" + w + "×" + l + ") 单价: §f" + pricePerSqm + "/㎡" + src + " 预估: §e" + totalCost + "§7债券 余额: §a" + bal);
|
p.sendMessage("§e§l[防护] §f选区完成!面积: §a" + a + "㎡§7(" + w + "×" + l + ") 单价: §f" + pricePerSqm + "/㎡" + src + " 预估: §e" + totalCost + "§7债券 余额: §a" + bal);
|
||||||
|
boolean canCreateSub = true; // ★ 续21修1: 身份不合格/部分重叠时不展示确认创建按钮
|
||||||
|
// ★ 第57批续21 圈地棒选区后智能识别:若落在已有领地内,提示需具备该领地管理身份才能建子领地
|
||||||
|
{
|
||||||
|
int sx1 = Math.min(lp1.getBlockX(), newLoc.getBlockX());
|
||||||
|
int sx2 = Math.max(lp1.getBlockX(), newLoc.getBlockX());
|
||||||
|
int sz1 = Math.min(lp1.getBlockZ(), newLoc.getBlockZ());
|
||||||
|
int sz2 = Math.max(lp1.getBlockZ(), newLoc.getBlockZ());
|
||||||
|
AreaConfig selMain = findContainingMainLand(p.getWorld().getName(), sx1, sz1, sx2, sz2);
|
||||||
|
if (selMain != null) {
|
||||||
|
// 身份判定静默:不合格则不展示按钮,不提示原因
|
||||||
|
if (!isQualifiedLandManager(p, selMain)) {
|
||||||
|
canCreateSub = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (AreaConfig sot : areas.values()) {
|
||||||
|
if (sot == null || sot.parentId != 0) continue;
|
||||||
|
if (!sot.world.equalsIgnoreCase(p.getWorld().getName())) continue;
|
||||||
|
int so1x = Math.min(sot.x1, sot.x2), so2x = Math.max(sot.x1, sot.x2);
|
||||||
|
int so1z = Math.min(sot.z1, sot.z2), so2z = Math.max(sot.z1, sot.z2);
|
||||||
|
if (sx1 <= so2x && sx2 >= so1x && sz1 <= so2z && sz2 >= so1z) {
|
||||||
|
canCreateSub = false;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canCreateSub) {
|
||||||
// ★ 一键创建超链接:自动用玩家名,冲突则加数字(重名不区分大小写)
|
// ★ 一键创建超链接:自动用玩家名,冲突则加数字(重名不区分大小写)
|
||||||
String autoName = p.getName();
|
String autoName = p.getName();
|
||||||
if (areaNameExists(autoName)) {
|
if (areaNameExists(autoName)) {
|
||||||
@@ -5911,6 +6114,9 @@ public class AreaProtection implements Listener {
|
|||||||
.clickEvent(ClickEvent.runCommand("/protect confirm_create " + finalAutoName))
|
.clickEvent(ClickEvent.runCommand("/protect confirm_create " + finalAutoName))
|
||||||
);
|
);
|
||||||
p.sendMessage(Component.text("§7或输入 §f/protect 创建 <自定义名> §7指定领地名"));
|
p.sendMessage(Component.text("§7或输入 §f/protect 创建 <自定义名> §7指定领地名"));
|
||||||
|
} else {
|
||||||
|
// 身份/范围限制:静默隐藏按钮,不提示原因
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7785,8 +7991,51 @@ public class AreaProtection implements Listener {
|
|||||||
if (!pos1.containsKey(cp.getUniqueId()) || !pos2.containsKey(cp.getUniqueId())) {
|
if (!pos1.containsKey(cp.getUniqueId()) || !pos2.containsKey(cp.getUniqueId())) {
|
||||||
cp.sendMessage("§c请先用工具选点"); return true;
|
cp.sendMessage("§c请先用工具选点"); return true;
|
||||||
}
|
}
|
||||||
|
// ★ 第57批续21 提前取选区坐标(供空间重叠识别,原在下方重复声明已移除)
|
||||||
|
Location cl1 = pos1.get(cp.getUniqueId());
|
||||||
|
Location cl2 = pos2.get(cp.getUniqueId());
|
||||||
|
int cw = Math.abs(cl2.getBlockX() - cl1.getBlockX()) + 1;
|
||||||
|
int cl = Math.abs(cl2.getBlockZ() - cl1.getBlockZ()) + 1;
|
||||||
|
int cArea = cw * cl;
|
||||||
// 检查重名(不区分大小写)
|
// 检查重名(不区分大小写)
|
||||||
if (areaNameExists(confirmName)) { cp.sendMessage("§c区域已存在(大小写不同也算重复)"); return true; }
|
if (areaNameExists(confirmName)) { cp.sendMessage("§c区域已存在(大小写不同也算重复)"); return true; }
|
||||||
|
// ★ 第57批续21 空间重叠智能识别 + 子领地身份闸门(修"任何人能在别人领地内建重叠领地"绕过漏洞)
|
||||||
|
int cnx1 = Math.min(cl1.getBlockX(), cl2.getBlockX());
|
||||||
|
int cnz1 = Math.min(cl1.getBlockZ(), cl2.getBlockZ());
|
||||||
|
int cnx2 = Math.max(cl1.getBlockX(), cl2.getBlockX());
|
||||||
|
int cnz2 = Math.max(cl1.getBlockZ(), cl2.getBlockZ());
|
||||||
|
String cnWorld = cl1.getWorld().getName();
|
||||||
|
AreaConfig cnMain = findContainingMainLand(cnWorld, cnx1, cnz1, cnx2, cnz2);
|
||||||
|
boolean cnIsSub = cnMain != null;
|
||||||
|
if (cnIsSub) {
|
||||||
|
if (!isQualifiedLandManager(cp, cnMain)) {
|
||||||
|
cp.sendMessage("§c§l[防护] §f拒绝创建: 当前选区已在领地 §e" + cnMain.name
|
||||||
|
+ " §f范围内,你需具备该领地的领主/管理员/授权管理员身份才能创建子领地。");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无完全包含的主领地 → 部分重叠(跨边界)一律拒绝
|
||||||
|
for (AreaConfig ot : areas.values()) {
|
||||||
|
if (ot == null || ot.parentId != 0) continue;
|
||||||
|
if (!ot.world.equalsIgnoreCase(cnWorld)) continue;
|
||||||
|
int omx = Math.min(ot.x1, ot.x2), omx2 = Math.max(ot.x1, ot.x2);
|
||||||
|
int omz = Math.min(ot.z1, ot.z2), omz2 = Math.max(ot.z1, ot.z2);
|
||||||
|
if (cnx1 <= omx2 && cnx2 >= omx && cnz1 <= omz2 && cnz2 >= omz) {
|
||||||
|
cp.sendMessage("§c§l[防护] §f拒绝创建: 选区与领地 §e" + ot.name
|
||||||
|
+ " §f部分重叠(跨边界),子领地必须完整位于某主领地内,请重选区域。");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cnIsSub) {
|
||||||
|
// 子领地命名冲突:自动追加序号(父领地内子领地可多个)
|
||||||
|
String subName = confirmName;
|
||||||
|
if (areaNameExists(subName)) {
|
||||||
|
int s2 = 1;
|
||||||
|
while (areaNameExists(subName + s2)) s2++;
|
||||||
|
confirmName = subName + s2;
|
||||||
|
}
|
||||||
|
}
|
||||||
// ★ 检查上限(含DB兜底)
|
// ★ 检查上限(含DB兜底)
|
||||||
UserGroupManager cugm = plugin.getUserGroup();
|
UserGroupManager cugm = plugin.getUserGroup();
|
||||||
int cMaxLands = (cugm != null) ? cugm.getPlayerMaxLands(cp.getName(), globalMaxLandsPerPlayer) : globalMaxLandsPerPlayer;
|
int cMaxLands = (cugm != null) ? cugm.getPlayerMaxLands(cp.getName(), globalMaxLandsPerPlayer) : globalMaxLandsPerPlayer;
|
||||||
@@ -7800,12 +8049,7 @@ public class AreaProtection implements Listener {
|
|||||||
cr.close(); cs.close();
|
cr.close(); cs.close();
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
if (cLandCount >= cMaxLands) { cp.sendMessage("§c§l[防护] §f已达领地上限!"); return true; }
|
if (!cnIsSub && cLandCount >= cMaxLands) { cp.sendMessage("§c§l[防护] §f已达领地上限!"); return true; }
|
||||||
Location cl1 = pos1.get(cp.getUniqueId());
|
|
||||||
Location cl2 = pos2.get(cp.getUniqueId());
|
|
||||||
int cw = Math.abs(cl2.getBlockX() - cl1.getBlockX()) + 1;
|
|
||||||
int cl = Math.abs(cl2.getBlockZ() - cl1.getBlockZ()) + 1;
|
|
||||||
int cArea = cw * cl;
|
|
||||||
int cPrice = (cugm != null) ? cugm.getPlayerLandPricePerSqm(cp.getName(), globalCreatePricePerSqm) : globalCreatePricePerSqm;
|
int cPrice = (cugm != null) ? cugm.getPlayerLandPricePerSqm(cp.getName(), globalCreatePricePerSqm) : globalCreatePricePerSqm;
|
||||||
int cCost = cArea * cPrice;
|
int cCost = cArea * cPrice;
|
||||||
|
|
||||||
@@ -7832,13 +8076,27 @@ public class AreaProtection implements Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AreaConfig cac = new AreaConfig();
|
AreaConfig cac = new AreaConfig();
|
||||||
cac.name = confirmName; cac.owner = cp.getName(); cac.world = cl1.getWorld().getName();
|
cac.name = confirmName;
|
||||||
|
// ★ 第57批续21 子领地:owner 沿用主领地主;主领地:owner=创建者
|
||||||
|
// ★ 续21续2: 子领地 owner=创建者(付款人)——退款退给付款人。
|
||||||
|
// 权限继承靠 getPermissionLevel 父领地ADMIN继承(已实现),子领地权限行挂父领地land_id
|
||||||
|
cac.owner = cp.getName();
|
||||||
|
cac.world = cl1.getWorld().getName();
|
||||||
cac.x1 = cl1.getBlockX(); cac.z1 = cl1.getBlockZ(); cac.x2 = cl2.getBlockX(); cac.z2 = cl2.getBlockZ();
|
cac.x1 = cl1.getBlockX(); cac.z1 = cl1.getBlockZ(); cac.x2 = cl2.getBlockX(); cac.z2 = cl2.getBlockZ();
|
||||||
cac.yMin = 0; cac.yMax = 255;
|
cac.yMin = 0; cac.yMax = 255;
|
||||||
cac.createCost = chargedCost; // ★ 记录创建扣款(删除时按比例退费的基数)
|
cac.createCost = chargedCost; // ★ 记录创建扣款(删除时按比例退费的基数)
|
||||||
|
cac.parentId = cnIsSub ? getLandIdFromDb(cnMain.name) : 0; // ★ 第57批续21 子领地父ID
|
||||||
|
// ★ 第57批续22: 子领地创建时继承主领地权限一次(后续可单独调配)
|
||||||
|
if (cnIsSub) {
|
||||||
|
copyLandPermissions(cnMain, cac);
|
||||||
|
}
|
||||||
saveAreaToDb(cac);
|
saveAreaToDb(cac);
|
||||||
areas.put(confirmName, cac); // ★ 立即更新内存
|
areas.put(confirmName, cac); // ★ 立即更新内存
|
||||||
cp.sendMessage("§a§l[防护] §f区域 " + confirmName + " 已创建 (owner: " + cp.getName() + ")");
|
if (cnIsSub) {
|
||||||
|
cp.sendMessage("§a§l[防护] §f子领地 §e" + confirmName + " §f已创建(父领地: " + cnMain.name + ")");
|
||||||
|
} else {
|
||||||
|
cp.sendMessage("§a§l[防护] §f区域 " + confirmName + " 已创建 (owner: " + cac.owner + ")");
|
||||||
|
}
|
||||||
loadAllAreas();
|
loadAllAreas();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -10454,6 +10712,39 @@ public class AreaProtection implements Listener {
|
|||||||
adm.sendMessage("§c§l[审批] §f领地名 §e" + pe.landName + " §f已被占用,申请作废");
|
adm.sendMessage("§c§l[审批] §f领地名 §e" + pe.landName + " §f已被占用,申请作废");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// ★ 第57批续21 审批路径也套空间重叠闸门:申请人具备主领地管理身份(领地主/管理员/授权管理员)
|
||||||
|
// 才能在重叠区创建子领地;审批管理员本人申请时 isAreaAdmin=true 恒合格。
|
||||||
|
int aN1x = Math.min(pe.newX1, pe.newX2), aN2x = Math.max(pe.newX1, pe.newX2);
|
||||||
|
int aN1z = Math.min(pe.newZ1, pe.newZ2), aN2z = Math.max(pe.newZ1, pe.newZ2);
|
||||||
|
AreaConfig apMain = findContainingMainLand(pe.world, aN1x, aN1z, aN2x, aN2z);
|
||||||
|
boolean apIsSub = apMain != null;
|
||||||
|
Player apReqOnline = Bukkit.getPlayerExact(pe.requester);
|
||||||
|
if (apIsSub) {
|
||||||
|
if (apReqOnline == null || !isQualifiedLandManager(apReqOnline, apMain)) {
|
||||||
|
pendingExpands.remove(pe.landName.toLowerCase());
|
||||||
|
adm.sendMessage("§c§l[审批] §f申请区已在领地 §e" + apMain.name
|
||||||
|
+ " §f内,但申请人 §e" + pe.requester + " §f不具备该领地的管理身份,申请作废");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (AreaConfig aot : areas.values()) {
|
||||||
|
if (aot == null || aot.parentId != 0) continue;
|
||||||
|
if (!aot.world.equalsIgnoreCase(pe.world)) continue;
|
||||||
|
int ao1x = Math.min(aot.x1, aot.x2), ao2x = Math.max(aot.x1, aot.x2);
|
||||||
|
int ao1z = Math.min(aot.z1, aot.z2), ao2z = Math.max(aot.z1, aot.z2);
|
||||||
|
if (aN1x <= ao2x && aN2x >= ao1x && aN1z <= ao2z && aN2z >= ao1z) {
|
||||||
|
pendingExpands.remove(pe.landName.toLowerCase());
|
||||||
|
adm.sendMessage("§c§l[审批] §f申请区与领地 §e" + aot.name
|
||||||
|
+ " §f部分重叠(跨边界),子领地必须完整位于某主领地内,申请作废");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apIsSub && areaNameExists(pe.landName)) {
|
||||||
|
int sa = 1;
|
||||||
|
while (areaNameExists(pe.landName + sa)) sa++;
|
||||||
|
pe.landName = pe.landName + sa;
|
||||||
|
}
|
||||||
UserGroupManager aPugm = plugin.getUserGroup();
|
UserGroupManager aPugm = plugin.getUserGroup();
|
||||||
int aMax = (aPugm != null) ? aPugm.getPlayerMaxLands(pe.requester, globalMaxLandsPerPlayer) : globalMaxLandsPerPlayer;
|
int aMax = (aPugm != null) ? aPugm.getPlayerMaxLands(pe.requester, globalMaxLandsPerPlayer) : globalMaxLandsPerPlayer;
|
||||||
long aCnt = areas.values().stream().filter(a -> samePlayer(pe.requester, a.owner)).count();
|
long aCnt = areas.values().stream().filter(a -> samePlayer(pe.requester, a.owner)).count();
|
||||||
@@ -10485,18 +10776,30 @@ public class AreaProtection implements Listener {
|
|||||||
}
|
}
|
||||||
AreaConfig nc = new AreaConfig();
|
AreaConfig nc = new AreaConfig();
|
||||||
nc.name = pe.landName;
|
nc.name = pe.landName;
|
||||||
|
// ★ 第57批续21 子领地:owner 沿用主领地主;主领地:owner=申请人
|
||||||
|
// ★ 续21续2: 子领地 owner=申请人(付款人)——退款退给付款人。权限继承靠父领地ADMIN继承
|
||||||
nc.owner = pe.requester;
|
nc.owner = pe.requester;
|
||||||
nc.world = pe.world;
|
nc.world = pe.world;
|
||||||
nc.x1 = pe.newX1; nc.z1 = pe.newZ1;
|
nc.x1 = pe.newX1; nc.z1 = pe.newZ1;
|
||||||
nc.x2 = pe.newX2; nc.z2 = pe.newZ2;
|
nc.x2 = pe.newX2; nc.z2 = pe.newZ2;
|
||||||
nc.yMin = 0; nc.yMax = 255;
|
nc.yMin = 0; nc.yMax = 255;
|
||||||
nc.createCost = charged;
|
nc.createCost = charged;
|
||||||
|
nc.parentId = apIsSub ? getLandIdFromDb(apMain.name) : 0; // ★ 第57批续21 子领地父ID
|
||||||
|
// ★ 第57批续22: 子领地创建时继承主领地权限一次(后续可单独调配)
|
||||||
|
if (apIsSub) {
|
||||||
|
copyLandPermissions(apMain, nc);
|
||||||
|
}
|
||||||
saveAreaToDb(nc);
|
saveAreaToDb(nc);
|
||||||
areas.put(pe.landName, nc);
|
areas.put(pe.landName, nc);
|
||||||
pendingExpands.remove(pe.landName.toLowerCase());
|
pendingExpands.remove(pe.landName.toLowerCase());
|
||||||
loadAllAreas();
|
loadAllAreas();
|
||||||
|
if (apIsSub) {
|
||||||
|
adm.sendMessage("§a§l[审批] §f已批准创建子领地 §e" + pe.landName
|
||||||
|
+ " §f(父领地 " + apMain.name + ", §e" + pe.area + "㎡§f),向申请人扣除 §e" + charged + " §f债券");
|
||||||
|
} else {
|
||||||
adm.sendMessage("§a§l[审批] §f已批准创建领地 §e" + pe.landName + " §f(§e"
|
adm.sendMessage("§a§l[审批] §f已批准创建领地 §e" + pe.landName + " §f(§e"
|
||||||
+ pe.area + "㎡§f),向申请人扣除 §e" + charged + " §f债券");
|
+ pe.area + "㎡§f),向申请人扣除 §e" + charged + " §f债券");
|
||||||
|
}
|
||||||
if (requester != null && requester.isOnline()) {
|
if (requester != null && requester.isOnline()) {
|
||||||
requester.sendMessage("§a§l[防护] §f你的领地 §e" + pe.landName
|
requester.sendMessage("§a§l[防护] §f你的领地 §e" + pe.landName
|
||||||
+ " §f已由管理员审批创建成功(" + pe.area + "㎡,扣除 " + charged + " 债券)");
|
+ " §f已由管理员审批创建成功(" + pe.area + "㎡,扣除 " + charged + " 债券)");
|
||||||
@@ -10761,8 +11064,8 @@ public class AreaProtection implements Listener {
|
|||||||
+ "confiscate_msg, enable_announce, announce_template, txt_content, created_at, "
|
+ "confiscate_msg, enable_announce, announce_template, txt_content, created_at, "
|
||||||
+ "deny_thrown_projectiles, deny_glowing, deny_redstone_interaction, deny_door_interaction, "
|
+ "deny_thrown_projectiles, deny_glowing, deny_redstone_interaction, deny_door_interaction, "
|
||||||
+ "deny_noteblock_jukebox, deny_lead, deny_crop_harvest, deny_wool_shear, deny_animal_feeding, "
|
+ "deny_noteblock_jukebox, deny_lead, deny_crop_harvest, deny_wool_shear, deny_animal_feeding, "
|
||||||
+ "warp_x, warp_y, warp_z, warp_yaw, warp_pitch, warp_world, deny_container, deny_mob_attack, deny_sign_edit, is_public_building, allow_visitor_teleport, deny_farmland_trample, deny_ender_teleport, create_cost, deny_spawn_egg, deny_wax, bad_effects, deny_villager_trade, confiscate_count, return_on_leave) "
|
+ "warp_x, warp_y, warp_z, warp_yaw, warp_pitch, warp_world, deny_container, deny_mob_attack, deny_sign_edit, is_public_building, allow_visitor_teleport, deny_farmland_trample, deny_ender_teleport, create_cost, deny_spawn_egg, deny_wax, bad_effects, deny_villager_trade, confiscate_count, return_on_leave, parent_id) "
|
||||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
+ "ON CONFLICT(name) DO UPDATE SET "
|
+ "ON CONFLICT(name) DO UPDATE SET "
|
||||||
+ "owner=excluded.owner, world=excluded.world, x1=excluded.x1, z1=excluded.z1, x2=excluded.x2, z2=excluded.z2, y_min=excluded.y_min, y_max=excluded.y_max, "
|
+ "owner=excluded.owner, world=excluded.world, x1=excluded.x1, z1=excluded.z1, x2=excluded.x2, z2=excluded.z2, y_min=excluded.y_min, y_max=excluded.y_max, "
|
||||||
+ "confiscate_items=excluded.confiscate_items, deny_use_items=excluded.deny_use_items, give_effects=excluded.give_effects, clear_effects=excluded.clear_effects, clear_all_bad=excluded.clear_all_bad, "
|
+ "confiscate_items=excluded.confiscate_items, deny_use_items=excluded.deny_use_items, give_effects=excluded.give_effects, clear_effects=excluded.clear_effects, clear_all_bad=excluded.clear_all_bad, "
|
||||||
@@ -10775,7 +11078,7 @@ public class AreaProtection implements Listener {
|
|||||||
+ "confiscate_msg=excluded.confiscate_msg, enable_announce=excluded.enable_announce, announce_template=excluded.announce_template, txt_content=excluded.txt_content, created_at=excluded.created_at, "
|
+ "confiscate_msg=excluded.confiscate_msg, enable_announce=excluded.enable_announce, announce_template=excluded.announce_template, txt_content=excluded.txt_content, created_at=excluded.created_at, "
|
||||||
+ "deny_thrown_projectiles=excluded.deny_thrown_projectiles, deny_glowing=excluded.deny_glowing, deny_redstone_interaction=excluded.deny_redstone_interaction, deny_door_interaction=excluded.deny_door_interaction, "
|
+ "deny_thrown_projectiles=excluded.deny_thrown_projectiles, deny_glowing=excluded.deny_glowing, deny_redstone_interaction=excluded.deny_redstone_interaction, deny_door_interaction=excluded.deny_door_interaction, "
|
||||||
+ "deny_noteblock_jukebox=excluded.deny_noteblock_jukebox, deny_lead=excluded.deny_lead, deny_crop_harvest=excluded.deny_crop_harvest, deny_wool_shear=excluded.deny_wool_shear, deny_animal_feeding=excluded.deny_animal_feeding, "
|
+ "deny_noteblock_jukebox=excluded.deny_noteblock_jukebox, deny_lead=excluded.deny_lead, deny_crop_harvest=excluded.deny_crop_harvest, deny_wool_shear=excluded.deny_wool_shear, deny_animal_feeding=excluded.deny_animal_feeding, "
|
||||||
+ "warp_x=excluded.warp_x, warp_y=excluded.warp_y, warp_z=excluded.warp_z, warp_yaw=excluded.warp_yaw, warp_pitch=excluded.warp_pitch, warp_world=excluded.warp_world, deny_container=excluded.deny_container, deny_mob_attack=excluded.deny_mob_attack, deny_sign_edit=excluded.deny_sign_edit, is_public_building=excluded.is_public_building, allow_visitor_teleport=excluded.allow_visitor_teleport, deny_farmland_trample=excluded.deny_farmland_trample, deny_ender_teleport=excluded.deny_ender_teleport, create_cost=excluded.create_cost, deny_spawn_egg=excluded.deny_spawn_egg, deny_wax=excluded.deny_wax, bad_effects=excluded.bad_effects, deny_villager_trade=excluded.deny_villager_trade, confiscate_count=excluded.confiscate_count, return_on_leave=excluded.return_on_leave");
|
+ "warp_x=excluded.warp_x, warp_y=excluded.warp_y, warp_z=excluded.warp_z, warp_yaw=excluded.warp_yaw, warp_pitch=excluded.warp_pitch, warp_world=excluded.warp_world, deny_container=excluded.deny_container, deny_mob_attack=excluded.deny_mob_attack, deny_sign_edit=excluded.deny_sign_edit, is_public_building=excluded.is_public_building, allow_visitor_teleport=excluded.allow_visitor_teleport, deny_farmland_trample=excluded.deny_farmland_trample, deny_ender_teleport=excluded.deny_ender_teleport, create_cost=excluded.create_cost, deny_spawn_egg=excluded.deny_spawn_egg, deny_wax=excluded.deny_wax, bad_effects=excluded.bad_effects, deny_villager_trade=excluded.deny_villager_trade, confiscate_count=excluded.confiscate_count, return_on_leave=excluded.return_on_leave, parent_id=excluded.parent_id");
|
||||||
|
|
||||||
stmt.setString(1, ac.name);
|
stmt.setString(1, ac.name);
|
||||||
stmt.setString(2, ac.owner != null ? ac.owner : "");
|
stmt.setString(2, ac.owner != null ? ac.owner : "");
|
||||||
@@ -10853,6 +11156,7 @@ public class AreaProtection implements Listener {
|
|||||||
stmt.setInt(74, ac.denyVillagerTrade ? 1 : 0);
|
stmt.setInt(74, ac.denyVillagerTrade ? 1 : 0);
|
||||||
stmt.setInt(75, ac.confiscateCount);
|
stmt.setInt(75, ac.confiscateCount);
|
||||||
stmt.setInt(76, ac.returnOnLeave ? 1 : 0);
|
stmt.setInt(76, ac.returnOnLeave ? 1 : 0);
|
||||||
|
stmt.setInt(77, ac.parentId); // ★ 第57批续21 子领地父ID(0=主领地)
|
||||||
stmt.executeUpdate();
|
stmt.executeUpdate();
|
||||||
stmt.close();
|
stmt.close();
|
||||||
// ★ 领地设置变更:立即触发PHP同步(防抖10秒)
|
// ★ 领地设置变更:立即触发PHP同步(防抖10秒)
|
||||||
@@ -10951,6 +11255,22 @@ public class AreaProtection implements Listener {
|
|||||||
/**
|
/**
|
||||||
* 从数据库获取领地ID
|
* 从数据库获取领地ID
|
||||||
*/
|
*/
|
||||||
|
/** ★ 第57批续21:按 area_lands.id 反查领地名(子领地继承判定用) */
|
||||||
|
public String findLandNameById(int id) {
|
||||||
|
if (dbConnection == null || id <= 0) return null;
|
||||||
|
try {
|
||||||
|
PreparedStatement stmt = dbConnection.prepareStatement("SELECT name FROM area_lands WHERE id = ?");
|
||||||
|
stmt.setInt(1, id);
|
||||||
|
ResultSet rs = stmt.executeQuery();
|
||||||
|
String n = rs.next() ? rs.getString("name") : null;
|
||||||
|
rs.close();
|
||||||
|
stmt.close();
|
||||||
|
return n;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int getLandIdFromDb(String name) {
|
public int getLandIdFromDb(String name) {
|
||||||
if (dbConnection == null) return -1;
|
if (dbConnection == null) return -1;
|
||||||
try {
|
try {
|
||||||
@@ -13782,6 +14102,10 @@ public class AreaProtection implements Listener {
|
|||||||
public boolean allowVisitorTeleport = false;
|
public boolean allowVisitorTeleport = false;
|
||||||
// ★ 创建领地时实际扣款金额(删除领地按比例退费的基数;0=无扣款)
|
// ★ 创建领地时实际扣款金额(删除领地按比例退费的基数;0=无扣款)
|
||||||
public int createCost = 0;
|
public int createCost = 0;
|
||||||
|
// ★ 第57批续21 子领地:父领地(主领地)的 area_lands.id;0=主领地(无父)
|
||||||
|
// 子领地必须完整包含在某主领地内(1级,不可再嵌套),owner 沿用主领地主,
|
||||||
|
// 权限行挂在父领地 land_id 下(area_land_permissions),可单独收紧/放行。
|
||||||
|
public int parentId = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -13948,11 +14272,27 @@ public class AreaProtection implements Listener {
|
|||||||
int x2 = cx + 1, z2 = cz + 1;
|
int x2 = cx + 1, z2 = cz + 1;
|
||||||
int area = 9; // 3×3
|
int area = 9; // 3×3
|
||||||
|
|
||||||
// ★ 检查这个区域是否与其他领地重叠
|
// ★ 第57批续21 先查 3×3 是否完全包含于某主领地 → 领地主身份合格则自动转 confirm_create 建子领地
|
||||||
|
AreaConfig qMain = findContainingMainLand(p.getWorld().getName(), x1, z1, x2, z2);
|
||||||
|
if (qMain != null) {
|
||||||
|
if (isQualifiedLandManager(p, qMain)) {
|
||||||
|
p.sendMessage("§a§l[防护] §f检测到 3×3 区域在主领地 §e" + qMain.name
|
||||||
|
+ " §f内,已为你转为创建子领地");
|
||||||
|
p.performCommand("protect confirm_create " + p.getName());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
p.sendMessage("§c§l[防护] §f该区域已在领地 §e" + qMain.name
|
||||||
|
+ " §f内,你需具备该领地的领主/管理员身份才能创建子领地");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ★ 部分重叠(跨边界)仍拒绝
|
||||||
for (AreaConfig other : areas.values()) {
|
for (AreaConfig other : areas.values()) {
|
||||||
|
if (other.parentId != 0) continue;
|
||||||
if (!other.world.equalsIgnoreCase(p.getWorld().getName())) continue;
|
if (!other.world.equalsIgnoreCase(p.getWorld().getName())) continue;
|
||||||
// AABB重叠检测
|
int omx = Math.min(other.x1, other.x2), omx2 = Math.max(other.x1, other.x2);
|
||||||
if (x1 <= other.x2 && x2 >= other.x1 && z1 <= other.z2 && z2 >= other.z1) {
|
int omz = Math.min(other.z1, other.z2), omz2 = Math.max(other.z1, other.z2);
|
||||||
|
if (x1 <= omx2 && x2 >= omx && z1 <= omz2 && z2 >= omz) {
|
||||||
p.sendMessage("§c§l[防护] §f该区域与领地 §e" + other.name + " §f重叠,请走远点再试");
|
p.sendMessage("§c§l[防护] §f该区域与领地 §e" + other.name + " §f重叠,请走远点再试");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ public class BanBroadcastManager implements Listener {
|
|||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
long cut0 = resolveStartCutoff();
|
long cut0 = resolveStartCutoff();
|
||||||
File bf0 = bannedPlayersFile();
|
File bf0 = bannedPlayersFile();
|
||||||
|
ensureTimestampFile();
|
||||||
plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName()
|
plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName()
|
||||||
+ " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条"
|
+ " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条"
|
||||||
+ " 起点=" + (cut0 > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut0)) : "不限")
|
+ " 起点=" + (cut0 > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut0)) : "不限")
|
||||||
@@ -497,6 +498,10 @@ public class BanBroadcastManager implements Listener {
|
|||||||
if (!startCutoffAuto) return startCutoffMs;
|
if (!startCutoffAuto) return startCutoffMs;
|
||||||
if (autoCutoffMs < 0) {
|
if (autoCutoffMs < 0) {
|
||||||
autoCutoffMs = 0;
|
autoCutoffMs = 0;
|
||||||
|
long tsFile = readTimestampFile();
|
||||||
|
if (tsFile > 0) {
|
||||||
|
autoCutoffMs = tsFile;
|
||||||
|
} else {
|
||||||
long folderCreated = 0;
|
long folderCreated = 0;
|
||||||
try {
|
try {
|
||||||
File df = plugin.getDataFolder();
|
File df = plugin.getDataFolder();
|
||||||
@@ -526,8 +531,62 @@ public class BanBroadcastManager implements Listener {
|
|||||||
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
|
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return autoCutoffMs;
|
return autoCutoffMs;
|
||||||
}
|
}
|
||||||
|
/** ★ 第57批:读插件数据目录中的时间戳文件(10位数字无后缀)
|
||||||
|
* 优先级:用户配置 > 时间戳文件 > 插件数据目录创建时间
|
||||||
|
*/
|
||||||
|
private long readTimestampFile() {
|
||||||
|
try {
|
||||||
|
File df = plugin.getDataFolder();
|
||||||
|
if (df == null) return 0;
|
||||||
|
File[] files = df.listFiles();
|
||||||
|
if (files == null) return 0;
|
||||||
|
// ★ 第57批续:多份时间戳文件取最小值(用户手动放的固定值 < 插件启动创建的当前秒值)
|
||||||
|
long minTs = -1;
|
||||||
|
for (File f : files) {
|
||||||
|
String name = f.getName();
|
||||||
|
if (name.length() == 10 && name.chars().allMatch(Character::isDigit)) {
|
||||||
|
long ts = Long.parseLong(name);
|
||||||
|
if (ts > 0 && (minTs < 0 || ts < minTs)) minTs = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minTs > 0) return minTs * 1000L;
|
||||||
|
} catch (Exception e) {
|
||||||
|
warn("读时间戳文件失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ★ 第57批:插件启动时创建时间戳文件(如果不存在)
|
||||||
|
* 文件名 = 当前时间戳(秒),内容为空
|
||||||
|
*/
|
||||||
|
private void ensureTimestampFile() {
|
||||||
|
try {
|
||||||
|
File df = plugin.getDataFolder();
|
||||||
|
if (df == null || !df.exists()) return;
|
||||||
|
// ★ 第57批续:目录里已有任意时间戳文件就跳过,不重复创建
|
||||||
|
File[] files = df.listFiles();
|
||||||
|
if (files != null) {
|
||||||
|
for (File f : files) {
|
||||||
|
String name = f.getName();
|
||||||
|
if (name.length() == 10 && name.chars().allMatch(Character::isDigit)) {
|
||||||
|
return; // 已有时间戳文件,不创建
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long tsSec = System.currentTimeMillis() / 1000L;
|
||||||
|
File tsFile = new File(df, String.valueOf(tsSec));
|
||||||
|
if (!tsFile.exists()) {
|
||||||
|
java.nio.file.Files.createFile(tsFile.toPath());
|
||||||
|
plugin.getLogger().info("[封禁播报] 已创建时间戳文件: " + tsFile.getName());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
warn("创建时间戳文件失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 28批:自动播报起点最多回溯 180 天(约半年)——数据目录重建时起点不得过新 */
|
/** 28批:自动播报起点最多回溯 180 天(约半年)——数据目录重建时起点不得过新 */
|
||||||
private static final long MAX_AUTO_CUTOFF_WINDOW_MS = 180L * 24 * 3600 * 1000;
|
private static final long MAX_AUTO_CUTOFF_WINDOW_MS = 180L * 24 * 3600 * 1000;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,10 @@ public class DatabaseManager {
|
|||||||
"TEXT DEFAULT ''");
|
"TEXT DEFAULT ''");
|
||||||
safeAdd(st, "twofa_pending_at",
|
safeAdd(st, "twofa_pending_at",
|
||||||
"INTEGER DEFAULT 0");
|
"INTEGER DEFAULT 0");
|
||||||
|
// ★ 第47批:绑定时间(秒)——与网页端 web_account_2fa.bound_at 对齐,
|
||||||
|
// 双向同步冲突时"取时间新者"用
|
||||||
|
safeAdd(st, "twofa_bound_at",
|
||||||
|
"INTEGER DEFAULT 0");
|
||||||
// ===== 2FA 恢复代码(SHA-256 逗号连接存库,明文只发邮件,一次性有效)=====
|
// ===== 2FA 恢复代码(SHA-256 逗号连接存库,明文只发邮件,一次性有效)=====
|
||||||
safeAdd(st, "twofa_recovery_codes",
|
safeAdd(st, "twofa_recovery_codes",
|
||||||
"TEXT DEFAULT ''");
|
"TEXT DEFAULT ''");
|
||||||
@@ -146,6 +150,20 @@ public class DatabaseManager {
|
|||||||
} catch (SQLException ignored) {
|
} catch (SQLException ignored) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 第52批:存量脏凭证一次性清洗——PHP json_encode 把 base64 中的
|
||||||
|
// / 转义成 \/,历史解析层漏还原曾把反斜杠写进库(hash 脏 → 密码恒错)。
|
||||||
|
// base64 合法字符永不含反斜杠(char92),删除即还原正确值;WHERE 带
|
||||||
|
// instr 条件,无脏值时为近似 no-op,可每次启动安全重跑。
|
||||||
|
try {
|
||||||
|
st.executeUpdate("UPDATE users SET password_hash=REPLACE(password_hash, char(92), '') "
|
||||||
|
+ "WHERE instr(password_hash, char(92))>0");
|
||||||
|
st.executeUpdate("UPDATE users SET password_salt=REPLACE(password_salt, char(92), '') "
|
||||||
|
+ "WHERE instr(password_salt, char(92))>0");
|
||||||
|
st.executeUpdate("UPDATE users SET temp_password=REPLACE(temp_password, char(92), '') "
|
||||||
|
+ "WHERE instr(temp_password, char(92))>0");
|
||||||
|
} catch (SQLException ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 背包备份表:安全迁移 =====
|
// ===== 背包备份表:安全迁移 =====
|
||||||
// 检查旧表是否存在(用 inventory_data 列判断)
|
// 检查旧表是否存在(用 inventory_data 列判断)
|
||||||
boolean hasOldTable = false;
|
boolean hasOldTable = false;
|
||||||
@@ -744,8 +762,22 @@ public class DatabaseManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 第52批:base64 凭证白名单清洗。
|
||||||
|
* PHP json_encode 默认把 base64 中的 / 转义成 \/,历史解析层漏还原
|
||||||
|
* 会把反斜杠写进库(hash 脏值 → 游戏/网页两端密码恒错)。
|
||||||
|
* base64 合法字符永不含反斜杠,先还原 \/ 再按白名单清洗即得正确值;
|
||||||
|
* 写库与比对两侧同款清洗,保证对称。
|
||||||
|
*/
|
||||||
|
public static String b64Clean(String v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
return v.replace("\\/", "/").replaceAll("[^A-Za-z0-9+/=]", "");
|
||||||
|
}
|
||||||
|
|
||||||
public void createUser(String name, String hash,
|
public void createUser(String name, String hash,
|
||||||
String salt) {
|
String salt) {
|
||||||
|
hash = b64Clean(hash);
|
||||||
|
salt = b64Clean(salt);
|
||||||
try {
|
try {
|
||||||
PreparedStatement ps = db.prepareStatement(
|
PreparedStatement ps = db.prepareStatement(
|
||||||
"INSERT INTO users "
|
"INSERT INTO users "
|
||||||
@@ -770,6 +802,8 @@ public class DatabaseManager {
|
|||||||
* 更新玩家密码凭证(salt + hash),用于Web重新注册时同步
|
* 更新玩家密码凭证(salt + hash),用于Web重新注册时同步
|
||||||
*/
|
*/
|
||||||
public void updatePassword(String name, String hash, String salt) {
|
public void updatePassword(String name, String hash, String salt) {
|
||||||
|
hash = b64Clean(hash);
|
||||||
|
salt = b64Clean(salt);
|
||||||
try {
|
try {
|
||||||
PreparedStatement ps = db.prepareStatement(
|
PreparedStatement ps = db.prepareStatement(
|
||||||
"UPDATE users SET password_hash = ?, password_salt = ? WHERE player_name = ?");
|
"UPDATE users SET password_hash = ?, password_salt = ? WHERE player_name = ?");
|
||||||
@@ -831,7 +865,8 @@ public class DatabaseManager {
|
|||||||
rs.getString("password_hash");
|
rs.getString("password_hash");
|
||||||
rs.close();
|
rs.close();
|
||||||
ps.close();
|
ps.close();
|
||||||
return hash.equals(mainHash);
|
// ★ 第52批:比对前双侧清洗(存量脏 hash 容错,见 b64Clean)
|
||||||
|
return b64Clean(hash).equals(b64Clean(mainHash));
|
||||||
} else {
|
} else {
|
||||||
rs.close();
|
rs.close();
|
||||||
ps.close();
|
ps.close();
|
||||||
@@ -870,7 +905,7 @@ public class DatabaseManager {
|
|||||||
ps.close();
|
ps.close();
|
||||||
if (tempHash != null
|
if (tempHash != null
|
||||||
&& !tempHash.isEmpty()
|
&& !tempHash.isEmpty()
|
||||||
&& mainHash.equals(tempHash)
|
&& b64Clean(mainHash).equals(b64Clean(tempHash))
|
||||||
&& System.currentTimeMillis() < expire
|
&& System.currentTimeMillis() < expire
|
||||||
&& used != 1) {
|
&& used != 1) {
|
||||||
return "temp";
|
return "temp";
|
||||||
@@ -919,7 +954,7 @@ public class DatabaseManager {
|
|||||||
ps.close();
|
ps.close();
|
||||||
if (tempHash != null
|
if (tempHash != null
|
||||||
&& !tempHash.isEmpty()
|
&& !tempHash.isEmpty()
|
||||||
&& hash.equals(tempHash)
|
&& b64Clean(hash).equals(b64Clean(tempHash))
|
||||||
&& used != 1) {
|
&& used != 1) {
|
||||||
return "temp";
|
return "temp";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,12 @@ public class GUIManager implements Listener {
|
|||||||
? "§e点击打开GUI"
|
? "§e点击打开GUI"
|
||||||
: "§e点击打开CLI"));
|
: "§e点击打开CLI"));
|
||||||
|
|
||||||
|
// ★ 信誉积分 - 40号位(领地系统旁边)
|
||||||
|
g.setItem(40, mkItem(Material.GOLD_INGOT,
|
||||||
|
"§d§l信誉积分",
|
||||||
|
"§7上报好人好事/举报违规",
|
||||||
|
"§7查看我的积分与明细"));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===== Row0(0-8): 自定义菜单项 =====
|
// ===== Row0(0-8): 自定义菜单项 =====
|
||||||
|
|||||||
@@ -646,6 +646,12 @@ public class GarbageManager implements Listener {
|
|||||||
// r == n 即「本轮结束时倾倒垃圾」(下一轮才真正倒)。
|
// r == n 即「本轮结束时倾倒垃圾」(下一轮才真正倒)。
|
||||||
final int nextRound = roundNow + 1; // 清理后开启的新一轮
|
final int nextRound = roundNow + 1; // 清理后开启的新一轮
|
||||||
final int r = ((nextRound - 1) % n) + 1;
|
final int r = ((nextRound - 1) % n) + 1;
|
||||||
|
// ★ 第45批:保留只有1轮(n==1)时
|
||||||
|
// 每一轮都是倒垃圾轮,「本轮结束时
|
||||||
|
// 倾倒垃圾」无增量信息 → 不播报;
|
||||||
|
// 仅保留>1才提示轮次进度(用户
|
||||||
|
// 2026-10-22 截图反馈)。
|
||||||
|
if (n > 1) {
|
||||||
if (r < n) {
|
if (r < n) {
|
||||||
broadcast("§7[垃圾站] §f当前是第 §e" + r
|
broadcast("§7[垃圾站] §f当前是第 §e" + r
|
||||||
+ " §f轮,第 §e" + n
|
+ " §f轮,第 §e" + n
|
||||||
@@ -655,6 +661,7 @@ public class GarbageManager implements Listener {
|
|||||||
broadcast("§7[垃圾站] §f当前是第 §e" + n
|
broadcast("§7[垃圾站] §f当前是第 §e" + n
|
||||||
+ " §f轮,本轮结束时倾倒垃圾");
|
+ " §f轮,本轮结束时倾倒垃圾");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 打印下次清理时间
|
// 打印下次清理时间
|
||||||
long nextMs = System.currentTimeMillis()
|
long nextMs = System.currentTimeMillis()
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ public class LoginManager {
|
|||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ★第48批#244:/login 失败自动对账重试的每人限流(值=上次对账触发时刻) */
|
||||||
|
private final java.util.Map<String, Long> pwSyncRetryAt =
|
||||||
|
new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
/** ★第48批#244:正在对账重试中的玩家(防递归) */
|
||||||
|
private final java.util.Set<String> pwSyncRetrying =
|
||||||
|
java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public boolean handleRegister(Player p,
|
public boolean handleRegister(Player p,
|
||||||
String[] args) {
|
String[] args) {
|
||||||
String name = p.getName();
|
String name = p.getName();
|
||||||
@@ -69,6 +76,10 @@ public class LoginManager {
|
|||||||
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
||||||
if (plugin.webManager != null) {
|
if (plugin.webManager != null) {
|
||||||
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
||||||
|
// ★ 第57批:/login 成功后推送 login_verified=1 给 PHP
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
plugin.webManager.pushLoginVerified(name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
p.setAllowFlight(false);
|
p.setAllowFlight(false);
|
||||||
p.setFlying(false);
|
p.setFlying(false);
|
||||||
@@ -191,6 +202,8 @@ public class LoginManager {
|
|||||||
if (matchMain) {
|
if (matchMain) {
|
||||||
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
||||||
if (markDualStep1(p, false)) return true;
|
if (markDualStep1(p, false)) return true;
|
||||||
|
// ★第48批#249:已绑定2FA → 密码过后强制第二关 /2fa code
|
||||||
|
if (requireTwoFaIfEnabled(p, false)) return true;
|
||||||
finishPasswordLogin(p, false);
|
finishPasswordLogin(p, false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -202,11 +215,17 @@ public class LoginManager {
|
|||||||
if (pwdResult != null) {
|
if (pwdResult != null) {
|
||||||
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
||||||
if (markDualStep1(p, true)) return true;
|
if (markDualStep1(p, true)) return true;
|
||||||
|
// ★第48批#249:已绑定2FA → 密码过后强制第二关 /2fa code
|
||||||
|
if (requireTwoFaIfEnabled(p, true)) return true;
|
||||||
finishPasswordLogin(p, true);
|
finishPasswordLogin(p, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ★第54批:密码不匹配 → 先同步账号数据(拉回PHP侧凭证入库为主密码),
|
||||||
|
// 15秒/人限流;同步完成后主线程自动重校验(以Java本地login.db为准),仍未过才计失败次数
|
||||||
|
if (maybeRetryAfterWebSync(p, pwd)) return true;
|
||||||
|
|
||||||
// ★ 密码错误,记录风控
|
// ★ 密码错误,记录风控
|
||||||
int remaining = plugin.getRiskControl()
|
int remaining = plugin.getRiskControl()
|
||||||
.recordFailAttempt(p);
|
.recordFailAttempt(p);
|
||||||
@@ -251,6 +270,67 @@ public class LoginManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★第48批#249:已绑定 2FA(twofa_enabled=1)的玩家,密码通过后不直接放行,
|
||||||
|
* 转入「密码 + 2FA」双验证第二步(等 /2fa code)。
|
||||||
|
* 仅在该玩家当前不在双验证流程时生效(IP风控已 mark 的走 markDualStep1)。
|
||||||
|
*
|
||||||
|
* @return true = 已进入双验证流程,本轮 /login 到此为止
|
||||||
|
*/
|
||||||
|
private boolean requireTwoFaIfEnabled(Player p, boolean tempPassword) {
|
||||||
|
TwoFactorManager twofa = plugin.getTwofa();
|
||||||
|
String name = p.getName();
|
||||||
|
if (twofa == null) return false;
|
||||||
|
if (twofa.isDualVerify(p)) return false; // IP风控路径已处理
|
||||||
|
if (!plugin.getDb().userExists(name)) return false;
|
||||||
|
if (!twofa.isEnabled(name)) return false; // 未绑定2FA:维持原单步登录
|
||||||
|
// 密码这一步确实对了 → 建立双验证状态并记录进度(不解冻、不恢复背包)
|
||||||
|
twofa.markDualVerify(p, plugin.getPlayerIP(p));
|
||||||
|
twofa.onDualPasswordPassed(p, tempPassword);
|
||||||
|
// 重置「密码错误次数」风控(密码对了,但仍未完成登录)
|
||||||
|
plugin.getRiskControl().onLoginSuccess(name);
|
||||||
|
p.sendMessage("§a§l[双重验证] §a第一步密码验证通过(尚未解冻)");
|
||||||
|
p.sendMessage("§7第二步: 执行 §f/2fa code <动态码|恢复代码>");
|
||||||
|
p.sendMessage("§c两步全部通过后才会解冻并恢复背包");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★第54批:密码校验失败时先同步账号数据(拉回PHP侧凭证入库)再自动重试一次。
|
||||||
|
* 15秒/人限流;重试仍失败走正常风控计数。
|
||||||
|
*
|
||||||
|
* @return true = 本轮 /login 已交给异步对账流程处理(调用方直接 return)
|
||||||
|
*/
|
||||||
|
private boolean maybeRetryAfterWebSync(Player p, String pwd) {
|
||||||
|
if (plugin.webManager == null) return false;
|
||||||
|
String name = p.getName();
|
||||||
|
if (pwSyncRetrying.contains(name)) return false; // 重试轮失败:不再触发
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Long last = pwSyncRetryAt.get(name);
|
||||||
|
if (last != null && now - last < 15000L) return false; // 15秒/人限流
|
||||||
|
pwSyncRetryAt.put(name, now);
|
||||||
|
pwSyncRetrying.add(name);
|
||||||
|
p.sendMessage("§e§l[登录] §e正在同步账号数据…");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
plugin.webManager.pushWebLoginCredentials(); // 含 alignGate 拉回
|
||||||
|
} catch (Throwable t) {
|
||||||
|
plugin.getLogger().warning("[登录] 对账重试同步异常: " + t.getMessage());
|
||||||
|
}
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
try {
|
||||||
|
Player online = Bukkit.getPlayerExact(name);
|
||||||
|
if (online == null || !online.isOnline()) return;
|
||||||
|
// 主线程重新走一次完整登录校验(含2FA/风控前置)
|
||||||
|
handleLogin(online, new String[]{pwd});
|
||||||
|
} finally {
|
||||||
|
pwSyncRetrying.remove(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 密码登录成功的统一收口:解冻 + 恢复背包 + 记录登录信息。
|
* 密码登录成功的统一收口:解冻 + 恢复背包 + 记录登录信息。
|
||||||
* 调用点:/login 主密码、/login 临时密码、IP风控双验证第二步通过后。
|
* 调用点:/login 主密码、/login 临时密码、IP风控双验证第二步通过后。
|
||||||
@@ -263,6 +343,10 @@ public class LoginManager {
|
|||||||
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
||||||
if (plugin.webManager != null) {
|
if (plugin.webManager != null) {
|
||||||
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
||||||
|
// ★ 第57批续3:/login 成功后推送 login_verified=1 给 PHP(统一收口)
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
plugin.webManager.pushLoginVerified(name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
plugin.getDb().setLoggedIn(name, true);
|
plugin.getDb().setLoggedIn(name, true);
|
||||||
if (!tempPassword) {
|
if (!tempPassword) {
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ public class Main extends JavaPlugin
|
|||||||
private CheckInManager checkIn;
|
private CheckInManager checkIn;
|
||||||
private InviteManager invite;
|
private InviteManager invite;
|
||||||
private PointsManager points;
|
private PointsManager points;
|
||||||
|
private CreditManager creditManager;
|
||||||
|
private AIChatManager aiChatManager;
|
||||||
public GUIManager gui;
|
public GUIManager gui;
|
||||||
private ChatInputManager chatInput;
|
private ChatInputManager chatInput;
|
||||||
private VerificationManager verification;
|
private VerificationManager verification;
|
||||||
@@ -141,6 +143,9 @@ public class Main extends JavaPlugin
|
|||||||
private final Map<String, Long> ipAutoLogin =
|
private final Map<String, Long> ipAutoLogin =
|
||||||
new HashMap<>();
|
new HashMap<>();
|
||||||
private static final long IP_SKIP_MS = 300000L;
|
private static final long IP_SKIP_MS = 300000L;
|
||||||
|
// ★ 第56批:/webbind 批准密码验证失败计数(玩家名 -> 失败次数)
|
||||||
|
// ★ 第57批续5: /webbind approve 改聊天框输入密码(玩家名 -> 请求ID)
|
||||||
|
private final ConcurrentHashMap<String, String> pendingWebbindApprove = new ConcurrentHashMap<>();
|
||||||
private SalesStatsManager salesStats;
|
private SalesStatsManager salesStats;
|
||||||
|
|
||||||
private final Map<UUID, Long> lastActivity =
|
private final Map<UUID, Long> lastActivity =
|
||||||
@@ -316,6 +321,10 @@ public class Main extends JavaPlugin
|
|||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CreditManager getCreditManager() {
|
||||||
|
return creditManager;
|
||||||
|
}
|
||||||
|
|
||||||
public GUIManager getGui() {
|
public GUIManager getGui() {
|
||||||
return gui;
|
return gui;
|
||||||
}
|
}
|
||||||
@@ -479,6 +488,9 @@ public class Main extends JavaPlugin
|
|||||||
checkIn = new CheckInManager(this);
|
checkIn = new CheckInManager(this);
|
||||||
invite = new InviteManager(this);
|
invite = new InviteManager(this);
|
||||||
points = new PointsManager(this);
|
points = new PointsManager(this);
|
||||||
|
creditManager = new CreditManager(this);
|
||||||
|
aiChatManager = new AIChatManager(this);
|
||||||
|
aiChatManager.onEnable();
|
||||||
chatInput = new ChatInputManager();
|
chatInput = new ChatInputManager();
|
||||||
verification = new VerificationManager(this);
|
verification = new VerificationManager(this);
|
||||||
riskControl = new RiskControlManager(this);
|
riskControl = new RiskControlManager(this);
|
||||||
@@ -1050,6 +1062,7 @@ public class Main extends JavaPlugin
|
|||||||
}
|
}
|
||||||
if (db != null) db.close();
|
if (db != null) db.close();
|
||||||
if (garbage != null) garbage.close();
|
if (garbage != null) garbage.close();
|
||||||
|
if (aiChatManager != null) aiChatManager.onDisable();
|
||||||
if (chatTransPool != null) {
|
if (chatTransPool != null) {
|
||||||
chatTransPool.shutdownNow();
|
chatTransPool.shutdownNow();
|
||||||
}
|
}
|
||||||
@@ -1550,7 +1563,7 @@ public class Main extends JavaPlugin
|
|||||||
emailBindTasks.put(uid, holder);
|
emailBindTasks.put(uid, holder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isAdmin(CommandSender sender) {
|
public boolean isAdmin(CommandSender sender) {
|
||||||
if (sender instanceof Player)
|
if (sender instanceof Player)
|
||||||
return ((Player) sender)
|
return ((Player) sender)
|
||||||
.getScoreboardTags()
|
.getScoreboardTags()
|
||||||
@@ -2983,6 +2996,25 @@ public class Main extends JavaPlugin
|
|||||||
Player p = e.getPlayer();
|
Player p = e.getPlayer();
|
||||||
String name = p.getName();
|
String name = p.getName();
|
||||||
String ip = getPlayerIP(p);
|
String ip = getPlayerIP(p);
|
||||||
|
// ★ 第57批:插件管理员登录 → CLI 通知待确认的封禁缺口
|
||||||
|
if (webManager != null && webManager.hasBanPending() && isAdmin(p)) {
|
||||||
|
p.sendMessage(net.kyori.adventure.text.Component.text()
|
||||||
|
.append(net.kyori.adventure.text.Component.text("§c§l[封禁-缺口] §f检测到 "))
|
||||||
|
.append(net.kyori.adventure.text.Component.text(String.valueOf(webManager.getBanPendingCount()))
|
||||||
|
.color(net.kyori.adventure.text.format.NamedTextColor.RED))
|
||||||
|
.append(net.kyori.adventure.text.Component.text(" 条封禁数据缺口待确认"))
|
||||||
|
.build());
|
||||||
|
p.sendMessage(net.kyori.adventure.text.Component.text()
|
||||||
|
.append(net.kyori.adventure.text.Component.text("§a[点击拉回封禁] §7").clickEvent(
|
||||||
|
net.kyori.adventure.text.event.ClickEvent.runCommand("/sdf1_login confirm ban")))
|
||||||
|
.append(net.kyori.adventure.text.Component.text("§7= /sdf1_login confirm ban"))
|
||||||
|
.build());
|
||||||
|
p.sendMessage(net.kyori.adventure.text.Component.text()
|
||||||
|
.append(net.kyori.adventure.text.Component.text("§c[点击确认解封] §7").clickEvent(
|
||||||
|
net.kyori.adventure.text.event.ClickEvent.runCommand("/sdf1_login cancel ban")))
|
||||||
|
.append(net.kyori.adventure.text.Component.text("§7= /sdf1_login cancel ban"))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 公告计分板(第35批)=====
|
// ===== 公告计分板(第35批)=====
|
||||||
// 延后 1 秒:避免与登录/权限流程抢计分板
|
// 延后 1 秒:避免与登录/权限流程抢计分板
|
||||||
@@ -3181,6 +3213,10 @@ public class Main extends JavaPlugin
|
|||||||
if (!db.userExists(name)) {
|
if (!db.userExists(name)) {
|
||||||
webManager.syncUserOnJoin(name);
|
webManager.syncUserOnJoin(name);
|
||||||
}
|
}
|
||||||
|
// 第47批:进服静默同步网页端 2FA 绑定状态(登录风控 isEnabled 读到同步后结果)
|
||||||
|
if (twofa != null) {
|
||||||
|
twofa.syncOnJoin(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ★ 同IP判断:只作为开启自动登录验证的钥匙,不作为自动登录依据
|
// ★ 同IP判断:只作为开启自动登录验证的钥匙,不作为自动登录依据
|
||||||
@@ -6154,6 +6190,18 @@ public class Main extends JavaPlugin
|
|||||||
chatFilter.markPlayerActive(p.getName());
|
chatFilter.markPlayerActive(p.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 第58批:AI聊天介入 — @//模型名/ 前缀触发直接提问
|
||||||
|
if (aiChatManager != null) {
|
||||||
|
aiChatManager.handlePlayerChat(p, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 第58批:信誉积分聊天引导 — 上报/加分/扣分分步输入
|
||||||
|
if (creditManager != null && creditManager.isInChatFlow(p)) {
|
||||||
|
e.setCancelled(true);
|
||||||
|
creditManager.handleChatInput(p, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ★ 先探一次禁言状态:若刚好到期,isMuted() 内部会立即解禁并打上
|
// ★ 先探一次禁言状态:若刚好到期,isMuted() 内部会立即解禁并打上
|
||||||
// 「需重新人机验证」标记(2026-10-08:解禁≠解除人机验证)
|
// 「需重新人机验证」标记(2026-10-08:解禁≠解除人机验证)
|
||||||
if (chatFilter != null) {
|
if (chatFilter != null) {
|
||||||
@@ -6231,6 +6279,57 @@ public class Main extends JavaPlugin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 第57批续5: /webbind approve 密码输入拦截
|
||||||
|
String pendingBindReq = pendingWebbindApprove.get(p.getName());
|
||||||
|
if (pendingBindReq != null) {
|
||||||
|
e.setCancelled(true);
|
||||||
|
String input = msg.trim();
|
||||||
|
// 拒绝选项
|
||||||
|
if (input.equalsIgnoreCase("拒绝") || input.equalsIgnoreCase("reject")) {
|
||||||
|
pendingWebbindApprove.remove(p.getName());
|
||||||
|
p.sendMessage("§e已拒绝,网页端直接返回");
|
||||||
|
webManager.submitWebEmailBindResult(pendingBindReq, p.getName(),
|
||||||
|
"reject", "", status -> {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ★ 第57批续10:接入统一风控(RiskControlManager),与 /login 完全一致
|
||||||
|
// 密码错5次 → Bukkit真实封禁5分钟 + 踢出 + 报警邮件(不再仅提示)
|
||||||
|
if (riskControl != null && riskControl.isBanned(p.getName())) {
|
||||||
|
int rem2 = riskControl.getBanRemainingSeconds(p.getName());
|
||||||
|
pendingWebbindApprove.remove(p.getName());
|
||||||
|
p.sendMessage("§c§l[风控] §f密码错误次数过多,账号已被临时封禁 §e"
|
||||||
|
+ rem2 + " §f秒");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String pwdSalt2 = (String) db.getField(p.getName(), "password_salt");
|
||||||
|
String pwdHash2 = PasswordUtils.hash(input, pwdSalt2);
|
||||||
|
String pwdResult2 = db.checkPasswordWithFallback(p.getName(), pwdHash2);
|
||||||
|
if (pwdResult2 == null) {
|
||||||
|
int remaining2 = riskControl != null
|
||||||
|
? riskControl.recordFailAttempt(p) : 4;
|
||||||
|
if (remaining2 < 0) {
|
||||||
|
// 已达阈值:triggerBan 已真实封禁并踢出
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p.sendMessage("§c密码错误");
|
||||||
|
if (remaining2 > 0) {
|
||||||
|
p.sendMessage("§c§l[风控] §f剩余 §e" + remaining2 + " §f次尝试机会");
|
||||||
|
} else {
|
||||||
|
p.sendMessage("§c§l[风控] §f连续5次失败,已被临时封禁");
|
||||||
|
pendingWebbindApprove.remove(p.getName());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 验证通过,重置风控计数
|
||||||
|
if (riskControl != null) riskControl.onLoginSuccess(p.getName());
|
||||||
|
String reqId2 = pendingBindReq;
|
||||||
|
pendingWebbindApprove.remove(p.getName());
|
||||||
|
p.sendMessage("§e正在提交批准…");
|
||||||
|
webManager.submitWebEmailBindResult(reqId2, p.getName(),
|
||||||
|
"approve", "", true, status -> {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ★ 2FA 解绑确认输入拦截(30秒 confirm 窗口,
|
// ★ 2FA 解绑确认输入拦截(30秒 confirm 窗口,
|
||||||
// 输入 confirm=解绑,其它任意内容/超时=取消)
|
// 输入 confirm=解绑,其它任意内容/超时=取消)
|
||||||
if (twofa != null && twofa.isRemoveConfirming(p)) {
|
if (twofa != null && twofa.isRemoveConfirming(p)) {
|
||||||
@@ -7085,6 +7184,15 @@ public class Main extends JavaPlugin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 信誉积分 GUI 点击处理 =====
|
||||||
|
if (title != null && title.contains("信誉积分")) {
|
||||||
|
if (creditManager != null) {
|
||||||
|
e.setCancelled(true);
|
||||||
|
creditManager.handleClick(p, slot, title);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 垃圾箱 =====
|
// ===== 垃圾箱 =====
|
||||||
// ===== 垃圾回收站 =====
|
// ===== 垃圾回收站 =====
|
||||||
if (title.equals("§6§l垃圾回收站")) {
|
if (title.equals("§6§l垃圾回收站")) {
|
||||||
@@ -7777,6 +7885,14 @@ public class Main extends JavaPlugin
|
|||||||
// CLI模式
|
// CLI模式
|
||||||
Bukkit.dispatchCommand(p, "protect menu");
|
Bukkit.dispatchCommand(p, "protect menu");
|
||||||
}
|
}
|
||||||
|
} else if (slot == 40) {
|
||||||
|
// ★ 信誉积分入口
|
||||||
|
p.closeInventory();
|
||||||
|
if (creditManager != null) {
|
||||||
|
p.openInventory(creditManager.createMainGUI(p));
|
||||||
|
} else {
|
||||||
|
p.sendMessage("§c信誉积分模块未初始化");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -9357,6 +9473,15 @@ public class Main extends JavaPlugin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== /credits:信誉积分管理 =====
|
||||||
|
if (cmdName.equals("credits")) {
|
||||||
|
if (creditManager == null) {
|
||||||
|
sender.sendMessage("§c信誉积分模块未初始化");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return creditManager.handleCommand(sender, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ★ 第30批:/addblack <玩家> [时长] [理由] —— 拉黑
|
// ★ 第30批:/addblack <玩家> [时长] [理由] —— 拉黑
|
||||||
if (cmdName.equals("addblack")) {
|
if (cmdName.equals("addblack")) {
|
||||||
@@ -9549,6 +9674,10 @@ public class Main extends JavaPlugin
|
|||||||
// ★ 未绑定邮箱:登录成功后提示
|
// ★ 未绑定邮箱:登录成功后提示
|
||||||
if (loggedIn.contains(p.getName())) {
|
if (loggedIn.contains(p.getName())) {
|
||||||
remindEmailBindIfNeeded(p);
|
remindEmailBindIfNeeded(p);
|
||||||
|
// ★ 第57批续6:/login成功后重新推送weblogin token(onJoin时推送的token login_verified=0,Web端不认)
|
||||||
|
if (webManager != null) {
|
||||||
|
webManager.autoGenerateWebLoginToken(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -9606,6 +9735,65 @@ public class Main extends JavaPlugin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 第53批:网页端绑邮箱 · 游戏内批准/拒绝(/webbind approve|reject <id>)
|
||||||
|
if (cmdName.equals("webbind")) {
|
||||||
|
if (!(sender instanceof Player)) {
|
||||||
|
sender.sendMessage("§c仅玩家可用");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final Player wp = (Player) sender;
|
||||||
|
if (isFrozen(wp)) {
|
||||||
|
wp.sendMessage("§c§l[登录] §f你还未登录,无法使用该命令。请先使用 §e/l <密码> §f登录");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (args.length < 2
|
||||||
|
|| (!args[0].equalsIgnoreCase("approve")
|
||||||
|
&& !args[0].equalsIgnoreCase("reject"))) {
|
||||||
|
wp.sendMessage("§c用法: /webbind approve|reject <id> [密码]");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final String bindReqId = args[1];
|
||||||
|
if (!bindReqId.matches("[0-9]+")) {
|
||||||
|
wp.sendMessage("§c无效的请求ID");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (webManager == null) {
|
||||||
|
wp.sendMessage("§cWeb通信未启用,无法处理");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final boolean bindApprove = args[0].equalsIgnoreCase("approve");
|
||||||
|
// ★ 第56批:approve 时需要密码(Java端直接验证,不再依赖PHP复核)
|
||||||
|
if (bindApprove) {
|
||||||
|
// ★ 第57批续5: 改聊天框输入密码(不再要求命令行参数密码)
|
||||||
|
// ★ 第57批续10:接入统一风控,封禁期内直接拒绝
|
||||||
|
if (riskControl != null && riskControl.isBanned(wp.getName())) {
|
||||||
|
int rem = riskControl.getBanRemainingSeconds(wp.getName());
|
||||||
|
wp.sendMessage("§c§l[风控] §f密码错误次数过多,账号已被临时封禁 §e"
|
||||||
|
+ rem + " §f秒,请稍后再试");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pendingWebbindApprove.put(wp.getName(), bindReqId);
|
||||||
|
wp.sendMessage("§e请在聊天框输入密码完成验证");
|
||||||
|
wp.sendMessage("§7还是输入「拒绝」拒绝本次绑定");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// reject 不需要密码
|
||||||
|
wp.sendMessage("§e正在提交拒绝…");
|
||||||
|
webManager.submitWebEmailBindResult(bindReqId, wp.getName(),
|
||||||
|
"reject", "", status -> {
|
||||||
|
if (status == null) {
|
||||||
|
wp.sendMessage("§c提交失败,请稍后重试");
|
||||||
|
} else if ("rejected".equals(status)) {
|
||||||
|
wp.sendMessage("§e已拒绝,网页端将直接返回,不做任何处置");
|
||||||
|
} else if ("expired".equals(status)) {
|
||||||
|
wp.sendMessage("§c请求已超时,未做任何处置");
|
||||||
|
} else {
|
||||||
|
wp.sendMessage("§7请求当前状态: §f" + status + " §7(重复操作已忽略)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 绑定邮箱
|
// 绑定邮箱
|
||||||
if (cmdName.equals("绑定邮箱")) {
|
if (cmdName.equals("绑定邮箱")) {
|
||||||
// ★ 3.0 安全修复:邮箱绑定必须登录后才能执行(未登录越权绑邮箱 + 任意收件人 SMTP 滥发)
|
// ★ 3.0 安全修复:邮箱绑定必须登录后才能执行(未登录越权绑邮箱 + 任意收件人 SMTP 滥发)
|
||||||
@@ -9852,6 +10040,13 @@ public class Main extends JavaPlugin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
String target = args[1];
|
String target = args[1];
|
||||||
|
// ★ 第57批续3:/del ban 拦截(ban 不作为待删除玩家账号的名字)
|
||||||
|
if (target.equalsIgnoreCase("ban")) {
|
||||||
|
sender.sendMessage("§cban 是封禁缺口确认命令,不是玩家账号名");
|
||||||
|
sender.sendMessage("§7确认拉回封禁:§a/sdf1_login confirm ban");
|
||||||
|
sender.sendMessage("§7确认解封:§a/sdf1_login cancel ban");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!db.userExists(target)) {
|
if (!db.userExists(target)) {
|
||||||
String twin = db.findSimilarUserNameIgnoreCase(target);
|
String twin = db.findSimilarUserNameIgnoreCase(target);
|
||||||
sender.sendMessage(
|
sender.sendMessage(
|
||||||
@@ -9910,6 +10105,23 @@ public class Main extends JavaPlugin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 第57批:confirm ban 确认封禁缺口拉回
|
||||||
|
if (sub.equals("confirm") && args.length >= 2 && args[1].equalsIgnoreCase("ban")) {
|
||||||
|
if (!isAdmin(sender)) { sender.sendMessage("\u00a7c权限不足"); return true; }
|
||||||
|
if (webManager == null) { sender.sendMessage("\u00a7cWeb通信未初始化"); return true; }
|
||||||
|
if (!webManager.hasBanPending()) { sender.sendMessage("§c当前没有待确认的封禁缺口"); return true; }
|
||||||
|
webManager.confirmBanGap(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// ★ 第57批:cancel ban 确认解封(清空PHP封禁数据,不写回banned-players.json)
|
||||||
|
if (sub.equals("cancel") && args.length >= 2 && args[1].equalsIgnoreCase("ban")) {
|
||||||
|
if (!isAdmin(sender)) { sender.sendMessage("\u00a7c权限不足"); return true; }
|
||||||
|
if (webManager == null) { sender.sendMessage("\u00a7cWeb通信未初始化"); return true; }
|
||||||
|
if (!webManager.hasBanPending()) { sender.sendMessage("§c当前没有待确认的封禁缺口"); return true; }
|
||||||
|
webManager.cancelBanGap(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (sub.equals("confirm")) {
|
if (sub.equals("confirm")) {
|
||||||
if (!isAdmin(sender)) {
|
if (!isAdmin(sender)) {
|
||||||
sender.sendMessage("§c权限不足");
|
sender.sendMessage("§c权限不足");
|
||||||
|
|||||||
@@ -550,8 +550,8 @@ public class PVPArenaManager implements Listener {
|
|||||||
w.setPVP(true);
|
w.setPVP(true);
|
||||||
// ★ 和平模式:排除敌对生物干扰PVP
|
// ★ 和平模式:排除敌对生物干扰PVP
|
||||||
w.setDifficulty(Difficulty.PEACEFUL);
|
w.setDifficulty(Difficulty.PEACEFUL);
|
||||||
w.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
|
w.setGameRule(GameRules.ADVANCE_TIME, false);
|
||||||
w.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
|
w.setGameRule(GameRules.ADVANCE_WEATHER, false);
|
||||||
w.setTime(6000);
|
w.setTime(6000);
|
||||||
// ★ 不常驻出生点区块(与 WorldCreator.keepSpawnInMemory(false) 一致),避免同步加载区块卡主线程
|
// ★ 不常驻出生点区块(与 WorldCreator.keepSpawnInMemory(false) 一致),避免同步加载区块卡主线程
|
||||||
w.setKeepSpawnInMemory(false);
|
w.setKeepSpawnInMemory(false);
|
||||||
|
|||||||
@@ -182,10 +182,10 @@ public class PVPTestManager implements Listener {
|
|||||||
// 世界规则:常昼 / 无天气 / 关闭自然刷怪(只有我们生成的练习生物)/ PVP 开启
|
// 世界规则:常昼 / 无天气 / 关闭自然刷怪(只有我们生成的练习生物)/ PVP 开启
|
||||||
// ★ KEEP_INVENTORY=false:死亡掉落由事件处理器清空掉落物+复活时还原,避免gamerule与事件冲突
|
// ★ KEEP_INVENTORY=false:死亡掉落由事件处理器清空掉落物+复活时还原,避免gamerule与事件冲突
|
||||||
w.setPVP(true);
|
w.setPVP(true);
|
||||||
w.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
|
w.setGameRule(GameRules.ADVANCE_TIME, false);
|
||||||
w.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
|
w.setGameRule(GameRules.ADVANCE_WEATHER, false);
|
||||||
w.setGameRule(GameRule.DO_MOB_SPAWNING, false);
|
w.setGameRule(GameRules.SPAWN_MONSTERS, false);
|
||||||
w.setGameRule(GameRule.KEEP_INVENTORY, false);
|
w.setGameRule(GameRules.KEEP_INVENTORY, false);
|
||||||
w.setTime(6000);
|
w.setTime(6000);
|
||||||
w.setKeepSpawnInMemory(false);
|
w.setKeepSpawnInMemory(false);
|
||||||
|
|
||||||
@@ -895,7 +895,7 @@ public class PVPTestManager implements Listener {
|
|||||||
// 抵消皮革帽自带的1点护甲,使其不增加玩家对练习生物的伤害减免(纯用于挡太阳)
|
// 抵消皮革帽自带的1点护甲,使其不增加玩家对练习生物的伤害减免(纯用于挡太阳)
|
||||||
hm.addAttributeModifier(Attribute.ARMOR,
|
hm.addAttributeModifier(Attribute.ARMOR,
|
||||||
new AttributeModifier(
|
new AttributeModifier(
|
||||||
"pvp_test_no_armor", -1.0,
|
new NamespacedKey(plugin, "pvp_test_no_armor"), -1.0,
|
||||||
AttributeModifier.Operation.ADD_NUMBER));
|
AttributeModifier.Operation.ADD_NUMBER));
|
||||||
hat.setItemMeta(hm);
|
hat.setItemMeta(hm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,23 +88,18 @@ public class RiskControlManager {
|
|||||||
public int recordFailAttempt(Player player) {
|
public int recordFailAttempt(Player player) {
|
||||||
String name = player.getName();
|
String name = player.getName();
|
||||||
int maxAttempts = getMaxFailAttempts();
|
int maxAttempts = getMaxFailAttempts();
|
||||||
|
|
||||||
// 已被封禁,直接返回
|
// 已被封禁,直接返回
|
||||||
if (isBanned(name)) {
|
if (isBanned(name)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
int current = failAttempts
|
int current = failAttempts
|
||||||
.getOrDefault(name, 0) + 1;
|
.getOrDefault(name, 0) + 1;
|
||||||
failAttempts.put(name, current);
|
failAttempts.put(name, current);
|
||||||
|
|
||||||
int remaining = Math.max(0, maxAttempts - current);
|
int remaining = Math.max(0, maxAttempts - current);
|
||||||
|
|
||||||
// 达到阈值 → 触发封禁
|
// 达到阈值 → 触发封禁
|
||||||
if (current >= maxAttempts) {
|
if (current >= maxAttempts) {
|
||||||
triggerBan(player);
|
triggerBan(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
return remaining;
|
return remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,16 +112,13 @@ public class RiskControlManager {
|
|||||||
long durationMs = getBanDurationMs();
|
long durationMs = getBanDurationMs();
|
||||||
long until = System.currentTimeMillis() + durationMs;
|
long until = System.currentTimeMillis() + durationMs;
|
||||||
banUntil.put(name, until);
|
banUntil.put(name, until);
|
||||||
|
|
||||||
String ip = plugin.getPlayerIP(player);
|
String ip = plugin.getPlayerIP(player);
|
||||||
int banMinutes = (int) (durationMs / 60000);
|
int banMinutes = (int) (durationMs / 60000);
|
||||||
|
|
||||||
plugin.getLogger().warning(
|
plugin.getLogger().warning(
|
||||||
"[风控] 玩家 " + name
|
"[风控] 玩家 " + name
|
||||||
+ " 密码错误达到阈值("
|
+ " 密码错误达到阈值("
|
||||||
+ getMaxFailAttempts() + "次)"
|
+ getMaxFailAttempts() + "次)"
|
||||||
+ ",封禁 " + banMinutes + " 分钟");
|
+ ",封禁 " + banMinutes + " 分钟");
|
||||||
|
|
||||||
// ★ 真实封禁:调用Bukkit BanList API阻止玩家重新加入服务器
|
// ★ 真实封禁:调用Bukkit BanList API阻止玩家重新加入服务器
|
||||||
String banReason = "§c§l[安全风控] 密码连续错误"
|
String banReason = "§c§l[安全风控] 密码连续错误"
|
||||||
+ getMaxFailAttempts() + "次,"
|
+ getMaxFailAttempts() + "次,"
|
||||||
@@ -145,7 +137,6 @@ public class RiskControlManager {
|
|||||||
}
|
}
|
||||||
// 踢出玩家(无论BanList是否成功,立即踢出)
|
// 踢出玩家(无论BanList是否成功,立即踢出)
|
||||||
player.kickPlayer(banReason);
|
player.kickPlayer(banReason);
|
||||||
|
|
||||||
// 异步发送报警邮件
|
// 异步发送报警邮件
|
||||||
sendBanAlertEmail(name, ip, banMinutes);
|
sendBanAlertEmail(name, ip, banMinutes);
|
||||||
}
|
}
|
||||||
@@ -164,7 +155,6 @@ public class RiskControlManager {
|
|||||||
+ " 未绑定邮箱,跳过报警邮件");
|
+ " 未绑定邮箱,跳过报警邮件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String subject =
|
String subject =
|
||||||
"[安全警告] 您的账号已被临时封禁";
|
"[安全警告] 您的账号已被临时封禁";
|
||||||
String body = "尊敬的玩家 " + playerName + ":\n\n"
|
String body = "尊敬的玩家 " + playerName + ":\n\n"
|
||||||
@@ -187,7 +177,6 @@ public class RiskControlManager {
|
|||||||
+ "如果非本人操作,建议您立即修改密码"
|
+ "如果非本人操作,建议您立即修改密码"
|
||||||
+ "(使用 /sdf1_login pw)。\n\n"
|
+ "(使用 /sdf1_login pw)。\n\n"
|
||||||
+ "--Sdf1_login 安全系统";
|
+ "--Sdf1_login 安全系统";
|
||||||
|
|
||||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
try {
|
try {
|
||||||
plugin.getEmail().sendBody(
|
plugin.getEmail().sendBody(
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ public class TwoFactorManager {
|
|||||||
|
|
||||||
/** /2fa 命令分发,恒返回 true */
|
/** /2fa 命令分发,恒返回 true */
|
||||||
public boolean handleCommand(Player p, String[] args) {
|
public boolean handleCommand(Player p, String[] args) {
|
||||||
|
// 第47批:先异步同步网页端绑定状态再分发(网页已绑定 → 游戏内立即显示/生效,
|
||||||
|
// add 也不会对着网页绑定重复申请);网络失败静默按本地状态走
|
||||||
|
syncWithWebThen(p, pl -> dispatchSub(pl, args));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchSub(Player p, String[] args) {
|
||||||
String sub = args.length >= 1 ? args[0].toLowerCase() : "";
|
String sub = args.length >= 1 ? args[0].toLowerCase() : "";
|
||||||
switch (sub) {
|
switch (sub) {
|
||||||
case "add":
|
case "add":
|
||||||
@@ -113,7 +120,6 @@ public class TwoFactorManager {
|
|||||||
sendHelp(p);
|
sendHelp(p);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendHelp(Player p) {
|
public void sendHelp(Player p) {
|
||||||
@@ -271,6 +277,10 @@ public class TwoFactorManager {
|
|||||||
plugin.getDb().setField(name, "twofa_enabled", 1);
|
plugin.getDb().setField(name, "twofa_enabled", 1);
|
||||||
plugin.getDb().setField(name, "twofa_pending_secret", "");
|
plugin.getDb().setField(name, "twofa_pending_secret", "");
|
||||||
plugin.getDb().setField(name, "twofa_pending_at", 0L);
|
plugin.getDb().setField(name, "twofa_pending_at", 0L);
|
||||||
|
markBoundNow(name);
|
||||||
|
// 第47批:绑定结果同步到网页端(网页安全卡随之显示已绑定)
|
||||||
|
pushStateAsync(name, true, pending,
|
||||||
|
System.currentTimeMillis() / 1000L);
|
||||||
|
|
||||||
p.sendMessage("§a§l[2FA] §a二次验证绑定成功!");
|
p.sendMessage("§a§l[2FA] §a二次验证绑定成功!");
|
||||||
p.sendMessage("§7 此后敏感操作需提供认证器动态码");
|
p.sendMessage("§7 此后敏感操作需提供认证器动态码");
|
||||||
@@ -370,6 +380,9 @@ public class TwoFactorManager {
|
|||||||
plugin.getDb().setField(name, "twofa_secret", "");
|
plugin.getDb().setField(name, "twofa_secret", "");
|
||||||
plugin.getDb().setField(name, "twofa_enabled", 0);
|
plugin.getDb().setField(name, "twofa_enabled", 0);
|
||||||
plugin.getDb().setField(name, "twofa_recovery_codes", "");
|
plugin.getDb().setField(name, "twofa_recovery_codes", "");
|
||||||
|
plugin.getDb().setField(name, "twofa_bound_at", 0L);
|
||||||
|
// 第47批:解绑结果同步到网页端
|
||||||
|
pushStateAsync(name, false, "", System.currentTimeMillis() / 1000L);
|
||||||
plugin.getLogger().info("[2FA] 玩家 " + name
|
plugin.getLogger().info("[2FA] 玩家 " + name
|
||||||
+ " 使用恢复代码通过验证,已自动解绑二次验证");
|
+ " 使用恢复代码通过验证,已自动解绑二次验证");
|
||||||
sendUnbindAlarm(name, "恢复代码解绑", "玩家本人(恢复代码)",
|
sendUnbindAlarm(name, "恢复代码解绑", "玩家本人(恢复代码)",
|
||||||
@@ -653,6 +666,9 @@ public class TwoFactorManager {
|
|||||||
plugin.getDb().setField(name, "twofa_enabled", 0);
|
plugin.getDb().setField(name, "twofa_enabled", 0);
|
||||||
// 解绑要彻底:恢复代码一并作废,避免残留可再次触发解绑/验证
|
// 解绑要彻底:恢复代码一并作废,避免残留可再次触发解绑/验证
|
||||||
plugin.getDb().setField(name, "twofa_recovery_codes", "");
|
plugin.getDb().setField(name, "twofa_recovery_codes", "");
|
||||||
|
plugin.getDb().setField(name, "twofa_bound_at", 0L);
|
||||||
|
// 第47批:解绑结果同步到网页端
|
||||||
|
pushStateAsync(name, false, "", System.currentTimeMillis() / 1000L);
|
||||||
Player pl = Bukkit.getPlayer(uuid);
|
Player pl = Bukkit.getPlayer(uuid);
|
||||||
String srcIp = "";
|
String srcIp = "";
|
||||||
if (pl != null && pl.isOnline()) {
|
if (pl != null && pl.isOnline()) {
|
||||||
@@ -810,6 +826,9 @@ public class TwoFactorManager {
|
|||||||
plugin.getDb().setField(target, "twofa_recovery_codes", "");
|
plugin.getDb().setField(target, "twofa_recovery_codes", "");
|
||||||
plugin.getDb().setField(target, "twofa_pending_secret", "");
|
plugin.getDb().setField(target, "twofa_pending_secret", "");
|
||||||
plugin.getDb().setField(target, "twofa_pending_at", 0L);
|
plugin.getDb().setField(target, "twofa_pending_at", 0L);
|
||||||
|
plugin.getDb().setField(target, "twofa_bound_at", 0L);
|
||||||
|
// 第47批:解绑结果同步到网页端
|
||||||
|
pushStateAsync(target, false, "", System.currentTimeMillis() / 1000L);
|
||||||
|
|
||||||
out.sendMessage("§a[2FA] 强制解绑成功:玩家 " + target);
|
out.sendMessage("§a[2FA] 强制解绑成功:玩家 " + target);
|
||||||
out.sendMessage("§7 已清空 twofa_secret / twofa_enabled / "
|
out.sendMessage("§7 已清空 twofa_secret / twofa_enabled / "
|
||||||
@@ -896,6 +915,153 @@ public class TwoFactorManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 网页端绑定状态双向同步(第47批) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步拉取网页端 2FA 状态并合并到本地,回主线程后执行后续动作。
|
||||||
|
* 网络失败/未启用:静默按本地状态直接继续,绝不阻塞命令。
|
||||||
|
*/
|
||||||
|
private void syncWithWebThen(Player p, java.util.function.Consumer<Player> next) {
|
||||||
|
if (plugin.webManager == null) {
|
||||||
|
next.accept(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final UUID uuid = p.getUniqueId();
|
||||||
|
final String name = p.getName();
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
WebManager.TwoFaWebState web = null;
|
||||||
|
try {
|
||||||
|
web = plugin.webManager.pullTwoFactorState(name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[2FA] 网页状态拉取异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
final WebManager.TwoFaWebState st = web;
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
Player pl = Bukkit.getPlayer(uuid);
|
||||||
|
if (pl == null || !pl.isOnline()) return;
|
||||||
|
try {
|
||||||
|
applyWebState(pl, st);
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[2FA] 网页状态合并异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
next.accept(pl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并网页端状态到本地(主线程)。
|
||||||
|
* 规则:网页绑定而本地未绑定 → 导入;两端都绑定但密钥不同 → bound_at 新者胜;
|
||||||
|
* 网页已解绑且比本地绑定更新 → 本地解绑;本地新 → 反向推送覆盖网页。
|
||||||
|
* 恢复代码不做跨端同步(网页 16 位明文 vs 游戏 6 位 SHA-256,格式不兼容,各自维护)。
|
||||||
|
*/
|
||||||
|
private void applyWebState(Player p, WebManager.TwoFaWebState web) {
|
||||||
|
if (web == null) return; // 拉取失败:保持本地状态
|
||||||
|
String name = p.getName();
|
||||||
|
boolean localBound = isEnabled(name);
|
||||||
|
String localSecret = str(plugin.getDb().getField(name, "twofa_secret"));
|
||||||
|
long localBoundAt = longOf(plugin.getDb().getField(name, "twofa_bound_at"));
|
||||||
|
|
||||||
|
if (web.bound && !web.secret.isEmpty()) {
|
||||||
|
if (!localBound) {
|
||||||
|
// 网页已绑定、本地未绑定 → 导入(修复截图场景)
|
||||||
|
plugin.getDb().setField(name, "twofa_secret", web.secret);
|
||||||
|
plugin.getDb().setField(name, "twofa_enabled", 1);
|
||||||
|
plugin.getDb().setField(name, "twofa_bound_at", web.boundAt);
|
||||||
|
plugin.getDb().setField(name, "twofa_pending_secret", "");
|
||||||
|
plugin.getDb().setField(name, "twofa_pending_at", 0L);
|
||||||
|
p.sendMessage("§a§l[2FA] §a已同步网页端的二次验证绑定");
|
||||||
|
p.sendMessage("§7 此后敏感操作需提供认证器动态码;网页端恢复代码在游戏内不可用");
|
||||||
|
plugin.getLogger().info("[2FA] 已导入网页端绑定: " + name
|
||||||
|
+ " (bound_at=" + web.boundAt + ")");
|
||||||
|
} else if (!localSecret.equals(web.secret)) {
|
||||||
|
if (web.boundAt > localBoundAt) {
|
||||||
|
// 网页端换绑更新 → 覆盖本地
|
||||||
|
plugin.getDb().setField(name, "twofa_secret", web.secret);
|
||||||
|
plugin.getDb().setField(name, "twofa_bound_at", web.boundAt);
|
||||||
|
p.sendMessage("§e§l[2FA] §e检测到网页端更新的绑定,已切换为新密钥");
|
||||||
|
plugin.getLogger().info("[2FA] 密钥冲突取网页端(更新): " + name);
|
||||||
|
} else {
|
||||||
|
// 本地更新 → 反向推送覆盖网页
|
||||||
|
plugin.getLogger().info("[2FA] 密钥冲突取本地(更新): " + name);
|
||||||
|
pushStateAsync(name, true, localSecret, localBoundAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网页端未绑定/无记录
|
||||||
|
if (localBound) {
|
||||||
|
if (web.exists && web.updatedAt > localBoundAt) {
|
||||||
|
// 网页端主动解绑且更晚 → 本地跟随解绑
|
||||||
|
plugin.getDb().setField(name, "twofa_secret", "");
|
||||||
|
plugin.getDb().setField(name, "twofa_enabled", 0);
|
||||||
|
plugin.getDb().setField(name, "twofa_recovery_codes", "");
|
||||||
|
plugin.getDb().setField(name, "twofa_bound_at", 0L);
|
||||||
|
p.sendMessage("§e§l[2FA] §e网页端已解除二次验证,游戏内同步解绑");
|
||||||
|
plugin.getLogger().info("[2FA] 网页端解绑传播到本地: " + name);
|
||||||
|
} else if (!web.exists) {
|
||||||
|
// 网页端无记录(本地绑定未推送过)→ 推送本地绑定到网页
|
||||||
|
pushStateAsync(name, true, localSecret, localBoundAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 两端都未绑定:无需动作(网页端 pending 待确认态不导入)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异步推送本地绑定状态到网页端(解绑/绑定收口统一走这里) */
|
||||||
|
private void pushStateAsync(String name, boolean bound, String secret, long boundAt) {
|
||||||
|
if (plugin.webManager == null) return;
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
plugin.webManager.pushTwoFactorState(name, bound, secret, boundAt);
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[2FA] 推送绑定状态异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家进服时静默同步(第47批,Main.PlayerJoinEvent 调用):
|
||||||
|
* 网页端绑定在进服即落本地,登录 IP 风控的 isEnabled() 读到的就是同步后状态。
|
||||||
|
* 无回调、无提示(除非真的导入了网页绑定);网络失败静默跳过。
|
||||||
|
*/
|
||||||
|
public void syncOnJoin(String playerName) {
|
||||||
|
if (plugin.webManager == null || playerName == null || playerName.isEmpty()) return;
|
||||||
|
final String name = playerName;
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
WebManager.TwoFaWebState web = null;
|
||||||
|
try {
|
||||||
|
web = plugin.webManager.pullTwoFactorState(name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[2FA] 进服同步拉取异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
final WebManager.TwoFaWebState st = web;
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
try {
|
||||||
|
Player pl = Bukkit.getPlayerExact(name);
|
||||||
|
if (pl == null || !pl.isOnline()) return; // 已下线:跳过(无处提示,/2fa 时还会再同步)
|
||||||
|
applyWebState(pl, st);
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().warning("[2FA] 进服同步合并异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地绑定时刻(秒)—— 绑定完成时写入,供与网页端 bound_at 比新旧 */
|
||||||
|
private void markBoundNow(String name) {
|
||||||
|
plugin.getDb().setField(name, "twofa_bound_at", System.currentTimeMillis() / 1000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long longOf(Object v) {
|
||||||
|
if (v instanceof Number) return ((Number) v).longValue();
|
||||||
|
try {
|
||||||
|
return Long.parseLong(String.valueOf(v).trim());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 状态读取 ====================
|
// ==================== 状态读取 ====================
|
||||||
|
|
||||||
private boolean isEnabled(Player p) {
|
private boolean isEnabled(Player p) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"对话留档":{
|
||||||
|
"怎么写":"键是提问,值是回答",
|
||||||
|
"谁来写":"每次提问/控场自动写",
|
||||||
|
"请求失败":"响应原文落盘"
|
||||||
|
},
|
||||||
|
"问":"答",
|
||||||
|
"自动":"答"
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"人设部分": {
|
||||||
|
"服务器名字": "",
|
||||||
|
"助理名字": "",
|
||||||
|
"写法": "1行1个"
|
||||||
|
},
|
||||||
|
"配置部分": {
|
||||||
|
"1": {
|
||||||
|
"模型配置": {
|
||||||
|
"内置供应商": {
|
||||||
|
"供应商名字": "",
|
||||||
|
"apikey": "",
|
||||||
|
"max_token": "",
|
||||||
|
"可用模型": [],
|
||||||
|
"默认使用": ""
|
||||||
|
},
|
||||||
|
"自定义供应商": {
|
||||||
|
"baseurl": "",
|
||||||
|
"apikey": "",
|
||||||
|
"max_token": "",
|
||||||
|
"可用模型": [],
|
||||||
|
"默认使用": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"默认上下文长度": "200000",
|
||||||
|
"发言间隔": {
|
||||||
|
"最短": "",
|
||||||
|
"最长": "",
|
||||||
|
"注释": "单位分钟"
|
||||||
|
},
|
||||||
|
"自动补齐": {
|
||||||
|
"开关": true,
|
||||||
|
"备注": "如果你的厂商"
|
||||||
|
},
|
||||||
|
"互动指标": {
|
||||||
|
"指标": 1,
|
||||||
|
"说明": "指标为在线玩家数量,当在线玩家大于等于指标才会触发自动互动。手动不受此影响"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"填写规范": {
|
||||||
|
"自定义": {
|
||||||
|
"baseurl": "填写厂家给你的地址,比如https://example.com/v1",
|
||||||
|
"apikey": "填厂家开放平台的key,比如sk-example",
|
||||||
|
"可用模型": "填厂家给的模型名字,比如example",
|
||||||
|
"max_token": "上下文长度,不填则使用默认20万上下文长度"
|
||||||
|
},
|
||||||
|
"内置供应商": {
|
||||||
|
"供应商名字": "一层一层填,比如mimo/按量计费,deepseek等。第一段即厂家名,@//厂家名/ 可直接调用",
|
||||||
|
"apikey": "填厂家开放平台的key,比如sk-example",
|
||||||
|
"可用模型": "填厂家给的模型名字,比如example",
|
||||||
|
"max_token": "上下文长度,不填则使用默认20万上下文长度"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"配置填写示例": {
|
||||||
|
"自定义供应商": {
|
||||||
|
"baseurl": "https://example.com/v1",
|
||||||
|
"apikey": "sk-example",
|
||||||
|
"max_token": "1000000",
|
||||||
|
"可用模型": [
|
||||||
|
"example"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"内置供应商": {
|
||||||
|
"供应商名字": "mimo/token-plan",
|
||||||
|
"apikey": "sk-example",
|
||||||
|
"可用模型": [
|
||||||
|
"mimo-v2.6-flash",
|
||||||
|
"mimo-v2.6-pro"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"内置供应商1": {
|
||||||
|
"供应商名字": "deepseek",
|
||||||
|
"apikey": "sk-example",
|
||||||
|
"可用模型": [
|
||||||
|
"deepseek-flash",
|
||||||
|
"deepseek-v4-pro"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"内置供应商3": {
|
||||||
|
"供应商名字": "讯飞/讯飞星辰maas/标准",
|
||||||
|
"apikey": "sk-example",
|
||||||
|
"可用模型": [
|
||||||
|
"spakr-x2.6",
|
||||||
|
"xopglm5"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"openai协议": "内置供应商",
|
||||||
|
"添加方法": [
|
||||||
|
"键值对格式,键是供应商名字,值是请求地址",
|
||||||
|
"如果供应商有区分按量计费、token-plan则用一堆花括号即可。可参考mimo的写法",
|
||||||
|
"如果要备注,就用花括号展开,比如向Agnes、opencode一样",
|
||||||
|
"额外说明:本文件默认会从插件内置的服务器定期拉取更新。"
|
||||||
|
],
|
||||||
|
"国产": {
|
||||||
|
"mimo": {
|
||||||
|
"按量计费": "https://api.xiaomimimo.com/v1",
|
||||||
|
"token-plan": "https://token-plan-cn.xiaomimimo.com/v1",
|
||||||
|
"批量推理": "https://batch-api-cn.xiaomimimo.com/v1"
|
||||||
|
},
|
||||||
|
"deepseek": "https://api.deepseek.com",
|
||||||
|
"kimi": "https://api.moonshot.cn/v1",
|
||||||
|
"智谱GLM": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
"通义千问": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
"百度AIStudio": "https://aistudio.baidu.com/llm/lmapi/v3",
|
||||||
|
"MiniMax": "https://api.minimax.cn/v1",
|
||||||
|
"阶跃星辰": {
|
||||||
|
"标准": "https://api.stepfun.com/v1",
|
||||||
|
"step-plan": "https://api.stepfun.com/step_plan/v1"
|
||||||
|
},
|
||||||
|
"零一万物": "https://api.lingyiwanwu.com/v1",
|
||||||
|
"百川": "https://api.baichuan-ai.com/v1",
|
||||||
|
"美团": "https://api.longcat.chat/openai",
|
||||||
|
"抖音云(豆包/火山)": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
"讯飞": {
|
||||||
|
"讯飞星火": "https://spark-api-open.xf-yun.com/v1",
|
||||||
|
"讯飞星辰MaaS": {
|
||||||
|
"标准": "https://maas-api.cn-huabei-1.xf-yun.com/v2",
|
||||||
|
"Coding Plan": "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2",
|
||||||
|
"Token Plan": "https://maas-token-api.cn-huabei-1.xf-yun.com/v2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"腾讯云": {
|
||||||
|
"Token Plan": "https://api.lkeap.cloud.tencent.com/plan/v3",
|
||||||
|
"Coding Plan": "https://api.lkeap.cloud.tencent.com/coding/v3",
|
||||||
|
"TokenHub(广州)": "https://tokenhub.tencentmaas.com",
|
||||||
|
"TokenHub(新加坡)": "https://tokenhub-intl.tencentmaas.com"
|
||||||
|
},
|
||||||
|
"硅基流动": "https://api.siliconflow.cn/v1"
|
||||||
|
},
|
||||||
|
"非国产": {
|
||||||
|
"OpenAI": "https://api.openai.com/v1",
|
||||||
|
"Claude(Anthropic)": "https://api.anthropic.com/v1/",
|
||||||
|
"推特(xAI Grok)": "https://api.x.ai/v1",
|
||||||
|
"Gemini": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||||
|
"英伟达": "https://integrate.api.nvidia.com/v1",
|
||||||
|
"agnes": {
|
||||||
|
"国际版": "https://apihub.agnes-ai.com/v1",
|
||||||
|
"中国版": "https://apihub.agnes-ai.cn/v1",
|
||||||
|
"说明": "中国版和国际版互通key,但账号、token-plan不互通"
|
||||||
|
},
|
||||||
|
"opencode": {
|
||||||
|
"zen": "https://opencode.ai/zen/v1",
|
||||||
|
"go": "https://opencode.ai/zen/go/v1",
|
||||||
|
"说明": "opencode zen可以通过9router或其它工具反代opencode cli终端免费使用。但不推荐,额度特别低。作者实测:反代出来跑了50万~100万左右就429限流。"
|
||||||
|
},
|
||||||
|
"OpenRouter": "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 此文件填写的模型将被正是运用请求
|
||||||
|
# 以#开头的表示注释,会被忽略
|
||||||
|
# models=name,这个=就是模型的简称,比如你可以配置deepseek=大肥鱼,后续用户@大肥鱼 xx,就会自动向deepseek的模型发起请求
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Sdf1_login
|
name: Sdf1_login
|
||||||
version: 3.1
|
version: 3.11
|
||||||
main: Sdf1_login.Main
|
main: Sdf1_login.Main
|
||||||
api-version: '1.21'
|
api-version: '1.21'
|
||||||
commands:
|
commands:
|
||||||
@@ -72,6 +72,9 @@ commands:
|
|||||||
绑定邮箱:
|
绑定邮箱:
|
||||||
description: 绑定邮箱
|
description: 绑定邮箱
|
||||||
usage: /绑定邮箱
|
usage: /绑定邮箱
|
||||||
|
webbind:
|
||||||
|
description: 网页邮箱绑定批准/拒绝
|
||||||
|
usage: /webbind <approve|reject> <id>
|
||||||
chat:
|
chat:
|
||||||
description: 聊天过滤管理
|
description: 聊天过滤管理
|
||||||
usage: /chat
|
usage: /chat
|
||||||
@@ -208,3 +211,6 @@ commands:
|
|||||||
json:
|
json:
|
||||||
description: JSON消息构造器(tellraw/大标题/小标题/actionbar+颜色,需OP以上权限)
|
description: JSON消息构造器(tellraw/大标题/小标题/actionbar+颜色,需OP以上权限)
|
||||||
usage: /json
|
usage: /json
|
||||||
|
credits:
|
||||||
|
description: 信誉积分管理(上报/审批/记录/规则)
|
||||||
|
usage: /credits [上报|审批|记录|规则] [参数]
|
||||||
|
|||||||
Reference in New Issue
Block a user