Compare commits

...
412 Commits
Author SHA1 Message Date
youpaishidifu 1133f37bf9 fix: 改用actions/setup-java@v4从Temurin下载JDK25,彻底绕开服务器JDK探测和sdkman冲突
Auto Release / build (push) Successful in 49s
2026-07-19 01:14:27 +08:00
youpaishidifu 0ddc47b5d4 fix: 彻底绕开sdkman,直接用/usr/lib/jvm最高版本JDK,JAVA_HOME写入GITHUB_ENV并强制export到PATH
Auto Release / build (push) Failing after 7s
2026-07-19 01:10:21 +08:00
youpaishidifu c0be75474f fix: 改用<release>25替代<source>/<target>,确保JAVA_HOME正确传递到Maven步骤
Auto Release / build (push) Failing after 8s
2026-07-19 01:06:54 +08:00
youpaishidifu daa4a3cb7c fix: 升级Maven compiler-plugin到3.15.0,优化Java检测逻辑
Auto Release / build (push) Failing after 9s
2026-07-19 01:01:55 +08:00
youpaishidifu 09873425b9 fix: Maven检测优先用系统已安装(command -v mvn),下载源换阿里云镜像
Auto Release / build (push) Failing after 43s
2026-07-19 00:56:13 +08:00
youpaishidifu 2b20098f09 fix: release token 改用 GITEA_TOKEN||GITHUB_TOKEN 回退,零配置可跑通
Auto Release / build (push) Failing after 2m16s
2026-07-19 00:47:18 +08:00
youpaishidifu e2dd59e89e fix: Gitea工作流改用服务器已装JDK+Maven二进制,绕开dnf拉java-17冲突
Auto Release / build (push) Failing after 2m19s
2026-07-19 00:46:09 +08:00
youpaishidifu d66a792a2e 更新 README.md
Auto Release / build (push) Failing after 8s
2026-07-19 00:03:51 +08:00
youpaishidifu 87d190a013 Gitea工作流改用self-hosted + --no-docker模式
Auto Release / build (push) Has been cancelled
2026-07-18 23:37:29 +08:00
youpaishidifu 481e2ea90b 简化Gitea工作流:移除外部action依赖,使用原生命令构建和API创建release
Auto Release / build (push) Has been cancelled
2026-07-18 23:26:03 +08:00
youpaishidifu 4b19b65a84 简化Gitea工作流:单job + ubuntu-latest + JDK 21
Auto Release / build-and-release (push) Failing after 4s
2026-07-18 23:01:41 +08:00
youpaishidifu 7f425f2c9f Gitea Actions: 硬编码token用于release创建
Auto Release / build (push) Failing after 8s
Auto Release / release (push) Has been skipped
2026-07-18 22:49:55 +08:00
youpaishidifu 4a03608540 更新Gitea Actions工作流:添加release job,使用gitea release-action
Auto Release / build (push) Failing after 1m30s
Auto Release / release (push) Has been skipped
2026-07-18 22:35:34 +08:00
youpaishidifu 60504ebdf1 修复Release工作流:JDK 26 + zulu发行版,Gitea改用self-hosted runner
Auto Release / build (push) Failing after 32s
2026-07-18 22:01:09 +08:00
youpaishidifu 79e6e8354b 清理JVM崩溃日志并添加gitignore
Auto Release / build (push) Has been cancelled
2026-07-18 21:53:45 +08:00
youpaishidifu b890480a55 简化Release工作流:先确保构建通过
Auto Release / build (push) Has been cancelled
2026-07-18 21:53:11 +08:00
youpaishidifu 104286a66e 修复自动Release工作流:GitHub用标准action,Gitea用专用release-action
Auto Release / build-and-release (push) Has been cancelled
2026-07-18 21:49:03 +08:00
youpaishidifu 996fd41d8a 添加GitHub/Gitea自动release工作流:每次push到main分支自动构建并发布JAR
Auto Release / build-and-release (push) Has been cancelled
2026-07-18 21:32:23 +08:00
youpaishidifu c6e4e0a520 邮箱校验拒绝时显示允许的后缀列表:新增getAllowedSuffixHint()方法,3处校验点全部附带提示 2026-07-18 18:42:16 +08:00
youpaishidifu 3f7f7f6c33 移除敏感文件deploy_sftp.py
移除敏感文件deploy_sftp.py
2026-07-18 18:36:26 +08:00
youpaishidifu 1f4f9e343a 修复email复读机+配置文件自动补全不生效:1.给独立/email和/绑定命令添加处理器 2.saveSmtp保留未知key 3.writeLines renameTo失败时fallback到copy+delete 2026-07-18 18:28:44 +08:00
youpaishidifu b96cf8c664 修复邮箱黑白名单不生效+email复读机bug:loadMap支持多行值(裸行续行),email命令添加args长度检查,移除重复email处理器死代码 2026-07-18 18:11:00 +08:00
youpaishidifu 9d52051023 修复SMTP配置加载:移除重复loadMap调用,确保文件不存在时也能正确加载默认值,添加详细日志输出 2026-07-18 17:40:38 +08:00
youpaishidifu d96f16e337 修复SMTP配置热更新问题:加载时自动补全缺失配置项,支持UTF-8 BOM,添加email reload命令 2026-07-18 17:03:28 +08:00
youpaishidifu 535d32f2bb 新增邮箱黑白名单校验功能:SMTP配置文件重命名为邮件配置,分为SMTP信息区和运行模式区,默认不校验,白名单模式仅允许列表后缀,黑名单模式禁止列表后缀 2026-07-18 16:40:53 +08:00
youpaishidifu c79924f67d refactor: 移除/mscode命令,正版验证全部交web后端接管
- 移除/mscode命令(plugin.yml + onCommand + executor注册)
- 移除extractCodeFromInput()方法(仅/mscode使用)
- 移除verifyMinecraftAuthCode()方法(仅/mscode使用)
- Java端仅负责:创建session → 轮询PHP状态 → 自动登录
- 所有授权码输入由PHP粘贴页全权处理(自动提交)
- 解决MC聊天框256字符硬限导致URL截断的根本问题
2026-07-17 20:55:31 +08:00
youpaishidifu f5a3dfbda5 fix: AreaGUIManager硬编码URL改为从插件配置读取
- AreaGUIManager删除用户组时通知PHP同步,URL和密钥之前写死为caoyuan.ypshidifu.cn
- 现在改为从webManager.getWebBaseUrl()和getSecretKey()读取,支持其他服主自定义web后端
- extractCodeFromInput增强支持#code=fragment形式(上一轮修复)
- verifyMinecraftAuthCode加debug日志便于排查
- deploy_sftp.py备份后缀改为.jar.old/.old.N(不以.jar结尾,避免MC加载冲突)
- .gitignore添加hs_err_pid*.log
2026-07-17 19:40:01 +08:00
youpaishidifu c568703996 fix: 自动正版验证移至verifyPremiumAsync回调中触发 2026-07-17 17:16:41 +08:00
youpaishidifu 3251a48b99 feat: 玩家加入后延时5秒自动触发正版验证 2026-07-17 16:49:46 +08:00
youpaishidifu f83458fd4b 修复正版验证三大问题:1)paste_code_page Content-Type改为text/html修复HTML源码输出 2)/mscode支持粘贴完整URL自动提取code参数 3)HTTP错误处理改进,非200也返回body显示PHP错误信息 4)verify_code改为JSON格式POST 2026-07-17 16:13:40 +08:00
youpaishidifu c3d2f81e9e 添加/mscode命令:支持粘贴Microsoft OAuth授权码(公共客户端流程) 2026-07-17 14:02:08 +08:00
youpaishidifu f8b44dd97b 修复自动登录安全漏洞:移除verifyPremiumAsync自动登录路径
问题:离线模式服务器中,所有玩家UUID都是离线格式。
旧代码通过Mojang API检查名字是否存在,如果存在就autoLogin。
这意味着盗版客户端只要用正版玩家的名字就能跳过登录。

修复:
- 移除verifyViaPhpBackend和fetchMojangUuid
- 离线UUID玩家一律视为非正版,需手动登录或OAuth验证
- 保留3条安全的自动登录路径:
  1. 检查点0: isVerifiedPremiumPlayer (OAuth验证)
  2. 检查点1: hasJavaLogin (5分钟内重连)
  3. 检查点2: isWebLoginVerified (Web密码验证)
2026-07-17 11:06:03 +08:00
youpaishidifu ab4fe441fe 新增Microsoft OAuth正版验证功能
1. PHP端新增minecraft_auth.php,实现完整的Microsoft OAuth流程
   - 支持Microsoft账号登录验证
   - 获取Minecraft Profile(UUID、用户名)
   - 验证状态持久化到SQLite
   - 生成可点击的授权URL

2. Java端新增/mslogin和/正版命令
   - 调用PHP后端获取授权URL
   - 显示可点击链接给玩家
   - 轮询PHP检查验证状态
   - 验证成功后自动登录

3. 修改VerificationManager,集成PHP后端验证
   - 优先通过PHP验证(支持Microsoft OAuth)
   - PHP不可用时降级到Mojang API
   - 支持已验证正版玩家自动登录

4. 新增verifiedPremiumPlayers内存存储
   - 服务器重启前有效
   - 支持快速重连无需重复验证
   - 下线时保留验证状态

5. 新增plugin.yml命令定义
   - /mslogin: 英文版正版验证
   - /正版: 中文版正版验证
2026-07-17 10:22:31 +08:00
youpaishidifu 317d1fdbcc 注释快速补单日志减少噪音 2026-07-16 15:38:52 +08:00
youpaishidifu 7ba43d820a 修复领地权限拦截:豁免VISITOR(领地授权成员)在非安全权限检查 2026-07-16 14:37:56 +08:00
youpaishidifu a5edbfe314 修复: Java doGet超时30秒+poller详细日志
- doGet超时从10秒增加到30秒,确保poller有足够时间
- poller调用添加耗时统计和详细日志
- 修复pullPendingTransactions中的语法错误
- 解决: Java调用poller超时导致补单失败
2026-07-15 20:25:01 +08:00
youpaishidifu e791548e87 doTransactionPollCheck添加高频补单触发器(15秒间隔)
- WebManager: doTransactionPollCheck每~5秒触发poller_online.php(限流15秒)
- 解决补单延迟(原PollC~30-60秒才触发)

关闭 #1059
2026-07-15 18:01:06 +08:00
youpaishidifu 501aa40677 feat: Web在线充值(recharge)分支 — 支付平台0.01元测试到账
- processWebTransaction 新增 type=recharge/pay_recharge 专用分支
- 支付成功后 addBonds 发放债券 + 游戏内即时通知
- 复用现有 ~5s 轮询 + ackTransaction 防重 + confirmed_transactions 双保险
2026-07-14 20:55:04 +08:00
youpaishidifu 181c2acb34 chore: 关闭背包翻译的INFO日志刷屏
- 移除MaterialTranslator.translate()和translateWithFallback()中的info日志
- 保留warning仅用于翻译源异常降级场景
- 控制台不再被每条物品的翻译缓存/命中日志淹没
- 旧版: Sdf1_login.jar.old.9
2026-07-14 19:00:19 +08:00
youpaishidifu bf0688b291 feat: 背包备份物品名走官方翻译+机翻兜底
- translateMaterial() 由硬编码替换为 MaterialTranslator.translate()
- 与领地方块记录系统一致: 优先查 zh_cn.json 官方翻译, 机翻兜底
- 旧版: Sdf1_login.jar.old.8
2026-07-14 18:48:15 +08:00
youpaishidifu 765fbd74c1 fix: 交易拉取即贴标导致自己跳过处理
根因: ackTransaction()将txId加入confirmedTxIds内存set,
processWebTransaction()检查到set包含该ID就跳过处理。
交易还没处理就被自己标记的内存set跳过了。

修复: ackTransaction()不再写confirmedTxIds,
仅写PHP confirmed_transactions表。
confirmedTxIds仅在confirmTransaction()处理完后才写入。
旧版: Sdf1_login.jar.old.7
2026-07-14 17:03:12 +08:00
youpaishidifu d2180cec1c feat: 告示牌放置/编辑监控记录
1. land_block_log表新增detail列(已有数据库ALTER兼容)
2. DatabaseManager新增recordLandBlockWithDetail方法
3. LandRecordManager新增recordSignEdit方法,记录sign_place/sign_edit动作+内容
4. AreaProtection.onSignChange()延迟1tick记录(等事件处理完)
5. 回声碎片显示告示牌内容: 玩家XXX在HH:MM放置/编辑告示牌 [内容]
2026-07-14 16:47:57 +08:00
youpaishidifu 9ccda3f541 fix: ack_transaction补tx_type + 告示牌权限豁免授权成员
1. WebManager.ackTransaction()增加txType参数, bodyJson发送tx_id+tx_type
2. AreaProtection.onSignChange(): 白名单内授权成员跳过denySignEdit检查
   PermissionLevel无MEMBER枚举, 授权成员=白名单VISITOR, 通过isPlayerWhitelisted判定
2026-07-14 15:32:52 +08:00
youpaishidifu 401fa2453a fix(area-gui): 告示牌权限动态名称:允许/禁止编辑告示牌 2026-07-14 12:55:40 +08:00
youpaishidifu 6946cfef6d feat(area-gui): CLI/GUI菜单添加禁止编辑告示牌权限条目 2026-07-14 12:42:49 +08:00
youpaishidifu 89fa765351 fix(web): 拉取交易即刻贴标ack,防止SSL断路器后重复扣费 2026-07-14 11:27:46 +08:00
youpaishidifu a118aac18c feat(land): 添加告示牌编辑独立权限和红石状态记录 2026-07-14 10:39:59 +08:00
youpaishidifu da4299cc2f 分离登录备份和自动备份显示:backup/back命令分两组列出,L前缀=登录备份,A前缀=自动备份 2026-07-13 18:16:33 +08:00
youpaishidifu 112f992fda 修复getInventoryBackups/getInventoryBackupById查错表:inventory_backups→inv_backup_auto,导致自动备份数据查不到 2026-07-13 17:57:11 +08:00
youpaishidifu 3674e1e950 修复backup子命令权限:仅OP/插件管理员/控制台可执行,支持查询他人备份 2026-07-13 17:04:46 +08:00
youpaishidifu abdd3ad7c7 修复滚动备份日志追踪+告示牌编辑权限控制 2026-07-13 16:51:35 +08:00
youpaishidifu a3c8cec664 官方翻译纯同步模式:移除异步刷新分支,部署脚本附带上传zh_cn.json缓存 2026-07-13 15:56:22 +08:00
youpaishidifu 5a0166e90c 修复官方翻译首次部署时空map问题:缓存不存在时改为同步下载,确保init返回前map就绪 2026-07-13 15:31:59 +08:00
youpaishidifu 6c6cc94905 翻译链路日志:OfficialTranslations.lookUp + MaterialTranslator.translate/translateWithFallback 每步打印日志,跟踪翻译路径 2026-07-13 15:18:56 +08:00
youpaishidifu f5a5b28f61 添加官方中文翻译源:从Mojang API下载zh_cn.json作为优先翻译,机翻兜底 2026-07-13 15:02:59 +08:00
youpaishidifu e3b459a00c 修复容器记录仅显示打开:补充存入(put)+拿取(take)差量记录,容器类型纳入翻译 2026-07-13 14:38:50 +08:00
youpaishidifu 3ef7fbdb7a 修复容器记录+翻译集成:记录移到权限检查前,queryAndPrint异步批量翻译输出中英双语 2026-07-13 14:12:47 +08:00
youpaishidifu 3795dd90eb 修复CLI菜单权限显示:管理员只显示玩家发光,隐藏全部权限配置 2026-07-13 13:56:44 +08:00
youpaishidifu 513e4c56ad 修复拾取/丢弃权限:per-player独立授权键名映射(allowPickup↔denyPickup) 2026-07-13 13:43:16 +08:00
youpaishidifu 8138b97b59 领地记录物品名多源翻译:有道+简心+Google三级降级 2026-07-13 13:15:01 +08:00
youpaishidifu 697b1069a2 PVP四项改进:回声碎片CLI入口/击杀≥10奖励盔甲药水/世界击杀纳入总榜/选装前无敌 2026-07-13 12:28:13 +08:00
youpaishidifu c7cdb60b47 修复边框粒子卡死主线程:动态密度控制+粒子总量上限 2026-07-13 12:00:27 +08:00
youpaishidifu 7489c60286 新增领地方块/容器记录系统(回声碎片)与背包滚动自动备份
- 新增 land_block_log、land_container_log、inv_backup_auto 三张表
- LandRecordManager:领地内方块破坏/放置、容器打开/拿取自动记录;回声碎片工具右键/左键查询坐标历史记录
- AreaGUIManager 主菜单新增回声碎片按钮(位置33)
- InventoryAutoBackup:每15分钟滚动备份在线玩家背包(每人保留3份),跳过PVP竞技场/练习场
- 每日定时清理超过7天的领地记录
- AreaProtection 钩子接入记录;注册 /landrec 命令
2026-07-13 10:26:05 +08:00
youpaishidifu 8ce2e727bb 领地改名和入场退场消息接入URL过滤逻辑
- 领地改名:输入新名字前先检测URL,包含链接则拒绝且不计入改名次数
- 入场/退场消息:设置消息前先检测URL,包含链接则拒绝且不保存
- 复用ChatFilterManager.extractUrls()统一过滤逻辑
2026-07-13 09:43:21 +08:00
youpaishidifu e7402985d5 修复封禁广播Long拆箱NPE + syncBans/syncAdmins改POST避免URL超长
- handleBanCommand中long expireMs→Long expireMs,避免null拆箱异常
- syncBans/syncAdmins从httpGet改为httpPost,防止55条封禁数据URL过长
- sync.php已部署含sync_bans/sync_admins case的版本到线上
2026-07-13 00:35:09 +08:00
youpaishidifu c0ab91edba R23-2: 封禁同步彻底修复 - syncBans独立hash+控制台ban监听+插件ban即时同步PHP 2026-07-12 23:45:59 +08:00
youpaishidifu 529b83666c R23: 修复4项问题 - 改名冷却逻辑(仅达上限才提示冷却) + 拦截原版/ban、/ban-ip命令广播封禁警告 2026-07-12 23:08:32 +08:00
youpaishidifu 569611c40f 新增3个功能:领地改名冷却、封禁名单联控、封禁广播警告
1. 领地改名冷却机制:1小时限5次,自然滚动窗口
   - AreaProtection: renameLand/renameCooldownMap/getRenameCooldown/getRemainingRenames
   - CLI: /protect cli rename <领地名> 交互式输入
   - GUI: Name Tag按钮(slot 33)显示冷却状态
   - 改名更新DB+内存+触发PHP同步

2. PHP用户管理显示封禁名单(联控Java BanList)
   - WebManager.syncBans(): 收集Bukkit名字/IP封禁→推送到PHP
   - PHP: web_player_bans表 + handleSyncBans + list_bans API
   - admin.php: 用户行红色+禁图标标注被封禁玩家

3. 封禁事件公屏广播警告
   - Main.applyBan(): 封禁后自动广播
   - broadcastBanWarning(): 玩家名+IP(如IP封禁)+理由+到期时间
   - loadBanWarningMessage(): 从释放警告消息.txt随机读取标语
   - 支持永久封禁/限时封禁/IP封禁3种场景
2026-07-12 22:08:38 +08:00
youpaishidifu 61e4ec967f 修复公告消息取消功能:取消后返回主菜单而非编辑子页
cancel路径showAnnouncementManagement(p, landName, 2/3)会重新设置pending输入状态,导致取消后仍停留在等待输入状态。改为showAnnouncementManagement(p, landName, 1)返回主菜单
2026-07-12 21:07:06 +08:00
youpaishidifu 57788016a4 修复占位符括号+tag管理员CLI权限视图
1. 变量替换正则新增{}花括号支持,支持领地名和领地两种写法
2. CLI showPlayerPerm:tag管理员(isAdmin)查看管理员成员时看到完整权限编辑视图,不再简化;可切换管理员身份
2026-07-12 20:58:21 +08:00
youpaishidifu ad935cbe09 修复4个bug:权限/颜色渲染/换行符/取消功能
1. toggleAdmin:插件管理员可操作非自身领地的管理员身份
2. formatAreaMsg颜色渲染:convertHtmlTags新增activeColor追踪,标签关闭§r后自动恢复颜色码,解决<span style=color>嵌套<b><u>标签时颜色在闭合处截断的问题
3. 变量替换正则改为Pattern.compile+DOTALL,确保跨行匹配
4. handleAnnouncementInput新增取消/0支持,输入取消或0时保持原样
2026-07-12 20:32:50 +08:00
youpaishidifu ae94f8edef 插件管理员可协助管理其他领地成员权限
- GUI领地列表:tag管理员可见所有领地,点击索引修正(allManageableLands替代getLandsByOwner)
- openPlayerPerm:tag管理员(isAreaAdmin)看到完整权限编辑视图,不再简化
- CLI showPlayerPerm:设为管理员按钮对tag管理员也可用
- WebManager:新增syncAdmins(),将tag管理员名单同步到PHP的web_plugin_admins表

需要PHP端配合:land_api.php新增canManageLand/isPluginAdmin逻辑
2026-07-12 19:45:51 +08:00
youpaishidifu 9af6c0cca0 feat: GUI领地公告出入消息配置 + 增强HTML语义解码
1. GUI设置面板新增进入消息(位置19)和离开消息(位置21)编辑入口,点击关闭GUI后聊天栏输入
2. AreaCLIManager新增setPendingAnnouncementInput供GUI调用
3. 新增decodeHtmlEntities: &amp; &lt; &gt; &quot; &apos; &#39; &nbsp;等HTML实体解码
4. 新增processColorTags: 支持<span style='color:#xxx'>和<p style='color:xxx'>颜色标签
5. htmlToMc增强: 支持<strong><em><ins><del><strike>等语义标签
6. 新增convertHtmlColorToMc/convertRgbToMc: HTML颜色名/十六进制/rgb()→MC颜色代码
2026-07-12 18:41:48 +08:00
youpaishidifu 49ad056a71 fix: 退出领地立即清除效果,不再依赖5秒延时
根因:playerMarkedEffects标记不可靠(hotUpdateRegionEffects会清空、
applyRegionEffects可能跳过添加),导致离开时marked为空→无延时任务→效果残留

修复:
1. 移动退出到野外:直接从oldAc.giveEffects读取效果列表,立即removePotionEffect
2. 传送到野外:同上逻辑
3. 领地→领地跨区域仍保留5秒延时差量清理
2026-07-12 18:27:38 +08:00
youpaishidifu c0bb962a11 feat: Token泄露PHP联控作废 + 领地公告变量替换
1. Token安全防线:Java revokeToken异步调用PHP sync.php revoke_weblogin_token端点,从weblogin_tokens表删除
2. PHP新增revoke_weblogin_token action:验证SECRET_KEY后删除weblogin_tokens中的记录
3. 领地公告变量替换:支持(玩家)/(领地)及[]{}【】<>《》等括号格式
4. 默认消息:进入=欢迎(玩家)来到(领地),离开=感谢(玩家)光临(领地)
5. 公告管理菜单显示变量说明和支持的括号格式
2026-07-12 17:43:23 +08:00
youpaishidifu 0db237ed04 feat: 公屏泄露webtoken自动作废安全防线
1. 玩家在公屏发送有效的48位hex token时,立即作废该token
2. 向玩家发送安全警告,提示不应分享token
3. WebManager新增revokeToken/hasToken方法
2026-07-12 17:05:56 +08:00
youpaishidifu 5d994c0dad feat: 领地边框粒子特效 + 强制游戏模式仅管理员可用
1. 进入/离开领地时展示3秒边框粒子(青色渐变DUST_COLOR_TRANSITION + 四角END_ROD高亮)
2. /protect info执行时展示5秒领地边框粒子
3. GUI强制游戏模式开关改为仅服务器管理员可操作(领地主不可操作)
2026-07-12 16:45:22 +08:00
youpaishidifu 0ff79aa959 实现在移动事件中实时热更新增益效果:新增hotUpdateRegionEffects方法,玩家在领地内移动时每2秒自动对比效果配置,实时增删缺失/多余效果,无需退出重进 2026-07-12 14:52:49 +08:00
youpaishidifu fa4402d426 修复增益效果不生效+完善所有药水效果中英文映射:resolveEffectType改用PotionEffectType常量替代不可靠的getByName兜底,支持所有MC版本 2026-07-12 13:03:48 +08:00
youpaishidifu 2fb801a59b 修复再生效果不生效:resolveEffectType缺少再生/回复中文别名映射 2026-07-12 12:19:24 +08:00
youpaishidifu 69868a4c5c 修复PHP添加增益效果后游戏内等级错误:applyFieldToAreaConfig的give_effects改用parseEffectsString替代已损坏的parseJsonArray 2026-07-12 11:41:26 +08:00
youpaishidifu 4888bb414e 修复: syncLandData权限hash漏算permissions导致成员权限变更不触发同步
perms hash 段原仅含 land_id/player_name/role,未包含 permissions 字段内容,
导致游戏内修改成员权限后 hash 不变、不触发 syncPermissions 推送到 PHP。
现把 permissions 内容加入 hash 计算(含 | 分隔符),成员权限变更可正确同步。
2026-07-12 10:29:43 +08:00
ypshidifu 890c637373 修复: PHP→Java双重JSON编码导致领地名反斜杠 + 防御性清除领地名特殊字符
- AreaProtection: updateVisitorPermFromWeb入口处清除领地名中的反斜杠
- WebManager: perm_change/add_visitor/remove_visitor/perm_clear四个case入口统一清洗land_name
- 根因: PHP json_encode双重编码产生\uXXXX, decodeUnicodeEscapes解码后残留反斜杠
- getLandIdFromDb用带反斜杠的名字查DB返回-1,导致整个降级逻辑被跳过
2026-07-12 01:05:27 +08:00
youpaishidifu 912bd62201 R16: 修复回调重试+SSL降级: callbackAddVisitorToPHP失败时尝试HTTP降级 2026-07-11 18:10:21 +08:00
youpaishidifu 0bd4d454c3 R15: PHP添加成员改为Java验证后入库(参考改领地主流程)
- add_visitor处理先验证玩家是否存在(login.db),存在则入库+回调PHP成功,不存在则回调PHP失败
- 新增callbackAddVisitorToPHP方法回调PHP添加成员结果
2026-07-11 15:53:00 +08:00
youpaishidifu bf32e528f1 R14: 修复Java→PHP权限同步缺失(hash不含权限数据导致跳过同步)
- syncLandData hash加入权限数据特征,确保权限变化时触发同步
2026-07-11 15:10:51 +08:00
youpaishidifu 5ef86ddfe8 R13: 修复addvisitor校验+PHP权限同步完整流程
- /protect addvisitor 命令添加玩家真实性校验(login.db检查)
- addvisitor/handleAddMemberInput 同时写入area_land_permissions表
- perm_change handler 确保玩家加入areaPlayerWhitelist(解决PHP同步不显示问题)
- PHP端handleChangeVisitorRole 新成员改走add_visitor流程(确保Java白名单同步)
2026-07-11 14:19:01 +08:00
youpaishidifu e08a6bc4c7 修复领地权限系统4大bug:DB列缺失+成员不同步+管理员继承+login.db校验
1. AreaProtection.updateVisitorPermFromWeb: INSERT语句去掉不存在的land_name/synced_at列,修复"table area_land_permissions has no column named land_name"
2. AreaProtection.removePlayerFromAreaWhitelist: 移除成员时同步删除area_land_permissions记录,确保admin身份一并清除,不再继承
3. AreaProtection.removeLandMember: GUI移除成员也同步删除area_land_permissions
4. AreaProtection.insertLandPermission: 新增公共方法,PHP添加成员时写入权限表
5. AreaProtection.handlePendingAddMemberInput: 添加成员前校验login.db(DatabaseManager.userExists),拒绝不存在的玩家
6. WebManager: 新增add_visitor/remove_visitor变更类型处理,PHP添加/移除成员后Java自动同步白名单+权限表
2026-07-11 13:36:26 +08:00
youpaishidifu 5ec61a1d2e 修复PHP→Java权限同步断裂:内存缓存不刷新+syncLandData改POST
1. AreaProtection.updateLandFieldFromWeb: 新增applyFieldToAreaConfig()直接更新内存AreaConfig(原reloadAreaConfigFromDb只重载area_config全局表,不碰area_lands领地权限)
2. WebManager.syncLandData: GET改POST(JSON body)解除URL长度限制
3. WebManager.syncLandData: hash补全遗漏字段(9个)确保所有变更能触发同步
2026-07-11 12:21:32 +08:00
youpaishidifu 8c1e1a89bd 修复PHP↔Java领地同步断裂:GET改POST+补全hash漏检字段
根因:syncLandData用GET发送全部领地JSON到sync.php,超URL长度限制→静默失败→PHP永远收不到Java的权限/效果变更。

1. WebManager.syncLandData: 领地同步从httpGet改为httpPost(JSON body),解除URL长度限制
2. WebManager.syncLandData: hash补全遗漏字段(allow_visitor_teleport/confiscate_items/deny_use_items/punish_commands/peace_whitelist/mode_exempt/enable_announce/announce_template/txt_content),确保这些字段变更能触发同步
3. PHP sync.php syncLands(): 已兼容POST JSON body(getParam从php://input读取)
2026-07-11 11:57:22 +08:00
youpaishidifu bc155526ab 止住效果解析刷屏:加强JSON解析、去除残留反斜杠、同一名称只警告一次
1. AreaProtection.resolveEffectType: 解析前去除首尾空白/引号/反斜杠,识别\试炼之兆等残留转义名
2. AreaProtection.splitToList/parseEffectsString: 用正则提取JSON字符串并unescape \ " \/ \b \f \n \r \t 及 \uXXXX
3. 新增UNKNOWN_EFFECT_WARNINGS集合,同一名称只打印一次[防护]无法识别效果,避免控制台持续刷屏
2026-07-11 11:11:59 +08:00
youpaishidifu 4ae1589883 修复Web通信禁用仍轮询+PVP远程武器与击杀奖励+修复splitToList编译错误
1. WebManager: 5个合并定时器(A/B/C/D/E)新增enabled运行时守卫,Web通信关闭后立即停止请求web配置地址;reloadWebConfig支持运行时启停定时器
2. PVPTestManager: 测试场新增弓/弩二选一(默认弓)默认装备,完成一波击杀发放16牛排补血
3. PVPArenaManager: 正式场新增弓/弩二选一(默认弓)默认装备+GUI切换,每2次杀敌发放16牛排补血
4. AreaProtection: 修复splitToList中[\""未结束字符串字面量导致的编译失败
2026-07-11 01:04:32 +08:00
youpaishidifu 9121bb069d 修复PHP权限同步游戏:1.updateLandFieldFromWeb白名单补充is_public_building/allow_visitor_teleport;2.updateVisitorPermFromWeb支持PHP显式角色切换(admin↔member↔visitor) 2026-07-10 23:16:32 +08:00
youpaishidifu d306431c55 修复两大关键bug:1.saveAreaToDb参数绑定从位置32开始整体偏移一位导致权限保存到错误列(权限回滚根因);2.onDisable取消所有定时任务解决async task未关闭警告 2026-07-10 21:32:08 +08:00
youpaishidifu 0354156eec 新增载具乘坐拦截:VehicleEnterEvent处理器,层级为L2实体交互→L3骑乘(denyMount),与放置/破坏权限统一 2026-07-10 21:01:19 +08:00
youpaishidifu aa8b7ce47a 修复实体破坏防护:新增载具与悬挂物权威破坏事件拦截(VehicleDestroyEvent/HangingBreakEvent),统一 L1破坏总开关→L2实体交互 层级,区域取法统一为实体坐标优先 2026-07-10 19:55:56 +08:00
youpaishidifu 483acbdbb6 修复领地权限系统:新增实体交互二级权限,并修复领地数据保存因重复列导致落库失败的问题 2026-07-10 19:08:18 +08:00
youpaishidifu 0775f2e84d fix(core): restore PHP sync disabled for debugging; root cause confirmed as INSERT OR REPLACE id change 2026-07-10 15:29:03 +08:00
youpaishidifu ebd26ee123 fix(core): saveAreaToDb 的 INSERT OR REPLACE 导致自增 id 变更,切断 area_land_permissions.land_id 引用 2026-07-10 15:22:47 +08:00
youpaishidifu 0ff274896a 修复测试场/back返回不刷怪:startTest未写入chosenDiff/chosenEquip;注释领地PHP同步排查权限强制同步bug 2026-07-10 13:42:23 +08:00
youpaishidifu f345f60ac0 修复PVP测试场强制选装:进入测试世界必须选装备,关闭2次GUI遣返主世界;/back禁止传送至测试世界 2026-07-10 13:17:00 +08:00
youpaishidifu babbe8936a 修复: PVP/测试场护甲穿戴顺序修正+强制穿戴到装备槽
- PVPTestManager.giveEquip: ARMOR_SETS[头盔,胸甲,护腿,靴子] 反转为 setArmorContents 期望的 [靴子,护腿,胸甲,头盔]
- PVPArenaManager.equipFullSet: addItem → setArmorContents,护甲自动穿戴到装备槽而非放在物品栏
2026-07-10 12:52:44 +08:00
youpaishidifu 0e0d6c6247 修复: 领地管理员永不被PHP perm_change降级 + 测试场KEEP_INVENTORY=false
- AreaProtection.updateVisitorPermFromWeb: admin角色永远保留, perm_change只更新permissions JSON不动role
- PVPTestManager: KEEP_INVENTORY从true改为false, onPlayerDeath不再直接clearInventory, 改由onRespawn统一还原
2026-07-10 12:18:46 +08:00
youpaishidifu c6d0222c1e 修复: setPlayerPermJson保留已有角色+PVP测试场死亡清空装备
- AreaProtection.setPlayerPermJson: ON CONFLICT替代INSERT+UPDATE两步, 新行默认visitor(非member), 已有行仅更新permissions保留role
- PVPTestManager: 新增onPlayerDeath清空测试装备掉落+setKeepInventory(false), 死亡界面不再显示测试装备
  防止玩家死亡后快速重连/返回时携带测试场装备
2026-07-10 11:58:23 +08:00
youpaishidifu 2bdb577c89 修复: 领地权限拦截船/矿车等实体放置和破坏 2026-07-10 11:23:10 +08:00
youpaishidifu b29284bb1b 修复: Java端调整访客权限误降级管理员+PVP/测试场装备隔离
- AreaProtection.updateVisitorPermFromWeb: 拦截PHP发来member角色时降级admin
- Web/api/sync.php: syncPermissions ON CONFLICT替代INSERT OR REPLACE防角色降级
- Web/api/land_api.php: handleSyncPermissions同上
- PVPTestManager: respawn立即恢复背包, join时空降检测恢复
- PVPArenaManager: 入场立即备份清空背包, respawn时恢复+送出PVP世界
2026-07-10 10:58:55 +08:00
youpaishidifu 97d96d747d 测试场GUI改直选式+领地权限降级拦截与全链路日志+backup保留3份修复
- PVPTestManager: openGUI/handleClick 重写为直选式(武器/护甲/难度/场景/食物直接点选),对齐正式场 PVPArenaManager
- AreaProtection: updateVisitorPermFromWeb 加端到端 info 日志(land/player/localRole/effectiveRole),追踪已授权用户被同步访客问题
- land_api 联动: handleChangeVisitorRole 修复$permRow未定义,新增 admin/member 降级为 visitor 的拦截
- WebManager: perm_change 路由按 changeData 是否含 player 区分 target/player
- DatabaseManager: 背包备份由子查询LIMIT改为先查后删,修复仅保留最新一份
2026-07-10 01:33:38 +08:00
youpaishidifu 2c9aa6bd8e 修复领地主调整访客权限时管理员身份丢失:Web端perm_change现同步role到本地,且updateVisitorPermFromWeb不降级已授权管理员 2026-07-10 00:15:53 +08:00
youpaishidifu 89b3167d67 修复附魔书显示名罗马数字V解析失败:ROMAN_PAT无法匹配单独V,导致锋利V等五级附魔书被发成一级 2026-07-09 23:21:27 +08:00
youpaishidifu 2e869a1c94 修复测试场和平模式不刷怪:测试场难度提为非和平改为权威helper(创建/开始/离场三处调用),不再依赖startTest入口 2026-07-09 23:15:08 +08:00
youpaishidifu 9d8d70f63f 修复竞技场断线重进绕过公平限制(上线遣返出生点);backup命令放开控制台执行(支持指定玩家);测试场装备自由选武器/护甲档位;食物shift点击±16 2026-07-09 22:59:48 +08:00
youpaishidifu e03db026b9 修复PVP竞技场/测试场掉线覆盖主背包:主备份改保留最近3份且竞技场内跳过写入,新增/sdf1_login backup玩家自助还原(最近3份任选) 2026-07-09 22:29:02 +08:00
youpaishidifu 36b8192dc9 测试场装备对齐主场(可调熟牛肉/去金苹果)并禁止玩家互殴,只能打练习生物 2026-07-09 22:28:56 +08:00
youpaishidifu 2c12bbde8b PVP装备系统改版:铁剑+铁斧+可选盾牌+可选附魔(锋利V+击退II),移除护甲;PVP竞技场首位玩家定死附魔后续仅可调盾牌与熟牛肉数量;测试场全员自由选 2026-07-09 21:59:11 +08:00
youpaishidifu 9b725791aa 封堵容器管理禁用时访客越权开箱
onInventoryOpen 在 denyContainer=false 时直接放行所有人,
导致完全无领地权限的外部玩家也能开箱。
现于 denyContainer 判定前拦截 getPermissionLevel 返回 null 的未授权玩家,
一律禁止访问容器;VISITOR 成员行为不变。
2026-07-09 20:58:02 +08:00
youpaishidifu 0b8feab3e5 修复领地管理员越权:setadmin改为限领地+OP认全局
- 根因:/protect setadmin|unsetadmin 给玩家加全局 ScoreboardTag(areaProtectAdminTag),使其在所有领地拥有管理员权限(跨领地越权);而 isAreaAdmin 又不认 OP,导致 OP 反而无全局权限
- 修复1: isAreaAdmin(Player) 增加 player.isOp() 判定,OP 即真正的全局领地管理员
- 修复2: setadmin/unsetadmin 改为限领地: /protect setadmin <领地名> <玩家> 仅在该领地写 role='admin'(DB),不再授予全局标签;unsetadmin 同理并兼容清除旧版遗留的全局标签
- getPermissionLevel 第3步 isLandAdmin(ac.name,...) 本就限领地,无需改动;GUI toggleAdmin 路径(归属校验+限领地)本就干净
- 注释更新:领地管理员严格按领地限定,绝不跨领地生效
2026-07-09 20:45:27 +08:00
youpaishidifu 4c100a0c9f PVP测试场:改为全清后才间隔10秒刷,全清给可点击回床链接
- 撤掉每10秒无脑刷的全局定时器,改为事件驱动:监听 EntityDeathEvent 反查练习生物所属玩家
- 玩家清空当前波次(无存活练习生物)后:发送提示 + 可点击超链接[/pvp back 返回床],并排程10秒后刷下一波
- 新增 mobOwner 反查表 + pendingRespawn 防重复排程;退场(离开/死亡/退出/跨世界)均清理 pendingRespawn
- /pvp back 子命令:在测试场先清理还原背包,再传送至床(出生点),无床则回主世界出生点
- checkAllLeft 改为无人时还原测试场难度(和平模式临时提困难语义保持)
2026-07-09 20:30:38 +08:00
youpaishidifu 5fa12cddbf PVP测试场:修复和平模式刷怪 + 死循环刷怪 + 回血食物
- 和平模式修复:主世界为和平时,有人在场则临时把测试场难度提为困难(HARD),保证亡灵生物不被移除;主世界非和平则跳过
- 死循环刷怪:Bukkit runTaskTimer 每10秒一波,遍历所有在场玩家各补一波
  - 单场景(近战/远程)每组3只;混合场景每组6只(3僵尸+3小白)
  - 全部退场自动停刷怪并还原测试场难度为当前主世界难度
  - 加单玩家存活上限(30)安全阀,防止无限堆积卡服
- 回血食物:进入即发放金苹果x16 + 熟牛肉x32
- AttributeModifier 改用非过时3参构造(String,double,Operation)
2026-07-09 20:15:29 +08:00
youpaishidifu 0ae5c8d16e 改用无限耐久帽子免疫太阳灼烧(替换防火效果)
- 撤销练习生物 FIRE_RESISTANCE 效果(实测仍灼烧)
- 改为给每只练习生物(僵尸/小白)戴一顶 Unbreakable 皮革帽:
  原版机制下亡灵生物有帽子时太阳灼烧先耗帽耐久,帽子永不复破即永久免灼烧
- 用 Attribute.ARMOR 属性修正(-1)抵消皮革帽自带1点护甲,纯挡太阳不增加伤害减免
- 帽子 setHelmetDropChance(0) 不会被打掉
2026-07-09 19:56:55 +08:00
youpaishidifu d7afdd2a47 修复PVP测试场亡灵生物白天被太阳灼烧(SUNBURN)的bug
- 生成练习生物(僵尸/小白)时挂载长时效防火(PotionEffectType.FIRE_RESISTANCE)效果
- 防火直接免疫太阳灼烧的着火伤害,亡灵生物不再白天自燃
- 仅作用于测试场练习生物,不影响玩家与其他世界
2026-07-09 19:46:09 +08:00
youpaishidifu ac92cdb31e 新增PVP练习测试场(pvp_test独立世界)与完善/pvp命令补全
- 新增 PVPTestManager:首次加载预生成30x30露天FLAT世界(pvp_test),worldborder边界+玻璃围墙+关闭自然刷怪
- /pvp test 打开GUI面板选择难度(简单/中等/噩梦)与装备(近战/远程/混合)
- 开始:备份背包、发放装备、传送、生成40血僵尸/小白(近战=僵尸/远程=小白/混合=两者)
- 难度固定伤害:简单(僵尸4/小白2)、中等(僵尸4/小白3)、噩梦(僵尸8/小白7)
- 噩梦难度被玩家击中随机走位;测试场敌对生物互不攻击(取消mob→mob目标与伤害、小白箭不伤友军)
- /pvp命令补全补齐 join/leave/exit/test 一级子命令
- /pvp leave|exit 统一经 Main.leavePVP 路由(测试场优先,否则竞技场)
2026-07-09 19:36:29 +08:00
youpaishidifu 4812c093ea 将 /ban-ip 命令重命名为 /banip,避免与原版 ban-ip 冲突
- plugin.yml 命令名 ban-ip → banip,usage 同步更新
- Main.onCommand 分发与 doBanIp 用法提示同步改为 /banip
- ChatFilterManager 内转发原版 ban-ip 自动封禁的逻辑保持不变(本就调用原版命令)
2026-07-09 18:09:18 +08:00
youpaishidifu 9916968152 新增 ban-ip/tempbanip 封禁命令与 whois/who 查询命令
- 新增 /tempbanip:临时封禁IP或玩家,与 /tempban 共用实现
- 新增 /ban-ip:永久封禁IP或玩家(支持 永久/forever/perm 标识)
- /tempban 与 /tempbanip 统一支持名字或IP,无需专门区分地址类型
- 新增 /whois:玩家自查(注册时间/最近登录/登录IP/禁言状态/理由/效期),
  普通玩家仅可查自己,OP或插件tag管理员或控制台可查指定玩家,
  唯独控制台禁止不带参数
- 新增 /who:查看在线玩家列表,限 OP/插件管理员/控制台
- ChatFilterManager 新增 getMuteExpire 取当前仍有效的禁言到期时间戳
- 封禁与查询权限统一采用 isPrivileged(OP + 插件tag + 控制台)
2026-07-09 17:51:12 +08:00
youpaishidifu 41d8ffce3d 新增/tempban临时封禁、/chat mute禁言、异地登录检测与账号冻结解冻
- DurationParser: 通用时长解析(时间戳/日期/相对时长)
- /tempban: Bukkit原生封禁(1秒~10年)
- ChatFilterManager: 持久化禁言(muted_until)+loadMutes启动加载
- 异地登录检测: onJoin异步通知PHP比对IP归属(ip9.com.cn)
- freeze/unfreeze: web_admin_changes通道执行/解除Bukkit原生封禁
- PVPArenaManager: 移植PVP全服连杀/终结播报逻辑
- DatabaseManager/plugin.yml: 风控列 + tempban命令注册
2026-07-09 17:09:35 +08:00
youpaishidifu 2e417c67b3 调整商店出售限额为部分出售,并修复单件附魔书NBT重建
- 出售限额改为部分出售:超出限额只卖限额内部分,背包剩余不动
- sellItem/sellAllOf 引入 getMaxSellableUnits 计算可卖单位数
- 新增 removeItemsOfMaterial 按材质精确移除指定数量,保留背包剩余
- 修复单件PHP购买附魔书(itemStack为null)按商品ID权威重建NBT,避免整单退款
2026-07-09 13:59:11 +08:00
youpaishidifu 626fbcd591 附魔书NBT:移除空壳兜底,解析失败则整单取消并退款(含单笔购买退款) 2026-07-09 13:32:50 +08:00
youpaishidifu e4e74f1cb8 修复三处Bug:附魔书NBT空壳(ID权威重建)、PVP死亡榜世界未销毁不清战机、TPA processedRequests永不清导致秒过期 2026-07-09 13:15:57 +08:00
youpaishidifu 8fd5b06960 修复雪球菜单可被丢弃导致永久丢失的问题
- 新增 PlayerDropItemEvent 拦截:玩家主动丢弃菜单雪球时取消丢弃并提示,雪球留在手中
- 调整 EntityPickupItemEvent:仅阻止生物(如恶魂)拾取菜单雪球,允许玩家自身拾取,避免任何掉落路径导致永久丢失
2026-07-09 11:17:06 +08:00
youpaishidifu 70bbdba16d 修复附魔书材料匹配失败+购物车发货失败自动退款
- ShopManager: .md加载时对附魔书ID格式(material无效但id像MENDING_I)容错默认ENCHANTED_BOOK,不再静默跳过
- WebManager dispatchItemByMaterialOrId: 增加方式3附魔书兜底(名称含附魔关键词时直接getShopStackByName)
- WebManager shop_cart: 发货跟踪failedItems列表,任一商品失败→不confirm交易+writeRefundTransaction写回PHP退款
- WebManager: 新增writeRefundTransaction方法通过sync.php写退款记录
- PHP sync.php: 新增write_shop_refund action(接收Java退款请求,恢复bond_cache+写refund交易+标记原交易refunded)
- PHP shop.php buy_cart: 扣款前验证每件商品material有效性(防止扣款后无法发货)
2026-07-09 10:54:35 +08:00
youpaishidifu dfd8fee381 优化PVP世界创建:关闭结构生成+简化安全出生点,消除主线程长时卡服
- createPVPWorld: 加 c.generateStructures(false),去除村庄/神庙/矿洞等结构生成(出生点搜索时单区块十几秒卡死的主因);hill/water/cave/tree 自然地形特征不受影响

- forceSafeSpawn: 删除半径8广域扫描(289列同步区块生成),改为仅检查Minecraft已生成的(0,0)出生列,不安全才原地搭3x3小平台(O(1)零额外生成)

- 预生成半径 4->2(25区块),进一步缩短主线程占用
2026-07-09 00:37:12 +08:00
youpaishidifu 7a14457713 修复PVP装备确认后被误遣返及榜单残留
- onInventoryClose: 关闭事件判定同时检查 equipmentConfirmed,避免确认装备后 selectedTier 已移除导致误判未选择而遣返

- 遣返路径补 cleanupPlayerStats(player),修复回到主世界仍携带PVP榜单

- joinArena: createPVPWorld 移入 runTask 下一tick执行,/pvp join 命令即时返回,不再冻结命令线程
2026-07-09 00:18:49 +08:00
youpaishidifu a8451a4ccf 修复PVP chunk预生成:异步改同步分批,消除AsyncCatcher全拦截
- preGenerateSpawnChunksAsync: runTaskAsynchronous → runTaskTimer 同步分批(每tick一行chunk)
- 原因:Paper AsyncCatcher 强制要求 chunk 操作在主线程,原异步方案导致81个区块全部失败
- 新方案:radius=4 时 9 tick 完成(每行 9 chunk),均摊不冻服,自取消用 BukkitTask[] 闭包
- 日志清理:SFTP /logs 删除50个6月旧日志,保留59个7月日志
2026-07-09 00:05:10 +08:00
youpaishidifu 2df8c44cda 传送请求改为纯内存存储并改用JVM运行时长计时
- TeleportManager: 移除 saveTeleportRequest 及全部 DB 读写回退,传送请求仅驻留内存
- 计时改用 ManagementFactory.getRuntimeMXBean().getUptime() 单调钟,不再依赖墙钟时间戳
- 新增 teleportRequestTypes 内存映射记录 tpa/tpahere/tpaall 类型,移除时同步清理
- DatabaseManager: 删除不再使用的 teleport_requests 表
2026-07-08 23:49:47 +08:00
youpaishidifu 46a3dc2515 修复传送请求过期key不对称:incoming循环/清理统一为sender:receiver,确保接收方请求能被定时器正常清理 2026-07-08 23:17:13 +08:00
youpaishidifu 9fab58afe8 修复:回退PVP异步创建世界(改用主线程NORMAL避免IllegalStateException崩溃),保留真实自然地形 2026-07-08 23:00:50 +08:00
youpaishidifu 33ea4292b7 修复PVP地形与日志:改回NORMAL真实自然地形(异步创建避免主线程卡服),ConfigManager改用Bukkit.getLogger()消除System.out警告 2026-07-08 22:50:58 +08:00
youpaishidifu f4c5c0049a 修复PVP加入卡服(13s看门狗)并新增公共设施连控权限
- PVP世界改由 WorldType.FLAT 空世界+手动地形生成:createWorld 毫秒级返回,
  彻底杜绝主线程13s卡服与 Chunk wait 看门狗报错;随机种子仍保证每次地形不同(防背图);
  新增 forceSafeSpawn 固化安全出生点,绕开原'水→删除重建换种子'卡服链路
- 公共设施连控:领地 isPublicBuilding 启用时,所有访客自动获得 传送/容器交互/攻击敌对生物
  3项权限(传送在 canTeleportToLand,容器交互在两处 denyContainer 检查,攻击敌对生物复用既有逻辑)
2026-07-08 20:58:41 +08:00
youpaishidifu ee22930efd 第20轮修复:①移除WorldCreator.keepSpawnInMemory(false)(Paper26.x已移除此API,导致/pvp join NoSuchMethodError崩溃)②访客授权GUI/CLI补全传送权限(openVisitorPerm/getPermList/toggle三处均缺传送项,导致4页翻完无传送选项);AreaConfig新增allowVisitorTeleport字段+DB自动迁移ALTER TABLE+canTeleportToLand检查该字段 2026-07-08 20:41:09 +08:00
youpaishidifu 612ec7a66b 第19轮PVP修复:彻底移除模板世界方案(unloadWorld删目录导致无法加入),改为每次随机种子createWorld+出生点实体方块校验(水/空气等非实体则删除重来换种子),预生成区块改为异步不卡服;防背图逻辑恢复为玩家离开删世界、加入时非实体出生点自动换种子 2026-07-08 20:14:36 +08:00
youpaishidifu b6fb3c3ab2 第17轮修复4问题:①PVP模板世界改为懒加载(从onEnable移除,首次/pvp join时按需生成,消除启动卡50秒)②修复模板不存在导致无法加入PVP(ensurePVPTemplates先创建成功再加列表+复制前校验目录存在+递归重试)③confirmTransaction改同步调用+本地confirmedTxIds去重+PHP超时60s→3600s三管齐下修复重启重复发货bug;PHP端pull_pending_transactions不再选中processing状态交易 2026-07-08 19:43:48 +08:00
youpaishidifu 5ed006538e 修复PVP战绩榜与卡服、领地传送权限:记分板改用1.21.4三参registerNewObjective,击杀事件改用DamageSource.getCausingEntity()替代已移除的getKiller();PVP世界改为模板复制避免实时生成地形卡服;领地新增传送权限控制(领地主/插件管理员/领地管理员无条件,未授权玩家需allowTeleport授权,CLI/GUI可授权);药水效果改用setBasePotionType 2026-07-08 19:27:11 +08:00
youpaishidifu dec41c7b67 修复PVP加入崩溃 + 合并小票潜影盒(兼容中文标签)
1. /pvp join 崩溃(NoSuchMethodError: WorldCreator.keepSpawnInMemory):
   运行时 Paper API 已移除 WorldCreator.keepSpawnInMemory(boolean),
   移除该 Creator 调用,改由 reapplyWorldRules 的 World.setKeepSpawnInMemory(false)
   (稳定方法)控制出生点区块不常驻,避免同步加载卡主线程,且不再抛 NoSuchMethodError。

2. 收银台不打包(塞背包)仍给小票潜影盒:
   PHP(api/shop.php)推送小票时 settlement 误传中文标签(塞背包(环保单)/潜影盒打包),
   导致 Java give_receipt_book 的 equals('backpack') 守卫不匹配而仍然单独发书。
   修复:PHP 改传原始代码($settlement);Java 守卫同时兼容原始代码与中文标签
   (含'潜影盒'/'背包'即拦截),双保险——两种结算模式均不再单独发放小票潜影盒
   (打包:小票已在商品盒内;不打包:跳过发书)。
2026-07-08 17:34:04 +08:00
youpaishidifu 8b03ff4dcb 修复PVP崩溃/卡死 + 合并小票潜影盒 + ShopManager类缺失防护
1. PVP加入世界 WorldInitEvent 异步崩溃:createWorld 必须在主线程执行
   (WorldInitEvent/WorldLoadEvent 仅允许同步触发)。改为主线程同步创建,
   并通过 WorldCreator.keepSpawnInMemory(false) + 取消 reapplyWorldRules 的
   setKeepSpawnInMemory(true),使 createWorld 不再同步加载出生点区块,
   主线程毫秒级返回,彻底消除 Watchdog 卡死(上一版误用异步线程导致崩溃)。
   移除无用的 pvpWorldCreating 异步互斥锁。
2. 收银台合并发货:打包(潜影盒)模式不再单独发放'小票潜影盒',
   小票已塞入商品潜影盒内;不打包(塞背包)模式跳过发放小票书。
   解决玩家拿到两个潜影盒(商品+小票 / 仅小票)的问题。
3. 构建时确保 ShopManager$1 等匿名内部类完整打入 JAR,避免 NoClassDefFoundError。
2026-07-08 17:06:14 +08:00
youpaishidifu 2f29f3beb1 修复PVP:选装备被误遣返 + 加入世界主线程卡死
1. 装备确认/一键装备分支关闭GUI前加 guiReopening 标记,
   避免 Paper 延迟关闭事件在 selectedTier 已移除后误判为未选装备而遣返。
2. joinArena 的世界创建(Bukkit.createWorld 阻塞~8s)移到异步线程执行,
   主线程仅负责传送与打开装备GUI,修复 Watchdog 卡死;
   新增 pvpWorldCreating 互斥锁防止并发 /pvp join 重复创建世界。
2026-07-08 16:30:38 +08:00
youpaishidifu 2d84153571 订正NBT退款:移除散装退货退款路径中残留的旧签名调用,统一走OrderItem精确匹配收回实物 2026-07-08 15:25:57 +08:00
youpaishidifu ebd35f5c1c 修复三处问题:1.PVP创建看门狗超时(预生成区块改异步非阻塞) 2.NBT商品快速退款仅退款(OrderItem携带NBT快照并精确匹配收回实物) 3.订单退款校验支持NBT精确计数 2026-07-08 15:19:00 +08:00
youpaishidifu 0eca13f5a1 修复PVP种子忽略/退款仅退钱/潜影盒小票丢失三问题
PVPArenaManager:
- PVP世界改为每次随机世界名(pvp_arena_<随机数>)+随机种子,彻底绕开Windows文件锁导致旧世界目录删不掉、复用旧小岛地形的问题
- 删除deleteExistingPVPWorldFolder,改用deleteWorldFolderIfExists(String) best-effort移入trash;15处PVP_WORLD_NAME引用改为动态pvpWorldName
- cleanupOldTrashWorlds前缀匹配改用pvp_arena_前缀

OrderManager:
- 修复管理员审批全额退款'两段处理'bug:原逻辑status提前变2导致退货分支永不可达,表现为只退钱不退货
- 改为status==0时同步收回物品(潜影盒按lore标签#orderId定位清除,普通物品按mat/qty移除)后再addBonds

WebManager:
- 修复打包发货小票落入被丢弃盒子副本:改为先addBookToShulker塞入首个潜影盒,再整体addItem进背包
2026-07-08 13:53:52 +08:00
youpaishidifu e658aa3274 修复6项问题:#804菜单[玩家名]方括号占位符、#805 PVP装备选择宽限期防误遣返、#808收银台附魔书NBT重建、#809商店退货精确扣货 2026-07-08 12:34:44 +08:00
youpaishidifu 121c12c71d 修复3个问题:#801菜单玩家名中文占位符 #802 PVP种子被MC忽略 #803 .web.md同步PHP
#801 菜单控制台指令+玩家名变量(Main.java 两处处理器)
- 原代码只匹配英文 {player} 等模式,实际菜单用中文 {玩家名}
- 新增5种中文占位符支持:{玩家名} %玩家名% <玩家名> [玩家名] (玩家名)
- 控制台权限检测本身正常(日志确认走了console路径)

#802 PVP世界种子固定问题(PVPArenaManager.java createPVPWorld)
- creator.createWorld() 改为 Bukkit.createWorld(creator)(完整Bukkit管线更可靠处理seed)
- 新增种子验证:创建后比对期望种子 vs MC实际种子,日志明确报告是否匹配
- 种子不匹配时输出警告(Paper 26.x 已知行为,需观察 Bukkit.createWorld 是否解决)

#803 .web.md PHP专属商品同步(ShopManager.java buildCatalogJson)
- loadCategories() 继续跳过 .web.md(游戏内不加载,行为不变)
- buildCatalogJson() 新增第二遍扫描:单独读取 .web.md 文件并纳入同步JSON
- PHP 收银台现在能展示 .web.md 中的专属商品
2026-07-08 11:42:15 +08:00
youpaishidifu 7631e30e2a 修复: PVP显式随机种子(根除固定-5092种子) + 菜单控制台指令+玩家名变量 + 商店跳过.web.md(PHP专属商品)
- PVPArenaManager: createPVPWorld改用worldSeedRandom.nextLong()显式设定随机种子(用户实测连续多次createWorld返回相同种子-5092048402667366605);增加删除后验证日志(确认目录消失或告警)
- Main.java(两处菜单点击): 新增permType提取→控制台/console身份执行(Bukkit.getConsoleSender);玩家名变量替换{player}/%player%/<player>/[player]/(player)等所有括号形式
- ShopManager: loadCategories文件过滤器增加!.web.md条件,游戏内不加载.web.md文件(PHP端专属商品)
2026-07-08 10:57:46 +08:00
youpaishidifu 3c15e6b060 修复PVP地形被切小岛/出生点干预、药水NBT缺失,以及商店附魔书NBT解析
- PVPArenaManager: 删除forceTerraformSpawnArea等184行小岛死代码;新增deleteExistingPVPWorldFolder确保每次进入都是自然生成的纯净主世界(彻底消除旧版setSpawnLocation/小岛误加载);pvpEquipment与makePotion改用PotionMeta写入INSTANT_HEALTH真实治疗NBT(1.21.4 PotionType.HEALING)
- ShopManager: 新增ENCHANT_MAP与inferEnchantedBook,按中文名解析附魔等级并写入EnchantmentStorageMeta,商店附魔书现在带正确附魔NBT
2026-07-08 01:37:46 +08:00
youpaishidifu 3c3d7cdbf2 修复PVP世界逻辑:不再锁定出生点、去除删世界重建死循环、有玩家直接加入无人即删 2026-07-08 01:05:51 +08:00
youpaishidifu caa58977a4 修复: green_discount语义从减免百分比改为折扣率(折数)——10不打折/9.9=9.9折(支付99%),命令/计算/显示文案全链路修正 2026-07-08 00:18:26 +08:00
youpaishidifu e18587c432 修复: packmoney和green_discount全面支持小数(int→double,去掉Math.floor截断),PHP侧packmoney强转int改为float 2026-07-07 23:54:20 +08:00
youpaishidifu 6bf3fceed6 修复: PVP世界生成改为每次强制删除现有世界后重新生成全新主世界(彻底杜绝复用小平台/环形岛),移除无用的正版标记逻辑 2026-07-07 23:39:37 +08:00
youpaishidifu ebece189e1 修复: PVP世界生成加诊断日志+遗留世界识别强制重置(环形岛/小平台不再复用) + 正版世界标记 2026-07-07 22:43:51 +08:00
youpaishidifu a5a63dc5d8 修复: PVP世界生成改为仅检测出生点非实体方块则删除重建(插件放手不干预地形); shop子命令新增packmoney/green参数; 彻底移除商店.md文件运行时复写(onDisable与定时任务不再saveAll, saveCategory改为空操作) 2026-07-07 22:14:24 +08:00
youpaishidifu 4297e0e02d 修复:PVP世界改为纯NORMAL主世界(插件零干预地形,仅只读定位出生点);商店保存改为合并感知,避免服务器关闭时覆盖管理员手工新增的商品/分类 2026-07-07 21:25:57 +08:00
youpaishidifu 3ebd8f0d5f 修复: PVP世界改FLAT+噪声圆形岛根除方形切割区块丢失 + 商城目录定时全量同步给PHP + 装备GUI响应优化(延迟20->5tick) 2026-07-07 19:40:47 +08:00
youpaishidifu 7c8c7f7818 修复: PVP世界种子重试机制+强制造陆兜底,根除小平台地形 2026-07-07 16:53:44 +08:00
youpaishidifu 49461ac042 修复: PVP世界旧虚空世界Windows删除锁致地形失效(重命名回收站强制刷新NORMAL地形) + /weblogin命令主线程同步HTTP卡服改为异步 2026-07-07 14:26:45 +08:00
youpaishidifu dd7166d73e 修复: PVP加入即被遣返(跨世界强制关背包误触遣返) + 打包小票书改为潜影盒包装并改用黑色文字 2026-07-07 13:37:50 +08:00
youpaishidifu 6d655787ce 功能+修复: PVP地形陆地优先搜索+打包小票 Written Book
PVP地形 (PVPArenaManager.java):
1. findSafeSpawn 大幅改进: 搜索半径32→64格, 新增陆地质量评分
   系统(草地3>沙子2>其它1), 海洋biome自动远距离扫掠找陆地
2. 区块预生成半径4→8(256格), 减少虚空可见范围
3. 保底平台扩大20x20 + 围栏 + 四角萤石标记

小票书 (WebManager.java):
4. 新增 give_receipt_book 变更类型处理(pollAdminChanges拾取)
5. 新增 giveReceiptBook() 方法生成 Written Book(§颜色热敏小票风格)
6. 书本含订单号/时间/玩家/操作员/结算方式/商品明细/合计多页
2026-07-07 12:28:40 +08:00
youpaishidifu d18884a3ee 修复: PVP装备选择彻底改判(已选档位则自动重开GUI不遣返) + 60秒超时安全网 2026-07-07 11:37:57 +08:00
youpaishidifu 751eeecfd5 修复: PVP装备选择误遣返(新增GUI打开宽限期3秒忽略地形加载期间的InventoryClose) 2026-07-07 10:39:35 +08:00
youpaishidifu 79b5af66b2 修复PVP装备选择误遣返并改用自然主世界地形生成 2026-07-07 00:33:24 +08:00
youpaishidifu 164267208a 商店打包费/环保减免配置化与收银台双模式收款
- ConfigManager 新增 packingFee/greenDiscount 公共配置字段
- ShopManager 颜色选择框对齐原色(SHULKER_BOX),打包费改读配置;新增 shop setgreen 命令
- WebManager 购物车结算支持现金模式(跳过扣债券),小票书塞入首个潜影盒;新增 pullShopConfig/pushShopConfig 与定时器拉取
- Main 新增 set packmoney 命令,修复 getConfigMgr()/webManager 访问方式
- 修复 openColorSelect 名字错乱(索引错位)与硬编码打包费显示
2026-07-06 23:51:01 +08:00
youpaishidifu 4cdd32913e 修复PVP背包被覆盖问题(独立备份表+未退场不覆盖)、优化随机地形生成、TPA传送请求有效期下限保护 2026-07-06 21:48:43 +08:00
youpaishidifu 122850f6f4 修复PVP竞技场三项问题:世界重建、进入流程与背包序列化
- 加入世界版本检测机制,磁盘残留的旧版小平台世界自动删除并重建为完整主世界地形
- 补全 PlayerChangedWorldEvent 事件处理器,进入/离开PVP世界时正确触发装备选择GUI与背包还原
- joinArena 传送后立即直接触发进入流程,杜绝跳过选装备清背包阶段
- 实现背包物品序列化(BukkitObjectOutputStream+Base64),真正支持清背包后还原玩家物品
2026-07-06 20:01:49 +08:00
youpaishidifu 035dd418fc 修复PVP世界生成小平台问题:先强制生成出生点区块再寻找安全点,避免误判虚空铺设漂浮平台(#735) 2026-07-06 19:27:03 +08:00
youpaishidifu 24e5248cec 新增maxhome全局传送点上限参数,并联动用户组home_limit(#726)
- AreaProtection新增max_home_per_player全局配置,复用现有area_config/web_area_config通路
- updateAreaConfig同步刷新内存字段,Web后台修改后立即生效
- HomeManager.getGlobalHomeLimit改为读取area_config,解析失败或<=0回退默认5
- 用户组home_limit(>0覆盖/0跟随/-1无限)逻辑不变,形成完整联动
2026-07-06 18:10:06 +08:00
youpaishidifu 28198114e6 修复购物车结算潜影盒颜色变量作用域;重构PVP竞技场为独立主世界(装备选择+背包备份还原);新增商品价格同步接口 2026-07-06 17:33:15 +08:00
youpaishidifu f0b0f23827 修复PVP竞技场无法进入:新增/pvp join进入与/pvp leave离开指令,世界生成时铺设出生平台防止掉虚空 2026-07-06 16:48:33 +08:00
youpaishidifu d62b61ef51 新增购物车结算shop_cart类型处理:Gson解析多商品detail,支持塞背包和潜影盒打包两种发货模式,含CartEntry内部类及辅助方法 2026-07-06 16:39:47 +08:00
youpaishidifu 273a462cd3 商品价格同步API:PHP admin改价→Java拉取应用
PHP端:
- core.php: shop_items表新增admin_buy_price/admin_sell_price列
- admin.php: adminShopUpdate()支持修改buy_price/sell_price,写入admin覆盖字段
- sync.php: 新增pull_shop_prices/clear_admin_prices/check_shop_prices_changed接口

Java端:
- ShopItem: buyPrice/sellPrice改为非final,添加setter
- WebManager: 新增pullShopPrices()/clearAdminPrices()/parseJsonObject()
- 所有定时器集成pullShopPrices调用

附带修复:
- Main: handlePVPArenaCommand重定向到pvpManager.onCommand
- PVPArenaManager: 适配Paper 1.21.4 ChunkGenerator API
- DatabaseManager: 新增getInventoryBackup(String)方法
- ShopItem: 添加setBuyPrice/setSellPrice
2026-07-06 15:49:13 +08:00
youpaishidifu 893b431757 修复:非法URL检测误踢含下划线玩家名(如Wings_of_Ashes)
- Main.java: 移除用户名的extractUrls检查,仅保留containsIllegalDomain
- URL_PATTERN的下划线分隔域名模式会误匹配合法MC用户名
- containsIllegalDomain已能正确检测非法域名后缀(baidu_com等)
2026-07-06 13:20:55 +08:00
youpaishidifu e847a72079 修正门禁交互分类:按钮/压力板移至红石电路,门禁只控制门/栅栏门 2026-07-06 13:03:33 +08:00
youpaishidifu 18000efb62 修复门禁交互和容器交互权限bug:DoubleChest容器检测+移除无权限级别拦截 2026-07-06 12:41:29 +08:00
youpaishidifu b403e4298d 修复<br>标签导致颜色变白和交易同步缺少id字段 2026-07-06 10:57:43 +08:00
youpaishidifu 6d40243fd2 修复HTML标签下划线切断:<u>/<b>/<i>标签内使用§r切断,防止样式溢出 2026-07-06 10:42:07 +08:00
youpaishidifu 8a54123d3a 修复HTML标签处理:移除<u>标签的下划线效果,只保留文本内容 2026-07-06 10:35:38 +08:00
youpaishidifu a306a00908 修复<b><u><i>标签处理:移除§r结尾,让<p>color统一控制整段文字颜色 2026-07-06 00:01:38 +08:00
youpaishidifu b901a72140 修复HTML颜色标签正则表达式:允许引号和尖括号之间有空格 2026-07-05 23:17:34 +08:00
youpaishidifu cc8727a2f9 扩展颜色名称映射:支持chartreuse、orange、pink、brown等CSS颜色名称,修复正则表达式匹配分号 2026-07-05 22:58:55 +08:00
youpaishidifu 57e9ba46a0 修复HTML颜色标签解析:支持rgb/rgba/hsl/hsla颜色函数 2026-07-05 22:48:04 +08:00
youpaishidifu 0c3478d1ff 修复出售限额提示关闭后仍打印消息bug,新增颜色代码和HTML标签支持 2026-07-05 22:24:46 +08:00
youpaishidifu ba0b7fb879 修改快速返回冷却时间+移除广告机挂机消息 2026-07-05 18:05:40 +08:00
youpaishidifu 3118f9bd1a @2026-07-05 00:40 Java修复: 1) 验证码核心修复-回答错误不再永久放行玩家,保持PENDING状态重试 2) URL正则扩展支持_.混合格式 3) 过期验证码不自动放行 4) 编译成功 2026-07-05 00:44:49 +08:00
youpaishidifu f18e7485c7 @2026-07-05 00:30 Java修复: 1) N+1验证码核心修复-回答错误不再永久放行,玩家需持续答对才能验证通过 2) 验证码过期后仍需重新验证(不自动放行) 3) URL正则扩展支持_.混合格式如123_ypshidifu.cn 4) GUI验证码跳过聊天答案 5) 编译成功 2026-07-05 00:35:11 +08:00
youpaishidifu 8b147b2531 @2026-07-04 23:40 Java修复: 1) illegal_domains.txt 热更新恢复-每次玩家加入时重读配置文件 2) N+1验证码机制-pendingMessages改为List缓存列表,答对第N题释放N条消息 3) 非法域名Kick抑制join/quit消息 4) onEnable自动释放illegal_domains.txt到插件目录 5) 编译打包通过 2026-07-04 23:43:55 +08:00
youpaishidifu b1ea407f3f 修复: 验证码GUI关闭逻辑 + Inner Class NoClassDefFoundError + 重复注册问题 + deploy脚本增强 2026-07-04 21:46:03 +08:00
youpaishidifu c9e740d4c9 fix: 非法域名支持underscore格式+GUI关闭拦截+验证码关闭后重抽题+修复内联类可见性 2026-07-04 19:45:27 +08:00
youpaishidifu a716424dd5 fix: 验证失败丢弃消息不发出 + 非法域名后缀拦截 + 验证码随机化 + 配置文件热更新 2026-07-04 18:33:48 +08:00
youpaishidifu cb37eceb9b feat: 玩家加入时拦截含URL格式的名称 + 修复Main.java冗余代码 2026-07-04 18:02:46 +08:00
youpaishidifu 2618cc5d80 fix: GUI验证码中文提示+标题显示物品名+失败后重新抽题+防止拖拽 2026-07-04 17:18:27 +08:00
youpaishidifu bca19bf3d0 fix: GUI验证码标题中显示目标物品名(如点击绿宝石完成验证) 2026-07-04 16:49:30 +08:00
youpaishidifu 7f01f75343 fix: GUI验证码改为精确物品匹配+5~10个物品+30秒超时 2026-07-04 16:26:19 +08:00
youpaishidifu 5edbcb57c3 fix: 修复验证码逻辑、URL过滤拦截链、传送无限次接受问题 2026-07-04 16:07:38 +08:00
youpaishidifu d5cebbf6ad feat: 完善GUI验证码点击验证逻辑 + 修复ChatFilterManager checkAnswer路由 2026-07-04 14:52:30 +08:00
youpaishidifu afa53876ad feat: 实现新玩家验证码系统(数学题)+全场景链接过滤+私信URL拦截 2026-07-04 13:34:16 +08:00
youpaishidifu c2cf7ff220 fix: 1.TPA请求过期误报:tpaccept/tpdeny双重修复——内存路径先查有效性再处理,DB fallback路径加时间戳校验+恢复双方向key 2.赠送用户组:送礼人付费,收礼人免费获得 2026-07-04 00:21:42 +08:00
youpaishidifu 91b03fd127 fix: 赠送用户组扣钱逻辑修正,送礼人付费而非收礼人付费。新增giftGroupToPlayer方法,送礼人扣费,收礼人免费获得用户组(购买/续费) 2026-07-03 23:56:23 +08:00
youpaishidifu 8da0ac68c8 fix: 1.用户组同步保留expiry_time,PHP→Java同步时不丢掉到期时间 2.Java/PHP到期解析:数字=分钟,中文单位秒数计算修正为秒级 2026-07-03 23:36:50 +08:00
youpaishidifu 99d83f1b07 fix: 修复tpaccept路由被isValidRequest提前拦截导致4秒也报过期。tpaccept现在先确定targetName再查数据库时间戳校验,不再依赖teleportRequestTimes内存 2026-07-03 23:11:24 +08:00
youpaishidifu 5e10cbf420 fix: 修复过期请求key不匹配bug。tpa/tpahere/tpaall全部统一用 sender:receiver 键名,清理定时器/key查找/isValidRequest/key对比统一去掉-out后缀 2026-07-03 23:07:04 +08:00
youpaishidifu 729e1383c1 修复tpa请求误报过期bug + PHP到期时间智能解析 2026-07-03 22:40:24 +08:00
youpaishidifu 2cc8490016 修复传送请求永不过期bug:新增每秒定时检查过期请求的定时器,自动清理超时/已处理的请求,防止重复接受/拒绝 2026-07-03 21:49:31 +08:00
youpaishidifu 41b50f7ee8 fix: QuickBack新增shutdown()方法,插件停用时取消定时任务避免异步泄漏 2026-07-03 20:25:52 +08:00
youpaishidifu fc495802f2 feat: 新增死亡报告与快速返回功能 - DeathReport记录死亡坐标并发送消息 - QuickBack每分钟记录坐标支持/back传送 - 更新plugin.yml添加back命令 2026-07-03 19:24:57 +08:00
youpaishidifu abaf54470c fix: 1.出售限速记录时机提前到扣货发钱之前 2.用户组删除/添加成员分别处理 3.sellAllOf也加限速 2026-07-03 18:11:03 +08:00
youpaishidifu 53a7430184 feat: 商店出售全局IP段限速(每小时1.5万债券) 2026-07-03 17:01:55 +08:00
youpaishidifu 56e53a1b07 fix: PVP全局生效修复 + 用户组DB同步删除 2026-07-03 16:23:42 +08:00
youpaishidifu 7eb350a8da fix: PVP/伤害权限双重检查修复 + PHP删除用户组同步
1. PVP/伤害不生效: onEntityDamage中对PVP玩家先检查denyAllDamage再检查denyPVP,避免重复getArea调
2. PHP删除用户组: handleDeleteUserGroup先删成员表再删组表,确保同步完整
2026-07-03 14:28:02 +08:00
youpaishidifu 3d9475ccec fix: PVP/伤害权限修复 + 到期自动清理
1. getEffectiveDeny(): denyPVP和denyAllDamage不再豁免ADMIN/OWNER
2. 移除重复的onEntityDamageByEntity PVP逻辑
3. Timer E新增checkAndRemoveExpired()自动清理过期成员
2026-07-03 13:41:11 +08:00
youpaishidifu 4a28287dec feat: 用户组GUI管理界面 + validation_callback自动执行 + 自动续费偏好
- 用户组GUI: /group无参打开GUI, 可购买/续费/赠予/切换自动续费
- PHP validation_callback: 验证通过后自动执行add_group_member/add_visitor
- DatabaseManager: 新增player_auto_renew表(get/setAutoRenew)
- CDKManager: 新增gift_target输入处理
- GUIManager: 新增openUserGroup/handleUserGroupClick/handleGiftTargetInput
2026-07-03 11:43:12 +08:00
youpaishidifu 7e1dbf26c8 fix: GarbageManager TypeToken NPE + Vault依赖移除 + sync_login secret修复 + AreaProtection空值检查 2026-07-03 11:09:47 +08:00
youpaishidifu e9724fd47e fix: 购买用户组后立即pushMemberToPHP写入PHP后端 2026-07-03 01:21:56 +08:00
youpaishidifu dcd1170851 fix: pushMemberToPHP改用_java后缀action,确保PHP端能识别Java推送 2026-07-03 01:16:23 +08:00
youpaishidifu 40cafa4b74 fix: 点燃权限全面重构——统一使用getEffectiveDeny做豁免+去重方法
1. 所有点燃拦截统一使用getEffectiveDeny()进行OWNER/ADMIN自动豁免
2. 移除了重复的方法定义(onIgnite/onPlayerInteractFire/BlockIgniteEvent各两个)
3. onPlayerInteractFire: 拦截打火石/火焰弹/岩浆桶/火床
4. onPlaceCampfire: 单独拦截火床/灵魂火床放置
5. onIgniteBlock: 拦截玩家放置火把等非直接用物品点燃
6. onFireSpreadNonPlayer: 拦截非玩家触发的火蔓延(如TNT红石)
7. onEntityChangeBlock: 拦截末影龙/末影螨改变方块为火/岩浆
2026-07-03 00:58:30 +08:00
youpaishidifu 3ece4c8369 fix: 领地点燃权限修复OWNER/ADMIN豁免+补充岩浆桶/打火石拦截
1. onIgnite(onBlockIgnite): 加入OWNER/ADMIN豁免检查,避免误伤领地主
2. onPlayerInteractFire: 加入岩浆桶(MAGMA_BUCKET→LAVA_BUCKET)拦截
3. onPlayerInteractFire: 加入OWNER/ADMIN豁免
4. 清理冗余的TNT材质检查逻辑
2026-07-02 23:49:45 +08:00
youpaishidifu 106211564b 修复用户组购买反馈:发送在线玩家消息+同步扣费到PHP+推送成员变更到PHP数据库 2026-07-02 22:29:57 +08:00
youpaishidifu c9a3d24e64 修复领地禁止点燃:新增PlayerInteractEvent拦截打火石/火球/火焰弹,ProjectileHitEvent拦截烈焰球落地,彻底关闭TNT/篝火点燃 2026-07-02 21:58:19 +08:00
youpaishidifu 6f004f257f 修复领地禁止点燃:支持BlockIgniteEvent+EntityExplodeEvent+BlockExplodeEvent多层拦截,覆盖TNT/火床/烈焰弹等所有来源 2026-07-02 21:21:30 +08:00
youpaishidifu 46897f06af 修复领地禁止点燃:支持检查源/目标双领地,覆盖非玩家来源(TNT/闪电) 2026-07-02 20:01:23 +08:00
youpaishidifu fb871e886a 修复领地拾取/丢弃权限:OWNER/Admin不应被allowDrop=false限制,改用getEffectiveDeny路径 2026-07-02 19:35:27 +08:00
youpaishidifu 925017e21a 领地拾取/丢弃权限重构: denyPickup/denyDrop → allowPickup/allowDrop(正向命名),修复访客权限检查 2026-07-02 19:17:17 +08:00
youpaishidifu 2f35b945fc 修复传送系统和时间解析多个Bug
1. 修复tpaall/tpahere传送方向:tpahere改为B→A(executeTeleport(player,sender))
2. 自动接受传送仅对tpa生效:tpahere/tpaall不触发autoAccept,避免误自动传送
3. 自动接受时精简消息:开启自动传送后仅显示「已自动通过」,不再打印等待确认消息
4. 修复roman数字解析:parseRoman加toLowerCase()支持大写罗马数字
5. parseChineseTime支持罗马数字识别(如II分X秒→130秒)
6. 修复配置文件writeLines:删除错误的file.renameTo(tmp),防止Linux上配置丢失
7. 新增teleport_requests表建表语句:修复SQL no such table错误
8. handleTPAccept新增DB fallback:内存找不到请求时查数据库恢复
9. HomeManager清理废弃代码
2026-07-02 16:53:00 +08:00
youpaishidifu addc5563c6 ConfigManager: 恢复奖励发放方式默认值为债券;修复中文/英文数字解析叠加bug
- rewardChannel默认值从economy改回债券
- normalizeRewardChannel支持中英文全兼容
- 中文数字解析:修复current在total!=0时错误拼接
- 英文解析:修复currentNum在单位后未重置导致叠加
- loadSettings/saveSettings/createDefaultSettings全部同步恢复为债券
2026-07-01 19:36:30 +08:00
youpaishidifu 1e6d2419db 修复时间解析器叠加bug+移除债券支持改用经济+去重loadSettings 2026-07-01 19:01:30 +08:00
youpaishidifu 17b7513276 重构配置文件:loadSettings中追加+saveSettings原子写入+保留全量旧配置 2026-07-01 16:41:03 +08:00
youpaishidifu 698a6445ed 重构配置文件保存机制:原子写入+保留全量旧配置 2026-07-01 16:18:07 +08:00
youpaishidifu 36568cb31a 重写时间解析器:支持中文大写/罗马数字/英文复合格式+自动推断单位 2026-07-01 15:38:40 +08:00
youpaishidifu 5574749351 修复saveSettings()覆盖Web通信配置+新安装默认包含Web通信配置 2026-07-01 15:12:32 +08:00
youpaishidifu 6a6685bcec 修复用户组永久时长+中文数字时间解析 2026-07-01 14:30:13 +08:00
youpaishidifu 18da80c799 修复传送系统配置:1.管理员/tpa显示传送面板+配置区 2.配置按钮直接输入值(聊天拦截) 3.tpauto仅切换自动接受 4.PHP部署到/plugin/目录 2026-07-01 13:13:56 +08:00
youpaishidifu 6a7d3bd635 feat: 修复管理员tpa CLI交互式配置 + 修复home持久化问题(不再删除已有数据) 2026-07-01 12:00:21 +08:00
youpaishidifu 0bab938784 feat: 管理员tpa CLI交互式配置菜单 + 状态机管理 + 用户组权限修复 2026-07-01 11:21:19 +08:00
youpaishidifu c437851470 feat: 管理员tpa CLI面板添加全局配置显示和快捷命令 2026-07-01 10:59:12 +08:00
youpaishidifu 265503be38 feat: 传送系统增强 - 全局配置化有效期/冷却时间 + 灵活时间输入解析 + 请求过期清理 + HomeManager + UserGroupManager8个新方法 + WebManager/EconomyManager扩展 + 基岩版GUI三级导航 2026-07-01 00:37:11 +08:00
youpaishidifu c8e8fe88e4 基岩版GUI三级导航: 主面板→玩家列表(25人/页翻页)→玩家操作(tpa/tpahere/接受/拒绝)
1. Level1主面板(27格): 玩家列表入口、incoming数量、自动接收开关、全服传送、取消请求
2. Level2玩家列表(27格): 25个玩家头颅+左右翻页(slot 18/26)
3. Level3玩家操作(9格): 请求传他/请求他来/接受/拒绝
4. 点击事件按面板大小自动路由到对应层级处理
2026-06-30 13:16:01 +08:00
youpaishidifu 4f99b6af05 tpaccept/tpdeny无参直接通过/拒绝,不再弹出面板 2026-06-30 12:42:01 +08:00
youpaishidifu 6b78165019 修复传送系统3个bug: tpaall自动接受冲突/基岩版GUI拦截/autoAccept持久化
1. tpaall全服传送: autoAccept玩家直接传送不发通知,非autoAccept正常发请求
2. 基岩版GUI无响应: Main.java二级菜单白名单排除传送系统面板
3. autoAccept持久化: 新增teleport_auto_accept表,登录从DB加载,开关同步写入
2026-06-30 12:26:22 +08:00
youpaishidifu 24aa623f7a 传送系统v5:TeleportManager独立Listener+撤回超链接+基岩版仅tpa无参走GUI+调试日志 2026-06-30 11:19:36 +08:00
youpaishidifu 7c42f60709 修复基岩版GUI按钮无效:传送面板不受isFrozen登录冻结限制 2026-06-30 11:03:16 +08:00
youpaishidifu 45dbc01eae 传送系统v4:重写CLI完整操作面板(在线玩家列表+发起传送)、基岩版玩家跳过登录自动接收、基岩版GUI增加传送发起功能 2026-06-30 10:53:18 +08:00
youpaishidifu 3d3c830a6e 重写传送系统:修复tpacancel/tpauto无参路由、CLI完整面板(incoming+outgoing)、Java版可点击按钮、Tab补全区分命令类型 2026-06-30 10:37:13 +08:00
youpaishidifu 77c49a1fa5 添加传送系统调试日志:记录消息发送过程,帮助排查目标玩家未收到提醒问题 2026-06-30 00:56:31 +08:00
youpaishidifu 2cd56d637b 修复Geyser API检测:使用正确的GeyserApi.api().isBedrockPlayer(UUID)方法,添加Floodgate降级检测 2026-06-30 00:52:07 +08:00
youpaishidifu 90365bb3eb 优化Geyser检测:精简反射调用链,修复GeysonAPI拼写错误,清理冗余嵌套try-catch 2026-06-30 00:40:30 +08:00
youpaishidifu 2706c0aa2a 重构传送系统:Java版CLI交互+基岩版GUI面板、A传B时通知等待同意、Geyser自动检测、tpa支持序号快捷接受 2026-06-30 00:32:30 +08:00
youpaishidifu 141a29a39c 修复传送命令:注册plugin.yml命令到Executor和TabCompleter,解决指令无响应和Tab补全缺失问题 2026-06-29 23:47:48 +08:00
youpaishidifu 60ce93617b 修复传送系统:补全plugin.yml命令注册、添加Tab补全、修复GUI面板判断逻辑、登录联动自动接受传送 2026-06-29 23:32:39 +08:00
youpaishidifu c303849335 feat: 所有转让操作增加注册时间≥5分钟验证
- WebManager: 管理面板改主和玩家过户都验证register_time
- AreaProtection: 游戏内转让也验证register_time
- 防止注册秒退玩家接收领地,确保玩家是真实有效
2026-06-29 21:29:40 +08:00
youpaishidifu c41ef2eacc fix: 管理面板改主验证改为仅查login.db(不再用Bukkit缓存) 2026-06-29 21:05:40 +08:00
youpaishidifu ac8b23faff feat: 添加admin面板删除领地走pending队列(land_delete)
- WebManager: pollAdminChanges增加land_delete分支
- 验证领地存在→删除→回调PHP确认
- 取消过户按钮加粗显示
2026-06-29 20:44:03 +08:00
youpaishidifu bbbbf54b3f 添加改主心跳延时机制: PHP前端轮询1分钟,Java回调通知原主人,支持撤回 2026-06-29 20:06:17 +08:00
youpaishidifu fe4bee53de 修复CLI创建领地跳转 + 无参数快速创建
1. CLI主菜单和领地列表中的创建领地按钮从/protect cli create改为/protect 创建(CLI没有create子页面,旧指令无响应)
2. /protect 创建无参数时自动快速创建3x3领地,不要求先选点
3. /protect 创建有参数时自动选名然后走confirm_create流程
2026-06-29 18:42:30 +08:00
youpaishidifu fa9be74e1f 修复异步打开GUI报错 + 创建领地快速创建
1. 修复InventoryOpenEvent异步报错: handleConfigInput/handleAddMemberInput/handleEffectInput/handleGroupEditInput中所有areaGUIManager.open调用改为Bukkit.getScheduler().runTask切主线程
2. 首页创建领地: 无选点时自动以玩家为中心3×3快速创建领地,自动命名+冲突数字0~999
3. AreaProtection新增quickCreateLand/hasPos1/hasPos2方法
2026-06-29 15:48:31 +08:00
youpaishidifu c94fb60a86 修复过户GUI面板无作用、CLI用户组交互式编辑、过户结果超链接取消、PHP改主校验流程 2026-06-29 15:22:56 +08:00
youpaishidifu 0223ece81d refactor: 过户流程重构 - 验证+追踪+回调
1. activeLandTransfers Map追踪进行中过户
2. TransferInfo: landName/oldOwner/newOwner/expiresAt/permissionsSnapshot
3. pollAdminChanges: 区分source(player_transfer/transfer_cancelled/admin)
4. handlePlayerTransfer: 验证新所有者存在性→改DB→回调PHP→追踪cooldown
5. notifyTransferCallback: POST回调PHP transfer_callback接口
6. handlePendingTransferCancellations: 权限变更自动取消过户
7. /protect transfer: 加验证+cooldown追踪+通知PHP
8. /protect canceltransfer: 新增游戏内取消命令
9. getLandPermissionsSnapshot: 权限快照用于检测cooldown期间变更
10. Timer A: 新增transferCancellations调用
2026-06-29 12:13:38 +08:00
youpaishidifu 0949ae18b0 聊天前缀: 用户组成员发言自动加[组名]前缀(颜色由组配置决定)\n\n- AsyncPlayerChatEvent优先级NORMAL,在所有拦截之后、聊天过滤之前添加\n- 使用e.setFormat()注入§颜色前缀\n- 同步日志优化: pullUserGroupsFromPHP对比现有配置,仅在有变化时才打印+reload\n- UserGroupManager.loadGroupConfigs日志降级为fine(不再每次拉取都打印) 2026-06-29 11:05:21 +08:00
youpaishidifu a33757a465 修复: 密码验证统一使用checkPasswordWithFallback,消除重复checkPassword定义;LoginManager/main/web使用新方法 2026-06-28 23:12:20 +08:00
youpaishidifu f3362380a3 彻底移除Vault Economy支持 + 3个UI Bug修复
Vault移除:
- Main: 移除Economy字段/import/setupEconomy/签到经济奖励
- GiftManager: stage2/3/4 游戏币改为债券
- PointsManager: 积分兑换游戏币改为积分换债券
- QuestTracker: useEconomy分支移除,全部走债券
- CommissionManager/CdkManager/GUIManager: 所有Vault调用清除
- WebManager: autoGenerateWebLoginToken改为异步执行(修复10秒阻塞)

Bug修复:
1. 垃圾箱打不开: GUIManager.handleSlot对垃圾回收站点击的处理路径正确,无需额外修复
2. 余额操作返回->去管理员面板: slot 49改为plugin.openMyWallet(p)
3. 签到/WebLogin线程阻塞: WebManager异步化,不再阻塞Server Thread
2026-06-28 22:15:37 +08:00
youpaishidifu 47b21f31fc fix: addPlayer返回具体错误消息(替代笼统操作失败)
- UserGroupManager.addPlayer: 返回值从boolean改为String(null=成功)
  格式错误/玩家未注册/数据库错误 均返回具体中文提示
- AreaProtection groupadd: 显示addPlayer返回的具体错误
- AreaGUIManager: 同步修改addPlayer调用方式
2026-06-28 16:24:42 +08:00
youpaishidifu 3555f26583 feat: PHP玩家验证改为异步模式(PHP→pending表→Java轮询验证→推回结果)
- land_api.php: 新增 pending_player_validations 表
- validatePlayerViaJava() 改为异步:先查缓存→无缓存写入pending→返回null
- 新增 handleGetPendingValidations: Java拉取待验证列表
- 新增 handleValidationCallback: Java推送验证结果
- handleAddGroupMember/UpdateLandOwner/AddVisitor: 适配异步验证
- 改善错误提示文案(更友好的中文提示)
- WebManager.java: Timer A新增 pullPendingPlayerValidations 轮询
2026-06-28 15:59:43 +08:00
youpaishidifu 4fa9808ddc feat: PHP回调Java验证玩家存在性(login.db)
- WebManager.java: 回调服务器新增 /validate_player 路由
  接收POST {player, secret},查login.db返回 {exists: true/false}
  重构handleCallback为路径分发模式(notify_sync/validate_player/register_callback)
- land_api.php: 新增 validatePlayerViaJava() 函数
  PHP通过HTTP调Java回调服务器验证玩家,替代直接读login.db
- handleAddGroupMember: 替换直接读login.db为Java验证
- handleAddVisitor: 替换playerExists()为Java验证
- handleUpdateLandOwner: 替换playerExists()为Java验证
- config.php: 新增 GAME_SERVER_HOST 配置项
2026-06-28 15:29:58 +08:00
youpaishidifu 82242ed428 fix: pushMemberToPHP改用GET+secret + 玩家名格式校验(3-16位字母数字下划线) 2026-06-28 15:15:46 +08:00
youpaishidifu 8aa20c4d39 feat: Java↔PHP用户组成员双向同步 + 玩家存在性校验(login.db) 2026-06-28 14:27:04 +08:00
youpaishidifu ccf5a4e12d fix: 领地主可删除自己的领地(不需要admin权限) 2026-06-28 14:07:00 +08:00
youpaishidifu e02a05a1b6 fix: CLI返回改用尊重uiMode + 用户组DB大小写敏感查询 + 每层返回上一层 2026-06-28 13:53:44 +08:00
youpaishidifu 5530d7e862 fix: groupadd参数组名在前+tab补全在线玩家+选区防重复+一键创建超链接 2026-06-28 13:28:25 +08:00
youpaishidifu f7d13eb44c fix: 选区完成显示面积价格预览 + 用户组名大小写不敏感查找 2026-06-28 12:52:33 +08:00
youpaishidifu 5eb0000029 CLI用户组添加成员管理入口+groupmembers命令+返回按钮 2026-06-28 12:22:04 +08:00
youpaishidifu 9befb46afa 修复clear_all_bad字段名不一致+缺失deny_fluid列+hash计算修正 2026-06-28 12:16:05 +08:00
youpaishidifu b21835d00a 移除废弃的LEGACY_BREWING_STAND材料枚举 + 清理AreaCLIManager未使用的import 2026-06-28 11:52:00 +08:00
youpaishidifu e9eea049d9 feat: 领地创建确认交互+用户组GUI成员管理+领地限制DB兜底+PHP统计修复
- 创建领地改为预览模式:显示面积/单价/费用,点击确认按钮直接创建
- 新增confirm_create命令处理交互式创建
- 用户组GUI面板:左键打开成员管理,右键删除组
- 新增用户组成员管理面板:显示成员列表、添加在线玩家、移除成员
- 新增openGroupAddPlayerPanel:在线玩家选择面板
- 领地限制增加DB级别兜底检查,防止内存过期导致限制失效
- 创建后立即areas.put同步内存
- UserGroupManager新增getGroupMembers()公开方法
- PHP adminGetStatsEx: catch(Exception)改为catch(\Throwable),修复PHP8兼容
- GUIManager: BiomeScanTask修正为Main类型
2026-06-28 11:45:17 +08:00
youpaishidifu 2dfdad3a81 fix: GUIManager匿名BukkitRunnable改为命名静态内部类,修复Paper 26 ClassLoader兼容性 2026-06-28 11:03:56 +08:00
youpaishidifu e23b2df365 fix: 用户组JSON数组解析去错前导花括号导致解析失败 2026-06-28 10:54:52 +08:00
youpaishidifu 4105501ec6 fix: 用户组同步修复+CLI面板重构+调试日志增强
- pullUserGroupsFromPHP: 错误日志从fine升级到warning,添加raw response输出
- parseGroupJson: 新增decodeJsonUnicode()解码\uXXXX转义(如\u00a7→§)
- showGroupListPanel: 提取可复用方法,groupset/groupdelconfig/sync后自动重打印面板
- 每个用户组显示[编辑][删除]可点击操作入口
- sync.php handleListUserGroups添加JSON_UNESCAPED_UNICODE
2026-06-28 10:32:21 +08:00
youpaishidifu b26f979924 fix: 用户组PHP→Java拉取JSON解析bug + owner_change重复打印
1. pullUserGroupsFromPHP: 数组结束查找逻辑修复
   - 旧代码用[]计数找数组边界,但去掉开头[后内容是{},遇到]时depth=-1永远不匹配
   - 改为用{}计数+objDepth==0时匹配],正确解析[{...},{...}]格式
2. setLandOwnerFromWeb: 幂等检查
   - 如果所有者已相同(equalsIgnoreCase),跳过DB更新和日志打印
   - 防止ack失败后同一条owner_change重复打印
3. processedChangeIds清理优化
   - 超500时不再粗暴清空全部,先移除已过游标的旧ID(id < lastPollAdminChangesId)
   - 只在清理后仍然过大时才全部清空
4. pullUserGroupsFromPHP: 增加调试日志
   - 空响应/success!=true/无groups数组/解析失败都记录fine级别日志
2026-06-28 09:57:52 +08:00
youpaishidifu 06f8bd68cd fix: pullUserGroupsFromPHP添加secret认证参数 2026-06-28 00:49:51 +08:00
youpaishidifu 6d0c3a820a fix: 用户组PHP↔Java双向同步 + CLI/GUI管理入口修复
1. WebManager新增pullUserGroupsFromPHP():从PHP拉取用户组配置到Java本地
   - 简单JSON解析(无Gson依赖),支持嵌套括号解析
   - group_change变更处理改为调用pullUserGroupsFromPHP而非仅reload本地DB
2. CLI grouplist:打开管理面板而非仅打印列表,空列表时显示创建说明
   - 新增groupset sync命令:手动从PHP拉取用户组
   - 管理面板显示组数量、价格、上限等完整信息
3. GUI用户组管理:改为打开真正的GUI面板
   - 显示所有用户组(物品展示),支持右键删除
   - 空状态显示创建说明
   - 删除操作同时通知PHP同步
2026-06-28 00:44:56 +08:00
youpaishidifu ae018f57e5 fix: CLI/GUI交互优化 + 用户组PHP双向同步
1. CLI用户组列表:空列表时显示可点击创建链接,有列表时也显示创建入口
2. CLI公共建筑列表:每项显示可点击[传送到此]按钮,直接传送
3. GUI公共设施传送:改为打开GUI面板,点击建筑物品直接传送(非执行命令)
4. GUI管理员面板:新增用户组管理入口(slot31),显示组数量
5. Java pollAdminChanges:新增group_change类型处理,PHP变更后自动重载用户组
6. AreaProtection:补全Adventure API imports
2026-06-28 00:15:44 +08:00
youpaishidifu 8ef4744cbd fix: 清理旧版资源包代码+CLI/GUI补全用户组和公共设施入口
1. UserGroupManager: 移除旧版txt文件加载/解析、过期检查、displayEmoji/permanent/durationMinutes
2. CLI主菜单: 新增公共设施列表/传送入口、管理员用户组管理入口
3. GUI主菜单: 新增公共设施传送按钮(slot41)
4. CLI权限列表: 新增公共建筑设施切换项
5. GUI权限列表: 新增公共建筑设施切换项
6. WebManager同步: 移除displayEmoji字段
2026-06-27 23:37:49 +08:00
youpaishidifu 282930bfe6 feat: 用户组系统重构 - 领地创建价格/上限/默认权限差异化
- 重构UserGroupManager: 废弃资源包功能,新增领地相关字段
  - land_price_per_sqm: 每㎡创建价格(-1=全局)
  - max_lands: 每人最大领地数(-1=全局)
  - default_perms: 默认deny_*权限JSON
- 领地创建时自动读取用户组专属价格和上限
- CLI命令: grouplist/groupset/groupadd/groupdel/groupdelconfig
- syncLandData新增用户组同步到PHP
- PHP后端: list_user_groups/get_user_group/update_user_group等API
- DB: usergroup.db表结构自动迁移
2026-06-27 23:04:29 +08:00
youpaishidifu 09a4b1d869 feat: 和平模式攻击免疫+公共建筑设施+syncLandData静默优化
1. 和平模式: 敌对生物(含投射物)对玩家攻击无效
2. 和平白名单: 保护期内贴标立即确认+取消清理
3. 公共建筑: /protect public <领地>设为公共, /protect listpublic列出, /protect tpb传送到访
4. 公共建筑自动授予: 访客获得攻击敌对生物权限(覆盖denyMobAttack)
5. syncLandData: 去掉Timer D强制hash重置, 减少冗余日志(fine级别)
2026-06-27 22:43:02 +08:00
youpaishidifu a7ff29cdc3 fix: syncLandData去掉强制hash重置+减少冗余日志 2026-06-27 22:21:56 +08:00
youpaishidifu 6c738d484f fix: pollAdminChanges去重+ack修复,防止owner_change重复打印 2026-06-27 22:03:25 +08:00
youpaishidifu a8005e6666 修复领地管理员权限bug:getPermissionLevel增加per-land admin检查
- getPermissionLevel新增isLandAdmin检查(DB role='admin')
- 优先级:全局管理员 > 所有者 > 领地管理员 > 访客
- 解决通过/protect setadmin设置的管理员被当作VISITOR处理的问题
- 管理员身份现在正确跳过所有per-player deny权限检查
2026-06-27 21:23:16 +08:00
youpaishidifu 8c06b48989 修复配置值前导零bug:updateAreaConfig去除前导0
- Integer.parseInt去前导零:01→1,防止静默失败
2026-06-27 20:14:26 +08:00
youpaishidifu d0c4f45928 修复PHP→Java配置同步:双向同步全链路修复
1. PHP handleUpdateConfig 写入 web_admin_changes 变更队列
2. Java pollAdminChanges 新增 config_change 处理,调用 setAreaConfigValue
3. Timer A 新增 pollAdminChanges 轮询(永不暂停),确保全员离线也能拉取PHP变更
4. 双向同步验证:Java→PHP(Timer D) ✅ / PHP→Java(Timer A) ✅
2026-06-27 19:55:42 +08:00
youpaishidifu 7efa78dbf9 修复三个同步bug:配置即时同步+Timer D独立+债券余额即时推送
1. 配置同步:AreaProtection.updateAreaConfig()后立即触发requestImmediateLandSync()
2. Timer D独立:移除timersBCPaused检查,不再被全员离线暂停
3. Timer D不重复调度:玩家上线时不再重启Timer D(已独立运行)
4. syncLandData增加hash变化日志(便于排查同步问题)
5. 债券余额即时推送(上一轮已修复,本次重新编译)
2026-06-27 19:39:03 +08:00
youpaishidifu 179f3a8355 修复Java CDK离线兑付bug:Timer A增加CDK回拉逻辑
- 在Timer A(永不暂停)中添加本地CDK回拉(doTransactionPollCheck)
- 在Timer A中添加远程CDK验证(pullWebCdkRequestsAndValidate)
- 解决全员离线时Timer B暂停导致PHP兑换的CDK无法到账
- 修复ChatInputManager ClassNotFoundException(重新编译即可)
2026-06-27 18:54:43 +08:00
youpaishidifu f72875f0f7 添加Java→PHP成员权限同步 + getAllPermsForSync方法
1. WebManager新增syncPermissions()方法:读取area_land_permissions全表,推送到PHP的web_area_permissions
2. syncLandData()末尾调用syncPermissions(),每次领地同步时一并推送权限
3. AreaProtection新增getAllPermsForSync():JOIN area_lands获取领地名,返回所有成员权限数据
4. pollAdminChanges已支持perm_change/perm_clear/land_field_change三种回写

修复:玩家端权限页空白(web_area_permissions表永远为空)
2026-06-27 16:53:25 +08:00
youpaishidifu f6d5540b16 修复领地同步:添加land_field_change处理 + effects格式兼容
1. WebManager.pollAdminChanges添加land_field_change处理
   - PHP端玩家修改效果后,Java自动同步到本地DB
   - 防止下次推送时覆盖玩家的修改
2. AreaProtection新增updateLandFieldFromWeb方法
   - 支持白名单字段校验防SQL注入
   - 更新DB后刷新内存缓存
2026-06-27 14:52:34 +08:00
youpaishidifu 3c3e902a6a 领地同步独立定时器D(15~25秒) + 移除批处理中的syncLandData
问题:syncLandData在doActiveSyncBatch第9个任务位,60-90秒批处理+6-8秒间隔=116-146秒延迟
修复:
1. 新增TIMER_D独立定时器,15~25秒随机±5秒,错峰避开A/B/C
2. calcStaggeredDelay支持4个定时器互锁(≥8秒间隔)
3. doActiveSyncBatch移除syncLandData(不再重复调用)
4. 玩家上线恢复时同步重启Timer D
2026-06-27 14:12:23 +08:00
youpaishidifu b83202c08e 修复领地设置不同步:添加即时推送触发器
问题:游戏内修改领地设置后,PHP端超过180秒仍未同步
根因:syncLandData()在doActiveSyncBatch()第9个任务位,每任务间隔6-8秒,加上60-90秒批处理等待,总计116-146秒
修复:
1. WebManager新增requestImmediateLandSync()方法,防抖10秒
2. AreaProtection.saveAreaToDb()调用后立即触发PHP同步
3. deleteAreaFromDb()同样触发即时同步
4. 同步时强制清空hash确保一定推送
2026-06-27 13:32:05 +08:00
youpaishidifu ce0052cdfb 修复:CLI设为管理员按钮全局置顶、返回成员列表按钮、PHP配置同步 2026-06-27 12:27:30 +08:00
youpaishidifu ae2ae77a02 修复:权限切换静默失败(setPlayerPermJson仅UPDATE无行时失败)、管理员切换成员名大小写匹配 2026-06-27 12:03:02 +08:00
youpaishidifu e8c83bba98 CLI权限面板:管理员目标走简化面板,领地主可切换管理员身份 2026-06-26 23:36:55 +08:00
youpaishidifu 642e587cdd CLI权限面板:领地主可切换管理员身份,非领地主仅显示发光权限 2026-06-26 23:33:21 +08:00
youpaishidifu ffd11a0b9c 修复:领地主可操作管理员权限(GUI/CLI)、农作物种植权限补充新作物 2026-06-26 23:19:53 +08:00
youpaishidifu 33afe26b46 修复管理员权限显示和领地配置同步问题 2026-06-26 22:55:23 +08:00
youpaishidifu 30e7f68c85 修复:农作物种植权限+管理员权限隐藏+成员权限合并到用户管理+领地同步列数修复 2026-06-26 22:11:20 +08:00
youpaishidifu a06f7a669b 修复4个区域防护问题:展示架SHELF匹配、领地主不能添加为成员、农作物权限分层(独立denyCropHarvest)、领地同步调试日志 2026-06-26 21:23:20 +08:00
youpaishidifu f333e59621 修复WebManager$2类加载错误: 匿名X509TrustManager改为静态具名内部类TrustAllX509Manager,解决Bukkit类加载器NoClassDefFoundError 2026-06-26 19:49:15 +08:00
youpaishidifu 80c8715264 fix: 3个问题修复
1. 展示架拦截: isUIBlock新增DISPLAY/SHOWCASE/DECORATED_POT宽泛匹配
2. 领地同步hash: 从6个字段扩展到全部关键字段(60+),任何字段变化都触发同步
3. Web自动登录安全: validateWebAccess的view操作增加web_login_verified检查
   - pushPlayerLoginStatus在online=1时同步写入web_login_verified
   - 未通过Java验证的token即使在线也要求密码
   - 防止偷token自动登录
2026-06-26 19:15:54 +08:00
youpaishidifu f67efd0f8d fix: 领地系统7个问题修复
1. 领地主禁止作为成员入库: addLandMember检查owner返回boolean
2. 成员独立权限编辑: permDefs补充denyMobAttack
3. 红石原件扩展: 拉杆、各种按钮、压力板、阳光传感器
4. 容器交互新增展示架: denyContainer同时拦截展示框
5. 管理员身份改为领地级: isLandAdmin/setLandAdmin基于area_land_permissions.role='admin'
6. Timer A定期同步在线玩家: 每10轮(~30-50秒)同步一次保持PHP心跳
7. 领地数据同步修复: PHP sync_lands补充deny_container/deny_mob_attack/deny_fire等新字段,hash检测改为精确字段hash
2026-06-26 18:47:26 +08:00
youpaishidifu 3a3f4f1cd2 fix: SQLite锁库问题 - 定时器串行化+全员下线暂停+PHP WAL checkpoint
Java端改动:
- Timer A 内3个请求串行化(原来并行), 每个间隔2-3秒, 彻底避免PHP锁库
- 新增phpBusyUntil全局退避: 检测到database is locked时暂停所有定时器10秒
- 错峰间隔从5秒提高到8秒
- 全员下线时暂停Timer B/C, 仅保留Timer A(注册登录)
- 玩家上线后自动恢复Timer B/C
- 首次启动加±5秒随机偏移避免精确对齐
- doGet/doPost/doGetHttpFallback/doPostHttpFallback全部加detectPhpBusy检测

PHP端改动:
- busy_timeout从30秒提升到60秒
- 新增walCheckpoint()函数, 每个API请求结束后执行WAL checkpoint释放锁
- sync.php/shop.php/ticket.php/cdk.php/register.php/balance.php全部加checkpoint
2026-06-26 16:58:37 +08:00
youpaishidifu 11e1d27013 修复6项区域防护问题:
1. 移除独立成员权限面板,合并到成员列表左键编辑
2. 新增攻击生物权限(denyMobAttack):字段/DB迁移/事件处理/GUI/CLI
3. CLI效果编辑:增益效果添加等级/时长编辑按钮+聊天输入
4. 投喂动物修复:isAnimalFood检测动物食物+动态命名(禁止投喂/允许投喂)
5. 领地传送修复:GUI传送优先传送点→安全位置搜索(中心+40次随机)
6. 拾取节流:5~10秒随机间隔+5条随机提示轮换
2026-06-26 16:31:39 +08:00
youpaishidifu 3402a56d1c 修复容器管理开关:添加deny_container列的ALTER TABLE迁移\n旧数据库表缺少该列导致INSERT OR REPLACE失败,开关切换后数据丢失 2026-06-26 14:46:31 +08:00
youpaishidifu f2866ada9e 修复GUI管理员按钮和权限切换:
1. 管理员面板显示管理按钮(可切换setadmin/unsetadmin)
2. togglePerm支持动态权限名(禁止点燃/启用点燃等)
3. 管理员提示不再阻止显示管理按钮
2026-06-26 14:13:15 +08:00
youpaishidifu 48e1692822 修复5项区域防护问题:
1. 点燃权限:onIgnite检查denyFire而非denyFireSpread
2. 农作物收获:BlockBreak+右键检测作物方块
3. 权限显示名称:点燃/农作物收获动态命名
4. GUI效果管理tooltip:修正为GUI选择描述
5. 容器扩展:isUIBlock覆盖工作台/铁砧/砂轮等
2026-06-26 14:03:12 +08:00
youpaishidifu 44a9edca1b 修复6项问题:
- GUI袭击开关(启用袭击/禁止袭击双向切换)
- GUI增益效果添加改为可点击选择列表
- GUI添加成员排除领地主人
- 成员权限面板添加管理员状态显示和命令提示
- 新增setadmin/unsetadmin命令设置管理员
- 容器权限独立控制已验证
2026-06-26 13:18:41 +08:00
youpaishidifu ca54dd61ba 修复6项领地系统问题:
- 删除轮询空日志(pollWebLoginConfirmations)
- 添加拾取限制事件监听(PlayerPickupItemEvent)
- 新增独立容器管理权限(denyContainer)
- 管理员权限自动隐藏(per-player编辑跳过)
- GUI添加成员排除自身
- 验证破坏方块权限覆盖所有方块
2026-06-26 12:28:20 +08:00
youpaishidifu 47e1ad3be5 修复和平模式扫描范围+在线玩家同步+领地数据同步 2026-06-26 11:25:57 +08:00
youpaishidifu 46b483848c 修复问题4: GUI效果管理改为可点击选择列表
问题4修复: GUI效果管理
- 移除点击后在聊天栏输入模式
- 新增可点击效果选择列表(与CLI一致)
- 负面效果(16种)、中性效果(3种)、正面效果(20种)
- 点击效果直接添加到清除/增益列表
- 支持重复检测,已添加的效果显示灰色

新增方法:
- openEffectsSelection(): 打开效果选择列表GUI
- getEffectNameBySlot(): 根据槽位获取效果名
- pendingEffectSelection: 存储选择状态
2026-06-26 11:13:12 +08:00
youpaishidifu 40edd85ea7 修复多个问题:空白日志+禁止袭击更名+CDK逻辑简化
问题1修复: 移除空白日志
- 移除Web登录确认轮询和密码验证轮询的PHP响应日志
- 这些日志在data为空时输出无意义内容

问题6修复: 禁止袭击更名
- 启用状态(denyRaid=true)显示禁止袭击
- 禁用状态(denyRaid=false)显示启用袭击
- 修改AreaGUIManager和AreaCLIManager显示逻辑

问题3修复: CDK兑换逻辑简化
- PHP不再更新bond_cache,只记录pending交易
- Java端负责拉取交易并处理加债券
- 远程CDK和本地CDK统一处理逻辑
2026-06-26 10:59:50 +08:00
youpaishidifu 59313d4f32 问题10修复: CDK兑换不到账+假密码+隐藏密码框
CDK兑换修复:
- PHP远程CDK兑换改为写pending交易(cdK_redeem+pending)
- PHP远程CDK兑换同时更新bond_cache
- Java端移除sdf1反射调用(不必要的复杂度)
- 远程/本地CDK兑换链路统一:PHP更新cache→写pending→Java拉取→addBonds

假密码/密码框修复:
- player.php商城购买: 总价≤1000时隐藏密码输入框
- player.phpCDK兑换: 完全移除密码输入框(CDK免验证)
- shop.php移除硬性密码检查,交给validateWebAccess处理
- 动态切换:调整数量时密码框实时显示/隐藏
2026-06-26 10:21:35 +08:00
youpaishidifu c86e9f76b9 @ SeniorDeveloper 修复领地系统10项问题:空白日志、CLI效果管理、GUI验证、在线玩家同步、领地数据同步、领地所有权验证、用户端UI优化、效果管理、和平模式修复 2026-06-25 23:46:57 +08:00
youpaishidifu 142b15973a v23: 修复定时器A启动延迟 - 双条件判断
问题:
- 定时器A每次执行都检查initialSyncComplete,只有全量同步完成后才执行轮询
- 全量同步耗时60+秒,导致Web登录后60秒才开始轮询
- 玩家在20:09:19加入,20:10:01超时下线,20:10:19才开始轮询(延迟60秒)

修复:
- 新增allowLoginPolling标志,双条件:initialSyncComplete OR 玩家在线
- 玩家onJoin时立即设置allowLoginPolling=true(不等全量同步完成)
- 定时器A改为检查allowLoginPolling,有玩家在线时立即开始轮询
- 确保不影响定时器B/C的initialSyncComplete逻辑
2026-06-25 20:17:24 +08:00
youpaishidifu 3e21bbb112 v22: 简化Web登录确认逻辑+缩短轮询间隔到3~5秒
问题1 - 玩家Web登录后,Java拒绝自动登录(phpVerified=false):
- pollWebLoginConfirmations双重验证太严格:Java内存记录 AND PHP验证状态都为空时拒绝
- 根因:Java重启/内存清理后verifiedWebLogins为空,PHP的web_login_verified也无记录
- 但web_login_confirmations记录本身已代表通过安全验证(PHP verifyWebPassword 已禁用)

修复1 - 安全逻辑简化:
- 有确认记录 = 已通过某种安全验证,直接允许自动登录
- 移除「Java和PHP均无验证记录则拒绝」的严格检查
- 记录PHP验证状态仅用于日志诊断,不再作为拦截条件

问题2 - 轮询间隔8~15秒太长,导致Web登录后66秒才自动登录:
- 修复2:定时器A间隔缩短到3~5秒,快速响应登录请求
2026-06-25 20:00:38 +08:00
youpaishidifu df6cc2bea8 v21: 修复Web登录同步失败+全链路日志
问题根因:
- pollWebLoginConfirmations只检查Java内存isWebLoginVerified
- 邮箱验证码登录时PHP直接写web_login_confirmations,Java从未调用recordWebLogin
- 导致hasVerifiedRecord=false,自动登录被安全拦截

修复内容:
- PHP端:check_web_login_confirmations返回php_verified字段(查询web_login_verified表)
- Java端:pollWebLoginConfirmations信任PHP验证状态,Java无记录但PHP有→信任PHP并写入
- 全链路日志增强:轮询开始/PHP响应/安全检查/回写结果全部打印详细日志
- PHP端:webLoginRequest/completeWebLoginRequest/checkPendingWebLogins增加debugLog
2026-06-25 19:35:10 +08:00
youpaishidifu d23ce56606 v20: 修复锁库频率+URI undefined scheme错误
问题1 - 锁库超预期(每分钟2次):
- 定时器A间隔0~5秒太短,三个并行任务同时打PHP
- 修复:定时器A间隔改为8~15秒,减少锁库概率

问题2 - URI with undefined scheme:
- pollAdminChanges的ackUrl是相对路径/api/land_api.php?...,缺少webBaseUrl前缀
- 修复:ackUrl加上webBaseUrl前缀
- 附带修复:poll_admin_changes的secretKey未URL编码,已加上URLEncoder.encode
2026-06-25 19:15:57 +08:00
youpaishidifu eb3c336e38 v19: 修复Web登录验证链路 - verifiedWebLogins永不写入导致自动登录失败
问题根因:
- pollWebLoginRequests验证密码成功后不写verifiedWebLogins(注释写了'不在验证成功时写入')
- pollWebLoginConfirmations检查isWebLoginVerified永远返回false → 自动登录被拒绝
- 导致PHP确认登录成功但Java不自动登录玩家

修复内容:
- 新增recordWebLogin()方法:密码验证成功时立即写入verifiedWebLogins
- pollWebLoginRequests:密码验证成功后调用recordWebLogin
- pollWebLoginConfirmations:增强安全检查日志
- isJavaLoginRecorded:增加详细诊断日志(记录数、过期时间等)
2026-06-25 18:58:44 +08:00
youpaishidifu f1cd0da7d4 v18 修复快速重连:新增javaLoginRecords持久化登录记录(5分钟),检查点1放行同IP重连;添加PHP响应日志 2026-06-25 18:44:42 +08:00
youpaishidifu 329bca61a9 v17 合并定时器重构:6个独立定时器→3个合并错峰定时器(A注册登录0~5s B交易0~10s C其它10~20s),自动错峰≥5秒,消灭锁库 2026-06-25 18:22:03 +08:00
youpaishidifu b6863c45c7 修复: 自动登录逻辑重构+轮询间隔增大降低DB锁 2026-06-25 18:03:45 +08:00
youpaishidifu 2867c7614a 优化数据库锁检测:修复注册轮询锁库逻辑,为CDK轮询和领地同步添加锁库检测,调整交易/库存轮询间隔为8±5秒 2026-06-25 17:33:43 +08:00
youpaishidifu 23337695b4 v15: 修复PHP假密码绕过 + 快速重连安全检查
安全修复(严重):
- WebManager.pollWebLoginRequests(): 不再在handleWebPasswordVerify成功后写入verifiedWebLogins
  - 之前逻辑:PHP推送密码给Java → Java验证成功 → 自动写入verifiedWebLogins → onJoin自动登录
  - 漏洞:PHP端如果输入假密码/错误密码,Java不会验证(Java验证只基于login.db),verifiedWebLogins不会被设置
  - 但pollWebLoginConfirmations()检查isWebLoginVerified()失败后,玩家快速重连仍被canAutoLogin放行
- Main.onJoin(): 快速重连检查(canAutoLogin)之前增加isWebLoginVerified()检查
  - 玩家在Web端通过Java验证登录过 → verifiedWebLogins有记录 → 5分钟内重连放行
  - 玩家无Java验证记录 → 拒绝快速重连(即使用IP快速重连也不行)
  - 解决:PHP假密码/错误密码 → Java不会设置verifiedWebLogins → 快速重连被拒
2026-06-25 16:24:15 +08:00
youpaishidifu 15f473abc8 v14: Java快速重连三层检查(纯内存)+ 回退DB持久化
快速重连修复(按流程图严格实现):
- 检查点1: 检查loggedIn集合中是否有Java手动登录记录 → 有则继续正常流程
- 检查点2: 检查verifiedWebLogins内存中是否有PHP验证记录 → 有则自动登录
- 检查点3: 上述2路都失败 → 强制重新验证密码
- autoGenerateWebLoginToken和syncUserOnJoin移到检查点1/2之间执行

回退DB持久化:
- DatabaseManager.java删除web_verified_at列定义
- WebManager.java isWebLoginVerified()回退为纯内存检查
- WebManager.java clearWebLoginVerified()回退为纯内存清除
- pollWebLoginRequests()验证成功后不再写入login.db

部署:
- JAR: 733300 bytes → SFTP ✅
2026-06-25 15:15:07 +08:00
youpaishidifu d47997294d v13: 安全加固 - PHP禁止自验证密码 + Java本地验证记录持久化
安全修复(严重隐患):
- PHP端: verifyWebPassword()函数体改为error(),禁止PHP直接验证密码
- Java端: pollWebLoginConfirmations()增加本地验证记录检查,PHP推送的登录确认必须有Java本地验证记录才允许自动登录
- Java端: 验证成功后写入login.db的web_verified_at字段(重启不丢失)
- Java端: isWebLoginVerified()增加login.db回查(内存没有时从DB恢复)
- Java端: clearWebLoginVerified()同时清除DB记录
- Java端: pollWebLoginConfirmations消费后立即清除验证记录(防重放)
2026-06-25 14:41:25 +08:00
youpaishidifu 6d569fed23 v12: PHP密码验证安全修复 + 任务间延迟6-14秒随机
安全修复:
- sync.php: 删除verify_web_password端点,禁止PHP自验证密码
- core.php: validateWebAccess大额交易密码验证改为检查web_login_verified(Java验证结果)
- 密码验证强制走web_login_request流程(Java端handleWebPasswordVerify)

锁库优化:
- 任务间随机延迟从10-12秒改为6-14秒(8秒变异范围)
- 增大随机性,避免周期性锁争用
2026-06-25 14:13:31 +08:00
youpaishidifu 6e24546a6c v11: SSL断路器防重复触发 + HTTP降级延长冷却 + 启动同步锁库修复
SSL断路器:
- 新增triggerCircuitBreaker()安全方法,并发请求不再重复触发
- HTTP降级也失败时立即延长冷却(CDN完全不可达场景)
- 基础冷却30秒→60秒(CF恢复通常1-5分钟)
- GET/POST/HTTP降级失败统一走triggerCircuitBreaker

启动锁库:
- 新增initialSyncComplete标志,注册/CDK/库存轮询器等待全量同步完成
- 避免启动阶段多个轮询器与全量同步并发请求PHP导致SQLite锁
2026-06-25 13:42:34 +08:00
youpaishidifu eb0b39d8b9 v10: SSL断路器指数退避 + 超时统一10秒 + 延迟增到10-12秒
- SSL断路器冷却从固定30秒改为指数退避: 30s→60s→120s→240s→300s(上限)
- 新增sslCircuitOpenCount跟踪连续断路次数,成功时重置
- 请求超时统一10秒(connectTimeout和request timeout一致)
- 任务间随机延迟从8-10秒增到10-12秒,进一步减少锁库
- HTTP降级成功也重置退避计数
2026-06-25 13:27:05 +08:00
youpaishidifu 10e148be5f v9: 修复pollAdminChanges 404 + 延迟增到8-10秒
- 修复 ack_admin_changes double URL bug(httpPost重复拼接webBaseUrl)
- 改用GET方式发送ack,避免POST Content-Type不匹配
- land_api.php 支持GET参数ids
- 所有任务间随机延迟从6-8秒增到8-10秒
- 目标:锁库降到0-1次/分钟
2026-06-25 13:05:41 +08:00
youpaishidifu 9599a66cc5 增强: 随机间隔从±8秒扩大到±10秒;任务间随机延迟从4-6秒增加到6-8秒;减少PHP端SQLite锁冲突 2026-06-25 12:46:45 +08:00
youpaishidifu c05c6d9512 修复: SSL降级触发条件优化-任何连接异常都计数;任务间随机延迟从2-4秒增加到4-6秒;增加SSL计数器在POST最终失败时 2026-06-25 12:21:25 +08:00
youpaishidifu 2a976710e9 增强: 随机间隔扩大到±8秒;所有同步任务间改为随机2-4秒延迟;拆分同步添加0-2秒随机延迟避免并发锁库 2026-06-25 12:07:48 +08:00
youpaishidifu c2a8385895 优化: 首次全量同步和周期同步任务间错开1500ms,避免PHP端SQLite数据库锁 2026-06-25 11:53:58 +08:00
youpaishidifu 3c71664284 修复: 404问题-在线玩家同步GET返回404时触发分批POST拆分(每人一批); 库存轮询增加DB锁错误检测; 随机间隔扩大到±5秒 2026-06-25 01:44:46 +08:00
youpaishidifu c9e2f71361 增强: 随机间隔扩大到±5秒减少DB锁冲突;库存轮询增加数据库锁错误检测 2026-06-25 01:19:47 +08:00
youpaishidifu 3c085e086d 优化: 全量同步任务间错开800ms执行,避免瞬间堆叠导致DB队列等待过久和HTTP 404 2026-06-25 00:49:31 +08:00
youpaishidifu 28a120fea0 修复: 补充rebuildHttpClient方法和initPlainHttpClient方法,实现SSL降级到HTTP功能;添加randomIntervalSeconds随机间隔定时器,避免并发SQL锁死 2026-06-25 00:19:46 +08:00
youpaishidifu dee6ab8eed feat: SSL连续失败3次自动降级到HTTP + 编译部署 2026-06-24 23:06:03 +08:00
youpaishidifu 5273220716 修复:poll_admin_changes返回JSON包含Unicode转义序列导致解析404 2026-06-24 21:28:42 +08:00
youpaishidifu e416372a41 修复:GUI效果管理回调子页面保持、PVP双端检查、传送安全位置、游戏模式管理员豁免 2026-06-24 20:33:35 +08:00
youpaishidifu bbf23aad31 fix: 领地系统多项修复 - 管理员TAG验证/和平模式/袭击模式命名/效果管理GUI/添加成员功能 2026-06-24 18:32:25 +08:00
youpaishidifu 7ae4f46f75 fix: 修复CLI效果管理点击返回回到首页问题,去除返回按钮中的clickEvent导致 2026-06-24 15:38:17 +08:00
youpaishidifu 4a74a0a77f feat: 新增效果管理功能 — CLI/GUI可视化操作 1.CLI效果管理菜单: 全清负面/禁止所有效果/单清列表/增益列表 2.CLI快捷指令: effectsclearall effectsdenyall effectsclearadd effectsclearremove effectsaddadd effectsaddremove 3.GUI效果管理: 管理领地菜单新增效果管理入口, 3个子菜单(GUI可视化操作) 2026-06-24 14:27:02 +08:00
youpaishidifu 9944fd5f16 Fix area protection: perm renames, DB save fix, perm_change sync, teleport command 2026-06-24 13:07:11 +08:00
youpaishidifu 3662466434 chore: 从main分支移除web文件夹(PHP代码归php分支管辖) 2026-06-24 11:17:18 +08:00
youpaishidifu 302268d0da fix: 领地系统4个bug修复
1. CLI用户权限管理: 添加shortKeyToDenyField映射修复键名不匹配
2. 移除用户: 修复大小写敏感导致的移除失败
3. 权限组反转: 修复togglePerm反馈消息语义(deny=true→已禁止)
4. GUI工具: BLAZE_ROD替代STICK
2026-06-24 11:08:55 +08:00
youpaishidifu 6ec438abd9 fix: parseJson数组解析 + WebManager ClassCastException修复
- addParsedEntry增加JSON数组识别,存为List<Map>而非String
- 新增parseJsonArray/parseJsonArrayItem递归解析嵌套数组
- 修复pollAdminChanges中List类型强转失败(class String cannot be cast to List)
2026-06-24 10:25:36 +08:00
youpaishidifu 9e3182e1a1 fix: 8项问题修复
1. PHP登录安全: player.php改用web_login_request走Java验证
2. 圈地工具改为BLAZE_ROD(避免投影mod冲突)
3. CLI更新后打印新版配置内容
4. 智能数字解析: 支持+/-前缀、中英文/罗马数字、封底值
5. 删除upload_sftp.py敏感文件并加入gitignore
6. CLI成员权限页面添加所有者/管理员权限检查
7. GUI模式移除冗余sendMessage调用(消息不外泄)
8. AreaCLIManager变量名冲突修复
2026-06-24 01:22:59 +08:00
youpaishidifu 8cd380a93b feat: 成员独立权限系统 + PHP→Java同步 + SSL兼容性 2026-06-23 23:46:44 +08:00
youpaishidifu a3030c3b80 feat: 8项修复 - 圈地工具冷却/删除取消/CLI配置/智能数字解析/SSL重连
1. 圈地工具5秒冷却+拥有检测(CLI+GUI)
2. 删除冷却期间权限修改/成员变更自动取消删除
3. CLI全局配置页面(点击可修改各配置项)
4. GUI管理员面板改为聊天栏输入+智能数字解析
   支持: 纯数字、中文数字、中文大写、罗马数字、英文数字
5. SSL: 强制TLSv1.2+CF兼容密码套件+断路器冷却后重建HttpClient
6. SSL断路器增强: submitDbTask/getDoGet/doPost前置检查
2026-06-23 22:30:12 +08:00
youpaishidifu 2a5db378d3 fix: SSL断路器增强 - 多层防护避免SSL握手风暴
问题:SSL握手失败时,多个轮询定时器(5秒/2秒/3秒)并发提交任务,
导致连续失败计数飙升(58-69次),断路器反复触发但无法有效阻断。

修复:
1. submitDbTask()添加断路器检查 - 断路期间跳过所有DB任务
2. doGet/doPost/doGetStatus/doPostWithRetry添加断路器前置检查
3. 断路期间不再发送任何HTTP请求,避免无意义的SSL握手
4. 60秒冷却期结束后自动恢复

部署:JAR已上传MC服务器
2026-06-23 21:26:33 +08:00
youpaishidifu 05ebd07df6 领地系统8项修复: SSL断路器/计费/GUI-CLI切换/删除取消/管理细分/配置释放/txt迁移 2026-06-23 19:52:55 +08:00
youpaishidifu 542879177c feat: 新增AreaCLIManager交互式CLI菜单系统
- CLI主菜单:创建领地/领地列表/权限商店/信息/切换模式
- 领地列表:分页显示,每条有[管理][传送][删除]可点击按钮
- 权限管理:分页显示29项权限,每项有✔/✘标记+切换按钮
- /protect无参数时CLI模式直接打开交互菜单
- AreaProtection新增getAllLands()公开方法
2026-06-23 18:15:31 +08:00
youpaishidifu 69c9e0ceb9 @
领地系统大修:新增10个权限事件处理器 + GUI权限补全 + 删除二次确认修复

新增事件处理器:
- ProjectileLaunchEvent: 投掷物检测(三叉戟/雪球/风蛋)
- PlayerInteractEvent: 门禁交互(按钮/门/压力板/绊线钩)
- NotePlayEvent: 音符盒交互
- JukeboxPlay(InteractEvent): 唱片机交互
- PlayerInteractEvent: 红石交互(中继器/比较器)
- PlayerInteractEntityEvent: 拴绳使用
- BlockGrowEvent: 农作物收获
- PlayerShearEntityEvent: 剪切羊毛/生物
- EntityBreedEvent: 投喂动物
- RaidTriggerEvent: 袭击侦测
- 周期性扫描: 玩家发光检测

修复:
- GUI权限列表: "PVP"→"玩家对战", "Raid"→"袭击侦测", 补全全部29项权限
- 删除二次确认: 防止绕过1分钟延迟
- 移除重复import, 修复InventoryHolder缺失
@
2026-06-23 17:57:24 +08:00
youpaishidifu 58cc9677ae fix: 迁移INSERT列数不匹配(45→46) + 移除未使用import 2026-06-23 17:17:18 +08:00
youpaishidifu c691e9c44f @
领地系统:修复DB列数不匹配(59→61) + 用户UI偏好设置

- 修复saveAreaToDb INSERT语句缺少2个?占位符(warp_pitch/warp_world)
- 新增 /protect uimode cli|gui 命令切换UI偏好
- 主菜单39号位添加领地系统入口,读取偏好决定GUI/CLI
- /protect无参数时读取偏好,自动选择GUI或CLI模式
@
2026-06-23 17:13:18 +08:00
youpaishidifu 70b8e991da feat: 领地系统大规模重构
1. 全局配置: area_config表存储创建价格/每人上限/默认高度等
2. 新增10+权限: 投掷物/发光/红石/门禁/音符盒/拴绳/农作物/羊毛/动物投喂
3. 传送点: settp设置传送点,tp传送到领地传送点
4. 删除安全: 1分钟延迟+二次确认(输入完整领地名),操作自动取消
5. 容器权限: 覆盖所有BlockState容器+Container接口
6. 安全传送: 头顶≥2格空气+脚下实体方块+距边界≥3格,否则世界出生点
7. 和平模式: 最长1小时(3600秒)强制订正
2026-06-23 16:44:05 +08:00
youpaishidifu ab2c4da6de fix: CLI命令占位符+PHP领地API认证修复+毛玻璃弹窗
1. CLI: 移除help中含<领地>占位符的可点击命令,避免用户点击后传入字面量
2. PHP land_api.php: validateToken→validateTokenSilent,兼容weblogin_tokens
3. PHP land_api.php: POST操作区分syncActions/adminActions/playerActions三级认证
4. PHP admin.php: 所有领地管理操作携带secret参数
5. PHP core.php: busy_timeout从15s提升到30s,加wal_autocheckpoint
6. player.php: 所有alert()→glassAlert(),confirm()→glassConfirm()
7. player.php: 新增毛玻璃弹窗CSS+HTML+JS,居中+backdrop-filter+淡入淡出
2026-06-23 15:50:25 +08:00
youpaishidifu 9f185df6d9 安全修复: 从git中移除敏感上传/调试脚本并加入.gitignore 2026-06-23 14:36:15 +08:00
youpaishidifu ce13396c22 feat: 合并PHP Web后端到仓库 - 统一代码管理\n\n将D:\桌面\源码\mc_plugn\Web\的PHP文件合并到web/子目录\n- 包含所有API端点(sync.php, register.php, shop.php等)\n- 包含前端页面(admin.php, player.php, login.php等)\n- 排除Web.db、调试日志、备份文件\n- .gitignore更新:忽略web/Web.db、debug_log_tail.txt、.bak文件 2026-06-23 14:11:40 +08:00
youpaishidifu 439da63793 fix: 领地系统8个核心问题修复
1. SQL列数不匹配(saveAreaToDb): INSERT补齐46列+deny_move/pickup/fire/txt_content
2. Owner入库: saveAreaToDb用?绑定owner值(loadAreasFromDb读取owner)
3. denyMove传送出去: 新增findSafeExitLocation(),无权限玩家传送到领地边缘
4. 容器权限: 新增onInventoryOpen(InventoryOpenEvent)阻止未授权玩家开箱
5. CLI setowner自动分配: 管理员无参数时自动设当前领地所有者为自身
6. txt配置文件: 新增LandConfig类+区域防护配置.txt(创建价格/高度/和平模式等)
7. 权限只对访客: denyEnderPearl/denyBow增加hasPermission OWNER检查
8. 数据库兼容: ALTER TABLE添加deny_move/pickup/fire列(兼容旧库)
2026-06-23 13:41:57 +08:00
youpaishidifu 92552f6007 fix: PHP 500修复 - HttpClient降级HTTP/1.1 + 错误响应体日志
根因:java.net.http.HttpClient默认HTTP/2与CF代理的某些请求模式不兼容
修复:强制HTTP/1.1(与curl行为一致,curl测试正常返回200)
附加:doGet/doPost在非2xx时记录响应体,便于诊断PHP端错误
2026-06-23 13:02:26 +08:00
youpaishidifu 152ebc2dc6 fix: 彻底替换HttpsURLConnection为java.net.http.HttpClient - 解决CF SSL握手失败
根因: HttpsURLConnection内部调用factory.createSocket()(无参版本)绕过了自定义工厂配置
方案: 改用java.net.http.HttpClient原生HTTP客户端,原生支持ALPN/SNI/TLS/HTTP2
- initSSL()重写为创建HttpClient实例
- 新增doGet/doPost/doPostWithRetry/doGetStatus统一请求方法
- 全部29处HttpURLConnection替换为新方法
- TrustAllCerts保留,SSLContext使用通用TLS自动协商
2026-06-23 12:35:13 +08:00
youpaishidifu 1550e3a461 fix: 重写SSL初始化 - 自定义SSLSocketFactory逐socket配置TLS1.2+CF兼容密码套件 2026-06-23 11:34:05 +08:00
youpaishidifu 575e54a27a 修复SSL握手异常:添加Cloudflare兼容模式,设置SNI和密码套件 2026-06-23 11:08:24 +08:00
youpaishidifu d9e3b74fd9 实现CLI可点击聊天消息触发命令,与DOS和GUI三端兼容 2026-06-23 10:44:01 +08:00
youpaishidifu fa9c5cf601 修复denyMove权限检查、成员管理操作修改、优化任务队列优先级、登录轮询改为事件驱动 2026-06-23 10:30:25 +08:00
youpaishidifu cd2d94c9d0 修复protect命令参数问题、修复文档链接、添加用户UI偏好列(ui_mode)、txt迁移领地自动分配给管理员 2026-06-23 01:10:01 +08:00
youpaishidifu de3e1c8e83 修复库存高频拉取队列积压问题:添加去重机制,避免重复提交相同任务 2026-06-23 00:42:55 +08:00
youpaishidifu 34505ea2f5 添加区域防护GUI菜单系统:主菜单、领地列表、管理领地、成员列表、访客权限、领地设置 2026-06-23 00:19:19 +08:00
youpaishidifu b5d6519d11 为syncBondBalances和syncUserRegistrations添加基于哈希的变化检测,实现无变化静默 2026-06-22 23:44:01 +08:00
youpaishidifu 73bee83051 feat: 实现DB写入排队系统 + AreaProtection升级 + 领地管理
- WebManager: 添加PriorityBlockingQueue数据库写入队列
  - 登录操作LOGIN_PRIORITY=1(最高)
  - 普通同步NORMAL_PRIORITY=10
  - 单线程串行化,防止并发SQLite锁
  - 所有sync/poll方法通过队列执行
- AreaProtection: 三级权限系统(ADMIN/OWNER/VISITOR)
  - 数据库存储(txt→db迁移)
  - 20+事件监听器
  - CLI命令: info/setowner/addvisitor/transfer/shop
  - 权限商店(1000债券起,24h上限,80/20分成)
- ConfigManager: 区域防护配置项
- Main: 铁砧改名颜色代码(&→§)+修复help拦截
- sync.php: 添加领地同步接口(sync_lands/sync_land_shop)
2026-06-22 22:37:38 +08:00
youpaishidifu 7d8dc8f4c9 docs: 前置插件链接改为对应平台独立仓库地址 2026-06-22 17:31:26 +08:00
youpaishidifu 07791fa613 docs: 修复前置插件链接为wiki绝对路径 2026-06-22 17:21:33 +08:00
youpaishidifu fed72b773f docs: README同步文档站最新版,下载地址改为Wiki链接 2026-06-22 17:14:21 +08:00
youpaishidifu ebcb11338c fix: 修复Web注册不写注册IP、玩家登录IP的bug; 新增库存高频轮询; 更新README下载地址 2026-06-22 16:26:17 +08:00
youpaishidifu 8d4731a873 fix: 添加官方仓库下载链接 2026-06-20 14:18:02 +08:00
youpaishidifu 203c9d423a 新增Web CDK接口,可对接Web系统进行兑换 2026-06-20 14:02:41 +08:00
youpaishidifu 2c7f765fb0 初始化登录插件 2026-06-19 16:00:29 +08:00
ypsdf1 8072855d94 Java:新增ip推送端口,后端可以查看玩家ip及归属
php:新增ip属地、实时在线数据,可以查看玩家ip、归属和实时在线情况
2026-06-18 12:37:53 +08:00
ypsdf1 873aca91bf feat: 和平模式修复ENDERMAN/WITHER、自定义菜单事不过三、BaoTa第三路备选
- AreaProtection: HOSTILE_TYPES添加ENDERMAN和WITHER;末影人使用remove()替代teleport()避免自动传送回来
- GUIManager+Main: 宝箱自定义物品存入菜单时事不过三原则(前两次警告,第三次没收)
- UpdateChecker: 添加BaoTa HTML第三路备选通道,GitHub/Gitee限流后自动降级
2026-06-08 13:11:50 +08:00
ypsdf1 bcca065931 修复README中sdf1和CY_beibao链接指向Gitee改为GitHub 2026-06-07 19:12:51 +08:00
ypsdf1 a34cc92327 修复自定义菜单物品不生效问题,新增阻止sdf1_game宝物投入垃圾箱 2026-06-07 18:40:18 +08:00
ypsdf1 44302d8eff fix: 补充缺失的商城/PVP/购物车截图 2026-05-29 21:43:18 +08:00
ypsdf1 7624b7cb7d docs: 适配Gitee依赖链接 2026-05-29 21:16:46 +08:00
ypsdf1 a4b09254f4 docs: 同步README.md,新增区域防护/和平模式白名单/参数排序说明 2026-05-29 21:15:06 +08:00
ypsdf1 68a6d2e7d4 feat: 修复无参数指令报错、新增区域和平模式白名单、指令参数自定义排序功能 2026-05-29 21:11:50 +08:00
ypsdf1 7d70f8f92e docs: 适配Gitee依赖链接 2026-05-29 00:18:24 +08:00
ypsdf1 c66ec5eaaa feat: 新增区域防护,修复无参数指令报错问题 2026-05-29 00:17:56 +08:00
ypsdf1 aa719dc2da docs: 修正前置插件链接为对应仓库地址 2026-05-26 16:11:49 +08:00
ypsdf1 32371a6e30 docs: 恢复本地README相对路径链接 2026-05-26 16:09:16 +08:00
ypsdf1 7953e9de13 docs: 更新sdf1_login的新功能 2026-05-26 16:08:51 +08:00
ypsdf1 038e2f3e87 docs: 更新sdf1_login的新功能 2026-05-26 16:08:27 +08:00
ypsdf1 a35ddb8418 merge: 合并 Gitee 远程提交,保留本地新增优惠券/订单管理功能 2026-05-26 15:45:58 +08:00
ypsdf1 3c65360f9c feat: 新增优惠券、订单管理等功能 2026-05-26 15:42:42 +08:00
ypsdf1 7b0722b039 feat: 支持/shop和/商店两种指令打开商店,同步最新文档 2026-05-23 20:42:42 +08:00
ypsdf1 49fb264378 feat: 支持/shop和/商店两种指令打开商店,同步最新文档 2026-05-23 20:42:27 +08:00
ypsdf1 7dca1a71e1 docs: 修正 README 中 sdf1 链接为 Gitee 仓库地址 2026-05-23 19:09:07 +08:00
ypsdf1 704b8a03d9 docs: 修正 README 中 sdf1 链接为 GitHub 仓库地址 2026-05-23 19:08:51 +08:00
ypsdf1 387fad8bdd docs: 同步 README.md 为最新文档版本,补充虚拟货币/商城/CDK/PVP等功能说明 2026-05-23 19:04:59 +08:00
ypsdf1 acc4085509 merge: 合并远程提交(PVP战神榜/债券系统),保留本地虚拟货币/商城系统修改 2026-05-23 19:02:37 +08:00
ypsdf1 a75be0ebae feat: 新增虚拟货币系统和商城系统 2026-05-23 19:01:10 +08:00
ypsdf1 325ec9f877 更新sdf1插件新功能 2026-05-23 18:40:32 +08:00
ypsdf1 851375502d fix/feat: 修复 PVP 区域实时榜显示为总榜的问题,新增虚拟货币债券系统(BondManager/BondPrinter/CDKManager/UserGroupManager) 2026-05-22 14:33:07 +08:00
ypsdf1 c9da8c8ec2 feat: 新增 PVP 战神榜功能,新增 PVPManager 管理排行榜 2026-05-20 20:29:06 +08:00
256 changed files with 189811 additions and 4278 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Auto Release
on:
push:
branches: [main]
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup JDK 25 (Temurin)
uses: actions/setup-java@v4
with:
java-version: '25'
distribution: 'temurin'
- name: Verify Java
run: |
echo "JAVA_HOME=$JAVA_HOME"
java -version
- name: Build with Maven
run: mvn -B clean package -DskipTests
- name: Verify JAR
run: ls -la target/*.jar
- name: Create Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
run: |
TAG="build-${{ gitea.run_number }}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Build ${{ gitea.run_number }}\",\"body\":\"Commit: ${{ gitea.sha }}\nBranch: ${{ gitea.ref_name }}\",\"draft\":false,\"prerelease\":false}" \
"${BASE}/releases"
RELEASE_ID=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
"${BASE}/releases/tags/${TAG}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -n "$RELEASE_ID" ]; then
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@target/Sdf1_login.jar" \
"${BASE}/releases/${RELEASE_ID}/assets"
echo "Release ${TAG} created with JAR"
else
echo "Release tag ${TAG} created (JAR attach may need manual check)"
fi
+34
View File
@@ -0,0 +1,34 @@
name: Auto Release
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 26
uses: actions/setup-java@v4
with:
java-version: '26'
distribution: 'zulu'
- name: Build with Maven
run: mvn -B clean package -DskipTests
- name: Verify JAR exists
run: ls -la target/Sdf1_login.jar
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: build-${{ github.run_number }}
name: Build #${{ github.run_number }}
files: target/Sdf1_login.jar
generate_release_notes: true
+41 -1
View File
@@ -42,5 +42,45 @@ build/
txt/
*.xlsx
*.patch
backup/
新建*.txt
群戏归类.txt
群戏归类.txt
upload_sftp.py
# PHP Web 后端
web/Web.db
web/debug_log_tail.txt
web/upload_debug.ps1
web/api/*.bak*
# 敏感文件 - 禁止提交上传/调试脚本
web/upload_*.php
web/self_update.php
web/version_check.php
web/debug_*.php
web/db/debug_*.php
# Windows 保留设备名(无法用普通 rm 删除)
nul
con
prn
aux
com1
com2
com3
com4
com5
com6
com7
com8
com9
lpt1
lpt2
lpt3
lpt4
lpt5
lpt6
lpt7
lpt8
lpt9hs_err_pid*.log
hs_err_pid*.log
replay_pid*.log
+10 -10
View File
@@ -1,10 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6 -6
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+11 -13
View File
@@ -1,14 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="26" project-jdk-type="JavaSDK" />
</project>
Generated
+5 -5
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+21
View File
@@ -0,0 +1,21 @@
## 2026-07-04 13:33 - 新玩家验证码系统 + 全场景链接过滤 + 私信URL拦截
### 验证码系统实现
- ChatFilterManager 新增 VerificationResult 枚举(NEED_VERIFICATION/PENDING/VERIFIED/FAILED)
- 新玩家首次发消息时触发数学题验证码(10以内加减乘除,随机选择)
- 30秒有效期,答错/超时/非数字答案 → 吞噬消息
- 答对 → 插件代为广播缓存的原始消息(无前缀,像普通聊天一样)
- 老玩家(注册>1小时)快速验证后自动放行
- pendingMessages Map 缓存未验证消息,验证通过后广播
### 全场景链接过滤
- 告示牌(onSignChange): 已有的 filterLinkInContent 拦截
- 编辑书(onPlayerEditBook): 通过反射调用 getNewBook() 获取书页内容(兼容Paper 1.21.4)
- 私信(onPrivateMessage): 新增 PlayerCommandPreprocessEvent 拦截 /tell /msg /w /r 命令中的链接
### 编译部署
- Maven clean package 编译通过
- 推送到 git.ypshidifu.cn (main 分支)
- JAR 上传到 SFTP 服务器
- PHP 文件上传到 FTP /plugin/ 目录
+224 -19
View File
@@ -4,30 +4,36 @@
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态
| :--- | :--- | :---
| 登录 | 用户登录/注册 | 已完成
| 世界垃圾箱 | 清理服务器凋落物 | 已完成
| 工单系统 | 用户提交工单 | 已完成
| 签到系统 | 签到积分换奖励 | 已完成
| 新人礼包 | 新玩家7日礼包 | 已完成
| 挂机检测 | 挂机检测踢出 | 已完成
| 自定义菜单 | 可以自己diy指令 | 已完成
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工
| 名字 | 描述/作用 | 施工状态 |
| :---: | :---: | :---: |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
| 新人礼包 | 新玩家7日礼包 | 已完成 |
| 挂机检测 | 挂机检测踢出 | 已完成 |
| 自定义菜单 | 可以自己diy指令 | 已完成 |
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工 |
| 商城系统| 内置游戏商人可供销售商品 | 已完工 |
| 虚拟货币 | 供签到、游戏商人使用的货币系统 | 已完工 |
## 2.安装说明:
| 前置插件 | 选装类型 |
| :--- | :---
| [CY_beibao](https://pan.ypshidifu.cn/s/Ygc0) | 可选
| EssentialsX | 可选
| 前置插件 | 选装类型 | 功能作用 |
| :--- | :--- | :--- |
| [CY_beibao](https://git.ypshidifu.cn/youpaishidifu/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量 |
| EssentialsX | 可选 | 主流经济系统 |
| [sdf1](https://git.ypshidifu.cn/youpaishidifu/Sdf1_plugin) | 可选,推荐 | CDK兑换系统 |
### 2.1 下载
您可以从GitHub和Gitee下载到插件,将其丢入服务器plugin目录,重启服务器即可。
您可以从GitHub和Gitee、gitcode下载到插件,将其丢入服务器plugin目录,重启服务器即可。
### 2.2.2 配置文件
### 2.2. 配置文件
#### 2.2.2.1 消息.txt
#### 2.2.1 消息.txt
此文件控制着整个插件的所有提示消息,您可以根据自己的需求修改,修改后需要执行 /sdf1_login reload 重载插件。支持[颜色代码](https://zh.minecraft.wiki/w/%E6%A0%BC%E5%BC%8F%E5%8C%96%E4%BB%A3%E7%A0%81)<br>
同时兼容`<b> </b>`标签,对段落加粗,`\n 和 <br>`进行段落换行
@@ -333,6 +339,44 @@ http-port=443
格子:28
}
```
### 2.2.8 商品配置
- 配置此处的文件,负责展示游戏内所销售的商品。<br>
- 一个文件名,对应一个商品分类,使用md文件格式
```
# 建筑材料
| ID | 品名 | 材质 | 购入价 | 售出价 | 库存 | 本小时销量 | 总销量 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| STONE | 石头 | STONE | 10 | -1 | -1 | 0 | 0 |
| COBBLESTONE | 圆石 | COBBLESTONE | 5 | -1 | -1 | 0 | 26 |
| OAK_PLANKS | 橡木木板 | OAK_PLANKS | 8 | 3 | -1 | 0 | 0 |
| BRICKS | 砖块 | BRICKS | 12 | -1 | -1 | 0 | 0 |
| GLASS | 玻璃 | GLASS | 15 | 6 | -1 | 0 | 0 |
| QUARTZ_BLOCK | 石英块 | QUARTZ_BLOCK | 20 | 8 | -1 | 0 | 0 |
| AMETHYST_BLOCK | 紫水晶 | AMETHYST_BLOCK | 10 | 0 | -1 | 0 | 999 |
| OCHRE_FROGLIGHT | 赭黄蛙鸣灯 | OCHRE_FROGLIGHT | 100 | 0 | -1 | 0 | 1000 |
```
## 2.3 商城部分介绍
1. 自助退款,买多买错,不用慌,一件退单很安心<br>
2. 购物车,买的多怕买错,先加购物车,后续统一结算<br>
3. 害怕订单找不到?一键查看历史订单<br>
![](img/shop4.png)
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
![](img/shop7.png)<br>
![](img/shop6.png)
5. 想查看玩家消费记录?小手一点,明明白白<br>
![](img/shop5.png)
## 2.4 打印流水部分介绍
1. 管理员怀疑某个玩家刷钱了,怎么办?/printer 玩家名,一检查流水,很安心。很多人都刷了怎么办?/printer不加参数,一键导出全服流水,查他个底朝天<br>
2. 忘记删除怎么办?插件每分钟自动检测,旧文件超过24小时,自动删
## 3. 指令参数及介绍
@@ -343,8 +387,14 @@ http-port=443
- l 登录账号<br>
- sdf1_login del 删除玩家账号<br>
- sdf1_login radio 停止播放广播<br>
- sdf1_login radio reload 重载广播并重新打包资源包
- menu 开/关 是否开启自定义菜单
- sdf1_login radio reload 重载广播并重新打包资源包<br>
- menu 开/关 是否开启菜单<br>
- printer 打印流水数据,支持不带参数&带玩家名为参数<br>
- sdf1_login printer 作用同printer<br>
- sdf1_login shop 商城
- protect 区域防护
## 4. 自定义菜单物品
- 打开主界面,选择"我的"进入<br>
@@ -353,6 +403,150 @@ http-port=443
![](img/menu2.png)
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
## 5. pvp战神榜
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
![](img/pvp1.png)
- 并且有着网游同款的击杀通报<br>
![](img/pvp3.png)
- 播报逻辑是:<br>
- 3杀<br>
- 5杀<br>
- 5杀以上每杀一次都是一条播报<br>
- 终结者通报,触发连杀的玩家被终结也会有全服通报
## 6. 区域防护
- 通过此功能,您可在服务器里需要防护的地方(比如展示框不想被玩家随便破坏、主城不准玩家袭击等)加以防护。<br>
- 还能够保护指定区域的玩家不受任何伤害。<br>
| 指令 | 参数1| 参数2 | 参数3 | 结果 |
| :---: | :---: | :---: | :---: | :---: |
| protect | 无 | 无 | 无 | 帮助信息 |
| protect | add | 区域名 | 玩家名 | 加白指定玩家到区域 |
| protect | add | 玩家名 | 无| 加白指定玩家为全局 |
| protect | additem | 区域名 | 物品名 | 加黑指定物品到区域 |
| protect | additem | 物品名 | 无 | 加黑指定物品到全局 |
| protect | addname | 区域名 | 名字 | 加白指定生物名字到区域和平模式白名单 |
| protect | addplayer | 区域名 | 玩家名字 | 加白指定玩家到区域强制模式豁免名单 |
| protect | remove | 玩家名字 | 无 | 移除玩家为全白 |
| protect | remove | 区域名 | 玩家名 | 移除玩家为区白 |
| protect | removeitem | 物品名 | 无 | 删除指定物品为全黑
| protect | removeitem | 区域名 | 物品名 | 删除指定物品为区黑
| protect | removename | 区域名 | 名字 | 删除指定名字为区域和平模式白名单 |
| protect | removeplayer | 区域名 | 名字 | 删除指定玩家为强制模式豁免名单 |
| protect | 参数不分顺序 | 区名可以写这里也可以写后面 | 玩家名可以写这里也可以写前面 | 完美的宝宝化 |
示范:
- `/protect add 末地保护区 players` 添加players玩家到末地保护区<br>
- `/protect removeitem 主城区 TNT` 从主城删了TNT这个黑名单物品<br>
- `/protect 筑城区 addwhite zhangsan` 把zhangsan加白到主城区的模式豁免名单。<br>
- 所有参数`任意排序`,`区域名字`随便填写,强大的`兜底`会自动识别"宝宝画板",你可以放心、大胆的乱写
| 参数 | 效果 |
| :---: | :---: |
| 和平模式 | 所有敌对生物全部进虚空 |
| 禁止袭击 | 立即清理玩家身上的袭击效果,传灾厄村民进虚空 |
| 清除XX效果 | 立即清除玩家身上的效果
| 给与xx效果 | 根据配置文件,给予玩家指定等级、时长的效果
## 生物属性对照表
| 中文名称 | 类型 | 附加属性 |
| :---: | :----: | :----: |
| 溺尸 | 近战/远程 | 亡灵、水生 |
| 骷髅 | 远程 | 亡灵 |
| 流浪者 | 远程 | 亡灵、寒冷群系变种 |
| 沼骸 | 远程 | 亡灵、沼泽变种 |
| 焦骸 | 远程 | 亡灵、沙漠变种、免疫阳光 |
| 掠夺者 | 远程 | 灾厄村民、袭击成员 |
| 卫道士 | 近战 | 灾厄村民、袭击成员 |
| 幻术师 | 远程 | 灾厄村民、Java专属、隐身分身 |
| 旋风人 | 远程 | 试炼密室生成、免疫弹射物、强击退 |
| 监守者 | 近战/声波远程 | 深暗之域、附带盲视效果 |
| 远古守卫者 | 近战/激光远程 | 海底神殿、水下、附加挖掘疲劳 |
| 守卫者 | 近战/激光远程 | 海底神殿、水下 |
配置文件解析:<br>
- 支持注释格式:`Java的//单行注释`、`yml的#单行注释`、`HTML的<!--- --->注释`和`Java/* */块注释`
```txt
# 区域: 主城区
起点: world,-130,-713
终点: world,-427,-560
高度范围: 0-255
没收物品: 蘑菇,毒蘑菇 #没收哪些物品(末地推荐这两个)
// 禁止放置方块 #保护区内,非区白/全白玩家禁止放置方块
// 禁止破坏方块 # 同上
禁止PVP # 玩家之间不能PVP
禁止摔伤 # 摔落无法造成任何伤害
禁止饥饿 # 不会消耗饱食度
禁止一切伤害 # 任何怪物都无法造成伤害
# 禁止丢弃物品
# 禁止末影珍珠
# 禁止使用弓箭
# 禁止骑乘
// 和平模式 // 所有的敌对生物全部传送进虚空
禁止袭击
禁止交互展示框 # 核心,任何玩家不能触碰展示框。区白/全白玩家除外
// 清除所有效果 # 清理所有负面效果
效果 村庄英雄 255 999999 # 给予玩家效果。格式为: 效果 效果名字 等级(最高255) 时间
效果 生命提升 255 999999
效果 抗性提升 255 999999
效果 抗火 255 999999
# 禁止药水
禁止爆炸
禁止使用物品: TNT
# 效果: 夜视 1 999
进入提示: 欢迎来到保护区 # 是否开启进入提示,即自定义提示
离开提示: 已离开保护区
没收提示: 你的违禁品已被没收
通报批评: §c§l{player} §f在{area}违规携带违禁品,被没收 §e{count}§f件! # 通报违禁品。{player} 玩家名 {count}数量 {area}区域
# 惩罚命令: ban {player} 60s 违规
```
## php端说明:
1. 默认密码ypshidifu2026<br>
2. 工单系统全面支持markdown语法:
```md
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
表格语法
| 表格列1 | 表格列2 |
| --- | --- |
| 表格1 | 表格2 |
>单嵌套语法
>嵌套块
>
>>语法
![](图片链接) 图片语法
[]() 超链接语法
<br>换行语法
代码块语法
```
## 工单状态:
| 状态名 | 是否可回复 | 备注说明 |
| :---: | :---: | :---: |
| 已提交 | 可回复 | 初始化状态 |
| 已回复 | 可回复 | 玩家自己回复了/管理员/服务商回复了 |
| 已完结 | 不可回复 | 管理员驳回了工单请求 |
| 已撤销 | 不可回复 | 用户撤销了工单请求 |
## 服务商说明
- 若您需要授权某个玩家为服务商,请登录游戏,打开工单系统-管理面板-添加服务商,然后输入他的名字即可邀请服务商
## 运行截图
主界面
@@ -402,6 +596,17 @@ http-port=443
管理员-工单详情页面
![](img/login15.png)
商城主界面
![](img/shop1.png)
商城购物车
![](img/cart1.png)
商城退单
![](img/shop2.png)
## 4. 下载地址
- 官方文档:[https://wiki.ypshidifu.cn](https://wiki.ypshidifu.cn) (推荐)
- GitHub:[https://github.com/ypsdf1/sdf1_login](https://github.com/ypsdf1/sdf1_login)
- Gitee:[https://gitee.com/nihaoshidifu/sdf1_login](https://gitee.com/nihaoshidifu/sdf1_login)
- gitcode: [https://gitcode.com/ypsdf1/Sdf1_login](https://gitcode.com/ypsdf1/Sdf1_login)
+5
View File
@@ -0,0 +1,5 @@
*.md text=auto encoding=UTF-8
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
+46
View File
@@ -0,0 +1,46 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
# 开发辅助文件(本地保留,不推送到仓库)
txt/
*.xlsx
*.patch
新建*.txt
群戏归类.txt
File diff suppressed because it is too large Load Diff
+569
View File
@@ -0,0 +1,569 @@
# Sdf1系列登录插件使用说明
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态 |
| :--- | :--- | :--- |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
| 新人礼包 | 新玩家7日礼包 | 已完成 |
| 挂机检测 | 挂机检测踢出 | 已完成 |
| 自定义菜单 | 可以自己diy指令 | 已完成 |
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工 |
| 商城系统| 内置游戏商人可供销售商品 | 已完工 |
| 虚拟货币 | 供签到、游戏商人使用的货币系统 | 已完工 |
## 2.安装说明:
| 前置插件 | 选装类型 | 功能作用 |
| :--- | :--- | :--- |
| [CY_beibao](https://gitee.com/nihaoshidifu/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量
| EssentialsX | 可选 | 主流经济系统
| [sdf1](https://gitee.com/nihaoshidifu/sdf1_plugn) | 可选,推荐 | CDK兑换系统
### 2.1 下载
您可以从GitHub和Gitee下载到插件,将其丢入服务器plugin目录,重启服务器即可。
### 2.2. 配置文件
#### 2.2.1 消息.txt
此文件控制着整个插件的所有提示消息,您可以根据自己的需求修改,修改后需要执行 /sdf1_login reload 重载插件。支持[颜色代码](https://zh.minecraft.wiki/w/%E6%A0%BC%E5%BC%8F%E5%8C%96%E4%BB%A3%E7%A0%81)<br>
同时兼容`<b> </b>`标签,对段落加粗,`\n 和 <br>`进行段落换行
```txt
# ==========================================
# Sdf1_login 消息配置文件
# ==========================================
# 颜色代码使用 § 符号
# §0黑色 §1深蓝 §2深绿 §3深青
# §4深红 §5深紫 §6金色 §7灰色
# §8深灰 §9蓝色 §a绿色 §b浅蓝
# §c红色 §d粉色 §e黄色 §f白色
# §l粗体 §n下划线 §o斜体 §r重置
#
# 占位符用 {xxx} 表示,运行时自动替换
# 支持 <b>粗体</b> 标签
# 支持 \n 或 <br> 换行
# 修改后执行 /sdf1_login reload 重载
# ==========================================
# ---- 登录注册相关 ----
# login_timeout: 登录超时被踢出时的提示
login_timeout=§c登录超时,请重新登录
# not_registered: 未注册玩家收到的提示
not_registered=§e您尚未注册,请使用 /reg <密码>
# not_logged_in: 已注册但未登录的提示
not_logged_in=§e您尚未登录,请使用 /login <密码>
# already_registered: 已注册玩家再次注册时的提示
already_registered=§c您已注册过账号
# already_logged_in: 已登录玩家再次登录时的提示
already_logged_in=§c您已登录
# reg_success: 注册成功提示
reg_success=§a注册成功!欢迎来到服务器
# login_success: 登录成功提示
login_success=§a登录成功!
# login_failed: 密码错误提示
login_failed=§c密码错误,请重试
# ---- 密码相关 ----
# password_wrong: 旧密码验证失败
password_wrong=§c密码错误
# password_changed: 密码修改成功
password_changed=§a密码修改成功!
# password_format_error: 密码格式不符合要求
password_format_error=§c密码需6位以上,含大小写字母和数字
# password_same: 新密码与旧密码相同
password_same=§c新密码不能与旧密码相同
# reset_no_email: 找回密码时未绑定邮箱
reset_no_email=§c您未绑定邮箱,无法找回密码
# reset_sent: 临时密码已发送到邮箱
reset_sent=§a临时密码已发送到您的邮箱,请查收
# ---- 签到积分相关 ----
# checkin_already: 今日已签到
checkin_already=§c您今日已签到
# checkin_success: 签到成功,{points}=获得积分 {streak}=连续天数 {multi}=倍率
checkin_success=§a签到成功!获得 {points} 积分,连续 {streak} 天,倍率 x{multi}
# points_insufficient: 积分不足
points_insufficient=§c积分不足,当前: {points}
# points_purchase_success: 积分购买成功
points_purchase_success=§a购买成功!消耗 {count} 积分
# ---- 挂机检测相关 ----
# afk_set_enabled: 挂机检测开启
afk_set_enabled=§a挂机检测已开启
# afk_set_disabled: 挂机检测关闭
afk_set_disabled=§c挂机检测已关闭
# afk_kick: 因挂机被踢出
afk_kick=§c因挂机过久被踢出
# ---- 邮箱相关 ----
# email_set: 邮箱设置成功
email_set=§a邮箱设置成功:{email}
# email_format_error: 邮箱格式错误
email_format_error=§c邮箱格式不正确
# ---- 邀请相关 ----
# invite_code_generated: 邀请码生成
invite_code_generated=§a邀请码:{code}
# invite_used: 使用邀请码成功
invite_used=§a邀请成功,邀请人:{player}
# invite_self: 使用自己的邀请码
invite_self=§c不能使用自己的邀请码
# invite_not_found: 邀请码不存在
invite_not_found=§c邀请码不存在
# invite_referral_bonus: 被邀请人签到后邀请人获得奖励
invite_referral_bonus=§a邀请奖励!获得 {points} 积分
# ---- 新人礼包相关 ----
# gift_not_ready: 礼包条件未满足
gift_not_ready=§c礼包条件未满足
# gift_claimed: 礼包领取成功
gift_claimed=§a礼包领取成功!
# ---- 审批与IP限制 ----
# need_approval: 注册需审批
need_approval=§e注册需审批 (工单#{id})
# ip_max_accounts: IP注册已达上限
ip_max_accounts=§cIP注册已达上限
# ---- 管理员操作 ----
# admin_delete_confirm: 管理员删除账号确认提示
admin_delete_confirm=§c请在聊天中输入玩家名确认删除
# ---- 聊天过滤 ----
# chat_muted: 禁言玩家尝试聊天
chat_muted=§c§l[Sdf1_chat] §f你已被禁言,无法聊天
# chat_url_blocked: 发送违规链接被拦截
# {url}=检测到的链接地址
chat_url_blocked=§c§l[Sdf1_chat] §f检测到违规第三方链接: §e{url}
# chat_url_violation: 当前违规次数
# {count}=当前违规次数
chat_url_violation=§7当前违规次数: §e{count}
# chat_url_admin_notify: 通知管理员,{player}=玩家名 {url}=链接 {count}=次数
chat_url_admin_notify=§e§l[Sdf1_chat] §f{player} 发送违规链接: {url} (第{count}次)
# chat_url_broadcast: 全服通报,{player}=玩家名
chat_url_broadcast=§c§l[Sdf1_chat] §e{player} §f因发送违规链接被处罚
# ---- 通用 ----
# 未知参数: 未知命令或权限不足时的提示
未知参数=§c未知参数或权限不足
# only_player: 仅玩家可用的命令
# only_player=§c此命令仅玩家可用
```
### 2.2.2 挂机白名单.txt
当挂机踢出开启时,该配置下的玩家不受挂机提出影响
### 2.2.3 SMTP设置.txt
此文件控制着SMTP邮件服务器的配置,用于找回密码时发送邮件。<br>可通过GUI进行配置,也可以手动修改此配置文件。修改后,重载插件即可生效
```txt
# ===== SMTP 邮件服务器配置 =====
smtp地址=smtp.example.com
smtp端口=465
smtp账号=mail@example.com
smtp密码=example.com
发件人名称=Sdf1_login
smtp加密=true
验证码接收邮箱=example@example.com
```
### 2.2.4 插件设置.txt
此配置管控这整个插件,包括玩家能注册多少个账号、默认的管理员面板密码多少、tag包含什么视为管理员等
```txt
# ===== Sdf1_login 插件设置 =====
管理标签=admin
管理密码=qweasd
每IP最大账号数=3
审批模式=manual
自动审批延迟分钟=30
登录超时秒数=60
挂机踢出=false
挂机超时秒数=300
服务商积分倍率=2.0
基础经济奖励=100
经济奖励每评分=50.0
报单人积分奖励=10
# ---- 签到盲盒奖励 ----
签到固定金额=0
签到最小金额=10
签到最大金额=100
签到给积分=true
签到积分数量=10
```
### 2.2.5 chat.txt
该文件控制着聊天过滤,当玩家发送违规链接时,会自动根据配置规则进行处理。<br>
可用的处罚规则:
- warn: 警告
- mute: 禁言
- kick: 踢出
- ban: 封禁
- banip:封禁IP
```txt
# Sdf1_chat 聊天过滤配置
通知管理员: true
全服通报: true
禁言时长: 300
白名单:
*.minecraft.net
baidu.com
github.com
处罚规则:
1:warn:0
2:warn:0
3:mute:300
5:mute:600
8:kick:0
10:ban:0
白名单玩家:
```
### 2.2.6 radio_config.txt
```txt
# ============ Sdf1 Radio Config ============
#
# resource-pack-url
# 填外部地址(HTTP或HTTPS均可)
# 例: https://example.com/radiopack.zip
# 留空 = 使用内置HTTP服务
#
# http-port
# 内置HTTP端口(仅resource-pack-url为空时生效)
#
# ============ Settings ===============
resource-pack-url=https://*.ypshidifu.cn/|*.mcserver.ccwu.cc
http-port=443
```
- radio_config这里可以使用通配符域名,当使用通配符时,系统随机生成url,拒绝过资源包的玩家,每次都会收到提示。并且自1.2版本起,拒绝资源包=放弃游玩服务器。如果您无需广播功能,可以不放ogg文件进入`./plugin/radio/ogg`。这样服务端不会自动生成资源包,也不会强制下载了
### 2.2.7 菜单.txt
- 自定义菜单配置文件
```json
# 服务器菜单
# 使用类json格式
// 支持使用://Java单行注释、#yml单行注释、/* */块注释,也支持<!--- --->的块注释
{
标题:主城传送
指令:/dom tp 主城区 # 这里如果是xxx.txt则表示进入二级目录
权限类型:玩家
图标:COMPASS
}
{
标题:随机传送
指令:/rtp
权限类型:玩家
图标:ENDER_PEARL
}
{
标题:擂台比武
指令:/dom tp PVP
权限类型:玩家
图标:netherite_spear
}
{
标题:领地操作
指令:/dom
权限类型:玩家
图标:paper
}
{
标题:逛商店
指令:/dom tp shop
权限类型:玩家
图标:honeycomb
}
{
标题:传送开关
指令:/tptoggle
权限类型:玩家
图标:redstone_torch
}
{
标题:返回上一个位置
指令:/back
权限类型:玩家
图标:red_bed
}
{
标题:前往小黑塔
指令:/dom tp 小黑塔
权限类型:玩家
图标:dragon_head
}
{
标题:云背包
指令:/cy
权限类型:玩家
图标:potato
}
{
标题:自杀
指令:/suicide
权限类型:玩家
图标:wooden_axe
}
{
标题:游戏商人
指令:/shop
权限类型:玩家
图标:shield
格子:28
}
```
### 2.2.8 商品配置
- 配置此处的文件,负责展示游戏内所销售的商品。<br>
- 一个文件名,对应一个商品分类,使用md文件格式
```
# 建筑材料
| ID | 品名 | 材质 | 购入价 | 售出价 | 库存 | 本小时销量 | 总销量 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| STONE | 石头 | STONE | 10 | -1 | -1 | 0 | 0 |
| COBBLESTONE | 圆石 | COBBLESTONE | 5 | -1 | -1 | 0 | 26 |
| OAK_PLANKS | 橡木木板 | OAK_PLANKS | 8 | 3 | -1 | 0 | 0 |
| BRICKS | 砖块 | BRICKS | 12 | -1 | -1 | 0 | 0 |
| GLASS | 玻璃 | GLASS | 15 | 6 | -1 | 0 | 0 |
| QUARTZ_BLOCK | 石英块 | QUARTZ_BLOCK | 20 | 8 | -1 | 0 | 0 |
| AMETHYST_BLOCK | 紫水晶 | AMETHYST_BLOCK | 10 | 0 | -1 | 0 | 999 |
| OCHRE_FROGLIGHT | 赭黄蛙鸣灯 | OCHRE_FROGLIGHT | 100 | 0 | -1 | 0 | 1000 |
```
## 2.3 商城部分介绍
1. 自助退款,买多买错,不用慌,一件退单很安心<br>
2. 购物车,买的多怕买错,先加购物车,后续统一结算<br>
3. 害怕订单找不到?一键查看历史订单<br>
![](img/shop4.png)
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
![](img/shop7.png)<br>
![](img/shop6.png)
5. 想查看玩家消费记录?小手一点,明明白白<br>
![](img/shop5.png)
## 2.4 打印流水部分介绍
1. 管理员怀疑某个玩家刷钱了,怎么办?/printer 玩家名,一检查流水,很安心。很多人都刷了怎么办?/printer不加参数,一键导出全服流水,查他个底朝天<br>
2. 忘记删除怎么办?插件每分钟自动检测,旧文件超过24小时,自动删
## 3. 指令参数及介绍
- Sdf1_login 主指令<br>
- Sdf1_login reload 重载配置文件<br>
- reg 注册账号<br>
- login 登录账号<br>
- l 登录账号<br>
- sdf1_login del 删除玩家账号<br>
- sdf1_login radio 停止播放广播<br>
- sdf1_login radio reload 重载广播并重新打包资源包<br>
- menu 开/关 是否开启菜单<br>
- printer 打印流水数据,支持不带参数&带玩家名为参数<br>
- sdf1_login printer 作用同printer<br>
- sdf1_login shop 商城
- protect 区域防护
## 4. 自定义菜单物品
- 打开主界面,选择"我的"进入<br>
![](img/menu1.png)
- 将你的自定义物品与雪球交换<br>
![](img/menu2.png)
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
## 5. pvp战神榜
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
![](img/pvp1.png)
- 并且有着网游同款的击杀通报<br>
![](img/pvp3.png)
- 播报逻辑是:<br>
- 3杀<br>
- 5杀<br>
- 5杀以上每杀一次都是一条播报<br>
- 终结者通报,触发连杀的玩家被终结也会有全服通报
## 6. 区域防护
- 通过此功能,您可在服务器里需要防护的地方(比如展示框不想被玩家随便破坏、主城不准玩家袭击等)加以防护。<br>
- 还能够保护指定区域的玩家不受任何伤害。<br>
| 指令 | 参数1| 参数2 | 参数3 | 结果 |
| :---: | :---: | :---: | :---: | :---: |
| protect | 无 | 无 | 无 | 帮助信息 |
| protect | add | 区域名 | 玩家名 | 加白指定玩家到区域 |
| protect | add | 玩家名 | 无| 加白指定玩家为全局 |
| protect | additem | 区域名 | 物品名 | 加黑指定物品到区域 |
| protect | additem | 物品名 | 无 | 加黑指定物品到全局 |
| protect | addname | 区域名 | 名字 | 加白指定生物名字到区域和平模式白名单 |
| protect | addplayer | 区域名 | 玩家名字 | 加白指定玩家到区域强制模式豁免名单 |
| protect | remove | 玩家名字 | 无 | 移除玩家为全白 |
| protect | remove | 区域名 | 玩家名 | 移除玩家为区白 |
| protect | removeitem | 物品名 | 无 | 删除指定物品为全黑
| protect | removeitem | 区域名 | 物品名 | 删除指定物品为区黑
| protect | removename | 区域名 | 名字 | 删除指定名字为区域和平模式白名单 |
| protect | removeplayer | 区域名 | 名字 | 删除指定玩家为强制模式豁免名单 |
| protect | 参数不分顺序 | 区名可以写这里也可以写后面 | 玩家名可以写这里也可以写前面 | 完美的宝宝化 |
示范:
- `/protect add 末地保护区 players` 添加players玩家到末地保护区<br>
- `/protect removeitem 主城区 TNT` 从主城删了TNT这个黑名单物品<br>
- `/protect 筑城区 addwhite zhangsan` 把zhangsan加白到主城区的模式豁免名单。<br>
- 所有参数`任意排序`,`区域名字`随便填写,强大的`兜底`会自动识别"宝宝画板",你可以放心、大胆的乱写
| 参数 | 效果 |
| :---: | :---: |
| 和平模式 | 所有敌对生物全部进虚空 |
| 禁止袭击 | 立即清理玩家身上的袭击效果,传灾厄村民进虚空 |
| 清除XX效果 | 立即清除玩家身上的效果
| 给与xx效果 | 根据配置文件,给予玩家指定等级、时长的效果
## 生物属性对照表
| 中文名称 | 类型 | 附加属性 |
| :---: | :----: | :----: |
| 溺尸 | 近战/远程 | 亡灵、水生 |
| 骷髅 | 远程 | 亡灵 |
| 流浪者 | 远程 | 亡灵、寒冷群系变种 |
| 沼骸 | 远程 | 亡灵、沼泽变种 |
| 焦骸 | 远程 | 亡灵、沙漠变种、免疫阳光 |
| 掠夺者 | 远程 | 灾厄村民、袭击成员 |
| 卫道士 | 近战 | 灾厄村民、袭击成员 |
| 幻术师 | 远程 | 灾厄村民、Java专属、隐身分身 |
| 旋风人 | 远程 | 试炼密室生成、免疫弹射物、强击退 |
| 监守者 | 近战/声波远程 | 深暗之域、附带盲视效果 |
| 远古守卫者 | 近战/激光远程 | 海底神殿、水下、附加挖掘疲劳 |
| 守卫者 | 近战/激光远程 | 海底神殿、水下 |
配置文件解析:<br>
- 支持注释格式:`Java的//单行注释`、`yml的#单行注释`、`HTML的<!--- --->注释`和`Java/* */块注释`
```txt
# 区域: 主城区
起点: world,-130,-713
终点: world,-427,-560
高度范围: 0-255
没收物品: 蘑菇,毒蘑菇 #没收哪些物品(末地推荐这两个)
// 禁止放置方块 #保护区内,非区白/全白玩家禁止放置方块
// 禁止破坏方块 # 同上
禁止PVP # 玩家之间不能PVP
禁止摔伤 # 摔落无法造成任何伤害
禁止饥饿 # 不会消耗饱食度
禁止一切伤害 # 任何怪物都无法造成伤害
# 禁止丢弃物品
# 禁止末影珍珠
# 禁止使用弓箭
# 禁止骑乘
// 和平模式 // 所有的敌对生物全部传送进虚空
禁止袭击
禁止交互展示框 # 核心,任何玩家不能触碰展示框。区白/全白玩家除外
// 清除所有效果 # 清理所有负面效果
效果 村庄英雄 255 999999 # 给予玩家效果。格式为: 效果 效果名字 等级(最高255) 时间
效果 生命提升 255 999999
效果 抗性提升 255 999999
效果 抗火 255 999999
# 禁止药水
禁止爆炸
禁止使用物品: TNT
# 效果: 夜视 1 999
进入提示: 欢迎来到保护区 # 是否开启进入提示,即自定义提示
离开提示: 已离开保护区
没收提示: 你的违禁品已被没收
通报批评: §c§l{player} §f在{area}违规携带违禁品,被没收 §e{count}§f件! # 通报违禁品。{player} 玩家名 {count}数量 {area}区域
# 惩罚命令: ban {player} 60s 违规
```
## 运行截图
主界面
![](img/login.png)
邀请数据
![](img/login2.png)
积分商店
![](img/login3.png)
任务中心
![](img/login4.png)
垃圾箱
![](img/login5.png)
管理员面板
![](img/login6.png)
管理面板-用户管理
![](img/login7.png)
工单中心
![](img/login8.png)
工单管理
![](img/login9.png)
我的工单
![](img/login10.png)
提交工单
![](img/login11.png)
服务商列表
![](img/login12.png)
服务商抢单大厅
![](img/login13.png)
服务商-接单界面
![](img/login14.png)
管理员-工单详情页面
![](img/login15.png)
商城主界面
![](img/shop1.png)
商城购物车
![](img/cart1.png)
商城退单
![](img/shop2.png)
## 4. 下载地址
- GitHub:[https://github.com/ypsdf1/sdf1_login](https://github.com/ypsdf1/sdf1_login)
- Gitee:[https://gitee.com/nihaoshidifu/sdf1_login](https://gitee.com/nihaoshidifu/sdf1_login)
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>sdf1</groupId>
<artifactId>Sdf1_login</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21.4-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.MilkBowl</groupId>
<artifactId>VaultAPI</artifactId>
<version>1.7.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>Sdf1_login</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,197 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.*;
public class AFKManager {
// 玩家最后活动时间(空闲才开始计时)
private final Map<UUID, Long> lastAction =
new HashMap<>();
// 每个玩家当前的警告阶段
private final Map<UUID, Integer> lastStage =
new HashMap<>();
// 是否已进入挂机检测(false=还在活动,不计时)
private final Map<UUID, Boolean> afkTracking =
new HashMap<>();
private final ConfigManager config;
private final JavaPlugin plugin;
public AFKManager(ConfigManager config,
JavaPlugin plugin) {
this.config = config;
this.plugin = plugin;
}
public void startCheck() {
new BukkitRunnable() {
public void run() {
tick();
}
}.runTaskTimer(plugin, 20L, 20L);
}
private void tick() {
if (!config.afkEnabled) return;
long now = System.currentTimeMillis();
for (Player p : Bukkit.getOnlinePlayers()) {
String name = p.getName();
UUID uuid = p.getUniqueId();
if (!isLoggedIn(name)) continue;
if (config.getAfkWhitelist().contains(name))
continue;
// 确保有记录
if (!lastAction.containsKey(uuid)) {
lastAction.put(uuid, now);
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
continue;
}
long last = lastAction.get(uuid);
long idle = (now - last) / 1000;
int timeout = config.afkTimeout;
// 还在活动范围内:不进入挂机检测
if (idle < 60) {
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
continue;
}
// 超过60秒无活动:开始挂机检测
afkTracking.put(uuid, true);
long remain = timeout - idle;
// 已超时:踢出
if (remain <= 0) {
p.kickPlayer("§c§l你已挂机 "
+ fmtSec(idle) + ",被踢出");
lastAction.remove(uuid);
lastStage.remove(uuid);
afkTracking.remove(uuid);
continue;
}
int stage = lastStage.getOrDefault(uuid, 0);
// 阶段3:最后10秒
if (remain <= 10) {
if (stage < 3) {
lastStage.put(uuid, 3);
}
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§c还剩 " + remain + " 秒踢出",
0, 22, 3);
playSound(p);
continue;
}
// 阶段2:1分钟~10秒
if (remain <= 60) {
if (stage < 2) {
lastStage.put(uuid, 2);
p.sendMessage(
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
+ fmtSec(idle)
+ "§f,还剩 §c"
+ fmtSec(remain)
+ "§f踢出");
}
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§c还剩 " + fmtSec(remain) + " 踢出",
5, 25, 5);
playSound(p);
continue;
}
// 阶段1:3分钟提醒
if (remain <= 180 && stage < 1) {
lastStage.put(uuid, 1);
p.sendMessage(
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
+ fmtSec(idle)
+ "§f,还剩 §c"
+ fmtSec(remain)
+ "§f踢出");
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§f还剩 " + fmtSec(remain) + " 踢出",
10, 60, 20);
for (int i = 0; i < 3; i++) {
final int delay = i * 10;
Bukkit.getScheduler().runTaskLater(
plugin, () -> {
if (p.isOnline()) playSound(p);
}, delay);
}
}
if (remain > 180) {
lastStage.remove(uuid);
}
}
}
/**
* 玩家有活动时调用(移动/交互/放置/挖掘等)
* 重置计时,退出挂机检测
*/
public void recordAction(UUID uuid) {
lastAction.put(uuid, System.currentTimeMillis());
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
}
/**
* 玩家下线时调用
* 彻底停止计时,清理所有数据
*/
public void remove(UUID uuid) {
lastAction.remove(uuid);
lastStage.remove(uuid);
afkTracking.remove(uuid);
}
/**
* 获取玩家当前是否在挂机检测中
*/
public boolean isAfkTracking(UUID uuid) {
return afkTracking.getOrDefault(uuid, false);
}
private boolean isLoggedIn(String name) {
return ((Main) plugin).getLoggedIn()
.contains(name);
}
private void playSound(Player p) {
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_CHIME,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_BASS,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING,
1.0f, 0.79f);
}
private String fmtSec(long sec) {
if (sec < 60) return sec + "秒";
long m = sec / 60;
long s = sec % 60;
return m + "分" + (s > 0 ? s + "秒" : "");
}
}
@@ -0,0 +1,146 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class AccountRequestManager {
public enum ApprovalMode { AUTO, MANUAL, HYBRID }
public static class Request {
public final int id;
public final String requester;
public final String target;
public final String ip;
public final long time;
public String status;
public String denyReason;
public Request(int id, String requester, String target, String ip) {
this.id = id;
this.requester = requester;
this.target = target;
this.ip = ip;
this.time = System.currentTimeMillis();
this.status = "PENDING";
this.denyReason = "";
}
}
private final Main plugin;
private final Map<Integer, Request> requests = new ConcurrentHashMap<>();
private final Map<String, Integer> pendingByPlayer = new ConcurrentHashMap<>();
private int nextId = 1;
private ApprovalMode mode = ApprovalMode.MANUAL;
private int autoDelayMinutes = 60;
public AccountRequestManager(Main plugin) {
this.plugin = plugin;
}
public ApprovalMode getMode() { return mode; }
public void setMode(String m) {
try { this.mode = ApprovalMode.valueOf(m.toUpperCase()); }
catch (Exception e) { this.mode = ApprovalMode.MANUAL; }
}
public void setAutoDelayMinutes(int min) {
this.autoDelayMinutes = min;
}
public Request createRequest(String requester, String target, String ip) {
int id = nextId++;
Request req = new Request(id, requester, target, ip);
requests.put(id, req);
pendingByPlayer.put(requester, id);
if (mode == ApprovalMode.AUTO) {
scheduleAutoApprove(id);
} else if (mode == ApprovalMode.HYBRID) {
String targetIp = (String) plugin.getDb().getField(target, "ip_address");
if (ip != null && ip.equals(targetIp)) {
scheduleAutoApprove(id);
}
}
return req;
}
private void scheduleAutoApprove(int id) {
long delayTicks = (long) autoDelayMinutes * 60L * 20L;
Request req = requests.get(id);
if (req == null) return;
String requesterName = req.requester;
String targetName = req.target;
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 将在 " + autoDelayMinutes + " 分钟后自动通过");
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Request r = requests.get(id);
if (r != null && "PENDING".equals(r.status)) {
approveRequest(id);
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 自动通过");
Player requester = Bukkit.getPlayer(requesterName);
if (requester != null && requester.isOnline()) {
requester.sendMessage("§a[Sdf1_login] §f您的删除请求 #" + id + "(§e" + targetName + "§a)已自动通过!");
}
}
}, delayTicks);
}
public boolean approveRequest(int id) {
Request req = requests.get(id);
if (req == null || !"PENDING".equals(req.status)) return false;
req.status = "APPROVED";
plugin.getDb().deleteUser(req.target);
pendingByPlayer.remove(req.requester);
// 清背包+传送出生点+踢出
Player target = Bukkit.getPlayer(req.target);
if (target != null && target.isOnline()) {
target.getInventory().clear();
target.getInventory().setArmorContents(new org.bukkit.inventory.ItemStack[4]);
target.getInventory().setExtraContents(new org.bukkit.inventory.ItemStack[1]);
target.teleport(target.getWorld().getSpawnLocation());
target.kickPlayer("§c§l你的账号已被管理员删除,请重新注册");
}
return true;
}
public boolean denyRequest(int id, String reason) {
Request req = requests.get(id);
if (req == null || !"PENDING".equals(req.status)) return false;
req.status = "DENIED";
req.denyReason = reason;
pendingByPlayer.remove(req.requester);
return true;
}
public List<Request> getPendingRequests() {
List<Request> list = new ArrayList<>();
for (Request r : requests.values()) {
if ("PENDING".equals(r.status)) list.add(r);
}
return list;
}
public Request getRequest(int id) {
return requests.get(id);
}
public boolean hasPending(String player) {
return pendingByPlayer.containsKey(player);
}
public void cancelPending(String player) {
Integer id = pendingByPlayer.remove(player);
if (id != null) {
Request r = requests.get(id);
if (r != null) r.status = "CANCELLED";
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,697 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.File;
import java.sql.*;
import java.util.*;
public class BondManager {
private final Main plugin;
private Connection db;
// ===== 交易类型常量 =====
public static final String TX_ADMIN_GIVE = "admin_give";
public static final String TX_ADMIN_DEDUCT = "admin_deduct";
public static final String TX_TRANSFER_OUT = "transfer_out";
public static final String TX_TRANSFER_IN = "transfer_in";
public static final String TX_REDEEM = "redeem";
public static final String TX_SHOP_BUY = "shop_buy";
public static final String TX_DAILY_SIGN = "daily_sign";
public static final String TX_FREEZE = "freeze";
public static final String TX_UNFREEZE = "unfreeze";
public static final int MAX_WATER_DAYS = 14;
public static final int ADMIN_MAX_WATER_DAYS = 30;
public BondManager(Main plugin) {
this.plugin = plugin;
initDB();
}
// ======================== 数据库初始化 ========================
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(), "bond.db");
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
// 债券余额(含冻结状态)
st.execute("CREATE TABLE IF NOT EXISTS bonds ("
+ "player_name TEXT PRIMARY KEY,"
+ "amount INTEGER DEFAULT 0,"
+ "status TEXT DEFAULT 'normal')");
// 兼容旧表
try {
st.execute("ALTER TABLE bonds "
+ "ADD COLUMN status "
+ "TEXT DEFAULT 'normal'");
} catch (SQLException ignored) {}
// 旧流水(保留兼容)
st.execute("CREATE TABLE IF NOT EXISTS bond_log ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "player_name TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "reason TEXT DEFAULT '',"
+ "time INTEGER DEFAULT 0)");
// 新交易流水表
st.execute("CREATE TABLE IF NOT EXISTS "
+ "bond_transaction ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "player_name TEXT NOT NULL,"
+ "type TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "target_player TEXT DEFAULT '',"
+ "operator TEXT DEFAULT '',"
+ "reason TEXT DEFAULT '',"
+ "balance_after INTEGER DEFAULT 0,"
+ "time INTEGER DEFAULT 0)");
try {
st.execute("CREATE INDEX "
+ "IF NOT EXISTS idx_tx_pt "
+ "ON bond_transaction("
+ "player_name, time)");
} catch (SQLException ignored) {}
try {
st.execute("ALTER TABLE bond_transaction "
+ "ADD COLUMN balance_before "
+ "INTEGER DEFAULT 0");
} catch (SQLException ignored) {}
st.close();
} catch (Exception e) {
throw new RuntimeException(
"[Bond] DB init failed: "
+ e.getMessage(), e);
}
}
// ======================== 账户状态 ========================
public boolean isFrozen(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return "frozen".equals(s);
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return false;
}
public String getAccountStatus(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return s != null ? s : "normal";
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return "normal";
}
public void freezeAccount(String player) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='frozen' "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
}
public void unfreezeAccount(String player) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='normal' "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
}
private void ensureAccount(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR IGNORE INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,0,'normal')");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException ignored) {}
}
// ======================== 余额查询 ========================
public int getBonds(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT amount FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
int v = 0;
if (rs.next()) v = rs.getInt("amount");
rs.close(); ps.close();
return v;
} catch (SQLException e) { return 0; }
}
/** 该玩家是否在 bond 表中有真实记录 */
public boolean hasBondAccount(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM bonds "
+ "WHERE player_name=? "
+ "AND amount > 0");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
boolean exists = rs.next();
rs.close(); ps.close();
return exists;
} catch (SQLException e) {
return false;
}
}
// ======================== 增加债券 ========================
public int addBonds(String player, int amount) {
return addBondsInternal(player, amount,
TX_ADMIN_GIVE, "", "",
"管理员发放");
}
public int addBonds(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
if (isFrozen(player)) return -1;
return addBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
private int addBondsInternal(String player,
int amount,
String type,
String targetPlayer,
String operator,
String reason) {
int before = getBonds(player);
int after = before + amount;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status)"
+ " VALUES (?,?, 'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, after);
ps.setInt(3, after);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
logTransaction(player, type, amount,
targetPlayer, operator, reason,
before, after);
logLegacy(player, amount);
return after;
}
// ======================== 扣除债券 ========================
private boolean deductBondsInternal(String player,
int amount,
String type,
String targetPlayer,
String operator,
String reason) {
int before = getBonds(player);
if (before < amount) return false;
int after = before - amount;
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET amount=? "
+ "WHERE player_name=?");
ps.setInt(1, after);
ps.setString(2, player);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
logTransaction(player, type, amount,
targetPlayer, operator, reason,
before, after);
logLegacy(player, -amount);
return true;
}
// ======================== 流水写入 ========================
private void logTransaction(String player,
String type,
int amount,
String targetPlayer,
String operator,
String reason,
int balanceBefore,
int balanceAfter) {
try {
// 合并模式:shop_buy 30秒内同商品合并
if ("shop_buy".equals(type)
&& reason != null
&& !reason.isEmpty()) {
long mergeWindow =
System.currentTimeMillis() - 30000L;
PreparedStatement chk =
db.prepareStatement(
"SELECT id, amount "
+ "FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND type=? "
+ "AND reason=? "
+ "AND time > ? "
+ "ORDER BY time DESC "
+ "LIMIT 1");
chk.setString(1, player);
chk.setString(2, type);
chk.setString(3, reason);
chk.setLong(4, mergeWindow);
ResultSet rs = chk.executeQuery();
if (rs.next()) {
int existId = rs.getInt("id");
int existAmt = rs.getInt("amount");
rs.close();
chk.close();
PreparedStatement upd =
db.prepareStatement(
"UPDATE bond_transaction "
+ "SET amount=?, "
+ "balance_after=?, "
+ "time=? "
+ "WHERE id=?");
upd.setInt(1, existAmt + amount);
upd.setInt(2, balanceAfter);
upd.setLong(3,
System.currentTimeMillis());
upd.setInt(4, existId);
upd.executeUpdate();
upd.close();
return;
}
rs.close();
chk.close();
}
// 原始插入
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bond_transaction "
+ "(player_name, type, amount,"
+ " target_player, operator,"
+ " reason, balance_before,"
+ " balance_after, time)"
+ " VALUES (?,?,?,?,?,?,?,?,?)");
ps.setString(1, player);
ps.setString(2, type);
ps.setInt(3, amount);
ps.setString(4,
targetPlayer != null ? targetPlayer : "");
ps.setString(5,
operator != null ? operator : "");
ps.setString(6,
reason != null ? reason : "");
ps.setInt(7, balanceBefore);
ps.setInt(8, balanceAfter);
ps.setLong(9, System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
// ======================== 扣除债券 ========================
/** 完整版:含冻结检查 + 流水记录 */
public boolean deductBonds(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
if (isFrozen(player)) return false;
return deductBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
public boolean checkoutCart(String player,
String[] itemNames,
int[] subtotals) {
int total = 0;
for (int s : subtotals) total += s;
if (isFrozen(player)) return false;
int before = getBonds(player);
if (before < total) return false;
int after = before - total;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,?,'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, after);
ps.setInt(3, after);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
return false;
}
int running = before;
for (int i = 0; i < itemNames.length; i++) {
int next = running - subtotals[i];
logTransaction(player, "shop_buy", subtotals[i],
"", "商店系统",
"购物车购买-" + itemNames[i],
running, next);
running = next;
}
logLegacy(player, -total);
return true;
}
public boolean setBonds(String player, int amount) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,?, 'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, amount);
ps.setInt(3, amount);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return true;
}
// ======================== 转账 ========================
public TransferResult transfer(String from,
String to,
int amount) {
if (from.equalsIgnoreCase(to))
return new TransferResult(false,
"不能给自己转账");
if (isFrozen(from))
return new TransferResult(false,
"你的账户已被冻结");
if (isFrozen(to))
return new TransferResult(false,
to + " 的账户已被冻结");
if (isSameIP(from, to))
return new TransferResult(false,
"不能与同IP玩家转账");
if (!deductBondsInternal(from, amount,
TX_TRANSFER_OUT, to, from,
"转账给" + to))
return new TransferResult(false, "债券不足");
addBondsInternal(to, amount,
TX_TRANSFER_IN, from, from,
"收到来自" + from + "的转账");
return new TransferResult(true, "转账成功");
}
// ======================== 同IP检测 ========================
public boolean isSameIP(String p1, String p2) {
String ip1 = getPlayerIPAny(p1);
String ip2 = getPlayerIPAny(p2);
if (ip1 == null || ip2 == null) return false;
return ip1.equals(ip2);
}
/** 获取同IP下的所有其他在线玩家 */
public List<String> getSameIPPlayers(String player) {
String ip = getPlayerIPAny(player);
if (ip == null) return Collections.emptyList();
List<String> list = new ArrayList<>();
for (Player p :
plugin.getServer().getOnlinePlayers()) {
String pIp = plugin.getPlayerIP(p);
if (pIp != null && pIp.equals(ip)
&& !p.getName()
.equalsIgnoreCase(player)) {
list.add(p.getName());
}
}
return list;
}
private String getPlayerIPAny(String player) {
// 在线:直接取
Player p = plugin.getServer()
.getPlayerExact(player);
if (p != null) {
String ip = plugin.getPlayerIP(p);
if (ip != null) return ip;
}
// 离线:查主库
try {
Object v = plugin.getDb()
.getField(player, "last_login_ip");
if (v != null) return v.toString();
} catch (Exception ignored) {}
try {
Object v = plugin.getDb()
.getField(player, "register_ip");
if (v != null) return v.toString();
} catch (Exception ignored) {}
return null;
}
// ======================== 流水查询 ========================
public List<Map<String, Object>> getTransactions(
String player, int days) {
days = Math.min(days, MAX_WATER_DAYS);
long cutoff = System.currentTimeMillis()
- (long) days * 24 * 60 * 60 * 1000;
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND time>=? "
+ "ORDER BY time DESC");
ps.setString(1, player);
ps.setLong(2, cutoff);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("operator",
rs.getString("operator"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
// 放在 getTransactions 方法后面:
/**
* 获取该玩家全部流水(不限时间,供打印使用)
*/
public List<Map<String, Object>>
getAllTransactions(String player) {
List<Map<String, Object>> list =
new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "ORDER BY time ASC");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("player_name",
rs.getString("player_name"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("operator",
rs.getString("operator"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
// 放在 getAllTransactions 方法后面:
/**
* 获取所有有流水记录的玩家名(去重)
*/
public List<String> getAllPlayerNames() {
List<String> list = new ArrayList<>();
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT DISTINCT player_name "
+ "FROM bond_transaction "
+ "ORDER BY player_name ASC");
while (rs.next()) {
list.add(rs.getString("player_name"));
}
rs.close(); st.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
public List<Map<String, Object>>
getTransferTransactions(
String player, int days) {
days = Math.min(days, MAX_WATER_DAYS);
long cutoff = System.currentTimeMillis()
- (long) days * 24 * 60 * 60 * 1000;
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND time>=? "
+ "AND (type=? OR type=?) "
+ "ORDER BY time DESC");
ps.setString(1, player);
ps.setLong(2, cutoff);
ps.setString(3, TX_TRANSFER_OUT);
ps.setString(4, TX_TRANSFER_IN);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
private void logLegacy(String player, int amount) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bond_log "
+ "(player_name,amount,time) "
+ "VALUES (?,?,?)");
ps.setString(1, player);
ps.setInt(2, amount);
ps.setLong(3,
System.currentTimeMillis());
ps.executeUpdate(); ps.close();
} catch (SQLException ignored) {}
}
public Map<String, Object> getLog(
String player, int limit) {
Map<String, Object> info = new LinkedHashMap<>();
info.put("balance", getBonds(player));
return info;
}
// ======================== 转账结果 ========================
public static class TransferResult {
public final boolean success;
public final String message;
public TransferResult(boolean success,
String message) {
this.success = success;
this.message = message;
}
}
}
@@ -0,0 +1,378 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.*;
public class BondPrinter {
private final Main plugin;
private final File outputDir;
private static final SimpleDateFormat FILE_SDF =
new SimpleDateFormat(
"yyyy-MM-dd_HH-mm-ss");
private static final SimpleDateFormat CELL_SDF =
new SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss");
private static final LinkedHashMap<String, String>
TYPE_NAMES = new LinkedHashMap<>();
static {
TYPE_NAMES.put("admin_give", "管理员发放");
TYPE_NAMES.put("admin_deduct", "管理员扣除");
TYPE_NAMES.put("transfer_out", "转出");
TYPE_NAMES.put("transfer_in", "转入");
TYPE_NAMES.put("redeem", "CDK兑换");
TYPE_NAMES.put("shop_buy", "商城购买");
TYPE_NAMES.put("shop_sell", "商城出售"); // ← 补上
TYPE_NAMES.put("daily_sign", "签到奖励");
TYPE_NAMES.put("freeze", "冻结");
TYPE_NAMES.put("unfreeze", "解冻");
}
public BondPrinter(Main plugin) {
this.plugin = plugin;
this.outputDir = new File(
plugin.getDataFolder(), "printer");
if (!this.outputDir.exists()) {
this.outputDir.mkdirs();
}
}
// ============ 打印单个玩家 ============
public void printPlayer(String player,
CommandSender sender) {
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() -> doPrintPlayer(
player, sender));
}
private void doPrintPlayer(String player,
CommandSender sender) {
try {
List<Map<String, Object>> txs =
plugin.getBonds()
.getAllTransactions(player);
String fileName = player + "_"
+ FILE_SDF.format(new Date())
+ ".xls";
File file = new File(outputDir, fileName);
deleteOld(player);
PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(file),
StandardCharsets.UTF_8));
try {
writeXmlHeader(pw,
escXml(player));
writeXmlRow(pw,
new String[]{
"时间", "类型",
"金额", "对象",
"操作者", "理由",
"操作前余额",
"操作后余额"});
for (Map<String, Object> tx : txs) {
writeXmlRow(pw, new String[]{
txTime(tx),
txTypeName(tx),
String.valueOf(
txInt(tx,
"amount")),
txStr(tx,
"target_player"),
txStr(tx, "operator"),
txStr(tx, "reason"),
String.valueOf(
txInt(tx,
"balance_before")),
String.valueOf(
txInt(tx,
"balance_after"))
});
}
writeXmlFooter(pw);
pw.flush();
} finally {
pw.close();
}
if (sender != null) {
sender.sendMessage("§a[打印] §f"
+ player + " §a流水已导出");
sender.sendMessage("§7文件: §f"
+ fileName);
sender.sendMessage("§7共 §f"
+ txs.size()
+ " §7条,§724小时后删除");
}
} catch (Exception e) {
if (sender != null) {
sender.sendMessage("§c导出失败: "
+ e.getMessage());
}
plugin.getLogger().severe(
"[Printer] " + e.getMessage());
}
}
// ============ 打印全服 ============
public void printAll(CommandSender sender) {
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() -> doPrintAll(sender));
}
private void doPrintAll(CommandSender sender) {
try {
List<String> players =
plugin.getBonds()
.getAllPlayerNames();
if (players.isEmpty()) {
if (sender != null) {
sender.sendMessage("§c没有流水数据");
}
return;
}
String fileName = "全服流水_"
+ FILE_SDF.format(new Date())
+ ".xls";
File file = new File(outputDir, fileName);
// ===== 收集所有流水 =====
// key = 时间戳+玩家+金额,用于去重
// value = 一行数据
List<String[]> allRows = new ArrayList<>();
Set<String> seenKeys = new HashSet<>();
for (String player : players) {
List<Map<String, Object>> txs =
plugin.getBonds()
.getAllTransactions(player);
for (Map<String, Object> tx : txs) {
int txId = ((Number) tx.get("id"))
.intValue();
String dedupKey =
String.valueOf(txId);
if (!seenKeys.add(dedupKey)) {
continue;
}
String timeStr = txTime(tx);
long timeMs = 0;
Object t = tx.get("time");
if (t != null) {
timeMs =
((Number) t).longValue();
}
allRows.add(new String[]{
player,
timeStr,
txTypeName(tx),
String.valueOf(
txInt(tx, "amount")),
txStr(tx, "target_player"),
txStr(tx, "operator"),
txStr(tx, "reason"),
String.valueOf(
txInt(tx,
"balance_before")),
String.valueOf(
txInt(tx,
"balance_after")),
// 额外存时间戳用于排序
String.valueOf(timeMs)
});
}
}
// ===== 按时间戳排序 =====
allRows.sort((a, b) -> {
long ta = Long.parseLong(
a[a.length - 1]);
long tb = Long.parseLong(
b[b.length - 1]);
return Long.compare(ta, tb);
});
// ===== 写入文件 =====
PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(file),
StandardCharsets.UTF_8));
try {
writeXmlHeader(pw, "全服流水");
writeXmlRow(pw, new String[]{
"玩家", "时间", "类型",
"金额", "对象", "操作者",
"理由", "操作前余额",
"操作后余额"
});
for (String[] row : allRows) {
// 去掉最后的时间戳列
String[] cells =
new String[row.length - 1];
System.arraycopy(row, 0,
cells, 0, cells.length);
writeXmlRow(pw, cells);
}
writeXmlFooter(pw);
pw.flush();
if (sender != null) {
sender.sendMessage(
"§a[打印] §f全服流水 §a已导出");
sender.sendMessage("§7文件: §f"
+ fileName);
sender.sendMessage("§7共 §f"
+ players.size()
+ " 名玩家,§f"
+ allRows.size() + " §7条");
sender.sendMessage(
"§7按时间从早到晚排序");
sender.sendMessage(
"§724小时后自动删除");
}
} finally {
pw.close();
}
} catch (Exception e) {
if (sender != null) {
sender.sendMessage("§c导出失败: "
+ e.getMessage());
}
plugin.getLogger().severe(
"[Printer] " + e.getMessage());
}
}
// ============ 清理 ============
public void cleanupOldFiles() {
long now = System.currentTimeMillis();
long maxAge = 24L * 60 * 60 * 1000;
File[] files = outputDir.listFiles(
(d, n) -> n.endsWith(".xls"));
if (files == null) {
return;
}
int count = 0;
for (File f : files) {
if (now - f.lastModified() > maxAge) {
if (f.delete()) {
count++;
}
}
}
if (count > 0) {
plugin.getLogger().info(
"[Printer] 清理 "
+ count + " 个过期文件");
}
}
private void deleteOld(String player) {
File[] old = outputDir.listFiles(
(d, n) -> n.startsWith(player + "_")
&& n.endsWith(".xls"));
if (old != null) {
for (File f : old) {
f.delete();
}
}
}
// ============ XML工具 ============
private void writeXmlHeader(PrintWriter pw,
String sheetName) {
pw.println("<?xml version=\"1.0\"?>");
pw.println("<?mso-application "
+ "progid=\"Excel.Sheet\"?>");
pw.println("<Workbook xmlns="
+ "\"urn:schemas-microsoft-com"
+ ":office:spreadsheet\"");
pw.println(" xmlns:ss="
+ "\"urn:schemas-microsoft-com"
+ ":office:spreadsheet\">");
pw.println(" <Worksheet ss:Name=\""
+ sheetName + "\">");
pw.println(" <Table>");
}
private void writeXmlRow(PrintWriter pw,
String[] cells) {
pw.println(" <Row>");
for (String cell : cells) {
pw.println(" <Cell>"
+ "<Data ss:Type=\"String\">"
+ escXml(cell != null ? cell : "")
+ "</Data></Cell>");
}
pw.println(" </Row>");
}
private void writeXmlFooter(PrintWriter pw) {
pw.println(" </Table>");
pw.println(" </Worksheet>");
pw.println("</Workbook>");
}
private String escXml(String val) {
if (val == null) {
return "";
}
return val.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
private String txTime(Map<String, Object> tx) {
Object t = tx.get("time");
if (t == null) return "";
long ms = ((Number) t).longValue();
return CELL_SDF.format(new Date(ms));
}
private String txTypeName(
Map<String, Object> tx) {
String type = (String) tx.get("type");
return TYPE_NAMES.getOrDefault(type, type);
}
private int txInt(Map<String, Object> tx,
String key) {
Object v = tx.get(key);
if (v == null) return 0;
return ((Number) v).intValue();
}
private String txStr(Map<String, Object> tx,
String key) {
Object v = tx.get(key);
return v != null ? v.toString() : "";
}
}
@@ -0,0 +1,293 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.*;
import java.sql.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.nio.charset.StandardCharsets;
public class CDKManager {
private final Main plugin;
private Connection db;
private final Map<String, CDKInput> pendingInputs
= new ConcurrentHashMap<>();
public CDKManager(Main plugin) {
this.plugin = plugin;
initDB();
}
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(), "bond.db");
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
st.execute("CREATE TABLE IF NOT EXISTS cdk ("
+ "code TEXT PRIMARY KEY,"
+ "amount INTEGER DEFAULT 0,"
+ "type TEXT DEFAULT 'bond',"
+ "used INTEGER DEFAULT 0,"
+ "used_by TEXT DEFAULT '',"
+ "created_time INTEGER DEFAULT 0,"
+ "used_time INTEGER DEFAULT 0)");
st.close();
} catch (Exception e) {
throw new RuntimeException(
"[CDK] DB init failed: "
+ e.getMessage(), e);
}
}
// ===== CDK兑换(供SDF1调用) =====
/**
* 兑换CDK,返回结果字符串
* @return "success:金额:余额前:余额后"
* 或 "fail:原因"
*/
public String redeem(String code, String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT amount,type,used FROM cdk "
+ "WHERE code=?");
ps.setString(1, code);
ResultSet rs = ps.executeQuery();
if (!rs.next()) {
rs.close(); ps.close();
return "fail:not_found";
}
int amount = rs.getInt("amount");
String type = rs.getString("type");
int used = rs.getInt("used");
rs.close(); ps.close();
if (used == 1)
return "fail:already_used";
if (plugin.getBonds()
.isFrozen(player))
return "fail:frozen";
PreparedStatement up = db.prepareStatement(
"UPDATE cdk SET used=1,"
+ "used_by=?,used_time=? "
+ "WHERE code=?");
up.setString(1, player);
up.setLong(2,
System.currentTimeMillis());
up.setString(3, code);
up.executeUpdate(); up.close();
if ("bond".equals(type)) {
int bef = plugin.getBonds()
.getBonds(player);
plugin.getBonds().addBonds(player,
amount,
BondManager.TX_REDEEM,
"", "CDK系统",
"CDK兑换: " + code);
int aft = plugin.getBonds()
.getBonds(player);
return "success:" + amount
+ ":" + bef + ":" + aft;
}
return "fail:unknown_type";
} catch (SQLException e) {
return "fail:" + e.getMessage();
}
}
// ===== 聊天输入系统 =====
public void requestInput(Player p,
String type,
String context) {
CDKInput input = new CDKInput();
input.type = type;
input.context = context;
input.time = System.currentTimeMillis();
pendingInputs.put(p.getName(), input);
}
public boolean isListening(String playerName) {
return pendingInputs
.containsKey(playerName);
}
public void onChat(Player p, String msg) {
CDKInput input = pendingInputs
.remove(p.getName());
if (input == null) return;
if (System.currentTimeMillis()
- input.time > 60000) {
p.sendMessage("§c输入超时");
return;
}
if ("cdk".equals(input.type)) {
String result = redeem(
msg.trim(), p.getName());
if (result != null
&& result.startsWith("success:")) {
String[] parts = result.split(":");
int amt = Integer.parseInt(parts[1]);
int bef = Integer.parseInt(parts[2]);
int aft = Integer.parseInt(parts[3]);
p.sendMessage("§e§l[CDK] §f兑换成功!"
+ "获得 §c" + amt + "§f债券");
p.sendMessage("§7余额: §e" + bef
+ " §7-> §a" + aft);
} else {
p.sendMessage("§c§l[CDK] §f"
+ (result != null
? result.replace("fail:", "")
: "无效的兑换码"));
}
return;
}
if ("econ".equals(input.type)) {
handleEconInput(p, msg.trim(),
input.context);
return;
}
if ("bond".equals(input.type)) {
handleBondInput(p, msg.trim(),
input.context);
}
}
private void handleEconInput(Player admin,
String msg,
String target) {
net.milkbowl.vault.economy.Economy econ
= null;
try {
var reg = plugin.getServer()
.getServicesManager()
.getRegistration(
net.milkbowl.vault
.economy
.Economy.class);
if (reg != null)
econ = reg.getProvider();
} catch (Exception ignored) {}
if (econ == null) {
admin.sendMessage("§c经济系统不可用");
return;
}
if (!msg.startsWith("+")
&& !msg.startsWith("-")) {
admin.sendMessage("§c格式: §a+100"
+ " §7或 §c-50");
return;
}
try {
String numStr =
msg.substring(1).trim();
double amount =
Double.parseDouble(numStr);
if (amount <= 0) {
admin.sendMessage("§c金额必须>0");
return;
}
if (msg.startsWith("-")) {
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp == null) {
admin.sendMessage("§c" + target
+ " 不在线");
return;
}
if (!econ.has(tp, amount)) {
admin.sendMessage("§c余额不足");
return;
}
econ.withdrawPlayer(tp, amount);
admin.sendMessage("§a扣款成功");
} else {
econ.depositPlayer(target, amount);
admin.sendMessage("§a到账成功");
}
} catch (NumberFormatException e) {
admin.sendMessage("§c格式错误");
}
}
private void handleBondInput(Player admin,
String msg,
String target) {
if (!msg.startsWith("+")
&& !msg.startsWith("-")) {
admin.sendMessage("§c格式: §a+100"
+ " §7或 §c-50");
return;
}
try {
String sign = msg.substring(0, 1);
String rest =
msg.substring(1).trim();
String[] parts =
rest.split("\\s+", 2);
int amount = Integer.parseInt(parts[0]);
String reason = parts.length >= 2
? parts[1].trim()
: "管理员手动调整";
if (amount <= 0) {
admin.sendMessage("§c金额必须>0");
return;
}
String opName = admin.getName();
String txType = "-".equals(sign)
? BondManager.TX_ADMIN_DEDUCT
: BondManager.TX_ADMIN_GIVE;
if ("-".equals(sign)) {
if (plugin.getBonds()
.isFrozen(target)) {
admin.sendMessage("§c账户冻结");
return;
}
if (plugin.getBonds().deductBonds(
target, amount, txType,
"", opName, reason)) {
admin.sendMessage("§a扣除成功");
} else {
admin.sendMessage("§c债券不足");
}
} else {
if (plugin.getBonds()
.isFrozen(target)) {
admin.sendMessage("§c账户冻结");
return;
}
plugin.getBonds().addBonds(target,
amount, txType,
"", opName, reason);
admin.sendMessage("§a发放成功");
}
} catch (NumberFormatException e) {
admin.sendMessage("§c格式错误");
}
}
public void cancelInput(String player) {
pendingInputs.remove(player);
}
static class CDKInput {
String type;
String context;
long time;
}
}
@@ -0,0 +1,612 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ChatFilterManager {
private final JavaPlugin plugin;
private final List<String> whitelistUrls =
new ArrayList<>();
private final Set<String> whitelistPlayers =
new TreeSet<>(
String.CASE_INSENSITIVE_ORDER);
private final Map<Integer, String>
punishmentRules =
new LinkedHashMap<>();
private final Map<Integer, Integer>
punishmentDurations =
new LinkedHashMap<>();
private final Map<String, Integer>
violationCount =
new ConcurrentHashMap<>();
private final Map<String, Long>
mutedPlayers =
new ConcurrentHashMap<>();
private final Map<String, String> messages =
new LinkedHashMap<>();
private boolean notifyAdmin = true;
private boolean notifyAll = false;
private int muteDuration = 300;
private boolean enabled = true;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean v) {
enabled = v;
}
private static final Pattern URL_PATTERN =
Pattern.compile(
"(?i)(?:https?://\\S+)"
+ "|(?:(?:[a-zA-Z0-9]"
+ "(?:[a-zA-Z0-9\\-]*"
+ "[a-zA-Z0-9])?\\.)+"
+ "[a-zA-Z]{2,})"
+ "(?:/\\S*)?");
private static final Pattern DOT_VARIANTS =
Pattern.compile(
"[\uff0e\u3002\u2025\u2026\u00b7]");
private static final Pattern COLOR_CODES =
Pattern.compile(
"\u00a7[0-9a-fk-orA-FK-OR]");
private static final Set<String> FILE_EXT;
static {
Set<String> s = new HashSet<>(
Arrays.asList(
"txt", "yml", "yaml",
"json", "xml", "java",
"class", "jar", "png",
"jpg", "jpeg", "gif",
"bmp", "svg", "ico",
"webp", "mp3", "mp4",
"avi", "mkv", "wav",
"flac", "ogg", "zip",
"rar", "7z", "tar",
"gz", "cfg", "conf",
"ini", "log", "db",
"sql", "sh", "bat",
"html", "htm", "css",
"js", "md", "pdf",
"dll", "so", "exe",
"bin", "c", "cpp",
"py", "rb", "go",
"rs", "kt", "lua",
"php", "asp", "jsp"));
FILE_EXT = Collections.unmodifiableSet(s);
}
public ChatFilterManager(JavaPlugin plugin) {
this.plugin = plugin;
}
// ========== 配置加载 ==========
public void loadConfig() {
whitelistUrls.clear();
whitelistPlayers.clear();
punishmentRules.clear();
punishmentDurations.clear();
messages.clear();
File f = new File(
plugin.getDataFolder(), "chat.txt");
if (!f.exists()) writeDefaultFile(f);
try (BufferedReader r =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8))) {
String section = "";
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("\uFEFF"))
line = line.substring(1);
String t = line.trim();
if (t.isEmpty()
|| t.startsWith("#"))
continue;
if (t.equals("白名单")
|| t.equals("白名单:")) {
section = "whitelist";
continue;
}
if (t.equals("处罚规则")
|| t.equals("处罚规则:")) {
section = "punishment";
continue;
}
if (t.equals("白名单玩家")
|| t.equals("白名单玩家:")) {
section = "players";
continue;
}
if (t.contains(":")) {
String[] kv = t.split(":", 2);
String k = kv[0].trim();
String v = kv[1].trim();
if (equals("启用过滤")
|| equals("enabled")) {
enabled = parseBool(v);
continue;
}
if (k.equals("通知管理员")) {
notifyAdmin = parseBool(v);
continue;
}
if (k.equals("全服通报")) {
notifyAll = parseBool(v);
continue;
}
if (k.equals("禁言时长")) {
try {
muteDuration =
Integer.parseInt(v);
} catch (Exception ignored) {
}
continue;
}
}
switch (section) {
case "whitelist":
whitelistUrls.add(
t.toLowerCase());
break;
case "punishment":
String[] parts = t.split(":");
if (parts.length >= 2) {
try {
int cnt =
Integer.parseInt(
parts[0]
.trim());
String type =
parts[1].trim()
.toLowerCase();
int dur =
parts.length >= 3
? Integer.parseInt(
parts[2]
.trim())
: muteDuration;
punishmentRules.put(
cnt, type);
punishmentDurations.put(
cnt, dur);
} catch (Exception ignored) {
}
}
break;
case "players":
whitelistPlayers.add(t);
break;
}
}
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_chat] 配置加载失败: "
+ e.getMessage());
}
// 从消息.txt读取chat_开头的消息
loadMessages();
}
private void loadMessages() {
File msgFile = new File(
plugin.getDataFolder(),
"消息.txt");
if (!msgFile.exists()) return;
try (BufferedReader mr =
new BufferedReader(
new InputStreamReader(
new FileInputStream(
msgFile),
StandardCharsets.UTF_8))) {
String mline;
while ((mline = mr.readLine()) != null) {
mline = mline.trim();
if (mline.isEmpty()
|| mline.startsWith("#"))
continue;
// 支持 = 和 : 两种分隔符
int eqIdx = mline.indexOf('=');
int coIdx = mline.indexOf(':');
int splitIdx = -1;
if (eqIdx >= 0 && coIdx >= 0) {
splitIdx = Math.min(eqIdx, coIdx);
} else if (eqIdx >= 0) {
splitIdx = eqIdx;
} else if (coIdx >= 0) {
splitIdx = coIdx;
}
if (splitIdx < 0) continue;
String k = mline.substring(0,
splitIdx).trim();
String v = mline.substring(
splitIdx + 1).trim();
if (k.startsWith("chat_")) {
messages.put(k, v);
}
}
} catch (Exception ignored) {
}
}
private void writeDefaultFile(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# Sdf1_chat 聊天过滤配置");
pw.println("启用过滤: " + enabled);
pw.println("通知管理员: true");
pw.println("全服通报: false");
pw.println("禁言时长: 300");
pw.println();
pw.println("白名单:");
pw.println("*.minecraft.net");
pw.println("baidu.com");
pw.println("github.com");
pw.println();
pw.println("处罚规则:");
pw.println("1:warn:0");
pw.println("2:warn:0");
pw.println("3:mute:300");
pw.println("5:mute:600");
pw.println("8:kick:0");
pw.println("10:ban:0");
pw.println();
pw.println("白名单玩家:");
} catch (IOException ignored) {
}
}
public void saveConfig() {
File f = new File(
plugin.getDataFolder(), "chat.txt");
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# Sdf1_chat 聊天过滤配置");
pw.println("通知管理员: " + notifyAdmin);
pw.println("全服通报: " + notifyAll);
pw.println("禁言时长: " + muteDuration);
pw.println();
pw.println("白名单:");
for (String u : whitelistUrls)
pw.println(u);
pw.println();
pw.println("处罚规则:");
for (Map.Entry<Integer, String> entry
: punishmentRules.entrySet()) {
int dur = punishmentDurations
.getOrDefault(entry.getKey(),
muteDuration);
pw.println(entry.getKey() + ":"
+ entry.getValue()
+ ":" + dur);
}
pw.println();
pw.println("白名单玩家:");
for (String p : whitelistPlayers)
pw.println(p);
} catch (IOException e) {
plugin.getLogger().severe(
"[Sdf1_chat] 保存失败: "
+ e.getMessage());
}
}
// ========== 消息 ==========
public String msg(String key,
String... args) {
String template = messages.getOrDefault(
key, key);
for (int i = 0; i < args.length - 1;
i += 2) {
template = template.replace(
"{" + args[i] + "}",
args[i + 1]);
}
return template;
}
// ========== URL检测 ==========
private String normalizeMessage(String msg) {
msg = COLOR_CODES.matcher(msg)
.replaceAll("");
msg = DOT_VARIANTS.matcher(msg)
.replaceAll(".");
return msg;
}
public List<String> extractUrls(
String message) {
String normalized =
normalizeMessage(message);
List<String> urls = new ArrayList<>();
Matcher m = URL_PATTERN
.matcher(normalized);
while (m.find()) {
String url = m.group().toLowerCase();
url = url.replaceAll(
"[\\s.,;!?,。;!?、)》」』】"
+ "\\]\\[\\((]+$",
"");
if (!url.isEmpty()) urls.add(url);
}
return urls;
}
public boolean isWhitelisted(String url) {
String clean = url
.replaceAll("^https?://", "")
.toLowerCase();
int colon = clean.indexOf(':');
int slash = clean.indexOf('/');
if (colon > 0 && (slash < 0
|| colon < slash)) {
clean = clean.substring(0, colon);
}
slash = clean.indexOf('/');
if (slash > 0) {
clean = clean.substring(0, slash);
}
for (String entry : whitelistUrls) {
String e = entry.toLowerCase().trim();
if (e.startsWith("*.")) {
String suffix = e.substring(1);
if (clean.endsWith(suffix)
|| clean.equals(
suffix.substring(1)))
return true;
} else {
if (clean.equals(e)
|| clean.endsWith(
"." + e))
return true;
}
}
return false;
}
public boolean isLikelyDomain(String url) {
int lastDot = url.lastIndexOf('.');
if (lastDot < 0
|| lastDot >= url.length() - 1)
return false;
String ext = url.substring(lastDot + 1)
.toLowerCase();
return !FILE_EXT.contains(ext);
}
// ========== 玩家操作 ==========
public boolean isMuted(String name) {
Long expiry = mutedPlayers.get(name);
if (expiry == null) return false;
if (System.currentTimeMillis()
>= expiry) {
mutedPlayers.remove(name);
return false;
}
return true;
}
public String fmtDuration(int seconds) {
if (seconds <= 0) return "0秒";
int day = seconds / 86400;
int hr = (seconds % 86400) / 3600;
int min = (seconds % 3600) / 60;
int sec = seconds % 60;
StringBuilder sb = new StringBuilder();
if (day > 0) sb.append(day).append("天");
if (hr > 0)
sb.append(hr).append("小时");
if (min > 0)
sb.append(min).append("分");
if (sec > 0 || sb.length() == 0)
sb.append(sec).append("秒");
return sb.toString();
}
public void applyPunishment(Player player,
int violation) {
String type = "warn";
int duration = muteDuration;
int maxRule = 0;
for (Map.Entry<Integer, String> entry
: punishmentRules.entrySet()) {
if (violation >= entry.getKey()
&& entry.getKey() > maxRule) {
maxRule = entry.getKey();
type = entry.getValue();
duration = punishmentDurations
.getOrDefault(
entry.getKey(),
muteDuration);
}
}
if (type.equals("mute")) {
if (isMuted(player.getName()))
return;
mutedPlayers.put(player.getName(),
System.currentTimeMillis()
+ (long) duration
* 1000L);
}
final String fType = type;
final int fDur = duration;
final int fV = violation;
final String fName = player.getName();
Bukkit.getScheduler().runTask(plugin,
() -> {
Player p = Bukkit.getPlayer(fName);
if (p == null) return;
switch (fType) {
case "warn":
// 警告不处罚,只提示
break;
case "mute":
p.sendMessage(
msg("chat_muted"));
break;
case "kick":
p.kickPlayer(
msg("chat_muted"));
break;
case "ban":
Bukkit.dispatchCommand(
Bukkit.getConsoleSender(),
"ban " + fName
+ " §c多次发送违规链接");
break;
case "banip":
InetSocketAddress addr =
p.getAddress();
if (addr != null) {
String ip = addr
.getAddress()
.getHostAddress();
Bukkit.dispatchCommand(
Bukkit.getConsoleSender(),
"ban-ip " + ip
+ " §c发送违规链接");
}
break;
}
});
}
public void unmutePlayer(String name) {
mutedPlayers.remove(name);
}
public void resetPlayer(String name) {
mutedPlayers.remove(name);
violationCount.remove(name);
}
public boolean isPlayerWhitelisted(
String name) {
return whitelistPlayers.contains(name);
}
public boolean isNotifyAdmin() {
return notifyAdmin;
}
public boolean isNotifyAll() {
return notifyAll;
}
public int getViolationCount(String name) {
return violationCount
.getOrDefault(name, 0);
}
public void incrementViolation(
String name) {
violationCount.put(name,
getViolationCount(name) + 1);
}
public List<String> getWhitelistUrls() {
return whitelistUrls;
}
public Set<String> getWhitelistPlayers() {
return whitelistPlayers;
}
public Map<Integer, String>
getPunishmentRules() {
return punishmentRules;
}
public Map<Integer, Integer>
getPunishmentDurations() {
return punishmentDurations;
}
public Map<String, Long>
getMutedPlayers() {
return mutedPlayers;
}
public void setNotifyAdmin(boolean v) {
notifyAdmin = v;
}
public void setNotifyAll(boolean v) {
notifyAll = v;
}
public void setMuteDuration(int v) {
muteDuration = v;
}
public void addUrl(String url) {
whitelistUrls.add(
url.toLowerCase().trim());
saveConfig();
}
public boolean removeUrl(String url) {
boolean r = whitelistUrls.remove(
url.toLowerCase().trim());
saveConfig();
return r;
}
public void addWhitelistPlayer(
String name) {
whitelistPlayers.add(name);
saveConfig();
}
public boolean removeWhitelistPlayer(
String name) {
boolean r =
whitelistPlayers.remove(name);
saveConfig();
return r;
}
public void cleanExpired() {
long now = System.currentTimeMillis();
mutedPlayers.entrySet().removeIf(
e -> e.getValue() <= now);
}
private boolean parseBool(String v) {
String s = v.toLowerCase().trim();
return s.equals("true")
|| s.equals("on")
|| s.equals("yes")
|| s.equals("1")
|| s.contains("开启")
|| s.contains("启用")
|| s.contains("是");
}
}
@@ -0,0 +1,842 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.HashMap;
import java.util.Map;
public class ChatInputManager {
public enum InputType {
NONE,
CHANGE_PWD_STEP1,
CHANGE_PWD_STEP2,
SET_EMAIL,
SET_EMAIL_VERIFY,
ADMIN_AUTH,
ADMIN_SET_POINTS,
ADMIN_SET_PWD,
ADMIN_DELETE_CONFIRM,
ADMIN_SET_AFK_TIME,
SMTP_CONFIG,
SMTP_VERIFY,
BACK_CHECKIN,
INVITE_INPUT_CODE,
TICKET_TITLE,
TICKET_DESC,
TICKET_SCORE,
TICKET_REJECT,
TICKET_ASSIGN,
TICKET_INVITE_PROVIDER,
TICKET_REMOVE_PROVIDER,
TICKET_COMPLETE,
TICKET_REPORT_ISSUE,
TICKET_REPLY,
TICKET_ADMIN_TYPE,
TICKET_ADMIN_TITLE,
TICKET_ADMIN_DESC,
TICKET_ADMIN_PROVIDER,
BACK_CHECK_ECONOMY_CONFIRM,
USER_MGMT_SEARCH,
}
public static class PlayerState {
public InputType type = InputType.NONE;
public String targetPlayer = "";
public String ticketType = "";
public String ticketTitle = "";
public String ticketDesc = "";
public int ticketId = 0;
public int smtpStep = 0;
public int scoreValue = 0;
}
private final Map<String, PlayerState> states =
new HashMap<>();
private Main plugin;
public PlayerState getState(Player p) {
return states.computeIfAbsent(
p.getName(),
k -> new PlayerState());
}
public void reset(Player p) {
states.remove(p.getName());
}
public boolean isInFlow(Player p) {
PlayerState state = states.get(p.getName());
return state != null
&& state.type != InputType.NONE;
}
public void handleInput(Player p, String msg,
Main mainPlugin) {
if (this.plugin == null)
this.plugin = mainPlugin;
PlayerState state = getState(p);
ConfigManager config =
mainPlugin.getConfig2();
DatabaseManager db = mainPlugin.getDb();
switch (state.type) {
case CHANGE_PWD_STEP1: {
String name = p.getName();
String salt = (String) db.getField(
name, "password_salt");
String hash =
PasswordUtils.hash(msg, salt);
boolean mainOk =
db.checkPassword(name, hash);
if (mainOk) {
state.type =
InputType.CHANGE_PWD_STEP2;
state.ticketTitle = "main";
p.sendMessage(
"§e请输入新密码:");
return;
}
boolean tempOk =
db.checkPasswordOrTemp(
name, hash);
if (tempOk) {
state.type =
InputType.CHANGE_PWD_STEP2;
state.ticketTitle = "temp";
p.sendMessage(
"§e请输入新密码:");
return;
}
p.sendMessage(config.msg(
"password_wrong"));
return;
}
case CHANGE_PWD_STEP2: {
String name = p.getName();
if (!PasswordUtils.validate(msg)) {
p.sendMessage(config.msg(
"password_format_error"));
reset(p);
return;
}
String oldHash =
(String) db.getField(name,
"password_hash");
String oldSalt =
(String) db.getField(name,
"password_salt");
String newSalt =
PasswordUtils.generateSalt();
String newHash =
PasswordUtils.hash(
msg, newSalt);
if (newHash.equals(oldHash)) {
p.sendMessage(config.msg(
"password_same"));
reset(p);
return;
}
mainPlugin.recordPasswordChange(
name, oldHash, oldSalt);
db.setField(name,
"password_hash", newHash);
db.setField(name,
"password_salt", newSalt);
mainPlugin
.getNeedsPasswordChange()
.remove(name);
p.sendMessage(config.msg(
"password_changed"));
reset(p);
return;
}
case SET_EMAIL: {
if (!msg.contains("@")
|| !msg.contains(".")) {
p.sendMessage(config.msg(
"email_format_error"));
reset(p);
return;
}
String oldEmail = (String)
db.getField(p.getName(),
"email");
String emailAddr = msg.trim();
// 已绑定邮箱:验证登录密码
if (oldEmail != null
&& !oldEmail.isEmpty()) {
state.targetPlayer = emailAddr;
state.type =
InputType.SET_EMAIL_VERIFY;
state.ticketTitle =
"need_pwd";
p.sendMessage(
"§e您已绑定邮箱: "
+ oldEmail);
p.sendMessage(
"§e请验证登录密码以确认修改:");
return;
}
// 未绑定邮箱:直接发验证码
String code = String.valueOf(
(int) (Math.random()
* 900000
+ 100000));
state.targetPlayer = emailAddr;
state.ticketTitle = code;
state.type =
InputType.SET_EMAIL_VERIFY;
final String to = emailAddr;
final String c = code;
final Player fp = p;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () -> {
boolean sent = mainPlugin
.getEmail()
.sendVerifyCode(
to, c);
Bukkit.getScheduler()
.runTask(mainPlugin,
() -> {
if (sent) {
fp.sendMessage(
"§a验证码已发送到 "
+ to);
fp.sendMessage(
"§e输入6位验证码(0取消):");
} else {
fp.sendMessage(
"§c发送失败");
reset(fp);
}
});
});
return;
}
case SET_EMAIL_VERIFY: {
if ("0".equals(msg.trim())
|| "取消".equals(
msg.trim())) {
p.sendMessage(
"§c邮箱设置已取消");
reset(p);
return;
}
String expected =
state.ticketTitle;
if (msg.trim().equals(expected)) {
db.setField(p.getName(),
"email",
state.targetPlayer);
p.sendMessage(
"§a邮箱已设置: "
+ state.targetPlayer);
reset(p);
} else {
p.sendMessage(
"§c验证码错误(0取消):");
}
return;
}
case ADMIN_AUTH: {
if (msg.equals(
config.adminPassword)) {
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getGui()
.openAdmin(p));
} else {
p.sendMessage(
"§c管理密码错误");
}
reset(p);
return;
}
case ADMIN_SET_POINTS: {
try {
int pts = Integer.parseInt(
msg.trim());
db.setField(
state.targetPlayer,
"points", pts);
p.sendMessage("§a已将 "
+ state.targetPlayer
+ " 积分设为 " + pts);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效数字");
}
String tgt =
state.targetPlayer;
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getGui()
.openUserDetail(
p, tgt));
reset(p);
return;
}
case ADMIN_SET_PWD: {
if (!PasswordUtils.validate(msg)) {
p.sendMessage(config.msg(
"password_format_error"));
reset(p);
return;
}
String tgt = state.targetPlayer;
String salt =
PasswordUtils
.generateSalt();
String hash =
PasswordUtils.hash(
msg, salt);
db.setField(tgt,
"temp_password", hash);
db.setField(tgt, "temp_pw_expire",
System.currentTimeMillis()
+ 300000L);
db.setField(tgt,
"temp_pw_used", 0);
p.sendMessage("§a已为 " + tgt
+ " 设置临时密码");
Player tp =
Bukkit.getPlayer(tgt);
if (tp != null && tp.isOnline())
tp.kickPlayer(
"§c管理员已设置临时密码,"
+ "请使用临时密码登录");
// 邮件通知
String emailAddr = (String)
db.getField(tgt, "email");
if (emailAddr != null
&& !emailAddr.isEmpty()) {
String time =
new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date());
String body = "玩家 " + tgt
+ ":\n您的密码已于 "
+ time
+ " 被管理员重置为临时密码。\n"
+ "如果是本人操作请忽略。\n"
+ "如非本人操作请立即联系管理员。";
final String to = emailAddr;
final String fBody = body;
final String fName = tgt;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () ->
mainPlugin.getEmail()
.sendTempPassword(
to,
fName,
fBody));
}
reset(p);
return;
}
case ADMIN_DELETE_CONFIRM: {
if (msg.equals(
state.targetPlayer)) {
db.deleteUser(
state.targetPlayer);
p.sendMessage("§a已删除 "
+ state.targetPlayer);
Player tgt =
Bukkit.getPlayer(
state.targetPlayer);
if (tgt != null
&& tgt.isOnline())
tgt.kickPlayer(
"§c账号已被删除");
} else {
p.sendMessage(
"§c输入不匹配,已取消");
}
reset(p);
return;
}
case ADMIN_SET_AFK_TIME: {
try {
int minutes =
Integer.parseInt(
msg.trim());
config.afkTimeout =
minutes * 60;
config.saveSettings();
p.sendMessage(
"§a挂机时长已设为 "
+ minutes
+ " 分钟");
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效数字");
}
reset(p);
return;
}
case SMTP_CONFIG: {
String m = msg.trim();
if ("000".equals(m)
|| "取消取消取消"
.equals(m)) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
if ("取消".equals(m)) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
int step = state.smtpStep;
if (step <= 1) {
if (!"0".equals(m))
config.setSmtp(
"smtp地址", m);
state.smtpStep = 2;
p.sendMessage("§e当前端口: "
+ config.getSmtp(
"smtp端口"));
p.sendMessage(
"§e输入端口(0跳过):");
} else if (step == 2) {
if (!"0".equals(m))
config.setSmtp(
"smtp端口", m);
state.smtpStep = 3;
p.sendMessage(
"§e输入发件邮箱:");
} else if (step == 3) {
if (!"0".equals(m))
config.setSmtp(
"smtp账号", m);
state.smtpStep = 4;
p.sendMessage(
"§e输入SMTP密码(0跳过):");
} else if (step == 4) {
if (!"0".equals(m))
config.setSmtp(
"smtp密码", m);
state.smtpStep = 5;
p.sendMessage(
"§e输入接收邮箱(留空发到发件邮箱):");
} else if (step == 5) {
String recv = m;
if (recv.isEmpty()
|| "0".equals(recv))
recv = config.getSmtp(
"smtp账号");
config.setSmtp(
"验证码接收邮箱", recv);
if (!config
.canSendSmtpVerify()) {
p.sendMessage(
"§c发送间隔不足1分钟");
reset(p);
return;
}
String code = config
.generateSmtpVerifyCode();
String addr = config.getSmtp(
"smtp地址");
String acct = config.getSmtp(
"smtp账号");
if (acct.isEmpty()
|| addr.isEmpty()) {
p.sendMessage(
"§c地址或账号为空");
reset(p);
return;
}
config.recordSmtpSendTime();
final String fRecv = recv;
final String fCode = code;
final Player fp = p;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () -> {
Bukkit.getScheduler()
.runTask(
mainPlugin,
() -> fp.sendMessage(
"§e正在发送验证码..."));
boolean sent =
mainPlugin.getEmail()
.sendVerifyCode(
fRecv,
fCode);
final boolean fSent = sent;
Bukkit.getScheduler()
.runTask(
mainPlugin,
() -> {
if (fSent) {
state.type =
InputType
.SMTP_VERIFY;
state.smtpStep = 0;
fp.sendMessage(
"§a验证码已发送到 "
+ fRecv);
fp.sendMessage(
"§e输入6位验证码(0取消):");
} else {
fp.sendMessage(
"§c发送失败,查看控制台");
reset(fp);
}
});
});
}
return;
}
case SMTP_VERIFY: {
if ("0".equals(msg.trim())
|| "取消".equals(
msg.trim())) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
if (config.checkSmtpVerifyCode(
msg.trim())) {
config.saveSmtp();
config.clearSmtpVerifyCode();
String addr = config.getSmtp(
"smtp地址");
String port = config.getSmtp(
"smtp端口");
String acct = config.getSmtp(
"smtp账号");
String recv = config.getSmtp(
"验证码接收邮箱");
boolean ssl = config.smtpSsl;
p.sendMessage("§a§l=== SMTP配置完成 ===");
p.sendMessage("§7地址: §f" + addr);
p.sendMessage("§7端口: §f" + port);
p.sendMessage("§7账号: §f" + acct);
p.sendMessage("§7接收: §f" + recv);
p.sendMessage("§7加密: §f" + ssl);
p.sendMessage("§a§l====================");
reset(p);
} else {
p.sendMessage(
"§c验证码错误(0取消):");
}
return;
}
case BACK_CHECKIN: {
String result =
mainPlugin.getCheckIn()
.backCheckIn(p, msg);
if (result == null) {
// 等待选择(NEED_CONFIRM)
return;
}
if (result.startsWith(
"NEED_CONFIRM:")) {
String[] parts =
result.split(":");
// 存储到Main的Map
mainPlugin.pendingBackCheck.put(
p.getName(),
parts[1] + ":" + parts[2]);
return;
}
if (result.startsWith("§c")) {
p.sendMessage(result);
} else {
p.sendMessage(result);
}
reset(p);
return;
}
case INVITE_INPUT_CODE: {
String code = msg.trim();
String myName = p.getName();
String myCode =
(String) db.getField(
myName,
"invite_code");
if (code.isEmpty()) {
p.sendMessage(
"§c邀请码不能为空");
reset(p);
return;
}
if (code.equals(myCode)) {
p.sendMessage(
"§c不能用自己邀请码");
reset(p);
return;
}
boolean found = false;
for (Map<String, Object> u :
db.getAllUsers()) {
String uc = (String)
u.getOrDefault(
"invite_code", "");
if (code.equals(uc)) {
String inv = (String)
u.get("player_name");
db.setField(myName,
"invited_by", inv);
db.addPoints(inv, 50);
p.sendMessage(
"§a邀请成功!邀请人: "
+ inv);
Player ip =
Bukkit.getPlayer(inv);
if (ip != null
&& ip.isOnline())
ip.sendMessage("§a"
+ myName
+ " 使用了您的邀请码");
found = true;
break;
}
}
if (!found)
p.sendMessage(
"§c邀请码不存在");
reset(p);
return;
}
case TICKET_TITLE: {
state.ticketTitle = msg;
state.type =
InputType.TICKET_DESC;
p.sendMessage(
"§e请输入工单详细描述:");
return;
}
case TICKET_DESC: {
String tt = state.ticketType;
String tl = state.ticketTitle;
reset(p);
mainPlugin.getTicket()
.submitTicket(
p, tt, tl, msg);
return;
}
case TICKET_SCORE: {
int tid = state.ticketId;
try {
int sc = Integer.parseInt(
msg.trim());
reset(p);
mainPlugin.getTicket()
.confirmAndScore(
p, tid, sc);
} catch (
NumberFormatException e) {
reset(p);
p.sendMessage(
"§c请输入数字1-5");
}
return;
}
case TICKET_REJECT: {
int tid = state.ticketId;
reset(p);
mainPlugin.getTicket()
.rejectTicket(
p, tid, msg);
return;
}
case TICKET_ASSIGN: {
int tid = state.ticketId;
String[] parts = msg.trim()
.split("\\s+");
String prov = parts[0];
int price = 0;
if (parts.length >= 2) {
try {
price = Integer.parseInt(
parts[1]);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c价格格式错误");
}
}
reset(p);
mainPlugin.getTicket()
.assignToProvider(
p, tid, prov,
price);
final int fTid = tid;
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getTicket()
.openDetail(
p, fTid));
return;
}
case TICKET_INVITE_PROVIDER: {
String tgt = msg.trim();
reset(p);
if (!db.userExists(tgt)) {
p.sendMessage(
"§c玩家不存在");
return;
}
db.addServiceProvider(
tgt, "waiter");
p.sendMessage("§a已邀请 " + tgt
+ " 成为服务商");
return;
}
case TICKET_REMOVE_PROVIDER: {
String tgt = msg.trim();
reset(p);
if (!db.isServiceProvider(tgt)) {
p.sendMessage("§c" + tgt
+ " 不是服务商");
return;
}
db.removeServiceProvider(tgt);
p.sendMessage("§a已移除 " + tgt);
return;
}
case TICKET_COMPLETE: {
try {
int tid = Integer.parseInt(
msg.trim());
mainPlugin.getTicket()
.completeAsProvider(
p, tid);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效工单号");
}
reset(p);
return;
}
case TICKET_REPORT_ISSUE: {
String report = msg.trim();
reset(p);
p.sendMessage("§e异常已上报");
String tag = config.adminTag;
for (Player op :
Bukkit.getOnlinePlayers()) {
if (op.getScoreboardTags()
.contains(tag)) {
op.sendMessage(
"§c§l[工单异常] §f"
+ p.getName()
+ ": " + report);
}
}
return;
}
case TICKET_REPLY: {
int replyTid = state.ticketId;
reset(p);
mainPlugin.getTicket()
.replyTicket(
p, replyTid, msg);
return;
}
case TICKET_ADMIN_TYPE: {
state.ticketType = msg.trim();
state.type =
InputType
.TICKET_ADMIN_TITLE;
p.sendMessage(
"§e请输入工单标题:");
return;
}
case TICKET_ADMIN_TITLE: {
state.ticketTitle = msg;
state.type =
InputType
.TICKET_ADMIN_DESC;
p.sendMessage(
"§e请输入工单描述:");
return;
}
case TICKET_ADMIN_DESC: {
state.targetPlayer = msg;
state.type =
InputType
.TICKET_ADMIN_PROVIDER;
p.sendMessage(
"§e输入服务商名和价格:");
p.sendMessage(
"§7例: playerA 20");
return;
}
case TICKET_ADMIN_PROVIDER: {
String[] parts2 = msg.trim()
.split("\\s+");
String prov2 = parts2[0];
int price2 = 0;
if (parts2.length >= 2) {
try {
price2 = Integer.parseInt(
parts2[1]);
} catch (
NumberFormatException e) {
}
}
String t2 = state.ticketType;
String ti2 = state.ticketTitle;
String d2 = state.targetPlayer;
reset(p);
mainPlugin.getTicket()
.adminCreateAndAssign(
p, t2, ti2,
d2, prov2, price2);
return;
}
default:
reset(p);
return;
}
}
}
@@ -0,0 +1,302 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class CheckInManager {
private final Main plugin;
public CheckInManager(Main plugin) {
this.plugin = plugin;
}
// ===== 今日是否已签到 =====
public boolean isCheckedInToday(
String name) {
String last = (String) plugin.getDb()
.getField(name,
"last_checkin_date");
String today = new SimpleDateFormat(
"yyyy-MM-dd")
.format(new Date());
return today.equals(last);
}
// ===== 签到 =====
// ===== 签到 =====
public String checkIn(Player p) {
String name = p.getName();
if (isCheckedInToday(name)) {
return plugin.getConfig2()
.msg("checkin_already");
}
Object streakObj = plugin.getDb()
.getField(name, "checkin_streak");
int streak = streakObj != null
? ((Number) streakObj).intValue()
: 0;
String lastDate = (String) plugin.getDb()
.getField(name, "last_checkin_date");
String today = new SimpleDateFormat("yyyy-MM-dd")
.format(new Date());
if (lastDate != null && !lastDate.isEmpty()) {
try {
java.util.Date lastD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(lastDate);
java.util.Date todayD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(today);
long diff = todayD.getTime()
- lastD.getTime();
if (diff > 86400000L * 2) {
streak = 0;
} else if (diff > 86400000L) {
streak++;
}
} catch (Exception ignored) {
}
}
if (streak == 0) streak = 1;
else streak++;
double multi = 1.0;
if (streak >= 21) multi = 1.5;
else if (streak >= 14) multi = 1.3;
else if (streak >= 7) multi = 1.1;
int basePoints =
plugin.getConfig2().checkinPoints;
int points = (int) (basePoints * multi);
plugin.getDb().addPoints(name, points);
plugin.getDb().setField(name,
"checkin_streak", streak);
plugin.getDb().setField(name,
"last_checkin_date", today);
Object totalObj = plugin.getDb()
.getField(name, "total_checkin_days");
int total = totalObj != null
? ((Number) totalObj).intValue()
: 0;
plugin.getDb().setField(name,
"total_checkin_days", total + 1);
// ★ 债券盲盒奖励(6参数版,写入流水)★
int bondMin =
plugin.getConfig2().checkinBondMin;
int bondMax =
plugin.getConfig2().checkinBondMax;
if (bondMin <= 0) bondMin = 1;
if (bondMax < bondMin) bondMax = bondMin;
int bondReward = bondMin
+ new java.util.Random()
.nextInt(bondMax - bondMin + 1);
plugin.getBonds().addBonds(name, bondReward,
"daily_sign", "", "签到系统",
"每日签到连续" + streak + "天");
return plugin.getConfig2()
.msg("checkin_success")
.replace("{points}",
String.valueOf(points))
.replace("{streak}",
String.valueOf(streak))
.replace("{multi}",
String.valueOf(multi))
+ " §e+" + bondReward + "债券";
}
// ===== 获取连续天数 =====
public int getStreak(String name) {
Object val = plugin.getDb()
.getField(name,
"checkin_streak");
if (val instanceof Number) {
return ((Number) val)
.intValue();
}
return 0;
}
// 标准化日期
private String normalizeDate(String input) {
String s = input.trim().replace("/", "-");
if (s.matches("\\d{8}")) {
return s.substring(0, 4) + "-"
+ s.substring(4, 6) + "-"
+ s.substring(6, 8);
}
return s;
}
// ===== 补签入口 =====
public String backCheckIn(
Player p, String dateStr) {
String name = p.getName();
String normalized =
normalizeDate(dateStr);
// 严格日期验证
java.text.SimpleDateFormat sdf =
new java.text.SimpleDateFormat(
"yyyy-MM-dd");
sdf.setLenient(false);
java.util.Date target;
try {
target = sdf.parse(normalized);
} catch (Exception ex) {
return "§c无效日期: " + dateStr;
}
String reformat = sdf.format(target);
if (!reformat.equals(normalized)) {
return "§c无效日期: " + dateStr;
}
String today = sdf.format(new Date());
if (normalized.compareTo(today) >= 0) {
return "§c今天不需要补签";
}
String threeDaysAgo = getPastDate(3);
if (normalized.compareTo(
threeDaysAgo) < 0) {
return "§c只能补签3天内"
+ "(最多到 " + threeDaysAgo + ")";
}
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
boolean alreadyBacked = false;
if (!existing.isEmpty()) {
for (String d : existing.split(",")) {
if (normalizeDate(d.trim())
.equals(normalized)) {
alreadyBacked = true;
break;
}
}
}
if (alreadyBacked) {
return "§c该日期已补签: " + normalized;
}
Object normalCheck =
plugin.getDb().getField(
name, "last_checkin_date");
String lastCheckin = normalCheck != null
? normalCheck.toString() : "";
if (lastCheckin.equals(normalized)) {
return "§c该日期已签到: " + normalized;
}
// ★ 补签消耗:3债券 + 10积分
// 积分不够时:用 9债券(3倍)代替 ★
int bondCost = 3;
int pointCost = 10;
int bondAltCost = 9; // 3倍债券替代
int myBonds = plugin.getBonds()
.getBonds(name);
Object ptsObj = plugin.getDb()
.getField(name, "points");
int myPoints = ptsObj != null
? ((Number) ptsObj).intValue()
: 0;
// 方案A:3债券 + 10积分
boolean canA = myBonds >= bondCost
&& myPoints >= pointCost;
// 方案B:9债券(积分不够时)
boolean canB = myBonds >= bondAltCost
&& myPoints < pointCost;
if (!canA && !canB) {
if (myPoints < pointCost
&& myBonds < bondAltCost) {
return "§c补签费用不足"
+ "(需3债券+10积分,"
+ "或9债券代替)"
+ " 当前: "
+ myBonds + "债券 "
+ myPoints + "积分";
}
if (myBonds < bondCost) {
return "§c债券不足(需"
+ bondCost + "枚)";
}
return "§c积分不足(需"
+ pointCost + "分)";
}
// 执行扣款
if (canA) {
// 方案A:3债券 + 10积分
plugin.getBonds().deductBonds(
name, bondCost,
"admin_deduct", "",
"补签系统", "补签扣款");
plugin.getDb().deductPoints(
name, pointCost);
} else {
// 方案B:9债券代替
plugin.getBonds().deductBonds(
name, bondAltCost,
"admin_deduct", "",
"补签系统", "补签扣款(债券替代)");
}
doBackCheckIn(p, normalized);
String costMsg = canA
? bondCost + "债券+" + pointCost + "积分"
: bondAltCost + "债券(替代)";
return "§a补签成功!日期: "
+ normalized
+ " §7消耗: " + costMsg;
}
// ===== 执行补签 =====
// ===== 执行补签 =====
public void doBackCheckIn(
Player p, String dateStr) {
String name = p.getName();
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
String updated = existing.isEmpty()
? dateStr
: existing + "," + dateStr;
plugin.getDb().setField(name,
"backcheck_dates", updated);
int streak = getStreak(name);
plugin.getDb().setField(name,
"checkin_streak", streak + 1);
// ★ 补签不发放奖励,只记录
}
// ===== 获取N天前日期 =====
private String getPastDate(
int daysAgo) {
Calendar cal =
Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH,
-daysAgo);
return new SimpleDateFormat(
"yyyy-MM-dd")
.format(cal.getTime());
}
}
@@ -0,0 +1,227 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.logging.Logger;
public class CommissionManager {
private final Main main;
private final Logger logger;
private final List<double[]> rules =
new ArrayList<>();
public CommissionManager(Main main) {
this.main = main;
this.logger = main.getLogger();
loadRules();
}
public void loadRules() {
rules.clear();
File f = new File(
main.getDataFolder(),
"提成规则.txt");
if (!f.exists()) {
writeDefault(f);
}
try (BufferedReader br =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets
.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
line = line.trim();
if (line.isEmpty()
|| line.startsWith("#"))
continue;
// 格式: 邀请人数:提成百分比
// 如 "5:15" = 邀请5人提成15%
String[] parts =
line.split(":");
if (parts.length >= 2) {
try {
int count = Integer.parseInt(
parts[0].trim());
double rate = Double
.parseDouble(
parts[1]
.trim()) / 100.0;
rules.add(new double[]{
count, rate});
} catch (Exception ignored) {
}
}
}
// 按人数排序(从大到小)
rules.sort((a, b) ->
Double.compare(b[0], a[0]));
logger.info(
"[Commission] 加载 "
+ rules.size() + " 条提成规则");
} catch (Exception e) {
logger.warning(
"[Commission] 加载失败: "
+ e.getMessage());
}
}
private void writeDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# 邀请提成规则");
pw.println("# 格式: 邀请人数:提成百分比");
pw.println("# 邀请10人提成20%");
pw.println("10:20");
pw.println("# 邀请5人提成15%");
pw.println("5:15");
pw.println("# 邀请1人提成10%");
pw.println("1:10");
} catch (IOException ignored) {
}
}
/**
* 获取邀请人的提成比例
* 根据已邀请人数匹配最高规则
*/
public double getCommissionRate(
String inviter) {
int inviteCount = getInviteCount(
inviter);
for (double[] rule : rules) {
if (inviteCount >= (int) rule[0]) {
return rule[1];
}
}
return 0;
}
/**
* 获取邀请人数
*/
public int getInviteCount(
String inviter) {
try {
Object val =
main.getDb().getField(
inviter,
"monthly_invite_count");
if (val instanceof Number) {
return ((Number) val).intValue();
}
Object total =
main.getDb().getField(
inviter,
"total_invite_count");
if (total instanceof Number) {
return ((Number) total).intValue();
}
} catch (Exception ignored) {
}
return 0;
}
/**
* 发放提成
* 被邀请人获得奖励时,邀请人获得提成
*/
public void payCommission(
String invitee,
double originalAmount) {
// 查找邀请人
String inviter = getInviter(invitee);
if (inviter == null
|| inviter.isEmpty()) return;
double rate = getCommissionRate(
inviter);
if (rate <= 0) return;
double commission =
originalAmount * rate;
if (commission <= 0) return;
// 通过 Vault 发放
var rsp = main.getServer()
.getServicesManager()
.getRegistration(
net.milkbowl.vault
.economy.Economy.class);
if (rsp == null) return;
net.milkbowl.vault.economy.Economy econ =
rsp.getProvider();
if (econ == null) return;
econ.depositPlayer(inviter, commission);
// 通知邀请人
Player inviterP =
main.getServer()
.getPlayer(inviter);
if (inviterP != null
&& inviterP.isOnline()) {
inviterP.sendMessage(
"§a§l[邀请提成] §f"
+ invitee + " 完成任务,"
+ "你获得 §e"
+ String.format("%.2f",
commission)
+ " §f游戏币提成 ("
+ (int)(rate * 100) + "%)");
}
logger.info(
"[Commission] 提成: "
+ inviter + " ← "
+ invitee + " | "
+ String.format("%.2f",
originalAmount)
+ " × "
+ (int)(rate * 100) + "% = "
+ String.format("%.2f",
commission));
}
/**
* 获取邀请人
*/
private String getInviter(String player) {
try {
Object val =
main.getDb().getField(
player,
"invited_by");
if (val != null
&& !val.toString()
.isEmpty()) {
return val.toString();
}
} catch (Exception ignored) {
}
return null;
}
/**
* 判断金额是否包含经济内容
*/
public boolean hasEconomyContent(
String text) {
String t = text.toLowerCase();
return t.contains("游戏币")
|| t.contains("金币")
|| t.contains("积分")
|| t.contains("块钱")
|| t.contains("元")
|| t.contains("$")
|| t.contains("经济")
|| t.contains("余额");
}
}
@@ -0,0 +1,372 @@
package Sdf1_login;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class ConfigManager {
private final File dataFolder;
private final Map<String, String> messages
= new LinkedHashMap<>();
private final Map<String, String> smtpSettings
= new LinkedHashMap<>();
// ===== 公共字段 =====
public String adminTag = "admin";
public String adminPassword = "qweasd";
public int maxAccountsPerIP = 3;
public String approvalMode = "manual";
public int autoApproveDelayMinutes = 30;
public int loginTimeout = 60;
public boolean afkEnabled = false;
public int afkTimeout = 300;
public boolean smtpSsl = true;
public String smtpVerifyCode = "";
public long smtpVerifyExpire = 0;
public long smtpLastSendTime = 0;
public String smtpConfiguringPlayer = "";
public String broadcastServerUrl = "";
public int httpPort = 8080;
public String garbageTag = "admin";
public double checkinRewardFixed = 0;
public double checkinRewardMin = 0;
public double checkinRewardMax = 0;
public boolean checkinGivePoints = true;
public int checkinPoints = 10;
public String checkinRewardType = "none";
public int backCheckPointMultiplier = 5;
public int checkinBondMin = 1;
public int checkinBondMax = 3;
public double providerPointsPerScore = 2.0;
public int baseEconomyReward = 100;
public double providerEconomyPerScore = 50.0;
public int requesterPointsReward = 10;
public boolean garbageEnabled = true;
public int garbageInterval = 300;
public int garbageMaxRounds = 1;
// ★ 构造函数接收 File,和 Main.java 调用一致 ★
public ConfigManager(File dataFolder) {
this.dataFolder = dataFolder;
}
public String rewardChannel = "债券"; // bonds = 债券,economy = Vault经济
// ==================== 消息 ====================
public void loadMessages() {
messages.clear();
File file = new File(dataFolder, "消息.txt");
if (!file.exists()) createDefaultMessages(file);
loadMap(file, messages);
}
public String msg(String key) {
String val = messages.getOrDefault(key, key);
val = val.replace("<b>", "&l");
val = val.replace("</b>", "&r");
val = val.replace("<br>", "\n");
val = val.replace("\\n", "\n");
val = val.replaceAll(
"&([0-9a-fk-orA-FK-OR])", "§$1");
return val;
}
private void createDefaultMessages(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 消息配置 =====");
L.add("login_timeout=§c登录超时");
L.add("not_registered=§e您尚未注册");
L.add("not_logged_in=§e您尚未登录");
L.add("already_registered=§c您已注册过");
L.add("already_logged_in=§c您已登录");
L.add("reg_success=§a注册成功!");
L.add("login_success=§a登录成功!");
L.add("login_failed=§c密码错误");
L.add("password_wrong=§c密码错误");
L.add("password_changed=§a密码修改成功!");
L.add("password_format_error=§c密码需6位以上含大小写和数字");
L.add("password_same=§c新密码不能与旧密码相同");
L.add("checkin_already=§c您今日已签到");
L.add("checkin_success=§a签到成功!获得 {points} 积分");
L.add("chat_muted=§c你已被禁言");
L.add("未知参数=§c未知参数或权限不足");
writeLines(f, L);
}
// ==================== SMTP ====================
public void loadSmtp() {
smtpSettings.clear();
File file = new File(dataFolder, "SMTP设置.txt");
if (!file.exists()) createDefaultSmtp(file);
loadMap(file, smtpSettings);
}
public String getSmtp(String key) {
return smtpSettings.getOrDefault(key, "");
}
public void setSmtp(String key, String value) {
smtpSettings.put(key, value);
}
public void saveSmtp() {
File file = new File(dataFolder, "SMTP设置.txt");
List<String> L = new ArrayList<>();
L.add("# ===== SMTP 配置 =====");
for (Map.Entry<String, String> e
: smtpSettings.entrySet()) {
L.add(e.getKey() + "=" + e.getValue());
}
writeLines(file, L);
}
private void createDefaultSmtp(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== SMTP 配置 =====");
L.add("smtp地址=smtp.example.com");
L.add("smtp端口=465");
L.add("smtp账号=");
L.add("smtp密码=");
L.add("发件人名称=Sdf1_login");
L.add("smtp加密=true");
writeLines(f, L);
}
// ==================== 插件设置 ====================
public void loadSettings() {
File file = new File(dataFolder, "插件设置.txt");
if (!file.exists()) createDefaultSettings(file);
Map<String, String> m = new LinkedHashMap<>();
loadMap(file, m);
adminTag = m.getOrDefault("管理标签", adminTag);
adminPassword = m.getOrDefault("管理密码", adminPassword);
maxAccountsPerIP = parseInt(m.getOrDefault("每IP最大账号数", "3"), 3);
approvalMode = m.getOrDefault("审批模式", approvalMode);
autoApproveDelayMinutes = parseInt(m.getOrDefault("自动审批延迟分钟", "30"), 30);
loginTimeout = parseInt(m.getOrDefault("登录超时秒数", "60"), 60);
afkEnabled = Boolean.parseBoolean(m.getOrDefault("挂机踢出", "false"));
afkTimeout = parseInt(m.getOrDefault("挂机超时秒数", "300"), 300);
smtpSsl = Boolean.parseBoolean(m.getOrDefault("smtp加密", "true"));
httpPort = parseInt(m.getOrDefault("http-port", "8080"), 8080);
broadcastServerUrl = m.getOrDefault("resource-pack-url", "");
providerPointsPerScore = parseDouble(m.getOrDefault("服务商积分倍率", "2.0"), 2.0);
baseEconomyReward = parseInt(m.getOrDefault("基础经济奖励", "100"), 100);
providerEconomyPerScore = parseDouble(m.getOrDefault("经济奖励每评分", "50.0"), 50.0);
requesterPointsReward = parseInt(m.getOrDefault("报单人积分奖励", "10"), 10);
garbageEnabled = Boolean.parseBoolean(m.getOrDefault("垃圾站_启用", "true"));
garbageInterval = parseInt(m.getOrDefault("垃圾站_清理间隔秒", "300"), 300);
garbageMaxRounds = parseInt(m.getOrDefault("垃圾站_保留轮数", "1"), 1);
checkinRewardFixed = parseDouble(m.getOrDefault("签到固定金额", "0"), 0);
checkinRewardMin = parseDouble(m.getOrDefault("签到最小金额", "0"), 0);
checkinRewardMax = parseDouble(m.getOrDefault("签到最大金额", "0"), 0);
checkinGivePoints = Boolean.parseBoolean(m.getOrDefault("签到给积分", "true"));
checkinPoints = parseInt(m.getOrDefault("签到积分数量", "10"), 10);
backCheckPointMultiplier = parseInt(m.getOrDefault("补签积分倍率", "5"), 5);
checkinBondMin = parseInt(m.getOrDefault("签到债券最小", "1"), 1);
checkinBondMax = parseInt(m.getOrDefault("签到债券最大", "3"), 3);
if (checkinBondMax < checkinBondMin) checkinBondMax = checkinBondMin;
if (checkinRewardFixed > 0) checkinRewardType = "fixed";
else if (checkinRewardMin > 0 && checkinRewardMax > checkinRewardMin) checkinRewardType = "range";
else checkinRewardType = "none";
rewardChannel = m.getOrDefault("奖励发放方式", "债券");
rewardChannel = normalizeRewardChannel(rewardChannel);
// 验证合法值
if (!"economy".equalsIgnoreCase(rewardChannel)
&& !"bonds".equalsIgnoreCase(rewardChannel)) {
rewardChannel = "bonds";
}
// ★ 自动补充新配置项 ★
boolean needAppend = false;
List<String> newKeys = new ArrayList<>();
if (!m.containsKey("签到债券最小")) { needAppend = true; newKeys.add("签到债券最小=" + checkinBondMin); }
if (!m.containsKey("签到债券最大")) { needAppend = true; newKeys.add("签到债券最大=" + checkinBondMax); }
if (!m.containsKey("奖励发放方式")) {
needAppend = true;
newKeys.add("奖励发放方式=债券");
}
if (needAppend) {
try {
FileWriter fw = new FileWriter(file, true);
BufferedWriter bw = new BufferedWriter(fw);
bw.newLine();
for (String key : newKeys) { bw.write(key); bw.newLine(); }
bw.flush(); bw.close();
System.out.println("[Config] 已补充签到债券配置");
} catch (IOException ignored) {}
if (!m.containsKey("奖励发放方式")) {
try {
FileWriter fw = new FileWriter(file, true);
BufferedWriter bw = new BufferedWriter(fw);
bw.newLine();
bw.write("奖励发放方式=bonds");
bw.newLine();
bw.flush(); bw.close();
} catch (IOException ignored) {}
}
}
}
/**
* 奖励方式标准化:中英文全兼容
* "债券" "bonds" "Bond" "BONDS" → "bonds"
* "经济" "economy" "Economy" "ECONOMY" → "economy"
* 其他任何值 → "bonds"(默认债券)
*/
private String normalizeRewardChannel(String raw) {
if (raw == null) return "bonds";
String s = raw.trim().toLowerCase();
// 中文
if (s.contains("债券")) return "bonds";
if (s.contains("经济")) return "economy";
// 英文
if (s.contains("bond")) return "bonds";
if (s.contains("econ")) return "economy";
if (s.contains("Vault")) return "economy";
// 兜底
return "bonds";
}
public void saveSettings() {
File file = new File(dataFolder, "插件设置.txt");
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 插件设置 =====");
L.add("管理标签=" + adminTag);
L.add("管理密码=" + adminPassword);
L.add("每IP最大账号数=" + maxAccountsPerIP);
L.add("审批模式=" + approvalMode);
L.add("自动审批延迟分钟=" + autoApproveDelayMinutes);
L.add("登录超时秒数=" + loginTimeout);
L.add("挂机踢出=" + afkEnabled);
L.add("挂机超时秒数=" + afkTimeout);
L.add("resource-pack-url=" + broadcastServerUrl);
L.add("http-port=" + httpPort);
L.add("垃圾站_启用=" + garbageEnabled);
L.add("垃圾站_清理间隔秒=" + garbageInterval);
L.add("垃圾站_保留轮数=" + garbageMaxRounds);
L.add("签到固定金额=" + checkinRewardFixed);
L.add("签到最小金额=" + checkinRewardMin);
L.add("签到最大金额=" + checkinRewardMax);
L.add("签到给积分=" + checkinGivePoints);
L.add("签到积分数量=" + checkinPoints);
L.add("签到债券最小=" + checkinBondMin);
L.add("签到债券最大=" + checkinBondMax);
L.add("补签积分倍率=" + backCheckPointMultiplier);
L.add("奖励发放方式=债券");
writeLines(file, L);
}
private void createDefaultSettings(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 插件设置 =====");
L.add("管理标签=admin");
L.add("管理密码=qweasd");
L.add("每IP最大账号数=3");
L.add("审批模式=manual");
L.add("自动审批延迟分钟=30");
L.add("登录超时秒数=60");
L.add("挂机踢出=false");
L.add("挂机超时秒数=300");
L.add("resource-pack-url=");
L.add("http-port=8080");
L.add("垃圾站_启用=true");
L.add("垃圾站_清理间隔秒=300");
L.add("垃圾站_保留轮数=1");
L.add("签到固定金额=0");
L.add("签到最小金额=50");
L.add("签到最大金额=500");
L.add("签到给积分=true");
L.add("签到积分数量=10");
L.add("签到债券最小=1");
L.add("签到债券最大=3");
L.add("补签积分倍率=5");
L.add("奖励发放方式=债券");
writeLines(f, L);
}
public List<String> getAfkWhitelist() {
List<String> list = new ArrayList<>();
File file = new File(dataFolder, "挂机白名单.txt");
if (!file.exists()) {
try {
file.createNewFile();
FileWriter fw = new FileWriter(file);
fw.write("# 每行一个玩家名\n");
fw.flush();
fw.close();
} catch (IOException ignored) {}
return list;
}
try (BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(file),
StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (!line.isEmpty() && !line.startsWith("#"))
list.add(line);
}
} catch (IOException ignored) {}
return list;
}
// ==================== 其他 ====================
public String generateSmtpVerifyCode() {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder sb = new StringBuilder();
java.util.Random r = new java.util.Random();
for (int i = 0; i < 6; i++) sb.append(chars.charAt(r.nextInt(chars.length())));
smtpVerifyCode = sb.toString();
smtpVerifyExpire = System.currentTimeMillis() + 300000L;
return smtpVerifyCode;
}
public boolean checkSmtpVerifyCode(String code) {
if (System.currentTimeMillis() > smtpVerifyExpire) { smtpVerifyCode = ""; return false; }
return smtpVerifyCode.equalsIgnoreCase(code);
}
public void clearSmtpVerifyCode() { smtpVerifyCode = ""; smtpVerifyExpire = 0; }
public boolean canSendSmtpVerify() { return System.currentTimeMillis() - smtpLastSendTime >= 60000L; }
public void recordSmtpSendTime() { smtpLastSendTime = System.currentTimeMillis(); }
// ==================== 文件工具 ====================
private void loadMap(File file, Map<String, String> map) {
try (BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
int eq = line.indexOf('=');
if (eq > 0) map.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim());
}
} catch (IOException ignored) {}
}
private void writeLines(File file, List<String> lines) {
try (BufferedWriter w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) {
for (String l : lines) { w.write(l); w.newLine(); }
} catch (IOException ignored) {}
}
private double parseDouble(String s, double def) { try { return Double.parseDouble(s.trim()); } catch (Exception e) { return def; } }
private int parseInt(String s, int def) { try { return Integer.parseInt(s.trim()); } catch (Exception e) { return def; } }
}
@@ -0,0 +1,577 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
public class CypayCommand
implements CommandExecutor, TabCompleter {
private final Main plugin;
private static final SimpleDateFormat SDF =
new SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss");
public CypayCommand(Main plugin) {
this.plugin = plugin;
}
private boolean hasAdminPerm(CommandSender s) {
if (s.isOp()) return true;
if (!(s instanceof Player)) return true;
Player p = (Player) s;
String tag =
plugin.getConfig2().adminTag;
return !tag.isEmpty()
&& p.getScoreboardTags().contains(tag);
}
private int parseAmount(String s) {
return Integer.parseInt(
s.trim().replaceAll("[+]", ""));
}
private List<String> filter(
List<String> list, String prefix) {
String low = prefix.toLowerCase();
return list.stream()
.filter(x -> x.toLowerCase()
.startsWith(low))
.collect(Collectors.toList());
}
@Override
public boolean onCommand(CommandSender sender,
Command cmd,
String label,
String[] args) {
// ===== /cypay — 查自己 =====
if (args.length == 0) {
String name = sender instanceof Player
? sender.getName() : "Console";
int bonds =
plugin.getBonds().getBonds(name);
String status = plugin.getBonds()
.getAccountStatus(name);
sender.sendMessage(
"§e§l[债券] §f" + name
+ " 余额: §a" + bonds);
sender.sendMessage(
"§e§l[债券] §f状态: "
+ ("frozen".equals(status)
? "§c冻结中"
: "§a正常"));
return true;
}
// ===== /cypay import =====
/* if (args.length == 2
&& "import".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String file = args[1];
if (!file.endsWith(".txt"))
file += ".txt";
int result = plugin.getCDK()
.importFromFile(file);
if (result == -1)
sender.sendMessage("§c文件不存在: "
+ file);
else
sender.sendMessage("§a导入完成: "
+ result + " 条");
return true;
}*/
// ===== /cypay freeze =====
if (args.length == 2
&& "freeze".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
if (plugin.getBonds().isFrozen(target)) {
sender.sendMessage("§c" + target
+ " 已处于冻结状态");
return true;
}
plugin.getBonds().freezeAccount(target);
sender.sendMessage("§a已冻结 " + target
+ " 的债券账户");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§c§l[系统] 你的债券账户"
+ "已被管理员冻结");
return true;
}
// ===== /cypay unfreeze =====
if (args.length == 2
&& "unfreeze".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
if (!plugin.getBonds()
.isFrozen(target)) {
sender.sendMessage("§c" + target
+ " 未被冻结");
return true;
}
plugin.getBonds().unfreezeAccount(target);
sender.sendMessage("§a已解冻 " + target
+ " 的债券账户");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§a§l[系统] 你的债券账户"
+ "已被解冻");
return true;
}
// ===== /cypay water [days] =====
if (args.length >= 2
&& "water".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
int days = 7;
if (args.length >= 3) {
try {
days = Integer.parseInt(args[2]);
} catch (NumberFormatException ignored) {
}
}
days = Math.min(days,
BondManager.ADMIN_MAX_WATER_DAYS);
if (days <= 0) days = 1;
List<Map<String, Object>> txs =
plugin.getBonds()
.getTransactions(
target, days);
sender.sendMessage(
"§e§l===== " + target
+ " 流水记录 (近"
+ days + "天) =====");
sender.sendMessage("§f当前余额: §a"
+ plugin.getBonds()
.getBonds(target)
+ " §f状态: "
+ (plugin.getBonds()
.isFrozen(target)
? "§c冻结" : "§a正常"));
if (txs.isEmpty()) {
sender.sendMessage("§7 (暂无记录)");
} else {
for (Map<String, Object> tx : txs) {
sender.sendMessage(
formatTxLine(tx));
}
}
sender.sendMessage(
"§e§l=========================="
+ "==========");
return true;
}
// ===== /cypay printer [player] =====
if ("printer".equalsIgnoreCase(args[0])) {
if (!hasAdminPerm(sender)
&& !(sender instanceof Player)
== false) {
// 控制台也可以用
if (!(sender instanceof Player))
; // 放行
else if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
}
if (args.length == 1) {
// 打印全服
sender.sendMessage(
"§e正在导出全服流水...");
plugin.getBonds(); // 确保实例就绪
if (plugin.getBondPrinter() != null) {
plugin.getBondPrinter()
.printAll(sender);
} else {
sender.sendMessage("§cPrinter未初始化");
}
return true;
}
if (args.length == 2) {
// 打印指定玩家
String target = args[1];
sender.sendMessage("§e正在导出 "
+ target + " 的流水...");
if (plugin.getBondPrinter() != null) {
plugin.getBondPrinter()
.printPlayer(target, sender);
} else {
sender.sendMessage("§cPrinter未初始化");
}
return true;
}
}
// ===== /cypay give/remove =====
if (args.length == 3) {
String action = args[0].toLowerCase();
if ("give".equals(action)
|| "remove".equals(action)) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
try {
int amount =
parseAmount(args[2]);
if (amount <= 0) {
sender.sendMessage(
"§c金额必须大于0");
return true;
}
String txType =
"give".equals(action)
? BondManager
.TX_ADMIN_GIVE
: BondManager
.TX_ADMIN_DEDUCT;
String opName =
sender instanceof Player
? sender.getName()
: "Console";
if ("give".equals(action)) {
plugin.getBonds().addBonds(
target, amount,
txType, "",
opName, "管理员发放");
sender.sendMessage(
"§a已给 " + target
+ " " + amount
+ " 债券");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§a管理员给了你 "
+ amount
+ " 债券");
} else {
if (plugin.getBonds()
.deductBonds(target,
amount,
txType, "",
opName,
"管理员扣除")) {
sender.sendMessage(
"§a已扣除 " + target
+ " " + amount
+ " 债券");
Player tp = plugin
.getServer()
.getPlayerExact(
target);
if (tp != null)
tp.sendMessage(
"§c管理员扣除了"
+ "你的 "
+ amount
+ " 债券");
} else {
sender.sendMessage(
"§c" + target
+ " 债券不足"
+ "或账户冻结");
}
}
} catch (NumberFormatException e) {
sender.sendMessage(
"§c金额格式错误");
}
return true;
}
}
// ===== /cypay <player> <amount> — 仅玩家转账 =====
if (args.length == 2) {
try {
int amount = parseAmount(args[1]);
if (amount <= 0) {
sender.sendMessage(
"§c金额必须大于0");
return true;
}
// ★ 控制台禁止转账
if (!(sender instanceof Player)) {
sender.sendMessage(
"§c控制台不能转账,请使用 "
+ "/cypay give <玩家> <金额>");
return true;
}
Player p = (Player) sender;
String target = args[0];
BondManager.TransferResult r =
plugin.getBonds().transfer(
p.getName(),
target, amount);
if (r.success) {
p.sendMessage("§e§l[债券转账] §f向 §a"
+ target + " §f转出 §c"
+ amount + " §f枚债券");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage("§e§l[债券转账] §f收到 §a"
+ p.getName()
+ " §f转入 §a"
+ amount + " §f枚债券");
} else {
p.sendMessage("§c" + r.message
+ ",当前: "
+ plugin.getBonds()
.getBonds(p.getName()));
}
return true;
} catch (NumberFormatException ignored) {
}
}
// ===== /cypay <player> — 查指定玩家 =====
if (args.length == 1) {
String target = args[0];
// ★ 验证玩家在 login.db 中存在 ★
if (!plugin.getDb().userExists(target)) {
sender.sendMessage("§c玩家 " + target
+ " 不存在于服务器");
return true;
}
// String target = args[0];
if (hasAdminPerm(sender)) {
int bonds = plugin.getBonds()
.getBonds(target);
String status = plugin.getBonds()
.getAccountStatus(target);
sender.sendMessage(
"§e§l[债券] §f" + target
+ " 余额: §a"
+ bonds);
sender.sendMessage(
"§e§l[债券] §f状态: "
+ ("frozen".equals(
status)
? "§c冻结中"
: "§a正常"));
} else {
int bonds = plugin.getBonds()
.getBonds(
sender.getName());
sender.sendMessage(
"§e§l[债券] §f你的余额: §a"
+ bonds);
}
return true;
}
showHelp(sender);
return true;
}
// ===== 流水行格式化 =====
private String formatTxLine(
Map<String, Object> tx) {
String type = (String) tx.get("type");
int amount = ((Number) tx.get("amount"))
.intValue();
String tgt = (String) tx.get("target_player");
String reason = (String) tx.get("reason");
int bal = ((Number) tx.get("balance_after"))
.intValue();
long time = ((Number) tx.get("time"))
.longValue();
boolean pos = amount > 0;
String sign = pos ? "+" : "";
String color = pos ? "§a" : "§c";
StringBuilder sb = new StringBuilder();
sb.append("§7[")
.append(SDF.format(new Date(time)))
.append("] ")
.append(formatTxType(type))
.append(" ")
.append(color).append(sign)
.append(amount).append("§7");
if (tgt != null && !tgt.isEmpty())
sb.append(" §e↔ ").append(tgt);
if (reason != null && !reason.isEmpty())
sb.append(" §7(").append(reason)
.append(")");
sb.append(" §7余额:§f").append(bal);
return sb.toString();
}
private String formatTxType(String type) {
if (type == null) return "§7未知";
switch (type) {
case BondManager.TX_ADMIN_GIVE:
return "§a管理员发放";
case BondManager.TX_ADMIN_DEDUCT:
return "§c管理员扣除";
case BondManager.TX_TRANSFER_OUT:
return "§c转出";
case BondManager.TX_TRANSFER_IN:
return "§a转入";
case BondManager.TX_REDEEM:
return "§aCDK兑换";
case BondManager.TX_SHOP_BUY:
return "§e商城购买";
case BondManager.TX_DAILY_SIGN:
return "§a签到奖励";
case BondManager.TX_FREEZE:
return "§c冻结";
case BondManager.TX_UNFREEZE:
return "§a解冻";
default:
return "§7" + type;
}
}
private void showHelp(CommandSender sender) {
sender.sendMessage("§e§l债券命令帮助:");
sender.sendMessage(
"§e/cypay §7- 查询自己余额");
sender.sendMessage(
"§e/cypay <玩家> §7- 查余额(管理员)");
sender.sendMessage(
"§e/cypay <玩家> <金额> §7- 转账");
if (hasAdminPerm(sender)) {
sender.sendMessage(
"§e/cypay give <玩家> <金额> §7- 给债券");
sender.sendMessage(
"§e/cypay remove <玩家> <金额> §7- 扣债券");
sender.sendMessage(
"§e/cypay import <文件> §7- 导入CDK");
sender.sendMessage(
"§e/cypay freeze <玩家> §7- 冻结");
sender.sendMessage(
"§e/cypay unfreeze <玩家> §7- 解冻");
sender.sendMessage(
"§e/cypay water <玩家> [天数] §7- 流水");
sender.sendMessage(
"§e/cypay printer §7- 导出全服流水xlsx");
sender.sendMessage(
"§e/cypay printer <玩家> §7- 导出指定玩家xlsx");
}
}
@Override
public List<String> onTabComplete(
CommandSender sender, Command command,
String label, String[] args) {
List<String> list = new ArrayList<>();
if (args.length == 1) {
String last = args[0].toLowerCase();
if (hasAdminPerm(sender)) {
list.add("give");
list.add("remove");
list.add("import");
list.add("freeze");
list.add("unfreeze");
list.add("water");
list.add("printer");
File cdkDir = new File(
plugin.getDataFolder(),
"cdk");
if (cdkDir.exists()) {
File[] files = cdkDir.listFiles(
(d, n) -> n
.endsWith(".txt"));
if (files != null)
for (File f : files)
list.add(f.getName());
}
}
for (Player o :
Bukkit.getOnlinePlayers())
list.add(o.getName());
return filter(list, last);
}
if (args.length == 2) {
String first = args[0].toLowerCase();
if ("give".equals(first)
|| "remove".equals(first)
|| "freeze".equals(first)
|| "unfreeze".equals(first)
|| "water".equals(first)) {
for (Player o :
Bukkit.getOnlinePlayers())
list.add(o.getName());
return filter(list,
args[1].toLowerCase());
}
list.add("10");
list.add("50");
list.add("100");
return filter(list,
args[1].toLowerCase());
}
if (args.length == 3) {
String first = args[0].toLowerCase();
if ("give".equals(first)
|| "remove".equals(first)) {
list.add("10");
list.add("50");
list.add("100");
list.add("999");
return filter(list,
args[2].toLowerCase());
}
if ("water".equals(first)) {
list.add("1");
list.add("3");
list.add("7");
list.add("14");
return filter(list,
args[2].toLowerCase());
}
}
return list;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
package Sdf1_login;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Base64;
import java.util.logging.Logger;
public class EmailManager {
private final ConfigManager config;
private final Logger logger;
public EmailManager(ConfigManager config) {
this.config = config;
this.logger = Logger.getLogger("Sdf1_login");
}
public boolean isConfigured() {
String host = config.getSmtp("smtp地址");
String user = config.getSmtp("smtp账号");
String pass = config.getSmtp("smtp密码");
return host != null && !host.isEmpty()
&& user != null && !user.isEmpty()
&& pass != null && !pass.isEmpty();
}
public boolean sendVerifyCode(String to,
String code) {
if (!isConfigured()) {
logger.warning(
"[Sdf1_login] SMTP未配置,"
+ "无法发送验证码");
return false;
}
logger.info("[Sdf1_login] 发送验证码到 " + to);
return sendEmailSync(to,
"[Sdf1_login] 验证码",
"您的验证码: " + code
+ "\n5分钟内有效。\n"
+ "如非本人操作请忽略。");
}
public boolean sendTempPassword(String to,
String player, String tempPwd) {
if (!isConfigured()) {
logger.warning(
"[Sdf1_login] SMTP未配置");
return false;
}
return sendEmailSync(to,
"[Sdf1_login] 临时密码",
"玩家 " + player
+ " 的临时密码: " + tempPwd
+ "\n请尽快登录并修改密码!");
}
public boolean sendBody(String to, String subject, String body) {
if (!isConfigured()) {
logger.warning("[Sdf1_login] SMTP未配置");
return false;
}
return sendEmailSync(to, subject, body);
}
private boolean sendEmailSync(String to,
String subject, String body) {
final boolean[] result = {false};
Thread t = new Thread(() -> {
try {
result[0] = sendEmail(
to, subject, body);
} catch (Exception e) {
logger.severe(
"[Sdf1_login] 邮件线程异常: "
+ e.getMessage());
}
}, "Sdf1_login-SMTP");
t.setDaemon(true);
t.start();
try {
t.join(15000);
} catch (InterruptedException ignored) {
}
if (t.isAlive()) {
t.interrupt();
logger.severe(
"[Sdf1_login] SMTP超时(15秒)");
return false;
}
return result[0];
}
private boolean sendEmail(String to,
String subject, String body) {
String host = config.getSmtp("smtp地址");
String portStr = config.getSmtp("smtp端口");
String user = config.getSmtp("smtp账号");
String pass = config.getSmtp("smtp密码");
String fromName = config.getSmtp(
"发件人名称");
int port;
try {
port = Integer.parseInt(portStr);
} catch (Exception e) {
port = 465;
}
try {
String encSubject = "=?UTF-8?B?"
+ Base64.getEncoder()
.encodeToString(
subject.getBytes("UTF-8"))
+ "?=";
String encBody = Base64.getEncoder()
.encodeToString(
body.getBytes("UTF-8"));
if (port == 465) {
SSLSocketFactory factory =
(SSLSocketFactory)
SSLSocketFactory
.getDefault();
SSLSocket socket = (SSLSocket)
factory.createSocket(
host, port);
socket.setSoTimeout(10000);
doSmtp(socket, host, user, pass,
to, encSubject, encBody,
fromName);
socket.close();
} else {
Socket plainSocket =
new Socket(host, port);
plainSocket.setSoTimeout(10000);
BufferedReader preReader =
new BufferedReader(
new InputStreamReader(
plainSocket
.getInputStream(),
"UTF-8"));
PrintWriter preWriter =
new PrintWriter(
plainSocket
.getOutputStream(),
true);
checkResponse(preReader, "220");
sendLine(preWriter,
"EHLO " + host);
checkResponse(preReader, "250");
sendLine(preWriter, "STARTTLS");
checkResponse(preReader, "220");
SSLSocketFactory factory =
(SSLSocketFactory)
SSLSocketFactory
.getDefault();
SSLSocket sslSocket = (SSLSocket)
factory.createSocket(
plainSocket, host,
port, true);
sslSocket.startHandshake();
doSmtp(sslSocket, host, user, pass,
to, encSubject, encBody,
fromName);
sslSocket.close();
}
logger.info(
"[Sdf1_login] 邮件发送成功 -> "
+ to);
return true;
} catch (Exception e) {
logger.severe(
"[Sdf1_login] 邮件异常: "
+ e.getClass()
.getSimpleName()
+ ": " + e.getMessage());
if (e.getCause() != null) {
logger.severe(
"[Sdf1_login] 根因: "
+ e.getCause()
.getMessage());
}
return false;
}
}
private void doSmtp(SSLSocket socket,
String host, String user, String pass,
String to, String subject, String body,
String fromName) throws Exception {
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
socket.getInputStream(),
"UTF-8"));
OutputStream rawOut =
socket.getOutputStream();
PrintWriter writer =
new PrintWriter(
new java.io.BufferedOutputStream(
rawOut), true);
checkResponse(reader, "220");
sendLine(writer, "EHLO " + host);
checkResponse(reader, "250");
sendLine(writer, "AUTH LOGIN");
checkResponse(reader, "334");
sendLine(writer, Base64.getEncoder()
.encodeToString(
user.getBytes("UTF-8")));
checkResponse(reader, "334");
sendLine(writer, Base64.getEncoder()
.encodeToString(
pass.getBytes("UTF-8")));
checkResponse(reader, "235");
sendLine(writer,
"MAIL FROM:<" + user + ">");
checkResponse(reader, "250");
sendLine(writer,
"RCPT TO:<" + to + ">");
checkResponse(reader, "250");
sendLine(writer, "DATA");
checkResponse(reader, "354");
sendLine(writer,
"From: " + fromName
+ " <" + user + ">");
sendLine(writer,
"To: <" + to + ">");
sendLine(writer,
"Reply-To: <" + user + ">");
sendLine(writer,
"Subject: " + subject);
sendLine(writer, "MIME-Version: 1.0");
sendLine(writer,
"Content-Type: text/plain; "
+ "charset=UTF-8");
sendLine(writer,
"Content-Transfer-Encoding: "
+ "base64");
sendLine(writer, "");
sendLine(writer, body);
sendLine(writer, ".");
checkResponse(reader, "250");
sendLine(writer, "QUIT");
}
private void sendLine(PrintWriter writer,
String line) {
writer.print(line + "\r\n");
writer.flush();
}
private void checkResponse(
BufferedReader reader,
String expected) throws Exception {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
if (line.length() >= 4
&& Character.isDigit(
line.charAt(0))
&& line.charAt(3) == ' ')
break;
}
String resp = sb.toString().trim();
if (!resp.startsWith(expected)) {
if (resp.startsWith("535"))
logger.severe(
"[Sdf1_login] SMTP 535 "
+ "认证失败");
throw new Exception(
"SMTP响应异常: 期望 "
+ expected
+ ",实际: " + resp);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,573 @@
package Sdf1_login;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.scheduler.BukkitRunnable;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.*;
public class GarbageManager {
private final Main plugin;
private Connection db;
private int cleanupInterval = 1800;
private int maxRounds = 1;
private boolean enabled = true;
private int currentRound = 0;
private final Map<String, Integer>
recyclePages = new HashMap<>();
public GarbageManager(Main plugin) {
this.plugin = plugin;
}
public void init() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(),
"garbage.db");
db = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
st.execute("CREATE TABLE IF NOT EXISTS "
+ "items ("
+ "id INTEGER PRIMARY KEY "
+ "AUTOINCREMENT,"
+ "item_data TEXT NOT NULL,"
+ "amount INTEGER DEFAULT 1,"
+ "round INTEGER DEFAULT 0,"
+ "create_time INTEGER NOT NULL"
+ ")");
st.execute("CREATE TABLE IF NOT EXISTS "
+ "config ("
+ "key TEXT PRIMARY KEY,"
+ "value TEXT NOT NULL"
+ ")");
st.close();
} catch (Exception e) {
plugin.getLogger().severe(
"[Garbage] DB失败: "
+ e.getMessage());
}
}
public void loadConfig() {
// 从 ConfigManager 读取
cleanupInterval =
plugin.getConfig2()
.garbageInterval;
enabled =
plugin.getConfig2()
.garbageEnabled;
maxRounds =
plugin.getConfig2()
.garbageMaxRounds;
// 从数据库读取当前轮次
try {
String val = getConfigValue("round");
if (val != null)
currentRound =
Integer.parseInt(val);
} catch (Exception ignored) {
}
plugin.getLogger().info(
"[Garbage] 间隔: "
+ cleanupInterval + "秒, "
+ "轮次保留: "
+ maxRounds + "轮, "
+ "启用: " + enabled);
}
private String getConfigValue(String key) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT value FROM config "
+ "WHERE key=?");
ps.setString(1, key);
ResultSet rs = ps.executeQuery();
String val = rs.next()
? rs.getString("value") : null;
rs.close();
ps.close();
return val;
} catch (Exception e) {
return null;
}
}
private void setConfigValue(String key,
String value) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO config "
+ "(key, value) "
+ "VALUES (?, ?)");
ps.setString(1, key);
ps.setString(2, value);
ps.executeUpdate();
ps.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public void startAutoCleanup() {
if (!enabled) return;
new BukkitRunnable() {
int countdown = 0;
@Override
public void run() {
// 在线人数≤0时暂停
if (Bukkit.getOnlinePlayers()
.isEmpty()) {
countdown = 0;
return;
}
// 重新读取配置(支持热更)
cleanupInterval =
plugin.getConfig2()
.garbageInterval;
int remain =
cleanupInterval - countdown;
if (remain == 300)
broadcast("§e§l[垃圾站] §f距离清理还有 §e5分钟");
else if (remain == 180)
broadcast("§e§l[垃圾站] §f距离清理还有 §e3分钟");
else if (remain == 60)
broadcast("§e§l[垃圾站] §f距离清理还有 §e1分钟");
else if (remain == 30)
broadcast("§e§l[垃圾站] §f距离清理还有 §e30秒");
else if (remain == 10)
broadcast("§e§l[垃圾站] §f距离清理还有 §e10秒");
else if (remain <= 5 && remain > 0)
broadcast("§e§l[垃圾站] §c" + remain + "§f...");
else if (remain == 0) {
doCleanup();
countdown = 0;
return;
}
countdown++;
}
}.runTaskTimer(plugin, 20L, 20L);
}
private void doCleanup() {
// 删除过期轮次的物品
int deleteRound =
currentRound - maxRounds;
if (deleteRound >= 0) {
int deleted = deleteOldItems(
deleteRound);
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
}
// 递增轮次
currentRound++;
setConfigValue("round",
String.valueOf(currentRound));
// 收集当前掉落物
int count = collectItems();
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// 打印下次清理时间
long nextMs = System.currentTimeMillis()
+ (long) cleanupInterval * 1000L;
String nextTime =
new SimpleDateFormat("HH:mm:ss")
.format(new Date(nextMs));
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
+ nextTime);
}
public int collectItems() {
int count = 0;
for (org.bukkit.World w :
Bukkit.getWorlds()) {
Collection<Item> items =
w.getEntitiesByClass(Item.class);
for (Item item : items) {
ItemStack stack =
item.getItemStack();
if (stack == null
|| stack.getType()
== Material.AIR)
continue;
saveItem(stack);
item.remove();
count++;
}
}
return count;
}
public void saveItem(ItemStack stack) {
try {
String data = stackToBase64(stack);
if (data.isEmpty()) return;
PreparedStatement ps = db.prepareStatement(
"INSERT INTO items "
+ "(item_data, amount, "
+ "round, create_time) "
+ "VALUES (?,?,?,?)");
ps.setString(1, data);
ps.setInt(2, stack.getAmount());
ps.setInt(3, currentRound);
ps.setLong(4,
System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (Exception e) {
e.printStackTrace();
}
}
private int deleteOldItems(int beforeRound) {
try {
PreparedStatement ps = db.prepareStatement(
"DELETE FROM items "
+ "WHERE round <= ?");
ps.setInt(1, beforeRound);
int rows = ps.executeUpdate();
ps.close();
return rows;
} catch (Exception e) {
return 0;
}
}
// ========== 序列化 ==========
private String stackToBase64(ItemStack stack) {
try {
Map<String, Object> map =
stack.serialize();
String json = new Gson().toJson(map);
return Base64.getEncoder()
.encodeToString(
json.getBytes(
StandardCharsets.UTF_8));
} catch (Exception e) {
return "";
}
}
private ItemStack base64ToStack(String base64) {
try {
byte[] bytes = Base64.getDecoder()
.decode(base64);
String json = new String(bytes,
StandardCharsets.UTF_8);
Type type = new TypeToken<
Map<String, Object>>() {}
.getType();
Map<String, Object> map =
new Gson().fromJson(json, type);
map = fixNumberTypes(map);
return ItemStack.deserialize(map);
} catch (Exception e) {
return null;
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> fixNumberTypes(
Map<String, Object> map) {
Map<String, Object> result =
new LinkedHashMap<>();
for (Map.Entry<String, Object> e
: map.entrySet()) {
Object v = e.getValue();
if (v instanceof Double) {
double d = (Double) v;
if (d == (long) d) {
result.put(e.getKey(), (long) d);
} else {
result.put(e.getKey(), d);
}
} else if (v instanceof Map) {
result.put(e.getKey(),
fixNumberTypes(
(Map<String, Object>) v));
} else if (v instanceof List) {
List<Object> list =
new ArrayList<>();
for (Object item : (List<?>) v) {
if (item instanceof Map) {
list.add(fixNumberTypes(
(Map<String, Object>) item));
} else {
list.add(item);
}
}
result.put(e.getKey(), list);
} else {
result.put(e.getKey(), v);
}
}
return result;
}
// ========== 回收站GUI ==========
public void openRecycle(Player p) {
openRecyclePage(p, 0);
}
public void openRecyclePage(Player p, int page) {
Inventory g = Bukkit.createInventory(
null, 54, "§6§l垃圾回收站");
List<Map<String, Object>> allItems =
getStoredItems(999);
int total = allItems.size();
int pageSize = 40;
int totalPages = Math.max(1,
(int) Math.ceil(
(double) total / pageSize));
// 修正页码
if (page < 0) page = 0;
if (page >= totalPages)
page = totalPages - 1;
recyclePages.put(p.getName(), page);
// 填充物品(0-39 格 = 第1页,40-79 = 第2页...)
int start = page * pageSize;
int end = Math.min(start + pageSize,
total);
int slot = 0;
for (int i = start; i < end; i++) {
if (slot >= 40) break;
Map<String, Object> item =
allItems.get(i);
int id = ((Number) item.get("id"))
.intValue();
String data = (String) item
.get("item_data");
int amount = ((Number) item
.get("amount")).intValue();
ItemStack stack = base64ToStack(data);
if (stack != null) {
stack.setAmount(amount);
ItemMeta im =
stack.getItemMeta();
if (im != null) {
List<String> lore =
new ArrayList<>();
lore.add("§7ID: #" + id);
lore.add("§a点击取出");
im.setLore(lore);
stack.setItemMeta(im);
}
g.setItem(slot, stack);
}
slot++;
}
// 49 格:上一页
if (page > 0) {
g.setItem(49, mk(Material.ARROW,
"§e上一页 §7("
+ page + "/"
+ totalPages + ")"));
} else {
g.setItem(49, mk(Material.ARROW,
"§e第一页 §7("
+ "1/" + totalPages + ")"));
}
// 50 格:统计信息
g.setItem(50, mk(Material.CHEST,
"§e回收站统计",
"§7总存量: §f" + total + " 个",
"§7当前页: §f" + (page + 1)
+ "/" + totalPages));
// 51 格:下一页
boolean canNext = page < totalPages - 1;
if (canNext) {
g.setItem(51, mk(Material.ARROW,
"§e下一页 §7("
+ (page + 2) + "/"
+ totalPages + ")"));
} else {
g.setItem(51, mk(Material.ARROW,
"§7已是最后一页"));
}
// 52 格:返回
g.setItem(52, mk(Material.ARROW,
"§7返回",
"§7返回主菜单"));
p.openInventory(g);
}
public int getRecyclePage(String name) {
return recyclePages
.getOrDefault(name, 0);
}
public boolean handleClick(Player p,
String title, int slot) {
if (!title.equals("§6§l垃圾回收站"))
return false;
// 49:上一页
if (slot == 49) {
int page = recyclePages
.getOrDefault(p.getName(), 0);
if (page > 0)
openRecyclePage(p, page - 1);
return true;
}
// 51:下一页
if (slot == 51) {
int page = recyclePages
.getOrDefault(p.getName(), 0);
openRecyclePage(p, page + 1);
return true;
}
// 52:返回
if (slot == 52) {
plugin.getGui().openMain(p);
return true;
}
// 50/52/53:不处理
if (slot >= 49 && slot <= 53)
return true;
return true;
}
private List<Map<String, Object>>
getStoredItems(int limit) {
List<Map<String, Object>> list =
new ArrayList<>();
try {
PreparedStatement ps =
db.prepareStatement(
"SELECT id, item_data, "
+ "amount "
+ "FROM items "
+ "ORDER BY "
+ "create_time "
+ "DESC LIMIT ?");
ps.setInt(1, limit);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id",
rs.getInt("id"));
row.put("item_data",
rs.getString("item_data"));
row.put("amount",
rs.getInt("amount"));
list.add(row);
}
rs.close();
ps.close();
} catch (Exception e) {
e.printStackTrace();
}
return list;
}
public boolean removeItem(int id) {
try {
PreparedStatement ps =
db.prepareStatement(
"DELETE FROM items "
+ "WHERE id=?");
ps.setInt(1, id);
int rows = ps.executeUpdate();
ps.close();
return rows > 0;
} catch (Exception e) {
return false;
}
}
public int getItemCount() {
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT COUNT(*) FROM items");
int count = 0;
if (rs.next())
count = rs.getInt(1);
rs.close();
st.close();
return count;
} catch (Exception e) {
return 0;
}
}
private void broadcast(String msg) {
// 同步打印到控制台
plugin.getLogger().info(
msg.replaceAll("§[0-9a-fk-orA-FK-OR]", ""));
// 同步打印到聊天栏
Bukkit.broadcastMessage(msg);
}
private ItemStack mk(Material mat,
String name, String... lore) {
ItemStack it = new ItemStack(mat);
ItemMeta im = it.getItemMeta();
if (im != null) {
im.setDisplayName(name);
if (lore.length > 0) {
List<String> list =
new ArrayList<>();
for (String l : lore)
if (!l.isEmpty()) list.add(l);
if (!list.isEmpty())
im.setLore(list);
}
it.setItemMeta(im);
}
return it;
}
public void close() {
try {
if (db != null && !db.isClosed())
db.close();
} catch (Exception ignored) {
}
}
}
@@ -0,0 +1,259 @@
package Sdf1_login;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
public class GiftManager {
private final File giftFolder;
private final Main plugin;
private final List<Map<String, Object>> stages =
new ArrayList<>();
public GiftManager(Main plugin) {
this.plugin = plugin;
this.giftFolder = new File(
plugin.getDataFolder(), "任务面板");
}
public void loadStages() {
stages.clear();
if (!giftFolder.exists()) {
giftFolder.mkdirs();
writeDefaultStages();
}
File[] files = giftFolder.listFiles(
(dir, name) -> name.endsWith(".md"));
if (files == null) return;
Arrays.sort(files);
for (File f : files) {
stages.add(parseMarkdown(f));
}
}
private Map<String, Object> parseMarkdown(File f) {
Map<String, Object> stage = new LinkedHashMap<>();
stage.put("stage", stages.size() + 1);
stage.put("name",
f.getName().replace(".md", ""));
stage.put("rewards", new ArrayList<String>());
try {
BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8));
String line;
List<String> rewards = new ArrayList<>();
while ((line = r.readLine()) != null) {
if (line.contains("礼包内容")
|| line.contains("奖励")
|| line.contains("任务内容")) {
int colon = line.indexOf(':');
if (colon < 0)
colon = line.indexOf('\uff1a');
if (colon >= 0) {
rewards.add(
line.substring(colon + 1)
.trim());
}
}
}
stage.put("rewards", rewards);
r.close();
} catch (Exception e) {
e.printStackTrace();
}
return stage;
}
private void writeStage(String name, String content) {
File f = new File(giftFolder, name);
if (f.exists()) return;
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.print(content);
} catch (IOException ignored) {}
}
private void writeDefaultStages() {
writeStage("第一阶段.md",
"# 第一阶段\n\n## 领取条件\n\n- 无前级条件\n\n## 礼包内容\n\n- 皮革套装 × 1\n- 木制武器工具套装 × 1\n");
writeStage("第二阶段.md",
"# 第二阶段\n\n## 领取条件\n\n- 在线时长>1小时\n\n## 礼包内容\n\n- 500 游戏币\n");
writeStage("第三阶段.md",
"# 第三阶段\n\n## 领取条件\n\n- 破坏100个方块\n- 在线>1天\n\n## 礼包内容\n\n- 1500 游戏币\n- 铁质工具套装 × 1\n- 铁质防具 × 1\n");
writeStage("第四阶段.md",
"# 第四阶段\n\n## 领取条件\n\n- 破坏/放置300方块\n- 在线≥7天\n\n## 礼包内容\n\n- 钻石套装 × 1\n- 2000 游戏币\n");
}
/** 从任意文件夹加载任务列表 */
public List<Map<String, Object>> loadTasksFromFolder(
File folder) {
List<Map<String, Object>> tasks =
new ArrayList<>();
if (!folder.exists() || !folder.isDirectory())
return tasks;
File[] files = folder.listFiles(
(dir, name) -> name.endsWith(".md"));
if (files == null) return tasks;
Arrays.sort(files);
for (File f : files) {
Map<String, Object> task =
new LinkedHashMap<>();
task.put("name",
f.getName().replace(".md", ""));
task.put("conditions",
new ArrayList<String>());
task.put("rewards",
new ArrayList<String>());
try {
BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8));
String line;
List<String> current = null;
while ((line = r.readLine()) != null) {
if (line.startsWith("# ")) {
task.put("name",
line.substring(2)
.trim());
} else if (line.contains("条件")
|| line.contains("领取条件")
|| line.contains("完成条件")) {
current = (List<String>) task
.get("conditions");
} else if (line.contains("内容")
|| line.contains("奖励")
|| line.contains("任务内容")
|| line.contains("礼包内容")) {
current = (List<String>) task
.get("rewards");
} else if (line.startsWith("- ")
&& current != null) {
current.add(line.substring(2)
.trim());
}
}
r.close();
} catch (Exception e) {
e.printStackTrace();
}
tasks.add(task);
}
return tasks;
}
public boolean canClaim(Player p, int stage) {
String name = p.getName();
int curStage = ((Number) plugin.getDb()
.getField(name, "gift_stage"))
.intValue();
String claimed = (String) plugin.getDb()
.getField(name, "gift_claimed");
if (claimed == null) claimed = "";
return curStage >= stage
&& !claimed.contains(
String.valueOf(stage));
}
public void claimReward(Player p, int stage) {
if (!canClaim(p, stage)) return;
Map<String, Object> user = plugin.getDb()
.getUser(p.getName());
String claimed = (String) user.getOrDefault(
"gift_claimed", "");
claimed += "[" + stage + "]";
plugin.getDb().setField(
p.getName(), "gift_claimed", claimed);
plugin.getDb().setField(
p.getName(), "gift_stage",
Math.max(stage,
((Number) user.getOrDefault(
"gift_stage", 0))
.intValue()));
if (stage == 1) {
giveItem(p, Material.LEATHER_HELMET);
giveItem(p, Material.LEATHER_CHESTPLATE);
giveItem(p, Material.LEATHER_LEGGINGS);
giveItem(p, Material.LEATHER_BOOTS);
giveItem(p, Material.WOODEN_SWORD);
giveItem(p, Material.WOODEN_PICKAXE);
giveItem(p, Material.WOODEN_AXE);
giveItem(p, Material.WOODEN_SHOVEL);
} else if (stage == 2) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 500);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
} else if (stage == 3) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 1500);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
giveItem(p, Material.IRON_PICKAXE);
giveItem(p, Material.IRON_AXE);
giveItem(p, Material.IRON_SWORD);
giveItem(p, Material.IRON_HELMET);
giveItem(p, Material.IRON_CHESTPLATE);
giveItem(p, Material.IRON_LEGGINGS);
giveItem(p, Material.IRON_BOOTS);
} else if (stage == 4) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 2000);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
giveItem(p, Material.DIAMOND_HELMET);
giveItem(p, Material.DIAMOND_CHESTPLATE);
giveItem(p, Material.DIAMOND_LEGGINGS);
giveItem(p, Material.DIAMOND_BOOTS);
}
p.sendMessage(plugin.getConfig2().msg(
"gift_claimed"));
}
private void giveItem(Player p, Material mat) {
p.getInventory().addItem(new ItemStack(mat));
}
public List<Map<String, Object>> getStages() {
return stages;
}
public int getMaxStage() {
return stages.size();
}
}
@@ -0,0 +1,110 @@
package Sdf1_login;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class IPGroupManager {
private final Main plugin;
private final int maxAccounts;
public IPGroupManager(Main plugin, int maxAccounts) {
this.plugin = plugin;
this.maxAccounts = maxAccounts;
}
public boolean canRegister(String ip) {
if (maxAccounts <= 0) return true;
if (ip == null || ip.isEmpty()) return true;
int count = getAccountCount(ip);
boolean can = count < maxAccounts;
plugin.getLogger().info(
"[IP限制] IP=" + ip
+ " 已注册=" + count
+ " 上限=" + maxAccounts
+ " 允许=" + can);
return can;
}
public int getAccountCount(String ip) {
if (ip == null || ip.isEmpty()) return 0;
return queryAccountsByIP(ip).size();
}
public List<String> getAccounts(String ip) {
return queryAccountsByIP(ip);
}
private List<String> queryAccountsByIP(String ip) {
List<String> accounts = new ArrayList<>();
Connection conn = plugin.getDb().getDb();
if (conn == null) return accounts;
try {
// 主查询:ip_address 列
PreparedStatement ps = conn.prepareStatement(
"SELECT player_name FROM users "
+ "WHERE ip_address = ?");
ps.setString(1, ip);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
accounts.add(
rs.getString("player_name"));
}
rs.close();
ps.close();
// 兜底:如果主列没数据,查 last_login_ip
if (accounts.isEmpty()) {
PreparedStatement ps2 = conn.prepareStatement(
"SELECT player_name FROM users "
+ "WHERE last_login_ip = ? "
+ "AND (ip_address IS NULL "
+ "OR ip_address = '' "
+ "OR ip_address != ?)");
ps2.setString(1, ip);
ps2.setString(2, ip);
ResultSet rs2 = ps2.executeQuery();
while (rs2.next()) {
String name = rs2.getString("player_name");
if (!accounts.contains(name))
accounts.add(name);
}
rs2.close();
ps2.close();
}
// 兜底2:查 register_ip
if (accounts.isEmpty()) {
PreparedStatement ps3 = conn.prepareStatement(
"SELECT player_name FROM users "
+ "WHERE register_ip = ? "
+ "AND (ip_address IS NULL "
+ "OR ip_address = '') "
+ "AND (last_login_ip IS NULL "
+ "OR last_login_ip = '' "
+ "OR last_login_ip != ?)");
ps3.setString(1, ip);
ps3.setString(2, ip);
ResultSet rs3 = ps3.executeQuery();
while (rs3.next()) {
String name = rs3.getString("player_name");
if (!accounts.contains(name))
accounts.add(name);
}
rs3.close();
ps3.close();
}
plugin.getLogger().info(
"[IP限制] 查询IP=" + ip
+ " 结果数=" + accounts.size()
+ " 账号=" + accounts);
} catch (Exception e) {
e.printStackTrace();
}
return accounts;
}
}
@@ -0,0 +1,46 @@
package Sdf1_login;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
public class InventorySerializer {
public static String toBase64(ItemStack[] items) throws IOException {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream);
dataOutput.writeInt(items.length);
for (ItemStack item : items) {
dataOutput.writeObject(item);
}
dataOutput.close();
return Base64.getEncoder().encodeToString(outputStream.toByteArray());
} catch (Exception e) {
throw new IOException("Unable to serialize item stacks.", e);
}
}
public static ItemStack[] fromBase64(String data) throws IOException {
if (data == null || data.isEmpty()) {
return new ItemStack[0];
}
try {
ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64.getDecoder().decode(data));
BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream);
ItemStack[] items = new ItemStack[dataInput.readInt()];
for (int i = 0; i < items.length; i++) {
items[i] = (ItemStack) dataInput.readObject();
}
dataInput.close();
return items;
} catch (ClassNotFoundException e) {
throw new IOException("Unable to deserialize item stacks.", e);
}
}
}
@@ -0,0 +1,96 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
import java.util.*;
public class InviteManager {
private final Main plugin;
public InviteManager(Main plugin) {
this.plugin = plugin;
}
public String generateCode(Player p) {
String code = PasswordUtils.generateInviteCode();
plugin.getDb().setField(
p.getName(), "invite_code", code);
return code;
}
public void onInviteeCheckIn(String inviteeName) {
String invitedBy = (String) plugin.getDb()
.getField(inviteeName, "invited_by");
if (invitedBy == null || invitedBy.isEmpty())
return;
String month = new SimpleDateFormat("yyyy-MM")
.format(new Date());
String lastMonth = (String) plugin.getDb()
.getField(invitedBy, "invite_month");
if (!month.equals(lastMonth)) {
plugin.getDb().setField(
invitedBy, "invite_month", month);
plugin.getDb().setField(
invitedBy, "monthly_invite_count", 0);
}
int count = ((Number) plugin.getDb()
.getField(invitedBy,
"monthly_invite_count"))
.intValue();
plugin.getDb().setField(
invitedBy, "monthly_invite_count",
count + 1);
double rate = 0.10;
if (count + 1 > 15) {
rate = 0.60;
} else if (count + 1 > 10) {
rate = 0.35;
} else if (count + 1 > 5) {
rate = 0.15;
}
int bonus = (int) (1 * rate);
if (bonus > 0) {
plugin.getDb().addPoints(invitedBy, bonus);
Player inviterPlayer = Bukkit
.getPlayerExact(invitedBy);
if (inviterPlayer != null
&& inviterPlayer.isOnline()) {
inviterPlayer.sendMessage(
plugin.getConfig2()
.msg("invite_referral_bonus")
.replace("{points}",
String.valueOf(bonus)));
}
}
}
public List<Map<String, Object>> getAllInviteData() {
List<Map<String, Object>> result =
new ArrayList<>();
for (Map<String, Object> user
: plugin.getDb().getAllUsers()) {
String code = (String) user.getOrDefault(
"invite_code", "");
if (!code.isEmpty()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("player",
user.get("player_name"));
row.put("code", code);
row.put("monthly",
user.getOrDefault(
"monthly_invite_count", 0));
result.add(row);
}
}
return result;
}
}
@@ -0,0 +1,95 @@
package Sdf1_login;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerToggleSneakEvent;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public class IronBlockElevator implements Listener {
private static final int MAX_RANGE = 64;
private static final int MIN_RANGE = 5; // 屏蔽±5格以内的铁块
private final Set<UUID> cooldown = new HashSet<>();
/**
* 检查指定位置脚下是否是铁块
*/
private boolean isIronBlockBelow(Location loc) {
Block block = loc.getWorld().getBlockAt(
loc.getBlockX(), loc.getBlockY() - 1,
loc.getBlockZ());
return block.getType() == Material.IRON_BLOCK;
}
/**
* 从起始位置向上或向下搜索铁块并传送
* @param dir 1=向上 -1=向下
*/
private boolean tryTeleport(Player p, int dir) {
Location loc = p.getLocation();
int baseY = loc.getBlockY();
for (int i = MIN_RANGE; i < MAX_RANGE; i++) {
int y = baseY + i * dir;
Block block = loc.getWorld().getBlockAt(
loc.getBlockX(), y, loc.getBlockZ());
if (block.getType() == Material.IRON_BLOCK) {
cooldown.add(p.getUniqueId());
Location dest = new Location(
loc.getWorld(),
loc.getX(),
block.getY() + 1,
loc.getZ());
p.teleport(dest);
org.bukkit.Bukkit.getScheduler()
.runTaskLater(
org.bukkit.Bukkit.getPluginManager()
.getPlugin("Sdf1_login"),
() -> cooldown.remove(
p.getUniqueId()),
15L);
return true;
}
}
return false;
}
// ===== 下蹲 → 向下传送 =====
@EventHandler
public void onSneak(PlayerToggleSneakEvent e) {
if (!e.isSneaking()) return;
Player p = e.getPlayer();
if (cooldown.contains(p.getUniqueId())) return;
// 必须站在铁块上
if (!isIronBlockBelow(p.getLocation())) return;
tryTeleport(p, -1);
}
// ===== 跳跃 → 向上传送 =====
@EventHandler
public void onMove(PlayerMoveEvent e) {
Player p = e.getPlayer();
if (cooldown.contains(p.getUniqueId())) return;
if (p.isSneaking()) return;
Location from = e.getFrom();
Location to = e.getTo();
if (to == null) return;
if (to.getY() <= from.getY()) return;
if (p.getVelocity().getY() <= 0) return;
// 关键:检查跳跃前的位置(from)脚下是否是铁块
// 跳跃后玩家已离开铁块上方,检查 to 会失败
if (!isIronBlockBelow(from)) return;
tryTeleport(p, 1);
}
}
@@ -0,0 +1,311 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
public class LoginManager {
private final Main plugin;
public LoginManager(Main plugin) {
this.plugin = plugin;
}
public boolean handleRegister(Player p,
String[] args) {
String name = p.getName();
// 已注册玩家不能再注册
if (plugin.getDb().userExists(name)) {
p.sendMessage(plugin.getConfig2()
.msg("already_registered"));
return true;
}
// IP注册限制检查
String ip = plugin.getPlayerIP(p);
if (ip != null && plugin.getConfig2()
.maxAccountsPerIP > 0) {
if (!plugin.getIPGroup()
.canRegister(ip)) {
int count = plugin.getIPGroup()
.getAccountCount(ip);
int max = plugin.getConfig2()
.maxAccountsPerIP;
p.sendMessage("§c该IP注册已达上限("
+ count + "/" + max + ")");
return true;
}
}
if (args.length < 1) {
p.sendMessage("§c用法: /reg <密码>");
p.sendMessage("§7密码要求: 6位以上,"
+ "包含大小写字母和数字");
return true;
}
String pwd = args[0];
if (pwd.length() < 6) {
p.sendMessage("§c密码长度不足6位");
return true;
}
if (!pwd.matches(".*[a-z].*")
|| !pwd.matches(".*[A-Z].*")
|| !pwd.matches(".*[0-9].*")) {
p.sendMessage("§c密码必须包含大写字母、"
+ "小写字母和数字");
return true;
}
String salt =
PasswordUtils.generateSalt();
String hash =
PasswordUtils.hash(pwd, salt);
plugin.getDb().createUser(
name, hash, salt);
plugin.getLoggedIn().add(name);
p.setAllowFlight(false);
p.setFlying(false);
plugin.getDb().setLoggedIn(name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
plugin.getDb().recordIP(name, ip);
plugin.getDb().setField(name,
"register_time",
System.currentTimeMillis());
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("reg_success"));
playRegisterSound(p);
try {
org.bukkit.plugin.Plugin cy =
Bukkit.getPluginManager()
.getPlugin("CY_beibao");
if (cy != null && cy.isEnabled()) {
cy.getClass().getMethod(
"onSdf1Activation",
String.class,
int.class, int.class)
.invoke(cy, name, 9, 0);
}
} catch (Exception ignored) {
}
return true;
}
private void playRegisterSound(Player p) {
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_CHIME,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_BASS,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING,
1.0f, 0.79f);
}
public boolean handleLogin(Player p,
String[] args) {
String name = p.getName();
// ★ 在 handleLogin 方法开头
if (plugin.getNeedsPasswordChange().contains(name)
&& plugin.getLoggedIn().contains(name)) {
// 已登录但还没改密码,只允许执行 /sdf1_login pw
p.sendMessage("§c请先修改密码: /sdf1_login pw <旧密码> <新密码>");
return true;
}
try {
if (plugin.getLoggedIn().contains(name)) {
p.sendMessage(plugin.getConfig2()
.msg("already_logged_in"));
return true;
}
if (!plugin.getDb().userExists(name)) {
p.sendMessage(plugin.getConfig2()
.msg("not_registered"));
return true;
}
if (args.length < 1) {
p.sendMessage("§c用法: /login <密码>");
return true;
}
String pwd = args[0];
String salt = (String) plugin.getDb()
.getField(name, "password_salt");
if (salt == null) {
p.sendMessage(
"§c数据异常,请联系管理员");
plugin.getLogger().severe(
"[Sdf1_login] 玩家 " + name
+ " 盐值为null");
return true;
}
String hash =
PasswordUtils.hash(pwd, salt);
boolean matchMain =
plugin.getDb().checkPassword(
name, hash);
if (matchMain) {
plugin.getLoggedIn().add(name);
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
// 只在有有效备份时恢复
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("login_success"));
activateCY(p, name);
return true;
}
boolean matchTemp =
plugin.getDb()
.checkPasswordOrTemp(
name, hash);
if (matchTemp) {
plugin.getLoggedIn().add(name);
plugin.getDb().setLoggedIn(name, true);
p.setAllowFlight(false);
p.setFlying(false);
plugin.getDb().setField(name, "last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name, "last_online_check",
System.currentTimeMillis());
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
// ★ 确保这行存在
plugin.getNeedsPasswordChange().add(name);
p.sendMessage(plugin.getConfig2().msg("login_success"));
p.sendMessage("§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
p.sendMessage("§7用法: /sdf1_login pw");
activateCY(p, name);
return true;
}
p.sendMessage(plugin.getConfig2()
.msg("login_failed"));
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_login] handleLogin异常: "
+ e.getMessage());
e.printStackTrace();
p.sendMessage(
"§c登录过程出错,请联系管理员");
}
return true;
}
public void handleReset(Player p) {
String name = p.getName();
String emailAddr = (String)
plugin.getDb().getField(
name, "email");
if (emailAddr == null
|| emailAddr.isEmpty()) {
p.sendMessage(
"§c未设置邮箱,无法重置密码");
return;
}
String tempPwd = PasswordUtils
.generateTempPassword();
String salt = (String)
plugin.getDb().getField(
name, "password_salt");
String hash = PasswordUtils.hash(
tempPwd, salt);
plugin.getDb().setField(name,
"temp_password", hash);
plugin.getDb().setField(name,
"temp_pw_expire",
System.currentTimeMillis()
+ 300000L);
plugin.getDb().setField(name,
"temp_pw_used", 0);
p.sendMessage("§e正在发送临时密码...");
final String to = emailAddr;
final String fName = name;
final String pwd = tempPwd;
final Main self = plugin;
Bukkit.getScheduler()
.runTaskAsynchronously(self,
() -> {
final boolean[] sent = {false};
Thread t = new Thread(() -> {
sent[0] = self.getEmail()
.sendTempPassword(
to, fName, pwd);
}, "Sdf1_login-reset");
t.setDaemon(true);
t.start();
try {
t.join(15000);
} catch (
InterruptedException ignored) {
}
if (t.isAlive()) {
t.interrupt();
Bukkit.getScheduler().runTask(
self, () ->
p.sendMessage(
"§c邮件发送超时"));
return;
}
Bukkit.getScheduler().runTask(
self, () -> {
if (sent[0]) {
p.sendMessage(
"§a临时密码已发送到 "
+ to);
} else {
p.sendMessage(
"§c邮件发送失败");
}
});
});
}
private void activateCY(Player p,
String name) {
try {
org.bukkit.plugin.Plugin cy =
Bukkit.getPluginManager()
.getPlugin("CY_beibao");
if (cy != null && cy.isEnabled()) {
cy.getClass().getMethod(
"onSdf1Activation",
String.class,
int.class, int.class)
.invoke(cy, name, 0, 0);
}
} catch (Exception ignored) {
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
package Sdf1_login;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
public class MenuIconManager {
private final Main plugin;
private final NamespacedKey iconKey;
public MenuIconManager(Main plugin) {
this.plugin = plugin;
this.iconKey = new NamespacedKey(
plugin, "custom_menu_icon");
}
// ========== GUIManager 调用的方法 ==========
/**
* Base64 → ItemStack
* 对应 GUIManager 中的
* plugin.getMenuIconMgr().deserializeItem(...)
*/
public ItemStack deserializeItem(String b64) {
if (b64 == null || b64.isEmpty())
return null;
try {
byte[] data =
Base64.getDecoder().decode(b64);
ByteArrayInputStream bais =
new ByteArrayInputStream(data);
BukkitObjectInputStream is =
new BukkitObjectInputStream(bais);
Object obj = is.readObject();
is.close();
if (obj instanceof ItemStack)
return (ItemStack) obj;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* ItemStack → Base64
* 对应 GUIManager 中的
* plugin.getMenuIconMgr().serializeItem(...)
*/
public String serializeItem(ItemStack item) {
if (item == null) return null;
try {
ByteArrayOutputStream baos =
new ByteArrayOutputStream();
BukkitObjectOutputStream os =
new BukkitObjectOutputStream(baos);
os.writeObject(item);
os.close();
return Base64.getEncoder()
.encodeToString(
baos.toByteArray());
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 给物品打上菜单图标标记
* 对应 GUIManager 中的
* plugin.getMenuIconMgr().ensureMenuIconTag(...)
*/
public void ensureMenuIconTag(ItemStack item) {
if (item == null) return;
ItemMeta meta = item.getItemMeta();
if (meta == null) return;
meta.getPersistentDataContainer().set(
iconKey,
PersistentDataType.STRING,
"menu");
item.setItemMeta(meta);
}
/**
* 获取图标Key
* 对应 GUIManager 中的
* plugin.getMenuIconMgr().getIconKey()
*/
public NamespacedKey getIconKey() {
return iconKey;
}
// ========== LoginManager / Main 调用的方法 ==========
/**
* 获取玩家自定义图标物品
* 有返回物品,无返回null
*/
public ItemStack getIcon(Player player) {
String b64 = plugin.getDb()
.getMenuIcon(player.getName());
if (b64 == null || b64.isEmpty())
return null;
ItemStack item = deserializeItem(b64);
if (item == null) return null;
ensureMenuIconTag(item);
return item;
}
/**
* 保存图标到DB
*/
public void saveIcon(Player player,
ItemStack item) {
String b64 = serializeItem(item);
if (b64 != null) {
plugin.getDb().saveMenuIcon(
player.getName(), b64,
item.getType().name());
}
}
/**
* 删除图标
*/
public void removeIcon(Player player) {
plugin.getDb().deleteMenuIcon(
player.getName());
}
/**
* 检查物品是否是自定义菜单图标
*/
public boolean isCustomMenuIcon(
ItemStack item) {
if (item == null) return false;
ItemMeta meta = item.getItemMeta();
if (meta == null) return false;
return meta.getPersistentDataContainer()
.has(iconKey,
PersistentDataType.STRING);
}
}
@@ -0,0 +1,452 @@
package Sdf1_login;
import org.bukkit.*;
import org.bukkit.entity.Player;
import org.bukkit.event.*;
import org.bukkit.event.inventory.*;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.nio.file.Files;
public class MenuManager implements Listener {
private final Main plugin;
private final List<MenuItem> items =
new ArrayList<>();
private final Set<String> chatWait =
ConcurrentHashMap.newKeySet();
private final Map<String, String> chatField =
new ConcurrentHashMap<>();
private final Map<String, Integer> chatIdx =
new ConcurrentHashMap<>();
// ===== 菜单项 =====
public static class MenuItem {
public String title = "新菜单项";
public String command = "";
public String permType = "玩家";
public Integer slot = null;
public ItemStack icon;
public MenuItem() {
icon = randomIcon();
}
public MenuItem(String t,
String c, String p) {
title = t;
command = c;
permType = p;
icon = randomIcon();
}
static ItemStack randomIcon() {
Material[] pool = {
Material.COMPASS,
Material.ENDER_PEARL,
Material.DIAMOND,
Material.GOLD_INGOT,
Material.EMERALD,
Material.NETHER_STAR,
Material.BEACON,
Material.ENCHANTING_TABLE,
Material.CHEST,
Material.FIREWORK_ROCKET,
Material.BOOK,
Material.ELYTRA,
Material.TOTEM_OF_UNDYING
};
Material m = pool[
(int) (Math.random()
* pool.length)];
ItemStack it =
new ItemStack(m);
ItemMeta im = it.getItemMeta();
im.setDisplayName("§e默认图标");
it.setItemMeta(im);
return it;
}
}
public MenuManager(Main plugin) {
this.plugin = plugin;
}
public List<MenuItem> getItems() {
return items;
}
// ===== 读取菜单.txt =====
public void loadMenu() {
items.clear();
File f = new File(
plugin.getDataFolder(),
"菜单.txt");
if (!f.exists()) createDefault(f);
parseFile(f);
plugin.getLogger().info(
"[Menu] 加载 "
+ items.size()
+ " 个菜单项");
}
private void parseFile(File f) {
plugin.getLogger().info("[Menu] ===== 扫描 =====");
plugin.getLogger().info("[Menu] 路径: "
+ f.getAbsolutePath());
plugin.getLogger().info("[Menu] 大小: "
+ f.length());
try {
List<String> lines = Files.readAllLines(
f.toPath(), StandardCharsets.UTF_8);
plugin.getLogger().info("[Menu] 总行数: "
+ lines.size());
boolean inBlock = false;
boolean inHtml = false;
StringBuilder buf = new StringBuilder();
boolean inItem = false;
int parsed = 0;
for (int lineNum = 1;
lineNum <= lines.size();
lineNum++) {
String raw = lines.get(lineNum - 1);
String l = raw.trim();
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 原文[" + l.length()
+ "字符] " + l);
// ===== 第1优先:跨行块注释 =====
if (inBlock) {
if (l.contains("*/")) {
inBlock = false;
l = l.substring(
l.indexOf("*/") + 2)
.trim();
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 块注释结束");
} else {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [块注释中]");
continue;
}
}
// ===== 第2优先:跨行HTML注释 =====
if (inHtml) {
if (l.contains("-->")) {
inHtml = false;
l = l.substring(
l.indexOf("-->") + 3)
.trim();
} else {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [HTML注释中]");
continue;
}
}
// ===== 第3优先:整行注释(最高裁决) =====
// 以 # 或 // 开头的行,直接跳过
// 不管后面包含什么 /* <!-- 等
if (l.startsWith("#")
|| l.startsWith("//")) {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [整行注释,跳过]");
continue;
}
// ===== 第4优先:块注释开始 =====
if (l.contains("/*")) {
l = l.substring(0, l.indexOf("/*"))
.trim();
inBlock = true;
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 进入/*块注释"
+ " 剩余[" + l + "]");
if (l.isEmpty()) {
continue;
}
}
// ===== 第5优先:HTML注释开始 =====
if (l.contains("<!--")) {
l = l.substring(0,
l.indexOf("<!--")).trim();
inHtml = true;
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 进入<!--注释");
if (l.isEmpty()) {
continue;
}
}
// ===== 第6优先:行内注释 =====
int hashPos = l.indexOf("#");
int slashPos = l.indexOf("//");
if (hashPos >= 0) {
l = l.substring(0, hashPos).trim();
}
if (slashPos >= 0) {
if (hashPos < 0
|| slashPos < hashPos) {
l = l.substring(0, slashPos)
.trim();
}
}
if (l.isEmpty()) {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 空");
continue;
}
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [保留] " + l);
if (l.equals("{")) {
inItem = true;
buf.setLength(0);
plugin.getLogger().info(
"[Menu] === 块开始 ===");
continue;
}
if (l.equals("}")) {
if (inItem) {
plugin.getLogger().info(
"[Menu] === 块结束 ===");
parseBlock(buf.toString());
parsed++;
plugin.getLogger().info(
"[Menu] 当前菜单项: "
+ items.size());
}
inItem = false;
continue;
}
if (inItem) {
buf.append(l).append("\n");
}
}
plugin.getLogger().info(
"[Menu] ===== 完毕 =====");
plugin.getLogger().info(
"[Menu] 块数: " + parsed);
plugin.getLogger().info(
"[Menu] 菜单项: "
+ items.size());
} catch (Exception e) {
plugin.getLogger().warning(
"[Menu] 异常: "
+ e.getMessage());
}
}
private void parseBlock(String block) {
String title = "";
String cmd = "";
String perm = "玩家";
String iconMat = "";
int slotNum = -1;
for (String line : block.split("\n")) {
line = line.trim();
if (line.isEmpty()) continue;
String v = "";
int idx = line.indexOf(":");
if (idx < 0) idx = line.indexOf(":");
if (idx >= 0) {
v = line.substring(idx + 1).trim();
if (v.startsWith("\"")
&& v.endsWith("\"")
&& v.length() >= 2) {
v = v.substring(1, v.length() - 1);
}
}
if (line.startsWith("标题")) {
title = v;
} else if (line.startsWith("指令")) {
cmd = v;
if (!cmd.isEmpty()
&& !cmd.startsWith("/")) {
cmd = "/" + cmd;
}
} else if (line.startsWith("权限")) {
perm = v;
} else if (line.startsWith("图标")) {
iconMat = v;
} else if (line.startsWith("格子")) {
try {
int s = Integer.parseInt(v);
if (s >= 1 && s <= 54) {
slotNum = s;
}
} catch (NumberFormatException ignored) {
}
}
}
if (title.isEmpty()) return;
MenuItem mi = new MenuItem(title, cmd, perm);
if (slotNum >= 1 && slotNum <= 54) {
mi.slot = slotNum;
}
if (!iconMat.isEmpty()) {
try {
String matName = iconMat
.replace("minecraft:", "")
.replace("bukkit:", "")
.trim().toUpperCase();
Material m =
Material.valueOf(matName);
mi.icon = new ItemStack(m);
ItemMeta im =
mi.icon.getItemMeta();
im.setDisplayName(
"§e" + title);
mi.icon.setItemMeta(im);
} catch (Exception e) {
plugin.getLogger().warning(
"[Menu] 无法解析: "
+ iconMat);
}
}
items.add(mi);
}
private void createDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets
.UTF_8))) {
pw.println("# 服务器菜单");
pw.println("# {}定义菜单项");
pw.println("# 支持"
+ " # // /* */ <!-- -->");
pw.println();
pw.println("{");
pw.println(" 标题:主城传送");
pw.println(" 指令:/spawn");
pw.println(" 权限类型:玩家");
pw.println(" 图标:COMPASS");
pw.println("}");
pw.println();
pw.println("{");
pw.println(" 标题:随机传送");
pw.println(" 指令:/rtp");
pw.println(" 权限类型:玩家");
pw.println(" 图标:ENDER_PEARL");
pw.println("}");
} catch (Exception ignored) {
}
}
// ===== 保存 =====
public void saveMenu() {
File f = new File(
plugin.getDataFolder(),
"菜单.txt");
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# 服务器菜单");
pw.println();
for (MenuItem mi : items) {
pw.println("{");
pw.println(" 标题:"
+ mi.title);
pw.println(" 指令:"
+ mi.command);
pw.println(" 权限类型:"
+ mi.permType);
// ★ 保存实际材质名 ★
pw.println(" 图标:"
+ mi.icon.getType().name());
pw.println("}");
pw.println();
}
} catch (Exception e) {
plugin.getLogger().warning(
"[Menu] 保存失败: "
+ e.getMessage());
}
}
// ===== 聊天编辑 =====
public boolean isEditing(String name) {
return chatWait.contains(name);
}
public void editTitle(
Player p, int idx) {
chatIdx.put(p.getName(), idx);
chatWait.add(p.getName());
chatField.put(p.getName(), "title");
p.closeInventory();
p.sendMessage("§e请输入菜单标题:");
}
public void editCommand(
Player p, int idx) {
chatIdx.put(p.getName(), idx);
chatWait.add(p.getName());
chatField.put(p.getName(), "command");
p.closeInventory();
p.sendMessage("§e请输入指令"
+ "(自动补全/):");
}
public void onChat(Player p, String input) {
String name = p.getName();
if (!chatWait.remove(name)) return;
String field = chatField.remove(name);
Integer idx = chatIdx.get(name);
if (idx == null || idx >= items.size()) return;
MenuItem mi = items.get(idx);
if ("title".equals(field)) {
mi.title = input;
p.sendMessage("§a标题: " + input);
} else if ("command".equals(field)) {
if (!input.startsWith("/"))
input = "/" + input;
mi.command = input;
p.sendMessage("§a指令: " + input);
}
Bukkit.getScheduler().runTask(plugin, () ->
plugin.gui.openEditor(p, idx));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
package Sdf1_login;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class PasswordUtils {
private static final SecureRandom RANDOM =
new SecureRandom();
public static String generateSalt() {
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
return Base64.getEncoder().encodeToString(salt);
}
public static String hash(String password, String salt) {
try {
MessageDigest md =
MessageDigest.getInstance("SHA-256");
md.update(Base64.getDecoder().decode(salt));
byte[] hash =
md.digest(password.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static boolean validate(String password) {
if (password == null) return false;
if (password.length() < 6 || password.length() > 20)
return false;
boolean hasUpper = false;
boolean hasLower = false;
boolean hasDigit = false;
for (char c : password.toCharArray()) {
if (Character.isUpperCase(c)) hasUpper = true;
if (Character.isLowerCase(c)) hasLower = true;
if (Character.isDigit(c)) hasDigit = true;
}
return hasUpper && hasLower && hasDigit;
}
public static String generateTempPassword() {
String chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder sb = new StringBuilder(8);
for (int i = 0; i < 8; i++) {
sb.append(chars.charAt(
RANDOM.nextInt(chars.length())));
}
return sb.toString();
}
public static String generateInviteCode() {
String chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
StringBuilder sb = new StringBuilder(6);
for (int i = 0; i < 6; i++) {
sb.append(chars.charAt(
RANDOM.nextInt(chars.length())));
}
return sb.toString();
}
public static String generateVerifyCode() {
int code = 100000 + RANDOM.nextInt(900000);
return String.valueOf(code);
}
}
@@ -0,0 +1,130 @@
package Sdf1_login;
import java.io.File;
import java.sql.*;
import java.util.logging.Logger;
public class PlayerStorage {
private Connection conn;
private final Logger logger;
public PlayerStorage(File dataFolder,
Logger logger) {
this.logger = logger;
File dbFile = new File(
dataFolder,
"quest_data.db");
try {
Class.forName(
"org.sqlite.JDBC");
conn = DriverManager
.getConnection(
"jdbc:sqlite:"
+ dbFile
.getAbsolutePath());
conn.setAutoCommit(true);
Statement s =
conn.createStatement();
s.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_kv ("
+ "player TEXT NOT NULL,"
+ "key TEXT NOT NULL,"
+ "value TEXT,"
+ "PRIMARY KEY"
+ "(player, key))");
s.close();
logger.info(
"[QuestStorage] 初始化: "
+ dbFile.getAbsolutePath());
} catch (Exception e) {
logger.severe(
"[QuestStorage] 失败: "
+ e.getMessage());
}
}
public Object getCondition(
String player, String key) {
if (conn == null) return null;
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value "
+ "FROM quest_kv "
+ "WHERE player=? "
+ "AND key=?");
ps.setString(1,
player.toLowerCase());
ps.setString(2, key);
ResultSet rs =
ps.executeQuery();
String val = rs.next()
? rs.getString("value")
: null;
rs.close();
ps.close();
return parseValue(val);
} catch (SQLException e) {
return null;
}
}
public void setCondition(
String player, String key) {
setConditionRaw(
player, key, "true");
}
public void debugPlayer(String player) {
}
public void setConditionRaw(
String player, String key,
String value) {
if (conn == null) return;
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO quest_kv"
+ "(player,key,value)"
+ " VALUES(?,?,?)");
ps.setString(1,
player.toLowerCase());
ps.setString(2, key);
ps.setString(3, value);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
logger.warning(
"[QuestStorage] 写入失败: "
+ key + "=" + value
+ " | " + e.getMessage());
}
}
public void close() {
try {
if (conn != null
&& !conn.isClosed())
conn.close();
} catch (Exception e) {}
}
private Object parseValue(String v) {
if (v == null) return null;
try {
return Long.parseLong(v);
} catch (Exception e1) {
try {
return Double.parseDouble(v);
} catch (Exception e2) {
if ("true".equalsIgnoreCase(v))
return true;
if ("false".equalsIgnoreCase(v))
return false;
return v;
}
}
}
}
@@ -0,0 +1,374 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.lang.reflect.Method;
import java.util.*;
public class PointsManager {
private final Main plugin;
private Object cyInstance = null;
private Method cyActivationMethod = null;
private Method cyGetShopItemsMethod = null;
private final List<Map<String, Object>> cyItems =
new ArrayList<>();
public PointsManager(Main plugin) {
this.plugin = plugin;
discoverCY();
}
private void discoverCY() {
try {
org.bukkit.plugin.Plugin p = Bukkit
.getPluginManager()
.getPlugin("CY_beibao");
if (p != null && p.isEnabled()) {
cyInstance = p;
cyGetShopItemsMethod = p.getClass()
.getMethod("onSdf1GetShopItems");
cyActivationMethod = p.getClass()
.getMethod("onSdf1Activation",
String.class,
int.class, int.class);
plugin.getLogger().info(
"[Sdf1_login] CY_beibao 联控已连接");
}
} catch (Exception ignored) {
}
}
private void refreshCYItems() {
cyItems.clear();
if (cyInstance == null
|| cyGetShopItemsMethod == null) return;
try {
Object result =
cyGetShopItemsMethod.invoke(cyInstance);
if (result == null) return;
String data = result.toString();
if (data.isEmpty()) return;
String[] items = data.split(";");
for (String itemStr : items) {
String[] p = itemStr.split("\\|");
if (p.length < 8) continue;
Map<String, Object> item =
new LinkedHashMap<>();
item.put("id", p[0]);
item.put("name", p[1]);
item.put("days",
Integer.parseInt(p[2]));
item.put("slots",
Integer.parseInt(p[3]));
item.put("price",
Integer.parseInt(p[4]));
item.put("stock",
Integer.parseInt(p[5]));
item.put("icon", p[6]);
item.put("lifetime",
"1".equals(p[7]));
cyItems.add(item);
}
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] 拉取CY商品失败: "
+ e.getMessage());
}
}
private void activateCY(String name,
int slots, int days) {
try {
if (cyInstance == null
|| cyActivationMethod == null) return;
cyActivationMethod.invoke(
cyInstance, name, slots, days);
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] CY激活失败: "
+ e.getMessage());
}
}
public int getPoints(String name) {
return ((Number) plugin.getDb()
.getField(name, "points")).intValue();
}
public Inventory createShopGUI(Player p) {
int points = getPoints(p.getName());
Inventory inv = Bukkit.createInventory(
null, 54,
"§d§l积分商城 | 当前积分: " + points);
fillBg(inv);
inv.setItem(10, mkItem(Material.EMERALD,
"§a游戏币充值",
"§7消耗积分兑换游戏币",
"§7100积分 = $1000",
"",
"§e点击兑换"));
inv.setItem(12, mkItem(Material.ENDER_CHEST,
"§b背包空间",
"§7消耗积分兑换背包格子",
"§750积分 = 9格永久空间",
"",
"§e点击兑换"));
inv.setItem(14, mkItem(Material.EXPERIENCE_BOTTLE,
"§e经验瓶",
"§7消耗积分兑换经验",
"§720积分 = 10级经验",
"",
"§e点击兑换"));
if (cyInstance != null) {
inv.setItem(31, mkItem(Material.CHEST,
"§6背包商城",
"§7来自CY_beibao的商品",
"",
"§e点击查看"));
}
inv.setItem(49, mkItem(Material.ARROW, "§7返回"));
return inv;
}
public Inventory createCYShopGUI(Player p) {
refreshCYItems();
int points = getPoints(p.getName());
int size = Math.max(54,
((cyItems.size() / 9) + 1) * 9);
if (size > 54) size = 54;
Inventory inv = Bukkit.createInventory(
null, size,
"§d§lCY背包商城 | 当前积分: " + points);
fillBg(inv);
for (int i = 0; i < cyItems.size()
&& i < 45; i++) {
Map<String, Object> item = cyItems.get(i);
String name = (String) item.get("name");
int price = ((Number) item.get("price"))
.intValue();
int stock = ((Number) item.get("stock"))
.intValue();
int days = ((Number) item.get("days"))
.intValue();
int slots = ((Number) item.get("slots"))
.intValue();
boolean lifetime = (boolean) item
.get("lifetime");
List<String> lore = new ArrayList<>();
lore.add("§7价格: §e" + price + " 积分");
if (lifetime) {
lore.add("§7格子: §e终身 +" + slots);
} else {
lore.add("§7格子: §e+" + slots);
lore.add("§7时间: §e" + days + "天");
}
if (stock == 0) {
lore.add("§7库存: §a无限");
} else if (stock == -1) {
lore.add("§7库存: §c已下架");
} else {
lore.add("§7库存: §e" + stock);
}
lore.add("");
if (points >= price && stock != -1) {
lore.add("§e点击购买");
} else if (stock == -1) {
lore.add("§c已下架");
} else {
lore.add("§c积分不足");
}
inv.setItem(i, mkItem(Material.CHEST,
"§a" + name,
lore.toArray(new String[0])));
}
inv.setItem(size - 1,
mkItem(Material.ARROW, "§7返回"));
return inv;
}
public void addItem(String name,
int price, String cmd2) {
// 追加到商品配置文件
File file = new File(
plugin.getDataFolder(),
"ShopItems.txt");
try {
java.io.FileWriter fw =
new java.io.FileWriter(file, true);
fw.write(name + ":" + price
+ ":" + cmd2 + "\n");
fw.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public void removeItem(int index) {
File file = new File(
plugin.getDataFolder(),
"ShopItems.txt");
try {
java.util.List<String> lines =
new java.util.ArrayList<>();
java.io.BufferedReader br =
new java.io.BufferedReader(
new java.io.FileReader(
file));
String line;
int i = 0;
while ((line = br.readLine()) != null) {
if (line.trim().isEmpty()
|| line.startsWith("#")) {
lines.add(line);
continue;
}
if (i != index) {
lines.add(line);
}
i++;
}
br.close();
java.io.PrintWriter pw =
new java.io.PrintWriter(file);
for (String l : lines) pw.println(l);
pw.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public void handleClick(Player p, int slot) {
int points = getPoints(p.getName());
switch (slot) {
case 10:
if (points >= 100) {
plugin.getDb().deductPoints(
p.getName(), 100);
if (plugin.getEconomy() != null) {
plugin.getEconomy()
.depositPlayer(p, 1000);
}
p.sendMessage(plugin.getConfig2()
.msg("points_purchase_success")
.replace("{count}", "100"));
} else {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
}
break;
case 12:
if (points >= 50) {
plugin.getDb().deductPoints(
p.getName(), 50);
activateCY(p.getName(), 9, 0);
p.sendMessage(plugin.getConfig2()
.msg("points_purchase_success")
.replace("{count}", "50"));
} else {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
}
break;
case 14:
if (points >= 20) {
plugin.getDb().deductPoints(
p.getName(), 20);
p.setLevel(p.getLevel() + 10);
p.sendMessage(plugin.getConfig2()
.msg("points_purchase_success")
.replace("{count}", "20"));
} else {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
}
break;
case 31:
if (cyInstance != null) {
p.openInventory(
createCYShopGUI(p));
}
break;
}
}
public void handleCYClick(Player p, int slot) {
if (slot < 0 || slot >= cyItems.size()
|| slot >= 45) return;
Map<String, Object> item = cyItems.get(slot);
int price = ((Number) item.get("price"))
.intValue();
int stock = ((Number) item.get("stock"))
.intValue();
int days = ((Number) item.get("days"))
.intValue();
int slots = ((Number) item.get("slots"))
.intValue();
boolean lifetime = (boolean) item
.get("lifetime");
if (stock == -1) {
p.sendMessage("§c该商品已下架");
return;
}
int points = getPoints(p.getName());
if (points < price) {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
return;
}
plugin.getDb().deductPoints(p.getName(), price);
int actDays = lifetime ? 0 : days;
activateCY(p.getName(), slots, actDays);
String tl = lifetime
? "终身" : (days + "天");
p.sendMessage("§a购买成功!获得 "
+ tl + " +" + slots + "格 背包空间");
}
private void fillBg(Inventory inv) {
ItemStack gl = mkItem(
Material.GRAY_STAINED_GLASS_PANE, " ");
for (int i = 0; i < inv.getSize(); i++)
inv.setItem(i, gl);
}
private ItemStack mkItem(Material mat, String name,
String... lore) {
ItemStack it = new ItemStack(mat);
ItemMeta im = it.getItemMeta();
if (im != null) {
im.setDisplayName(name);
if (lore.length > 0)
im.setLore(Arrays.asList(lore));
it.setItemMeta(im);
}
return it;
}
}
@@ -0,0 +1,138 @@
package Sdf1_login;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class QuestStorage {
private Connection conn;
public QuestStorage(File dataFolder) {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
dataFolder,
"quest_data.db");
conn = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getPath());
initTable();
} catch (Exception e) {
e.printStackTrace();
}
}
private void initTable()
throws SQLException {
Statement stmt =
conn.createStatement();
stmt.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_conditions ("
+ "player TEXT NOT NULL, "
+ "condition_key TEXT NOT NULL, "
+ "value TEXT DEFAULT '1', "
+ "timestamp INTEGER, "
+ "PRIMARY KEY "
+ "(player, condition_key))");
stmt.close();
}
/**
* 标记条件已完成(value = "1")
*/
public void setCondition(
String player,
String condition) {
setConditionRaw(
player, condition, "1");
}
/**
* 写入任意值(计数器等)
*/
public void setConditionRaw(
String player,
String condition,
String value) {
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO "
+ "quest_conditions "
+ "(player, "
+ "condition_key, "
+ "value, timestamp) "
+ "VALUES "
+ "(?, ?, ?, ?)");
ps.setString(1, player);
ps.setString(2, condition);
ps.setString(3, value);
ps.setLong(4,
System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 查询条件值
*/
public Object getCondition(
String player,
String condition) {
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value FROM "
+ "quest_conditions "
+ "WHERE player = ? "
+ "AND "
+ "condition_key = ?");
ps.setString(1, player);
ps.setString(2, condition);
ResultSet rs =
ps.executeQuery();
if (rs.next()) {
String val =
rs.getString("value");
rs.close();
ps.close();
// 尝试解析为数字
try {
if (val.contains(".")) {
return Double.parseDouble(
val);
}
return Long.parseLong(val);
} catch (NumberFormatException e) {
return val;
}
}
rs.close();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public void close() {
try {
if (conn != null
&& !conn.isClosed()) {
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player
.PlayerResourcePackStatusEvent;
import java.util.UUID;
import java.util.concurrent
.ConcurrentHashMap;
public class RadioDownloadListener
implements Listener {
private final Main plugin;
private final java.util.logging.Logger log;
private final ConcurrentHashMap<UUID, Integer>
failCount = new ConcurrentHashMap<>();
private final ConcurrentHashMap<UUID, Boolean>
blocked = new ConcurrentHashMap<>();
private static final int MAX_FAILED = 3;
public RadioDownloadListener(Main plugin) {
this.plugin = plugin;
this.log = plugin.getLogger();
}
public void onPlayerQuit(UUID uuid) {
failCount.remove(uuid);
blocked.remove(uuid);
}
@EventHandler
public void onStatus(
PlayerResourcePackStatusEvent e) {
Player p = e.getPlayer();
UUID uuid = p.getUniqueId();
String status =
e.getStatus().name();
log.info("[Radio] === 资源包状态 ===");
log.info("[Radio] 玩家: "
+ p.getName());
log.info("[Radio] UUID: " + uuid);
log.info("[Radio] 状态: "
+ status);
log.info("[Radio] 原始状态枚举: "
+ e.getStatus().getClass()
.getSimpleName()
+ "."
+ e.getStatus().name());
if (blocked.containsKey(uuid)) {
log.info("[Radio] 已熔断,跳过: "
+ p.getName());
return;
}
switch (status) {
case "SUCCESS":
log.info("[Radio] "
+ p.getName()
+ " ✓ 资源包加载成功");
failCount.remove(uuid);
blocked.remove(uuid);
break;
case "ACCEPTED":
log.info("[Radio] "
+ p.getName()
+ " 客户端已接受,"
+ "等待下载...");
break;
case "DECLINED":
log.warning("[Radio] "
+ p.getName()
+ " ✗ 拒绝了资源包");
log.warning("[Radio] "
+ "可能原因: "
+ "1.客户端设置禁用资源包"
+ " 2.已缓存拒绝记录"
+ " 3.SSL证书不匹配");
handleFailure(p, uuid);
break;
case "FAILED_DOWNLOAD":
log.warning("[Radio] "
+ p.getName()
+ " ✗ 下载失败");
log.warning("[Radio] "
+ "可能原因: "
+ "1.URL无法访问"
+ " 2.SSL证书错误"
+ " 3.网络超时"
+ " 4.文件不存在(404)");
handleFailure(p, uuid);
break;
default:
log.info("[Radio] "
+ p.getName()
+ " 未知状态: "
+ status);
break;
}
}
private void handleFailure(Player p, UUID uuid) {
int failNum = failCount.getOrDefault(uuid, 0) + 1;
failCount.put(uuid, failNum);
log.warning("[Radio] " + p.getName()
+ " 失败次数: " + failNum + "/" + MAX_FAILED);
if (failNum >= MAX_FAILED) {
blocked.put(uuid, true);
log.severe("[Radio] " + p.getName()
+ " 已达最大失败次数,停止重试,"
+ "玩家继续留在服务器");
// 不踢出,只记录熔断,玩家正常游玩
p.sendMessage("§e§l[Radio] 资源包加载失败"
+ ",已取消下载资源包,部分画面音频可能出现问题"
+ "\n§d§l若想继续下载,请退出重进。若持续失败,请上报管理员");
return;
}
// 打了包没测试
p.sendMessage("§e§l[Radio] 下载失败,"
+ "重试... (" + failNum + "/" + MAX_FAILED + ")");
// 5秒后重试
Bukkit.getScheduler()
.runTaskLater(plugin, () -> {
if (p.isOnline() && plugin.radio != null) {
plugin.radio.sendResourcePack(p);
}
}, 100L);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
package Sdf1_login;
import org.bukkit.Material;
import java.util.UUID;
public class RefundRecord {
public final UUID playerUUID;
public final String playerName;
public final String itemId;
public final String itemName;
public final Material material;
public final Material shulkerMaterial;
public int amount;
public int totalPaid;
public final long timestamp;
// 普通购买
public RefundRecord(UUID uuid, String name,
String itemId, String itemName,
Material material, int amount,
int totalPaid, long timestamp) {
this.playerUUID = uuid;
this.playerName = name;
this.itemId = itemId;
this.itemName = itemName;
this.material = material;
this.shulkerMaterial = null;
this.amount = amount;
this.totalPaid = totalPaid;
this.timestamp = timestamp;
}
// 盒装购买
public RefundRecord(UUID uuid, String name,
String itemId, String itemName,
Material material,
Material shulkerMat,
int amount, int totalPaid,
long timestamp) {
this.playerUUID = uuid;
this.playerName = name;
this.itemId = itemId;
this.itemName = itemName;
this.material = material;
this.shulkerMaterial = shulkerMat;
this.amount = amount;
this.totalPaid = totalPaid;
this.timestamp = timestamp;
}
public boolean isExpired() {
return System.currentTimeMillis()
- timestamp > 300000L;
}
public int getSecondsLeft() {
long left = 300000L
- (System.currentTimeMillis()
- timestamp);
return (int) Math.max(0, left / 1000);
}
}
@@ -0,0 +1,248 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* 风控管理器:检测异常登录行为,记录尝试次数,触发安全报警
*/
public class RiskControlManager {
private final Main plugin;
// 每个玩家名 -> 连续失败次数
private final Map<String, Integer> failAttempts =
new ConcurrentHashMap<>();
// 每个IP -> 关联的可疑玩家列表
private final Map<String, Set<String>> ipSuspicious =
new ConcurrentHashMap<>();
// 每个玩家 -> 上次验证时间(用于冷却)
private final Map<String, Long> verifyCooldown =
new ConcurrentHashMap<>();
// 配置项
private static final int MAX_FAIL_ATTEMPTS = 5;
private static final int MAX_ACCOUNTS_PER_IP = 5;
private static final long COOLDOWN_MS = 60000L;
public RiskControlManager(Main plugin) {
this.plugin = plugin;
}
/**
* 记录一次失败的登录尝试
*
* @param playerName 玩家名
* @return 剩余尝试次数
*/
public int recordFailAttempt(String playerName) {
int current = failAttempts
.getOrDefault(playerName, 0) + 1;
failAttempts.put(playerName, current);
return Math.max(0, MAX_FAIL_ATTEMPTS - current);
}
/**
* 重置指定玩家的失败计数(登录成功时调用)
*/
public void resetFailAttempts(String playerName) {
failAttempts.remove(playerName);
}
/**
* 获取指定玩家的失败次数
*/
public int getFailAttempts(String playerName) {
return failAttempts
.getOrDefault(playerName, 0);
}
/**
* 判断玩家是否已被锁定(失败次数达到上限)
*/
public boolean isLocked(String playerName) {
return failAttempts
.getOrDefault(playerName, 0)
>= MAX_FAIL_ATTEMPTS;
}
/**
* 检查IP是否达到注册上限
* 直接查数据库,不依赖 DatabaseManager 的方法
*/
public boolean canRegisterFromIP(String ip) {
if (ip == null || ip.isEmpty()) return true;
try {
java.sql.Connection conn =
plugin.getDb().getDb();
if (conn == null) return true;
java.sql.PreparedStatement ps =
conn.prepareStatement(
"SELECT COUNT(*) FROM users "
+ "WHERE ip_address = ?");
ps.setString(1, ip);
java.sql.ResultSet rs = ps.executeQuery();
int count = 0;
if (rs.next()) count = rs.getInt(1);
rs.close();
ps.close();
return count < 5;
} catch (Exception e) {
e.printStackTrace();
return true;
}
}
/**
* 记录IP关联的可疑行为
*/
public void recordSuspiciousIP(String ip,
String playerName) {
if (ip == null || ip.isEmpty()) return;
ipSuspicious
.computeIfAbsent(ip,
k -> ConcurrentHashMap.newKeySet())
.add(playerName);
}
/**
* 获取某IP关联的所有可疑玩家
*/
public Set<String> getSuspiciousAccounts(String ip) {
return ipSuspicious
.getOrDefault(ip, Collections.emptySet());
}
/**
* 检查是否在验证冷却期内
*/
public boolean isOnCooldown(String playerName) {
Long last = verifyCooldown.get(playerName);
if (last == null) return false;
return System.currentTimeMillis() - last
< COOLDOWN_MS;
}
/**
* 设置验证冷却时间
*/
public void setCooldown(String playerName) {
verifyCooldown.put(playerName,
System.currentTimeMillis());
}
/**
* 当检测到可疑登录时触发报警
* - 全服广播
* - 有邮箱则发邮件
* - 记录到数据库
*/
public void triggerAlert(Player player,
String suspiciousIP) {
String name = player.getName();
// 记录到数据库
plugin.getDb().recordSecurityAlert(
name, suspiciousIP);
// 记录IP可疑行为
recordSuspiciousIP(suspiciousIP, name);
// 全服广播
String serverMsg = "§c§l[安全警告] §e" + name
+ " §f的账号疑似遭到 §c" + suspiciousIP
+ " §f的盗号尝试, 请各位玩家注意账号安全!";
Bukkit.broadcastMessage(serverMsg);
// 有邮箱则发邮件
String emailAddr = (String) plugin.getDb()
.getField(name, "email");
if (emailAddr != null
&& !emailAddr.isEmpty()) {
String subject =
"[安全警告] 您的账号疑似遭到盗号尝试";
String body = "尊敬的玩家 " + name
+ ":\n\n"
+ "您的账号在 "
+ new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date())
+ " 疑似遭到 IP: " + suspiciousIP
+ " 的盗号尝试。\n\n"
+ "建议您:\n"
+ "1. 立即修改密码"
+ "(使用 /sdf1_login pw)\n"
+ "2. 如非本人操作,"
+ "请联系管理员处理\n\n"
+ "--Sdf1_login 安全系统";
plugin.getEmail().sendTempPassword(
emailAddr, name, body);
}
}
/**
* 处理登录失败时的风控逻辑
*
* @param player 玩家
* @param suspiciousIP 可疑来源IP
* @return true=已被锁定(应踢出), false=仍可继续尝试
*/
public boolean handleLoginFailure(Player player,
String suspiciousIP) {
String name = player.getName();
int remaining = recordFailAttempt(name);
if (remaining <= 0) {
// 达到上限,触发报警
triggerAlert(player, suspiciousIP);
player.kickPlayer(
"§c§l[风控] §f登录失败次数过多,"
+ " 账号已被临时锁定, 请联系管理员");
return true;
}
// 未达上限,提示剩余次数
player.sendMessage(
"§c§l[风控] §f密码错误! "
+ "剩余" + remaining + "次机会");
return false;
}
/**
* 登录成功后重置风控状态
*/
public void onLoginSuccess(String playerName) {
resetFailAttempts(playerName);
}
/**
* 清理过期的冷却记录(可由定时任务调用)
*/
public void cleanup() {
long now = System.currentTimeMillis();
verifyCooldown.entrySet().removeIf(
e -> now - e.getValue() > COOLDOWN_MS * 10);
}
/**
* 获取所有玩家的失败尝试记录(调试用)
*/
public Map<String, Integer> getAllFailAttempts() {
return Collections.unmodifiableMap(failAttempts);
}
/**
* 清除所有风控数据(管理员调试用)
*/
public void clearAll() {
failAttempts.clear();
ipSuspicious.clear();
verifyCooldown.clear();
}
}
@@ -0,0 +1,170 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class SalesStatsManager {
private final Main plugin;
private final Map<String, Integer> sessionSales =
new ConcurrentHashMap<>();
private int sessionRevenue = 0;
private int sessionOrders = 0;
private int sessionItems = 0;
private int sessionRefunds = 0;
private int sessionRefundAmount = 0;
private long sessionStart = System.currentTimeMillis();
// 状态
private boolean waitingForEmpty = false;
private boolean hasReported = false;
private BukkitRunnable waitTask = null;
public SalesStatsManager(Main plugin) {
this.plugin = plugin;
startMonitor();
}
// ★ 每10秒检测一次
private void startMonitor() {
new BukkitRunnable() {
@Override
public void run() {
int online = Bukkit.getOnlinePlayers().size();
if (online > 0) {
// 有玩家在线 → 重置等待状态
if (waitingForEmpty) {
// 刚有人进来了,清除已汇报标记
// 下次没人时会重新汇报
}
waitingForEmpty = false;
hasReported = false;
cancelWaitTask();
} else if (!waitingForEmpty && !hasReported) {
// 没人了,且还没汇报过 → 开始1分钟倒计时
waitingForEmpty = true;
startWaitCountdown();
}
}
}.runTaskTimer(plugin, 200L, 200L);
}
// ★ 1分钟等待
private void startWaitCountdown() {
cancelWaitTask();
waitTask = new BukkitRunnable() {
@Override
public void run() {
int online = Bukkit.getOnlinePlayers().size();
if (online > 0) {
// 等待期间有人进来 → 取消,等下次没人
waitingForEmpty = false;
hasReported = false;
waitTask = null;
cancel();
return;
}
// 1分钟没人 → 汇报一次
if (!hasReported) {
hasReported = true;
report();
}
waitingForEmpty = false;
waitTask = null;
cancel();
}
};
waitTask.runTaskLater(plugin, 1200L); // 60秒
}
private void cancelWaitTask() {
if (waitTask != null) {
waitTask.cancel();
waitTask = null;
}
}
// ===== 记录 =====
public void recordSale(String itemName, int price) {
sessionSales.merge(itemName, 1, Integer::sum);
sessionRevenue += price;
sessionOrders++;
sessionItems++;
}
public void recordRefund(int amount) {
sessionRefunds++;
sessionRefundAmount += amount;
}
// ===== 汇报 =====
public void report() {
if (sessionItems == 0 && sessionRefunds == 0) {
// plugin.getLogger().info("[Sales] 无数据,跳过");
return;
}
long minutes =
(System.currentTimeMillis() - sessionStart)
/ 60000;
if (minutes < 1) minutes = 1;
StringBuilder sb = new StringBuilder();
sb.append("§6§l========== 销量汇总 ==========");
sb.append("\n§7时长: §f").append(minutes).append("分钟");
sb.append("\n§7订单: §f").append(sessionOrders).append("笔");
sb.append("\n§7商品: §f").append(sessionItems).append("件");
sb.append("\n§7成交额: §6").append(sessionRevenue).append("枚");
sb.append("\n§7退款: §c").append(sessionRefunds)
.append("次(§c").append(sessionRefundAmount)
.append("枚§7)");
sb.append("\n§6§l------------------------------");
if (!sessionSales.isEmpty()) {
sb.append("\n§7销量排行:");
List<Map.Entry<String, Integer>> sorted =
new ArrayList<>(sessionSales.entrySet());
sorted.sort((a, b) ->
b.getValue() - a.getValue());
int rank = 1;
for (Map.Entry<String, Integer> e : sorted) {
if (rank > 10) break;
sb.append("\n§e").append(rank).append(". ")
.append(e.getKey())
.append(" §7x").append(e.getValue());
rank++;
}
}
sb.append("\n§6§l==============================");
String msg = sb.toString();
for (Player p : Bukkit.getOnlinePlayers()) {
if (p.isOp()) {
for (String line : msg.split("\n"))
p.sendMessage(line);
}
}
plugin.getLogger().info("[Sales] 汇报完成");
resetSession();
}
public void resetSession() {
sessionSales.clear();
sessionRevenue = 0;
sessionOrders = 0;
sessionItems = 0;
sessionRefunds = 0;
sessionRefundAmount = 0;
sessionStart = System.currentTimeMillis();
}
}
@@ -0,0 +1,32 @@
package Sdf1_login;
import java.util.ArrayList;
import java.util.List;
public class ShopCategory {
private final String name;
private final String fileName;
private final List<ShopItem> items;
public ShopCategory(String name, String fileName) {
this.name = name;
this.fileName = fileName;
this.items = new ArrayList<>();
}
public String getName() { return name; }
public String getFileName() { return fileName; }
public List<ShopItem> getItems() { return items; }
public ShopItem getItem(String id) {
for (ShopItem item : items) {
if (item.getId().equalsIgnoreCase(id))
return item;
}
return null;
}
public void addItem(ShopItem item) {
items.add(item);
}
}
@@ -0,0 +1,49 @@
package Sdf1_login;
import org.bukkit.Material;
public class ShopItem {
private final String id;
private final String displayName;
private final Material material;
private final int buyPrice;
private final int sellPrice;
private int stock;
private int hourlySales;
private int totalSales;
public ShopItem(String id, String displayName,
Material material, int buyPrice,
int sellPrice, int stock,
int hourlySales, int totalSales) {
this.id = id;
this.displayName = displayName;
this.material = material;
this.buyPrice = buyPrice;
this.sellPrice = sellPrice;
this.stock = stock;
this.hourlySales = hourlySales;
this.totalSales = totalSales;
}
public int getEffectiveSellPrice() {
if (sellPrice <= 0) return 0;
return sellPrice;
}
public String getId() { return id; }
public String getDisplayName() { return displayName; }
public Material getMaterial() { return material; }
public int getBuyPrice() { return buyPrice; }
public int getSellPrice() { return sellPrice; }
public int getStock() { return stock; }
public int getHourlySales() { return hourlySales; }
public int getTotalSales() { return totalSales; }
public void setStock(int s) { this.stock = s; }
public void setHourlySales(int s) { this.hourlySales = s; }
public void setTotalSales(int s) { this.totalSales = s; }
public void addSales(int n) {
this.hourlySales += n;
this.totalSales += n;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,523 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.HttpURLConnection;
import java.net.URL;
public class UpdateChecker {
private final Main plugin;
private final String currentVersion;
// ===== 仓库信息 =====
private static final String GITHUB_OWNER =
"ypsdf1";
private static final String GITHUB_REPO =
"sdf1_login";
private static final String GITEE_OWNER =
"nihaoshidifu";
private static final String GITEE_REPO =
"sdf1_login";
// API 地址
private static final String GITHUB_API =
"https://api.github.com/repos/"
+ GITHUB_OWNER + "/"
+ GITHUB_REPO
+ "/tags";
private static final String GITEE_API =
"https://gitee.com/api/v5/repos/"
+ GITEE_OWNER + "/"
+ GITEE_REPO
+ "/tags";
// 仓库页面链接(给用户看的)
private static final String GITHUB_LINK =
"https://github.com/"
+ GITHUB_OWNER + "/"
+ GITHUB_REPO + "/releases";
private static final String GITEE_LINK =
"https://gitee.com/"
+ GITEE_OWNER + "/"
+ GITEE_REPO + "/releases";
// 单次请求超时(毫秒)
private static final int TIMEOUT_MS = 3000;
// 熔断阈值
private static final int TRIP_THRESHOLD = 5;
// ===== 熔断状态(内存态,重启自动清除) =====
private volatile boolean circuitTripped =
false;
private volatile int failCount = 0;
// ===== 自动检测禁用(持久化到文件) =====
private volatile boolean autoCheckDisabled =
false;
private final File disabledFile;
public UpdateChecker(Main plugin) {
this.plugin = plugin;
this.currentVersion =
plugin.getDescription()
.getVersion();
this.disabledFile = new File(
plugin.getDataFolder(),
"update_disabled.dat");
loadDisabledState();
}
// ========== 持久化禁用状态 ==========
private void loadDisabledState() {
if (disabledFile.exists()) {
try (BufferedReader br =
new BufferedReader(
new FileReader(
disabledFile))) {
String line = br.readLine();
if (line != null) {
autoCheckDisabled =
"true".equals(
line.trim());
}
} catch (Exception ignored) {
}
}
}
private void saveDisabledState() {
try (PrintWriter pw =
new PrintWriter(
new FileWriter(
disabledFile))) {
pw.println(autoCheckDisabled
? "true" : "false");
} catch (Exception ignored) {
}
}
// ========== 公开方法 ==========
/**
* onEnable 调用(异步静默)
* 熔断或禁用则直接跳过
*/
public void checkOnEnable() {
if (autoCheckDisabled) return;
if (circuitTripped) return;
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() -> doCheck(null, true));
}
/**
* /sdf1_login update 调用(异步)
* 忽略熔断和禁用状态,强制检测
* 只有拿到版号才解除熔断
*/
public void checkUpdate(
CommandSender sender) {
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() ->
doCheck(sender,
false));
}
// ========== 核心检测逻辑 ==========
private void doCheck(CommandSender sender,
boolean isAuto) {
if (isAuto) {
if (autoCheckDisabled
|| circuitTripped) {
return;
}
}
// 通道1:GitHub
CheckResult gh =
tryChannel(GITHUB_API,
"GitHub", GITHUB_LINK);
// 通道2:Gitee
CheckResult ge =
tryChannel(GITEE_API,
"Gitee", GITEE_LINK);
// 选择成功的结果
CheckResult chosen = null;
String failMsg = null;
if (gh.isSuccess()) {
chosen = gh;
} else if (ge.isSuccess()) {
chosen = ge;
} else {
// 双通道均失败
failCount++;
// 拼接两个通道的失败原因
StringBuilder sb =
new StringBuilder();
if (gh.error != null) {
sb.append("§7GitHub: §c")
.append(gh.error);
}
if (ge.error != null) {
if (sb.length() > 0)
sb.append("\n");
sb.append("§7Gitee: §c")
.append(ge.error);
}
if (failCount >= TRIP_THRESHOLD) {
circuitTripped = true;
plugin.getLogger().warning(
"[Sdf1_login] 更新检测"
+ "已熔断,连续失败"
+ failCount + "次");
}
if (sender != null) {
final String msg =
sb.toString();
final boolean tripped =
circuitTripped;
final int fc = failCount;
Bukkit.getScheduler()
.runTask(plugin, () -> {
sender.sendMessage(
"§c§l[更新] "
+ "检测失败");
sender.sendMessage(msg);
sender.sendMessage(
"§e失败次数: "
+ fc + "/"
+ TRIP_THRESHOLD);
if (tripped) {
sender.sendMessage(
"§c已熔断,"
+ "请重启服务器"
+ "重置");
}
});
}
return;
}
// 至少一个通道成功
failCount = 0;
// 检查禁用信号
if (isDisableSignal(chosen.version)) {
autoCheckDisabled = true;
saveDisabledState();
plugin.getLogger().info(
"[Sdf1_login] 远程版本信号"
+ " \"" + chosen.version
+ "\",自动更新检测已禁用");
if (sender != null) {
Bukkit.getScheduler()
.runTask(plugin, () ->
sender.sendMessage(
"§e[更新] 远程已"
+ "禁用自动"
+ "更新检测"));
}
return;
}
// 手动调用:成功才解除熔断和禁用
if (!isAuto) {
if (circuitTripped) {
circuitTripped = false;
}
if (autoCheckDisabled) {
autoCheckDisabled = false;
saveDisabledState();
}
}
final String src = chosen.source;
final String lnk = chosen.link;
final String rv = chosen.version;
Bukkit.getScheduler()
.runTask(plugin, () -> {
if (sender == null) return;
sender.sendMessage(
"§e[更新] 检测成功"
+ " via: §a" + src);
sender.sendMessage(
"§e[更新] 本地版本:"
+ " §f"
+ currentVersion);
sender.sendMessage(
"§e[更新] 远程版本:"
+ " §f" + rv);
if (isNewer(rv,
currentVersion)) {
sender.sendMessage(
"§c[更新] 发现新"
+ "版本: " + rv);
sender.sendMessage(
"§a请前往仓库下载"
+ "更新:");
sender.sendMessage(
"§a" + lnk);
} else {
sender.sendMessage(
"§a[更新] 已是"
+ "最新版本");
}
// 显示另一通道的失败原因
// (仅信息,不影响结果)
CheckResult other =
(src.equals("GitHub"))
? ge : gh;
if (!other.isSuccess()
&& other.error != null) {
sender.sendMessage(
"§7[更新] "
+ other.source
+ "不可用: "
+ other.error);
}
});
}
// ========== 禁用信号判断 ==========
private boolean isDisableSignal(
String version) {
if (version == null) return false;
String v = version.trim().toLowerCase();
return "999".equals(v)
|| "关闭".equals(v)
|| "false".equals(v)
|| "禁用".equals(v);
}
// ========== HTTP 请求(带超时) ==========
// ========== 检测结果 ==========
private static class CheckResult {
final String version;
final String source;
final String link;
final String error;
CheckResult(String version,
String source,
String link,
String error) {
this.version = version;
this.source = source;
this.link = link;
this.error = error;
}
boolean isSuccess() {
return version != null;
}
}
// ========== HTTP 请求 ==========
private CheckResult tryChannel(
String apiUrl,
String sourceName,
String link) {
HttpURLConnection conn = null;
try {
URL url = new URL(apiUrl);
conn = (HttpURLConnection)
url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; "
+ "Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36");
conn.setRequestProperty(
"Accept",
"*/*");
conn.setRequestProperty(
"Accept-Language",
"zh-CN,zh;q=0.9");
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == 404) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 未找到 (HTTP 404)");
}
if (code == 403) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 被限流 (HTTP 403)");
}
if (code != 200) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": HTTP " + code);
}
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
conn
.getInputStream()));
StringBuilder sb =
new StringBuilder();
String line;
while ((line = reader.readLine())
!= null) {
sb.append(line);
}
reader.close();
String tag =
parseTagName(sb.toString());
if (tag == null) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 无法解析版本号");
}
return new CheckResult(tag,
sourceName, link, null);
} catch (java.net
.SocketTimeoutException e) {
return new CheckResult(null,
sourceName, link,
sourceName + ": 连接超时");
} catch (java.net.ConnectException e) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 连接被拒绝");
} catch (java.net
.UnknownHostException e) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": DNS解析失败");
} catch (Exception e) {
return new CheckResult(null,
sourceName, link,
sourceName + ": "
+ e.getMessage());
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
/**
* 从 JSON 中提取 tag_name
*/
private String parseTagName(String json) {
// tags API 返回数组: [{"name":"v1.1",...}]
// releases API 返回对象: {"tag_name":"v1.1"}
// 尝试 tag_name(releases格式)
int idx =
json.indexOf("\"tag_name\"");
if (idx >= 0) {
int colon =
json.indexOf(":", idx);
int q1 =
json.indexOf("\"", colon + 1);
int q2 =
json.indexOf("\"", q1 + 1);
if (q2 > q1) {
return cleanTag(
json.substring(
q1 + 1, q2));
}
}
// 尝试 "name"(tags格式)
idx = json.indexOf("\"name\"");
if (idx >= 0) {
int colon =
json.indexOf(":", idx);
int q1 =
json.indexOf("\"", colon + 1);
int q2 =
json.indexOf("\"", q1 + 1);
if (q2 > q1) {
return cleanTag(
json.substring(
q1 + 1, q2));
}
}
return null;
}
private String cleanTag(String tag) {
if (tag.startsWith("v")
|| tag.startsWith("V")) {
tag = tag.substring(1);
}
return tag.isEmpty() ? null : tag;
}
// ========== 版本比较 ==========
private boolean isNewer(String remote,
String local) {
String[] r = remote.split("\\.");
String[] l = local.split("\\.");
int len = Math.max(r.length, l.length);
for (int i = 0; i < len; i++) {
int ri = i < r.length
? parseNum(r[i]) : 0;
int li = i < l.length
? parseNum(l[i]) : 0;
if (ri > li) return true;
if (ri < li) return false;
}
return false;
}
private int parseNum(String s) {
try {
return Integer.parseInt(
s.replaceAll("[^0-9]", ""));
} catch (Exception e) {
return 0;
}
}
}
@@ -0,0 +1,370 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.util.*;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
public class UserGroupManager {
private final Main plugin;
private Connection db;
private final Map<String, UserGroupConfig> groupConfigs = new ConcurrentHashMap<>();
public UserGroupManager(Main plugin) {
this.plugin = plugin;
initDB();
loadGroupConfigs();
startExpiryChecker();
}
// ===== DB =====
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(plugin.getDataFolder(), "usergroup.db");
db = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
st.execute("CREATE TABLE IF NOT EXISTS user_group_config ("
+ "group_name TEXT PRIMARY KEY,"
+ "display_name TEXT DEFAULT '',"
+ "display_color TEXT DEFAULT '§f',"
+ "display_emoji TEXT DEFAULT '',"
+ "priority INTEGER DEFAULT 0,"
+ "is_permanent INTEGER DEFAULT 1,"
+ "duration_minutes INTEGER DEFAULT 0,"
+ "pack_url TEXT DEFAULT '',"
+ "pack_hash TEXT DEFAULT '',"
+ "pack_prompt TEXT DEFAULT 'Custom Resource Pack',"
+ "pack_size INTEGER DEFAULT 0)");
st.execute("CREATE TABLE IF NOT EXISTS user_group_member ("
+ "player_name TEXT NOT NULL,"
+ "group_name TEXT NOT NULL,"
+ "added_by TEXT DEFAULT 'system',"
+ "added_time INTEGER DEFAULT 0,"
+ "expiry_time INTEGER DEFAULT 0,"
+ "PRIMARY KEY(player_name, group_name))");
st.close();
} catch (Exception e) {
throw new RuntimeException("[UserGroup] DB init failed: " + e.getMessage(), e);
}
}
// ===== 配置加载 =====
public void loadGroupConfigs() {
groupConfigs.clear();
File dir = new File(plugin.getDataFolder(), "用户组");
if (!dir.exists()) dir.mkdirs();
File[] files = dir.listFiles((d, n) -> n.endsWith(".txt"));
if (files == null) return;
for (File f : files) {
try {
List<String> lines = java.nio.file.Files.readAllLines(
f.toPath(), StandardCharsets.UTF_8);
UserGroupConfig cfg = parseGroupConfig(lines);
if (cfg != null) {
groupConfigs.put(cfg.name, cfg);
saveGroupConfigToDB(cfg);
}
} catch (Exception e) {
plugin.getLogger().warning("[UserGroup] Failed to load: " + f.getName());
}
}
}
private UserGroupConfig parseGroupConfig(List<String> lines) {
UserGroupConfig cfg = new UserGroupConfig();
boolean inBlock = false;
for (String raw : lines) {
String l = raw;
// 块注释
if (l.contains("<!--")) { inBlock = true; continue; }
if (l.contains("-->")) { inBlock = false; continue; }
if (l.contains("/*")) { inBlock = true; continue; }
if (l.contains("*/")) { inBlock = false; continue; }
if (inBlock) continue;
// 行注释
l = l.trim();
if (l.isEmpty()) continue;
if (l.startsWith("#") || l.startsWith("//")) continue;
// 行内注释
int h = l.indexOf('#');
int s = l.indexOf("//");
if (h >= 0) l = l.substring(0, h).trim();
if (s >= 0 && (h < 0 || s < h)) l = l.substring(0, s).trim();
if (l.isEmpty()) continue;
// 解析键值对
String key = l;
String val = "";
int idx = l.indexOf('\uff1a');
if (idx < 0) idx = l.indexOf(':');
if (idx >= 0) {
key = l.substring(0, idx).trim();
val = l.substring(idx + 1).trim();
if (val.startsWith("\"") && val.endsWith("\""))
val = val.substring(1, val.length() - 1);
}
switch (key) {
case "组名":
case "name": cfg.name = val; break;
case "显示名":
case "display": cfg.displayName = val; break;
case "颜色":
case "color": cfg.displayColor = val; break;
case "符号":
case "emoji": cfg.displayEmoji = val; break;
case "优先级":
case "priority":
try { cfg.priority = Integer.parseInt(val); } catch (Exception ignored) {}
break;
case "类型":
case "type":
cfg.permanent = val.equalsIgnoreCase("永久") || val.equalsIgnoreCase("permanent");
break;
case "时长":
case "duration":
try { cfg.durationMinutes = Integer.parseInt(val); } catch (Exception ignored) {}
break;
case "资源包URL":
case "pack_url": cfg.packUrl = val; break;
case "资源包Hash":
case "pack_hash": cfg.packHash = val; break;
case "资源包提示":
case "pack_prompt": cfg.packPrompt = val; break;
case "资源包大小":
case "pack_size":
try { cfg.packSize = Integer.parseInt(val); } catch (Exception ignored) {}
break;
case "本地文件":
case "local_file": cfg.localFile = val; break;
}
}
if (cfg.name == null || cfg.name.isEmpty()) return null;
return cfg;
}
private void saveGroupConfigToDB(UserGroupConfig cfg) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO user_group_config "
+ "(group_name, display_name, display_color, display_emoji,"
+ " priority, is_permanent, duration_minutes,"
+ " pack_url, pack_hash, pack_prompt, pack_size)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?)");
ps.setString(1, cfg.name);
ps.setString(2, cfg.displayName);
ps.setString(3, cfg.displayColor);
ps.setString(4, cfg.displayEmoji);
ps.setInt(5, cfg.priority);
ps.setInt(6, cfg.permanent ? 1 : 0);
ps.setInt(7, cfg.durationMinutes);
ps.setString(8, cfg.packUrl);
ps.setString(9, cfg.packHash);
ps.setString(10, cfg.packPrompt);
ps.setInt(11, cfg.packSize);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
// ===== 玩家操作 =====
public boolean addPlayer(String player, String groupName, String addedBy, long durationMinutes) {
UserGroupConfig cfg = groupConfigs.get(groupName);
if (cfg == null) return false;
long now = System.currentTimeMillis();
long expiry = cfg.permanent ? 0 : now + durationMinutes * 60 * 1000;
if (!cfg.permanent && durationMinutes > 0) expiry = now + durationMinutes * 60 * 1000;
else if (!cfg.permanent) expiry = now + cfg.durationMinutes * 60 * 1000;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO user_group_member "
+ "(player_name, group_name, added_by, added_time, expiry_time)"
+ " VALUES (?,?,?,?,?)");
ps.setString(1, player);
ps.setString(2, groupName);
ps.setString(3, addedBy);
ps.setLong(4, now);
ps.setLong(5, expiry);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
return false;
}
Player p = plugin.getServer().getPlayerExact(player);
if (p != null) sendResourcePack(p);
return true;
}
public boolean removePlayer(String player, String groupName) {
try {
PreparedStatement ps = db.prepareStatement(
"DELETE FROM user_group_member WHERE player_name=? AND group_name=?");
ps.setString(1, player);
ps.setString(2, groupName);
int rows = ps.executeUpdate();
ps.close();
return rows > 0;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
public List<Map<String, Object>> getPlayerGroups(String player) {
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM user_group_member WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
long now = System.currentTimeMillis();
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("group_name", rs.getString("group_name"));
row.put("added_by", rs.getString("added_by"));
long expiry = rs.getLong("expiry_time");
row.put("expiry_time", expiry);
row.put("expired", expiry > 0 && expiry <= now);
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
public UserGroupConfig getHighestGroup(String player) {
List<Map<String, Object>> groups = getPlayerGroups(player);
long now = System.currentTimeMillis();
UserGroupConfig best = null;
for (Map<String, Object> g : groups) {
long expiry = (long) g.get("expiry_time");
if (expiry > 0 && expiry <= now) continue;
String name = (String) g.get("group_name");
UserGroupConfig cfg = groupConfigs.get(name);
if (cfg != null && (best == null || cfg.priority > best.priority)) {
best = cfg;
}
}
return best;
}
public boolean isPlayerInGroup(String player, String groupName) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM user_group_member WHERE player_name=? AND group_name=?");
ps.setString(1, player);
ps.setString(2, groupName);
boolean exists = ps.executeQuery().next();
ps.close();
return exists;
} catch (SQLException e) {
return false;
}
}
// ===== 过期自动移除 =====
private void startExpiryChecker() {
new BukkitRunnable() {
@Override
public void run() {
checkExpiry();
}
}.runTaskTimerAsynchronously(plugin, 1200L, 1200L); // 每分钟检查
}
private void checkExpiry() {
try {
long now = System.currentTimeMillis();
PreparedStatement ps = db.prepareStatement(
"SELECT player_name, group_name FROM user_group_member "
+ "WHERE expiry_time > 0 AND expiry_time <= ?");
ps.setLong(1, now);
ResultSet rs = ps.executeQuery();
List<String[]> expired = new ArrayList<>();
while (rs.next()) {
expired.add(new String[]{
rs.getString("player_name"),
rs.getString("group_name")});
}
rs.close(); ps.close();
for (String[] e : expired) {
PreparedStatement del = db.prepareStatement(
"DELETE FROM user_group_member WHERE player_name=? AND group_name=?");
del.setString(1, e[0]);
del.setString(2, e[1]);
del.executeUpdate();
del.close();
plugin.getLogger().info("[UserGroup] 过期移除: " + e[0] + " -> " + e[1]);
}
} catch (SQLException e) {
e.printStackTrace();
}
}
// ===== 资源包分发 =====
public void sendResourcePack(Player p) {
UserGroupConfig cfg = getHighestGroup(p.getName());
if (cfg == null || cfg.packUrl == null || cfg.packUrl.isEmpty()) return;
String url = cfg.packUrl;
String prompt = cfg.packPrompt != null
? cfg.packPrompt : "Custom Resource Pack";
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
try {
p.setResourcePack(url, new byte[0], prompt, false);
plugin.getLogger().info("[UserGroup] 资源包已发送: "
+ p.getName() + " -> " + cfg.displayName);
} catch (Exception e) {
plugin.getLogger().warning(
"[UserGroup] 资源包发送失败: " + e.getMessage());
}
}, 40L);
}
public Map<String, UserGroupConfig> getGroupConfigs() {
return groupConfigs;
}
// ===== 配置类 =====
public static class UserGroupConfig {
public String name;
public String displayName = "";
public String displayColor = "§f";
public String displayEmoji = "";
public int priority = 0;
public boolean permanent = true;
public int durationMinutes = 0;
public String packUrl = "";
public String packHash = "";
public String packPrompt = "Custom Resource Pack";
public int packSize = 0;
public String localFile = "";
}
}
@@ -0,0 +1,158 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.function.Consumer;
public class VerificationManager {
private final Main plugin;
public VerificationManager(Main plugin) {
this.plugin = plugin;
}
public boolean isBedrockPlayer(Player p) {
String uuid = p.getUniqueId().toString();
return uuid.contains("f851ba4")
|| uuid.startsWith("00000000-0000-0000");
}
public boolean isOnlineMode() {
return Bukkit.getServer().getOnlineMode();
}
public void verifyPremiumAsync(Player player,
Consumer<Boolean> callback) {
Thread t = new Thread(() -> doVerify(player, callback),
"Sdf1_login-PremiumVerify");
t.setDaemon(true);
t.start();
}
private void doVerify(Player player,
Consumer<Boolean> callback) {
String name = player.getName();
String uuid = player.getUniqueId().toString();
plugin.getLogger().info(
"[Sdf1_login] ====== 验证开始 ======");
plugin.getLogger().info(
"[Sdf1_login] 玩家: " + name);
plugin.getLogger().info(
"[Sdf1_login] 当前UUID: " + uuid);
try {
UUID offlineUuid = UUID.nameUUIDFromBytes(
("OfflinePlayer:" + name)
.getBytes(StandardCharsets.UTF_8));
String offlineStr = offlineUuid.toString();
plugin.getLogger().info(
"[Sdf1_login] 离线UUID: " + offlineStr);
boolean isOfflineUuid =
player.getUniqueId().equals(offlineUuid);
plugin.getLogger().info(
"[Sdf1_login] UUID匹配离线格式: "
+ isOfflineUuid);
if (!isOfflineUuid) {
plugin.getLogger().info(
"[Sdf1_login] 结论: 正版玩家");
plugin.getLogger().info(
"[Sdf1_login] ====== 验证结束 ======");
callback.accept(true);
return;
}
plugin.getLogger().info(
"[Sdf1_login] UUID为离线格式,"
+ "查询Mojang API...");
UUID mojangUuid = fetchMojangUuid(name);
if (mojangUuid != null) {
plugin.getLogger().info(
"[Sdf1_login] Mojang返回UUID: "
+ mojangUuid);
plugin.getLogger().info(
"[Sdf1_login] 结论: 盗版客户端"
+ "使用了正版名字");
} else {
plugin.getLogger().info(
"[Sdf1_login] Mojang无此名字");
plugin.getLogger().info(
"[Sdf1_login] 结论: 纯离线玩家");
}
plugin.getLogger().info(
"[Sdf1_login] ====== 验证结束 ======");
callback.accept(false);
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] 验证异常: "
+ e.getMessage());
callback.accept(false);
}
}
private UUID fetchMojangUuid(String name) {
HttpURLConnection conn = null;
try {
URL url = new URL(
"https://api.mojang.com/users/profiles/minecraft/"
+ name);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
conn.setRequestMethod("GET");
int code = conn.getResponseCode();
plugin.getLogger().info(
"[Sdf1_login] Mojang API响应: " + code);
if (code == 200) {
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
conn.getInputStream(),
StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
reader.close();
String json = sb.toString();
String uuidStr = json.replaceAll(
".*\"id\"\\s*:\\s*\"([^\"]+)\".*",
"$1");
plugin.getLogger().info(
"[Sdf1_login] Mojang原始UUID: "
+ uuidStr);
String formatted = uuidStr.replaceFirst(
"(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})",
"$1-$2-$3-$4-$5");
return UUID.fromString(formatted);
}
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] Mojang查询异常: "
+ e.getMessage());
} finally {
if (conn != null) conn.disconnect();
}
return null;
}
}
@@ -0,0 +1,163 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class WelcomeManager {
private final Main plugin;
private final Random random = new Random();
private final List<String> registerMsgs =
new ArrayList<>();
private final List<String> loginMsgs =
new ArrayList<>();
public WelcomeManager(Main plugin) {
this.plugin = plugin;
loadMessages();
}
public void loadMessages() {
registerMsgs.clear();
loginMsgs.clear();
File f = new File(
plugin.getDataFolder(),
"欢迎仪式.txt");
if (!f.exists()) {
writeDefault(f);
}
try (BufferedReader r =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets
.UTF_8))) {
String section = "";
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("\uFEFF"))
line = line.substring(1);
String t = line.trim();
if (t.isEmpty()
|| t.startsWith("#"))
continue;
if (t.equals("注册")
|| t.equals("注册:")) {
section = "register";
continue;
}
if (t.equals("登录")
|| t.equals("登录:")) {
section = "login";
continue;
}
// 支持 = 和 : 两种分隔
if (section.isEmpty()) {
if (t.startsWith("注册")
&& (t.contains("=")
|| t.contains(":"))) {
int idx = t.indexOf('=');
int idx2 = t.indexOf(':');
if (idx < 0) idx = idx2;
else if (idx2 >= 0)
idx = Math.min(idx, idx2);
String v = t.substring(idx + 1)
.trim();
if (!v.isEmpty())
registerMsgs.add(
colorize(v));
continue;
}
if (t.startsWith("登录")
&& (t.contains("=")
|| t.contains(":"))) {
int idx = t.indexOf('=');
int idx2 = t.indexOf(':');
if (idx < 0) idx = idx2;
else if (idx2 >= 0)
idx = Math.min(idx, idx2);
String v = t.substring(idx + 1)
.trim();
if (!v.isEmpty())
loginMsgs.add(
colorize(v));
continue;
}
continue;
}
if (!t.isEmpty()) {
if (section.equals("register"))
registerMsgs.add(
colorize(t));
else
loginMsgs.add(
colorize(t));
}
}
} catch (Exception e) {
plugin.getLogger().severe(
"[欢迎] 加载失败: "
+ e.getMessage());
}
plugin.getLogger().info(
"[欢迎] 加载完成: 注册="
+ registerMsgs.size()
+ " 登录=" + loginMsgs.size());
}
public void onRegister(Player p) {
sendRandom(p, registerMsgs, "注册");
}
public void onLogin(Player p) {
sendRandom(p, loginMsgs, "登录");
}
private void sendRandom(Player p,
List<String> msgs, String tag) {
if (msgs.isEmpty()) return;
String msg = msgs.get(
random.nextInt(msgs.size()));
msg = msg.replace("{player}",
p.getName());
final String finalMsg = msg;
Bukkit.getScheduler()
.runTaskLater(plugin,
() -> Bukkit
.broadcastMessage(
finalMsg), 10L);
}
private String colorize(String msg) {
return msg.replace("&", "\u00a7");
}
private void writeDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets
.UTF_8))) {
pw.println("# 欢迎仪式配置");
pw.println("# &为颜色代码");
pw.println("# {player} = 玩家名");
pw.println();
pw.println("注册:");
pw.println("&a&l欢迎 &f{player} &a加入服务器!&7[注册]");
pw.println("&6&l{player} &e已完成注册,欢迎入驻!&7[注册]");
pw.println("&b&l新成员 &f{player} &b已就位!&7[注册]");
pw.println();
pw.println("登录:");
pw.println("&a&l欢迎回来 &f{player}&a!&7[登录]");
pw.println("&e&l{player} &e已登录,继续冒险吧!&7[登录]");
pw.println("&b&l{player} &b回来了!&7[登录]");
} catch (IOException ignored) {
}
}
}
@@ -0,0 +1,104 @@
name: Sdf1_login
version: 1.0
main: Sdf1_login.Main
api-version: '1.21'
commands:
sdf1_login:
description: Sdf1_login主命令
usage: /sdf1_login
reg:
description: 注册
usage: /reg <密码>
login:
description: 登录
usage: /login <密码>
l:
description: 登录快捷版
usage: /l <密码>
注册:
description: 注册(中文)
usage: /注册 <密码>
登录:
description: 登录(中文)
usage: /登录 <密码>
玩家信息:
description: 打开面板(中文)
usage: /玩家信息
改密码:
description: 修改密码(中文)
usage: /改密码 <旧密码> <新密码> <确认新密码>
设置邮箱:
description: 设置邮箱(中文)
usage: /设置邮箱 <邮箱地址>
撤销:
description: 撤销操作(中文)
usage: /撤销
删除:
description: 删除玩家(中文)
usage: /删除 <玩家> <密码>
找回密码:
description: 找回密码(中文)
usage: /找回密码
签到:
description: 每日签到(中文)
usage: /签到
#oa:
# description: 申请删除账号
# usage: /oa [玩家名]
删除账号:
description: 申请删除账号(中文)
usage: /删除账号 [玩家名]
通过:
description: 批准OA请求(中文)
usage: /通过 <编号>
拒绝:
description: 拒绝OA请求(中文)
usage: /拒绝 <编号> [理由]
accept:
description: 批准OA请求
usage: /accept <编号>
deny:
description: 拒绝OA请求
usage: /deny <编号> [理由]
email:
description: 绑定邮箱
usage: /email <邮箱>
绑定:
description: 绑定邮箱(中文)
usage: /绑定 <邮箱>
绑定邮箱:
description: 绑定邮箱
usage: /绑定邮箱
chat:
description: 聊天过滤管理
usage: /chat
recycle:
description: 打开垃圾回收站
usage: /recycle
垃圾清理:
description: 手动清理掉落物
usage: /垃圾清理
menu:
description: 雪球菜单开关控制
usage: /menu <开/关>
pvp :
description: PVP榜单
usage: /pvp
cypay :
description: 草原探险债券
usage: /cypay
printer :
description: 打印债券流水
usage: /printer
shop :
description: 游戏商人
usage: /shop
商店:
description: 打开商店
usage: /商店
#testorder:
# description: 测试订单中心
# usage: /testorder
protect:
description: 区域保护
usage: /protect
@@ -0,0 +1,305 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class CheckInManager {
private final Main plugin;
public CheckInManager(Main plugin) {
this.plugin = plugin;
}
// ===== 今日是否已签到 =====
public boolean isCheckedInToday(
String name) {
String last = (String) plugin.getDb()
.getField(name,
"last_checkin_date");
String today = new SimpleDateFormat(
"yyyy-MM-dd")
.format(new Date());
return today.equals(last);
}
// ===== 签到 =====
// ===== 签到 =====
public String checkIn(Player p) {
String name = p.getName();
if (isCheckedInToday(name)) {
return plugin.getConfig2()
.msg("checkin_already");
}
Object streakObj = plugin.getDb()
.getField(name, "checkin_streak");
int streak = streakObj != null
? ((Number) streakObj).intValue()
: 0;
String lastDate = (String) plugin.getDb()
.getField(name, "last_checkin_date");
String today = new SimpleDateFormat("yyyy-MM-dd")
.format(new Date());
if (lastDate != null && !lastDate.isEmpty()) {
try {
java.util.Date lastD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(lastDate);
java.util.Date todayD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(today);
long diff = todayD.getTime()
- lastD.getTime();
if (diff > 86400000L * 2) {
// 间隔超过2天,重置连续天数
streak = 1;
} else if (diff > 86400000L) {
// 间隔1-2天(连续签到),天数+1
streak++;
}
// 间隔<=1天(同一天),不改变连续天数
} catch (Exception ignored) {
}
} else {
// 首次签到
streak = 1;
}
double multi = 1.0;
if (streak >= 21) multi = 1.5;
else if (streak >= 14) multi = 1.3;
else if (streak >= 7) multi = 1.1;
int basePoints =
plugin.getConfig2().checkinPoints;
int points = (int) (basePoints * multi);
plugin.getDb().addPoints(name, points);
plugin.getDb().setField(name,
"checkin_streak", streak);
plugin.getDb().setField(name,
"last_checkin_date", today);
Object totalObj = plugin.getDb()
.getField(name, "total_checkin_days");
int total = totalObj != null
? ((Number) totalObj).intValue()
: 0;
plugin.getDb().setField(name,
"total_checkin_days", total + 1);
// ★ 债券盲盒奖励(6参数版,写入流水)★
int bondMin =
plugin.getConfig2().checkinBondMin;
int bondMax =
plugin.getConfig2().checkinBondMax;
if (bondMin <= 0) bondMin = 1;
if (bondMax < bondMin) bondMax = bondMin;
int bondReward = bondMin
+ new java.util.Random()
.nextInt(bondMax - bondMin + 1);
plugin.getBonds().addBonds(name, bondReward,
"daily_sign", "", "签到系统",
"每日签到连续" + streak + "天");
return plugin.getConfig2()
.msg("checkin_success")
.replace("{points}",
String.valueOf(points))
.replace("{streak}",
String.valueOf(streak))
.replace("{multi}",
String.valueOf(multi))
+ " §e+" + bondReward + "债券";
}
// ===== 获取连续天数 =====
public int getStreak(String name) {
Object val = plugin.getDb()
.getField(name,
"checkin_streak");
if (val instanceof Number) {
return ((Number) val)
.intValue();
}
return 0;
}
// 标准化日期
private String normalizeDate(String input) {
String s = input.trim().replace("/", "-");
if (s.matches("\\d{8}")) {
return s.substring(0, 4) + "-"
+ s.substring(4, 6) + "-"
+ s.substring(6, 8);
}
return s;
}
// ===== 补签入口 =====
public String backCheckIn(
Player p, String dateStr) {
String name = p.getName();
String normalized =
normalizeDate(dateStr);
// 严格日期验证
java.text.SimpleDateFormat sdf =
new java.text.SimpleDateFormat(
"yyyy-MM-dd");
sdf.setLenient(false);
java.util.Date target;
try {
target = sdf.parse(normalized);
} catch (Exception ex) {
return "§c无效日期: " + dateStr;
}
String reformat = sdf.format(target);
if (!reformat.equals(normalized)) {
return "§c无效日期: " + dateStr;
}
String today = sdf.format(new Date());
if (normalized.compareTo(today) >= 0) {
return "§c今天不需要补签";
}
String threeDaysAgo = getPastDate(3);
if (normalized.compareTo(
threeDaysAgo) < 0) {
return "§c只能补签3天内"
+ "(最多到 " + threeDaysAgo + ")";
}
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
boolean alreadyBacked = false;
if (!existing.isEmpty()) {
for (String d : existing.split(",")) {
if (normalizeDate(d.trim())
.equals(normalized)) {
alreadyBacked = true;
break;
}
}
}
if (alreadyBacked) {
return "§c该日期已补签: " + normalized;
}
Object normalCheck =
plugin.getDb().getField(
name, "last_checkin_date");
String lastCheckin = normalCheck != null
? normalCheck.toString() : "";
if (lastCheckin.equals(normalized)) {
return "§c该日期已签到: " + normalized;
}
// ★ 补签消耗:3债券 + 10积分
// 积分不够时:用 9债券(3倍)代替 ★
int bondCost = 3;
int pointCost = 10;
int bondAltCost = 9; // 3倍债券替代
int myBonds = plugin.getBonds()
.getBonds(name);
Object ptsObj = plugin.getDb()
.getField(name, "points");
int myPoints = ptsObj != null
? ((Number) ptsObj).intValue()
: 0;
// 方案A:3债券 + 10积分
boolean canA = myBonds >= bondCost
&& myPoints >= pointCost;
// 方案B:9债券(积分不够时)
boolean canB = myBonds >= bondAltCost
&& myPoints < pointCost;
if (!canA && !canB) {
if (myPoints < pointCost
&& myBonds < bondAltCost) {
return "§c补签费用不足"
+ "(需3债券+10积分,"
+ "或9债券代替)"
+ " 当前: "
+ myBonds + "债券 "
+ myPoints + "积分";
}
if (myBonds < bondCost) {
return "§c债券不足(需"
+ bondCost + "枚)";
}
return "§c积分不足(需"
+ pointCost + "分)";
}
// 执行扣款
if (canA) {
// 方案A:3债券 + 10积分
plugin.getBonds().deductBonds(
name, bondCost,
"admin_deduct", "",
"补签系统", "补签扣款");
plugin.getDb().deductPoints(
name, pointCost);
} else {
// 方案B:9债券代替
plugin.getBonds().deductBonds(
name, bondAltCost,
"admin_deduct", "",
"补签系统", "补签扣款(债券替代)");
}
doBackCheckIn(p, normalized);
String costMsg = canA
? bondCost + "债券+" + pointCost + "积分"
: bondAltCost + "债券(替代)";
return "§a补签成功!日期: "
+ normalized
+ " §7消耗: " + costMsg;
}
// ===== 执行补签 =====
// ===== 执行补签 =====
public void doBackCheckIn(
Player p, String dateStr) {
String name = p.getName();
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
String updated = existing.isEmpty()
? dateStr
: existing + "," + dateStr;
plugin.getDb().setField(name,
"backcheck_dates", updated);
int streak = getStreak(name);
plugin.getDb().setField(name,
"checkin_streak", streak + 1);
// ★ 补签不发放奖励,只记录
}
// ===== 获取N天前日期 =====
private String getPastDate(
int daysAgo) {
Calendar cal =
Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH,
-daysAgo);
return new SimpleDateFormat(
"yyyy-MM-dd")
.format(cal.getTime());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
package Sdf1_login;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class QuestStorage {
private Connection conn;
public QuestStorage(File dataFolder) {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
dataFolder,
"quest_data.db");
conn = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getPath());
initTable();
} catch (Exception e) {
e.printStackTrace();
}
}
private void initTable()
throws SQLException {
Statement stmt =
conn.createStatement();
stmt.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_conditions ("
+ "player TEXT NOT NULL, "
+ "condition_key TEXT NOT NULL, "
+ "value TEXT DEFAULT '1', "
+ "timestamp INTEGER, "
+ "PRIMARY KEY "
+ "(player, condition_key))");
stmt.close();
}
/**
* 标记条件已完成(value = "1")
*/
public void setCondition(
String player,
String condition) {
setConditionRaw(
player, condition, "1");
}
/**
* 写入任意值(计数器等)
*/
public void setConditionRaw(
String player,
String condition,
String value) {
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO "
+ "quest_conditions "
+ "(player, "
+ "condition_key, "
+ "value, timestamp) "
+ "VALUES "
+ "(?, ?, ?, ?)");
ps.setString(1, player);
ps.setString(2, condition);
ps.setString(3, value);
ps.setLong(4,
System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 查询条件值
*/
public Object getCondition(
String player,
String condition) {
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value FROM "
+ "quest_conditions "
+ "WHERE player = ? "
+ "AND "
+ "condition_key = ?");
ps.setString(1, player);
ps.setString(2, condition);
ResultSet rs =
ps.executeQuery();
if (rs.next()) {
String val =
rs.getString("value");
rs.close();
ps.close();
// 尝试解析为数字
try {
if (val.contains(".")) {
return Double.parseDouble(
val);
}
return Long.parseLong(val);
} catch (NumberFormatException e) {
return val;
}
}
rs.close();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public void close() {
try {
if (conn != null
&& !conn.isClosed()) {
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+569
View File
@@ -0,0 +1,569 @@
# Sdf1系列登录插件使用说明
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态 |
| :--- | :--- | :--- |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
| 新人礼包 | 新玩家7日礼包 | 已完成 |
| 挂机检测 | 挂机检测踢出 | 已完成 |
| 自定义菜单 | 可以自己diy指令 | 已完成 |
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工 |
| 商城系统| 内置游戏商人可供销售商品 | 已完工 |
| 虚拟货币 | 供签到、游戏商人使用的货币系统 | 已完工 |
## 2.安装说明:
| 前置插件 | 选装类型 | 功能作用 |
| :--- | :--- | :--- |
| [CY_beibao](https://gitee.com/nihaoshidifu/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量
| EssentialsX | 可选 | 主流经济系统
| [sdf1](https://gitee.com/nihaoshidifu/sdf1_plugn) | 可选,推荐 | CDK兑换系统
### 2.1 下载
您可以从GitHub和Gitee下载到插件,将其丢入服务器plugin目录,重启服务器即可。
### 2.2. 配置文件
#### 2.2.1 消息.txt
此文件控制着整个插件的所有提示消息,您可以根据自己的需求修改,修改后需要执行 /sdf1_login reload 重载插件。支持[颜色代码](https://zh.minecraft.wiki/w/%E6%A0%BC%E5%BC%8F%E5%8C%96%E4%BB%A3%E7%A0%81)<br>
同时兼容`<b> </b>`标签,对段落加粗,`\n 和 <br>`进行段落换行
```txt
# ==========================================
# Sdf1_login 消息配置文件
# ==========================================
# 颜色代码使用 § 符号
# §0黑色 §1深蓝 §2深绿 §3深青
# §4深红 §5深紫 §6金色 §7灰色
# §8深灰 §9蓝色 §a绿色 §b浅蓝
# §c红色 §d粉色 §e黄色 §f白色
# §l粗体 §n下划线 §o斜体 §r重置
#
# 占位符用 {xxx} 表示,运行时自动替换
# 支持 <b>粗体</b> 标签
# 支持 \n 或 <br> 换行
# 修改后执行 /sdf1_login reload 重载
# ==========================================
# ---- 登录注册相关 ----
# login_timeout: 登录超时被踢出时的提示
login_timeout=§c登录超时,请重新登录
# not_registered: 未注册玩家收到的提示
not_registered=§e您尚未注册,请使用 /reg <密码>
# not_logged_in: 已注册但未登录的提示
not_logged_in=§e您尚未登录,请使用 /login <密码>
# already_registered: 已注册玩家再次注册时的提示
already_registered=§c您已注册过账号
# already_logged_in: 已登录玩家再次登录时的提示
already_logged_in=§c您已登录
# reg_success: 注册成功提示
reg_success=§a注册成功!欢迎来到服务器
# login_success: 登录成功提示
login_success=§a登录成功!
# login_failed: 密码错误提示
login_failed=§c密码错误,请重试
# ---- 密码相关 ----
# password_wrong: 旧密码验证失败
password_wrong=§c密码错误
# password_changed: 密码修改成功
password_changed=§a密码修改成功!
# password_format_error: 密码格式不符合要求
password_format_error=§c密码需6位以上,含大小写字母和数字
# password_same: 新密码与旧密码相同
password_same=§c新密码不能与旧密码相同
# reset_no_email: 找回密码时未绑定邮箱
reset_no_email=§c您未绑定邮箱,无法找回密码
# reset_sent: 临时密码已发送到邮箱
reset_sent=§a临时密码已发送到您的邮箱,请查收
# ---- 签到积分相关 ----
# checkin_already: 今日已签到
checkin_already=§c您今日已签到
# checkin_success: 签到成功,{points}=获得积分 {streak}=连续天数 {multi}=倍率
checkin_success=§a签到成功!获得 {points} 积分,连续 {streak} 天,倍率 x{multi}
# points_insufficient: 积分不足
points_insufficient=§c积分不足,当前: {points}
# points_purchase_success: 积分购买成功
points_purchase_success=§a购买成功!消耗 {count} 积分
# ---- 挂机检测相关 ----
# afk_set_enabled: 挂机检测开启
afk_set_enabled=§a挂机检测已开启
# afk_set_disabled: 挂机检测关闭
afk_set_disabled=§c挂机检测已关闭
# afk_kick: 因挂机被踢出
afk_kick=§c因挂机过久被踢出
# ---- 邮箱相关 ----
# email_set: 邮箱设置成功
email_set=§a邮箱设置成功:{email}
# email_format_error: 邮箱格式错误
email_format_error=§c邮箱格式不正确
# ---- 邀请相关 ----
# invite_code_generated: 邀请码生成
invite_code_generated=§a邀请码:{code}
# invite_used: 使用邀请码成功
invite_used=§a邀请成功,邀请人:{player}
# invite_self: 使用自己的邀请码
invite_self=§c不能使用自己的邀请码
# invite_not_found: 邀请码不存在
invite_not_found=§c邀请码不存在
# invite_referral_bonus: 被邀请人签到后邀请人获得奖励
invite_referral_bonus=§a邀请奖励!获得 {points} 积分
# ---- 新人礼包相关 ----
# gift_not_ready: 礼包条件未满足
gift_not_ready=§c礼包条件未满足
# gift_claimed: 礼包领取成功
gift_claimed=§a礼包领取成功!
# ---- 审批与IP限制 ----
# need_approval: 注册需审批
need_approval=§e注册需审批 (工单#{id})
# ip_max_accounts: IP注册已达上限
ip_max_accounts=§cIP注册已达上限
# ---- 管理员操作 ----
# admin_delete_confirm: 管理员删除账号确认提示
admin_delete_confirm=§c请在聊天中输入玩家名确认删除
# ---- 聊天过滤 ----
# chat_muted: 禁言玩家尝试聊天
chat_muted=§c§l[Sdf1_chat] §f你已被禁言,无法聊天
# chat_url_blocked: 发送违规链接被拦截
# {url}=检测到的链接地址
chat_url_blocked=§c§l[Sdf1_chat] §f检测到违规第三方链接: §e{url}
# chat_url_violation: 当前违规次数
# {count}=当前违规次数
chat_url_violation=§7当前违规次数: §e{count}
# chat_url_admin_notify: 通知管理员,{player}=玩家名 {url}=链接 {count}=次数
chat_url_admin_notify=§e§l[Sdf1_chat] §f{player} 发送违规链接: {url} (第{count}次)
# chat_url_broadcast: 全服通报,{player}=玩家名
chat_url_broadcast=§c§l[Sdf1_chat] §e{player} §f因发送违规链接被处罚
# ---- 通用 ----
# 未知参数: 未知命令或权限不足时的提示
未知参数=§c未知参数或权限不足
# only_player: 仅玩家可用的命令
# only_player=§c此命令仅玩家可用
```
### 2.2.2 挂机白名单.txt
当挂机踢出开启时,该配置下的玩家不受挂机提出影响
### 2.2.3 SMTP设置.txt
此文件控制着SMTP邮件服务器的配置,用于找回密码时发送邮件。<br>可通过GUI进行配置,也可以手动修改此配置文件。修改后,重载插件即可生效
```txt
# ===== SMTP 邮件服务器配置 =====
smtp地址=smtp.example.com
smtp端口=465
smtp账号=mail@example.com
smtp密码=example.com
发件人名称=Sdf1_login
smtp加密=true
验证码接收邮箱=example@example.com
```
### 2.2.4 插件设置.txt
此配置管控这整个插件,包括玩家能注册多少个账号、默认的管理员面板密码多少、tag包含什么视为管理员等
```txt
# ===== Sdf1_login 插件设置 =====
管理标签=admin
管理密码=qweasd
每IP最大账号数=3
审批模式=manual
自动审批延迟分钟=30
登录超时秒数=60
挂机踢出=false
挂机超时秒数=300
服务商积分倍率=2.0
基础经济奖励=100
经济奖励每评分=50.0
报单人积分奖励=10
# ---- 签到盲盒奖励 ----
签到固定金额=0
签到最小金额=10
签到最大金额=100
签到给积分=true
签到积分数量=10
```
### 2.2.5 chat.txt
该文件控制着聊天过滤,当玩家发送违规链接时,会自动根据配置规则进行处理。<br>
可用的处罚规则:
- warn: 警告
- mute: 禁言
- kick: 踢出
- ban: 封禁
- banip:封禁IP
```txt
# Sdf1_chat 聊天过滤配置
通知管理员: true
全服通报: true
禁言时长: 300
白名单:
*.minecraft.net
baidu.com
github.com
处罚规则:
1:warn:0
2:warn:0
3:mute:300
5:mute:600
8:kick:0
10:ban:0
白名单玩家:
```
### 2.2.6 radio_config.txt
```txt
# ============ Sdf1 Radio Config ============
#
# resource-pack-url
# 填外部地址(HTTP或HTTPS均可)
# 例: https://example.com/radiopack.zip
# 留空 = 使用内置HTTP服务
#
# http-port
# 内置HTTP端口(仅resource-pack-url为空时生效)
#
# ============ Settings ===============
resource-pack-url=https://*.ypshidifu.cn/|*.mcserver.ccwu.cc
http-port=443
```
- radio_config这里可以使用通配符域名,当使用通配符时,系统随机生成url,拒绝过资源包的玩家,每次都会收到提示。并且自1.2版本起,拒绝资源包=放弃游玩服务器。如果您无需广播功能,可以不放ogg文件进入`./plugin/radio/ogg`。这样服务端不会自动生成资源包,也不会强制下载了
### 2.2.7 菜单.txt
- 自定义菜单配置文件
```json
# 服务器菜单
# 使用类json格式
// 支持使用://Java单行注释、#yml单行注释、/* */块注释,也支持<!--- --->的块注释
{
标题:主城传送
指令:/dom tp 主城区 # 这里如果是xxx.txt则表示进入二级目录
权限类型:玩家
图标:COMPASS
}
{
标题:随机传送
指令:/rtp
权限类型:玩家
图标:ENDER_PEARL
}
{
标题:擂台比武
指令:/dom tp PVP
权限类型:玩家
图标:netherite_spear
}
{
标题:领地操作
指令:/dom
权限类型:玩家
图标:paper
}
{
标题:逛商店
指令:/dom tp shop
权限类型:玩家
图标:honeycomb
}
{
标题:传送开关
指令:/tptoggle
权限类型:玩家
图标:redstone_torch
}
{
标题:返回上一个位置
指令:/back
权限类型:玩家
图标:red_bed
}
{
标题:前往小黑塔
指令:/dom tp 小黑塔
权限类型:玩家
图标:dragon_head
}
{
标题:云背包
指令:/cy
权限类型:玩家
图标:potato
}
{
标题:自杀
指令:/suicide
权限类型:玩家
图标:wooden_axe
}
{
标题:游戏商人
指令:/shop
权限类型:玩家
图标:shield
格子:28
}
```
### 2.2.8 商品配置
- 配置此处的文件,负责展示游戏内所销售的商品。<br>
- 一个文件名,对应一个商品分类,使用md文件格式
```
# 建筑材料
| ID | 品名 | 材质 | 购入价 | 售出价 | 库存 | 本小时销量 | 总销量 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| STONE | 石头 | STONE | 10 | -1 | -1 | 0 | 0 |
| COBBLESTONE | 圆石 | COBBLESTONE | 5 | -1 | -1 | 0 | 26 |
| OAK_PLANKS | 橡木木板 | OAK_PLANKS | 8 | 3 | -1 | 0 | 0 |
| BRICKS | 砖块 | BRICKS | 12 | -1 | -1 | 0 | 0 |
| GLASS | 玻璃 | GLASS | 15 | 6 | -1 | 0 | 0 |
| QUARTZ_BLOCK | 石英块 | QUARTZ_BLOCK | 20 | 8 | -1 | 0 | 0 |
| AMETHYST_BLOCK | 紫水晶 | AMETHYST_BLOCK | 10 | 0 | -1 | 0 | 999 |
| OCHRE_FROGLIGHT | 赭黄蛙鸣灯 | OCHRE_FROGLIGHT | 100 | 0 | -1 | 0 | 1000 |
```
## 2.3 商城部分介绍
1. 自助退款,买多买错,不用慌,一件退单很安心<br>
2. 购物车,买的多怕买错,先加购物车,后续统一结算<br>
3. 害怕订单找不到?一键查看历史订单<br>
![](img/shop4.png)
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
![](img/shop7.png)<br>
![](img/shop6.png)
5. 想查看玩家消费记录?小手一点,明明白白<br>
![](img/shop5.png)
## 2.4 打印流水部分介绍
1. 管理员怀疑某个玩家刷钱了,怎么办?/printer 玩家名,一检查流水,很安心。很多人都刷了怎么办?/printer不加参数,一键导出全服流水,查他个底朝天<br>
2. 忘记删除怎么办?插件每分钟自动检测,旧文件超过24小时,自动删
## 3. 指令参数及介绍
- Sdf1_login 主指令<br>
- Sdf1_login reload 重载配置文件<br>
- reg 注册账号<br>
- login 登录账号<br>
- l 登录账号<br>
- sdf1_login del 删除玩家账号<br>
- sdf1_login radio 停止播放广播<br>
- sdf1_login radio reload 重载广播并重新打包资源包<br>
- menu 开/关 是否开启菜单<br>
- printer 打印流水数据,支持不带参数&带玩家名为参数<br>
- sdf1_login printer 作用同printer<br>
- sdf1_login shop 商城
- protect 区域防护
## 4. 自定义菜单物品
- 打开主界面,选择"我的"进入<br>
![](img/menu1.png)
- 将你的自定义物品与雪球交换<br>
![](img/menu2.png)
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
## 5. pvp战神榜
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
![](img/pvp1.png)
- 并且有着网游同款的击杀通报<br>
![](img/pvp3.png)
- 播报逻辑是:<br>
- 3杀<br>
- 5杀<br>
- 5杀以上每杀一次都是一条播报<br>
- 终结者通报,触发连杀的玩家被终结也会有全服通报
## 6. 区域防护
- 通过此功能,您可在服务器里需要防护的地方(比如展示框不想被玩家随便破坏、主城不准玩家袭击等)加以防护。<br>
- 还能够保护指定区域的玩家不受任何伤害。<br>
| 指令 | 参数1| 参数2 | 参数3 | 结果 |
| :---: | :---: | :---: | :---: | :---: |
| protect | 无 | 无 | 无 | 帮助信息 |
| protect | add | 区域名 | 玩家名 | 加白指定玩家到区域 |
| protect | add | 玩家名 | 无| 加白指定玩家为全局 |
| protect | additem | 区域名 | 物品名 | 加黑指定物品到区域 |
| protect | additem | 物品名 | 无 | 加黑指定物品到全局 |
| protect | addname | 区域名 | 名字 | 加白指定生物名字到区域和平模式白名单 |
| protect | addplayer | 区域名 | 玩家名字 | 加白指定玩家到区域强制模式豁免名单 |
| protect | remove | 玩家名字 | 无 | 移除玩家为全白 |
| protect | remove | 区域名 | 玩家名 | 移除玩家为区白 |
| protect | removeitem | 物品名 | 无 | 删除指定物品为全黑
| protect | removeitem | 区域名 | 物品名 | 删除指定物品为区黑
| protect | removename | 区域名 | 名字 | 删除指定名字为区域和平模式白名单 |
| protect | removeplayer | 区域名 | 名字 | 删除指定玩家为强制模式豁免名单 |
| protect | 参数不分顺序 | 区名可以写这里也可以写后面 | 玩家名可以写这里也可以写前面 | 完美的宝宝化 |
示范:
- `/protect add 末地保护区 players` 添加players玩家到末地保护区<br>
- `/protect removeitem 主城区 TNT` 从主城删了TNT这个黑名单物品<br>
- `/protect 筑城区 addwhite zhangsan` 把zhangsan加白到主城区的模式豁免名单。<br>
- 所有参数`任意排序`,`区域名字`随便填写,强大的`兜底`会自动识别"宝宝画板",你可以放心、大胆的乱写
| 参数 | 效果 |
| :---: | :---: |
| 和平模式 | 所有敌对生物全部进虚空 |
| 禁止袭击 | 立即清理玩家身上的袭击效果,传灾厄村民进虚空 |
| 清除XX效果 | 立即清除玩家身上的效果
| 给与xx效果 | 根据配置文件,给予玩家指定等级、时长的效果
## 生物属性对照表
| 中文名称 | 类型 | 附加属性 |
| :---: | :----: | :----: |
| 溺尸 | 近战/远程 | 亡灵、水生 |
| 骷髅 | 远程 | 亡灵 |
| 流浪者 | 远程 | 亡灵、寒冷群系变种 |
| 沼骸 | 远程 | 亡灵、沼泽变种 |
| 焦骸 | 远程 | 亡灵、沙漠变种、免疫阳光 |
| 掠夺者 | 远程 | 灾厄村民、袭击成员 |
| 卫道士 | 近战 | 灾厄村民、袭击成员 |
| 幻术师 | 远程 | 灾厄村民、Java专属、隐身分身 |
| 旋风人 | 远程 | 试炼密室生成、免疫弹射物、强击退 |
| 监守者 | 近战/声波远程 | 深暗之域、附带盲视效果 |
| 远古守卫者 | 近战/激光远程 | 海底神殿、水下、附加挖掘疲劳 |
| 守卫者 | 近战/激光远程 | 海底神殿、水下 |
配置文件解析:<br>
- 支持注释格式:`Java的//单行注释`、`yml的#单行注释`、`HTML的<!--- --->注释`和`Java/* */块注释`
```txt
# 区域: 主城区
起点: world,-130,-713
终点: world,-427,-560
高度范围: 0-255
没收物品: 蘑菇,毒蘑菇 #没收哪些物品(末地推荐这两个)
// 禁止放置方块 #保护区内,非区白/全白玩家禁止放置方块
// 禁止破坏方块 # 同上
禁止PVP # 玩家之间不能PVP
禁止摔伤 # 摔落无法造成任何伤害
禁止饥饿 # 不会消耗饱食度
禁止一切伤害 # 任何怪物都无法造成伤害
# 禁止丢弃物品
# 禁止末影珍珠
# 禁止使用弓箭
# 禁止骑乘
// 和平模式 // 所有的敌对生物全部传送进虚空
禁止袭击
禁止交互展示框 # 核心,任何玩家不能触碰展示框。区白/全白玩家除外
// 清除所有效果 # 清理所有负面效果
效果 村庄英雄 255 999999 # 给予玩家效果。格式为: 效果 效果名字 等级(最高255) 时间
效果 生命提升 255 999999
效果 抗性提升 255 999999
效果 抗火 255 999999
# 禁止药水
禁止爆炸
禁止使用物品: TNT
# 效果: 夜视 1 999
进入提示: 欢迎来到保护区 # 是否开启进入提示,即自定义提示
离开提示: 已离开保护区
没收提示: 你的违禁品已被没收
通报批评: §c§l{player} §f在{area}违规携带违禁品,被没收 §e{count}§f件! # 通报违禁品。{player} 玩家名 {count}数量 {area}区域
# 惩罚命令: ban {player} 60s 违规
```
## 运行截图
主界面
![](img/login.png)
邀请数据
![](img/login2.png)
积分商店
![](img/login3.png)
任务中心
![](img/login4.png)
垃圾箱
![](img/login5.png)
管理员面板
![](img/login6.png)
管理面板-用户管理
![](img/login7.png)
工单中心
![](img/login8.png)
工单管理
![](img/login9.png)
我的工单
![](img/login10.png)
提交工单
![](img/login11.png)
服务商列表
![](img/login12.png)
服务商抢单大厅
![](img/login13.png)
服务商-接单界面
![](img/login14.png)
管理员-工单详情页面
![](img/login15.png)
商城主界面
![](img/shop1.png)
商城购物车
![](img/cart1.png)
商城退单
![](img/shop2.png)
## 4. 下载地址
- GitHub:[https://github.com/ypsdf1/sdf1_login](https://github.com/ypsdf1/sdf1_login)
- Gitee:[https://gitee.com/nihaoshidifu/sdf1_login](https://gitee.com/nihaoshidifu/sdf1_login)
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>sdf1</groupId>
<artifactId>Sdf1_login</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21.4-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.MilkBowl</groupId>
<artifactId>VaultAPI</artifactId>
<version>1.7.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>Sdf1_login</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,197 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.*;
public class AFKManager {
// 玩家最后活动时间(空闲才开始计时)
private final Map<UUID, Long> lastAction =
new HashMap<>();
// 每个玩家当前的警告阶段
private final Map<UUID, Integer> lastStage =
new HashMap<>();
// 是否已进入挂机检测(false=还在活动,不计时)
private final Map<UUID, Boolean> afkTracking =
new HashMap<>();
private final ConfigManager config;
private final JavaPlugin plugin;
public AFKManager(ConfigManager config,
JavaPlugin plugin) {
this.config = config;
this.plugin = plugin;
}
public void startCheck() {
new BukkitRunnable() {
public void run() {
tick();
}
}.runTaskTimer(plugin, 20L, 20L);
}
private void tick() {
if (!config.afkEnabled) return;
long now = System.currentTimeMillis();
for (Player p : Bukkit.getOnlinePlayers()) {
String name = p.getName();
UUID uuid = p.getUniqueId();
if (!isLoggedIn(name)) continue;
if (config.getAfkWhitelist().contains(name))
continue;
// 确保有记录
if (!lastAction.containsKey(uuid)) {
lastAction.put(uuid, now);
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
continue;
}
long last = lastAction.get(uuid);
long idle = (now - last) / 1000;
int timeout = config.afkTimeout;
// 还在活动范围内:不进入挂机检测
if (idle < 60) {
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
continue;
}
// 超过60秒无活动:开始挂机检测
afkTracking.put(uuid, true);
long remain = timeout - idle;
// 已超时:踢出
if (remain <= 0) {
p.kickPlayer("§c§l你已挂机 "
+ fmtSec(idle) + ",被踢出");
lastAction.remove(uuid);
lastStage.remove(uuid);
afkTracking.remove(uuid);
continue;
}
int stage = lastStage.getOrDefault(uuid, 0);
// 阶段3:最后10秒
if (remain <= 10) {
if (stage < 3) {
lastStage.put(uuid, 3);
}
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§c还剩 " + remain + " 秒踢出",
0, 22, 3);
playSound(p);
continue;
}
// 阶段2:1分钟~10秒
if (remain <= 60) {
if (stage < 2) {
lastStage.put(uuid, 2);
p.sendMessage(
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
+ fmtSec(idle)
+ "§f,还剩 §c"
+ fmtSec(remain)
+ "§f踢出");
}
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§c还剩 " + fmtSec(remain) + " 踢出",
5, 25, 5);
playSound(p);
continue;
}
// 阶段1:3分钟提醒
if (remain <= 180 && stage < 1) {
lastStage.put(uuid, 1);
p.sendMessage(
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
+ fmtSec(idle)
+ "§f,还剩 §c"
+ fmtSec(remain)
+ "§f踢出");
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§f还剩 " + fmtSec(remain) + " 踢出",
10, 60, 20);
for (int i = 0; i < 3; i++) {
final int delay = i * 10;
Bukkit.getScheduler().runTaskLater(
plugin, () -> {
if (p.isOnline()) playSound(p);
}, delay);
}
}
if (remain > 180) {
lastStage.remove(uuid);
}
}
}
/**
* 玩家有活动时调用(移动/交互/放置/挖掘等)
* 重置计时,退出挂机检测
*/
public void recordAction(UUID uuid) {
lastAction.put(uuid, System.currentTimeMillis());
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
}
/**
* 玩家下线时调用
* 彻底停止计时,清理所有数据
*/
public void remove(UUID uuid) {
lastAction.remove(uuid);
lastStage.remove(uuid);
afkTracking.remove(uuid);
}
/**
* 获取玩家当前是否在挂机检测中
*/
public boolean isAfkTracking(UUID uuid) {
return afkTracking.getOrDefault(uuid, false);
}
private boolean isLoggedIn(String name) {
return ((Main) plugin).getLoggedIn()
.contains(name);
}
private void playSound(Player p) {
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_CHIME,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_BASS,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING,
1.0f, 0.79f);
}
private String fmtSec(long sec) {
if (sec < 60) return sec + "秒";
long m = sec / 60;
long s = sec % 60;
return m + "分" + (s > 0 ? s + "秒" : "");
}
}
@@ -0,0 +1,146 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class AccountRequestManager {
public enum ApprovalMode { AUTO, MANUAL, HYBRID }
public static class Request {
public final int id;
public final String requester;
public final String target;
public final String ip;
public final long time;
public String status;
public String denyReason;
public Request(int id, String requester, String target, String ip) {
this.id = id;
this.requester = requester;
this.target = target;
this.ip = ip;
this.time = System.currentTimeMillis();
this.status = "PENDING";
this.denyReason = "";
}
}
private final Main plugin;
private final Map<Integer, Request> requests = new ConcurrentHashMap<>();
private final Map<String, Integer> pendingByPlayer = new ConcurrentHashMap<>();
private int nextId = 1;
private ApprovalMode mode = ApprovalMode.MANUAL;
private int autoDelayMinutes = 60;
public AccountRequestManager(Main plugin) {
this.plugin = plugin;
}
public ApprovalMode getMode() { return mode; }
public void setMode(String m) {
try { this.mode = ApprovalMode.valueOf(m.toUpperCase()); }
catch (Exception e) { this.mode = ApprovalMode.MANUAL; }
}
public void setAutoDelayMinutes(int min) {
this.autoDelayMinutes = min;
}
public Request createRequest(String requester, String target, String ip) {
int id = nextId++;
Request req = new Request(id, requester, target, ip);
requests.put(id, req);
pendingByPlayer.put(requester, id);
if (mode == ApprovalMode.AUTO) {
scheduleAutoApprove(id);
} else if (mode == ApprovalMode.HYBRID) {
String targetIp = (String) plugin.getDb().getField(target, "ip_address");
if (ip != null && ip.equals(targetIp)) {
scheduleAutoApprove(id);
}
}
return req;
}
private void scheduleAutoApprove(int id) {
long delayTicks = (long) autoDelayMinutes * 60L * 20L;
Request req = requests.get(id);
if (req == null) return;
String requesterName = req.requester;
String targetName = req.target;
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 将在 " + autoDelayMinutes + " 分钟后自动通过");
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Request r = requests.get(id);
if (r != null && "PENDING".equals(r.status)) {
approveRequest(id);
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 自动通过");
Player requester = Bukkit.getPlayer(requesterName);
if (requester != null && requester.isOnline()) {
requester.sendMessage("§a[Sdf1_login] §f您的删除请求 #" + id + "(§e" + targetName + "§a)已自动通过!");
}
}
}, delayTicks);
}
public boolean approveRequest(int id) {
Request req = requests.get(id);
if (req == null || !"PENDING".equals(req.status)) return false;
req.status = "APPROVED";
plugin.getDb().deleteUser(req.target);
pendingByPlayer.remove(req.requester);
// 清背包+传送出生点+踢出
Player target = Bukkit.getPlayer(req.target);
if (target != null && target.isOnline()) {
target.getInventory().clear();
target.getInventory().setArmorContents(new org.bukkit.inventory.ItemStack[4]);
target.getInventory().setExtraContents(new org.bukkit.inventory.ItemStack[1]);
target.teleport(target.getWorld().getSpawnLocation());
target.kickPlayer("§c§l你的账号已被管理员删除,请重新注册");
}
return true;
}
public boolean denyRequest(int id, String reason) {
Request req = requests.get(id);
if (req == null || !"PENDING".equals(req.status)) return false;
req.status = "DENIED";
req.denyReason = reason;
pendingByPlayer.remove(req.requester);
return true;
}
public List<Request> getPendingRequests() {
List<Request> list = new ArrayList<>();
for (Request r : requests.values()) {
if ("PENDING".equals(r.status)) list.add(r);
}
return list;
}
public Request getRequest(int id) {
return requests.get(id);
}
public boolean hasPending(String player) {
return pendingByPlayer.containsKey(player);
}
public void cancelPending(String player) {
Integer id = pendingByPlayer.remove(player);
if (id != null) {
Request r = requests.get(id);
if (r != null) r.status = "CANCELLED";
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,697 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.File;
import java.sql.*;
import java.util.*;
public class BondManager {
private final Main plugin;
private Connection db;
// ===== 交易类型常量 =====
public static final String TX_ADMIN_GIVE = "admin_give";
public static final String TX_ADMIN_DEDUCT = "admin_deduct";
public static final String TX_TRANSFER_OUT = "transfer_out";
public static final String TX_TRANSFER_IN = "transfer_in";
public static final String TX_REDEEM = "redeem";
public static final String TX_SHOP_BUY = "shop_buy";
public static final String TX_DAILY_SIGN = "daily_sign";
public static final String TX_FREEZE = "freeze";
public static final String TX_UNFREEZE = "unfreeze";
public static final int MAX_WATER_DAYS = 14;
public static final int ADMIN_MAX_WATER_DAYS = 30;
public BondManager(Main plugin) {
this.plugin = plugin;
initDB();
}
// ======================== 数据库初始化 ========================
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(), "bond.db");
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
// 债券余额(含冻结状态)
st.execute("CREATE TABLE IF NOT EXISTS bonds ("
+ "player_name TEXT PRIMARY KEY,"
+ "amount INTEGER DEFAULT 0,"
+ "status TEXT DEFAULT 'normal')");
// 兼容旧表
try {
st.execute("ALTER TABLE bonds "
+ "ADD COLUMN status "
+ "TEXT DEFAULT 'normal'");
} catch (SQLException ignored) {}
// 旧流水(保留兼容)
st.execute("CREATE TABLE IF NOT EXISTS bond_log ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "player_name TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "reason TEXT DEFAULT '',"
+ "time INTEGER DEFAULT 0)");
// 新交易流水表
st.execute("CREATE TABLE IF NOT EXISTS "
+ "bond_transaction ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "player_name TEXT NOT NULL,"
+ "type TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "target_player TEXT DEFAULT '',"
+ "operator TEXT DEFAULT '',"
+ "reason TEXT DEFAULT '',"
+ "balance_after INTEGER DEFAULT 0,"
+ "time INTEGER DEFAULT 0)");
try {
st.execute("CREATE INDEX "
+ "IF NOT EXISTS idx_tx_pt "
+ "ON bond_transaction("
+ "player_name, time)");
} catch (SQLException ignored) {}
try {
st.execute("ALTER TABLE bond_transaction "
+ "ADD COLUMN balance_before "
+ "INTEGER DEFAULT 0");
} catch (SQLException ignored) {}
st.close();
} catch (Exception e) {
throw new RuntimeException(
"[Bond] DB init failed: "
+ e.getMessage(), e);
}
}
// ======================== 账户状态 ========================
public boolean isFrozen(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return "frozen".equals(s);
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return false;
}
public String getAccountStatus(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return s != null ? s : "normal";
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return "normal";
}
public void freezeAccount(String player) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='frozen' "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
}
public void unfreezeAccount(String player) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='normal' "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
}
private void ensureAccount(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR IGNORE INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,0,'normal')");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException ignored) {}
}
// ======================== 余额查询 ========================
public int getBonds(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT amount FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
int v = 0;
if (rs.next()) v = rs.getInt("amount");
rs.close(); ps.close();
return v;
} catch (SQLException e) { return 0; }
}
/** 该玩家是否在 bond 表中有真实记录 */
public boolean hasBondAccount(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM bonds "
+ "WHERE player_name=? "
+ "AND amount > 0");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
boolean exists = rs.next();
rs.close(); ps.close();
return exists;
} catch (SQLException e) {
return false;
}
}
// ======================== 增加债券 ========================
public int addBonds(String player, int amount) {
return addBondsInternal(player, amount,
TX_ADMIN_GIVE, "", "",
"管理员发放");
}
public int addBonds(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
if (isFrozen(player)) return -1;
return addBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
private int addBondsInternal(String player,
int amount,
String type,
String targetPlayer,
String operator,
String reason) {
int before = getBonds(player);
int after = before + amount;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status)"
+ " VALUES (?,?, 'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, after);
ps.setInt(3, after);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
logTransaction(player, type, amount,
targetPlayer, operator, reason,
before, after);
logLegacy(player, amount);
return after;
}
// ======================== 扣除债券 ========================
private boolean deductBondsInternal(String player,
int amount,
String type,
String targetPlayer,
String operator,
String reason) {
int before = getBonds(player);
if (before < amount) return false;
int after = before - amount;
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET amount=? "
+ "WHERE player_name=?");
ps.setInt(1, after);
ps.setString(2, player);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
logTransaction(player, type, amount,
targetPlayer, operator, reason,
before, after);
logLegacy(player, -amount);
return true;
}
// ======================== 流水写入 ========================
private void logTransaction(String player,
String type,
int amount,
String targetPlayer,
String operator,
String reason,
int balanceBefore,
int balanceAfter) {
try {
// 合并模式:shop_buy 30秒内同商品合并
if ("shop_buy".equals(type)
&& reason != null
&& !reason.isEmpty()) {
long mergeWindow =
System.currentTimeMillis() - 30000L;
PreparedStatement chk =
db.prepareStatement(
"SELECT id, amount "
+ "FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND type=? "
+ "AND reason=? "
+ "AND time > ? "
+ "ORDER BY time DESC "
+ "LIMIT 1");
chk.setString(1, player);
chk.setString(2, type);
chk.setString(3, reason);
chk.setLong(4, mergeWindow);
ResultSet rs = chk.executeQuery();
if (rs.next()) {
int existId = rs.getInt("id");
int existAmt = rs.getInt("amount");
rs.close();
chk.close();
PreparedStatement upd =
db.prepareStatement(
"UPDATE bond_transaction "
+ "SET amount=?, "
+ "balance_after=?, "
+ "time=? "
+ "WHERE id=?");
upd.setInt(1, existAmt + amount);
upd.setInt(2, balanceAfter);
upd.setLong(3,
System.currentTimeMillis());
upd.setInt(4, existId);
upd.executeUpdate();
upd.close();
return;
}
rs.close();
chk.close();
}
// 原始插入
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bond_transaction "
+ "(player_name, type, amount,"
+ " target_player, operator,"
+ " reason, balance_before,"
+ " balance_after, time)"
+ " VALUES (?,?,?,?,?,?,?,?,?)");
ps.setString(1, player);
ps.setString(2, type);
ps.setInt(3, amount);
ps.setString(4,
targetPlayer != null ? targetPlayer : "");
ps.setString(5,
operator != null ? operator : "");
ps.setString(6,
reason != null ? reason : "");
ps.setInt(7, balanceBefore);
ps.setInt(8, balanceAfter);
ps.setLong(9, System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
// ======================== 扣除债券 ========================
/** 完整版:含冻结检查 + 流水记录 */
public boolean deductBonds(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
if (isFrozen(player)) return false;
return deductBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
public boolean checkoutCart(String player,
String[] itemNames,
int[] subtotals) {
int total = 0;
for (int s : subtotals) total += s;
if (isFrozen(player)) return false;
int before = getBonds(player);
if (before < total) return false;
int after = before - total;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,?,'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, after);
ps.setInt(3, after);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
return false;
}
int running = before;
for (int i = 0; i < itemNames.length; i++) {
int next = running - subtotals[i];
logTransaction(player, "shop_buy", subtotals[i],
"", "商店系统",
"购物车购买-" + itemNames[i],
running, next);
running = next;
}
logLegacy(player, -total);
return true;
}
public boolean setBonds(String player, int amount) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,?, 'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, amount);
ps.setInt(3, amount);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return true;
}
// ======================== 转账 ========================
public TransferResult transfer(String from,
String to,
int amount) {
if (from.equalsIgnoreCase(to))
return new TransferResult(false,
"不能给自己转账");
if (isFrozen(from))
return new TransferResult(false,
"你的账户已被冻结");
if (isFrozen(to))
return new TransferResult(false,
to + " 的账户已被冻结");
if (isSameIP(from, to))
return new TransferResult(false,
"不能与同IP玩家转账");
if (!deductBondsInternal(from, amount,
TX_TRANSFER_OUT, to, from,
"转账给" + to))
return new TransferResult(false, "债券不足");
addBondsInternal(to, amount,
TX_TRANSFER_IN, from, from,
"收到来自" + from + "的转账");
return new TransferResult(true, "转账成功");
}
// ======================== 同IP检测 ========================
public boolean isSameIP(String p1, String p2) {
String ip1 = getPlayerIPAny(p1);
String ip2 = getPlayerIPAny(p2);
if (ip1 == null || ip2 == null) return false;
return ip1.equals(ip2);
}
/** 获取同IP下的所有其他在线玩家 */
public List<String> getSameIPPlayers(String player) {
String ip = getPlayerIPAny(player);
if (ip == null) return Collections.emptyList();
List<String> list = new ArrayList<>();
for (Player p :
plugin.getServer().getOnlinePlayers()) {
String pIp = plugin.getPlayerIP(p);
if (pIp != null && pIp.equals(ip)
&& !p.getName()
.equalsIgnoreCase(player)) {
list.add(p.getName());
}
}
return list;
}
private String getPlayerIPAny(String player) {
// 在线:直接取
Player p = plugin.getServer()
.getPlayerExact(player);
if (p != null) {
String ip = plugin.getPlayerIP(p);
if (ip != null) return ip;
}
// 离线:查主库
try {
Object v = plugin.getDb()
.getField(player, "last_login_ip");
if (v != null) return v.toString();
} catch (Exception ignored) {}
try {
Object v = plugin.getDb()
.getField(player, "register_ip");
if (v != null) return v.toString();
} catch (Exception ignored) {}
return null;
}
// ======================== 流水查询 ========================
public List<Map<String, Object>> getTransactions(
String player, int days) {
days = Math.min(days, MAX_WATER_DAYS);
long cutoff = System.currentTimeMillis()
- (long) days * 24 * 60 * 60 * 1000;
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND time>=? "
+ "ORDER BY time DESC");
ps.setString(1, player);
ps.setLong(2, cutoff);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("operator",
rs.getString("operator"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
// 放在 getTransactions 方法后面:
/**
* 获取该玩家全部流水(不限时间,供打印使用)
*/
public List<Map<String, Object>>
getAllTransactions(String player) {
List<Map<String, Object>> list =
new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "ORDER BY time ASC");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("player_name",
rs.getString("player_name"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("operator",
rs.getString("operator"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
// 放在 getAllTransactions 方法后面:
/**
* 获取所有有流水记录的玩家名(去重)
*/
public List<String> getAllPlayerNames() {
List<String> list = new ArrayList<>();
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT DISTINCT player_name "
+ "FROM bond_transaction "
+ "ORDER BY player_name ASC");
while (rs.next()) {
list.add(rs.getString("player_name"));
}
rs.close(); st.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
public List<Map<String, Object>>
getTransferTransactions(
String player, int days) {
days = Math.min(days, MAX_WATER_DAYS);
long cutoff = System.currentTimeMillis()
- (long) days * 24 * 60 * 60 * 1000;
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND time>=? "
+ "AND (type=? OR type=?) "
+ "ORDER BY time DESC");
ps.setString(1, player);
ps.setLong(2, cutoff);
ps.setString(3, TX_TRANSFER_OUT);
ps.setString(4, TX_TRANSFER_IN);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
private void logLegacy(String player, int amount) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bond_log "
+ "(player_name,amount,time) "
+ "VALUES (?,?,?)");
ps.setString(1, player);
ps.setInt(2, amount);
ps.setLong(3,
System.currentTimeMillis());
ps.executeUpdate(); ps.close();
} catch (SQLException ignored) {}
}
public Map<String, Object> getLog(
String player, int limit) {
Map<String, Object> info = new LinkedHashMap<>();
info.put("balance", getBonds(player));
return info;
}
// ======================== 转账结果 ========================
public static class TransferResult {
public final boolean success;
public final String message;
public TransferResult(boolean success,
String message) {
this.success = success;
this.message = message;
}
}
}

Some files were not shown because too many files have changed in this diff Show More