Compare commits

..
52 Commits
Author SHA1 Message Date
youpaishidifu 290042789f 第58批修复2:AI配置深度递归查找供应商URL
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- findUrlInObject 路径终点遇到嵌套对象时深度递归找第一个URL
- 新增 getElementIgnoreCase:大小写不敏感匹配(讯飞星辰maas vs 讯飞星辰MaaS)
- 新增 findFirstUrlInObject:递归遍历嵌套对象找第一个http URL
- 解决配置 讯飞/讯飞星辰MaaS(不指定子项)找不到URL的问题
2026-10-28 19:35:48 +08:00
youpaishidifu 67f8d6e7fb 第58批修复:
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 3m14s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- 领地误删致命bug:AreaProtection 改用 userExistsIgnoreCase(SQL异常返回true,不误删)
- getConnection 自动重连:连接已关闭时重建,解决 SN/信誉同步 database connection closed
- userExists/userExistsIgnoreCase/userExistsStrict 改用 getConnection() 自动重连
- AI配置解析跳过诊断日志:findProviderUrl 返回 null 时输出具体供应商名和路径,便于排查 0个模型 问题
2026-10-28 19:19:38 +08:00
youpaishidifu 6a5af09ed3 第58批续:AI聊天@厂家修复+信誉积分双向同步+错误日志恢复
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 6m24s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
AIChatManager:
- 修复@//顶层分组名报「未知厂家」bug:parseModelConfig 之前跳过第1段(如「非国产」)
  不注册为厂家别名,导致 @//非国产/ 无法调用。改为注册所有路径段。
- 重读配置前清理 modelEndpoints/providerToModel,避免旧配置残留
- getAvailableModels 改为返回别名+厂家名+模型全名,不再误报「未配置模型」
- checkAutoChat 失败分支恢复详细错误日志:完整请求URL、请求体、响应头、响应体
  (与 sendToModel 对齐,搞明白上游真寄了还是本地问题)

WebManager:
- 新增 pushCreditToPhp:Java→PHP 推送信誉积分(非默认积分玩家+最近200条记录)
- 新增 pullCreditFromPhp:PHP→Java 拉回管理员手动调整的记录(source=admin)
- 两个方法接入 Timer C 批处理队列错峰执行
- 新增 JsonElement/JsonParser import
2026-10-28 18:40:04 +08:00
youpaishidifu 13ee2eb515 第58批:修复自动互动配置重读刷屏
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m39s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
- loadConfig() 加 quiet 参数,重读时不打印配置加载日志
- checkAutoChat 中 loadConfig 移到时间检查之后,只有真正发请求时才重读
- 避免每秒刷屏
2026-10-28 17:37:22 +08:00
youpaishidifu d2835dd272 第58批:自动互动日志静默,只在状态变化时记录
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
- 移除每次触发的调试块(请求体/响应体/响应头)
- 正常触发零日志,失败只一行简要(HTTP码或超时)
- 新增prevAutoChatFailed状态追踪,失败→成功时记恢复
- 有变化在叫,没变化闭嘴
2026-10-28 17:07:43 +08:00
youpaishidifu b6dfad4aed 第58批:AI聊天每次请求前重读配置
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m14s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- sendToModel: 发请求前重读models.json/供应商.json/提示词.txt
- checkAutoChat: 自动互动触发前也重读配置
- 避免模型429/403报错后还用旧配置继续发请求
2026-10-28 16:58:09 +08:00
youpaishidifu 88e2dfa04c 第58批:AI聊天@前缀支持+信誉积分优化
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
- AIChatManager: 支持单@前缀(@厂家名/内容),不再只检测@//
- AIChatManager: 自动互动提示词改为引导性话题,不点名玩家
- CreditManager: /credits <玩家名> 直接查积分,无需user关键字
- admin.php: 信誉积分卡片统一使用CSS变量,与后台风格一致
2026-10-28 16:21:52 +08:00
youpaishidifu f5dcd5f455 第58批续24修复3:供应商名字支持省略组名 + @//参数屏蔽组名
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m40s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1. findProviderUrl 支持省略组名:
   - "deepseek" → 在所有顶层分组里查找,匹配到国产分类下的deepseek
   - "国产/mimo/token-plan" → 直接进国产分组找mimo
   - 组名可写可不写

2. @//厂家名注册时屏蔽组名:
   - 第1段如果是供应商.json顶层分组名(国产/非国产)→ 不注册为@参数
   - 从第2段开始注册为厂家名别名
   - "非国产/agnes/中国版" → 注册 agnes、中国版,屏蔽 非国产
   - "讯飞/讯飞Maas" → 讯飞不是分组名,注册 讯飞、讯飞Maas
2026-10-28 15:44:59 +08:00
youpaishidifu fa0ea9c7e9 第58批续24修复2:@//厂家名支持供应商名字路径每一段
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m53s
Simple Test / test (push) Successful in 3s
Test Env / test (push) Successful in 1s
之前只取第一段,但生产服配置是「非国产/agnes/中国版」——
第一段「非国产」是分组名不是厂家名,@//agnes/ 不识别。

修复:把「供应商名字」路径的每一段都注册为厂家名别名,
如 非国产/agnes/中国版 → 三个名字都能 @// 调用。
用 putIfAbsent 避免重复注册时覆盖已有别名。
2026-10-28 15:31:32 +08:00
youpaishidifu d159d2fb11 第58批续24修复:@//厂家名识别 + 互动指标日志静默
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m56s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
1. @//命令支持厂家名快捷引用:
   - 内置供应商:从「供应商名字」第一段提取厂家名(mimo/token-plan→mimo)
   - 自定义供应商:走「提示词.txt」已有的 models=全名=别名 映射
   - resolveModelAlias 新增 providerToModel 映射查找
2. @//命令优化:
   - 支持不带尾部斜杠:@//agnes → 整段作模型名
   - 不带内容时用默认提示词(自我介绍)
   - 未知模型提示新增「可用厂家」列表
3. 互动指标日志静默:
   - 调度器每1秒跑一次,跳过时不再每秒刷屏
   - 仅在状态变化(≥→< 或 <→≥)时记一条
4. models.json 填写规范更新:供应商名字说明厂家名提取规则
2026-10-28 15:19:15 +08:00
youpaishidifu 778bf5e4c2 第58批续24: 互动指标(在线玩家阈值) + 上下文压缩 + credit_score同步PHP
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
1. AIChatManager.interactionMetric 字段:在线玩家数量阈值,默认1
   - 配置加载:models.json → 配置部分 → 2 → 互动指标 → 指标
   - checkAutoChat() 在线玩家数量达标才触发自动互动(手动@//不受影响)
2. buildChatRequest 移除 max_tokens 字段(让API用默认输出上限)
3. 新增 compressChatBuffer() 上下文压缩:超阈值从尾部截断只保留最近消息
   - checkAutoChat 改用 compressChatBuffer(endpoint.maxToken) 构建提示词
4. models.json 新增「互动指标」配置模板
5. WebManager credit_score 字段同步到PHP(信誉积分双向对账)
2026-10-28 14:23:17 +08:00
youpaishidifu a7018e1e4f 第58批续23修复:待审批详情GUI交互缺陷(标题匹配+聊天拦截误伤)
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-28 13:22:23 +08:00
youpaishidifu d0323ea975 第58批续23:AI聊天400调试日志增强 + CreditManager GUI管理员直接操作 + 主菜单40号位入口
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-28 13:15:03 +08:00
youpaishidifu 6892c47599 第58批死循环修复: 自动互动触发前更新时间戳+失败也节流
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
根因: lastAiReplyTime初始0且只在请求成功时更新。
请求失败→时间戳不更新→每秒检查都判定超时→每秒触发→无限死循环。
修复: 触发前先更新时间戳(请求挂起/超时/失败都不会被下一秒重复触发);
handleRequestFailure也更新时间戳双保险。
2026-10-28 12:49:08 +08:00
youpaishidifu 2da1f2be9e 第58批防御: buildRequestUrl 已含chat/completions则绝不再补(防双重地址)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m48s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-28 12:41:02 +08:00
youpaishidifu 59bf992162 第58批修复: AI聊天URL解析 + 信誉积分9格GUI重构
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
AI聊天修复(AIChatManager):
- Bug1 内置供应商URL解析: findProviderUrl把含分组名的完整数组传进分组对象查找,
  国产.get(非国产)永远null → 改为先取顶层分组,再在分组内逐层查
- Bug2 自定义供应商双重路径: models.json已设自动补齐=false,
  代码仍无条件拼chat/completions → 加autoAppendCompletions开关,
  内置供应商总是拼接,自定义受开关控制
- 提取buildRequestUrl公共方法,sendToModel和checkAutoChat共用
2026-10-28 12:33:48 +08:00
youpaishidifu a0f84ee542 第58批功能1+2: 信誉积分管理系统 + AI聊天介入
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
信誉积分管理系统(CreditManager):
- /credits 上报好人好事/举报,每人初始化100积分
- 管理员审批后加分/扣分,低于阈值触发行为限制(可配置规则)
- PHP端可提交线索,admin审批接口
- GUI: 待审批/历史记录/限制规则
- DB: credit_records + credit_rules 表 + users.credit_score列

AI聊天介入(AIChatManager):
- 兼容openai协议,读models.json/供应商.json/提示词.txt配置
- @//模型名/前缀触发直接提问,私聊回复
- 空闲1-10分钟自动抓聊天互动,广播回复
- 黑名单拦截不浪费token
- 对话留档issue.json,HTTP异步请求不阻塞主线程
- 已解除.gitignore对openai资源的屏蔽
2026-10-28 10:49:12 +08:00
youpaishidifu cf84426bf6 第57批续22: 子领地权限失效修复 + 封禁播报时间被篡改修复
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m27s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1. 子领地权限失效: onPlayerMove区域检测循环遇首个匹配领地即break,
   子领地完全包含在主领地内,可能命中主领地导致子领地deny_move不生效。
   修复: 收集所有匹配领地选面积最小的(子领地自然优先)。

2. 子领地创建时不继承主领地权限: confirm_create和handleApproval路径
   新增copyLandPermissions方法,子领地创建时继承主领地权限一次。

3. 封禁播报时间被篡改: WebManager对账收回封禁时,banList.addBan()
   把created设为当前时间写入banned-players.json,setCreated只改内存。
   Bukkit BanList无updateBan API,文件里保留的是收回时间。
   修复: 新增persistBanCreatedToFile方法直接改写文件持久化真实创建时间。
2026-10-27 21:45:45 +08:00
youpaishidifu 9443a5cf1b 第57批续21续2: 子领地owner=创建者(付款人)修复退款退错人bug + 圈地棒身份判定静默 2026-10-27 20:32:31 +08:00
youpaishidifu 74db3d022b 第57批续21-fix: 修子领地创建不落库+确认创建按钮显隐
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
修1(问题2根因): saveAreaToDb INSERT VALUES 占位符76->77。续21加parent_id列(第77列)
但VALUES漏加第77个?,JDBC setInt(77,..)越界→SQLException被catch吞掉→DB未写入
→但代码继续areas.put+提示成功→loadAllAreas清空内存重读DB→子领地消失→传送/cli菜单找不到。
补全77个占位符后DB正常写入,loadAllAreas后子领地保留,传送/cli菜单可见。

修2(问题1): 圈地棒选区智能识别按钮显隐——身份不合格或部分重叠时不展示确认创建按钮。
canCreateSub标志:身份不合格→false,部分重叠→false,合格→true;false时隐藏一键创建链接,
提示'因身份或范围限制,已隐藏确认创建按钮'。confirm_create命令层闸门不变(双保险)。
2026-10-27 17:40:11 +08:00
youpaishidifu 7b8aa4e194 第57批续21: 领地子领地体系 + 重叠身份闸门(修绕过漏洞)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m36s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
【任务1 修绕过漏洞】confirm_create(圈地棒主路径)原零重叠检测,任何玩家都能在别人领地内
建重叠领地绕过权限限制。新增 findContainingMainLand(完全包含判定,排除子领地)+
isQualifiedLandManager(领地主/管理员/授权管理员身份校验):
- 选区完全包含于某主领地+身份合格→建子领地(parentId=主领地id,owner沿用主领地主)
- 身份不合格→拒绝'当前范围内已有领地'
- 部分重叠(跨边界)→一律拒绝
quickCreateLand(3×3)部分重叠仍拒绝;完全包含且身份合格则自动转 confirm_create 建子领地
审批 create 路径(handleApproval)同步套重叠闸门,防超面积审批绕过
【任务2 子领地体系】area_lands 加 parent_id 列(CREATE+ALTER迁移);AreaConfig 加 parentId
子领地权限挂父领地 land_id 的 area_land_permissions 表(可单独收紧/放行);
getPermissionLevel 加'父领地管理员在子领地内继承ADMIN'(封堵越权);新增 findLandNameById;
圈地棒选区后追加'智能识别领地'提示
约束:子领地仅1级、必须完全包含、按面积正常收费、命名冲突自动加序号
2026-10-27 16:47:28 +08:00
youpaishidifu 7c649675d6 修复.gitignore的一个错别字,并临时屏蔽没做完的开发内容
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-27 15:40:51 +08:00
youpaishidifu a44bc88bd0 第57批续20: syncOnlinePlayers 改推全量在线玩家(不再筛loggedIn)——修刚注册未/login玩家PHP判'未在线'
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
根因: syncOnlinePlayers 此前只推 loggedIn 玩家, 刚注册未 /login 的玩家
在 loggedIn 集合里但漏推 → PHP online_players 表无该玩家记录 →
validateWebAccess 判 !isOnline → 网页弹'未在线, 无法批准'。
修复: 删除 if(!loggedInPlayers.contains(...)) continue 过滤,
推送 Bukkit.getOnlinePlayers() 全量在线玩家(已登录+未登录)。
loggedIn 集合保留仅用于日志变化检测。
能否绕过密码验证仍由 login_verified 位决定(续19-hotfix已纠偏),
'在线'与'已验证'两语义彻底解耦。
2026-10-27 15:17:27 +08:00
youpaishidifu 49d86c9e85 第57批续19-hotfix: login_verified 语义纠偏(严格按isLoggedIn推)
Check Source / check (push) Successful in 7s
Auto Release / build (push) Successful in 3m36s
Simple Test / test (push) Successful in 3s
Test Env / test (push) Successful in 4s
续19把login_verified按isRegistered推, 导致'已注册但未登录'的玩家也被推1
→ PHP validateWebAccess view路径直接放行(绕过密码), 游戏里还在提示/login
但php已判定登录成功(与游戏状态不一致)。

修复: autoGenerate/handleWebLogin 两处push URL的login_verified改回
isLoggedIn(只有/login密码验证成功才=1), 已注册但未登录=0(网页要求输密码)。
PHP侧续19已有的web_login_verified 10分钟窗口做'登录状态同步延迟'兜底不受影响。

mvn -q -o clean package 编译通过, jar 1546400字节。
2026-10-27 14:46:02 +08:00
youpaishidifu 2c006d35b5 第57批续19: Java 侧 token 0/1 状态位统一管理
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m35s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1. autoGenerateWebLoginToken 加 10s 冷却去重(修"每次登录推2个token"刷屏)。
2. 三处 push URL 的 login_verified 值统一: 已注册/已登录→1(网页直接放行), 未注册→0(网页要求输密码/注册)。
3. pushLoginVerified 加注释说明(登录收口已保证玩家已登录, 此路径固定推1)。
4. 修复续17遗留损坏: submitWebEmailBindResult 5参重载方法体被吞, 保留委托调用(非递归)并加说明注释。

mvn -q -o clean package 编译通过。
2026-10-27 13:28:26 +08:00
youpaishidifu 687b592d69 第57批续17: Java拉取PHP邮箱回推队列写权威db——pullEmailBindings()幂等写db+触发syncUserRegistrations收敛,挂TimerA
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-27 11:27:10 +08:00
youpaishidifu a2c92d9150 第57批续11:PVP弃用API修复(Paper 26.x)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 3m5s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- GameRule.DO_DAYLIGHT_CYCLE → GameRules.ADVANCE_TIME
- GameRule.DO_WEATHER_CYCLE → GameRules.ADVANCE_WEATHER
- GameRule.DO_MOB_SPAWNING → GameRules.SPAWN_MONSTERS
- GameRule.KEEP_INVENTORY → GameRules.KEEP_INVENTORY
- AttributeModifier(String,...) → AttributeModifier(NamespacedKey,...)
2026-10-26 20:11:53 +08:00
youpaishidifu 325d363f4b 第57批续10:webbind绑邮箱密码验证接入统一风控+空行清理
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- Main.java: /webbind approve 密码验证从独立webbindPwdFails内存计数器
  接入RiskControlManager(与/login完全一致):密码错5次→Bukkit真实封禁
  5分钟+踢出+报警邮件+重连拒绝+到期自动解封
- Main.java: 删除已无引用的webbindPwdFails字段
- RiskControlManager.java: 按8a693e规则清理方法内部空行(11行)
2026-10-26 19:20:05 +08:00
youpaishidifu 74e2c60d60 第57批续7: Java端验证密码后直接回传pwd_verified=1,PHP不再重复验密;简化绑定流程
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-26 16:27:17 +08:00
youpaishidifu ad17f9ccba 第57批续6: 修复3个bug
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2. /login成功后重新推送weblogin token(onJoin推送的token login_verified=0,Web端不认)
2026-10-26 14:58:37 +08:00
youpaishidifu b65562850f 第57批续5: 修复4个bug
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m14s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1. onJoin封禁缺口通知改可点击Component(Bug#1)
2. webbind approve改聊天框输入密码+拒绝选项(Bug#2)
3. PHP acctBindEmailFinish rejected状态简化为单关闭按钮(Bug#3)
4. drawer-card毛玻璃效果修正(Bug#4)
2026-10-26 13:10:41 +08:00
youpaishidifu 956ee0e940 第57批续4:autoGenerateWebLoginToken改用isEnabled()实时读文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-26 12:00:05 +08:00
youpaishidifu f7f47e6178 第57批续4:confirm/cancel ban移到通用confirm/cancel之前 + 全文件\u00a7替换为§ 2026-10-26 11:36:54 +08:00
youpaishidifu 7b62075dc7 第57批续3修正:/del ban拦截移到del分支内 + 管理员通知改为可点击消息
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-26 11:10:45 +08:00
youpaishidifu d7d51cbb6b 第57批续3:登录推送补全+ban命令拦截+管理员广播
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- LoginManager.finishPasswordLogin 补 pushLoginVerified(/login 统一收口推送 login_verified=1)
- Main: /delete ban 拦截(ban 不是玩家账号名);confirm/cancel ban 加 hasBanPending 检查
- WebManager: broadcastToAdmins 方法 + enterBanPendingMode 缺口较大时给在线管理员发 CLI 通知
2026-10-26 10:49:22 +08:00
youpaishidifu 6eaa883849 第57批续2:时间戳文件两份问题修复
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- readTimestampFile:多份时间戳文件取最小值(用户手动放的固定值 < 插件启动创建的当前秒值)
- ensureTimestampFile:目录里已有任意时间戳文件就跳过,不重复创建
2026-10-26 09:53:03 +08:00
youpaishidifu 8a693e6417 第57批续:封禁播报时间戳文件+空行清理+补丁去重
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 3m0s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- BanBroadcastManager:resolveStartCutoff 优先读数据目录10位时间戳文件,无则回退目录创建时间+180天保护;启动时自动创建时间戳文件
- WebManager:删除2075行空行+激进清理(方法内部空行全删,块注释/方法间空行保留);bans_intercept/post_scan去重
- Main/LoginManager:去重重复插入的confirm/cancel ban块和pushLoginVerified块
2026-10-26 00:10:06 +08:00
youpaishidifu 8718dc6e87 第57批修复:补上post_scan+bans_intercept(v3静默失败的CRLF不匹配补丁)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m55s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-25 22:38:33 +08:00
youpaishidifu 51f56b9e18 第57批:封禁数据只读+缺口待确认+登录状态推送login_verified=1
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m13s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-25 21:28:39 +08:00
youpaishidifu 0b3a8debdd 第56批:webbind批准Java端验密码(5次风控)+注册token附带邮箱后缀配置
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m37s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-25 18:40:35 +08:00
youpaishidifu 340726205c 第55批:登录状态主权修复 - token生成时按实际登录状态推login_verified(未登录推0让PHP清除旧记录)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m30s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-25 17:37:15 +08:00
youpaishidifu ba73ebfdb2 第54批:登录验证主权反转 - 消息文案修正
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m25s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
根因:LoginManager.java:309 '正在核对网页端最新密码…' 暗示 PHP 有更高权威,
用户感知「主权搞反了」。

架构现状(已确认无需改动):
- PHP 不验证密码(sync.php verify_web_password 已禁用)
- Java 本地 checkPasswordWithFallback 为主
- Java 拉回 PHP 凭证入库主密码(applyPulledRow → updatePassword)
- Java 回传结果给 PHP(sendWebLoginResult → complete_web_login_request)

修复:
1. LoginManager.java: 消息文案改为中性「正在同步账号数据…」
2. WebManager.java:6550: 冻结解冻消息改为「请在游戏内执行 /sdf1_login pw」
3. 注释更新:明确「拉回PHP凭证入库为主密码」「以Java本地login.db为准」

验证:
- 括号配平 PASS(final=0, min=0)
- mvn clean compile BUILD SUCCESS (16.9s)
- 行尾:LoginManager LF, WebManager CRLF
2026-10-25 16:37:52 +08:00
youpaishidifu 5e9bd5f6bf 第53批:网页绑邮箱游戏内批准流——定时器A链尾轮询+clickable批准通知+/webbind命令
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-23 23:01:42 +08:00
youpaishidifu 474b912afe 第52批: 修复\/JSON转义脏值四层防护
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- 解析根修: addParsedEntry/parseJsonArrayItem清洗链补\/还原(置于\\还原之前); extractJsonString扫描跳过转义序列并按JSON规则反转义
- 写库兜底: createUser/updatePassword入参b64Clean; applyPulledRow credentials的hash/salt/temp写库前白名单清洗
- 存量迁移: init启动时REPLACE char92清洗users表password_hash/password_salt/temp_password(仿一次性自愈范式,WHERE instr条件幂等)
- 读取容错: checkPassword/checkPasswordWithFallback/checkPasswordForChange比对前双侧b64Clean
- 根因: PHP json_encode把base64中的/转义成\/,历史解析层漏还原写进库,hash脏值致游戏端+网页端密码恒错
2026-10-23 19:21:14 +08:00
youpaishidifu 5b705d0af2 第49轮诊断:收回写库前后抓新旧hash/salt完整值入日志,抓对账ping-pong现行
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m33s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-23 16:04:22 +08:00
youpaishidifu 7525b8f6b5 第48批(1/2):修复对账指纹未落盘致网页改密被反向覆盖;登录失败自动对账重试;绑定2FA后游戏登录强制密码+动态码两关
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- saveAlignSeen 自杀式节流修复:原节流条件 now-dirtyAt<5000 恒成立(调用点先设 dirtyAt 再调用)
  → align_seen.json 从未落盘 → 重启后 seen 全空 → 网页新密码被游戏旧密码反向覆盖;
  改为以「上次成功写入」alignSeenLastWrite 为基准节流
- /login 密码不匹配:15秒/人限流 → 异步 pushWebLoginCredentials(含 alignGate 拉回) →
  主线程自动重校验;重试仍败走正常风控计数(解决网页改密后游戏内密码对不上)
- 已绑定2FA(twofa_enabled=1)的玩家密码通过后不再直接放行:requireTwoFaIfEnabled
  转入双验证第二步等 /2fa code(此前仅 IP 变更才触发两关,同IP登录第一层过关直接放行)
2026-10-23 11:27:38 +08:00
youpaishidifu 2090939e87 第47批:2FA绑定状态与网页端双向同步——users表新增twofa_bound_at;WebManager新增pull_state/push_state;TwoFactorManager同步合并逻辑(导入/新者胜/解绑传播)+4写入点推送+进服静默同步;Main进服挂点
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 6m8s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-22 23:25:01 +08:00
youpaishidifu 14c288a77c 第45批:垃圾站轮次播报——保留仅1轮时不提示本轮结束时倾倒垃圾(仅保留>1才提示)
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-22 19:18:53 +08:00
youpaishidifu c92e79160c 合并远端 README 提交 a21e95d(仅 README) 2026-10-22 16:27:21 +08:00
youpaishidifu a21e95df68 docs: 完善 README 使用手册(3.11) 2026-10-22 16:23:16 +08:00
youpaishidifu 0b5422395c docs: 完善 README 使用手册(3.11)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 5m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-22 16:22:35 +08:00
youpaishidifu b7b564a091 3.11内测版:plugin.yml版本号升级
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m35s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-22 15:52:23 +08:00
21 changed files with 34061 additions and 29376 deletions
+5 -1
View File
@@ -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
+846 -331
View File
File diff suppressed because it is too large Load Diff
+994
View File
@@ -0,0 +1,994 @@
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) {
// ★ 重读前清理,避免旧配置残留(putIfAbsent 不会覆盖已存在的键)
modelEndpoints.clear();
providerToModel.clear();
// 遍历所有供应商配置
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) {
plugin.getLogger().info("[AI聊天] 配置跳过[" + key + "]: 既无baseurl也无供应商名字");
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()) {
plugin.getLogger().warning("[AI聊天] 配置跳过[" + key + "]: 内置供应商 '" + providerName + "' 在 供应商.json 中找不到URL");
plugin.getLogger().warning("[AI聊天] 诊断: 请确认 供应商.json 中存在路径 '" + providerName + "',且路径拼写完全正确");
}
}
if (baseUrl == null || baseUrl.isEmpty() || apiKey == null || apiKey.isEmpty()) {
plugin.getLogger().warning("[AI聊天] 配置跳过[" + key + "]: baseUrl=" + baseUrl + ", apiKey=" + apiKey);
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) {
// 注册所有路径段为厂家名别名(包括顶层分组名)
// 如 "非国产/agnes/中国版" → 非国产、agnes、中国版 都能 @// 调用
// ★ 第58批修复:顶层分组名(如"非国产")也要注册,否则 @//非国产/ 报"未知厂家"
String[] segs = providerName.split("/");
for (int i = 0; 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;
}
/**
* 大小写不敏感地获取 JsonObject 中的元素。
* 先精确匹配,再大小写不敏感匹配(解决 讯飞星辰maas vs 讯飞星辰MaaS 的问题)。
*/
private JsonElement getElementIgnoreCase(JsonObject obj, String key) {
JsonElement el = obj.get(key);
if (el != null) return el;
String lower = key.toLowerCase();
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
if (entry.getKey().toLowerCase().equals(lower)) {
return entry.getValue();
}
}
return null;
}
/**
* 深度递归:在嵌套 JsonObject 中找第一个 http URL。
* 不管嵌套多少层,找到第一个 http 开头的字符串值就返回。
* 用于路径终点是对象而非直接 URL 的场景(如 讯飞/讯飞星辰MaaS 没有指定子项)。
*/
private String findFirstUrlInObject(JsonObject obj) {
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
JsonElement el = entry.getValue();
if (el.isJsonPrimitive()) {
String val = el.getAsString();
if (val != null && val.startsWith("http")) return val;
} else if (el.isJsonObject()) {
String url = findFirstUrlInObject(el.getAsJsonObject());
if (url != null) return url;
}
}
return null;
}
private String findUrlInObject(JsonObject obj, String[] parts) {
JsonElement el = getElementIgnoreCase(obj, parts[0].trim());
if (el == null) return null;
if (parts.length == 1) {
// 路径终点:如果是字符串 → 直接返回 URL
if (el.isJsonPrimitive()) return el.getAsString();
// ★ 深度递归:如果是对象 → 继续往下钻找第一个 URL
// 如 "讯飞/讯飞星辰MaaS" 没指定子项,钻入 MaaS 对象找第一个 URL
if (el.isJsonObject()) return findFirstUrlInObject(el.getAsJsonObject());
return 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() {
// 合并别名、厂家名、模型全名,去重
LinkedHashSet<String> all = new LinkedHashSet<>();
all.addAll(aliasToFullModel.keySet());
all.addAll(providerToModel.keySet());
all.addAll(modelEndpoints.keySet());
return new ArrayList<>(all);
}
// ==================== 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) {
// ★ 完整异常详情:搞明白是上游真寄了还是本地问题
plugin.getLogger().warning("[AI聊天-调试] 自动互动请求异常: " + e.getClass().getSimpleName() + ": " + e.getMessage());
return null;
}
}).thenAccept(response -> {
if (response == null) {
plugin.getLogger().warning("[AI聊天-调试] 自动互动请求失败: 网络异常/超时");
plugin.getLogger().warning("[AI聊天-调试] 模型全名: " + defaultModelFull);
plugin.getLogger().warning("[AI聊天-调试] 请求URL: " + requestUrl);
plugin.getLogger().warning("[AI聊天-调试] 请求体JSON: " + jsonBody);
handleRequestFailure("[自动] " + prompt.substring(0, Math.min(100, prompt.length())), "自动互动失败: 请求异常");
prevAutoChatFailed = true;
return;
}
if (response.statusCode() != 200) {
// ★ 完整错误日志:请求头 + HTTP 返回一起打出来,定位上游是否真寄了
String errBody = response.body();
plugin.getLogger().warning("[AI聊天-调试] === 自动互动请求失败 ===");
plugin.getLogger().warning("[AI聊天-调试] HTTP状态码: " + response.statusCode());
plugin.getLogger().warning("[AI聊天-调试] 模型全名: " + defaultModelFull);
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.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();
}
}
+362 -19
View File
@@ -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 + " 债券)");
@@ -10608,8 +10911,11 @@ public class AreaProtection implements Listener {
String reason = (String) o[1]; String reason = (String) o[1];
if (reason == null) { if (reason == null) {
if (dbMgr == null) continue; if (dbMgr == null) continue;
// ★ 第58批修复:SQL 异常时 userExists 返回 false,
// 会把 DB 抖动误判为"账户已注销"从而删领地。
// 改用 userExistsIgnoreCase(异常返回 true,宁可放过)。
try { try {
if (!dbMgr.userExists(ac.owner)) { if (!dbMgr.userExistsIgnoreCase(ac.owner)) {
reason = "账户已注销"; reason = "账户已注销";
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
@@ -10761,8 +11067,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 +11081,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 +11159,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 +11258,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 +14105,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 +14275,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
+58 -6
View File
@@ -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;
@@ -508,7 +526,24 @@ public class DatabaseManager {
} }
} }
/** 获取数据库连接,连接已关闭时自动重连。★ 第58批修复:避免 SN/信誉同步等模块拿到已关闭连接报 "database connection closed"。 */
public Connection getConnection() { public Connection getConnection() {
try {
if (db == null || db.isClosed()) {
File dbDir = new File(dataFolder, "登录凭证");
dbDir.mkdirs();
File dbFile = new File(dbDir, "login.db");
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
st.execute("PRAGMA busy_timeout=5000");
st.close();
logger.warning("[Sdf1_login] 数据库连接已自动重建");
}
} catch (Exception e) {
logger.severe("[Sdf1_login] 数据库自动重连失败: " + e.getMessage());
}
return db; return db;
} }
@@ -686,7 +721,7 @@ public class DatabaseManager {
public boolean userExists(String name) { public boolean userExists(String name) {
try { try {
PreparedStatement ps = db.prepareStatement( PreparedStatement ps = getConnection().prepareStatement(
"SELECT 1 FROM users " "SELECT 1 FROM users "
+ "WHERE player_name=?"); + "WHERE player_name=?");
ps.setString(1, name); ps.setString(1, name);
@@ -706,7 +741,7 @@ public class DatabaseManager {
public boolean userExistsIgnoreCase(String name) { public boolean userExistsIgnoreCase(String name) {
if (name == null || name.isEmpty()) return false; if (name == null || name.isEmpty()) return false;
try { try {
PreparedStatement ps = db.prepareStatement( PreparedStatement ps = getConnection().prepareStatement(
"SELECT 1 FROM users " "SELECT 1 FROM users "
+ "WHERE player_name=? " + "WHERE player_name=? "
+ "COLLATE NOCASE"); + "COLLATE NOCASE");
@@ -729,7 +764,7 @@ public class DatabaseManager {
public boolean userExistsStrict(String name) { public boolean userExistsStrict(String name) {
if (name == null || name.isEmpty()) return false; if (name == null || name.isEmpty()) return false;
try { try {
PreparedStatement ps = db.prepareStatement( PreparedStatement ps = getConnection().prepareStatement(
"SELECT 1 FROM users " "SELECT 1 FROM users "
+ "WHERE player_name=? " + "WHERE player_name=? "
+ "COLLATE NOCASE"); + "COLLATE NOCASE");
@@ -744,8 +779,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 +819,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 +882,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 +922,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 +971,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";
} }
+6
View File
@@ -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) {
+213 -1
View File
@@ -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);
+5 -5
View File
@@ -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(
+167 -1
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"对话留档":{
"怎么写":"键是提问,值是回答",
"谁来写":"每次提问/控场自动写",
"请求失败":"响应原文落盘"
},
"问":"答",
"自动":"答"
}
+91
View File
@@ -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"
]
}
}
}
+63
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
# 此文件填写的模型将被正是运用请求
# 以#开头的表示注释,会被忽略
# models=name,这个=就是模型的简称,比如你可以配置deepseek=大肥鱼,后续用户@大肥鱼 xx,就会自动向deepseek的模型发起请求
+7 -1
View File
@@ -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 [上报|审批|记录|规则] [参数]