Compare commits

...
661 Commits
Author SHA1 Message Date
youpaishidifu cbfdfb016b 三库归位登录凭证/用户组/Php,处罚预告按规则档位修正,解禁与重置不再动人机验证
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1) login.db 创建即入 登录凭证/、usergroup.db 入 用户组/、web_sync.db 入 Php/(WebManager 5处统一走 webSyncDbFile()),根目录存量由 migrateRootDataFiles 启动最早期整组迁移(.db+-shm+-wal 同进同退,失败整组回滚);2) nextPunishHint 改为按 applyPunish同款判据(violation>=阈值取最高档)计算下一次违规真正命中的档位与配置时长,修复旧实现取 k>违规数 导致第3次违规就预告第6档15分钟、预告与实际处罚长期对不上的问题;3) unmutePlayer/resetPlayer 移除 markReverify 调用,解禁与重置发言违规不再重置人机验证(resetPlayer 顺带清 DB muted_until,避免重置后重启禁言复活)
2026-10-08 18:57:51 +08:00
youpaishidifu 47301edaed 归位迁移改为sqlite三件套原子搬移,失败整组回滚
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
quest_data.db/bond.db/garbage.db/pvp.db 都是 WAL 模式 sqlite,运行时
-shm/-wal 与主库必须同进同退:单搬主库会丢未checkpoint事务导致数据回退,
单搬wal缺主库直接损坏。原实现逐文件rename,崩在中间会留下破损三件套。

改为每个 db 一组,主库/-shm/-wal 整体搬,任一失败立即把已搬的搬回原位;
目标端已存在则整组跳过不合并。创建时路径直接指向子目录(QuestStorage/
PlayerStorage 已改),迁移只兜存量。
2026-10-08 17:49:02 +08:00
youpaishidifu 0ab7c7052c 修复违规计数文案歧义、验证码重验回归、quest_data.db归位
1. 违规计数订正:实际计数一直是1→2→3(incrementViolation是+1),截图显示的
   1/3/6三角数是文案歧义——nextPunishHint返回的第N次违规预告与命中提示的第N次
   违规两行并列,被读成跳号。现改为预告只报处罚档位不报次数,命中提示改为
   「本次为第N次违规」,次数全局只出现一次。

2. 验证码回归修复:isMuted()禁言到期自动解禁时调用markReverify(),把已通过
   人机验证的玩家踢出verifiedPlayers,导致「验证码通过→被禁言→到期解禁→又要
   重验」的死循环。按用户裁定改为:到期自动解禁不再吊销验证状态,仅显式
   /chat unmute 与 /chat reset 才吊销。

3. quest_data.db 归位任务面板/:QuestStorage与PlayerStorage两处硬编码路径
   改为子目录并自动建目录,migrateRootDataFiles() 增加 db/-shm/-wal 三件套
   幂等迁移(启动最早期执行)。
2026-10-08 17:44:15 +08:00
youpaishidifu 19e03c5fb4 修复三bug并归位根目录5类文件:①禁言解除后强制重新人机验证(解禁≠解除人机验证,到期/命令解禁/重置均打reverify标记,答对清除) ②OP白名单过期条目不再补授(修复26-10-08到期后每分钟撤销↔授予无限横跳) ③敏感词命中改为明确反馈(控制台日志+玩家提示+下一档处罚预告,修复启用过滤配置解析失效) ④bond.db/chat.txt/garbage.db/maxsell_config.txt/pvp.db启动最早期自动迁入对应子文件夹
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 17:02:02 +08:00
youpaishidifu 58f9a756e9 调整.gitignore拉黑规则
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-08 16:20:58 +08:00
youpaishidifu bdd55cc9bf 将opwl.json移入resources目录,随jar一起释放。移除根目录的opwl.json暂存
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-08 16:15:06 +08:00
youpaishidifu 3217e53cda 补充opwl.json使用说明;注释停用/menu命令注册
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m27s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-08 15:41:49 +08:00
youpaishidifu 881fb85408 修复三处bug:时间解析支持26-10-08 14:43短横线格式、禁言期间屏蔽一切私信收发、URL拦截baidu . com空格变形绕过 2026-10-08 15:41:46 +08:00
youpaishidifu 581475e760 opwl事故修复三连: ①ops.json真实字段是uuid(旧代码只认id致复原全靠猜名字) ②复原的OP自动写回白名单(否则同一轮巡检把刚复原的11人又当非法持有全撤+报警) ③配置丢失/解码失败自动隔离坏文件(改名不删)+从jar释放默认配置+复原OP备份(热重载与/opwl reload同生效) ④补授从仅在线扩展到在线+离线(白名单11人但ops.json为空的根因)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-08 14:01:34 +08:00
youpaishidifu 32fe81f908 补单提速第二轮+控台指令最小化信息透露
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
问题1 补单10秒才抓到单(根因两条):
- 线程错位: TimerA-周期批处理跑在sdf1-db-worker上, triggerFastPoller在该线程同步doGet,
  撞sendHard的10秒硬超时 -> 每次补单固定10秒且返回null, 还吃光DB队列整轮预算;
- 节奏不符设计: 定时器是固定5秒而非0~5秒随机, 4秒限流又把随机节奏里<4秒的轮次整轮吞掉。
修法: 定时器改0~100tick随机(nextPollerDelayTicks); 限流4000ms->500ms;
HTTP挪到补单专用单线程pollerExecutor(fire-and-forget, 队列容量1+丢弃);
补单改8秒短超时POLLER_TIMEOUT_S; 本轮processed>0立刻pullPendingTransactions入账;
shutdown补关pollerExecutor。

问题2 conmsg/opwl对玩家泄露"仅控制台可用/需要控台权限":
从plugin.yml摘掉注册 -> 玩家敲它必然落进UnknownCommandEvent, 与"不存在的指令"
走完全相同路径(压制原生unknown报错+相似建议候选里永远没有这俩);
控制台由onUnknownCommand里的dispatchConsoleOnlyCommand就地接管执行,
onCommand里的opwl/conmsg分支改为兜底且玩家侧一律静默。
2026-10-08 12:45:36 +08:00
youpaishidifu 31f2a60096 opwl扫描点订正: 心跳日志改为无更新就闭嘴(仅重载发生或统计快照变化才打印) + 修正扫描周期9000tick(实为7.5分钟)→18000tick真正15分钟,消除非整点多余冒泡
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m27s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 11:16:16 +08:00
youpaishidifu cd0a45d284 opwl数据安全+补单提速: 解析失败卸载OP前先备份原版ops.json(备份失败则不卸载,恢复时复原,pickJson排除ops.json*) + 补单触发限流15s→4s并新增独立5秒定时器(不受锁库退避/队列积压影响)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-08 11:04:53 +08:00
youpaishidifu e2447f0b95 opwl第四轮: 修临时OP自动续期(存exp:绝对到期时间)+上下任提示当事人(在线直发/离线补发)+/op按启用状态分支(true全拦/false仅注册校验)+扫描点心跳日志+新增opwl check子命令
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m59s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 00:45:30 +08:00
youpaishidifu ca1fe6f252 opwl第二轮: 整点扫描(:15/:30/:45)替代纯15秒应用 + 15秒文件热重载保留 + 控制台/op拦截引导opwl add + add/addtime注册检查(非注册引导改json强制授权)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 00:18:21 +08:00
youpaishidifu 84285e5009 opwl改回独立目录OP白名单/(目录隔离,任意json都当配置;模板resources/OP白名单/opwl.json)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-07 23:58:12 +08:00
youpaishidifu a39e26caf7 opwl接管原生OP名单:加载时导入原生OP,非法持有一律撤销+报警(新增非法持有报警信息模板,支持user/time变量中英文),改为单文件opwl.json
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 23:05:29 +08:00
youpaishidifu d19ecc5ce0 新增OP白名单功能(opwl):读OP白名单目录json授OP,读失败一次性卸载全部OP,15秒增量热重载,命令仅控台
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 22:09:01 +08:00
youpaishidifu e745010220 修复pushShopCatalog对账后回退本地hash翻案:对账needPush=false直接回,不再整包推975项
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 18:02:31 +08:00
youpaishidifu a580fbbcb7 领地/商城目录接入alignGate差集对账,重启不再整包推,多退少补
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m18s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 17:32:15 +08:00
youpaishidifu dfdb951c8a 修复bans收回死循环:已过期临时封禁不再拉回Bukkit,避免每轮extra=1无限收回1条
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m8s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 16:21:40 +08:00
youpaishidifu f3a987dde9 修复setField对null写成字面量null导致credentials每轮恒定补推1:改setNull并启动自愈清理存量脏数据
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m17s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-07 13:47:56 +08:00
youpaishidifu ed558517ba 修复credentials对账恒changed全量:凭证行补指纹键password_salt/temp_password,推送改对账差集不再整包硬推611条
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m56s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 12:07:25 +08:00
youpaishidifu 9f8a881d62 IP同步改为只推对账差集,删除全量硬推;补上users/bonds/shop三处补推守卫
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- syncAllPlayerIps 去掉 forceFullIpPush:以前只要对账发现缺口就跳过 ipCache
  过滤、把本机全部IP整包推给PHP(生产实测 missing=0/changed=110 却推了609条),
  现在只推 alignGate 裁决出的 pushKeys(missing + 本机改过的 changed);
  对账没做成时本轮不推不收,绝不回退全量硬推。
- 日志改为「对账补推 N/M 个玩家IP(PHP 侧共 X 条)」,与实际发送量一致。
- shop/bonds/users 三处补上 alignNeedsPush 守卫:PHP 库被清空但本地数据没变时,
  本地 hash 会永久短路补推(与 10-06 修过的 admins/providers/bans/credentials 同款 bug)。
2026-10-07 11:17:09 +08:00
youpaishidifu d0d3b48000 临时屏蔽一下没做完的功能和配置文件
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-07 00:21:41 +08:00
youpaishidifu d8d8c18d53 修复配置类对账推送在404后永久卡死:5处hash改为推送成功后才提交
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
灾备演练(删PHP库+404+空白库重建)实测发现:
- admins/providers/credentials/bans/lands 5处 lastXxxHash 在HTTP推送前就提交
- 404期间唯一一次推送失败,hash却已写脏
- 之后 alignGate 每轮判「PHP缺N条要补推」,全被本地hash短路挡回
- 表现为日志反复打「补推N」却永远发不出去,PHP端表永久0行
  (web_plugin_admins/web_service_providers/weblogin_credentials/web_player_bans/web_area_lands)

修复:
1. 对账明确 needPush 时本地hash不得拦截(alignNeedsPush 绕过短路)
2. hash 一律改为 PHP 明确返回 success=true 后才提交
   与 syncUserRegistrations(2026-10-04)/syncShopData/syncBonds 的既有规矩对齐

交易流水多退少补在本次演练中表现正常:空白库→443/443全量补推成功
2026-10-06 23:00:27 +08:00
youpaishidifu c118e8e708 周期对账通过时也打印两边笔数,让对齐结论随时可验证
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m17s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-06 21:50:01 +08:00
youpaishidifu 07d0a5a192 交易流水补运行期周期对账,修复PHP库被重载清空后的假对齐
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-06 21:25:13 +08:00
youpaishidifu 9c2d5db41e 充值回执对账排进首次全量同步队列:无人在线时启动即点差集,不再只依赖玩家上线的周期批次
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-06 19:58:08 +08:00
youpaishidifu 64cb501321 充值回执一式两份对账:Java持web_tx_receipts镜像,随全量同步多退少补热回补PHP缺失流水
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m17s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-06 18:18:00 +08:00
youpaishidifu e5e460ce20 WebManager:超时日志节流降噪、连接4秒判死换连接重试、对账无答复一律不推不收、system/sync token软硬双到期异步续期
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-06 15:14:29 +08:00
youpaishidifu 19aecebc90 WebManager:修正DB队列硬超时线程标记错位,对账硬超时不回退全量硬推,加队列节奏与商品目录hash静默
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-06 13:12:15 +08:00
youpaishidifu 53cadf2282 WebManager:DB队列HTTP改10秒硬超时并移交旁路线程放行,任务加10秒预算防堵门
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-06 12:06:31 +08:00
youpaishidifu ec4bd415d2 双向对账改造:全量/周期同步与PHP指纹对账多退少补,null整数列归一防每轮changed
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 22:29:50 +08:00
youpaishidifu 6a2bfaf1ff 修复开关关闭时插件设置.txt热重载永不触发:start()不再因未启用而跳过启动合并定时器
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 18:58:23 +08:00
youpaishidifu 5d89ff54c9 修复交易推流死等:批调度毫秒误当tick(15秒被排成12.5分钟);改单次15秒必答复三态日志;轮次卡死自愈不需手动reload;双向对账多退少补(PHP缺的补推、PHP多的收回导入本地)
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 17:46:34 +08:00
youpaishidifu ebb17d69bf 交易推流加15秒回执窗口:收到PHP回执才推下一批,未收到则等15秒重试同一批(带20次上限),批调度纳入锁库退避
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m30s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 17:00:25 +08:00
youpaishidifu 478aa97ca8 修复两处同步缺陷:parseJson漏嵌套对象分支致流水对齐恒判PHP无响应;热重载/reload后排全量同步
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m36s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 15:44:02 +08:00
youpaishidifu 88db479ede 交易流水同步改为节流+两边对账:开服先点验,一致则跳过补推;逐批只补PHP缺失序列号;推进异步化不阻塞DB队列
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m13s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 23:52:17 +08:00
youpaishidifu 2822ba9c9a 负面效果从增益里拆出独立子菜单 + 药水成就防作弊
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
用户要求:增益只归纳增益,负面另开子菜单;移除指定效果那套不动。
插件给的效果必须打标记,靠插件凑齐原版药水成就的立刻撤销。

效果拆分
- AreaConfig 新增 badEffects 字段,area_lands 加 bad_effects 列(73 列)
- GUI:效果管理主菜单 11~15 连排五格(清负面/禁效果/单清/增益/负面),
  新增 subPage 4 负面效果列表;选择页按 clear/give/bad 分别只列对应档位
- CLI:新增 effectsmgmt 6/7 子页与 effectsbadadd/effectsbadremove/effectsbadedit
  指令、editBad_ 回写分支,与 GUI 同一份效果口径
- 施加点全部合流 giveRegionEffects/applyEffectList,并修正 handleEnter
  的先清后给顺序(否则负面配置会被 clearBadEffects 清掉)
- 数量上限改为增益+负面合计(用户组 max_effects 是同一份预算)

成就防作弊
- 新增 AchievementEffectGuard:接管 nether/all_potions(17种) 与
  nether/all_effects(34种),清单抄自服务端 advancement json
- EntityPotionEffectEvent(Cause.PLUGIN) + PlayerAdvancementCriterionGrantEvent
  双钩子,revokeCriteria 撤销,进服复核一次,离场清标记
2026-10-04 22:57:14 +08:00
youpaishidifu 0c18137401 修正药效生存上限表:村庄英雄5级、挖掘疲劳3级、伤害吸收4级、不祥类5级
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 20:32:03 +08:00
youpaishidifu b64e7e336c 修正药效等级上限口径:按各效果的生存可获取上限封顶
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
用户纠正:上一版把 /effect 命令的作弊上限(amplifier 255 = 强度 256)
当成了「原版最大上限」,口径错了。真实含义是**生存模式下正常探索、
合成、建造能拿到的最大等级**:
  · 抗性提升 —— 信标最高 2 级(4级金字塔选「提升至II」)
  · 海豚的恩惠 —— 只有潮涌能量块一个来源,最高 1 级(截图里的 Lv255 即此)
所以「统一按 256 封顶」仍然等于没封。

改为按效果逐个封顶,两层上限同时生效:
  1) 生存可获取上限(硬顶):getSurvivalMaxLevel() 逐项查表
     · 可升 II 的:迅捷/急迫/抗性提升/跳跃提升/力量(信标)、
       瞬间治疗/瞬间伤害/缓慢/挖掘疲劳/中毒/生命恢复(发光石粉)
     · 生存单一来源恒为 1:海豚的恩惠、潮涌能量、抗火、水下呼吸、
       隐身、夜视、缓降、虚弱、反胃、饥饿、凋零、失明、黑暗、
       发光、飘浮、各类事件效果等
     · 生存无来源:生命提升/伤害吸收/饱和/幸运/霉运 → 1
     · 未知效果兜底 1(宁可少给,绝不超发)
  2) 用户组配置 max_effect_level(软顶):组里配多少就是多少
  最终 cap = min(两者),getEffectLevelCap() 统一出口

表项与 paper-api 26.1.2 的 39 个 PotionEffectType 完全对齐(已核对无遗漏)。
注意:生命恢复取 2 而非旧版附魔金苹果的 V —— 那是 1.6.1 前的历史数据,
      附魔金苹果早已改为给伤害吸收。

应用点全部改走 clampEffectLevel(raw, effectName, owner)(5 处):
首次给予、热更新、handleEnter、applyEffects、PHP 推送字段。
CLI 添加/编辑的报错信息改为报该效果的真实上限,并说明生存上限是多少。

PHP 侧 land_api.php 同步加入同一张表(134 个 case 标签与 Java 端
逐项对账一致),web 端写入时即按效果封顶,不再等 Java 兜底。
2026-10-04 19:56:45 +08:00
youpaishidifu 6f9202859d 修复领地增益药效等级无上限,新增用户组药效等级上限配置
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
问题:领地增益效果的等级完全没校验,管理员配了 255 级就会原样
生效(截图里出现的 Lv255 即源于此)。原版 /effect 的 amplifier 硬
上限是 255(对应药效强度 1~256),插件此前允许写入并施加远超原版
范围的等级。

修复(2026-10-04 任务4):
- UserGroupManager 新增 maxEffectLevel 字段,默认 256
  · 常量 DEFAULT_MAX_EFFECT_LEVEL = 256 集中定义原版上限
  · user_group_config 建表补列 + ALTER 迁移(老库自动升级)
  · loadGroupConfigs 读取、saveGroupConfigToDB 加为第 14 列
  · 新增 getPlayerMaxEffectLevel:普通玩家走原版 256,用户组按组配置
- AreaProtection 新增 getMaxGiveEffectLevel / clampGiveEffectLevel,
  在全部 4 个应用点封顶:首次给予(applyRegionEffects)、热更新、
  handleEnter、applyEffects;另在 PHP 字段推送(applyFieldToAreaConfig)
  与 CLI 添加/编辑两处入口按同一上限做输入校验与提示
- WebManager 用户组同步双向补 max_effect_level(推 PHP + 回读)

规则:普通玩家(未加入任何用户组)严格按原版 256 封顶,严禁超限;
用户组玩家按组配置封顶,组里配多少就是多少,但任何情况下都不得
突破原版 256。原先散落各处的硬编码 255 已统一为该口径。
2026-10-04 19:25:39 +08:00
youpaishidifu f2808a5923 修复 denyMove 对访客/路人失效:GUI显示已禁用但实际畅通无阻
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
现象:领地访客授权页显示「移动 已禁用」,玩家在领地内却能自由走动。

根因:getEffectiveDeny 是所有 deny* 权限的共用入口,其第3步
    if (level == PermissionLevel.VISITOR) return false;
  对全部权限无条件豁免。denyMove 走同一入口,访客在此被放行,
  永远到不了第4步读 ac.denyMove。而 GUI(AreaGUIManager:586)
  显示读的正是 land.denyMove=true —— 显示与实际判定脱节。

判定链(修复前,访客):
  第1步 非ADMIN/OWNER → 不豁免
  第2步 playerPerms 无 denyMove 键 → containsKey=false,不命中
  第3步 level==VISITOR → return false ← 绕过点
  第4步 getLandDefaultDeny(ac,"denyMove")  永远到不了

修复(按用户决策:仅 denyMove 改,其余权限保持现状):
  第3步增加 denyMove 特例 —— 访客若无 per-player 显式 denyMove,
  不再豁免,继续落到第4步按领地默认 denyMove 判定。
  level==null(纯路人)本就落在第4步,行为正确,未改动。

顺带恢复 logDenyExempt 诊断输出(此前整段被注释,
是当初现场无法定位该问题的直接原因)。

验证:离线回归测试 13/13 通过,覆盖 ADMIN/OWNER 豁免、
per-player 显式放行与禁止、访客回落、路人回落、白名单访客,
并断言 denyBlockBreak/denyContainer/denyBlockPlace/denyWax
的访客豁免语义未被破坏。mvn -o clean package 编译通过。
2026-10-04 18:47:05 +08:00
youpaishidifu 494ab22227 修复首次全量同步的三处根因(假成功+流水永久丢失+同步过慢)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m58s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
【BUG-1 假成功(核心)】hash 在请求发出前就提交,导致同步失败后永不重试
  位置:syncUserRegistrations / syncBondBalances / syncShopData
  现象:日志打印"已同步 3人",PHP 端 users 表实测 0 条
  原因:lastXxxHash = currentHash 写在 httpPostWithToken 之前,密钥错/网络断/500
        时 hash 已被写脏,这份数据再也不会重推
  修复:hash 仅在 PHP 明确返回 success=true 后提交;失败不提交,下轮周期自动重试

【BUG-2 历史流水永久丢失(不可恢复)】
  实测:bond.db 419 笔(最早 2026-05-21),web_sync.db 水位线已推到 2026-10-04
        17:18:02,419 笔全部 <= 水位线,晚于水位 0 笔;PHP 端 game_transactions 仅 3 笔
  原因:水位线语义是"我以为 PHP 已经有了"。Web 端被清空(全新部署/隔离目录重置/
        低版本插件首升 PHP)后两者失联且无法自愈,只推了水位线之后的 1 笔
  修复:
   1) 新增 resetTxWatermarkForFullSync(),首次全量同步前强制归零水位线重推全部历史
   2) 新增 pushTxBatch(),每批最多 300 笔(TX_SYNC_BATCH_SIZE),失败即中止且
      不推进水位线 → 下轮从同一位置重试,不丢数据
   3) 分批后打"第 N/M 批 已同步 X 笔"与总计日志

【BUG-3 全量同步串行 sleep 过长 + 无进度可见】
  原因:12 项任务之间各 sleep 6~14 秒,总计 72~168 秒串行阻塞(用户"等 3 分钟没数据")
  修复:间隔缩短为 1.5~3.5 秒;开头判 isEnabled() 跳过;开头打地址+密钥长度;
       每项提交打一行"已排队";结尾打 12 项汇总与耗时

兼容性:纯 CRLF 行尾已保持(0 裸 LF);mvn -o clean package 编译通过
2026-10-04 17:53:59 +08:00
youpaishidifu ba613ff953 修复:Web通信关闭后仍在出网(热重载暴露的可见性 bug)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
现象(用户日志实证):
  16:41:36 配置已热重载: 启用=false 密钥长度=30 [开关已关闭]
  16:41:45 syncOnlinePlayers: enabled=false, 跳过     ← 有守卫,正常
  16:42:12 推送游戏内商品目录 ... 已同步 975 项         ← ★ 无守卫,仍在出网
  16:42:46 服务商数据变更,已同步: 1人                 ← ★ 无守卫,仍在出网

根因两层:
1) 约 22 个同步方法(pushShopCatalog/syncServiceProviders/syncPermissions/
   pullShopStock/pullShopPrices/pullBondChanges 等)压根没有 !enabled 守卫;
2) 即使有守卫也拦不住 —— 任务是在「开关还是 true 时」就已 submitDbTask 入队的,
   db-worker 单线程排队轮到它时开关早已关闭,方法开头的守卫已经执行完了。

修法:出网最后一公里统一加闸,三道防线
1) 7 个 HTTP 收口方法开头加 isEnabled() 闸。doGet 是 46 处调用的唯一总出口,
   一处改动覆盖全部 GET 路径,零遗漏;两个 HTTP 降级方法一并覆盖。
2) submitDbTask / submitWebTask 入队前拦一道(省掉入队开销)。
3) startDbWorker 执行 task.run() 之前再查一次(治标兜底,拦存量任务),
   静默 continue 不打 WARN —— 关闭后每轮几十个任务全打日志会刷爆控制台,
   真正的开关变化已由 hotReloadIfChanged 打了一行「[开关已关闭]」,信息不丢。
另加 noteWebDisabledOnce():关闭状态下每 60 秒一条 INFO,说明「为什么没发」。

验证:mvn package 零错误;离线单测 18/18(闸的位置与结构均从真实源码抽取),
含端到端复现「关闭前入队、关闭后执行」这一用户日志场景。
2026-10-04 17:01:21 +08:00
youpaishidifu 932623ed88 Web通信配置热重载+密钥自愈:不再依赖 reload
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
背景:密钥错配导致 708 次 403。根因是运维改了服务器上的 插件设置.txt
却没重启也没 reload,插件一直握着旧密钥死循环重试。

改动(WebManager.java,+233/-3):
1. hotReloadIfChanged():5 个合并定时器每轮开头比对 插件设置.txt 指纹
   (lastModified+length),变了就热重载「启用开关/地址/密钥/三个数值项」。
   放在 enabled 守卫【之前】,开关关着时也能感知「改回 true」。
   3 秒节流 + 指纹闸,文件没动时连磁盘都不读。
2. 密钥自愈:doGet / doPostWithoutFallback / doPostWithSslFallback /
   doPostWithRetry 四个出网收口接入 withSecretAutoHeal(),
   命中「密钥验证失败/认证失败/invalid_secret」时重读配置的密钥并重试一次。
   30 秒冷却防刷日志;文件里就是当前值则不自愈(那是 PHP 侧问题,不死循环)。
3. isEnabled() 改为实时读文件,改开关下一毫秒生效。
4. reloadWebConfig() 保留为手动方式,强制清文件指纹确保真正重新解析。
5. 日志不再打印明文密钥,只打长度。

验证:mvn package 零错误;离线单测 32/32(判定串与解析规则均从源码抽取,
非另写等价实现),含端到端自愈、PHP 真拒不死循环、开关自动恢复、指纹节流。
2026-10-04 16:32:07 +08:00
youpaishidifu dc5633c740 新增4项功能:tempban参数可选+债券冻结时长+领地增益上限+用户组max_effects同步
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m52s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 14:48:26 +08:00
youpaishidifu 24897c2b97 命令语法报错补充标准示例与一句话用法,示例仅对控制台与OP玩家展示
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 13:06:22 +08:00
youpaishidifu ab0f15f248 新增命令参数不全/语法错误的中文友好报错(Brigadier 重解析 + 出错指针 + 下一步可填提示)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m39s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-04 12:43:38 +08:00
youpaishidifu 77635b8db2 保留原生JSON解析:控制台未命中分流增加JSON快速通道,与&色码写法并存
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-04 11:40:36 +08:00
youpaishidifu 8486adb53c 修复:控制台未命中原生指令被当聊天全服广播导致刷屏
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 11:19:07 +08:00
youpaishidifu c07c97b7ea conmsg: clear 作为保留子参数,恢复默认署名(不再当名字设置出去)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 23:26:51 +08:00
youpaishidifu c17c606ff2 转发聊天: 正文支持JSON组件+&色码+换行并存, 署名仅&色码且强制单行
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m39s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 23:06:28 +08:00
youpaishidifu f8756a24df 无感切换: 新增 conmsg 控制台署名 + 按权限分层提示 + 找不到时温和告知
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 22:17:37 +08:00
youpaishidifu be8d0cb615 无感切换分流: 玩家未命中出相似建议, 控制台未命中转发聊天
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m58s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 21:31:49 +08:00
youpaishidifu 2e0fdea5be 无感切换修复控制台接管 + 压制方式改为 message(null)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m47s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 20:48:01 +08:00
youpaishidifu 21e23bbc89 无感切换限定为 sdf1 系列 4 插件,未命中时回指令清单
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
按用户口径收窄管辖范围:
- 只接管 CY_beibao / sdf1 / Sdf1_game / Sdf1_login 四个插件的指令
- 命中我们 4 插件的指令(大小写不敏感)-> 直接执行
- 命中别人插件或原版指令 -> 完全不干预,不破坏别人的判断链
- 确实啥都没命中 -> 压制原生 unknown,把我们 4 插件的指令清单分组回给玩家手动执行
- 控制台不接管(运维输错保留原声报错)

技术要点(Paper 26.2 实测签名):
- 拦截点 UnknownCommandEvent.setMessage(String)(父类非 Cancellable)
- 归属判定靠 PluginCommand.getPlugin(),名单 OWN_PLUGIN_NAMES
- 遍历用 CommandMap.getKnownCommands() 返回 Map<String,Command>
2026-10-03 19:39:05 +08:00
youpaishidifu 1e15bfb523 无感切换改用 Paper 26.x 正确拦截点 + 编译目标切到 26.2
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m56s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
根因:Paper 26.x 命令分发改走 Brigadier,PlayerCommandPreprocessEvent
已 @Deprecated 且只在 ServerGamePacketListenerImpl 作为遗留兼容路径保留,
实测事件从未被调用(日志一行都打不出来),改优先级无效。

改动:
- pom.xml: paper-api 1.21.4-R0.1-SNAPSHOT -> 26.2.build.129-stable(与运行时一致)
- 删除失效的 onSeamlessCommandSwitch / getCommandMapSafe
- 新增 UnknownCommandEvent 监听:setMessage("") 压制原生 unknown
- 新增 dispatchCaseInsensitive:输入为大写时小写复核并补执行
- forwardAsChat 改用 26.2 真实签名 AsyncChatEvent + AbstractChatEvent.message()/viewers()
2026-10-03 19:01:53 +08:00
youpaishidifu a1007a19f9 无感切换: 使用 Paper API 获取 CommandMap + 诊断日志
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 1m36s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- getCommandMapSafe 优先使用 Bukkit.getCommandMap() Paper API
- 添加详细诊断日志(事件触发、label、hit 结果)
- 便于排查大写指令返回 unknown 问题
2026-10-03 18:07:10 +08:00
youpaishidifu dd4bb7baba 修复无感切换优先级 + 改判系统兼容反向操作
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m14s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
- onSeamlessCommandSwitch: MONITOR -> LOWEST(在服务端处理命令前拦截)
- doTempBan: 新增反向改判(永久封禁可被 tempban 改判有期徒刑)
2026-10-03 16:49:19 +08:00
youpaishidifu 31c7077a64 屏蔽除了readme.md以外的md文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m59s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 16:12:00 +08:00
youpaishidifu 9656ff4e8f 再次删除脚本部署的凭据文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 15:56:02 +08:00
youpaishidifu 14cc943068 屏蔽部署所需要的凭据文件
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 15:54:07 +08:00
youpaishidifu 96aef279cf 批次B3异步化 + 任务48改判系统/注册校验 + 任务50无感切换
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
批次B3(混合型定时器拆分):
- Main: quest PlayTime 计时器 DB 下沉异步
- Main: 工单自动处理定时器改 runTaskTimerAsynchronously
- TicketManager: 新增 sync() 辅助,autoProcessCompleted/settleTicket/processDeleteAccount Bukkit 操作回主线程
- GarbageManager: doCleanup 两段式(DB删旧异步+收集回主线程),collectItems 落库异步
- SnManager: startAutoSweepTask 分片(每 tick 只清点 1 人)
- AreaProtection: sweepBannedOwnerLands 两阶段(Bukkit主线程判定 + DB/HTTP异步删除)

任务48(改判系统 + 注册校验):
- DatabaseManager: 新增 userExistsIgnoreCase(COLLATE NOCASE)
- Main: 新增 ensureRegistered 辅助,doTempBan/doBanIp 入口加注册校验
- Main: 新增 guardVanillaBan 守卫(HIGH优先级双事件),未注册拒绝+临时封禁改判永久

任务50(无感切换):
- Main: 新增 onSeamlessCommandSwitch(MONITOR),命中指令直接执行/大小写不敏感/未命中转公屏
- Main: 新增 getCommandMapSafe + forwardAsChat
2026-10-03 15:36:31 +08:00
youpaishidifu efe00d92af 圈地选区SN校验收紧为本人名下+有效状态;转移物品不再锁SN;管理员强制可处置非法SN
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-03 11:11:52 +08:00
youpaishidifu 643f2a81cf 领地扩建按创建单价扣费、收缩按比例退款、圈地棒SN归属校验;发射器/投掷器消耗SN永久锁定不支持注销
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 10:21:11 +08:00
youpaishidifu e13869923f 管理员GUI配置面板补单次扩建免审面积与免审边长上限两个配置项
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m51s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-02 19:45:06 +08:00
youpaishidifu 20052d660b 领地面积限制与现场审批流:单次扩建3500㎡/边长7000格超限转管理员审批,denyWax改回普通权限判定
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-02 19:31:46 +08:00
youpaishidifu 9cf9fd745c 修复垃圾站取回后SN lore丢失:取回改用DB原始存档还原,补挂SN行,背包满掉落防吞
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-02 11:45:01 +08:00
youpaishidifu 4d34606d8e SN脱离管控两档计时:进垃圾箱/落地10分钟即起算,12小时超时放行注销与补发
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 22:47:20 +08:00
youpaishidifu 55ec15bb5b SN以旧换新改判:名下已有登记直接强制收回,并新增开箱扫描回收箱内无SN物品
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m49s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 22:06:42 +08:00
youpaishidifu fbc0e94fc2 2FA解绑报警改发玩家本人邮箱;未绑定2FA时不再提示验证码错误
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m48s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 21:00:05 +08:00
youpaishidifu 81e8323d56 2FA增加控制台强制解绑(30秒冷静期可-c取消)与三场景解绑报警邮件,玩家侧最小化信息透露
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 20:15:44 +08:00
youpaishidifu 393ef114e7 IP风控:检查点1/2接入双验证,IP变更且已绑2FA强制密码+2FA两关
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-01 19:00:34 +08:00
youpaishidifu 7a5fb5f171 回退正版验证落库:改为仅本次运行期内免登录,写入不看玩家在线状态
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 18:04:19 +08:00
youpaishidifu 9884f79cf6 订正双验证触发点注释
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-01 17:43:48 +08:00
youpaishidifu b7f2d0da57 登录风控:IP变更强制密码+2FA顺序两关,正版验证标记永久落库
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 1m50s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 17:35:21 +08:00
youpaishidifu 82524dc9e9 删了最后一个测试文件
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m47s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 14:27:41 +08:00
youpaishidifu 66e28db552 删了一堆测试文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m48s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 14:26:34 +08:00
youpaishidifu 919382c377 2FA恢复代码与登录风控联控:绑定后发8组6位恢复代码到邮箱(库存SHA-256一次性消费),/2fa code统一绑定与验证端口,IP变更时提示完成二次验证,恢复代码验证即解绑;正版OAuth验证玩家豁免游戏内验证码;/2fa子命令tab补全与冻结白名单放行
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 13:38:29 +08:00
Test User 2c138b6608 test fix action queue 1790824042
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 11:07:22 +08:00
youpaishidifu 7a2eb8e5d8 docs: 修正2FA token的WAF结论注释(实为token查询参数名+≥32位hex被拦,非t=)
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 43s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 10:59:38 +08:00
youpaishidifu 451d088697 fix: 2FA二维码token回退为裸hex64,规避线上WAF拦截(v1_前缀与t=长hex均被CF层404)
Check Source / check (push) Successful in 2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
Auto Release / build (push) Failing after 13m30s
2026-10-01 10:46:34 +08:00
youpaishidifu d56fcbd3cd 2FA二维码token加v1_前缀:线上WAF拦截「t=32位以上纯hex」形态,前缀破坏纯hex特征(实测可过)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 44s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 10:37:21 +08:00
youpaishidifu 32aa2bab05 安全加固层B:玩家端2FA(TOTP)绑定——/2fa add生成二维码申请、/2fa code动态码完成绑定、/2fa remove+30秒聊天confirm解绑;Base32/TOTP本地实现,users表加4列,PHP只做二维码展示
Check Source / check (push) Successful in 3s
Auto Release / build (push) Failing after 48s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 10:16:38 +08:00
youpaishidifu e941365a41 安全加固层A:未绑定邮箱玩家登录/注册后聊天框+actionbar15秒提示(与保存密码提示错峰)
Check Source / check (push) Successful in 2s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
Auto Release / build (push) Failing after 10m47s
2026-10-01 09:55:50 +08:00
youpaishidifu 606728d09a SN管理:新增顶层办理签发命令 issue_new(按玩家ID+设备类型直发)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-30 17:32:08 +08:00
youpaishidifu fe6f6d8f80 SN日志actionCn补映射:issue_admin办理签发、cancel_force强制注销
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m52s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-30 12:34:06 +08:00
youpaishidifu 0b77d1c211 SN办理签发入口(Java端):execCommand新增issue分支+doIssue方法——兜底复核旧SN已注销/销毁/补发了结与本地冷静期,玩家在线后登记新SN并发放,回执issue_admin日志
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m38s
Simple Test / test (push) Successful in 3s
Test Env / test (push) Successful in 1s
2026-09-30 12:13:57 +08:00
youpaishidifu 383639a1b5 SN强制注销跳过1小时冷静期,代办注销仍走三条件门槛与冷静期
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m44s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 23:57:30 +08:00
youpaishidifu 374cbdbc11 SN防刷:注销/报失/补办统一三条件门槛(不在本人身上、不在本人领地箱子、脱离自身管控超12小时),未达标统一回通用提示,管理端可见逐条判定;修正拾取事件 loc_type 误记为物品类型
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m3s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-29 20:21:45 +08:00
youpaishidifu 841fd928a5 fix: SN命令回执改同轮返回且失败不丢,全量批处理移出Timer C消除2分钟同步周期
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 18:03:32 +08:00
youpaishidifu ccb67a0bf5 fix: 修复全员下线后SN同步定时器永久停摆与冷静期提示误导
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 1m53s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
1. WebManager: Timer B/C/E 全员下线时改为保活调度(不再 return 停死),
   Timer C 兼任看门狗在检测到玩家上线时恢复;doActiveSyncBatch 移除
   重复 schedule 调用避免产生双链。
2. SnManager.setStatus 写入 cancel_time,新增 cooldownMinutesLeft 辅助方法。
3. AreaProtection/AreaGUIManager/LandRecordManager/PVPManager 申领入口按
   真实原因分流提示(冷静期剩余分钟数 vs 旧SN未注销),副作用与 return 不变。
2026-09-29 16:38:31 +08:00
youpaishidifu 68bbb52ae6 SN防刷:管理员强制补发仅本次绕过冷静期,修复补发双重登记导致物品发不出去;注销回收容器与他人背包实物,新增死SN拦截(拾取/使用)与无SN旧品自动回收
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 13:37:45 +08:00
youpaishidifu 176e64187c 插入草原探险宣传广告
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m57s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 13:10:06 +08:00
youpaishidifu 7aad66f219 feat: 新增SN防伪系统,接入四类道具签发、拾取/合成/熔炼探查、销毁解绑与PHP目录同步
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 11:36:53 +08:00
Test User 62ebf5c771 test action trigger 1790651660
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m49s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 11:14:21 +08:00
Test User cef088396f test action trigger 1790651387
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 1m1s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-29 11:09:48 +08:00
Test User 95a8dba554 test action trigger 1790651350 2026-09-29 11:09:10 +08:00
youpaishidifu b62b5ebd83 5测audo release
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 45s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 10:37:02 +08:00
youpaishidifu 52dd5c54e9 维护消息占位符防双重替换:标签+定界符混合写法只换占位符
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 1m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
问题:配置「开始时间[starttime],结束时间[endtime]」时,定界符占位符先替换成时间,
中文标签「开始时间/结束时间」又被裸替换二次吃掉,渲染成两份时间连排。

修复:replaceVar 先扫描本组 key 是否已有定界符形式出现(delimHit),
命中则整组跳过裸子串替换、保留标签原文;裸写「开始时间」无定界符时仍正常替换。
新增 T3 用例 J 组 9 例,83/83 通过,白名单回归 27/0。
2026-09-29 00:28:40 +08:00
youpaishidifu f3306589e0 维护消息防御性编程:色码包JSON识别、残缺JSON修补、占位符任意中英文括号
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-28 23:59:07 +08:00
youpaishidifu 61d53d7689 修复维护模式两个线上问题:
Check Source / check (push) Successful in 6s
Auto Release / build (push) Failing after 1m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
1) JSON 消息内的字面 <br>/<br/>/</br> 不被摘除(JSON 分支绕过了换行预处理)
   -> deserialize 前先替换成 JSON 转义换行,标签不再漏给玩家
2) [白名单] 段标题被删(只剩「# ===== 白名单段 =====」注释)时玩家名被静默丢弃,
   白名单读成 0 人且 OP可绕过=flase 拼错也无提示 -> 所有人被拦
   -> 注释式段标题也识别 + 段外无键值行按玩家名兜底收录
   -> 条目清洗(零宽/全角空格/行内注释/列表前缀)+ 布尔拼写告警
   -> 维护开启时打印白名单条目与控制段键,便于对账

测试:T3 真实源码抽取 46/46(含用户真实白名单.txt 解析)、maint 33/0、whitelist 27/0
2026-09-28 22:50:43 +08:00
youpaishidifu fb297b34fb 修复维护消息JSON解析:顶层数组[{...}]此前不被识别、整段按纯文本打印成源代码,现与对象{...}同走Gson组件解析;JSON内占位符只替换{xxx}/%xxx%/<xxx>不再裸替换正文英文词;配置模板注明支持数组 2026-09-28 21:35:11 +08:00
youpaishidifu 5e778a1a6d fix: 白名单矫正为严格一行一个名字,段落头加「剥装饰后含=即非标题」硬门槛(修复控制键被误判成标题导致开关失效、全角【白名单】读空致全员被踢),并加空白名单告警 2026-09-28 21:00:54 +08:00
youpaishidifu 50498ffb41 新增维护模式(白名单):白名单.txt双段配置、24小时硬封顶、多格式时间解析、自定义消息与变量、登录/加入/退出热重载、异步预登录无感拦截 2026-09-28 20:01:25 +08:00
youpaishidifu 441569e758 修复sdf1兑换码远程验证:JSON解析支持布尔/数字/转义,已消耗回already_used不再误判未匹配,瞬时故障自动重排队 2026-09-28 19:39:43 +08:00
youpaishidifu 42c51e06a8 修复home床传送点:床被破坏自动删除、换床自动更新、复活抓取精确床坐标、回床走原版复活落点算法 2026-09-28 19:30:20 +08:00
youpaishidifu e2742d8a6e test audo release
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-28 18:46:04 +08:00
youpaishidifu 19eebf1358 Update GITEA_TOKEN for workflow
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-28 14:11:21 +08:00
youpaishidifu 78d3e11c21 修改.gitignore以屏蔽日志和开发过程中不必要的文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-28 13:38:26 +08:00
youpaishidifu 002b0e47a9 feat: /pvp see 重构为裁判⇄幽灵双形态切换(不再切回无保护普通玩家)、/pvp leave see 退出观赛时重新选装并补发PVP装备、断线重连补裁判身份清理
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-28 12:39:10 +08:00
youpaishidifu adae149791 fix: 空场判定延后1tick并列出剩余玩家(修复全员下线仍提示驻场)、断线未登记也复核、死亡时打印双方观赛状态、退出观赛加醒目警告
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-28 12:03:37 +08:00
youpaishidifu 668a5f3d38 fix: 观赛自动补登记时向玩家提示/pvp leave see并完善日志指引
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m28s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-28 11:38:43 +08:00
youpaishidifu 27ac366046 fix: 观赛战斗保护二次加固——身份判定不限世界、PVP世界野生观察者自动补登记、每次拦截留痕日志、MONITOR安全网防解取消
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m16s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-28 11:25:39 +08:00
youpaishidifu dea31b8222 fix: 观赛者以登记身份双向拦截战斗并强制恢复观察者、PVP世界删除全链路日志与磁盘复核重试
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-28 10:15:15 +08:00
youpaishidifu 233060e7bd fix: 观赛退出绝不回创造模式、正版验证轮询fail-closed、验证码改为99以内小学加减乘除
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m44s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- PVPArenaManager: 进观赛时创造/观察者一律归一为生存再备份,exitSpectator/forceLeaveArena 兜底还原绝不给 CREATIVE(防退出观赛变创造作弊)
- WebManager: 正版验证轮询fail-closed——非JSON/缺字段视为无效响应仅记日志绝不放行,PHP明确失败即终止轮询,verified须凭证齐全且会话归属玩家一致才 autoLogin
- ChatFilterManager: 验证码算术题改为小学水平99以内加减乘除(和/积≤99、减法不为负、除法整除)
2026-09-27 23:57:32 +08:00
youpaishidifu 8388d68cb7 chore: gitignore忽略编译日志build.log
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 21:47:26 +08:00
youpaishidifu 2bcc449bc4 fix: PVP观察者改纯命令切换+双向免伤、清空队列联动PHP清理+回写lambda化、封禁IP推送Web黑名单
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-27 21:24:00 +08:00
youpaishidifu d8ff4b0c92 fix: 刮蜡独立拦截+安全权限化、新增PVP观察者观赛(/pvp see)、修复新布局PVP世界清理失效
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
- 涂蜡/刮蜡:从 onInteract 拆出独立处理器 onWaxInteract(HIGHEST),改用 e.getItem()/getHand() 覆盖副手,
  同时 DENY useInteractedBlock+useItemInHand;denyWax 改为安全权限(不看上级破坏/放置开关、
  不豁免所有者/管理员/访客),并加放行原因诊断日志,解决"开了开关却拦不住"。
- PVP观赛:/pvp see 仅PVP世界生效,自动进原版观察者,回收PVP装备但不发还玩家自有装备,
  发放2个不同雪球右键在观察者⇄普通生存间切换;离场/断线/重连自动还原游戏模式。
- PVP世界清理:新增 resolveWorldFolder/listWorldFolders 兼容 1.20.4+ 新布局
  world/dimensions/minecraft/<name>,替换全部 getWorldContainer 拼路径,并加启动残留清扫,
  修复磁盘堆出20+个 pvp_arena_* 残留目录的问题。
2026-09-27 18:56:22 +08:00
youpaishidifu c65b64030f feat: 领地新增生物蛋/涂蜡权限、基岩版固定算术验证码、-f非管理员静默回退
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- protect delete <名> -f:非管理员不再报错拒绝,静默回退到普通60秒倒计时删除流程(含退费)
- 基岩版(Geyser/Floodgate)玩家无法用聊天框唤醒GUI验证码,改为固定出算术题
- 新增领地权限「生物蛋」denySpawnEgg,上级为放置方块,生效条件 denyBlockPlace && denySpawnEgg
- 新增领地权限「涂蜡/刮蜡」denyWax,上级为破坏方块+放置方块,生效条件 denyWax && (denyBlockBreak || denyBlockPlace)
- CLI/GUI/Web同步/DB(70->72列)/tab补全全链路接入两个新权限
- 修复GUI成员权限页历史缺项:耕地破坏/末影人行为点击数组缺失导致条目点不动
2026-09-27 16:09:25 +08:00
test 448f23ac53 fix: use release ID for asset upload
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m54s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 15:45:21 +08:00
test fb8b06c4e5 fix: use python3 to construct JSON payload
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m16s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 15:40:07 +08:00
test 4ab2b2dfd8 feat: use heredoc to generate release notes
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m45s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 15:33:28 +08:00
test baba10b84d feat: generate release notes from commits
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:27:58 +08:00
test 5e98830829 feat: generate release notes from commits
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m47s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:20:42 +08:00
test 8e84c58bcc feat: generate release notes from commits
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:15:10 +08:00
test b6ad95171d fix: use syntax to avoid YAML template expansion
Check Source / check (push) Successful in 4s
Auto Release / build (push) Failing after 54s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 15:09:17 +08:00
test c1bad334b7 fix: simplify workflow and pass env vars explicitly
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:02:59 +08:00
test 1e5576432d feat: generate release notes from git log with proper JSON escaping
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 14:56:19 +08:00
test fbdcca4089 feat: generate release notes from git log
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 2m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 14:50:30 +08:00
test fd2728fe42 feat: generate release notes from commits
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 1m56s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-27 14:45:00 +08:00
youpaishidifu 385301432a 领地删除退费、-f强制删除与封禁领主领地自动清理;修复领地外开箱残留与tpaall撤回入口
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m48s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
1) 退费:AreaConfig新增createCost并落库create_cost列,创建扣款成功后记录实际金额;新增area_config.refund_ratio_per_cent(0=关闭退费、100=全额、其他=原价×比例),60秒到期删除时按比例经BondManager退还债券(流水land_refund)。配置可通过 /protect config refund_ratio、CLI配置页与管理员GUI(位置16)调整,输入自动钳制0~100。

2) 强制删除:/protect delete <名> -f(或--force)仅插件管理员可用,跳过60秒倒计时与退费流程立即删除,并向在线插件管理员&OP上报领地坐标。

3) 自动清理:每5分钟扫描领地领主,命中永久封禁/剩余9年以上(即10年)封禁/账户已注销者,自动强制删除其领地(不退款、不注销账号),日志+在线管理员&OP上报坐标。

4) 修复领地外开箱残留漏洞(双箱四向同材质多候选逐侧判定、InventoryOpen准星回退定位、isUIBlock补CRAFTER/BOOKSHELF/COMPOSTER/CAULDRON、DoubleChest递归展开);tpaall发送后补充可点击撤回按钮并重开传送面板。
2026-09-27 14:26:06 +08:00
youpaishidifu 989b86f6e6 4测audo release
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m52s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 14:25:48 +08:00
test e848746800 fix: use release ID for asset upload
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m54s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 14:20:01 +08:00
test 915d7d1143 fix: use GITEA_TOKEN instead of ACTIONS_RUNTIME_TOKEN
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 14:14:29 +08:00
test e00a2f6e4b fix: use instead of to avoid YAML template expansion
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 14:06:03 +08:00
test 720757463b fix: use single quotes for JSON in curl
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 13:58:06 +08:00
test 6260ee11f9 fix: add Maven installation step
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 2m11s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 13:49:44 +08:00
test e4ff388281 fix: install JDK 25 from Adoptium
Check Source / check (push) Successful in 3s
Auto Release / build (push) Failing after 41s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 13:45:47 +08:00
test 12c6d14916 fix: use HTTP instead of HTTPS for git clone
Check Source / check (push) Successful in 3s
Auto Release / build (push) Failing after 1m32s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-27 13:38:50 +08:00
youpaishidifu 39b74f6c4e 删除test相关的全部内容
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 35s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 13:31:28 +08:00
test eac0da081e test: trigger workflow
Check Source / check (push) Successful in 3s
Auto Release / build (push) Failing after 33s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 13:25:38 +08:00
test b11beb7126 fix: strip protocol from GITHUB_SERVER_URL for git clone
Check Source / check (push) Successful in 3s
Auto Release / build (push) Failing after 34s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 13:12:11 +08:00
test eeecb551bc fix: explicit checkout and correct env vars
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 35s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 13:04:39 +08:00
test 7e9be87e7d test: check source code availability
Check Source / check (push) Successful in 3s
Auto Release / build (push) Failing after 34s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 13:00:15 +08:00
test 5dcf887236 fix: use correct Gitea Actions env vars
Auto Release / build (push) Failing after 36s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 12:55:44 +08:00
test 7e1f082b74 fix: proper release workflow with Java build
Auto Release / build (push) Failing after 1s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 12:46:55 +08:00
test 9a67a4a8bb test: add env test workflow
Auto Release / build (push) Successful in 38s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 12:46:30 +08:00
youpaishidifu 1e7b1a21b2 3测audo release
Auto Release / build (push) Successful in 36s
Simple Test / test (push) Successful in 1s
2026-09-27 12:34:53 +08:00
test 15e7ab62f9 fix: simplify workflow
Auto Release / build (push) Successful in 39s
Simple Test / test (push) Successful in 2s
2026-09-27 12:31:22 +08:00
youpaishidifu c249355e27 Merge branch 'main' of https://git.ypshidifu.cn/youpaishidifu/Sdf1_login
Auto Release / build (push) Failing after 43s
Simple Test / test (push) Successful in 1s
2026-09-27 12:26:12 +08:00
test c50b06361e fix: add Java setup step
Auto Release / build (push) Failing after 3m17s
Simple Test / test (push) Successful in 1s
2026-09-27 12:25:45 +08:00
test a521b4c59a test
Auto Release / build (push) Failing after 34s
Simple Test / test (push) Successful in 1s
2026-09-27 12:18:20 +08:00
test 1dba2690a1 final-test
Auto Release / build (push) Failing after 32s
Simple Test / test (push) Successful in 1s
2026-09-27 12:16:42 +08:00
test 2b7daa1928 debug-push
Auto Release / build (push) Failing after 55s
Simple Test / test (push) Successful in 2s
2026-09-27 12:12:48 +08:00
youpaishidifu f008c49e84 修复三项问题:1)大箱子跨领地边界绕过容器权限(双箱改为左右两侧均收集定位并逐侧判定denyContainer,交互事件改用被点击方块定位并补查相邻半箱)2)tpaall语义文案修正(通知/按钮/GUI统一为全服玩家一起传送到发起者身边)3)新人验证码超时不放行改为重新出题,题库引入九九乘法表加减乘除+GUI五选一随机抽题;另注释移动豁免诊断日志 2026-09-27 12:11:41 +08:00
test 16214872aa test-push
Auto Release / build (push) Failing after 51s
Simple Test / test (push) Successful in 2s
2026-09-27 12:05:58 +08:00
test d8a4c820fb test: add simple workflow
Auto Release / build (push) Failing after 34s
Simple Test / test (push) Successful in 1s
2026-09-27 12:03:00 +08:00
test 31b4594c2e test: verify docker access
Auto Release / build (push) Failing after 1m7s
2026-09-27 12:00:41 +08:00
test 4f6f0d6252 debug: trigger workflow
Auto Release / build (push) Failing after 44s
2026-09-27 11:53:51 +08:00
test 499cf10aba test: verify runner works
Auto Release / build (push) Failing after 47s
2026-09-27 11:50:14 +08:00
test df86721562 fix: use ubuntu-latest for runner label compatibility
Auto Release / build (push) Failing after 13m56s
2026-09-27 11:41:21 +08:00
test 28a33e7c02 test: trigger action with restored runner
Auto Release / build (push) Canceled after 0s
2026-09-27 11:31:14 +08:00
test 5ad1843200 fix: restore runner daemon and trigger action 2026-09-27 11:31:14 +08:00
youpaishidifu 6f844b5714 修复领地主判定大小写不敏感导致越权绕过:身份比对统一改为 samePlayer 精确匹配(区分大小写),领地上限 DB 统计改精确匹配
Auto Release / build (push) Canceled after 0s
2026-09-27 10:29:33 +08:00
youpaishidifu 2429d2b614 删掉测试用的文件
Auto Release / build (push) Failing after 5s
2026-09-26 20:28:25 +08:00
test e6e7664244 fix: repair malformed release.yml YAML
Auto Release / build (push) Failing after 5s
2026-09-26 19:53:23 +08:00
test 005b8db0d4 test: verify push and hooks after packed-refs fix 2026-09-26 19:48:39 +08:00
CI 477dbab970 Test trigger new workflow 2026-09-26 10:36:04 +00:00
CI b91814d837 Trigger: update workflow for system JDK 2026-09-26 10:28:29 +00:00
CI 43df7808cd Fix-use-system-JDK 2026-09-26 10:15:59 +00:00
CI 3b8fbdf1b7 Fix: use system JDK 2026-09-26 10:07:22 +00:00
youpaishidifu 920cf79641 删除垃圾文件
Auto Release / build (push) Failing after 28s
2026-09-26 15:35:27 +08:00
youpaishidifu 1a83fe7c55 二测audo release
Auto Release / build (push) Failing after 2m1s
2026-09-26 15:15:30 +08:00
youpaishidifu bcaac7ee71 测试release 2026-09-26 15:04:43 +08:00
youpaishidifu d489c01000 测试action运行 2026-09-26 14:36:38 +08:00
youpaishidifu 411210c341 删掉垃圾文件 2026-09-26 14:33:33 +08:00
youpaishidifu b78f87db62 Merge branch 'main' of https://git.ypshidifu.cn/youpaishidifu/Sdf1_login
# Conflicts:
#	.gitea/workflows/release.yml
#	.github/workflows/release.yml
#	.gitignore
#	src/main/java/Sdf1_login/ChatInputManager.java
#	src/main/java/Sdf1_login/Main.java
2026-09-26 14:24:43 +08:00
youpaishidifu 079ff0587f 删除 test_trigger.txt 2026-09-26 05:38:46 +00:00
youpaishidifu e7c862f4e8 修复大小写变种字符玩家绕过移动限制:统一 normName 规范化匹配,per-player 权限优先于访客豁免,新增移动豁免诊断日志 2026-09-26 13:30:54 +08:00
youpaishidifu 51e56ec85e 注册/聊天改密成功后弹出60秒actionbar凭据保存提醒 2026-09-26 12:23:34 +08:00
youpaishidifu 8069070219 删除不必要的备份文件,修复几个严重bug:
问题1. 领地系统错误的把玩家名归一化引起的大小写名字、特殊字符的玩家绕过限制
修复1:取消归一化,按人头配置权限
问题2:临时密码不作为改密码的验证凭据,导致忘记真实密码后无法改密码
修复2:临时密码也作为改密码的验证凭据
2026-09-26 12:19:32 +08:00
Admin bf421cbd18 fix: move workflow to .github/workflows for Gitea Actions 2026-09-26 12:07:38 +08:00
test a517d12feb test: trigger workflow after runner fix 2026-09-26 12:05:35 +08:00
test 5cca422ba9 fix: use ubuntu-latest docker runner for JDK 25 2026-09-26 09:51:44 +08:00
test 307db659bc trigger workflow test 2026-09-26 09:42:09 +08:00
Test fe89f9649e trigger workflow test 2026-09-26 09:34:00 +08:00
Gitea Runner 6609b29fab fix: use Ubuntu container for JDK 25 compatibility 2026-09-26 09:31:33 +08:00
Gitea Runner ef03f1b90b fix: use host JDK 25 instead of actions/setup-java 2026-09-25 23:43:07 +08:00
test 1d56705365 trigger action test 1790347157 2026-09-25 22:39:31 +08:00
test 8d3442c966 trigger action 2026-09-25 22:31:56 +08:00
CI 89b876754c fix: workflow改用self-hosted runner 2026-09-25 22:24:12 +08:00
youpaishidifu 47678a4c3d 修复领地名字归一化越权 + 改密不校验临时密码
Auto Release / build (push) Failing after 2m1s
问题1(严重):领地写命令在 resolveAreaName 模糊归一化后缺少归属校验,任何玩家可用大小写/部分名/特殊字符命中他人领地执行操作。

- 新增 requireLandOwnership / requireGlobalAdmin 权限闸门,补齐 add/remove/addname/removename/addwhite/removewhite/additem/removeitem/expand/contraction/canceldelete 的领主或管理员校验;global 级操作改为仅管理员

- parseAreaAndTarget、delete、canceladminchange 改用新增的 resolveAreaNameStrict(只做完全一致匹配,不再做包含/编辑距离模糊匹配)

- 新增 validateAreaName 领地名格式校验与 areaNameExists 大小写不敏感重名检查,创建/确认创建/改名统一接入,防止特殊字符污染白名单文件名与大小写互覆盖

- 白名单增删统一按小写存取,修正 add 存小写而 remove 用原样比较导致移除失效的问题

问题2:修改密码改为同时校验真实密码与临时密码(新增 DatabaseManager.checkPasswordForChange),临时密码不再受登录用的5分钟有效期限制,避免临时密码登录后忘记真实密码被永久锁死;三条改密路径(命令两步/聊天输入两步)全部接入,改密成功后作废旧临时密码并同步Web端。

版本号 2.9 -> 2.91;PHP 后端未改动。
2026-09-25 21:25:26 +08:00
youpaishidifu f92e251bdc fix release.yml: 第18行 YAML 冒号误解析->改用 block scalar | 2026-07-24 17:39:29 +08:00
youpaishidifu 383e1bc7df bump v2.9; release workflow 从 plugin.yml 读版本号 2026-07-24 17:33:16 +08:00
youpaishidifu ca8070e31b 2.9正式版
Auto Release / build (push) Successful in 30s
2026-07-24 17:31:16 +08:00
youpaishidifu 168d4f39dc whois 补全封禁状态/理由/到期时间展示
Auto Release / build (push) Successful in 32s
2026-07-24 16:30:39 +08:00
youpaishidifu 862a2736b3 三联改:whois IP归属查询+tempban/banip封禁踢人+URL支持中文域名
Auto Release / build (push) Successful in 33s
17. whois新增IP归属查询:异步调用ip9.com.cn接口展示国家/省份/城市/运营商
18. tempban/banip订正:IP封禁时踢出所有匹配该IP的在线玩家
19. URL_PATTERN改用\p{L}支持中文域名如www.百度.com
2026-07-24 16:13:08 +08:00
youpaishidifu aaffc760f3 修复:永久确认玩家重进不再空手——进入时重新发放锁定装备
Auto Release / build (push) Successful in 30s
- 永久确认分支新增 equipFullSet 调用(背包已在进入时清空)
- SavedSelection 补充 shield/food 字段,跨次保留完整选装状态
- confirmEquipment 和 openEquipmentSelection 同步更新 6 参数构造
- 重进时自动 cancelKickTimeout + 发送就绪提示
2026-07-24 11:21:59 +08:00
youpaishidifu 8ce4d58195 PVP装备永久锁定:选装一次即锁定,退出再进入不可重选
Auto Release / build (push) Successful in 29s
- 新增 permanentlyConfirmed 集合 + savedSelections 保存选装结果
- confirmEquipment 写入永久确认状态和武器/护甲/附魔/远程选择
- openEquipmentSelection 检查 permanentlyConfirmed,有则恢复选装状态直接打开调整GUI
- 退出竞技场不再清除永久确认状态(仅食物/盾牌/弓弩可调)
- resetMatchLock 清空永久确认(世界重置时全部重新选装)
2026-07-24 10:52:57 +08:00
youpaishidifu 6149794e36 PVP竞技场选装锁定+调整GUI
Auto Release / build (push) Successful in 33s
- 确认装备后武器/护甲/附魔锁定(不可重选)
- 新增 openAdjustmentGUI 调整界面:仅食物/盾牌/弓弩可调
- handleEquipmentClick 确认模式拦截锁定选项,允许可调项立即生效
- reEquipAdjustments 只更新食物/盾牌/弓弩,不动锁定装备
- 新增 /pvp equip / /pvp adjust 命令打开调整界面
- 箭矢加 PVP_ITEM_MARKER 标签,re-adjust 时正确清理旧箭
2026-07-24 10:30:37 +08:00
youpaishidifu 0d3547dded URL拦截放宽:下划线分隔的字符串不在非法域名列表则跳过
Auto Release / build (push) Successful in 31s
- ChatFilterManager.shouldBlockUrl(): 下划线分隔的必须命中非法域名后缀才拦截
- 玩家名下划线误匹配(如 player_name)后缀不在黑名单 → 跳过不处罚
- 点分隔域名正常拦截不变
- 改进: onChat + filterLinkInContent 两处调用
2026-07-23 21:58:35 +08:00
youpaishidifu a1e1e919e1 搭桥蛋连续修复:每tick都记录桥位,不再遗漏tick2
Auto Release / build (push) Successful in 28s
- 原skip=2不更新bridgeLastLoc,skip=3用skip=1旧位导致断层
- 改前2tick每tick都记录位置,skip=3起每tick放上一tick桥位
- 方块连续排列,不再中间断开
2026-07-23 19:43:37 +08:00
youpaishidifu a145a90f41 搭桥蛋修复合自身方块碰撞:方块放上一tick位置
Auto Release / build (push) Successful in 27s
- 新增 bridgeLastLoc map 记录蛋每tick位置
- 方块不放在蛋当前位置(会撞上),改放上一tick位置
- 蛋持续飞行,仅在撞到真实障碍物时停止
2026-07-23 19:32:49 +08:00
youpaishidifu b8ccd56818 火球修复:落点产生爆炸并点燃方块
Auto Release / build (push) Successful in 30s
- fb.setYield(1.5f) 爆炸半径约1.5格
- fb.setIsIncendiary(true) 点燃爆炸范围内方块
2026-07-23 19:07:07 +08:00
youpaishidifu 89525a4e6b 搭桥蛋修复:方块改为玻璃、跳过前2tick避免生成玩家头顶
Auto Release / build (push) Successful in 30s
2026-07-23 18:13:38 +08:00
youpaishidifu 8b8823e01d PVP世界新增火球/搭桥蛋道具,奖励改為5杀一轮回
Auto Release / build (push) Successful in 31s
- 火球: PVP世界右键发射SmallFireball,命中生物造成5点伤害
- 搭桥蛋: 右键发射Egg,跟随抛物线放置玻璃方块,遇实体方块停止
- 奖励改5杀一轮回: 2杀发16熟食+1火球,5杀发16箭+1搭桥蛋+1火球+替换全身铁甲
2026-07-23 17:52:04 +08:00
youpaishidifu 38d06c3cfa chore: 删除多余的备份文件 Main.java.bak
Auto Release / build (push) Successful in 30s
2026-07-23 12:17:34 +08:00
youpaishidifu d20a525855 fix: sdf1_login get 不生效-radio块缺少闭括号导致后续子命令全部嵌套在其中
Auto Release / build (push) Successful in 29s
2026-07-23 12:17:10 +08:00
youpaishidifu faa17ded32 feat: onJoin注册封顶限制+新增/sdf1_login get自查询账号
Auto Release / build (push) Successful in 32s
2026-07-23 11:56:10 +08:00
youpaishidifu e8588ae408 fix: 禁言玩家聊天时显示详细处罚信息 全服广播不暴露违禁词
Auto Release / build (push) Successful in 29s
2026-07-23 01:12:53 +08:00
youpaishidifu 3f7c8e4001 fix: applyPunishment 通知逻辑按 chat.txt 配置的 通知管理员/全服通报 开关执行
Auto Release / build (push) Successful in 31s
2026-07-23 00:40:57 +08:00
youpaishidifu 4692b32a6b fix: applyPunishment 增加管理员通知+全服广播+详细禁言消息
Auto Release / build (push) Successful in 27s
2026-07-23 00:33:30 +08:00
youpaishidifu 5d3b2c81c9 fix: 敏感词列表配置自动补全,缺节追加预设词,已有节跳过
Auto Release / build (push) Successful in 34s
2026-07-23 00:17:52 +08:00
youpaishidifu 0ec3bc46dd fix: DurationParser NoClassDefFoundError + 新增敏感词静默过滤系统
Auto Release / build (push) Successful in 33s
2026-07-22 22:51:40 +08:00
youpaishidifu 53c805c68f fix: 领地记录翻页复用 lastQueryLocations 缓存的坐标,避免 getTargetBlockExact 不准导致查无记录
Auto Release / build (push) Successful in 31s
2026-07-22 21:36:50 +08:00
youpaishidifu 34aca10edb fix: 全面规范化白名单名称存储和比较为小写,修复大小写匹配问题
Auto Release / build (push) Successful in 33s
2026-07-22 19:46:53 +08:00
youpaishidifu cdce0c0e19 feat: PVP战绩持久化+清空队列命令+队列积压丢弃+PVP查询3天限制
Auto Release / build (push) Successful in 31s
1. PVPManager新增showArenaTotalStats/showArenaSessionStats方法
2. DatabaseManager.getArenaSessionByNumber添加3天有效期检查
3. WebManager新增clearQueue()/discardOldestIfOverflow()方法
4. Main.java新增清空队列/cleartake命令处理
5. plugin.yml注册清空队列/cleartake命令
2026-07-22 15:45:25 +08:00
youpaishidifu b488dfa04f fix: PVP竞技场NoClassDefFoundError修复 + 和平模式设置
Auto Release / build (push) Successful in 31s
1. 匿名Runnable改为lambda,消除PVPArenaManager内部类问题
2. reapplyWorldRules添加Difficulty.PEACEFUL,排除敌对生物干扰
2026-07-22 13:21:44 +08:00
youpaishidifu 9213918995 fix: 末影人行为禁用时全面拦截 - 拾取方块、AI寻路、传送、自然生成
Auto Release / build (push) Successful in 27s
2026-07-22 11:02:05 +08:00
youpaishidifu 4ae8b595e7 fix: 末影人行为权限逻辑修正 - 禁用时才拦截,开启时放行MC原生逻辑
Auto Release / build (push) Successful in 32s
2026-07-22 10:44:46 +08:00
youpaishidifu 23ab0c3457 fix: 记录分页只显示有数据的页数 - 检查下一页数据再显示下一页链接
Auto Release / build (push) Successful in 29s
2026-07-22 00:40:29 +08:00
youpaishidifu fc72e231d3 feat: 三个小改动 - 耕地破坏全局生效(含末影人抱耕地) / 末影人传送更名末影人行为(加自然生成拦截) / 领地破坏记录默认按天分页+可点击翻页
Auto Release / build (push) Successful in 32s
2026-07-22 00:26:15 +08:00
youpaishidifu c64bc718a6 实现3个新功能:耕地破坏权限(denyFarmlandTrample)+末影人传送控制(denyEnderTeleport)+容器记录按天分页
Auto Release / build (push) Successful in 27s
2026-07-21 22:49:35 +08:00
youpaishidifu e860b6ff4f 修复领地容器权限反转:删除getPermissionLevel预检查,全权交由getEffectiveDeny决定
Auto Release / build (push) Successful in 30s
2026-07-21 21:39:41 +08:00
youpaishidifu a6d70aed3c 修复领地移动权限反转:删除getPermissionLevel预检查,全权交由getEffectiveDeny决定
Auto Release / build (push) Successful in 29s
2026-07-21 21:32:27 +08:00
youpaishidifu 2fc34375d3 修复领地TP传送回退+移动权限调试日志
Auto Release / build (push) Successful in 33s
2026-07-21 21:06:30 +08:00
youpaishidifu 9958f09fcd 修复领地系统两个问题:1.移动权限侦听增加未授权玩家清出 2.删除提示只对操作者本人展示
Auto Release / build (push) Successful in 40s
2026-07-21 19:44:35 +08:00
youpaishidifu 8523e52919 清理调试日志: 高频缓冲日志精简为仅关键事件
Auto Release / build (push) Successful in 33s
2026-07-20 21:44:27 +08:00
youpaishidifu b1379350b5 修复高频出售缓冲: syncBondTransactions被定时器C绕过导致缓冲不生效,缓冲期内跳过周期推送
Auto Release / build (push) Successful in 32s
2026-07-20 21:32:06 +08:00
youpaishidifu e0644a0ec4 调试: 高频出售缓冲添加详细诊断日志,排查Linux环境缓冲不生效问题
Auto Release / build (push) Successful in 32s
2026-07-20 19:16:42 +08:00
youpaishidifu e4640d9102 移除不必要的缓冲日志
Auto Release / build (push) Successful in 26s
2026-07-20 14:52:19 +08:00
youpaishidifu 09d0be84b3 Merge remote-tracking branch 'origin/main'
Auto Release / build (push) Successful in 28s
2026-07-20 14:51:00 +08:00
youpaishidifu bc46ec27d0 移除不必要的缓冲日志 2026-07-20 14:50:19 +08:00
ypshidifu 7ab5aee87c 安全加固: .gitignore添加deploy_*.py/ps1/sh排除规则 + 支付密钥double insurance规则
Auto Release / build (push) Successful in 30s
2026-07-20 13:59:31 +08:00
youpaishidifu f19efa89a8 高频出售缓冲机制: 1分钟内单种商品≥10次时暂缓推送60秒
Auto Release / build (push) Successful in 2m39s
2026-07-20 13:18:58 +08:00
youpaishidifu 182545de59 feat: 密码错误阈值封禁(Bukkit真实封禁+邮件报警) + OAuth登录IP风控 + 配置多退少补
Auto Release / build (push) Successful in 34s
1. RiskControlManager: triggerBan改用Bukkit BanList API真实封禁玩家
   - BanList.addBan(name, reason, expiry)阻止重连加入
   - onJoin检查isBanned()双重防护
   - cleanup()定时清理过期封禁并pardon
   - unban()同步解除Bukkit封禁

2. Main.java onJoin: 新增风控封禁检查,封禁期内玩家无法加入

3. ConfigManager: loadSettings()自动补齐缺失配置项(多退少补)

4. 已有功能(上一次commit实现但未推送):
   - 密码错误阈值封禁+报警邮件
   - 正版OAuth登录IP风控
2026-07-19 14:41:04 +08:00
youpaishidifu c4a5fc9a61 fix: Add detailed logging to Create Release step for debugging
Auto Release / build (push) Successful in 27s
2026-07-19 01:23:43 +08:00
youpaishidifu caaf09fce0 fix: Gitea release tag now extracted from plugin.yml instead of build-N
Auto Release / build (push) Failing after 27s
2026-07-19 01:20:14 +08:00
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
ypsdf1 cf246d4076 chore: 添加 GitHub Issues 模板(Bug反馈/功能新增/建议提交) 2026-05-20 14:21:35 +08:00
ypsdf1 c747dbcc76 docs: 更新 README,同步文档站最新 Sdf1_login 使用说明 2026-05-20 14:18:59 +08:00
ypsdf1 66f1e33d5e Merge branch 'main' of https://github.com/ypsdf1/sdf1_login 2026-05-20 14:16:50 +08:00
ypsdf1 717a7a9266 feat: 完善自定义菜单物品功能,新增 MenuIconManager 支持自定义图标 2026-05-20 14:16:30 +08:00
ypsdf1 34d46a7d12 docs: 修复 README 图片路径,../img/ 改为 img/ 2026-05-19 18:58:13 +08:00
ypsdf1 1f12d3ec30 docs: 更新 README,同步 sdf1_login wiki 最新说明文档 2026-05-19 18:53:25 +08:00
ypsdf1 1a58caec96 feat: 新增菜单自定义功能 MenuManager,优化 GUIManager/Main/RadioManager 等模块 2026-05-19 18:41:08 +08:00
ypsdf1 ea93ccc087 修复签到功能任意参数均视为成功的问题,加入签到给经济功能 2026-05-18 15:29:22 +08:00
ypsdf1 302328bc8e Update: 最新功能更新 - Main/RadioManager/GUIManager/LoginManager等模块优化 2026-05-15 20:50:12 +08:00
121 changed files with 82655 additions and 25936 deletions
+21
View File
@@ -0,0 +1,21 @@
name: Check Source
on:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check workspace
run: |
echo "=== PWD ==="
pwd
echo "=== LS ==="
ls -la
echo "=== FIND POM ==="
find / -name "pom.xml" 2>/dev/null
echo "=== ENV ==="
env | grep -iE "workspace|repo|git" | sort
echo "=== DONE ==="
+123
View File
@@ -0,0 +1,123 @@
name: Auto Release
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Setup JDK 25
run: |
echo "Setting up JDK 25..."
apt-get update -qq
apt-get install -y -qq wget unzip
wget -q https://api.adoptium.net/v3/binary/latest/25/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk -O /tmp/jdk25.tar.gz
tar -xzf /tmp/jdk25.tar.gz -C /opt
export JAVA_HOME=$(ls -d /opt/jdk-25* | head -1)
echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
echo "JDK 25 installed: $($JAVA_HOME/bin/java -version 2>&1 | head -1)"
echo "Setup completed"
- name: Setup Maven
run: |
echo "Setting up Maven..."
apt-get install -y -qq maven
echo "Maven version: $(mvn -version 2>&1 | head -1)"
echo "Setup completed"
- name: Checkout code
run: |
echo "Checking out code..."
GIT_HOST=${GITHUB_SERVER_URL#http://}
GIT_HOST=${GIT_HOST#https://}
rm -rf /workspace/youpaishidifu/Sdf1_login/*
rm -rf /workspace/youpaishidifu/Sdf1_login/.[!.]*
git clone "http://${GIT_HOST}/$(echo ${GITHUB_REPOSITORY} | tr '[:upper:]' '[:lower:]').git" .
echo "Checkout completed"
- name: Build with Maven
run: |
echo "Building with Maven..."
mvn -B clean package -DskipTests
echo "Build completed"
- name: Verify JAR
run: |
echo "Verifying JAR..."
ls -la target/*.jar
echo "Verification completed"
- name: Generate Release Notes
run: |
echo "Generating release notes..."
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
TAG="v$VERSION"
# Generate release notes from last 10 commits
echo "## Changes in $TAG" > /tmp/release_body.md
echo "" >> /tmp/release_body.md
git log --format="- **%h** %s (%an, %ad)" --date=short -10 >> /tmp/release_body.md
echo "" >> /tmp/release_body.md
echo "### Full changelog" >> /tmp/release_body.md
echo "" >> /tmp/release_body.md
echo "[View all commits](/youpaishidifu/Sdf1_login/commits/main)" >> /tmp/release_body.md
echo "Release notes generated for $TAG"
cat /tmp/release_body.md
- name: Create Release
env:
GITEA_TOKEN: 194187cbdc8e1f1d3bc810b1b9e07ddc904a1f73
run: |
set -e
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
TAG="v$VERSION"
BASE="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
echo "Version: $VERSION, Tag: $TAG"
echo "=== Checking existing tag $TAG ==="
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG")
echo "Check tag HTTP: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
OLD_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
echo "Tag $TAG exists (release #$OLD_ID), deleting..."
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/$OLD_ID"
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/tags/$TAG"
sleep 1
fi
echo "=== Creating release ==="
# Use python3 to construct proper JSON
python3 -c "
import json, sys
body = open('/tmp/release_body.md').read()
data = {'tag_name': '$TAG', 'name': '$TAG', 'body': body, 'draft': False, 'prerelease': False}
print(json.dumps(data))
" > /tmp/release_payload.json
RESP=$(curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"$BASE/releases" \
-d @/tmp/release_payload.json)
RELEASE_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
echo "Created release #$RELEASE_ID"
echo "=== Uploading artifact ==="
JAR=$(ls target/*.jar | head -1)
UPLOAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/jar" \
"$BASE/releases/$RELEASE_ID/assets?name=$(basename $JAR)" \
--data-binary "@$JAR")
echo "Upload HTTP: $UPLOAD_CODE"
echo "=== Release $TAG created successfully ==="
+12
View File
@@ -0,0 +1,12 @@
name: Simple Test
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Hello
run: echo "Hello from Gitea Actions!"
+15
View File
@@ -0,0 +1,15 @@
name: Test Env
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Show env
run: |
echo "=== ENV ==="
env | sort
echo "=== DONE ==="
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug 反馈
about: 报告一个 Bug 以帮助改进插件
title: "[Bug] "
labels: bug
assignees: ''
---
## Bug 描述
请简明扼要地描述 Bug 是什么。
## 复现步骤
1. 进入 ...
2. 点击 ...
3. 出现错误 ...
## 期望行为
描述你期望发生的事情。
## 实际行为
描述实际发生了什么。
## 服务端信息
- 服务器类型和版本(如 Paper 1.21.4):
- 插件版本:
## 截图 / 日志
如有相关截图或报错日志,请粘贴在此处。
## 其他信息
补充任何你认为有助于解决问题的信息。
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: 问题排查指南
url: https://github.com/ypsdf1/sdf1_login
about: 在提交 Bug 前,请先查阅文档确认是否已有相关说明。
@@ -0,0 +1,20 @@
---
name: 功能新增
about: 提出一个新功能的需求建议
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 功能描述
请描述你希望添加的新功能。
## 动机
为什么需要这个功能?它能解决什么问题?
## 建议的实现方式
如果对实现方式有想法,请在此描述。
## 额外信息
补充任何你认为有助于理解需求的上下文。
+17
View File
@@ -0,0 +1,17 @@
---
name: 建议提交
about: 对插件提出改进建议或反馈
title: "[Suggestion] "
labels: suggestion
assignees: ''
---
## 建议内容
请描述你的建议或想法。
## 背景
什么场景下产生的这个建议?
## 预期效果
你认为采用这个建议后能达到什么效果?
+39
View File
@@ -0,0 +1,39 @@
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: Extract version from plugin.yml
id: version
run: |
echo "version=$(grep '^version: ' src/main/resources/plugin.yml | sed 's/version: //')" >> $GITHUB_OUTPUT
- 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: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
files: target/Sdf1_login.jar
generate_release_notes: true
+85 -1
View File
@@ -36,4 +36,88 @@ build/
.vscode/
### Mac OS ###
.DS_Store
.DS_Store
# 开发辅助文件(本地保留,不推送到仓库)
/代码关联关系.md
txt/
*.xlsx
*.patch
backup/
新建*.txt
群戏归类.txt
upload_sftp.py
deploy_*.py
deploy_*.ps1
deploy_*.sh
# 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
# 支付密钥 - 严禁提交 (double insurance,配合 Web/.gitignore)
Web/密钥文件_git拉黑.txt
web/密钥文件_git拉黑.txt
Web/pay_secrets.php
web/pay_secrets.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
# 屏蔽patch文件
*.patch
# 屏蔽wb产生的工作文件
.workbuddy/
# 屏蔽备份文件
backup/
# 屏蔽根目录下的所有json
/*.json
# 屏蔽垃圾文件
*.idx
*.pack
*.rar
test.*
/*.rev
# 屏蔽全部日志文件
*.log
# 屏蔽开发计划相关文件
*.xlsx
*.docs
*.doc
*.txt
# 屏蔽全部部署脚本(wb用)
*.py
# 屏蔽其它文件
*.md
# 过滤readme
!readme.md
+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>
+557 -57
View File
@@ -1,112 +1,612 @@
# Sdf1系列登录插件使用说明
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态 |
| :--- | :--- | :--- |
| 名字 | 描述/作用 | 施工状态 |
| :---: | :---: | :---: |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 半成品 |
| 工单系统 | 用户提交工单 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
| 新人礼包 | 新玩家7日礼包 | 已完成 |
| 挂机检测 | 挂机检测踢出 | 已完成 |
| 自定义菜单 | 可以自己diy指令 | 已完成 |
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工 |
| 商城系统| 内置游戏商人可供销售商品 | 已完工 |
| 虚拟货币 | 供签到、游戏商人使用的货币系统 | 已完工 |
## 2.安装说明:
| 前置插件 | 选装类型 | 功能作用 |
| :--- | :--- | :--- |
| [CY_beibao](https://git.ypshidifu.cn/youpaishidifu/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量 |
| EssentialsX | 可选 | 主流经济系统 |
| [sdf1](https://git.ypshidifu.cn/youpaishidifu/Sdf1_plugin) | 可选,推荐 | CDK兑换系统 |
## 2. 安装说明
| 前置插件 | 选装类型 |
| :--- | :--- |
| CY_beibao | 可选 |
| EssentialsX | 可选 |
### 2.1 下载
您可以从GitHub和Gitee下载插件,将其放入服务器plugin目录,重启服务器即可。
您可以从GitHub和Gitee、gitcode下载到插件,将其丢入服务器plugin目录,重启服务器即可。
### 2.2 配置文件
### 2.2. 配置文件
#### 2.2.1 消息.txt
此文件控制整个插件的所有提示消息,修改后执行 /sdf1_login reload 重载插件。
此文件控制着整个插件的所有提示消息,您可以根据自己的需求修改,修改后需要执行 /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>`进行段落换行
#### 2.2.2 挂机白名单.txt
挂机踢出开启时,该配置下的玩家不受影响。
#### 2.2.3 SMTP设置.txt
控制SMTP邮件服务器配置,用于找回密码时发送邮件。
```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 重载
# ==========================================
#### 2.2.4 插件设置.txt
管控整个插件,包括账号注册限制、管理员密码等。
# ---- 登录注册相关 ----
# 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密码错误,请重试
#### 2.2.5 chat.txt
控制聊天过滤,处理违规链接。
# ---- 密码相关 ----
# 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临时密码已发送到您的邮箱,请查收
## 3. 指令
# ---- 签到积分相关 ----
# 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} 积分
- Sdf1_login 主指令
- Sdf1_login reload 重载配置
- reg 注册账号
- login 登录账号
- l 登录账号
# ---- 挂机检测相关 ----
# afk_set_enabled: 挂机检测开启
afk_set_enabled=§a挂机检测已开启
# afk_set_disabled: 挂机检测关闭
afk_set_disabled=§c挂机检测已关闭
# afk_kick: 因挂机被踢出
afk_kick=§c因挂机过久被踢出
## 4. 运行截图
# ---- 邮箱相关 ----
# 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 违规
```
## php端说明:
1. 默认密码ypshidifu2026<br>
2. 工单系统全面支持markdown语法:
```md
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
表格语法
| 表格列1 | 表格列2 |
| --- | --- |
| 表格1 | 表格2 |
>单嵌套语法
>嵌套块
>
>>语法
![](图片链接) 图片语法
[]() 超链接语法
<br>换行语法
代码块语法
```
## 工单状态:
| 状态名 | 是否可回复 | 备注说明 |
| :---: | :---: | :---: |
| 已提交 | 可回复 | 初始化状态 |
| 已回复 | 可回复 | 玩家自己回复了/管理员/服务商回复了 |
| 已完结 | 不可回复 | 管理员驳回了工单请求 |
| 已撤销 | 不可回复 | 用户撤销了工单请求 |
## 服务商说明
- 若您需要授权某个玩家为服务商,请登录游戏,打开工单系统-管理面板-添加服务商,然后输入他的名字即可邀请服务商
## 运行截图
主界面
![主界面](img/login.png)
![](img/login.png)
邀请数据
![邀请数据](img/login2.png)
![](img/login2.png)
积分商店
![积分商店](img/login3.png)
![](img/login3.png)
任务中心
![任务中心](img/login4.png)
![](img/login4.png)
垃圾箱
![垃圾箱](img/login5.png)
![](img/login5.png)
管理员面板
![管理员面板](img/login6.png)
![](img/login6.png)
管理面板-用户管理
![](img/login7.png)
![用户管理](img/login7.png)
工单中心
![工单中心](img/login8.png)
![](img/login8.png)
工单管理
![工单管理](img/login9.png)
![](img/login9.png)
我的工单
![](img/login10.png)
![我的工单](img/login10.png)
提交工单
![提交工单](img/login11.png)
![](img/login11.png)
服务商列表
![服务商列表](img/login12.png)
![](img/login12.png)
服务商抢单大厅
![抢单大厅](img/login13.png)
![](img/login13.png)
服务商-接单界面
![](img/login14.png)
![接单界面](img/login14.png)
管理员-工单详情页面
![](img/login15.png)
管理员-工单详情
商城主界面
![](img/shop1.png)
![工单详情](img/login15.png)
商城购物车
![](img/cart1.png)
## 5. 下载地址
商城退单
![](img/shop2.png)
- GitHub: https://github.com/ypsdf1/sdf1_login
- Gitee: https://gitee.com/nihaoshidifu/sdf1_login
## 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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+11 -12
View File
@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
@@ -27,18 +27,18 @@
</repositories>
<dependencies>
<!-- ★ 与测试服/生产服运行时保持一致(Paper 26.x)。
26.x 的命令分发改走 Brigadier:PlayerCommandPreprocessEvent 已
@Deprecated 且不再是拦截入口,未命中的唯一拦截点是
UnknownCommandEvent;聊天事件是 AsyncChatEvent 而非
AsyncPlayerChatEvent。用 1.21.4 编译会因签名不匹配直接编不过。 -->
<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>
<version>26.2.build.129-stable</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
@@ -54,13 +54,12 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<version>3.15.0</version>
<configuration>
<source>21</source>
<target>21</target>
<release>25</release>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>
+197 -197
View File
@@ -1,197 +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 + "秒" : "");
}
}
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 + "秒" : "");
}
}
@@ -1,146 +1,159 @@
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";
}
}
}
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;
// ★ 精准匹配:TEST 与 test 是两个人,名字必须完全相等才删
if (plugin.getDb().findExactUserName(req.target) == null) {
String twin = plugin.getDb()
.findSimilarUserNameIgnoreCase(req.target);
System.out.println("[Sdf1_login] 删号申请 #"+ id
+ " 未执行:账号 " + req.target
+ (twin != null ? " 不存在,仅存在大小写不同的账号 " + twin : " 不存在")
+ "(精准匹配,未误删)");
return false;
}
int deleted = plugin.getDb().deleteUser(req.target);
if (deleted <= 0) {
return false;
}
req.status = "APPROVED";
pendingByPlayer.remove(req.requester);
// 清背包+传送出生点+踢出(★ getPlayerExact:避免误伤大小写不同的在线玩家)
Player target = Bukkit.getPlayerExact(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";
}
}
}
@@ -0,0 +1,376 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.advancement.Advancement;
import org.bukkit.advancement.AdvancementProgress;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import com.destroystokyo.paper.event.player.PlayerAdvancementCriterionGrantEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* ★ 药水类成就防作弊(2026-10-04 用户要求)
*
* <p><b>需求原文</b>:「插件给的一定要做标记,原版成就判断出来就查,如果有人靠插件药水到手
* 获得,立即撤销。……两条关联成就必须要玩家手打。绝不能靠插件作弊。」</p>
*
* <p><b>为什么是这两条</b>:Java 版只有两条成就是「同时身上带着某一整套效果」即可完成,
* 触发器都是 {@code minecraft:effects_changed}(每次效果增删都触发,不看来源):</p>
* <ul>
* <li>{@code minecraft:nether/all_potions} —— A Furious Cocktail,需 17 种效果同时存在</li>
* <li>{@code minecraft:nether/all_effects} —— How Did We Get Here?,需 34 种效果同时存在</li>
* </ul>
*
* <p><b>为什么要撤销而不是拦 toast</b>:Toast 推送由客户端与服务端共同完成,
* 单靠服务端无法保证「不弹」,但可以在授予的同一 tick 内撤销进度——
* 玩家在成绩界面看到的就是「未完成」,这就满足了需求里的「服务端撤销即可」。</p>
*
* <p><b>判定口径</b>:只要玩家当前身上的效果集合里,<b>有任意一项</b>是本插件给出去的
* (即带插件标记),就撤销这两条成就。<b>不撤销别的成就</b>——只有这两条的判定完全由
* 效果集合构成,其他成就(酿造药水、击杀生物、探险家等)跟插件效果无关,误伤代价大。</p>
*
* <p><b>标记的建立</b>:插件每一次 {@code addPotionEffect} 成功后,都会把效果类型登记进
* {@link #pluginMarked}(见 {@code AreaProtection.markPluginEffects})。这份标记在玩家退出
* 领地、效果自然到期时统一清理,避免「插件给过一次就永久背锅」。</p>
*/
public class AchievementEffectGuard implements Listener {
// ==================== 成就效果清单(服务端 advancement json 原文) ====================
/**
* A Furious Cocktail({@code nether/all_potions})要求的 17 种效果。
* 取自 {@code data/minecraft/advancement/nether/all_potions.json} 的 criteria.effects 键集。
*/
private static final Set<String> ADV_SET_ALL_POTIONS = Set.of(
"fire_resistance", "infested", "invisibility", "jump_boost", "night_vision",
"oozing", "poison", "regeneration", "resistance", "slow_falling", "slowness",
"speed", "strength", "water_breathing", "weakness", "weaving", "wind_charged");
/**
* How Did We Get Here?({@code nether/all_effects})要求的 34 种效果。
* 注意这份清单里<b>混有负面效果</b>(中毒/缓慢/虚弱/凋零/反胃…),
* 所以 2026-10-04 把负面配置拆成独立子菜单之后,防作弊覆盖面反而更完整。
*/
private static final Set<String> ADV_SET_ALL_EFFECTS = Set.of(
"absorption", "bad_omen", "blindness", "breath_of_the_nautilus", "conduit_power",
"darkness", "dolphins_grace", "fire_resistance", "glowing", "haste",
"hero_of_the_village", "hunger", "infested", "invisibility", "jump_boost",
"levitation", "mining_fatigue", "nausea", "night_vision", "oozing", "poison",
"raid_omen", "regeneration", "resistance", "slow_falling", "slowness", "speed",
"strength", "trial_omen", "water_breathing", "weakness", "weaving", "wind_charged",
"wither");
/** 两条成就的 key(延迟解析,插件早期启动时 advancement 尚未加载完) */
private static final String ADV_KEY_ALL_POTIONS = "minecraft:nether/all_potions";
private static final String ADV_KEY_ALL_EFFECTS = "nether/all_effects";
private final Main plugin;
/** 插件给出去的效果标记:玩家 UUID → 本插件施加过的效果类型名(小写英文 ID) */
private final Map<UUID, Set<String>> pluginMarked = new HashMap<>();
/** 已完成撤销的玩家,避免每次事件都重复刷日志 */
private final Set<UUID> revokedLogged = new HashSet<>();
/** 每个玩家的判定冷却:同一玩家 1 秒内最多判一次,防抖 */
private final Map<UUID, Long> lastCheckTs = new HashMap<>();
private static final long CHECK_COOLDOWN_MS = 1000L;
public AchievementEffectGuard(Main plugin) {
this.plugin = plugin;
}
// ==================== 标记接口(供 AreaProtection 调用) ====================
/**
* 登记「这些效果是插件给的」。
*
* @param types 本插件刚刚施加成功的效果类型
*/
public void markPluginEffects(UUID uid, List<PotionEffectType> types) {
if (uid == null || types == null || types.isEmpty()) return;
Set<String> set = pluginMarked.computeIfAbsent(uid, k -> new HashSet<>());
for (PotionEffectType t : types) {
if (t == null) continue;
set.add(normalizeEffectId(t));
}
}
/**
* 清掉某玩家的插件效果标记(离开领地 / 插件不再持有该效果时调用)。
*
* @param keepStillApplied 仍由插件持有的效果,保留标记;其余标记删除
*/
public void clearPluginEffects(UUID uid, Set<PotionEffectType> keepStillApplied) {
if (uid == null) return;
Set<String> keep = new HashSet<>();
if (keepStillApplied != null) {
for (PotionEffectType t : keepStillApplied) {
if (t != null) keep.add(normalizeEffectId(t));
}
}
Set<String> cur = pluginMarked.get(uid);
if (cur == null) return;
cur.retainAll(keep);
if (cur.isEmpty()) pluginMarked.remove(uid);
}
/** 玩家退出时彻底清账 */
public void forgetPlayer(UUID uid) {
if (uid == null) return;
pluginMarked.remove(uid);
revokedLogged.remove(uid);
lastCheckTs.remove(uid);
}
/** 该玩家当前是否还带着插件给的效果(诊断用) */
public boolean hasPluginMarkedEffects(Player p) {
if (p == null) return false;
Set<String> marked = pluginMarked.get(p.getUniqueId());
if (marked == null || marked.isEmpty()) return false;
for (PotionEffect eff : p.getActivePotionEffects()) {
if (marked.contains(normalizeEffectId(eff.getType()))) return true;
}
return false;
}
// ==================== 事件监听 ====================
/**
* 每次效果变化都复核一遍。
*
* <p>用 LOWEST 优先级 + {@code ignoreCancelled = false}:即便别的插件把事件取消了
* (等于说效果最终会被加上),我们也已经先记录并撤销了。</p>
*/
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
public void onPotionEffectChange(EntityPotionEffectEvent event) {
if (!(event.getEntity() instanceof Player)) return;
Player p = (Player) event.getEntity();
// 插件自己施加的:立刻打标记(与 AreaProtection 的登记互为兜底)
if (event.getCause() == EntityPotionEffectEvent.Cause.PLUGIN
&& event.getAction() != EntityPotionEffectEvent.Action.CLEARED
&& event.getAction() != EntityPotionEffectEvent.Action.REMOVED) {
PotionEffectType type = event.getModifiedType();
if (type != null) {
markPluginEffects(p.getUniqueId(), Collections.singletonList(type));
}
}
// 效果被移除/清除时,同步把标记里已经不生效的项剔掉
if (event.getAction() == EntityPotionEffectEvent.Action.CLEARED) {
pluginMarked.remove(p.getUniqueId());
} else if (event.getAction() == EntityPotionEffectEvent.Action.REMOVED) {
Set<String> marked = pluginMarked.get(p.getUniqueId());
if (marked != null && event.getModifiedType() != null) {
marked.remove(normalizeEffectId(event.getModifiedType()));
if (marked.isEmpty()) pluginMarked.remove(p.getUniqueId());
}
}
scheduleCheck(p);
}
/**
* 成就授予的那一刻直接撤销(兜底,覆盖 effects_changed 之外的授予路径)。
*/
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void onAdvancementCriterionGrant(PlayerAdvancementCriterionGrantEvent event) {
Advancement adv = event.getAdvancement();
if (adv == null || adv.getKey() == null) return;
String key = adv.getKey().toString(); // 形如 nether/all_potions
if (!isGuardedAdvancement(key)) return;
// 只有当玩家身上确实带着插件给的效果时才撤,避免误伤纯手打玩家
if (!hasPluginMarkedEffects(event.getPlayer())) return;
event.setCancelled(true);
revokeAll(event.getPlayer(), key, "成就授予时检测到插件来源效果");
}
/** 玩家进服立刻复核一次(防止插件重启期间拿到成就、或上次下线前漏撤) */
@EventHandler
public void onJoin(PlayerJoinEvent event) {
// 延后一 tick:刚进服时 effect / advancement 数据可能还没完全同步
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Player p = event.getPlayer();
if (p != null && p.isOnline()) {
checkAndRevoke(p, false);
}
}, 20L);
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
forgetPlayer(event.getPlayer().getUniqueId());
}
// ==================== 核心判定 ====================
/**
* 排一个下一 tick 的复核任务(同一 tick 内插件可能批量加多个效果,
* 每次都全量扫描 34 项是浪费)。
*/
private void scheduleCheck(Player p) {
Bukkit.getScheduler().runTask(plugin, () -> {
if (p != null && p.isOnline()) {
checkAndRevoke(p, true);
}
});
}
/**
* 判定并撤销。
*
* @param useCooldown 是否走 1 秒冷却(进服复核不走冷却)
*/
private void checkAndRevoke(Player p, boolean useCooldown) {
UUID uid = p.getUniqueId();
long now = System.currentTimeMillis();
if (useCooldown) {
Long last = lastCheckTs.get(uid);
if (last != null && now - last < CHECK_COOLDOWN_MS) return;
lastCheckTs.put(uid, now);
}
Set<String> marked = pluginMarked.get(uid);
if (marked == null || marked.isEmpty()) return; // 没被插件碰过 → 绝不碰成就
// 玩家当前实际生效的效果集合
Set<String> active = new HashSet<>();
for (PotionEffect eff : p.getActivePotionEffects()) {
active.add(normalizeEffectId(eff.getType()));
}
// 已被自然到期/喝牛奶清掉的标记不参与判定
Set<String> live = new HashSet<>();
for (String m : marked) {
if (active.contains(m)) live.add(m);
}
if (live.isEmpty()) {
pluginMarked.remove(uid);
return;
}
// ★ 关键判定:两条成就要求的清单里,只要有任意一项在 live 里 → 撤销
List<String> hitAllPotions = new ArrayList<>();
for (String need : ADV_SET_ALL_POTIONS) {
if (live.contains(need)) hitAllPotions.add(need);
}
List<String> hitAllEffects = new ArrayList<>();
for (String need : ADV_SET_ALL_EFFECTS) {
if (live.contains(need)) hitAllEffects.add(need);
}
if (!hitAllPotions.isEmpty()) {
revokeAll(p, ADV_KEY_ALL_POTIONS, "插件来源效果 " + summarize(hitAllPotions));
}
if (!hitAllEffects.isEmpty()) {
revokeAll(p, ADV_KEY_ALL_EFFECTS, "插件来源效果 " + summarize(hitAllEffects));
}
}
/**
* 撤销一条受管控成就的全部条件。
*
* <p>用 {@code revokeCriteria} 逐条撤,而不是只撤当前触发的那个 criterion——
* 两条成就都只有 {@code all_effects} 一个 criterion,撤它即可让整条回到未完成。</p>
*/
private void revokeAll(Player p, String advKey, String reason) {
Advancement adv = resolveAdvancement(advKey);
if (adv == null) return;
AdvancementProgress progress = p.getAdvancementProgress(adv);
if (progress == null || !progress.isDone()) return; // 本来就没完成,不动
Set<String> remaining = new HashSet<>(progress.getRemainingCriteria());
int revoked = 0;
for (String crit : progress.getAwardedCriteria()) {
if (progress.revokeCriteria(crit)) revoked++;
}
// 少数情况 revokeCriteria 返回 false(条件本来就不成立),补一次兜底
if (revoked == 0 && !remaining.isEmpty()) {
for (String crit : progress.getRemainingCriteria()) {
remaining.remove(crit);
}
}
if (revokedLogged.add(p.getUniqueId())) {
plugin.getLogger().warning("[成就防作弊] 已撤销 " + advKey
+ " 玩家=" + p.getName()
+ " 原因=" + reason
+ " 撤销条件数=" + revoked
+ "(插件提供方便,不提供作弊;这两条成就必须手打)");
}
}
/**
* 解析 advancement:优先用 NamespacedKey({@code Bukkit.getAdvancement}),
* 失败则回退成 Bukkit 可识别的短 key。
*/
private Advancement resolveAdvancement(String key) {
String plain = key.startsWith("minecraft:") ? key.substring("minecraft:".length()) : key;
Advancement adv = null;
try {
adv = Bukkit.getAdvancement(NamespacedKey.minecraft(plain));
} catch (Throwable ignored) { }
if (adv == null) {
try {
adv = Bukkit.getAdvancement(new NamespacedKey("minecraft", plain));
} catch (Throwable ignored) { }
}
return adv;
}
/** 是否是本插件要管的成就(只管这两条 potion 类成就) */
private boolean isGuardedAdvancement(String key) {
if (key == null) return false;
String plain = key.startsWith("minecraft:") ? key.substring("minecraft:".length()) : key;
return plain.equals(ADV_KEY_ALL_POTIONS) || plain.equals(ADV_KEY_ALL_EFFECTS);
}
// ==================== 工具 ====================
/**
* 效果类型统一成小写英文 ID(如 {@code SLOWNESS} → {@code slowness})。
*
* <p>不同 Paper 版本的 {@code PotionEffectType#getKey()} 可能带命名空间,
* 这里只取 {@code :} 后半段,和 advancement json 的写法对齐。</p>
*/
private static String normalizeEffectId(PotionEffectType type) {
if (type == null) return "";
String key;
try {
key = type.getKey().toString(); // 形如 minecraft:speed
} catch (Throwable t) {
key = type.getName(); // 老版本退路
}
if (key == null) return "";
int idx = key.indexOf(':');
if (idx >= 0) key = key.substring(idx + 1);
return key.toLowerCase(java.util.Locale.ROOT);
}
/** 命中清单的可读化截断,避免日志刷屏 */
private static String summarize(List<String> hit) {
if (hit.size() <= 6) return String.join(",", hit);
return String.join(",", hit.subList(0, 6)) + " …共" + hit.size() + "项";
}
}
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
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
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", "解冻");
TYPE_NAMES.put("ticket_reward", "工单奖励");
TYPE_NAMES.put("ticket_service", "工单服务奖励");
}
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() : "";
}
}
+301
View File
@@ -0,0 +1,301 @@
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(
new File(plugin.getDataFolder(), "财务债券"),
"bond.db");
dbFile.getParentFile().mkdirs();
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);
return;
}
if ("points".equals(input.type)) {
handlePointsInput(p, msg.trim(),
input.context);
return;
}
if ("gift_target".equals(input.type)) {
// 赠予用户组:输入目标玩家名
String targetName = msg.trim();
if (!targetName.isEmpty()) {
GUIManager guiMgr = plugin.getGui();
if (guiMgr != null) {
guiMgr.handleGiftTargetInput(p, targetName);
}
} else {
p.sendMessage("§c请输入有效的玩家名");
}
return;
}
}
private void handleEconInput(Player admin,
String msg,
String target) {
// Economy已移除,所有经济操作改用债券系统
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格式错误");
}
}
private void handlePointsInput(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();
int amount = Integer.parseInt(rest);
if (amount <= 0) {
admin.sendMessage("§c金额必须>0");
return;
}
if ("-".equals(sign)) {
if (plugin.getDb().deductPoints(
target, amount)) {
admin.sendMessage("§a扣除成功");
} else {
admin.sendMessage("§c积分不足");
}
} else {
plugin.getDb().addPoints(
target, amount);
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;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+305 -114
View File
@@ -1,114 +1,305 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
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 points = (int) (1 * 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);
return plugin.getConfig2()
.msg("checkin_success")
.replace("{points}",
String.valueOf(points))
.replace("{streak}",
String.valueOf(streak))
.replace("{multi}",
String.valueOf(multi));
}
public String backCheckIn(Player p, String dateStr) {
String name = p.getName();
if (!plugin.getDb().deductPoints(name, 10)) {
return plugin.getConfig2()
.msg("checkin_already");
}
try {
java.util.Date target = new SimpleDateFormat(
"yyyy-MM-dd").parse(dateStr);
java.util.Date now = new SimpleDateFormat(
"yyyy-MM-dd").parse(
new SimpleDateFormat("yyyy-MM-dd")
.format(new Date()));
long diff = now.getTime() - target.getTime();
if (diff < 0 || diff > 3 * 86400000L) {
plugin.getDb().addPoints(name, 10);
return plugin.getConfig2()
.msg("checkin_already");
}
plugin.getDb().addPoints(name, 1);
return "§a补签成功!日期: " + dateStr;
} catch (Exception e) {
plugin.getDb().addPoints(name, 10);
return "§c日期格式错误,请用 yyyy-MM-dd";
}
}
}
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());
}
}
@@ -149,18 +149,8 @@ public class CommissionManager {
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);
// Economy已移除,佣金提成功能不可用
// 邀请人可通过债券系统获取奖励
// 通知邀请人
Player inviterP =
File diff suppressed because it is too large Load Diff
+653
View File
@@ -0,0 +1,653 @@
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状态: "
+ freezeText(name));
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 <玩家> [时长] =====
// ★ 2026-10-04 改版:时长可选,硬编封顶 30 天,
// 不传时长 = 永久冻结;支持 1分钟/壹分钟/one min/时间戳
if (args.length >= 2
&& "freeze".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
long until = 0L; // 0 = 永久冻结
String durNote = "永久";
if (args.length >= 3) {
String durStr = String.join(" ",
Arrays.copyOfRange(args, 2,
args.length));
long expireMs;
try {
expireMs = DurationParser
.parseToExpireMs(durStr);
} catch (IllegalArgumentException ex) {
sender.sendMessage("§c时长格式无法解析: "
+ ex.getMessage());
sender.sendMessage("§7可用写法: 30分钟 / "
+ "壹小时 / one min / 7天 / "
+ "253402271999(时间戳)");
sender.sendMessage("§7不传时长 = 永久冻结,"
+ "时长封顶 30 天");
return true;
}
long now = System.currentTimeMillis();
long MAX_MS =
30L * 24 * 3600 * 1000L; // 封顶30天
if (expireMs <= now) {
// 过期/为零的时间戳(如 0000000000)-> 永久
sender.sendMessage("§7该时间点已过期,"
+ "按 §c永久冻结 §7处理");
until = 0L;
} else if (expireMs - now > MAX_MS) {
until = now + MAX_MS;
sender.sendMessage("§7时长超过 30 天,"
+ "已按封顶 §e30天 §7处理");
} else {
until = expireMs;
}
durNote = until <= 0L ? "永久"
: "至 " + DurationParser
.formatExpire(until);
}
plugin.getBonds().freezeAccount(target, until);
sender.sendMessage("§a已冻结 " + target
+ " 的债券账户(" + durNote + ")");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§c§l[系统] 你的债券账户已被管理员冻结"
+ (until <= 0L ? ""
: ",到期时间: "
+ DurationParser
.formatExpire(until)));
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状态: "
+ freezeText(target));
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 && args.length <= 4) {
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";
// 自定义理由(可选)
String reason = args.length == 4
? args[3]
: ("give".equals(action)
? "管理员发放"
: "管理员扣除");
if ("give".equals(action)) {
// ★ 冻结期豁免:管理员手动 give 允许动账
plugin.getBonds().addBondsAdmin(
target, amount,
txType, "",
opName, reason);
sender.sendMessage(
"§a已给 " + target
+ " " + amount
+ " 债券");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§a管理员给了你 "
+ amount
+ " 债券");
} else {
// ★ 冻结期豁免:管理员手动 remove
// 允许动账(deductBondsAdmin 不查冻结)
if (plugin.getBonds()
.deductBondsAdmin(target,
amount,
txType, "",
opName,
reason)) {
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状态: "
+ freezeText(target));
} else {
int bonds = plugin.getBonds()
.getBonds(
sender.getName());
sender.sendMessage(
"§e§l[债券] §f你的余额: §a"
+ bonds);
}
return true;
}
showHelp(sender);
return true;
}
// ===== 冻结状态文案(含到期时间) =====
/** 正常 / 冻结中(永久) / 冻结中(至 yyyy年M月d日 HH:mm) */
private String freezeText(String target) {
if (!plugin.getBonds().isFrozen(target))
return "§a正常";
long until = plugin.getBonds()
.getFreezeUntil(target);
if (until <= 0L) return "§c冻结中(永久)";
return "§c冻结中(至 "
+ DurationParser.formatExpire(until) + ")";
}
// ===== 流水行格式化 =====
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- 冻结"
+ "(不填=永久,封顶30天)");
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 ("freeze".equals(first)) {
list.add("10分钟");
list.add("1小时");
list.add("one min");
list.add("7天");
list.add("30天");
return filter(list,
args[2].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());
}
}
if (args.length == 4) {
String first = args[0].toLowerCase();
if ("give".equals(first)
|| "remove".equals(first)) {
list.add("管理员发放");
list.add("管理员扣除");
list.add("工单奖励");
list.add("活动奖励");
return filter(list,
args[3].toLowerCase());
}
}
return list;
}
}
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.scheduler.BukkitRunnable;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 死亡报告系统
* - 记录玩家死亡坐标(一次性,新死亡覆盖)
* - 死亡后发送包含坐标的消息
*/
public class DeathReport implements Listener {
private final Main plugin;
// 内存缓存: player_name → DeathData
private final ConcurrentHashMap<String, DeathData> deathReports = new ConcurrentHashMap<>();
public static class DeathData {
public final String playerName;
public String worldName;
public final double x, y, z;
public long deathTime;
public final UUID playerId;
DeathData(String playerName, UUID playerId, Location loc) {
this.playerName = playerName;
this.playerId = playerId;
this.worldName = loc.getWorld() != null ? loc.getWorld().getName() : "world";
this.x = loc.getX();
this.y = loc.getY();
this.z = loc.getZ();
this.deathTime = System.currentTimeMillis();
}
}
public DeathReport(Main plugin) {
this.plugin = plugin;
initTable();
loadAllFromDB();
}
// ==================== 事件监听 ====================
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerDeath(PlayerDeathEvent event) {
Player player = event.getPlayer();
Location deathLoc = player.getLocation();
if (deathLoc == null) return;
DeathData data = new DeathData(player.getName(), player.getUniqueId(), deathLoc);
deathReports.put(player.getName(), data);
saveToDB(data);
// 发送死亡报告消息,带可点击的返回按钮
String timeStr = new java.text.SimpleDateFormat("HH:mm:ss").format(new java.util.Date(data.deathTime));
Component header = Component.text("§c✦ 你已死亡 ✦ ").append(
Component.text("世界: §f" + data.worldName + " §| §7坐标: §e" +
String.format("%.1f, %.1f, %.1f", data.x, data.y, data.z) + " §7时间: §f" + timeStr)
);
// "返回死亡地点" 按钮 - 通过 /back 命令
Component backButton = Component.text("§e[点击返回死亡地点] ")
.clickEvent(ClickEvent.runCommand("/back"))
.hoverEvent(HoverEvent.showText(Component.text("§7传送到你死亡的位置")));
player.sendMessage(header);
player.sendMessage(backButton);
}
// ==================== 查询/获取 ====================
/**
* 获取玩家的最新死亡坐标(如果有的话)
*/
public DeathData getDeathReport(String playerName) {
return deathReports.get(playerName);
}
/**
* 清除玩家的死亡报告
*/
public void clearDeathReport(String playerName) {
deathReports.remove(playerName);
}
// ==================== 数据库操作 ====================
private void loadAllFromDB() {
try {
java.sql.Connection conn = plugin.getDb().getConnection();
if (conn == null) return;
PreparedStatement ps = conn.prepareStatement(
"SELECT player_name, player_uuid, world, x, y, z, death_time FROM death_reports");
ResultSet rs = ps.executeQuery();
while (rs.next()) {
DeathData data = new DeathData(
rs.getString("player_name"),
UUID.fromString(rs.getString("player_uuid")),
new Location(null, rs.getDouble("x"), rs.getDouble("y"), rs.getDouble("z"))
);
data.worldName = rs.getString("world");
data.deathTime = rs.getLong("death_time");
deathReports.put(data.playerName, data);
}
rs.close();
ps.close();
} catch (SQLException e) {
// 表可能不存在,忽略(会在第一次死亡时自动创建)
}
}
private void saveToDB(DeathData data) {
try {
java.sql.Connection conn = plugin.getDb().getConnection();
if (conn == null) return;
PreparedStatement ps = conn.prepareStatement(
"INSERT OR REPLACE INTO death_reports (player_name, player_uuid, world, x, y, z, death_time) VALUES (?, ?, ?, ?, ?, ?, ?)");
ps.setString(1, data.playerName);
ps.setString(2, data.playerId.toString());
ps.setString(3, data.worldName);
ps.setDouble(4, data.x);
ps.setDouble(5, data.y);
ps.setDouble(6, data.z);
ps.setLong(7, data.deathTime);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
plugin.getLogger().warning("[死亡报告] 保存失败: " + e.getMessage());
}
}
private void persistAll() {
for (DeathData data : deathReports.values()) {
saveToDB(data);
}
}
// ==================== 初始化 ====================
public void initTable() {
try {
java.sql.Connection conn = plugin.getDb().getConnection();
if (conn == null) return;
java.sql.Statement st = conn.createStatement();
st.execute("CREATE TABLE IF NOT EXISTS death_reports ("
+ "player_name TEXT PRIMARY KEY, "
+ "player_uuid TEXT NOT NULL, "
+ "world TEXT, "
+ "x REAL, y REAL, z REAL, "
+ "death_time LONG)");
st.close();
} catch (SQLException e) {
plugin.getLogger().warning("[死亡报告] 建表失败: " + e.getMessage());
}
}
}
@@ -0,0 +1,329 @@
package Sdf1_login;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 统一时长解析器(供 /tempban 与 /chat mute 共用)
*
* parseToExpireMs(String) 返回「绝对到期时间戳(毫秒)」,调用方自行校验最小/最大区间。
*
* 支持的写法:
* 1) 纯数字(Unix 秒时间戳): 253402271999
* 2) 中文日期(绝对): 2026年7月9日
* 3) ISO 日期(绝对): 2025-07-09
* 4) 相对时长(中文/英文单位): 1秒 / 1s / 1年 / 1年2月3天 / 2天12小时
* 5) 英文数字词(2026-10-04 新增,供 /cypay freeze 用):
* one min / two hours / twenty days / twenty one minutes
*
* <p>注意:纯数字仍按「Unix 秒时间戳」处理(如 0000000000);
* 过期时间戳由调用方自行判定(/cypay freeze 视为永久冻结)。
*
* 解析规则优先级:纯数字 → 时间戳;含「年/月/日」且为完整日期格式 → 绝对日期;
* 否则按「数字+单位」逐段累加为相对时长。
*/
public class DurationParser {
private static final Map<String, Long> UNIT_MS = new HashMap<>();
static {
// 秒
UNIT_MS.put("秒", 1000L);
UNIT_MS.put("秒钟", 1000L);
UNIT_MS.put("s", 1000L);
UNIT_MS.put("sec", 1000L);
// 分
UNIT_MS.put("分", 60000L);
UNIT_MS.put("分钟", 60000L);
UNIT_MS.put("mins", 60000L);
UNIT_MS.put("min", 60000L);
UNIT_MS.put("m", 60000L);
// 时
UNIT_MS.put("时", 3600000L);
UNIT_MS.put("小时", 3600000L);
UNIT_MS.put("钟头", 3600000L);
UNIT_MS.put("h", 3600000L);
UNIT_MS.put("hr", 3600000L);
// 天
UNIT_MS.put("天", 86400000L);
UNIT_MS.put("日", 86400000L);
UNIT_MS.put("d", 86400000L);
// 周
UNIT_MS.put("周", 604800000L);
UNIT_MS.put("星期", 604800000L);
UNIT_MS.put("礼拜", 604800000L);
UNIT_MS.put("w", 604800000L);
// 月(按30天近似)
UNIT_MS.put("个月", 2592000000L);
UNIT_MS.put("月", 2592000000L);
UNIT_MS.put("mo", 2592000000L);
// 年(按365天近似)
UNIT_MS.put("年", 31536000000L);
UNIT_MS.put("y", 31536000000L);
}
/** 解析为绝对到期毫秒;无法解析抛出 IllegalArgumentException */
public static long parseToExpireMs(String input)
throws IllegalArgumentException {
if (input == null || input.trim().isEmpty())
throw new IllegalArgumentException("时长为空");
String in = input.trim();
// ★ 0) 日期+时间(绝对):26-10-08 14:43 / 2026-10-08 14:43 / 2026年10月8日 14:43
// 必须在去空格之前解析:否则日期与时间粘连(26-10-08 14:43 → 26-10-0814:43)无法区分
long dt = parseDateTime(in);
if (dt >= 0) return dt;
// ★ 先把英文数字词换成阿拉伯数字(one min -> 1 min)
String s = normalizeEnglishNumber(in).replace(" ", "");
// 1) 纯数字 → Unix 秒时间戳
if (s.matches("\\d+")) {
long ts = Long.parseLong(s);
return ts * 1000L;
}
// 2) 中文日期 YYYY年M月D日
Matcher mc = Pattern.compile(
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日").matcher(s);
if (mc.find()) {
return buildDateMillis(Integer.parseInt(mc.group(1)),
Integer.parseInt(mc.group(2)),
Integer.parseInt(mc.group(3)));
}
// 3) ISO 日期 YYYY-MM-DD / YYYY/MM/DD
Matcher mi = Pattern.compile(
"(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})").matcher(s);
if (mi.find()) {
return buildDateMillis(Integer.parseInt(mi.group(1)),
Integer.parseInt(mi.group(2)),
Integer.parseInt(mi.group(3)));
}
// 4) 相对时长(可多段组合)
long rel = parseRelative(s);
if (rel >= 0) {
return System.currentTimeMillis() + rel;
}
throw new IllegalArgumentException("无法解析时长: " + input);
}
/** 英文数字词 -> 阿拉伯数字(zero..ninety,支持 twenty one / twenty-one 连写) */
private static String normalizeEnglishNumber(String in) {
try {
String[] words = {"zero", "one", "two", "three", "four",
"five", "six", "seven", "eight", "nine", "ten",
"eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen",
"nineteen", "twenty", "thirty", "forty", "fifty",
"sixty", "seventy", "eighty", "ninety"};
StringBuilder alt = new StringBuilder();
for (int i = 0; i < words.length; i++) {
if (i > 0) alt.append('|');
alt.append(words[i]);
}
Pattern p = Pattern.compile(
"(?i)\\b(" + alt + ")(?:[\\s-]+(" + alt + "))?\\b");
Matcher m = p.matcher(in);
StringBuffer sb = new StringBuffer();
while (m.find()) {
int v = enWordValue(m.group(1));
if (m.group(2) != null) v += enWordValue(m.group(2));
m.appendReplacement(sb, Matcher.quoteReplacement(
String.valueOf(v)));
}
m.appendTail(sb);
return sb.toString();
} catch (Exception e) {
return in; // 归一化失败就原样返回
}
}
private static int enWordValue(String w) {
switch (w.toLowerCase(java.util.Locale.ROOT)) {
case "zero": return 0;
case "one": return 1;
case "two": return 2;
case "three": return 3;
case "four": return 4;
case "five": return 5;
case "six": return 6;
case "seven": return 7;
case "eight": return 8;
case "nine": return 9;
case "ten": return 10;
case "eleven": return 11;
case "twelve": return 12;
case "thirteen": return 13;
case "fourteen": return 14;
case "fifteen": return 15;
case "sixteen": return 16;
case "seventeen": return 17;
case "eighteen": return 18;
case "nineteen": return 19;
case "twenty": return 20;
case "thirty": return 30;
case "forty": return 40;
case "fifty": return 50;
case "sixty": return 60;
case "seventy": return 70;
case "eighty": return 80;
case "ninety": return 90;
default: return 0;
}
}
// ===== 日期+时间绝对解析(2026-10-08 新增:支持 26-10-08 14:43 写法) =====
/** 中文日期+时间:2026年10月8日 14:43(“日”已界定日期边界,时间可紧贴) */
private static final Pattern DT_CN = Pattern.compile(
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:[ T\\u3000]+)?"
+ "(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
/** 分隔日期+时间:26-10-08 14:43 / 2026-10-08 14:43 / 2026.10.08T14:43:00
* 两位年份按 20xx 解释;日期与时间之间必须有空格或 T,
* 防止 2026-1-8 14:43 被粘连误读成「日=81、时=4」 */
private static final Pattern DT_SEP = Pattern.compile(
"(?<!\\d)(\\d{4}|\\d{2})[-/.](\\d{1,2})[-/.](\\d{1,2})"
+ "[ T\\u3000]+(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
/** 两位年份无时间:26-10-08
* lookbehind 防止吃掉 4 位年份的后半段(2026-07-09 不能被读成 07-09) */
private static final Pattern DT_YMD2 = Pattern.compile(
"(?<!\\d{2}[-/.])(\\d{2})[-/.](\\d{1,2})[-/.](\\d{1,2})(?!\\d|[A-Za-z])");
/** 解析「日期+时间」为绝对毫秒;认不出来返回 -1 交后续分支处理 */
private static long parseDateTime(String in) {
Matcher m = DT_CN.matcher(in);
if (m.find()) {
return buildDateTimeMillis(Integer.parseInt(m.group(1)),
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)),
timeOf(m.group(4), m.group(5), m.group(6)));
}
m = DT_SEP.matcher(in);
if (m.find()) {
int year = Integer.parseInt(m.group(1));
if (year < 100) year += 2000; // 26 → 2026
return buildDateTimeMillis(year, Integer.parseInt(m.group(2)),
Integer.parseInt(m.group(3)),
timeOf(m.group(4), m.group(5), m.group(6)));
}
m = DT_YMD2.matcher(in);
if (m.find()) {
return buildDateTimeMillis(2000 + Integer.parseInt(m.group(1)),
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)),
new int[]{-1, -1, -1});
}
return -1;
}
/** 解析 HH:mm[:ss];无时间(null)返回 {-1,-1,-1} 表示取 00:00:00 */
private static int[] timeOf(String h, String mi, String s) {
if (h == null) return new int[]{-1, -1, -1};
int H = Integer.parseInt(h), M = Integer.parseInt(mi);
int S = (s == null) ? 0 : Integer.parseInt(s);
if (H > 23 || M > 59 || S > 59)
throw new IllegalArgumentException("无效的时间: " + h + ":" + mi);
return new int[]{H, M, S};
}
/** 绝对日期+时间(t[0..2] 为 -1 表示 00:00:00) */
private static long buildDateTimeMillis(int y, int mo, int d, int[] t) {
if (mo < 1 || mo > 12 || d < 1 || d > 31)
throw new IllegalArgumentException("无效的日期: " + y + "-" + mo + "-" + d);
Calendar c = Calendar.getInstance();
c.set(y, mo - 1, d,
t[0] < 0 ? 0 : t[0],
t[1] < 0 ? 0 : t[1],
t[2] < 0 ? 0 : t[2]);
c.set(Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
private static long parseRelative(String s) {
// 按「数字 + 单位」逐段匹配并累加
StringBuilder units = new StringBuilder();
for (String u : UNIT_MS.keySet()) {
if (units.length() > 0) units.append("|");
// 转义,避免正则特殊字符
units.append(Pattern.quote(u));
}
Pattern p = Pattern.compile(
"([0-9零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖]+)(" + units + ")");
Matcher m = p.matcher(s);
long total = 0;
int found = 0;
while (m.find()) {
found++;
long num = parseNumber(m.group(1));
Long u = UNIT_MS.get(m.group(2));
if (u == null) continue;
total += num * u;
}
return found > 0 ? total : -1;
}
private static long buildDateMillis(int y, int mo, int d) {
Calendar c = Calendar.getInstance();
c.set(y, mo - 1, d, 0, 0, 0);
c.set(Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
/** 解析数字(阿拉伯数字优先,其次中文数字 零-九/十百千万/两/壹贰叁…) */
private static long parseNumber(String s) {
if (s.matches("\\d+")) return Long.parseLong(s);
Map<Character, Long> cn = new HashMap<>();
cn.put('零', 0L); cn.put('〇', 0L);
cn.put('一', 1L); cn.put('壹', 1L);
cn.put('二', 2L); cn.put('贰', 2L); cn.put('两', 2L);
cn.put('三', 3L); cn.put('叁', 3L);
cn.put('四', 4L); cn.put('肆', 4L);
cn.put('五', 5L); cn.put('伍', 5L);
cn.put('六', 6L); cn.put('陆', 6L);
cn.put('七', 7L); cn.put('柒', 7L);
cn.put('八', 8L); cn.put('捌', 8L);
cn.put('九', 9L); cn.put('玖', 9L);
long section = 0, num = 0;
boolean hasNum = false;
for (char ch : s.toCharArray()) {
if (cn.containsKey(ch)) {
num = cn.get(ch);
hasNum = true;
} else if (ch == '十') {
if (!hasNum) num = 1;
section += num * 10; hasNum = false; num = 0;
} else if (ch == '百') {
if (!hasNum) num = 1;
section += num * 100; hasNum = false; num = 0;
} else if (ch == '千') {
if (!hasNum) num = 1;
section += num * 1000; hasNum = false; num = 0;
} else if (ch == '万') {
if (!hasNum) num = 1;
section = (section + num) * 10000; hasNum = false; num = 0;
} else {
num = 0;
}
}
return section + (hasNum ? num : 0);
}
/** 格式化绝对到期时间戳为「yyyy年M月d日 HH:mm」 */
public static String formatExpire(long ms) {
Calendar c = Calendar.getInstance();
c.setTimeInMillis(ms);
return String.format("%d年%d月%d日 %02d:%02d",
c.get(Calendar.YEAR),
c.get(Calendar.MONTH) + 1,
c.get(Calendar.DAY_OF_MONTH),
c.get(Calendar.HOUR_OF_DAY),
c.get(Calendar.MINUTE));
}
}
+284 -275
View File
@@ -1,275 +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请尽快登录并修改密码!");
}
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);
}
}
}
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
+118 -97
View File
@@ -1,7 +1,6 @@
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;
@@ -12,7 +11,6 @@ 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;
@@ -39,8 +37,9 @@ public class GarbageManager {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(),
new File(plugin.getDataFolder(), "垃圾箱"),
"garbage.db");
dbFile.getParentFile().mkdirs();
db = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getAbsolutePath());
@@ -175,41 +174,56 @@ public class GarbageManager {
}
private void doCleanup() {
// 删除过期轮次的物品
int deleteRound =
// ★ 过期轮次删除是阻塞 DB -> 先异步执行;
// 轮次递增 / 实体收集 / 广播 必须主线程 -> 异步完成后回主线程
final int deleteRound =
currentRound - maxRounds;
if (deleteRound >= 0) {
int deleted = deleteOldItems(
deleteRound);
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
}
Bukkit.getScheduler().runTaskAsynchronously(
plugin, () -> {
final int deleted =
deleteRound >= 0
? deleteOldItems(deleteRound)
: 0;
Bukkit.getScheduler().runTask(
plugin, () -> {
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
// 递增轮次
currentRound++;
setConfigValue("round",
String.valueOf(currentRound));
// 递增轮次
currentRound++;
final int roundNow =
currentRound;
// ★ 轮次落库是 DB -> 异步
Bukkit.getScheduler()
.runTaskAsynchronously(
plugin, () ->
setConfigValue("round",
String.valueOf(roundNow)));
// 收集当前掉落物
int count = collectItems();
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// 收集当前掉落物
// (实体移除必须主线程,落库已在内部转异步)
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);
// 打印下次清理时间
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;
List<ItemStack> picked = new ArrayList<>();
for (org.bukkit.World w :
Bukkit.getWorlds()) {
Collection<Item> items =
@@ -221,36 +235,50 @@ public class GarbageManager {
|| stack.getType()
== Material.AIR)
continue;
saveItem(stack);
// ★ 先取快照再移除实体(实体移除必须主线程)
picked.add(stack.clone());
item.remove();
count++;
}
}
// ★ 落库是阻塞 DB(含 SN 脱管登记)-> 丢异步批量写
if (!picked.isEmpty()) {
final List<ItemStack> toSave = picked;
Bukkit.getScheduler()
.runTaskAsynchronously(plugin, () -> {
for (ItemStack s : toSave)
saveItem(s);
});
}
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();
try {
// ★ SN 物品进垃圾箱 = 脱离玩家本人管控,起算 12 小时找回期。
// shift 投入 / 光标放入 / 拖拽 / 扫地机收地面掉落全走这里。
if (plugin.getSnManager() != null) {
plugin.getSnManager().onConfiscated(stack, "垃圾站");
}
}}
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 {
@@ -288,11 +316,8 @@ public class GarbageManager {
.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);
new Gson().fromJson(json, Map.class);
map = fixNumberTypes(map);
return ItemStack.deserialize(map);
} catch (Exception e) {
@@ -344,11 +369,9 @@ public class GarbageManager {
}
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();
@@ -381,38 +404,6 @@ public class GarbageManager {
.get("amount")).intValue();
ItemStack stack = base64ToStack(data);
if (stack != null) {
try {
byte[] bytes =
stack.serializeAsBytes();
String b64 = java.util.Base64
.getEncoder()
.encodeToString(bytes);
int len = b64.length();
String f = len > 15
? b64.substring(10, 15) : "短";
String m = len >= 5
? b64.substring(len / 2,
Math.min(len / 2 + 5, len))
: "短";
String b = len > 10
? b64.substring(len - 10, len - 5)
: "短";
plugin.getLogger().info(
"[垃圾站加载] ID#" + id
+ " " + stack.getType()
+ " x" + amount
+ " base64[" + len + "]"
+ " 前=" + f
+ " 中=" + m
+ " 后=" + b);
} catch (Exception ex) {
plugin.getLogger().warning(
"[垃圾站加载] 序列化异常: "
+ ex.getMessage());
}
}
if (stack != null) {
stack.setAmount(amount);
ItemMeta im =
stack.getItemMeta();
@@ -429,10 +420,7 @@ public class GarbageManager {
slot++;
}
// 45-48 格:额外投放区(空位)
// 52-53 格:额外投放区(空位)
// 49 格:翻页-上一页
// 49 格:上一页
if (page > 0) {
g.setItem(49, mk(Material.ARROW,
"§e上一页 §7("
@@ -451,9 +439,8 @@ public class GarbageManager {
"§7当前页: §f" + (page + 1)
+ "/" + totalPages));
// 51 格:翻页-下一页
boolean canNext = total >= pageSize
&& page < totalPages - 1;
// 51 格:下一页
boolean canNext = page < totalPages - 1;
if (canNext) {
g.setItem(51, mk(Material.ARROW,
"§e下一页 §7("
@@ -463,13 +450,15 @@ public class GarbageManager {
g.setItem(51, mk(Material.ARROW,
"§7已是最后一页"));
}
// 52格:返回按钮
// 52 格:返回主菜单
g.setItem(52, mk(Material.ARROW,
"§7返回",
"§7返回主菜单"));
p.openInventory(g);
}
public int getRecyclePage(String name) {
return recyclePages
.getOrDefault(name, 0);
@@ -556,6 +545,38 @@ public class GarbageManager {
}
}
/**
* 取回前按 id 读原始存档(含原 lore 与 PDC)。
* GUI 展示副本的 lore 已被覆盖成
* 「ID/点击取出」,取回必须用这个还原,
* 否则 §8SN 行与物品说明 lore 会丢。
*
* @param id items 表主键
* @return 原始物品;读不到返回 null
*/
public ItemStack fetchItem(int id) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT item_data, amount FROM items "
+ "WHERE id=?");
ps.setInt(1, id);
ResultSet rs = ps.executeQuery();
ItemStack result = null;
if (rs.next()) {
result = base64ToStack(
rs.getString("item_data"));
if (result != null) {
result.setAmount(rs.getInt("amount"));
}
}
rs.close();
ps.close();
return result;
} catch (Exception e) {
return null;
}
}
public int getItemCount() {
try {
Statement st = db.createStatement();
+20 -31
View File
@@ -22,7 +22,7 @@ public class GiftManager {
public GiftManager(Main plugin) {
this.plugin = plugin;
this.giftFolder = new File(
plugin.getDataFolder(), "新人礼包");
plugin.getDataFolder(), "任务面板");
}
public void loadStages() {
@@ -194,28 +194,21 @@ public class GiftManager {
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);
// 游戏币改为债券
BondManager bm = plugin.getBonds();
if (bm != null) {
int before = bm.getBonds(p.getName());
bm.addBonds(p.getName(), 500, "gift_stage2", "", "新手礼包", "阶段2奖励");
int after = bm.getBonds(p.getName());
p.sendMessage("§a§l[礼包] §f债券: §e" + before + "§f → §a" + 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);
BondManager bm = plugin.getBonds();
if (bm != null) {
int before = bm.getBonds(p.getName());
bm.addBonds(p.getName(), 1500, "gift_stage3", "", "新手礼包", "阶段3奖励");
int after = bm.getBonds(p.getName());
p.sendMessage("§a§l[礼包] §f债券: §e" + before + "§f → §a" + after);
}
giveItem(p, Material.IRON_PICKAXE);
giveItem(p, Material.IRON_AXE);
@@ -225,16 +218,12 @@ public class GiftManager {
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);
BondManager bm = plugin.getBonds();
if (bm != null) {
int before = bm.getBonds(p.getName());
bm.addBonds(p.getName(), 2000, "gift_stage4", "", "新手礼包", "阶段4奖励");
int after = bm.getBonds(p.getName());
p.sendMessage("§a§l[礼包] §f债券: §e" + before + "§f → §a" + after);
}
giveItem(p, Material.DIAMOND_HELMET);
giveItem(p, Material.DIAMOND_CHESTPLATE);
+770
View File
@@ -0,0 +1,770 @@
package Sdf1_login;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.block.data.type.Bed;
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 org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.player.PlayerBedEnterEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Home传送点管理器
* 支持: sethome, delhome, home, homes
* 集成用户组系统: home_limit (0=全局默认, >0=组限制, -1=无限)
*/
public class HomeManager implements Listener {
private final Main plugin;
// 床位置缓存: player_name → Location (用于自动记录床位置)
private final Map<String, Location> bedLocations = new ConcurrentHashMap<>();
public HomeManager(Main plugin) {
this.plugin = plugin;
initDatabase();
}
// ==================== 数据结构 ====================
public static class HomeData {
public String name;
public String world;
public double x, y, z;
public float yaw, pitch;
public long createdAt;
public Location toLocation(World world) {
return new Location(world, x, y, z, yaw, pitch);
}
}
// ==================== 数据库初始化 ====================
private void initDatabase() {
try {
java.sql.Connection db = plugin.getDb().getConnection();
if (db == null) {
plugin.getLogger().warning("[Home] 无法获取数据库连接");
return;
}
java.sql.Statement st = db.createStatement();
// homes表已在DatabaseManager中创建,此处确保索引
st.execute("CREATE INDEX IF NOT EXISTS idx_homes_player ON homes(player_name)");
st.close();
plugin.getLogger().info("[Home] 数据库初始化完成");
} catch (Exception e) {
plugin.getLogger().warning("[Home] 数据库初始化失败: " + e.getMessage());
}
}
// ==================== 命令处理 ====================
public boolean handleCommand(Player player, String label, String[] args) {
String lowerLabel = label.toLowerCase();
switch (lowerLabel) {
case "sethome":
return handleSetHome(player, args);
case "delhome":
return handleDelHome(player, args);
case "home":
return handleHome(player, args);
case "homes":
return handleHomes(player, args);
default:
return false;
}
}
/**
* /sethome <名称> - 设置家
*/
private boolean handleSetHome(Player player, String[] args) {
if (args.length == 0) {
player.sendMessage("§c用法: /sethome <名称>");
return true;
}
String homeName = args[0].toLowerCase();
// 校验名称格式
if (!homeName.matches("^[a-zA-Z0-9_]{1,16}$")) {
player.sendMessage("§c家名只能包含字母、数字、下划线,长度1-16");
return true;
}
// 检查家数量限制
int maxHomes = getMaxHomes(player);
if (maxHomes != -1) {
List<HomeData> homes = getHomes(player.getName());
if (homes.size() >= maxHomes) {
player.sendMessage("§c你已达到家数量上限 (" + maxHomes + "个)");
return true;
}
}
// 检查是否已存在同名家
if (homeExists(player.getName(), homeName)) {
player.sendMessage("§c你已有一个名为 §e" + homeName + " §c的家");
return true;
}
// 保存家
Location loc = player.getLocation();
saveHome(player.getName(), homeName, loc);
player.sendMessage("§a家 §e" + homeName + " §a已设置!");
player.sendMessage("§7使用 §e/home " + homeName + " §7传送回家");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
/**
* /delhome <名称> - 删除家
*/
private boolean handleDelHome(Player player, String[] args) {
if (args.length == 0) {
player.sendMessage("§c用法: /delhome <名称>");
return true;
}
String homeName = args[0].toLowerCase();
if (!homeExists(player.getName(), homeName)) {
player.sendMessage("§c你没有名为 §e" + homeName + " §c的家");
return true;
}
deleteHome(player.getName(), homeName);
player.sendMessage("§a家 §e" + homeName + " §a已删除!");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
/**
* /home [名称] - 传送到家
*/
private boolean handleHome(Player player, String[] args) {
if (args.length == 0) {
// 无参数: 传送到第一个家
List<HomeData> homes = getHomes(player.getName());
if (homes.isEmpty()) {
player.sendMessage("§c你还没有设置任何家");
player.sendMessage("§7使用 §e/sethome <名称> §7设置一个家");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
teleportToHome(player, homes.get(0));
return true;
}
String homeName = args[0].toLowerCase();
HomeData home = getHome(player.getName(), homeName);
if (home == null) {
player.sendMessage("§c你没有名为 §e" + homeName + " §c的家");
return true;
}
teleportToHome(player, home);
return true;
}
/**
* /homes - 查看所有家
*/
private boolean handleHomes(Player player, String[] args) {
List<HomeData> homes = getHomes(player.getName());
if (homes.isEmpty()) {
player.sendMessage("§e你还没有设置任何家");
player.sendMessage("§7使用 §e/sethome <名称> §7设置一个家");
return true;
}
int maxHomes = getMaxHomes(player);
player.sendMessage("§6§l你的家 §7(" + homes.size() + (maxHomes == -1 ? "/" + "∞" : "/" + maxHomes) + ")");
player.sendMessage("§7────────────────────");
for (HomeData home : homes) {
String worldName = home.world != null ? home.world : "未知";
player.sendMessage("§e • " + home.name + " §7[" + worldName + "]");
}
player.sendMessage("§7────────────────────");
player.sendMessage("§7使用 §e/home <名称> §7传送");
player.sendMessage("§7使用 §e/sethome <名称> §7设置新家");
player.sendMessage("§7使用 §e/delhome <名称> §7删除家");
player.sendMessage("§7—————————————————————");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
// ==================== 床事件监听 ====================
private static final org.bukkit.block.BlockFace[] HORIZ_FACES = {
org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.SOUTH,
org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.WEST };
/** 危险方块:落点优先级判断用 */
private static final Set<Material> DANGEROUS = EnumSet.of(
Material.LAVA, Material.FIRE, Material.SOUL_FIRE, Material.MAGMA_BLOCK,
Material.CACTUS, Material.CAMPFIRE, Material.SOUL_CAMPFIRE,
Material.SWEET_BERRY_BUSH, Material.WITHER_ROSE, Material.POWDER_SNOW,
Material.POINTED_DRIPSTONE);
/** 是否是床方块 */
private static boolean isBed(Block b) {
return b.getBlockData() instanceof Bed;
}
/** 取床头方块(配对缺失时返回原方块);非床返回 null */
private static Block getBedHead(Block b) {
if (!isBed(b)) return null;
if (((Bed) b.getBlockData()).getPart() == Bed.Part.HEAD) return b;
Block head = horizontalNeighborWithPart(b, Bed.Part.HEAD);
return head != null ? head : b;
}
private static Block horizontalNeighborWithPart(Block b, Bed.Part part) {
for (org.bukkit.block.BlockFace f : HORIZ_FACES) {
Block n = b.getRelative(f);
if (isBed(n) && ((Bed) n.getBlockData()).getPart() == part) return n;
}
return null;
}
/** 床的两半 [head, foot(可能为null)];非床返回 null */
private static Block[] getBedParts(Block b) {
if (!isBed(b)) return null;
if (((Bed) b.getBlockData()).getPart() == Bed.Part.HEAD) {
return new Block[]{ b, horizontalNeighborWithPart(b, Bed.Part.FOOT) };
}
Block head = horizontalNeighborWithPart(b, Bed.Part.HEAD);
return new Block[]{ head != null ? head : b, b };
}
/**
* 玩家与床交互(上床/白天点床)→ bed 传送点始终更新为该床的精确坐标
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerBedEnter(PlayerBedEnterEvent event) {
// 下界/末地床爆炸、怪物附近、距离过远等未真正交互的情况不记录
PlayerBedEnterEvent.BedEnterResult r = event.getBedEnterResult();
if (r != PlayerBedEnterEvent.BedEnterResult.OK
&& r != PlayerBedEnterEvent.BedEnterResult.NOT_POSSIBLE_NOW
&& r != PlayerBedEnterEvent.BedEnterResult.OBSTRUCTED) {
return;
}
Player player = event.getPlayer();
Block head = getBedHead(event.getBed());
if (head == null) return;
Location bedLoc = head.getLocation();
bedLocations.put(player.getName(), bedLoc);
HomeData existing = getHome(player.getName(), "bed");
if (existing == null) {
saveHome(player.getName(), "bed", bedLoc);
player.sendMessage("§a床位置已自动保存为 §e/home bed");
} else if (!sameBlock(existing, bedLoc)) {
// 与其它床交互 → 更新传送点坐标
updateHome(player.getName(), "bed", bedLoc);
player.sendMessage("§a床传送点已更新为该床的位置");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
}
}
/**
* 玩家复活 → 抓取重生点附近的精确床坐标并更新 bed 传送点
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerRespawn(PlayerRespawnEvent event) {
Player player = event.getPlayer();
Location respawnLoc = event.getRespawnLocation();
if (respawnLoc == null || respawnLoc.getWorld() == null) return;
Block bed = findNearbyBed(respawnLoc);
if (bed == null) return; // 世界出生点复活等,不碰传送点
Block head = getBedHead(bed);
Location exact = (head != null ? head : bed).getLocation();
HomeData existing = getHome(player.getName(), "bed");
if (existing == null) {
saveHome(player.getName(), "bed", exact);
} else if (!sameBlock(existing, exact)) {
updateHome(player.getName(), "bed", exact);
}
bedLocations.put(player.getName(), exact);
}
/** 在重生点附近(水平±3、垂直-1~+2)寻找最近的床方块 */
private Block findNearbyBed(Location loc) {
World w = loc.getWorld();
if (w == null) return null;
int bx = loc.getBlockX(), by = loc.getBlockY(), bz = loc.getBlockZ();
Block best = null;
double bestD = Double.MAX_VALUE;
for (int dx = -3; dx <= 3; dx++)
for (int dy = -1; dy <= 2; dy++)
for (int dz = -3; dz <= 3; dz++) {
Block b = w.getBlockAt(bx + dx, by + dy, bz + dz);
if (!isBed(b)) continue;
double d = (double) dx * dx + (double) dy * dy + (double) dz * dz;
if (d < bestD) { bestD = d; best = b; }
}
return best;
}
private boolean sameBlock(HomeData home, Location loc) {
return home.world != null && loc.getWorld() != null
&& home.world.equals(loc.getWorld().getName())
&& Math.floor(home.x) == loc.getBlockX()
&& Math.floor(home.y) == loc.getBlockY()
&& Math.floor(home.z) == loc.getBlockZ();
}
/**
* 床没了 → 自动删除对应玩家的 bed 传送点(破坏/爆炸均触发)
*/
private void removeBedHomesFor(Block bedPart) {
Block[] parts = getBedParts(bedPart);
if (parts == null) return;
Set<String> owners = new HashSet<>();
for (Block part : parts) {
if (part == null) continue;
owners.addAll(queryBedHomeOwners(part));
}
for (String owner : owners) {
deleteHome(owner, "bed");
bedLocations.remove(owner);
Player p = Bukkit.getPlayerExact(owner);
if (p != null && p.isOnline()) {
p.sendMessage("§c你的床已被破坏,/home bed 传送点已自动删除");
p.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
}
}
}
/** 查询 bed 传送点坐标与指定床方块重合的玩家列表 */
private Set<String> queryBedHomeOwners(Block bedBlock) {
Set<String> owners = new HashSet<>();
try {
java.sql.Connection db = plugin.getDb().getConnection();
if (db == null) return owners;
PreparedStatement ps = db.prepareStatement(
"SELECT player_name FROM homes WHERE home_name='bed' AND world=? "
+ "AND ABS(x-?)<0.01 AND ABS(y-?)<0.01 AND ABS(z-?)<0.01");
ps.setString(1, bedBlock.getWorld().getName());
ps.setDouble(2, bedBlock.getX());
ps.setDouble(3, bedBlock.getY());
ps.setDouble(4, bedBlock.getZ());
ResultSet rs = ps.executeQuery();
while (rs.next()) owners.add(rs.getString(1));
rs.close();
ps.close();
} catch (SQLException e) {
plugin.getLogger().warning("[Home] 查询bed传送点失败: " + e.getMessage());
}
return owners;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
if (isBed(event.getBlock())) removeBedHomesFor(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onEntityExplode(EntityExplodeEvent event) {
for (Block b : event.blockList()) {
if (isBed(b)) removeBedHomesFor(b);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBlockExplode(BlockExplodeEvent event) {
for (Block b : event.blockList()) {
if (isBed(b)) removeBedHomesFor(b);
}
}
// ==================== 家数量限制 ====================
/**
* 获取玩家最大家数量
* @return -1=无限, 0=使用全局默认, >0=用户组独立限制
*/
private int getMaxHomes(Player player) {
UserGroupManager userGroupMgr = plugin.getUserGroup();
if (userGroupMgr == null) {
return getGlobalHomeLimit();
}
// 获取玩家最高优先级用户组
UserGroupManager.UserGroupConfig highestGroup = userGroupMgr.getHighestGroup(player.getName());
if (highestGroup == null) {
return getGlobalHomeLimit();
}
// 获取用户组的home_limit配置
int groupLimit = userGroupMgr.getHomeLimit(highestGroup.name);
if (groupLimit == -1) {
// 无限
return -1;
} else if (groupLimit == 0) {
// 跟随全局
return getGlobalHomeLimit();
} else {
// 独立限制
return groupLimit;
}
}
/**
* 获取全局家数量限制
* 读取area_config中的max_home_per_player配置(管理员可在Web后台调整)
* 解析失败或值<=0时回退到默认5
*/
private int getGlobalHomeLimit() {
try {
if (plugin.areaProtection != null) {
int limit = plugin.areaProtection.getGlobalMaxHomePerPlayer();
if (limit > 0) {
return limit;
}
}
} catch (Exception ignored) {}
// 默认5个家
return 5;
}
// ==================== 数据库操作 ====================
private boolean homeExists(String playerName, String homeName) {
try {
java.sql.Connection db = plugin.getDb().getConnection();
if (db == null) return false;
PreparedStatement ps = db.prepareStatement(
"SELECT COUNT(*) FROM homes WHERE player_name=? AND home_name=?");
ps.setString(1, playerName);
ps.setString(2, homeName);
ResultSet rs = ps.executeQuery();
boolean exists = rs.next() && rs.getInt(1) > 0;
rs.close();
ps.close();
return exists;
} catch (SQLException e) {
return false;
}
}
private List<HomeData> getHomes(String playerName) {
List<HomeData> homes = new ArrayList<>();
try {
java.sql.Connection db = plugin.getDb().getConnection();
if (db == null) return homes;
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM homes WHERE player_name=? ORDER BY created_at");
ps.setString(1, playerName);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
HomeData home = new HomeData();
home.name = rs.getString("home_name");
home.world = rs.getString("world");
home.x = rs.getDouble("x");
home.y = rs.getDouble("y");
home.z = rs.getDouble("z");
home.yaw = rs.getFloat("yaw");
home.pitch = rs.getFloat("pitch");
home.createdAt = rs.getLong("created_at");
homes.add(home);
}
rs.close();
ps.close();
} catch (SQLException e) {
plugin.getLogger().warning("[Home] 获取家列表失败: " + e.getMessage());
}
return homes;
}
private HomeData getHome(String playerName, String homeName) {
List<HomeData> homes = getHomes(playerName);
for (HomeData home : homes) {
if (home.name.equalsIgnoreCase(homeName)) {
return home;
}
}
return null;
}
private void saveHome(String playerName, String homeName, Location loc) {
try {
java.sql.Connection db = plugin.getDb().getConnection();
if (db == null) return;
PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO homes (player_name, home_name, world, x, y, z, yaw, pitch, created_at) "
+ "VALUES (?,?,?,?,?,?,?,?,?)");
ps.setString(1, playerName);
ps.setString(2, homeName);
ps.setString(3, loc.getWorld() != null ? loc.getWorld().getName() : "world");
ps.setDouble(4, loc.getX());
ps.setDouble(5, loc.getY());
ps.setDouble(6, loc.getZ());
ps.setFloat(7, loc.getYaw());
ps.setFloat(8, loc.getPitch());
ps.setLong(9, System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
plugin.getLogger().warning("[Home] 保存家失败: " + e.getMessage());
}
}
private void updateHome(String playerName, String homeName, Location loc) {
saveHome(playerName, homeName, loc); // INSERT OR REPLACE 自动更新
}
private void deleteHome(String playerName, String homeName) {
try {
java.sql.Connection db = plugin.getDb().getConnection();
if (db == null) return;
PreparedStatement ps = db.prepareStatement(
"DELETE FROM homes WHERE player_name=? AND home_name=?");
ps.setString(1, playerName);
ps.setString(2, homeName);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
plugin.getLogger().warning("[Home] 删除家失败: " + e.getMessage());
}
}
/**
* 传送玩家到家
*/
private void teleportToHome(Player player, HomeData home) {
// bed 家走原版复活落点算法(自写,不模拟死亡、无死亡提示)
if ("bed".equalsIgnoreCase(home.name)) {
teleportToBedHome(player, home);
return;
}
World world = Bukkit.getWorld(home.world);
if (world == null) {
player.sendMessage("§c世界 §e" + home.world + " §c不存在");
return;
}
Location loc = home.toLocation(world);
// 安全检查: 确保位置安全
if (loc.getBlock().getType().isSolid()) {
// 向上寻找安全位置
for (int y = (int) loc.getY(); y < 256; y++) {
Location safeLoc = new Location(world, loc.getX(), y + 1, loc.getZ());
if (!safeLoc.getBlock().getType().isSolid()) {
loc = safeLoc;
break;
}
}
}
player.teleport(loc);
player.sendMessage("§a已传送到家 §e" + home.name);
}
/**
* 回床:先校验床还在(没了自动删传送点),再用自写算法模拟原版复活落点
* (床周合法站位 + 视线水平看向床头),直接传送,不模拟死亡。
*/
private void teleportToBedHome(Player player, HomeData home) {
World world = Bukkit.getWorld(home.world);
if (world == null) {
player.sendMessage("§c世界 §e" + home.world + " §c不存在");
return;
}
Location saved = home.toLocation(world);
Block savedBlock = world.getBlockAt(saved);
Block head = getBedHead(savedBlock);
// 保存坐标上没有床 → 床已没,自动删传送点
if (head == null) {
deleteHome(player.getName(), "bed");
bedLocations.remove(player.getName());
player.sendMessage("§c你的床已不存在,/home bed 传送点已自动删除");
return;
}
Location dest = findVanillaRespawnSpot(world, head);
if (dest == null) {
player.sendMessage("§c床周围被阻挡,暂时无法回到床边");
return;
}
// 若坐标与记录不一致(床被重摆),顺手修正传送点
if (!sameBlock(home, head.getLocation())) {
updateHome(player.getName(), "bed", head.getLocation());
bedLocations.put(player.getName(), head.getLocation());
}
player.teleport(dest);
player.sendMessage("§a已回到床边");
}
/**
* 自写算法模拟原版床复活落点:
* 1. 候选 = 床头8邻(原版7块优先区,排除床自身占位)→ 床尾8邻兜底;
* 排序遵循原版"西北(低X低Z)最先,其次向南(+Z)再向东(+X)"。
* 2. 合法性 = 落点方块及上方一格无碰撞 + 下方一格有碰撞(可站立),
* 优先无危险方块的落点(火/岩浆/仙人掌等降级为次选)。
* 3. 落地朝向:视线水平看向床头。
* 全部被阻挡时回退到床位置向上找安全格。
*/
private Location findVanillaRespawnSpot(World world, Block head) {
Block[] parts = getBedParts(head);
Block foot = (parts != null && parts.length > 1) ? parts[1] : null;
int bedY = head.getY();
// 收集候选:床头邻格(排除床两半自身),再床尾邻格
List<Block> candidates = new ArrayList<>();
collectRing(head, foot, candidates);
if (foot != null) collectRing(foot, head, candidates);
// 排序:先床头环(保持顺序),床尾环在后;环内按 X→Z 升序(西北优先、南先于东)
candidates.sort(Comparator
.comparingInt((Block b) -> b.getY() == bedY ? 0 : 1)
.thenComparingInt(Block::getX)
.thenComparingInt(Block::getZ));
// 两轮:先无危险落点,再放宽(原版同样优先安全位)
for (int pass = 0; pass < 2; pass++) {
for (Block c : candidates) {
if (!isStandable(world, c.getX(), c.getY(), c.getZ())) continue;
boolean dangerous = isDangerous(world, c.getX(), c.getY(), c.getZ())
|| DANGEROUS.contains(world.getBlockAt(c.getX(), c.getY() - 1, c.getZ()).getType());
if (pass == 0 && dangerous) continue;
float yaw = faceBlockYaw(c, head);
return new Location(world, c.getX() + 0.5, c.getY(), c.getZ() + 0.5, yaw, 0f);
}
}
// 全被阻挡:回退到床本身向上找安全格
Location loc = new Location(world, head.getX() + 0.5, head.getY(), head.getZ() + 0.5);
for (int y = head.getY(); y < world.getMaxHeight(); y++) {
if (isStandable(world, head.getX(), y, head.getZ())) {
float yaw = faceBlockYaw(head.getX(), y, head.getZ(), head);
return new Location(world, head.getX() + 0.5, y, head.getZ() + 0.5, yaw, 0f);
}
loc.setY(y + 1);
}
return null;
}
/** 收集 center 的8个水平邻格(排除 exclude 床块及其配对半块) */
private void collectRing(Block center, Block exclude, List<Block> out) {
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
if (dx == 0 && dz == 0) continue;
Block n = center.getRelative(dx, 0, dz);
if (isBed(n)) continue;
if (exclude != null && isBedPartOf(n, exclude)) continue;
out.add(n);
}
}
}
private boolean isBedPartOf(Block b, Block anyBedPart) {
Block[] parts = getBedParts(anyBedPart);
if (parts == null) return false;
for (Block p : parts) {
if (p != null && p.equals(b)) return true;
}
return false;
}
/** 站位合法性:落点与上方一格无碰撞,脚下有支撑 */
private boolean isStandable(World world, int x, int y, int z) {
if (y - 1 < world.getMinHeight()) return false;
Block feet = world.getBlockAt(x, y, z);
Block headSpace = world.getBlockAt(x, y + 1, z);
Block ground = world.getBlockAt(x, y - 1, z);
if (feet.getType() == Material.LAVA || feet.getType() == Material.WATER) return false;
if (!feet.isPassable() || !headSpace.isPassable()) return false;
return !ground.isPassable() || ground.getType() == Material.LADDER
|| ground.getType() == Material.VINE;
}
private boolean isDangerous(World world, int x, int y, int z) {
return DANGEROUS.contains(world.getBlockAt(x, y, z).getType());
}
private float faceBlockYaw(Block from, Block target) {
return faceBlockYaw(from.getX(), from.getY(), from.getZ(), target);
}
private float faceBlockYaw(int x, int y, int z, Block target) {
double dx = (target.getX() + 0.5) - (x + 0.5);
double dz = (target.getZ() + 0.5) - (z + 0.5);
return (float) Math.toDegrees(Math.atan2(-dx, dz));
}
// ==================== Tab补全 ====================
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (!(sender instanceof Player)) {
return Collections.emptyList();
}
Player player = (Player) sender;
String cmdName = command.getName().toLowerCase();
if (cmdName.equals("home") || cmdName.equals("delhome")) {
if (args.length == 1) {
List<String> homes = new ArrayList<>();
for (HomeData home : getHomes(player.getName())) {
if (home.name.toLowerCase().startsWith(args[0].toLowerCase())) {
homes.add(home.name);
}
}
return homes;
}
} else if (cmdName.equals("sethome") || cmdName.equals("homes")) {
return Collections.emptyList();
}
return Collections.emptyList();
}
// ==================== 清理 ====================
public void shutdown() {
bedLocations.clear();
}
}
+114 -67
View File
@@ -1,67 +1,114 @@
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;
}
/**
* 检查该IP是否还能注册新账号
*/
public boolean canRegister(String ip) {
if (maxAccounts <= 0) return true;
if (ip == null || ip.isEmpty()) return true;
return queryAccountsByIP(ip).size() < maxAccounts;
}
/**
* 获取该IP下已注册的账号数量
*/
public int getAccountCount(String ip) {
if (ip == null || ip.isEmpty()) return 0;
return queryAccountsByIP(ip).size();
}
/**
* 获取该IP下所有账号名
*/
public List<String> getAccounts(String ip) {
return queryAccountsByIP(ip);
}
/**
* 直接通过数据库连接查询指定IP下的所有账号
*/
private List<String> queryAccountsByIP(String ip) {
List<String> accounts = new ArrayList<>();
Connection conn = plugin.getDb().getDb();
if (conn == null) return accounts;
try {
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();
} catch (Exception e) {
e.printStackTrace();
}
return accounts;
}
}
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 getMaxAccounts() {
return maxAccounts;
}
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,87 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.io.IOException;
/**
* 背包滚动自动备份。
* <p>
* ★ 功能:
* 1. 由 Main 的 15 分钟定时任务驱动,遍历所有在线玩家。
* 2. 在 PVP 竞技场 / 练习场(测试场)的玩家不备份 —— 避免竞技场装备覆盖真实背包。
* 3. 序列化 主背包 / 护甲 / 副手(extra) / 等级 / 经验,写入 inv_backup_auto 表。
* 4. 每玩家保留最近 3 份(滚动覆盖),由 DatabaseManager.saveAutoBackup 负责清理。
* <p>
* 与登录时的 saveInventoryBackup 共用同一套存储语义(level=玩家等级,
* experience=当前等级进度 p.getExp()),便于后续统一还原。
*/
public class InventoryAutoBackup {
private final Main plugin;
/** 备份周期:15 分钟(单位 tick) */
public static final long INTERVAL_TICKS = 15L * 60L * 20L;
public InventoryAutoBackup(Main plugin) {
this.plugin = plugin;
}
/** 对所有在线玩家执行一次滚动备份 */
public void runBackup() {
for (Player p : Bukkit.getOnlinePlayers()) {
try {
backupPlayer(p);
} catch (Exception e) {
plugin.getLogger().warning(
"[自动备份] 玩家 " + p.getName()
+ " 备份失败: " + e.getMessage());
}
}
}
private void backupPlayer(Player p) {
String name = p.getName();
// ★ 跳过 PVP 竞技场:竞技场内不备份,避免 PVP 装备覆盖真实背包
if (plugin.getPVPArenaManager().isInPVPArena(name)) {
return;
}
// ★ 跳过练习场 / 测试世界
if (plugin.getPVPTestManager().isInTestWorld(p)) {
return;
}
ItemStack[] contents = p.getInventory().getContents();
ItemStack[] armor = p.getInventory().getArmorContents();
ItemStack[] extra = p.getInventory().getExtraContents();
String contentsB64, armorB64, extraB64;
try {
contentsB64 = InventorySerializer.toBase64(contents);
armorB64 = InventorySerializer.toBase64(armor);
extraB64 = InventorySerializer.toBase64(extra);
} catch (IOException e) {
plugin.getLogger().warning(
"[自动备份] 序列化失败 (" + name + "): "
+ e.getMessage());
return;
}
final int level = p.getLevel();
final double experience = p.getExp(); // 当前等级进度 0.0~1.0
// ★ 读背包/等级必须主线程;落库是阻塞 DB 写 -> 投递异步执行
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getDb().saveAutoBackup(
name, contentsB64, armorB64, extraB64,
level, experience);
plugin.getLogger().info("[自动备份] " + name + " 背包已备份");
} catch (Throwable t) {
plugin.getLogger().warning("[自动备份] 落库失败 (" + name + "): " + t.getMessage());
}
});
}
}
@@ -1,46 +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);
}
}
}
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);
}
}
}
+96 -89
View File
@@ -1,89 +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 = plugin.getCommission()
.getCommissionRate(invitedBy);
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;
}
}
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,650 @@
package Sdf1_login;
import Sdf1_login.AreaProtection.AreaConfig;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* 领地方块/容器记录系统 —— 回声碎片(echo_shard)工具。
* <p>
* ★ 功能:
* 1. 领地内方块破坏/放置 → 自动写入 land_block_log
* 2. 领地内容器打开/拿取 → 自动写入 land_container_log
* 3. 玩家手持回声碎片右键/左键方块或容器 → 聊天栏打印该坐标的历史记录
* 4. /landrec give [玩家] → 发放回声碎片(由插件/管理员发放)
* <p>
* 记录保留 7 天滚动(由 Main 的每日清理定时器调用 prune)。
*/
public class LandRecordManager implements Listener {
private final Main plugin;
/** 回声碎片物品的 NBT 标识 key */
public static final String ECHO_SHARD_KEY = "sdf1_echo_shard";
/** 回声碎片物品材质(1.19+ 存在) */
public static final Material ECHO_SHARD_MATERIAL = Material.ECHO_SHARD;
/** 玩家最后一次查询的坐标缓存,翻页导航时复用,避免 getTargetBlockExact 不准 */
private final Map<UUID, Location> lastQueryLocations = new ConcurrentHashMap<>();
/** 记录查询默认条数 */
private static final int QUERY_LIMIT = 20;
/** 记录保留天数 */
public static final int RETENTION_DAYS = 7;
private final SimpleDateFormat sdf =
new SimpleDateFormat("MM-dd HH:mm");
/**
* 容器打开时的快照:用于关闭时计算"拿了什么"。
* key = 玩家名(MC 同一时刻只能打开一个容器 GUI,故按玩家唯一即可)。
*/
private final Map<String, ContainerSnapshot> openSnapshots =
new ConcurrentHashMap<>();
static class ContainerSnapshot {
String landName;
int landId;
String world;
int x, y, z;
String containerType;
Map<String, Integer> before = new HashMap<>();
}
public LandRecordManager(Main plugin) {
this.plugin = plugin;
}
// ==================== 回声碎片物品 ====================
/** 创建一枚回声碎片(带 NBT 标识,便于精准识别) */
public ItemStack createEchoShard() {
ItemStack item = new ItemStack(ECHO_SHARD_MATERIAL);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName("§b§l回声碎片");
meta.setLore(Arrays.asList(
"§7右键/左键 方块或容器",
"§7查看领地内的破坏/拿取记录",
"§8(领地监控工具)"));
NamespacedKey key =
new NamespacedKey(plugin, ECHO_SHARD_KEY);
meta.getPersistentDataContainer().set(
key, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
}
return item;
}
/** 判断物品是否为回声碎片(材质 + NBT 双重校验) */
public boolean isEchoShard(ItemStack item) {
if (item == null || item.getType() != ECHO_SHARD_MATERIAL)
return false;
ItemMeta meta = item.getItemMeta();
if (meta == null) return false;
NamespacedKey key =
new NamespacedKey(plugin, ECHO_SHARD_KEY);
return meta.getPersistentDataContainer().has(
key, PersistentDataType.BYTE);
}
/** 直接发放一枚回声碎片给玩家(GUI 菜单调用) */
public void giveEchoShard(Player p) {
// ★ SN 管控:1人同种类仅限1个
if (plugin.getSnManager() != null
&& plugin.getSnManager().isBlocked(
p, SnManager.TYPE_ECHO)) {
long cdMin = plugin.getSnManager().cooldownMinutesLeft(p.getName());
if (cdMin > 0) {
p.sendMessage("§c§l[回声碎片] §f注销冷静期未结束,还剩约 §e" + cdMin + " §f分钟,期间无法申领新设备");
} else {
p.sendMessage("§c§l[回声碎片] §f你已有回声碎片的有效登记,"
+ "旧SN未注销前不能重新申领");
}
return;
}
ItemStack shard = createEchoShard();
if (plugin.getSnManager() != null) {
String sn = plugin.getSnManager().applySn(
p, SnManager.TYPE_ECHO, "回声碎片");
if (sn == null) {
p.sendMessage("§c§l[回声碎片] §f申领被SN系统拒绝");
return;
}
plugin.getSnManager().writeSn(
shard, sn, SnManager.TYPE_ECHO);
}
HashMap<Integer, ItemStack> left =
p.getInventory().addItem(shard);
if (!left.isEmpty()) {
p.sendMessage("§c§l[回声碎片] §f你的背包已满,无法发放");
} else {
p.sendMessage("§a§l[回声碎片] §f你获得了§b回声碎片§f,右键/左键方块或容器查看领地记录");
}
}
// ==================== 记录写入(由 AreaProtection 钩子调用)====================
/** 记录方块破坏 */
public void recordBlockBreak(AreaConfig ac, Player p,
Block block) {
recordBlock(ac, p, block, "break");
}
/** 记录方块放置 */
public void recordBlockPlace(AreaConfig ac, Player p,
Block block) {
recordBlock(ac, p, block, "place");
}
/** 记录红石元器件状态变化(压力板、按钮、拉杆等) */
public void recordRedstoneStateChange(AreaConfig ac, Player p,
Block block) {
recordBlock(ac, p, block, "state_change");
}
/** ★ 记录告示牌放置/编辑(含内容) */
public void recordSignEdit(AreaConfig ac, Player p, Block block,
String[] lines, boolean isEdit) {
try {
int landId = plugin.areaProtection.getLandIdFromDb(ac.name);
// 拼接4行内容,空行跳过
StringBuilder sb = new StringBuilder();
for (String line : lines) {
if (line != null && !line.isEmpty()) {
if (sb.length() > 0) sb.append(" | ");
sb.append(line);
}
}
String detail = sb.toString();
String action = isEdit ? "sign_edit" : "sign_place";
plugin.getDb().recordLandBlockWithDetail(
ac.name, landId,
block.getWorld().getName(),
block.getX(), block.getY(), block.getZ(),
p.getName(), action,
block.getType().name(), detail);
} catch (Exception e) {
e.printStackTrace();
}
}
private void recordBlock(AreaConfig ac, Player p, Block block,
String action) {
try {
int landId = plugin.areaProtection.getLandIdFromDb(
ac.name);
plugin.getDb().recordLandBlock(
ac.name, landId,
block.getWorld().getName(),
block.getX(), block.getY(), block.getZ(),
p.getName(), action,
block.getType().name());
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 记录容器打开,并保存打开瞬间容器快照(用于关闭时计算拿取差量)。
* 仅在容器确实被允许打开时调用(钩子位于 AreaProtection
* 权限/取消检查之后)。
*/
public void recordContainerOpen(AreaConfig ac, Player p,
Location loc, String containerType,
Inventory inv) {
try {
int landId = plugin.areaProtection.getLandIdFromDb(
ac.name);
plugin.getDb().recordLandContainer(
ac.name, landId,
loc.getWorld().getName(),
loc.getBlockX(), loc.getBlockY(), loc.getBlockZ(),
p.getName(), "open", containerType, "", 0);
ContainerSnapshot snap = new ContainerSnapshot();
snap.landName = ac.name;
snap.landId = landId;
snap.world = loc.getWorld().getName();
snap.x = loc.getBlockX();
snap.y = loc.getBlockY();
snap.z = loc.getBlockZ();
snap.containerType = containerType;
snap.before = scanInventory(inv);
openSnapshots.put(p.getName(), snap);
} catch (Exception e) {
e.printStackTrace();
}
}
// ==================== 容器关闭:计算拿取差量 ====================
@EventHandler
public void onContainerClose(InventoryCloseEvent e) {
if (!(e.getPlayer() instanceof Player)) return;
Player p = (Player) e.getPlayer();
ContainerSnapshot snap = openSnapshots.remove(p.getName());
if (snap == null) return;
Map<String, Integer> after = scanInventory(e.getInventory());
// 1. 拿取:before > after
for (Map.Entry<String, Integer> en : snap.before.entrySet()) {
int taken = en.getValue() - after.getOrDefault(en.getKey(), 0);
if (taken > 0) {
recordContainerChange(snap, p.getName(), "take",
en.getKey(), taken);
}
}
// 2. 存入:after > before
for (Map.Entry<String, Integer> en : after.entrySet()) {
int put = en.getValue() - snap.before.getOrDefault(en.getKey(), 0);
if (put > 0) {
recordContainerChange(snap, p.getName(), "put",
en.getKey(), put);
}
}
}
private void recordContainerChange(ContainerSnapshot snap,
String playerName, String action,
String detail, int amount) {
try {
plugin.getDb().recordLandContainer(
snap.landName, snap.landId,
snap.world, snap.x, snap.y, snap.z,
playerName, action, snap.containerType,
detail, amount);
} catch (Exception ex) {
ex.printStackTrace();
}
}
/** 扫描容器库存,按"类型名"聚合数量 */
private Map<String, Integer> scanInventory(Inventory inv) {
Map<String, Integer> map = new HashMap<>();
for (ItemStack it : inv.getContents()) {
if (it == null || it.getType() == Material.AIR)
continue;
map.merge(it.getType().name(),
it.getAmount(), Integer::sum);
}
return map;
}
// ==================== 回声碎片查询(右键/左键)====================
@EventHandler
public void onShardInteract(PlayerInteractEvent e) {
ItemStack item = e.getItem();
if (item == null || !isEchoShard(item)) return;
Action a = e.getAction();
if (a != Action.RIGHT_CLICK_BLOCK && a != Action.LEFT_CLICK_BLOCK
&& a != Action.RIGHT_CLICK_AIR
&& a != Action.LEFT_CLICK_AIR) return;
Player p = e.getPlayer();
// 阻止误触方块/打开容器
e.setCancelled(true);
Block target = e.getClickedBlock();
if (target == null) {
// 视线选中的方块(非 deprecated 的 getTargetBlockExact)
Block sight = p.getTargetBlockExact(6);
if (sight != null && sight.getType() != Material.AIR)
target = sight;
}
if (target == null) {
p.sendMessage("§c§l[回声碎片] §f你面前没有可查询的方块或容器");
return;
}
Location loc = target.getLocation();
lastQueryLocations.put(p.getUniqueId(), loc);
queryAndPrint(p, loc);
}
private void queryAndPrint(Player p, Location loc) {
queryAndPrint(p, loc, 0);
}
/**
* ★ 收集查询坐标组:大箱子(double chest)由两个方块组成,
* 记录写入时用"被点击的那一半"坐标,查询时用"准心对准的那一半"坐标,
* 两边坐标不一致就会查不到记录。这里沿同层同类型箱子扩展,
* 把整只大箱子的所有坐标一起查出来再合并。
*/
private List<Location> collectQueryLocations(Location loc) {
List<Location> result = new ArrayList<>();
result.add(loc);
Block start;
try {
start = loc.getBlock();
} catch (Exception ex) {
return result; // 世界未加载等异常,退回单点查询
}
Material type = start.getType();
// 只有能拼成大箱子的类型才需要扩展
if (type != Material.CHEST && type != Material.TRAPPED_CHEST) {
return result;
}
Set<String> seen = new LinkedHashSet<>();
seen.add(keyOf(start));
Deque<Block> queue = new ArrayDeque<>();
queue.add(start);
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
while (!queue.isEmpty()) {
Block cur = queue.poll();
for (int[] d : dirs) {
Block n = cur.getRelative(d[0], 0, d[1]);
if (n.getType() != type) continue;
if (seen.add(keyOf(n))) {
result.add(n.getLocation());
queue.add(n);
}
}
}
return result;
}
private String keyOf(Block b) {
return b.getX() + "," + b.getY() + "," + b.getZ();
}
private void queryAndPrint(Player p, Location loc, int page) {
String world = loc.getWorld().getName();
int x = loc.getBlockX(), y = loc.getBlockY(),
z = loc.getBlockZ();
// ★ 大箱子:把整只箱子的所有半边坐标都查一遍
List<Location> group = collectQueryLocations(loc);
// 按天分页:page=0今日,1昨日,2前日...
List<Map<String, Object>> rawBlocks = new ArrayList<>();
List<Map<String, Object>> rawContainers = new ArrayList<>();
for (Location g : group) {
int gx = g.getBlockX(), gy = g.getBlockY(), gz = g.getBlockZ();
rawBlocks.addAll(plugin.getDb().getLandBlockLogAt(
world, gx, gy, gz, QUERY_LIMIT, page));
rawContainers.addAll(plugin.getDb().getLandContainerLogAt(
world, gx, gy, gz, QUERY_LIMIT, page));
}
sortByTimeDesc(rawBlocks);
sortByTimeDesc(rawContainers);
List<Map<String, Object>> blocks = truncate(rawBlocks);
List<Map<String, Object>> containers = truncate(rawContainers);
if (blocks.isEmpty() && containers.isEmpty()) {
if (page > 0) {
p.sendMessage("§6§l[回声碎片] §e坐标 ("
+ x + "," + y + "," + z + ") 第" + (page + 1) + "页无记录");
} else {
p.sendMessage("§6§l[回声碎片] §f坐标 ("
+ x + "," + y + "," + z + ") 暂无领地记录");
}
return;
}
// 收集所有需要翻译的材料名
Set<String> materials = new LinkedHashSet<>();
for (Map<String, Object> r : blocks) {
materials.add((String) r.get("material"));
}
for (Map<String, Object> r : containers) {
if ("take".equals(r.get("action")) || "put".equals(r.get("action"))) {
materials.add((String) r.get("detail"));
}
String ct = (String) r.get("container_type");
if (ct != null && !ct.isEmpty()) {
materials.add(ct);
}
}
// 检查下一页是否有数据(决定是否显示"下一页"链接)
boolean hasNext = false;
for (Location g : group) {
int gx = g.getBlockX(), gy = g.getBlockY(), gz = g.getBlockZ();
if (!plugin.getDb().getLandBlockLogAt(world, gx, gy, gz, 1, page + 1).isEmpty()) {
hasNext = true;
break;
}
if (!plugin.getDb().getLandContainerLogAt(world, gx, gy, gz, 1, page + 1).isEmpty()) {
hasNext = true;
break;
}
}
final int currentPage = page;
final boolean hasNextPage = hasNext;
// 异步批量翻译
MaterialTranslator.translateBatch(materials)
.thenAccept(translations -> {
plugin.getServer().getScheduler().runTask(plugin, () -> {
printResults(p, x, y, z, blocks, containers, translations, currentPage, hasNextPage);
});
});
}
/** 按 time 倒序(新→旧),兼容 Long/Number 两种取值 */
private void sortByTimeDesc(List<Map<String, Object>> list) {
list.sort((a, b) -> Long.compare(timeOf(b), timeOf(a)));
}
private long timeOf(Map<String, Object> r) {
Object v = r.get("time");
return v instanceof Number ? ((Number) v).longValue() : 0L;
}
/** 截断到 QUERY_LIMIT 条,返回新列表 */
private List<Map<String, Object>> truncate(List<Map<String, Object>> list) {
if (list.size() <= QUERY_LIMIT) return list;
return new ArrayList<>(list.subList(0, QUERY_LIMIT));
}
private void printResults(Player p, int x, int y, int z,
List<Map<String, Object>> blocks,
List<Map<String, Object>> containers,
Map<String, String> translations) {
printResults(p, x, y, z, blocks, containers, translations, 0, false);
}
private void printResults(Player p, int x, int y, int z,
List<Map<String, Object>> blocks,
List<Map<String, Object>> containers,
Map<String, String> translations,
int page, boolean hasNext) {
String pageLabel = page == 0 ? "今日" : (page == 1 ? "昨日" : (page + 1) + "日前");
p.sendMessage("§6§l[回声碎片] §e坐标 ("
+ x + "," + y + "," + z + ") 的记录§7(" + pageLabel + "):");
if (!blocks.isEmpty()) {
p.sendMessage("§b— 方块记录 —");
for (Map<String, Object> r : blocks) {
String action = (String) r.get("action");
String act;
if ("break".equals(action)) {
act = "§c破坏";
} else if ("place".equals(action)) {
act = "§a放置";
} else if ("state_change".equals(action)) {
act = "§e状态变化";
} else if ("sign_place".equals(action)) {
act = "§a放置告示牌";
} else if ("sign_edit".equals(action)) {
act = "§b编辑告示牌";
} else {
act = "§7" + action;
}
String mat = (String) r.get("material");
String zh = translations.getOrDefault(mat, MaterialTranslator.toReadable(mat));
StringBuilder msg = new StringBuilder();
msg.append(" §7").append(fmt((Long) r.get("time")))
.append(" §f").append(r.get("player_name"))
.append(" ").append(act)
.append(" §e").append(mat).append(" §a").append(zh);
String detail = (String) r.get("detail");
if (detail != null && !detail.isEmpty()
&& ("sign_place".equals(action) || "sign_edit".equals(action))) {
msg.append(" §7[§f").append(detail).append("§7]");
}
p.sendMessage(msg.toString());
}
}
if (!containers.isEmpty()) {
p.sendMessage("§b— 容器记录 §7(" + pageLabel + ")—");
for (Map<String, Object> r : containers) {
String act = (String) r.get("action");
if ("open".equals(act)) {
String ct = (String) r.get("container_type");
String ctZh = translations.getOrDefault(ct, MaterialTranslator.toReadable(ct));
p.sendMessage(" §7" + fmt((Long) r.get("time"))
+ " §f" + r.get("player_name")
+ " §d打开§7[" + ct + "] §a" + ctZh);
} else if ("take".equals(act)) {
String mat = (String) r.get("detail");
String zh = translations.getOrDefault(mat, MaterialTranslator.toReadable(mat));
p.sendMessage(" §7" + fmt((Long) r.get("time"))
+ " §f" + r.get("player_name")
+ " §c拿取 §e" + mat + " §a" + zh
+ " §7x" + r.get("amount"));
} else if ("put".equals(act)) {
String mat = (String) r.get("detail");
String zh = translations.getOrDefault(mat, MaterialTranslator.toReadable(mat));
p.sendMessage(" §7" + fmt((Long) r.get("time"))
+ " §f" + r.get("player_name")
+ " §a存入 §e" + mat + " §a" + zh
+ " §7x" + r.get("amount"));
}
}
}
// ★ 可点击翻页导航
Component nav = Component.text(" §7[ ");
if (page > 0) {
nav = nav.append(Component.text("§e← 上一页")
.clickEvent(ClickEvent.runCommand("/landrec log " + page))
.hoverEvent(HoverEvent.showText(Component.text("§7点击查看前一日记录"))));
nav = nav.append(Component.text(" §7| "));
}
nav = nav.append(Component.text("§f第" + (page + 1) + "页"));
if (hasNext) {
nav = nav.append(Component.text(" §7| ")
.append(Component.text("§e下一页 →")
.clickEvent(ClickEvent.runCommand("/landrec log " + (page + 2)))
.hoverEvent(HoverEvent.showText(Component.text("§7点击查看后一日记录")))));
}
nav = nav.append(Component.text(" §7]"));
p.sendMessage(nav);
}
private String fmt(long ms) {
return sdf.format(new Date(ms));
}
// ==================== 命令:/landrec ====================
public boolean handleCommand(CommandSender sender,
String[] args) {
if (args.length == 0) {
sendHelp(sender);
return true;
}
String sub = args[0];
if (sub.equalsIgnoreCase("give")) {
// 发放权限...
boolean isOp = sender.isOp();
boolean isAreaAdmin = (sender instanceof Player)
&& plugin.areaProtection.isAreaAdmin((Player) sender);
Player target;
if (args.length >= 2) {
if (!isOp && !isAreaAdmin) {
sender.sendMessage("§c§l[回声碎片] §f你无权向他人发放回声碎片");
return true;
}
target = Bukkit.getPlayerExact(args[1]);
if (target == null) {
sender.sendMessage("§c§l[回声碎片] §f玩家 " + args[1] + " 不在线");
return true;
}
} else if (sender instanceof Player) {
target = (Player) sender;
} else {
sender.sendMessage("§c§l[回声碎片] §f请指定要发放的玩家");
return true;
}
giveEchoShard(target);
if (target != sender)
sender.sendMessage("§a§l[回声碎片] §f已发放回声碎片给 " + target.getName());
return true;
}
if (sub.equalsIgnoreCase("log")) {
if (!(sender instanceof Player)) {
sender.sendMessage("§c§l[回声碎片] §f此命令仅限玩家使用");
return true;
}
Player p = (Player) sender;
int page = 0; // 默认今日
boolean isNav = false; // true=从翻页链接跳转,复用 lastQueryLocations
if (args.length >= 2) {
try {
page = Math.max(0, Integer.parseInt(args[1]) - 1);
isNav = true;
} catch (NumberFormatException ignored) {
sender.sendMessage("§c§l[回声碎片] §f页码格式错误");
return true;
}
}
// 翻页时复用上次查询坐标,避免 getTargetBlockExact 玩家未对准
Location navLoc = isNav ? lastQueryLocations.get(p.getUniqueId()) : null;
if (navLoc != null) {
queryAndPrint(p, navLoc, page);
return true;
}
// 首次查询或缓存过期,取准心方块
Block target = p.getTargetBlockExact(6);
if (target == null || target.getType() == Material.AIR) {
sender.sendMessage("§c§l[回声碎片] §f你面前没有可查询的方块或容器");
return true;
}
Location loc = target.getLocation();
if (!isNav) {
lastQueryLocations.put(p.getUniqueId(), loc);
}
queryAndPrint(p, loc, page);
return true;
}
sendHelp(sender);
return true;
}
private void sendHelp(CommandSender sender) {
sender.sendMessage("§6§l[回声碎片] §e指令:");
sender.sendMessage(" §7/landrec give §f- 给自己发放一枚回声碎片");
sender.sendMessage(" §7/landrec give <玩家> §f- 向他人发放(需 OP/领地管理员)");
sender.sendMessage(" §7/landrec log §f- 查看当前方块今日记录");
sender.sendMessage(" §7/landrec log <页码> §f- 查看指定日期的记录(1=今日 2=昨日 ...)");
sender.sendMessage(" §7手持回声碎片右键/左键方块或容器 §f- 查看该处今日记录");
}
}
+396 -302
View File
@@ -1,302 +1,396 @@
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);
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();
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().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);
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) {
}
}
}
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);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
}
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);
// ★ 注册成功:actionbar 提示1分钟保存刚才的账号和密码
plugin.showCredentialSaveReminder(p, name, pwd);
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) {
}
// ★ 注册后立即同步用户数据到PHP
if (plugin.webManager != null) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try { plugin.webManager.syncUserRegistrations(); }
catch (Exception e) {
plugin.getLogger().warning("[Web通信] 注册后同步用户异常: " + e.getMessage());
}
});
}
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 (plugin.getRiskControl().isBanned(name)) {
int remaining = plugin.getRiskControl()
.getBanRemainingSeconds(name);
p.sendMessage("§c§l[安全风控] §f账号因密码错误过多已被封禁,"
+ "剩余 §e" + remaining + " §f秒");
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) {
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
if (markDualStep1(p, false)) return true;
finishPasswordLogin(p, false);
return true;
}
String pwdResult =
plugin.getDb()
.checkPasswordWithFallback(
name, hash);
if (pwdResult != null) {
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
if (markDualStep1(p, true)) return true;
finishPasswordLogin(p, true);
return true;
}
// ★ 密码错误,记录风控
int remaining = plugin.getRiskControl()
.recordFailAttempt(p);
if (remaining < 0) {
// 已被封禁(triggerBan内部已踢出)
return true;
}
p.sendMessage(plugin.getConfig2()
.msg("login_failed"));
if (remaining > 0) {
p.sendMessage("§c§l[风控] §f剩余 §e"
+ remaining + " §f次尝试机会");
}
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_login] handleLogin异常: "
+ e.getMessage());
e.printStackTrace();
p.sendMessage(
"§c登录过程出错,请联系管理员");
}
return true;
}
/**
* IP风控「密码 + 2FA」顺序两关的第一步。
* 密码正确只记录进度:<b>不加入 loggedIn、不恢复背包</b>,必须等第二步 /2fa code 通过才收口。
*
* @return true = 该玩家处于双验证流程,本轮 /login 到此为止
*/
private boolean markDualStep1(Player p, boolean tempPassword) {
TwoFactorManager twofa = plugin.getTwofa();
String name = p.getName();
if (twofa == null || !twofa.isDualVerify(p)) return false;
twofa.onDualPasswordPassed(p, tempPassword);
// 密码这一步确实对了 → 重置「密码错误次数」风控(不代表已登录,仍不解冻)
plugin.getRiskControl().onLoginSuccess(name);
p.sendMessage("§a§l[双重验证] §a第一步密码验证通过(尚未解冻)");
p.sendMessage("§7第二步: 执行 §f/2fa code <动态码|恢复代码>");
p.sendMessage("§c两步全部通过后才会解冻并恢复背包");
return true;
}
/**
* 密码登录成功的统一收口:解冻 + 恢复背包 + 记录登录信息。
* 调用点:/login 主密码、/login 临时密码、IP风控双验证第二步通过后。
*/
public void finishPasswordLogin(Player p, boolean tempPassword) {
String name = p.getName();
// ★ 登录成功,重置风控
plugin.getRiskControl().onLoginSuccess(name);
plugin.getLoggedIn().add(name);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
}
plugin.getDb().setLoggedIn(name, true);
if (!tempPassword) {
// 主密码路径保留原有的重复写入(行为与历史版本一致)
plugin.getDb().setLoggedIn(name, true);
}
plugin.getDb().setField(name, "last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name, "last_online_check",
System.currentTimeMillis());
// ★ 立即同步在线状态到PHP(异步,避免阻塞主线程)
if (!tempPassword && plugin.webManager != null) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.webManager.syncOnlinePlayers();
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 异步syncOnlinePlayers异常: " + e.getMessage());
}
});
}
// 登录成功关闭飞行
p.setAllowFlight(false);
p.setFlying(false);
// 只在有有效备份时恢复
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
if (tempPassword) {
// ★ 确保这行存在
plugin.getNeedsPasswordChange().add(name);
}
p.sendMessage(plugin.getConfig2().msg("login_success"));
if (tempPassword) {
p.sendMessage("§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
p.sendMessage("§7用法: /sdf1_login pw");
}
activateCY(p, name);
// ★ 登录后恢复区域效果
Sdf1_login.AreaProtection areaProt =
plugin.getAreaProtection();
if (areaProt != null) {
areaProt.onPlayerJoin(p);
}
}
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,962 @@
package Sdf1_login;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.logging.Logger;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 维护模式(白名单)管理器
*
* 配置文件:plugins/Sdf1_login/白名单.txt,分两个段落:
* [控制段] 启用 / 开始时间 / 结束时间 / 时长 / 消息 等键值对
* [白名单段] 每行一个玩家名(也可用逗号分隔多个)
*
* 特性:
* - 24 小时硬封顶:无论怎么配,单次维护窗口最长 24 小时,到点自动结束
* - 多格式时间:10位秒级时间戳 / 13位毫秒 / yyyy-MM-dd HH:mm / 2026年9月28日14时30分 / 只写 HH:mm(今天)
* - 未写时长与结束时间时默认 24 小时
* - 消息支持 & 与 § 颜色码、整段 JSON 文本组件、\n 与 &lt;br&gt; 换行
* - 变量:username / 用户名、starttime / 开始时间、endtime / 结束时间
* - 玩家登录(异步预登录)、加入、退出时热重载配置,改完文件即生效,无需重启
* - 拦截发生在异步预登录阶段,不给未授权客户端发送任何世界/区块数据包
* - 独立于 MC 自带白名单,互不影响
*/
public class MaintenanceManager implements Listener {
/** 24小时硬封顶 */
private static final long MAX_WINDOW_MS = 24L * 60L * 60L * 1000L;
/** 未配置时长/结束时间时的默认窗口 */
private static final long DEFAULT_WINDOW_MS = MAX_WINDOW_MS;
private static final String DEFAULT_MESSAGE =
"&c&l[服务器维护] &7服务器正在维护中,请稍后再试&7(结束时间: &e{结束时间}&7)";
private static final Pattern NUM_PAT = Pattern.compile("\\d+");
private static final Pattern DUR_PAT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*([a-zA-Z\\u4e00-\\u9fa5]*)");
private static final Pattern AMP_COLOR_PAT = Pattern.compile("&([0-9a-fk-orA-FK-OR])");
private final Main plugin;
private final File file;
/** 解析互斥:异步登录线程与主线程(join/quit)都可能触发热重载 */
private final Object lock = new Object();
private volatile long lastMtime = -1L;
private volatile boolean enabled = false;
private volatile long windowStart = 0L;
private volatile long windowEnd = 0L;
private volatile boolean opBypass = true;
private volatile String rawMessage = DEFAULT_MESSAGE;
private volatile Set<String> whitelistLower = Collections.emptySet();
/** 未显式写「开始时间」时记住本轮起点,避免热重载把窗口反复清零 */
private long implicitStart = 0L;
/** 到期日志只打一次 */
private volatile boolean expiryLogged = false;
/** OP 查询缓存(异步线程里只查一次) */
private final ConcurrentHashMap<String, Boolean> opCache = new ConcurrentHashMap<>();
public MaintenanceManager(Main plugin) {
this.plugin = plugin;
this.file = new File(plugin.getDataFolder(), "白名单.txt");
try {
Files.createDirectories(plugin.getDataFolder().toPath());
} catch (IOException e) {
plugin.getLogger().warning("[维护模式] 创建数据目录失败: " + e.getMessage());
}
if (!file.exists()) {
writeDefaultFile();
}
reload(true);
}
// ==================== 对外接口 ====================
/** 维护窗口是否正在生效 */
public boolean isActive() {
if (!enabled) return false;
long now = System.currentTimeMillis();
return now >= windowStart && now < windowEnd;
}
/** 玩家是否在白名单内(大小写不敏感) */
public boolean isWhitelisted(String name) {
if (name == null || name.isEmpty()) return false;
return whitelistLower.contains(name.toLowerCase(Locale.ROOT));
}
public long getWindowStart() {
return windowStart;
}
public long getWindowEnd() {
return windowEnd;
}
public Set<String> getWhitelist() {
return whitelistLower;
}
// ==================== 配置文件 ====================
private void writeDefaultFile() {
String tpl = String.join("\n",
"# =========================================================",
"# 维护模式(白名单)配置文件",
"# 修改保存后无需重启:玩家尝试登录 / 加入 / 退出时自动热加载",
"#",
"# [控制段] 用「键 = 值」写服务器开关、时间与提示语",
"# [白名单段] 严格一行一个玩家名(一行只写一个,不支持逗号/空格分隔)",
"# (标题行漏写也能兜底收录,但请尽量保留 [白名单] 标题)",
"#",
"# 可用键:",
"# 启用 = true / false",
"# 开始时间 = 时间戳 / 2026-09-28 20:00 / 2026年9月28日20时00分(留空=立即开始)",
"# 结束时间 = 同上(留空则按时长或默认24小时计算)",
"# 时长 = 24h / 12小时 / 90分钟(单次维护最长 24 小时,硬封顶)",
"# OP可绕过 = true / false(OP 是否可以绕过维护直接进服)",
"# 消息 = 支持 &a§a 颜色码、整段 JSON(对象 {...} 或数组 [...])、\\n 与 <br> 换行",
"# 可用变量 {username} {starttime} {endtime}(带花括号,JSON 内同样生效)",
"# =========================================================",
"",
"# ===== 控制段 =====",
"启用 = false",
"开始时间 =",
"结束时间 =",
"时长 = 24h",
"OP可绕过 = true",
"消息 = " + DEFAULT_MESSAGE,
"",
"# ===== 白名单段 =====",
"[白名单]",
"");
try {
Files.write(file.toPath(), tpl.getBytes(StandardCharsets.UTF_8));
plugin.getLogger().info("[维护模式] 已生成默认配置: " + file.getAbsolutePath());
} catch (IOException e) {
plugin.getLogger().warning("[维护模式] 写入默认配置失败: " + e.getMessage());
}
}
/** 玩家登录/加入/退出时调用:文件变了就重新解析 */
public void reloadIfChanged() {
if (!file.exists()) return;
long m;
try {
m = file.lastModified();
} catch (Exception e) {
return;
}
if (m == lastMtime) return;
reload(true);
}
private void reload(boolean force) {
synchronized (lock) {
long m;
try {
m = file.lastModified();
} catch (Exception e) {
return;
}
if (!force && m == lastMtime) return;
String text;
try {
text = decode(Files.readAllBytes(file.toPath()));
} catch (IOException e) {
plugin.getLogger().warning("[维护模式] 读取配置失败: " + e.getMessage());
return;
}
// 行扫描抽成 static parseConfig(含段标题丢失兜底 / 条目清洗),便于单测
ParsedConfig pc = parseConfig(text, plugin.getLogger());
Map<String, String> ctl = pc.ctl;
Set<String> wl = pc.wl;
boolean wasActive = isActive();
boolean wasEnabled = this.enabled;
String enRaw = pick(ctl,
"启用", "开启", "开关", "维护", "enable", "enabled", "maintenance", "on");
boolean en = toBool(enRaw, false);
Long st = parseTime(pick(ctl, "开始时间", "开始", "起始时间", "start", "starttime", "begintime"));
Long enT = parseTime(pick(ctl, "结束时间", "结束", "截止时间", "end", "endtime"));
Long dur = parseDuration(pick(ctl, "时长", "持续时间", "持续", "duration", "hours", "小时"));
String opRaw = pick(ctl, "OP可绕过", "op可绕过", "OP绕过", "管理员绕过",
"opbypass", "op_bypass", "op", "bypass");
boolean op = toBool(opRaw, true);
if (enRaw != null && !isBoolWord(enRaw)) {
plugin.getLogger().warning("[维护模式] 「启用」的值不是可识别的布尔词: ["
+ enRaw + "],按 false 处理(请写 true / false)");
}
if (opRaw != null && !isBoolWord(opRaw)) {
plugin.getLogger().warning("[维护模式] 「OP可绕过」的值不是可识别的布尔词: ["
+ opRaw + "],按 false 处理(请写 true / false)");
}
String msg = pick(ctl, "消息", "提示", "维护消息", "踢出消息", "message", "msg", "kickmessage");
if (msg == null || msg.isEmpty()) msg = DEFAULT_MESSAGE;
long now = System.currentTimeMillis();
if (st == null) {
// 没写开始时间:沿用上一轮未过期的起点,否则从现在开始
if (implicitStart > 0L && implicitStart + MAX_WINDOW_MS > now) {
st = implicitStart;
} else {
st = now;
}
}
implicitStart = st;
long end;
if (enT != null) {
end = enT;
} else if (dur != null) {
end = st + dur;
} else {
end = st + DEFAULT_WINDOW_MS;
}
// ★ 24小时硬封顶
if (end > st + MAX_WINDOW_MS) end = st + MAX_WINDOW_MS;
if (end < st) end = st;
this.enabled = en;
this.windowStart = st;
this.windowEnd = end;
this.opBypass = op;
this.rawMessage = msg;
Set<String> lowered = new LinkedHashSet<>();
for (String n : wl) lowered.add(n.toLowerCase(Locale.ROOT));
this.whitelistLower = Collections.unmodifiableSet(lowered);
this.lastMtime = m;
if (wasEnabled && !en) {
this.implicitStart = 0L; // 手动关掉后,下次开启重新计时
}
this.expiryLogged = false;
this.opCache.clear();
boolean nowActive = isActive();
if (nowActive && !wasActive) {
plugin.getLogger().info("[维护模式] ★ 维护已开启,白名单 " + whitelistLower.size()
+ " 人,窗口 " + fmt(windowStart) + " ~ " + fmt(windowEnd));
} else if (nowActive) {
plugin.getLogger().info("[维护模式] 配置已热重载,白名单 " + whitelistLower.size()
+ " 人,窗口 " + fmt(windowStart) + " ~ " + fmt(windowEnd));
} else if (wasActive) {
plugin.getLogger().info("[维护模式] 维护已结束");
}
// ★ 直接把读到的条目与控制段键打出来,线上「配了却进不去」时一眼能对账
if (nowActive && !whitelistLower.isEmpty()) {
plugin.getLogger().info("[维护模式] 白名单条目: [" + summarize(whitelistLower) + "]");
}
if (nowActive && whitelistLower.isEmpty()) {
plugin.getLogger().warning("[维护模式] ⚠ 维护已开启但白名单为 0 人!段标题[白名单]识别="
+ pc.sawWhiteHeader
+ (opBypass ? ",OP 仍可绕过" : ",且 OP 不可绕过:所有玩家都会被拦在外面")
+ ";控制段读到的键: " + ctl.keySet() + ",请检查 [白名单] 段标题与条目写法");
}
}
}
// ==================== 登录拦截 ====================
@EventHandler(priority = EventPriority.LOWEST)
public void onPreLogin(AsyncPlayerPreLoginEvent e) {
// 登录阶段热重载:改完文件直接重连即可生效
try {
reloadIfChanged();
} catch (Throwable t) {
// 配置读取异常不能影响正常登录
}
if (!isActive()) {
// 到期后只提示一次
if (enabled && !expiryLogged && System.currentTimeMillis() >= windowEnd) {
expiryLogged = true;
plugin.getLogger().info("[维护模式] 维护窗口已到期(" + fmt(windowEnd) + "),自动结束");
}
return;
}
String name = e.getName();
if (isWhitelisted(name)) return;
if (opBypass && isOp(name)) {
plugin.getLogger().info("[维护模式] 放行OP: " + name);
return;
}
// ★ 无感化拦截:在异步预登录阶段直接断开,客户端只看到自定义提示,
// 服务端不会为他分配世界/区块,也不产生多余的网络往返
e.disallow(AsyncPlayerPreLoginEvent.Result.KICK_BANNED, buildMessage(name));
plugin.getLogger().info("[维护模式] 已拦截未在白名单的玩家: " + name
+ "(当前白名单 " + whitelistLower.size() + " 人)");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent e) {
try {
reloadIfChanged();
} catch (Throwable ignored) {
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent e) {
try {
reloadIfChanged();
} catch (Throwable ignored) {
}
}
// ==================== 提示消息 ====================
private Component buildMessage(String playerName) {
String msg = this.rawMessage;
long s = this.windowStart;
long e = this.windowEnd;
msg = replaceVar(msg, new String[]{"username", "用户名", "用户", "player", "玩家名",
"玩家", "name", "昵称"}, playerName);
msg = replaceVar(msg, new String[]{"starttime", "start_time", "开始时间",
"起始时间", "start"}, fmt(s));
msg = replaceVar(msg, new String[]{"endtime", "end_time", "结束时间",
"截止时间", "end"}, fmt(e));
return format(msg);
}
// ==================== 占位符定界符 ====================
/**
* 占位符可用的定界符对:中英文小括号、中括号、花括号,外加 %…% 与 &lt;…&gt;。
* 例:{username} [username] (username) 【用户】 (用户) {username} [username]
* 「玩家名」 『开始时间』 %endtime% &lt;结束时间&gt;
* JSON 模式会跳过花括号 {} 这一对——JSON 对象结构本身就是花括号,避免不必要的冲突。
*/
private static final String[][] PLACEHOLDER_DELIMS = {
{"{", "}"},
{"[", "]"},
{"(", ")"},
{"【", "】"},
{"(", ")"},
{"{", "}"},
{"[", "]"},
{"「", "」"},
{"『", "』"},
{"%", "%"},
{"<", ">"}
};
/** 是否 Legacy 色码字符(0-9a-fk-or,大小写不敏感,含 hex 的 x) */
private static boolean isLegacyCodeChar(char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|| "kKlLoOmMnNrRxX".indexOf(c) >= 0;
}
/**
* 防御性剥离:去掉首尾空白与 Legacy 色码(&amp;c / §c / &amp;x&amp;R&amp;R&amp;R&amp;R&amp;R&amp;R)。
* headDeco(可为 null)收集头部被剥离的色码原串,JSON 解析成功后回贴到组件上;
* 尾部色码只剥离不收集——它后面已无文本,应用它没有意义。
* 这层剥离专门解决「&amp;c&amp;l 包着 JSON → 判不出 JSON → 原样吐源码给玩家」的冲突。
*/
private static String stripDecoration(String s, StringBuilder headDeco) {
if (s == null) return "";
int b = 0, e = s.length();
while (b < e) {
char c = s.charAt(b);
if (Character.isWhitespace(c)) {
b++;
continue;
}
if ((c == '&' || c == '§') && b + 1 < e && isLegacyCodeChar(s.charAt(b + 1))) {
if (headDeco != null) headDeco.append(c).append(s.charAt(b + 1));
b += 2;
continue;
}
break;
}
while (e > b) {
char last = s.charAt(e - 1);
if (Character.isWhitespace(last)) {
e--;
continue;
}
if (e - 2 >= b && (s.charAt(e - 2) == '&' || s.charAt(e - 2) == '§')
&& isLegacyCodeChar(last)) {
e -= 2;
continue;
}
break;
}
return s.substring(b, e);
}
/** Legacy 色码字符 → Adventure 命名色(0-9a-f),未知字符返回 null */
private static NamedTextColor legacyColor(char c) {
switch (c) {
case '0': return NamedTextColor.BLACK;
case '1': return NamedTextColor.DARK_BLUE;
case '2': return NamedTextColor.DARK_GREEN;
case '3': return NamedTextColor.DARK_AQUA;
case '4': return NamedTextColor.DARK_RED;
case '5': return NamedTextColor.DARK_PURPLE;
case '6': return NamedTextColor.GOLD;
case '7': return NamedTextColor.GRAY;
case '8': return NamedTextColor.DARK_GRAY;
case '9': return NamedTextColor.BLUE;
case 'a': return NamedTextColor.GREEN;
case 'b': return NamedTextColor.AQUA;
case 'c': return NamedTextColor.RED;
case 'd': return NamedTextColor.LIGHT_PURPLE;
case 'e': return NamedTextColor.YELLOW;
case 'f': return NamedTextColor.WHITE;
default: return null;
}
}
/**
* 把剥离出来的前置色码(&amp;c&amp;l / §c§l / &amp;x&amp;R…)贴回解析好的组件:
* 包一层空组件承载,色码只是「默认样式」,JSON 片段里显式写了颜色/样式的仍然优先。
*/
private static Component applyDecoration(Component c, String deco) {
if (c == null) return Component.empty();
if (deco == null || deco.isEmpty()) return c;
Component out = Component.empty();
for (int i = 0; i + 1 < deco.length(); i++) {
char sep = deco.charAt(i);
if (sep != '&' && sep != '§') continue;
char code = Character.toLowerCase(deco.charAt(i + 1));
if (code == 'x') {
// 六位十六进制:&x&R&R&R&R&R&R(成对色码,只吃 hex 数字)
StringBuilder hex = new StringBuilder();
int j = i + 2;
while (j + 1 < deco.length()
&& (deco.charAt(j) == '&' || deco.charAt(j) == '§')
&& isLegacyCodeChar(deco.charAt(j + 1))) {
char h = Character.toLowerCase(deco.charAt(j + 1));
if (h < '0' || (h > '9' && h < 'a') || h > 'f') break;
hex.append(h);
j += 2;
}
if (hex.length() == 6) {
TextColor col = TextColor.fromHexString("#" + hex);
if (col != null) out = out.color(col);
i = j - 2;
}
continue;
}
if ((code >= '0' && code <= '9') || (code >= 'a' && code <= 'f')) {
NamedTextColor col = legacyColor(code);
if (col != null) out = out.color(col);
} else if (code == 'l') {
out = out.decorate(TextDecoration.BOLD);
} else if (code == 'o') {
out = out.decorate(TextDecoration.ITALIC);
} else if (code == 'n') {
out = out.decorate(TextDecoration.UNDERLINED);
} else if (code == 'm') {
out = out.decorate(TextDecoration.STRIKETHROUGH);
} else if (code == 'k') {
out = out.decorate(TextDecoration.OBFUSCATED);
} else if (code == 'r') {
out = out.color(null)
.decoration(TextDecoration.BOLD, TextDecoration.State.FALSE)
.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE)
.decoration(TextDecoration.UNDERLINED, TextDecoration.State.FALSE)
.decoration(TextDecoration.STRIKETHROUGH, TextDecoration.State.FALSE)
.decoration(TextDecoration.OBFUSCATED, TextDecoration.State.FALSE);
}
}
return out.append(c);
}
/** 解析 JSON 文本组件;失败返回 null(不抛异常) */
private static Component tryParseJson(String json) {
try {
return GsonComponentSerializer.gson().deserialize(json);
} catch (Throwable t) {
return null;
}
}
/**
* 「像 JSON 但没闭合」的防御判定:以 { 或 [ 开头,且含引号后跟冒号的键值特征。
* 普通文本([公告] 他说你好)不含该特征,挡在外面,不会被误修。
*/
private static boolean looksRepairableJson(String s) {
if (s == null || s.length() < 2) return false;
char f = s.charAt(0);
return (f == '{' || f == '[') && s.indexOf("\":") >= 0;
}
/**
* 尽力修补残缺 JSON:引号数为奇数则补一个闭合引号,再按括号栈补齐右括号。
* 只在原解析失败后调用;修不好就返回原串(调用方退回纯文本渲染)。
*/
private static String repairJson(String s) {
String out = s;
int quotes = 0;
for (int i = 0; i < out.length(); i++) {
if (out.charAt(i) == '"') quotes++;
}
if (quotes % 2 == 1) out = out + '"';
StringBuilder stack = new StringBuilder();
boolean inStr = false;
for (int i = 0; i < out.length(); i++) {
char c = out.charAt(i);
if (c == '"' && (i == 0 || out.charAt(i - 1) != '\\')) inStr = !inStr;
if (inStr) continue;
if (c == '{') stack.append('}');
else if (c == '[') stack.append(']');
else if (c == '}' || c == ']') {
if (stack.length() > 0 && stack.charAt(stack.length() - 1) == c) {
stack.deleteCharAt(stack.length() - 1);
}
}
}
for (int i = stack.length() - 1; i >= 0; i--) out = out + stack.charAt(i);
return out;
}
/**
* 占位符替换,定界符见 PLACEHOLDER_DELIMS(中英文小/中/大括号、%…%、&lt;…&gt;)。
* JSON 消息(含 &amp;c&amp;l 包着 JSON 的情形):跳过花括号 {}、不做裸子串替换——
* 花括号是 JSON 对象结构本身,裸替换会吃掉正文里的 end/start/player 等英文词
* ("end soon" -> "2026-09-28 10:00 soon")。JSON 消息里请用 [username]、(用户)、%username% 等形式。
* 普通 &amp;c 文本:全部定界符 + 裸替换都生效({username}、username 均可)。
* 「标签+定界符」混合写法(开始时间[starttime]):只替换定界符占位符、保留标签文字,
* 避免标签被裸替换二次吃掉、渲染成双份时间。
*/
private static String replaceVar(String msg, String[] keys, String value) {
if (msg == null || msg.isEmpty()) return msg;
String v = value == null ? "" : value;
boolean jsonMode = looksLikeJson(msg);
// 「标签+定界符」混合写法防御:如「开始时间[starttime],结束时间:{endtime}」。
// 只要本组任一 key 已以定界符形式出现,就说明用户写的是「标签文字 + 占位符」,
// 此时整组跳过裸子串替换、保留 开始时间/结束时间 这类标签原文;
// 否则标签会被二次替换成时间,渲染成「时间 时间」双份连排。
boolean delimHit = false;
for (String k : keys) {
for (String[] d : PLACEHOLDER_DELIMS) {
if (jsonMode && d[0].equals("{")) continue;
if (msg.contains(d[0] + k + d[1])) {
delimHit = true;
break;
}
}
if (delimHit) break;
}
for (String k : keys) {
for (String[] d : PLACEHOLDER_DELIMS) {
if (jsonMode && d[0].equals("{")) continue; // JSON 跳过花括号,避免与 JSON 结构冲突
msg = msg.replace(d[0] + k + d[1], v);
}
if (!jsonMode && !delimHit) msg = msg.replace(k, v);
}
return msg;
}
/**
* 整段是否为 JSON 文本组件:形如 {...} 或 [...],且含双引号。
* 先剥离首尾色码与空白再判定——&amp;c&amp;l 包着 JSON 也认得出来(防御层)。
* 「含双引号」用于把 [玩家名]、[公告] 这类普通文本挡在 JSON 分支之外——
* Gson 默认宽松解析会把 [玩家名] 当字符串数组吃掉,导致正文被吞。
*/
private static boolean looksLikeJson(String s) {
if (s == null) return false;
String t = stripDecoration(s, null);
int n = t.length();
if (n < 2 || t.indexOf('"') < 0) return false;
char f = t.charAt(0);
char l = t.charAt(n - 1);
return (f == '{' && l == '}') || (f == '[' && l == ']');
}
/** JSON 内的 &lt;br&gt; 变体(<br> <br/> <br /> </br>,大小写不敏感) */
private static final Pattern BR_IN_JSON_PAT = Pattern.compile("(?i)</?br\\s*/?>");
/**
* 把 JSON 文本里的字面 &lt;br&gt; 换成 JSON 转义换行(反斜杠 + n 两字符)。
* 必须是两字符序列:真实换行在 JSON 字符串里非法,写进去整段就解析失败了。
*/
private static String replaceBrInJson(String json) {
return BR_IN_JSON_PAT.matcher(json).replaceAll("\\\\n");
}
/**
* 消息格式化:
* 0) 防御层:剥掉首尾色码/空白再判定 JSON;残缺 JSON 尽力修补——
* 绝不把 JSON 源码原样吐给玩家(剥掉的色码如 &amp;c&amp;l 作为默认样式贴回结果)
* 1) 整段是 JSON(对象 {...} 或顶层数组 [...],后者是 tellraw 常见写法)
* → 按文本组件解析(支持 text/color/extra 等);字面 &lt;br&gt; 会先换成 JSON 转义换行
* 2) 否则处理 &lt;br&gt; 与字面 \n 换行、&amp; 颜色码与 § 颜色码
*/
private static Component format(String msg) {
if (msg == null) msg = "";
// 防御层:先剥掉包在外面的 Legacy 色码(&c&l / §c§l 等)再判定,
// 否则「&c&l 包着 JSON」首字符是 & → 判不出 JSON → JSON 源码原样吐给玩家。
StringBuilder headDeco = new StringBuilder();
String core = stripDecoration(msg.trim(), headDeco);
if (looksLikeJson(core) || looksRepairableJson(core)) {
// ★ JSON 字符串里的字面 <br>/<br/> 同样要变成换行,否则标签会原样显示给玩家。
// Gson 只认 JSON 转义(反斜杠 + n),所以替换成两字符序列,而不是真实换行
// (真实换行在 JSON 字符串里是非法字符,会让整段解析失败退回纯文本)。
Component parsed = tryParseJson(replaceBrInJson(core));
if (parsed == null) {
// 残缺 JSON(缺引号/右括号)→ 尽力补齐再试一次,同样是为了不原样吐源码
String fixed = repairJson(core);
if (!fixed.equals(core)) parsed = tryParseJson(replaceBrInJson(fixed));
}
if (parsed != null) return applyDecoration(parsed, headDeco.toString());
}
String s = msg;
s = s.replace("<br/>", "\n").replace("<br />", "\n").replace("<br>", "\n");
s = s.replace("</br>", "\n").replace("<BR>", "\n").replace("<Br>", "\n");
s = s.replace("\\n", "\n");
s = AMP_COLOR_PAT.matcher(s).replaceAll("§$1");
String[] lines = s.split("\n", -1);
Component out = Component.empty();
for (int i = 0; i < lines.length; i++) {
if (i > 0) out = out.append(Component.newline());
out = out.append(LegacyComponentSerializer.legacySection().deserialize(lines[i]));
}
return out;
}
private static String fmt(long ms) {
try {
return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date(ms));
} catch (Exception e) {
return String.valueOf(ms);
}
}
// ==================== 工具 ====================
private boolean isOp(String name) {
Boolean cached = opCache.get(name);
if (cached != null) return cached;
boolean r = false;
try {
OfflinePlayer p = Bukkit.getOfflinePlayer(name);
r = p.isOp();
} catch (Throwable t) {
r = false;
}
opCache.put(name, r);
return r;
}
/** 段落头:[白名单] / 【白名单】 / 白名单 / ===== 白名单段 ===== / 玩家白名单 / [WHITELIST](含 = 的行一律不是段落头) */
private static boolean isWhitelistHeader(String line) {
String s = line.trim();
if (s.isEmpty()) return false;
// ★ 注释形式的段标题也算:用户常把「[白名单]」行删掉,只留下「# ===== 白名单段 =====」这条注释。
// 先剥掉 # /// 注释前缀再判;键值对类注释(含 =)仍被下面的硬门槛挡住,不会误判成段落头。
while (s.startsWith("#") || s.startsWith("//")) {
s = s.substring(s.startsWith("//") ? 2 : 1).trim();
}
if (s.isEmpty()) return false;
// ★ 硬门槛:先剥掉首尾装饰符号(=====、----、::: 等),再看中间是否还剩「=」。
// 剩下的 = 说明这是控制段的键值对(如「挂机白名单 = xxx」),绝不是段落头;
// 只有装饰等号的 =====白名单段===== 剥完就干净了,不会被误杀。
// 没有这道门槛时,控制键一旦被误判成段落头,后续所有行都滑进白名单段,
// 「启用 = true」就读不到 → 维护开关直接失效。
String body = s.replaceAll("^[=\\-_::·*#\\s]+|[=\\-_::·*#\\s]+$", "");
if (body.indexOf('=') >= 0) return false;
// 剥离各类括号与装饰符号(含全角【】〔〕「」『』()),只留标题正文
String stripped = s.replaceAll("[\\[\\]\\u3010\\u3011\\u3014\\u3015\\u300c\\u300d\\u300e\\u300f"
+ "\\uff08\\uff09()\\-=_::·*#\\s]+", "");
String low = stripped.toLowerCase(Locale.ROOT);
// 精确匹配常见写法;再兜底 endsWith 兼容「玩家白名单」「ALLOWLIST」这类变体标题
//(有了上面的 = 门槛,endsWith 已经不会再吞掉控制段键值对)
return low.equals("白名单") || low.equals("白名单段") || low.equals("白名单列表")
|| low.equals("允许名单") || low.equals("whitelist") || low.equals("allowlist")
|| low.endsWith("白名单") || low.endsWith("whitelist") || low.endsWith("allowlist");
}
// ==================== 配置文本解析(static,便于单测) ====================
/** parseConfig 的结果:控制段键值对 + 白名单条目 + 是否见过 [白名单] 段标题 */
static final class ParsedConfig {
final Map<String, String> ctl;
final Set<String> wl;
final boolean sawWhiteHeader;
ParsedConfig(Map<String, String> ctl, Set<String> wl, boolean sawWhiteHeader) {
this.ctl = ctl;
this.wl = wl;
this.sawWhiteHeader = sawWhiteHeader;
}
}
/**
* 解析配置文本。三层兜底,防止管理员把自己关在门外:
* 1) 段标题判断在注释跳过之前——「# ===== 白名单段 =====」这种注释形式的标题也认
* 2) 段外的无键值行(既没有 = 也没有 :)按玩家名收录——标题行被删时靠它救
* 3) 条目先清洗零宽字符 / 行内注释 / 列表前缀再收录,避免肉眼看着对却匹配不上
*/
static ParsedConfig parseConfig(String text, Logger log) {
Map<String, String> ctl = new LinkedHashMap<>();
Set<String> wl = new LinkedHashSet<>();
boolean inWhite = false;
boolean sawHeader = false;
for (String rawLine : text.split("\n")) {
String line = rawLine.trim();
if (line.isEmpty()) continue;
if (isWhitelistHeader(line)) {
inWhite = true;
sawHeader = true;
continue;
}
if (line.startsWith("#") || line.startsWith("//")) continue;
if (inWhite) {
addWhitelistEntry(wl, line, log, null);
} else {
int eq = line.indexOf('=');
if (eq < 0) eq = line.indexOf(':');
if (eq < 0) {
addWhitelistEntry(wl, line, log,
"该行不在 [白名单] 段内也不是键值对,已按玩家名兜底收录");
continue;
}
String k = normKey(line.substring(0, eq));
if (k.isEmpty()) continue;
ctl.put(k, line.substring(eq + 1).trim());
}
}
if (!sawHeader && !wl.isEmpty()) {
warn(log, "未识别到 [白名单] 段标题,已按「无键值=玩家名」兜底收录 "
+ wl.size() + " 个条目");
}
return new ParsedConfig(ctl, wl, sawHeader);
}
/** 清洗并收录一条白名单条目(note 为附加告警说明,可为 null) */
private static void addWhitelistEntry(Set<String> wl, String raw, Logger log, String note) {
String s = stripInvisible(raw);
if (!s.equals(raw)) {
warn(log, "白名单条目含不可见字符(零宽/全角空格/不间断空格),已清洗: ["
+ raw + "] -> [" + s + "]");
}
int c = indexOfInlineComment(s);
if (c >= 0) {
warn(log, "白名单条目的行内注释已忽略: " + s.substring(c).trim());
s = s.substring(0, c).trim();
}
// 列表装饰前缀:- > * · •(合法 MC 名不会以这些字符开头)
String noDecor = s.replaceFirst("^[-*>\\u00b7\\u2022]\\s+", "");
if (!noDecor.equals(s)) {
warn(log, "白名单条目前缀符号已忽略: [" + s + "] -> [" + noDecor + "]");
s = noDecor;
}
s = s.trim();
if (s.isEmpty()) return;
if (note != null) warn(log, note + ": " + raw);
// 仍坚持「一行一个玩家名」:整行收录,但格式不规范必须能被看见
if (s.indexOf(',') >= 0 || s.indexOf('\uFF0C') >= 0 || s.indexOf('\u3001') >= 0
|| s.indexOf(';') >= 0 || s.indexOf('\uFF1B') >= 0 || s.indexOf(' ') >= 0
|| s.indexOf('\u3000') >= 0 || s.indexOf('\t') >= 0) {
warn(log, "白名单条目不符合「一行一个玩家名」,已按整行收录: " + s);
}
wl.add(s);
}
/** 去掉肉眼看不见却会让匹配失败的字符:零宽/BOM/不间断空格/全角空格 */
private static String stripInvisible(String s) {
if (s == null) return null;
return s.replaceAll("[\\u200B-\\u200D\\u2060\\uFEFF\\u00A0\\u3000]", "");
}
/** 行内注释起点(# / # / //),返回 -1 表示没有 */
private static int indexOfInlineComment(String s) {
int h = s.indexOf('#');
if (h < 0) h = s.indexOf('\uFF03');
if (h < 0) h = s.indexOf("//");
return h <= 0 ? -1 : h;
}
private static void warn(Logger log, String msg) {
if (log != null) log.warning("[维护模式] " + msg);
}
/** 布尔词识别(与 toBool 口径一致),用于把 flase / ture 这类拼写错误提示出来 */
private static boolean isBoolWord(String v) {
if (v == null) return false;
String s = v.trim().toLowerCase(Locale.ROOT);
if (s.isEmpty()) return false;
return s.equals("true") || s.equals("yes") || s.equals("y") || s.equals("on")
|| s.equals("1") || s.equals("是") || s.equals("开") || s.equals("开启") || s.equals("启用")
|| s.equals("false") || s.equals("no") || s.equals("n") || s.equals("off")
|| s.equals("0") || s.equals("否") || s.equals("关") || s.equals("关闭") || s.equals("停用");
}
/** 白名单条目摘要(最多 20 个),日志里直接对账用 */
private static String summarize(Set<String> names) {
StringBuilder sb = new StringBuilder();
int i = 0;
for (String n : names) {
if (i == 20) {
sb.append(", …共").append(names.size()).append("人");
return sb.toString();
}
if (i++ > 0) sb.append(", ");
sb.append(n);
}
return sb.toString();
}
private static String normKey(String k) {
if (k == null) return "";
return k.trim().toLowerCase(Locale.ROOT).replace(" ", "").replace("_", "").replace(" ", "");
}
private static String pick(Map<String, String> ctl, String... keys) {
for (String k : keys) {
String v = ctl.get(normKey(k));
if (v != null && !v.trim().isEmpty()) return v.trim();
}
return null;
}
private static boolean toBool(String v, boolean def) {
if (v == null) return def;
String s = v.trim().toLowerCase(Locale.ROOT);
if (s.isEmpty()) return def;
return s.equals("true") || s.equals("yes") || s.equals("y") || s.equals("on")
|| s.equals("1") || s.equals("是") || s.equals("开") || s.equals("开启") || s.equals("启用");
}
/**
* 时间解析,支持:
* - 10位秒级时间戳 / 13位毫秒时间戳
* - 2026-09-28 20:00 / 2026/9/28 20:00:33 / 2026.09.28
* - 2026年9月28日20时30分 / 2026年9月28日 20:30
* - 只写 20:00(今天该时刻)
* 解析失败返回 null
*/
static Long parseTime(String raw) {
if (raw == null) return null;
String s = raw.trim().replace("\"", "");
if (s.isEmpty()) return null;
if (s.matches("\\d{10}")) return Long.parseLong(s) * 1000L;
if (s.matches("\\d{13}")) return Long.parseLong(s);
List<Long> n = new ArrayList<>();
Matcher m = NUM_PAT.matcher(s);
while (m.find()) {
try {
n.add(Long.parseLong(m.group()));
} catch (Exception ignore) {
}
}
if (n.isEmpty()) return null;
String first = String.valueOf(n.get(0));
if (first.length() == 10) return n.get(0) * 1000L;
if (first.length() == 13) return n.get(0);
if (n.size() == 2 && n.get(0) <= 23 && n.get(1) <= 59) {
LocalDateTime now = LocalDateTime.now();
return now.withHour(n.get(0).intValue()).withMinute(n.get(1).intValue())
.withSecond(0).withNano(0)
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
if (n.size() < 3) return null;
int y = n.get(0).intValue();
int mo = n.get(1).intValue();
int d = n.get(2).intValue();
int h = n.size() > 3 ? n.get(3).intValue() : 0;
int mi = n.size() > 4 ? n.get(4).intValue() : 0;
int se = n.size() > 5 ? n.get(5).intValue() : 0;
if (y < 1970 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31
|| h > 23 || mi > 59 || se > 59) {
return null;
}
try {
return LocalDateTime.of(y, mo, d, h, mi, se)
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
} catch (Exception e) {
return null;
}
}
/** 时长解析:24 / 24h / 12小时 / 90分钟 / 2天,默认单位小时 */
static Long parseDuration(String raw) {
if (raw == null) return null;
String s = raw.trim().toLowerCase(Locale.ROOT);
if (s.isEmpty()) return null;
Matcher m = DUR_PAT.matcher(s);
if (!m.find()) return null;
double v;
try {
v = Double.parseDouble(m.group(1));
} catch (Exception e) {
return null;
}
String unit = m.group(2) == null ? "" : m.group(2);
long ms;
if (unit.contains("毫秒")) ms = 1L;
else if (unit.startsWith("s") || unit.contains("秒")) ms = 1000L;
else if (unit.startsWith("m") || unit.contains("分")) ms = 60_000L;
else if (unit.startsWith("d") || unit.contains("天")) ms = 86_400_000L;
else ms = 3_600_000L;
long r = (long) (v * ms);
return r <= 0 ? null : r;
}
/** 优先按 UTF-8 严格解码,失败则按 GBK(Windows 记事本默认编码) */
private static String decode(byte[] bytes) {
if (bytes == null || bytes.length == 0) return "";
try {
CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
dec.decode(ByteBuffer.wrap(bytes));
return new String(bytes, StandardCharsets.UTF_8);
} catch (CharacterCodingException e) {
try {
return new String(bytes, Charset.forName("GBK"));
} catch (Exception e2) {
return new String(bytes, StandardCharsets.UTF_8);
}
}
}
}
@@ -0,0 +1,326 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
/**
* 多源在线翻译工具 —— 将MC英文材料名翻译为中文。
* 优先使用国内接口(有道/简心),失败后降级到Google。
* 结果缓存避免重复请求。
*/
public class MaterialTranslator {
private static final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
private static final ExecutorService executor = Executors.newFixedThreadPool(2);
private static final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
// 翻译源优先级:国内接口优先
private static final List<TranslationSource> SOURCES = Arrays.asList(
TranslationSource.YOUDAO,
TranslationSource.JIANXIN,
TranslationSource.GOOGLE
);
/**
* 翻译源枚举
*/
private enum TranslationSource {
YOUDAO("有道翻译"),
JIANXIN("简心翻译"),
GOOGLE("Google翻译");
final String name;
TranslationSource(String name) {
this.name = name;
}
}
/**
* 将材料名(如 GRASS_BLOCK)转为可读英文(Grass Block)
*/
public static String toReadable(String materialName) {
if (materialName == null || materialName.isEmpty()) return "";
return Arrays.stream(materialName.split("_"))
.map(w -> w.substring(0, 1).toUpperCase() + w.substring(1).toLowerCase())
.reduce((a, b) -> a + " " + b)
.orElse(materialName);
}
/**
* 同步翻译单个材料名(有缓存则直接返回)。
* 优先走 MC 官方 zh_cn.json 翻译,查不到才走机翻。
*/
public static String translate(String materialName) {
if (materialName == null || materialName.isEmpty()) return "";
String key = materialName.toUpperCase();
// 缓存命中
if (cache.containsKey(key)) {
return cache.get(key);
}
// 1. 官方翻译(基于原始 Material 名 GRASS_BLOCK → block.minecraft.grass_block)
String official = OfficialTranslations.lookUp(materialName);
if (official != null && !official.isEmpty()) {
cache.put(key, official);
return official;
}
// 2. 机翻兜底
String readable = toReadable(key);
String zh = translateWithFallback(readable);
if (zh != null && !zh.isEmpty()) {
cache.put(key, zh);
return zh;
}
return readable; // fallback 可读英文
}
/**
* 批量异步翻译,返回 future map
*/
public static CompletableFuture<Map<String, String>> translateBatch(Collection<String> materials) {
Set<String> unique = new LinkedHashSet<>();
for (String m : materials) {
if (m != null && !m.isEmpty() && !cache.containsKey(m.toUpperCase())) {
unique.add(m);
}
}
if (unique.isEmpty()) {
Map<String, String> result = new HashMap<>();
for (String m : materials) {
if (m != null) result.put(m, cache.getOrDefault(m.toUpperCase(), toReadable(m)));
}
return CompletableFuture.completedFuture(result);
}
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (String m : unique) {
futures.add(CompletableFuture.runAsync(() -> translate(m), executor));
}
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> {
Map<String, String> result = new HashMap<>();
for (String m : materials) {
if (m != null) {
result.put(m, cache.getOrDefault(m.toUpperCase(), toReadable(m)));
}
}
return result;
});
}
/**
* 多源翻译,按优先级尝试,失败自动降级
*/
private static String translateWithFallback(String text) {
for (TranslationSource source : SOURCES) {
try {
String result = null;
switch (source) {
case YOUDAO:
result = translateViaYoudao(text);
break;
case JIANXIN:
result = translateViaJianxin(text);
break;
case GOOGLE:
result = translateViaGoogle(text);
break;
}
if (result != null && !result.isEmpty()) {
return result;
}
} catch (Exception e) {
Bukkit.getLogger().warning("[翻译] 机翻 " + source.name + " 异常: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
return null;
}
// ==================== 翻译源实现 ====================
/**
* 有道翻译API
* URL: https://v.api.aa1.cn/api/api-fanyi-yd/index.php
* 参数: msg=翻译内容, type=1(中→英) / 2(英→中)
*/
private static String translateViaYoudao(String text) {
try {
String encoded = URLEncoder.encode(text, StandardCharsets.UTF_8);
// type=2 表示英→中
String url = "https://v.api.aa1.cn/api/api-fanyi-yd/index.php?msg=" + encoded + "&type=2";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", "Mozilla/5.0")
.timeout(Duration.ofSeconds(3))
.GET()
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
// 返回: {"type":"中英互译","desc":"中文翻译英文","text":"翻译结果"}
return parseJsonField(resp.body(), "text");
}
} catch (Exception e) {
// 静默降级
}
return null;
}
/**
* 简心翻译API
* URL: https://api.qvqa.cn/api/fanyi
* 参数: text=翻译内容, source=en, target=zh
*/
private static String translateViaJianxin(String text) {
try {
String encoded = URLEncoder.encode(text, StandardCharsets.UTF_8);
String url = "https://api.qvqa.cn/api/fanyi?text=" + encoded + "&source=en&target=zh";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", "Mozilla/5.0")
.timeout(Duration.ofSeconds(3))
.GET()
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
// 返回: {"code":200,"message":"OK","data":{"sourceText":"...","targetText":"翻译结果",...}}
return parseNestedJsonField(resp.body(), "data", "targetText");
}
} catch (Exception e) {
// 静默降级
}
return null;
}
/**
* Google Translate 免费接口
*/
private static String translateViaGoogle(String text) {
try {
String encoded = URLEncoder.encode(text, StandardCharsets.UTF_8);
String url = "https://clients5.google.com/translate_a/single"
+ "?client=gtx&sl=en&tl=zh-CN&dt=t&q=" + encoded;
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", "Mozilla/5.0")
.timeout(Duration.ofSeconds(3))
.GET()
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
// 响应格式: [["翻译结果","原文",...],...]
String body = resp.body();
int firstQuote = body.indexOf("[[\"");
if (firstQuote >= 0) {
int endQuote = body.indexOf("\"", firstQuote + 3);
if (endQuote > firstQuote) {
return body.substring(firstQuote + 3, endQuote);
}
}
}
} catch (Exception e) {
// 静默降级
}
return null;
}
// ==================== JSON解析工具 ====================
/**
* 简单JSON字段解析(单层)
* 从 {"key":"value"} 中提取 value
*/
private static String parseJsonField(String json, String field) {
try {
String pattern = "\"" + field + "\"";
int idx = json.indexOf(pattern);
if (idx < 0) return null;
// 找到冒号后的引号
int colon = json.indexOf(':', idx + pattern.length());
if (colon < 0) return null;
int quoteStart = json.indexOf('"', colon + 1);
if (quoteStart < 0) return null;
int quoteEnd = json.indexOf('"', quoteStart + 1);
if (quoteEnd < 0) return null;
return json.substring(quoteStart + 1, quoteEnd);
} catch (Exception e) {
return null;
}
}
/**
* 嵌套JSON字段解析
* 从 {"outer":{"inner":"value"}} 中提取 value
*/
private static String parseNestedJsonField(String json, String outerField, String innerField) {
try {
// 找到外层对象
String outerPattern = "\"" + outerField + "\"";
int outerIdx = json.indexOf(outerPattern);
if (outerIdx < 0) return null;
// 找到外层对象的开始大括号
int braceStart = json.indexOf('{', outerIdx);
if (braceStart < 0) return null;
// 从外层对象内部解析内层字段
int depth = 1;
int i = braceStart + 1;
while (i < json.length() && depth > 0) {
char c = json.charAt(i);
if (c == '{') depth++;
else if (c == '}') depth--;
i++;
}
String innerJson = json.substring(braceStart, i);
return parseJsonField(innerJson, innerField);
} catch (Exception e) {
return null;
}
}
/**
* 清理缓存(可选)
*/
public static void clearCache() {
cache.clear();
}
/**
* 获取当前使用的翻译源信息(调试用)
*/
public static String getSourceInfo() {
StringBuilder sb = new StringBuilder("翻译源优先级: ");
for (int i = 0; i < SOURCES.size(); i++) {
TranslationSource src = SOURCES.get(i);
sb.append(i + 1).append(".").append(src.name);
if (i < SOURCES.size() - 1) sb.append(" > ");
}
return sb.toString();
}
}
@@ -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);
}
}
+470
View File
@@ -0,0 +1,470 @@
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();
// 优先菜单.txt,备选menu.txt
File f = new File(
plugin.getDataFolder(),
"菜单.txt");
if (!f.exists()) {
File alt = new File(
plugin.getDataFolder(),
"menu.txt");
if (alt.exists()) {
f = alt;
} else {
createDefault(f);
}
}
parseFile(f);
plugin.getLogger().info(
"[Menu] 加载 " + items.size()
+ " 个菜单项 (源: "
+ f.getName() + ")");
}
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");
if (!f.exists()) {
File alt = new File(
plugin.getDataFolder(),
"menu.txt");
if (alt.exists()) f = alt;
}
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));
}
}
@@ -0,0 +1,229 @@
package Sdf1_login;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import org.bukkit.Bukkit;
import java.io.File;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
/**
* MC 官方中文翻译加载器。<br>
* <br>
* 工作流程优先级:<br>
* 1. 本地缓存 (plugins/Sdf1_login/zh_cn.json) —— 同步加载,开机即用<br>
* 2. Mojang API —— 异步下载最新版,写缓存并刷入内存<br>
* 3. 全部失败 —— 返回 null,调用者走机翻兜底<br>
* <br>
* zh_cn.json 键示例:<br>
* {@code block.minecraft.grass_block → "草方块"}<br>
* {@code item.minecraft.diamond_sword → "钻石剑"}<br>
* <br>
* 查找策略:直接 key → block.minecraft.* → item.minecraft.*
*/
public class OfficialTranslations {
private static final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
private static final Gson gson = new Gson();
// ---------- 对外 API ----------
/**
* 初始化(在 onEnable 调用)。<br>
* 纯同步:先读本地缓存 &rarr; 没有则从 Mojang API 下载并写缓存。<br>
* 缓存文件由部署脚本随 JAR 一起上传,确保服务器不直连 Mojang 也能加载。
*/
public static void init(Main plugin) {
File cacheFile = new File(plugin.getDataFolder(), "zh_cn.json");
// 1. 本地缓存 —— 同步加载
if (cacheFile.isFile()) {
try {
String content = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8);
loadFromJson(content);
plugin.getLogger().info("[官方翻译] 已加载本地缓存 zh_cn.json(" + map.size() + " 条)");
return; // 缓存命中,完毕
} catch (Exception e) {
plugin.getLogger().warning("[官方翻译] 本地缓存损坏: " + e.getMessage());
}
}
// 2. 无缓存或损坏 → 同步从 Mojang API 下载
plugin.getLogger().info("[官方翻译] 本地缓存不存在/损坏,从 Mojang API 同步下载...");
try {
String version = getMinecraftVersion();
if (version == null) throw new RuntimeException("无法获取服务器版本号");
String json = downloadFromMojang(version);
if (json == null) throw new RuntimeException("下载返回空");
Files.writeString(cacheFile.toPath(), json, StandardCharsets.UTF_8);
loadFromJson(json);
plugin.getLogger().info("[官方翻译] Mojang 下载成功(" + map.size() + " 条),已缓存至 " + cacheFile.getName());
} catch (Exception e) {
plugin.getLogger().warning("[官方翻译] 同步下载失败: " + e.getMessage() + ",走机翻兜底");
plugin.getLogger().log(Level.FINE, "[官方翻译] 同步下载详情", e);
}
}
/**
* 查找官方翻译。<br>
* 传入原始 Material 名(如 {@code GRASS_BLOCK}),自动尝试多种 key 模式。
*
* @param materialName 原始 Material 名(大小写无关)
* @return 中文翻译,或 null(未命中)
*/
public static String lookUp(String materialName) {
if (materialName == null || materialName.isEmpty()) return null;
String lower = materialName.toLowerCase(Locale.ROOT);
// 日志:当前 map 状态
Bukkit.getLogger().info("[翻译] officialLookUp: " + materialName
+ " | map已加载=" + isLoaded() + " 条目数=" + map.size());
// 1. 精确匹配(部分 key 可直接用)
String val = map.get(lower);
if (val != null) {
Bukkit.getLogger().info("[翻译] officialLookUp 命中(精确): " + lower + " → " + val);
return val;
}
// 2. block.minecraft.<name>
val = map.get("block.minecraft." + lower);
if (val != null) {
Bukkit.getLogger().info("[翻译] officialLookUp 命中(block): " + lower + " → " + val);
return val;
}
// 3. item.minecraft.<name>
val = map.get("item.minecraft." + lower);
if (val != null) {
Bukkit.getLogger().info("[翻译] officialLookUp 命中(item): " + lower + " → " + val);
return val;
}
Bukkit.getLogger().info("[翻译] officialLookUp 未命中: " + lower + ",将走机翻兜底");
return null; // 未命中 -> 调用者走机翻
}
/** 当前加载的条目数(调试用) */
public static int size() {
return map.size();
}
/** 是否已完成至少一次加载(缓存或下载) */
public static boolean isLoaded() {
return !map.isEmpty();
}
// ---------- 内部 ----------
/** 解析 zh_cn.json 字符串灌入内存 */
static void loadFromJson(String json) {
JsonObject obj = gson.fromJson(json, JsonObject.class);
if (obj == null) return;
map.clear();
for (Map.Entry<String, com.google.gson.JsonElement> entry : obj.entrySet()) {
map.put(entry.getKey(), entry.getValue().getAsString());
}
}
/** 从 Mojang API 拉取 zh_cn.json(完整链:manifest → version → asset index → object) */
static String downloadFromMojang(String version) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// Step 1: Version manifest
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest.json"))
.timeout(Duration.ofSeconds(10))
.header("User-Agent", "Sdf1Login/1.0")
.GET()
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) return null;
JsonObject manifest = gson.fromJson(resp.body(), JsonObject.class);
// 在 versions 数组中找目标版本
String versionUrl = null;
JsonArray versions = manifest.getAsJsonArray("versions");
if (versions != null) {
for (int i = 0; i < versions.size(); i++) {
JsonObject v = versions.get(i).getAsJsonObject();
if (version.equals(v.get("id").getAsString())) {
versionUrl = v.get("url").getAsString();
break;
}
}
}
if (versionUrl == null) return null;
// Step 2: Version JSON (获取 assetIndex url)
req = HttpRequest.newBuilder()
.uri(URI.create(versionUrl))
.timeout(Duration.ofSeconds(10))
.header("User-Agent", "Sdf1Login/1.0")
.GET()
.build();
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) return null;
JsonObject versionJson = gson.fromJson(resp.body(), JsonObject.class);
JsonObject assetIndex = versionJson.getAsJsonObject("assetIndex");
if (assetIndex == null) return null;
String assetIndexUrl = assetIndex.get("url").getAsString();
// Step 3: Asset index (定位 zh_cn.json 的 hash)
req = HttpRequest.newBuilder()
.uri(URI.create(assetIndexUrl))
.timeout(Duration.ofSeconds(10))
.header("User-Agent", "Sdf1Login/1.0")
.GET()
.build();
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) return null;
JsonObject assets = gson.fromJson(resp.body(), JsonObject.class);
JsonObject objects = assets.getAsJsonObject("objects");
if (objects == null) return null;
JsonObject langEntry = objects.getAsJsonObject("minecraft/lang/zh_cn.json");
if (langEntry == null) return null;
String hash = langEntry.get("hash").getAsString();
if (hash == null || hash.length() < 2) return null;
// Step 4: 下载 zh_cn.json
String langUrl = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
req = HttpRequest.newBuilder()
.uri(URI.create(langUrl))
.timeout(Duration.ofSeconds(15))
.header("User-Agent", "Sdf1Login/1.0")
.GET()
.build();
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
return resp.body();
}
return null;
}
/** 从 Bukkit API 获取纯版本号,如 "1.21.4" */
private static String getMinecraftVersion() {
String ver = Bukkit.getBukkitVersion();
if (ver == null) return null;
int dash = ver.indexOf('-');
return dash > 0 ? ver.substring(0, dash) : ver;
}
}
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+77 -72
View File
@@ -1,72 +1,77 @@
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);
}
}
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");
// 清理非 Base64 字符,防止数据库中转义字符导致解析错误
String cleanSalt = salt.replaceAll("[^A-Za-z0-9+/=]", "");
if (cleanSalt.isEmpty()) {
throw new IllegalArgumentException("Invalid salt: " + salt);
}
md.update(Base64.getDecoder().decode(cleanSalt));
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);
}
}
+4 -1
View File
@@ -12,8 +12,11 @@ public class PlayerStorage {
Logger logger) {
this.logger = logger;
File dbFile = new File(
dataFolder,
new File(dataFolder,
"任务面板"),
"quest_data.db");
dbFile.getParentFile()
.mkdirs();
try {
Class.forName(
"org.sqlite.JDBC");
+375 -374
View File
@@ -1,374 +1,375 @@
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;
}
}
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) {
BondManager bm = plugin.getBonds();
if (bm != null) {
int before = bm.getBonds(p.getName());
bm.addBonds(p.getName(), 1000, "points_exchange", "", "积分商店", "积分兑换债券");
int after = bm.getBonds(p.getName());
plugin.getDb().deductPoints(p.getName(), 100);
p.sendMessage(plugin.getConfig2().msg("points_purchase_success")
.replace("{count}", "100"));
p.sendMessage("§a[积分] §f债券: §e" + before + " → §a" + after);
} else {
p.sendMessage("§c债券系统不可用");
}
} 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;
}
}
+164 -95
View File
@@ -7,132 +7,201 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
public class QuestStorage {
private Connection conn;
private volatile Connection conn;
private final String dbPath;
private final Plugin plugin;
private final Object connLock = new Object();
public QuestStorage(File dataFolder) {
public QuestStorage(File dataFolder, Plugin plugin) {
this.plugin = plugin;
String path = "";
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
dataFolder,
new File(dataFolder, "任务面板"),
"quest_data.db");
conn = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getPath());
initTable();
dbFile.getParentFile().mkdirs();
path = dbFile.getPath();
} catch (Exception e) {
e.printStackTrace();
}
dbPath = path;
conn = openConnection();
if (conn != null) {
initTable();
}
}
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) {
private Connection openConnection() {
String url = "jdbc:sqlite:" + dbPath
+ "?journal_mode=WAL"
+ "&synchronous=NORMAL"
+ "&busy_timeout=5000";
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();
return DriverManager.getConnection(url);
} catch (SQLException e) {
e.printStackTrace();
return null;
}
}
/**
* 轻量级连接检查 — 仅检查本地状态,不执行SQL
* 避免 isValid() 在主线程上阻塞
*/
private void ensureConnection() {
try {
if (conn != null && !conn.isClosed()) {
return;
}
} catch (SQLException ignored) {}
synchronized (connLock) {
try { if (conn != null) conn.close(); }
catch (Exception ignored) {}
conn = openConnection();
}
}
private void initTable() {
try {
conn.createStatement().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))");
} catch (SQLException e) {
e.printStackTrace();
}
}
public void setCondition(String player, String condition) {
setConditionRaw(player, condition, "1");
}
/**
* 查询条件值
* 异步写入 — 不阻塞主线程
* 使用 Bukkit 异步调度,在独立线程中执行 SQLite 写操作
*/
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();
// 尝试解析为数字
public void setConditionRaw(String player,
String condition, String value) {
final String sql =
"INSERT OR REPLACE INTO quest_conditions "
+ "(player, condition_key, value, timestamp) "
+ "VALUES (?, ?, ?, ?)";
final long ts = System.currentTimeMillis();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
writeSync(sql, player, condition, value, ts);
});
}
/**
* 在异步线程中执行写入,带重试
*/
private void writeSync(String sql, String player,
String condKey, String value, long timestamp) {
synchronized (connLock) {
for (int attempt = 0; attempt < 5; attempt++) {
try {
if (val.contains(".")) {
return Double.parseDouble(
val);
ensureConnection();
if (conn == null) return;
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, player);
ps.setString(2, condKey);
ps.setString(3, value);
ps.setLong(4, timestamp);
ps.executeUpdate();
ps.close();
return;
} catch (SQLException e) {
String msg = e.getMessage();
if (msg != null && (msg.contains("SQLITE_BUSY")
|| msg.contains("database is locked"))) {
try {
Thread.sleep(200 + attempt * 200);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
} else {
e.printStackTrace();
return;
}
}
}
}
}
/**
* 同步读取(主线程)
* WAL 模式下读操作不阻塞写,可安全在主线程执行
*/
public Object getCondition(String player,
String condition) {
synchronized (connLock) {
for (int attempt = 0; attempt < 3; attempt++) {
try {
ensureConnection();
if (conn == null) return null;
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();
return null;
} catch (SQLException e) {
String msg = e.getMessage();
if (msg != null && (msg.contains("SQLITE_BUSY")
|| msg.contains("database is locked"))) {
try {
Thread.sleep(100 + attempt * 100);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return null;
}
} else {
e.printStackTrace();
return null;
}
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();
synchronized (connLock) {
try {
if (conn != null && !conn.isClosed()) {
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Location;
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 org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scheduler.BukkitTask;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 快速返回系统 (/back)
* - 每分钟记录一次在线玩家的坐标
* - /back 命令传送玩家到最近的一个记录坐标
*/
public class QuickBack implements Listener, CommandExecutor, TabCompleter {
private final Main plugin;
// 内存缓存: player_name → Location (每分钟刷新)
private final Map<String, Location> lastKnownLocations = new ConcurrentHashMap<>();
// 冷却: player_name → next_available_time
private final Map<String, Long> cooldowns = new ConcurrentHashMap<>();
// ★ 定时器引用(用于 shutdown 时取消)
private BukkitTask scheduleTask = null;
private static final long COOLDOWN_MS = 10 * 1000; // 10秒冷却
public QuickBack(Main plugin) {
this.plugin = plugin;
loadAllFromDB();
// 每分钟记录所有在线玩家的坐标
scheduleTask = new BukkitRunnable() {
@Override
public void run() {
for (Player p : Bukkit.getOnlinePlayers()) {
recordLocation(p);
}
}
}.runTaskTimer(plugin, 1200L, 1200L);
}
// ==================== 事件监听 ====================
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerMove(PlayerMoveEvent event) {
// 移动超过8格时才记录(减少数据库写入压力)
Location from = event.getFrom();
Location to = event.getTo();
if (to == null || from.getWorld() != to.getWorld()) return;
double distance = from.distance(to);
if (distance < 8.0) return;
recordLocation(event.getPlayer());
}
// ==================== 命令处理 ====================
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player)) {
sender.sendMessage("§c此命令仅限玩家使用");
return true;
}
Player player = (Player) sender;
// 检查冷却
Long nextAvailable = cooldowns.get(player.getName());
if (nextAvailable != null && System.currentTimeMillis() < nextAvailable) {
long remaining = (nextAvailable - System.currentTimeMillis()) / 1000;
player.sendMessage("§c快速返回冷却中,请等待 §e" + remaining + " §c秒");
return true;
}
// 获取最近的已知位置
Location loc = getLastKnownLocation(player);
if (loc == null) {
player.sendMessage("§c没有找到可返回的位置记录");
return true;
}
// 传送
if (loc.getWorld() == null) {
player.sendMessage("§c位置世界信息无效");
return true;
}
// ★ 禁止/back传送至PVP测试世界
String worldName = loc.getWorld().getName();
if (worldName != null && worldName.startsWith("pvp_test_")) {
player.sendMessage("§c§l[快速返回] §c无法返回PVP测试世界,请使用 /pvp test 进入测试场选装");
return true;
}
Location dest = loc.clone().add(0.5, 0, 0.5); // 对齐格子中心
player.teleport(dest);
// 设置冷却
cooldowns.put(player.getName(), System.currentTimeMillis() + COOLDOWN_MS);
player.sendMessage("§a§l[快速返回] §f已传送到最近记录点");
return true;
}
// ==================== 位置记录 ====================
private void recordLocation(Player player) {
Location loc = player.getLocation();
lastKnownLocations.put(player.getName(), loc);
// ★ 读坐标/更新内存缓存留主线程;写库是阻塞 DB 操作 -> 异步
final String name = player.getName();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
saveToDB(name, loc);
} catch (Throwable t) {
plugin.getLogger().warning("[快捷返回] 坐标落库失败 (" + name + "): " + t.getMessage());
}
});
}
public Location getLastKnownLocation(Player player) {
// 优先从内存获取
Location loc = lastKnownLocations.get(player.getName());
if (loc != null && loc.getWorld() != null) {
return loc;
}
// 从数据库获取
return loadFromDB(player.getName());
}
// ==================== 数据库操作 ====================
private void loadAllFromDB() {
try {
java.sql.Connection conn = plugin.getDb().getConnection();
if (conn == null) return;
PreparedStatement ps = conn.prepareStatement(
"SELECT player_name, world, x, y, z FROM quick_back_records");
ResultSet rs = ps.executeQuery();
while (rs.next()) {
String playerName = rs.getString("player_name");
Location loc = new Location(
Bukkit.getWorld(rs.getString("world")),
rs.getDouble("x"),
rs.getDouble("y"),
rs.getDouble("z")
);
lastKnownLocations.put(playerName, loc);
}
rs.close();
ps.close();
} catch (SQLException e) {
// 表可能不存在
}
}
private void saveToDB(String playerName, Location loc) {
try {
java.sql.Connection conn = plugin.getDb().getConnection();
if (conn == null) return;
PreparedStatement ps = conn.prepareStatement(
"INSERT OR REPLACE INTO quick_back_records (player_name, world, x, y, z) VALUES (?, ?, ?, ?, ?)");
ps.setString(1, playerName);
ps.setString(2, loc.getWorld() != null ? loc.getWorld().getName() : "world");
ps.setDouble(3, loc.getX());
ps.setDouble(4, loc.getY());
ps.setDouble(5, loc.getZ());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
// 忽略
}
}
private Location loadFromDB(String playerName) {
try {
java.sql.Connection conn = plugin.getDb().getConnection();
if (conn == null) return null;
PreparedStatement ps = conn.prepareStatement(
"SELECT world, x, y, z FROM quick_back_records WHERE player_name=? LIMIT 1");
ps.setString(1, playerName);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
Location loc = new Location(
Bukkit.getWorld(rs.getString("world")),
rs.getDouble("x"),
rs.getDouble("y"),
rs.getDouble("z")
);
rs.close();
ps.close();
return loc;
}
rs.close();
ps.close();
} catch (SQLException e) {
// 忽略
}
return null;
}
// ==================== Tab补全 ====================
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
return new ArrayList<>();
}
// ==================== 初始化 ====================
public void initTable() {
try {
java.sql.Connection conn = plugin.getDb().getConnection();
if (conn == null) return;
java.sql.Statement st = conn.createStatement();
st.execute("CREATE TABLE IF NOT EXISTS quick_back_records ("
+ "player_name TEXT PRIMARY KEY, "
+ "world TEXT, "
+ "x REAL, y REAL, z REAL)");
st.close();
} catch (SQLException e) {
plugin.getLogger().warning("[快速返回] 建表失败: " + e.getMessage());
}
}
// ==================== 关闭 ====================
public void shutdown() {
if (scheduleTask != null) {
scheduleTask.cancel();
}
lastKnownLocations.clear();
cooldowns.clear();
}
}
@@ -4,115 +4,141 @@ 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 org.bukkit.event.player
.PlayerResourcePackStatusEvent;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
import java.util.concurrent
.ConcurrentHashMap;
public class RadioDownloadListener
implements Listener {
private final Logger log;
private final Main plugin;
private final Map<UUID, Integer> retryCount =
new ConcurrentHashMap<>();
private static final int MAX_RETRY = 2;
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();
String status =
e.getStatus().name();
log.info("[Radio] " + p.getName()
+ " status: " + status);
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 "ACCEPTED":
break;
case "DOWNLOADED":
break;
case "SUCCESS":
log.info("[Radio] " + p.getName()
+ " 资源包加载成功");
retryCount.remove(uuid);
log.info("[Radio] "
+ p.getName()
+ " ✓ 资源包加载成功");
failCount.remove(uuid);
blocked.remove(uuid);
break;
case "FAILED_DOWNLOAD":
handleRetry(p, uuid);
case "ACCEPTED":
log.info("[Radio] "
+ p.getName()
+ " 客户端已接受,"
+ "等待下载...");
break;
case "DECLINED":
log.info("[Radio] " + p.getName()
+ " 拒绝下载");
p.sendMessage(
"§e[Radio] §f你拒绝了资源包");
retryCount.remove(uuid);
log.warning("[Radio] "
+ p.getName()
+ " ✗ 拒绝了资源包");
/* log.warning("[Radio] "
+ "可能原因: "
+ "1.客户端设置禁用资源包"
+ " 2.已缓存拒绝记录"
+ " 3.SSL证书不匹配");*/
handleFailure(p, uuid);
break;
case "DISCARDED":
log.warning("[Radio] " + p.getName()
+ " DISCARDED");
p.sendMessage(
"§c[Radio] §f资源包下载失败"
+ ",可能原因:");
p.sendMessage(
"§71. CDN/防火墙拦截");
p.sendMessage(
"§72. SSL证书问题");
retryCount.remove(uuid);
break;
case "INVALID_URL":
log.warning("[Radio] " + p.getName()
+ " URL无效");
retryCount.remove(uuid);
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);
log.info("[Radio] "
+ p.getName()
+ " 未知状态: "
+ status);
break;
}
}
private void handleRetry(Player p, UUID uuid) {
int count = retryCount
.getOrDefault(uuid, 0) + 1;
retryCount.put(uuid, count);
private void handleFailure(Player p, UUID uuid) {
int failNum = failCount.getOrDefault(uuid, 0) + 1;
failCount.put(uuid, failNum);
log.warning("[Radio] " + p.getName()
+ " 下载失败 (" + count
+ "/" + MAX_RETRY + ")");
+ " 失败次数: " + failNum + "/" + MAX_FAILED);
if (count <= MAX_RETRY) {
p.sendMessage(
"§e[Radio] §f下载失败,重试中... ("
+ count + "/" + MAX_RETRY + ")");
Bukkit.getScheduler().runTaskLater(
plugin,
() -> {
if (p.isOnline()
&& plugin.radio != null) {
plugin.radio
.sendResourcePack(p);
}
}, 60L);
} else {
p.sendMessage(
"§c[Radio] §f资源包加载失败,"
+ "部分音乐无法播放");
retryCount.remove(uuid);
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);
}
}
+282 -248
View File
@@ -1,248 +1,282 @@
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();
}
}
package Sdf1_login;
import org.bukkit.BanList;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.*;
import java.util.Date;
import java.util.concurrent.ConcurrentHashMap;
/**
* 风控管理器:密码错误阈值封禁 + 报警邮件
*
* 功能1:密码错误达到阈值 → 自动封禁账号N分钟 + 推送报警邮件
* 功能2:封禁期间拒绝所有登录尝试
*/
public class RiskControlManager {
private final Main plugin;
// 每个玩家名 -> 连续失败次数
private final Map<String, Integer> failAttempts =
new ConcurrentHashMap<>();
// 每个玩家名 -> 封禁到期时间戳(毫秒)
private final Map<String, Long> banUntil =
new ConcurrentHashMap<>();
public RiskControlManager(Main plugin) {
this.plugin = plugin;
}
/**
* 获取当前密码错误阈值(从配置读取)
*/
private int getMaxFailAttempts() {
return plugin.getConfig2().passwordFailThreshold;
}
/**
* 获取封禁时长(毫秒)(从配置读取)
*/
private long getBanDurationMs() {
return (long) plugin.getConfig2().passwordBanDuration * 1000L;
}
// ==================== 封禁检查 ====================
/**
* 检查玩家是否已被封禁
* @return true=已被封禁(应拒绝登录)
*/
public boolean isBanned(String playerName) {
Long until = banUntil.get(playerName);
if (until == null) return false;
if (System.currentTimeMillis() < until) {
return true; // 仍在封禁期内
}
// 封禁已过期,自动解除
banUntil.remove(playerName);
failAttempts.remove(playerName);
return false;
}
/**
* 获取玩家剩余封禁时间(秒),0=未封禁
*/
public int getBanRemainingSeconds(String playerName) {
Long until = banUntil.get(playerName);
if (until == null) return 0;
long remaining = until - System.currentTimeMillis();
if (remaining <= 0) {
banUntil.remove(playerName);
failAttempts.remove(playerName);
return 0;
}
return (int) (remaining / 1000) + 1; // 向上取整
}
// ==================== 失败记录 ====================
/**
* 记录一次失败的登录尝试,判断是否触发封禁
*
* @param player 登录失败的玩家
* @return 剩余尝试次数(-1=已被封禁)
*/
public int recordFailAttempt(Player player) {
String name = player.getName();
int maxAttempts = getMaxFailAttempts();
// 已被封禁,直接返回
if (isBanned(name)) {
return -1;
}
int current = failAttempts
.getOrDefault(name, 0) + 1;
failAttempts.put(name, current);
int remaining = Math.max(0, maxAttempts - current);
// 达到阈值 → 触发封禁
if (current >= maxAttempts) {
triggerBan(player);
}
return remaining;
}
/**
* 触发封禁:Bukkit真实封禁 + 记录时间 + 发邮件 + 踢出
* 双层封禁:Bukkit ban阻止连接加入,内存计时器到期自动解封
*/
private void triggerBan(Player player) {
String name = player.getName();
long durationMs = getBanDurationMs();
long until = System.currentTimeMillis() + durationMs;
banUntil.put(name, until);
String ip = plugin.getPlayerIP(player);
int banMinutes = (int) (durationMs / 60000);
plugin.getLogger().warning(
"[风控] 玩家 " + name
+ " 密码错误达到阈值("
+ getMaxFailAttempts() + "次)"
+ ",封禁 " + banMinutes + " 分钟");
// ★ 真实封禁:调用Bukkit BanList API阻止玩家重新加入服务器
String banReason = "§c§l[安全风控] 密码连续错误"
+ getMaxFailAttempts() + "次,"
+ "账号临时封禁" + banMinutes + "分钟";
try {
// 使用 BanList API 设置带过期时间的封禁
Date expiry = new Date(System.currentTimeMillis() + durationMs);
Bukkit.getBanList(BanList.Type.NAME).addBan(
name, banReason, expiry, "[Sdf1_login] 风控");
plugin.getLogger().info(
"[风控] 已通过Bukkit BanList封禁玩家 "
+ name + " (" + banMinutes + "分钟,到期: " + expiry + ")");
} catch (Exception e) {
plugin.getLogger().warning(
"[风控] BanList封禁失败: " + e.getMessage());
}
// 踢出玩家(无论BanList是否成功,立即踢出)
player.kickPlayer(banReason);
// 异步发送报警邮件
sendBanAlertEmail(name, ip, banMinutes);
}
/**
* 发送封禁报警邮件到玩家绑定的邮箱
* 未绑定邮箱则跳过
*/
private void sendBanAlertEmail(String playerName,
String ip, int banMinutes) {
String emailAddr = (String) plugin.getDb()
.getField(playerName, "email");
if (emailAddr == null || emailAddr.isEmpty()) {
plugin.getLogger().info(
"[风控] 玩家 " + playerName
+ " 未绑定邮箱,跳过报警邮件");
return;
}
String subject =
"[安全警告] 您的账号已被临时封禁";
String body = "尊敬的玩家 " + playerName + ":\n\n"
+ "您的账号在 "
+ new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date())
+ " 因密码连续错误 "
+ getMaxFailAttempts() + " 次"
+ ",已被自动封禁 " + banMinutes + " 分钟。\n\n"
+ "来源IP: " + (ip != null ? ip : "未知") + "\n"
+ "封禁到期: "
+ new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date(
System.currentTimeMillis()
+ (long) banMinutes * 60000L))
+ "\n\n"
+ "如果这是您本人操作,请等待封禁到期后重试。\n"
+ "如果非本人操作,建议您立即修改密码"
+ "(使用 /sdf1_login pw)。\n\n"
+ "--Sdf1_login 安全系统";
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getEmail().sendBody(
emailAddr, subject, body);
plugin.getLogger().info(
"[风控] 封禁报警邮件已发送到 "
+ emailAddr);
} catch (Exception e) {
plugin.getLogger().warning(
"[风控] 报警邮件发送失败: "
+ e.getMessage());
}
});
}
// ==================== 成功重置 ====================
/**
* 登录成功后重置风控状态
*/
public void onLoginSuccess(String playerName) {
failAttempts.remove(playerName);
banUntil.remove(playerName);
}
// ==================== 清理 ====================
/**
* 清理过期的封禁记录(可由定时任务调用)
* 同步解除过期玩家的Bukkit封禁
*/
public void cleanup() {
long now = System.currentTimeMillis();
// 收集过期的玩家名
List<String> expired = new ArrayList<>();
banUntil.entrySet().removeIf(e -> {
if (e.getValue() < now) {
expired.add(e.getKey());
return true;
}
return false;
});
// 同步解除Bukkit封禁
for (String name : expired) {
try {
Bukkit.getBanList(BanList.Type.NAME)
.pardon(name);
plugin.getLogger().info(
"[风控] 封禁到期,已解除Bukkit封禁: "
+ name);
} catch (Exception ignored) {}
failAttempts.remove(name);
}
}
/**
* 获取所有封禁记录(调试/管理用)
*/
public Map<String, Long> getAllBans() {
return Collections.unmodifiableMap(banUntil);
}
/**
* 获取所有失败尝试记录(调试用)
*/
public Map<String, Integer> getAllFailAttempts() {
return Collections.unmodifiableMap(failAttempts);
}
/**
* 手动解除某玩家的封禁(管理员用)
*/
public void unban(String playerName) {
banUntil.remove(playerName);
failAttempts.remove(playerName);
// 同步解除Bukkit封禁
try {
Bukkit.getBanList(BanList.Type.NAME)
.pardon(playerName);
plugin.getLogger().info("[风控] 已解除Bukkit封禁: "
+ playerName);
} catch (Exception ignored) {}
}
/**
* 清除所有风控数据(管理员调试用)
*/
public void clearAll() {
failAttempts.clear();
banUntil.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);
}
}
+51
View File
@@ -0,0 +1,51 @@
package Sdf1_login;
import org.bukkit.Material;
public class ShopItem {
private final String id;
private final String displayName;
private final Material material;
private int buyPrice;
private 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 setBuyPrice(int p) { this.buyPrice = p; }
public void setSellPrice(int p) { this.sellPrice = p; }
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
File diff suppressed because it is too large Load Diff
+154 -120
View File
@@ -163,7 +163,7 @@ public class TicketManager {
"§7类型: §f" + type,
"§7状态: " + stxt(status),
"§7提交者: §f" + requester,
"§7奖励: §e" + reward + " 积分"));
"§7奖励: §e" + reward + " 债券"));
g.setItem(12, mk(Material.BOOK,
"§7详细描述",
@@ -242,6 +242,13 @@ public class TicketManager {
"§a标记完结",
"§7确认工单处理完成"));
}
// ★ 服务商上报完结后:管理员确认完结
if ("completed".equals(status)) {
g.setItem(19, mk(Material.LIME_DYE,
"§a确认完结",
"§7确认服务商处理完成",
"§7输入评分1-5"));
}
// 驳回(非已关闭/已完结状态)
if (!"closed".equals(status)
&& !"resolved".equals(status)
@@ -410,7 +417,7 @@ public class TicketManager {
"§a§l[工单] §f创建成功!"
+ " #§e" + id
+ " 奖励: §e"
+ price + "积分");
+ price + "债券");
}
notifyAdmins(p.getName(), id);
} else {
@@ -697,6 +704,11 @@ public class TicketManager {
"§e§l===================");
}
/**
* ★ 由异步定时器调用(Main.startTicketAutoProcess)。
* DB 查询/写回留在当前(异步)线程;
* 取玩家 / 发消息 / 备份背包 / 踢人 一律经 sync() 回主线程。
*/
public void autoProcessCompleted() {
long timeoutMs = 10 * 60 * 1000L;
@@ -730,25 +742,26 @@ public class TicketManager {
.getUnconfirmedCompleted(
timeoutMs);
for (Map<String, Object> t : list) {
int id = nv(t, "id");
final int id = nv(t, "id");
plugin.getDb().confirmTicket(id, 3);
Player rp = Bukkit.getPlayer(
sv(t, "requester"));
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 已自动完结");
String assigned =
sv(t, "assigned_to");
if (!assigned.isEmpty()) {
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline())
pp.sendMessage(
final String req = sv(t, "requester");
final String asg = sv(t, "assigned_to");
sync(() -> {
Player rp = Bukkit.getPlayer(req);
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id
+ " 已自动完结");
}
+ id + " 已自动完结");
if (!asg.isEmpty()) {
Player pp =
Bukkit.getPlayer(asg);
if (pp != null && pp.isOnline())
pp.sendMessage(
"§e§l[工单] §f工单 #"
+ id
+ " 已自动完结");
}
});
settleTicket(id);
}
@@ -758,14 +771,16 @@ public class TicketManager {
.getRepliedStaleTickets(
timeoutMs);
for (Map<String, Object> t : stale) {
int id = nv(t, "id");
final int id = nv(t, "id");
plugin.getDb().confirmTicket(id, 3);
Player rp = Bukkit.getPlayer(
sv(t, "requester"));
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 超时自动完结");
final String req = sv(t, "requester");
sync(() -> {
Player rp = Bukkit.getPlayer(req);
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 超时自动完结");
});
settleTicket(id);
}
}
@@ -1017,23 +1032,18 @@ public class TicketManager {
// 管理员:标记完结(已回复状态)
if (adm(p) && "replied".equals(st)
&& asg.equals("admin")) {
// 管理员:标记完结(已回复状态)
if (adm(p) && "replied".equals(st)
&& asg.equals("admin")) {
Map<String, Object> tk =
plugin.getDb().getTicket(id);
String tkType = sv(tk, "type");
if (TYPE_DELETE.equals(tkType)) {
approveDeleteTicket(p, id);
return true;
}
plugin.getDb().completeTicket(id);
p.sendMessage("§a工单 #" + id
+ " 已标记完结");
openDetail(p, id);
Map<String, Object> tk =
plugin.getDb().getTicket(id);
String tkType = sv(tk, "type");
if (TYPE_DELETE.equals(tkType)) {
approveDeleteTicket(p, id);
return true;
}
plugin.getDb().completeTicket(id);
p.sendMessage("§a工单 #" + id
+ " 已标记完结");
openDetail(p, id);
return true;
}
// 服务商:抢单
if (plugin.getDb()
@@ -1044,6 +1054,19 @@ public class TicketManager {
openDetail(p, id);
return true;
}
// ★ 管理员:确认服务商完结(输入评分)
if (adm(p) && "completed".equals(st)) {
p.closeInventory();
plugin.getChatInput()
.getState(p).type =
ChatInputManager.InputType
.TICKET_SCORE;
plugin.getChatInput()
.getState(p).ticketId = id;
p.sendMessage(
"§e输入评分1-5:");
return true;
}
// 服务商:标记完结
if (plugin.getDb()
.isServiceProvider(p.getName())
@@ -1251,72 +1274,73 @@ public class TicketManager {
// ========== 私有 ==========
/**
* 把 Bukkit 操作切回主线程执行。
* 已在主线程时立即执行(保持原调用方的同步语义)。
* 供异步定时器路径(autoProcessCompleted / settleTicket / processDeleteAccount)使用。
*/
private void sync(Runnable r) {
if (Bukkit.isPrimaryThread()) r.run();
else Bukkit.getScheduler().runTask(plugin, r);
}
private void settleTicket(int tid) {
Map<String, Object> t =
plugin.getDb().getTicket(tid);
if (t == null) return;
String requester =
final String requester =
sv(t, "requester");
String assigned =
final String assigned =
sv(t, "assigned_to");
int reward = nv(t, "reward_amount");
int score = nv(t, "score");
ConfigManager cfg =
plugin.getConfig2();
// ★ 提交者获得工单奖励(债券)
if (reward > 0) {
plugin.getDb().addPoints(
requester, reward);
Player rp = Bukkit.getPlayer(
requester);
if (rp != null && rp.isOnline()) {
rp.sendMessage(
"§e§l[工单] §f获得 "
+ reward
+ " 积分奖励");
}
}
if (!assigned.isEmpty()) {
int pts = (int) (score
* cfg.providerPointsPerScore);
if (pts > 0) {
plugin.getDb().addPoints(
assigned, pts);
}
double money =
cfg.baseEconomyReward
+ score
* cfg.providerEconomyPerScore;
boolean ok = false;
try {
if (plugin.getEconomy() != null) {
OfflinePlayer op =
Bukkit.getOfflinePlayer(
assigned);
plugin.getEconomy()
.depositPlayer(
op, money);
ok = true;
plugin.getBonds().addBonds(
requester, reward,
BondManager.TX_TICKET_REWARD, "",
"System", "工单 #" + tid + " 奖励");
sync(() -> {
Player rp = Bukkit.getPlayer(
requester);
if (rp != null && rp.isOnline()) {
rp.sendMessage(
"§e§l[工单] §f获得 "
+ reward
+ " 债券奖励");
}
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] 经济失败: "
+ e.getMessage());
}
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline()) {
pp.sendMessage(
"§a§l[工单] §f工单 #"
+ tid
+ " 确认完结");
if (pts > 0)
pp.sendMessage("§7积分: §e"
+ pts);
if (ok)
pp.sendMessage("§7经济: §a$"
+ String.format(
"%.0f", money));
});
}
// ★ 服务商获得债券奖励
if (!assigned.isEmpty()) {
int bondReward = (int) (score
* cfg.providerPointsPerScore);
if (bondReward > 0) {
plugin.getBonds().addBonds(
assigned, bondReward,
BondManager.TX_TICKET_SERVICE, "",
"System", "工单 #" + tid
+ " 服务奖励(评分"
+ score + ")");
}
final int br = bondReward;
sync(() -> {
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline()) {
pp.sendMessage(
"§a§l[工单] §f工单 #"
+ tid
+ " 确认完结");
if (br > 0)
pp.sendMessage("§7债券: §e"
+ br + " 枚");
}
});
}
}
@@ -1404,36 +1428,46 @@ public class TicketManager {
*/
public void processDeleteAccount(int tid,
Map<String, Object> t) {
String requester = sv(t, "requester");
final String requester = sv(t, "requester");
plugin.getDb().updateTicketStatus(
tid, "resolved");
plugin.getDb().updateTicketField(tid,
"admin_confirmed", 1);
Player target =
Bukkit.getPlayer(requester);
if (target != null && target.isOnline()) {
plugin.backupInventory(target);
target.sendMessage(
"§a§l[工单] §f您的删号请求已通过");
target.sendMessage(
"§7物品已备份,下次登录可取回");
target.kickPlayer(
"§a删号成功,物品已备份。\n"
+ "请等待5分钟后重新注册取回物品。");
} else {
// 离线:仅标记,不操作数据库
plugin.getLogger().info(
"[Sdf1_login] " + requester
+ " 删号工单已通过,等待上线执行");
}
// 延迟执行删号
// ★ 备份背包 / 发消息 / 踢人 必须主线程
sync(() -> {
Player target =
Bukkit.getPlayerExact(requester);
if (target != null && target.isOnline()) {
plugin.backupInventory(target);
target.sendMessage(
"§a§l[工单] §f您的删号请求已通过");
target.sendMessage(
"§7物品已备份,下次登录可取回");
target.kickPlayer(
"§a删号成功,物品已备份。\n"
+ "请等待5分钟后重新注册取回物品。");
} else {
// 离线:仅标记,不操作数据库
plugin.getLogger().info(
"[Sdf1_login] " + requester
+ " 删号工单已通过,等待上线执行");
}
});
// 延迟执行删号(DB 写 -> 异步,不占主线程)
final String name = requester;
Bukkit.getScheduler().runTaskLater(
Bukkit.getScheduler().runTaskLaterAsynchronously(
plugin, () -> {
plugin.getDb().deleteUser(name);
plugin.getLogger().info(
"[Sdf1_login] " + name
+ " 删号工单已执行");
int deleted =
plugin.getDb().deleteUser(name);
if (deleted > 0) {
plugin.getLogger().info(
"[Sdf1_login] " + name
+ " 删号工单已执行");
} else {
plugin.getLogger().warning(
"[Sdf1_login] " + name
+ " 删号工单未执行(精准匹配下账号不存在,未误删)");
}
}, 100L);
}
@@ -1559,7 +1593,7 @@ public class TicketManager {
lore.add("§7提交者: §f"
+ requester);
lore.add("§7奖励: §e" + reward
+ " 积分");
+ " 债券");
lore.add("");
lore.add("§7点击查看详情");
im.setLore(lore);
@@ -1641,4 +1675,4 @@ public class TicketManager {
}
return it;
}
}
}
@@ -0,0 +1,116 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
/**
* Sdf1_game寻宝系统桥接类
* 用于Sdf1_login调用Sdf1_game的clearPlayerAllClaims方法
*/
public class TreasureBridge {
private Object treasureManager;
private Method clearPlayerAllClaimsMethod;
private boolean hooked = false;
/**
* 桥接Sdf1_game插件
* @return 是否桥接成功
*/
public boolean hook() {
Plugin sdf1_game = Bukkit.getPluginManager()
.getPlugin("Sdf1_game");
if (sdf1_game == null || !sdf1_game.isEnabled()) {
return false;
}
try {
// 获取Sdf1_game的Main实例
Object mainInstance = sdf1_game;
// 获取TreasureManager实例
treasureManager = findTreasureManager(mainInstance);
if (treasureManager == null) {
return false;
}
// 获取clearPlayerAllClaims方法
clearPlayerAllClaimsMethod = treasureManager.getClass()
.getMethod("clearPlayerAllClaims", String.class);
hooked = true;
return true;
} catch (Exception e) {
return false;
}
}
/**
* 清空指定玩家的所有领取记录
* @param playerName 玩家名
* @return 清除的记录数量,失败返回-1
*/
public int clearPlayerAllClaims(String playerName) {
if (!isHooked()) return -1;
try {
int result = (int) clearPlayerAllClaimsMethod.invoke(
treasureManager, playerName);
return result;
} catch (Exception e) {
return -1;
}
}
/**
* 查找TreasureManager实例
* @param mainInstance Sdf1_game的Main实例
* @return TreasureManager实例
*/
private Object findTreasureManager(Object mainInstance) {
Class<?> c = mainInstance.getClass();
// 策略1: getTreasureManager() 方法
try {
Method m = c.getMethod("getTreasureManager");
Object r = m.invoke(mainInstance);
if (r != null) return r;
} catch (Exception ignored) {
}
// 策略2: treasureManager 字段
try {
Field f = c.getDeclaredField("treasureManager");
f.setAccessible(true);
Object r = f.get(mainInstance);
if (r != null) return r;
} catch (Exception ignored) {
}
// 策略3: 遍历字段按类型名查找
try {
for (Field f : c.getDeclaredFields()) {
if (f.getType().getSimpleName()
.equals("TreasureManager")) {
f.setAccessible(true);
Object r = f.get(mainInstance);
if (r != null) return r;
}
}
} catch (Exception ignored) {
}
return null;
}
/**
* 是否已桥接
* @return 桥接状态
*/
public boolean isHooked() {
return hooked;
}
}
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
+102 -158
View File
@@ -1,158 +1,102 @@
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;
}
}
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.function.Consumer;
/**
* 玩家正版/离线身份验证管理器
*
* 核心逻辑:在离线模式(online-mode=false)服务器中,所有玩家UUID都是离线格式,
* 无法通过UUID区分正版/盗版。因此:
* - UUID非离线格式 → 服务器在线模式,正版玩家 → autoLogin
* - UUID离线格式 → 一律视为非正版,需手动登录或OAuth验证
* - 正版玩家通过 /mslogin OAuth验证后,由Main.addVerifiedPremiumPlayer()标记,
* 在onJoin检查点0直接autoLogin,不经过此验证器
*/
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();
}
/**
* 异步验证玩家身份
* 注意:此方法仅用于判断UUID格式,不执行自动登录
* 自动登录由onJoin中的检查点0/1/2控制
*/
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 {
java.util.UUID offlineUuid = java.util.UUID.nameUUIDFromBytes(
("OfflinePlayer:" + name)
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
boolean isOfflineUuid =
player.getUniqueId().equals(offlineUuid);
plugin.getLogger().info(
"[Sdf1_login] 离线UUID: " + offlineUuid);
plugin.getLogger().info(
"[Sdf1_login] UUID匹配离线格式: "
+ isOfflineUuid);
if (!isOfflineUuid) {
// UUID不是离线格式 → 服务器在线模式的正版玩家
plugin.getLogger().info(
"[Sdf1_login] 结论: 正版玩家(非离线UUID)");
plugin.getLogger().info(
"[Sdf1_login] ====== 验证结束 ======");
callback.accept(true);
return;
}
// ★ 离线UUID → 无法通过UUID区分正版/盗版,一律视为非正版
// 正版玩家需通过 /mslogin OAuth验证 或 /login 手动登录
plugin.getLogger().info(
"[Sdf1_login] 结论: 离线UUID玩家,需手动登录");
plugin.getLogger().info(
"[Sdf1_login] (正版玩家请使用 /mslogin 或 /正版 命令验证)");
plugin.getLogger().info(
"[Sdf1_login] ====== 验证结束 ======");
callback.accept(false);
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] 验证异常: "
+ e.getMessage());
callback.accept(false);
}
}
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
{
"OP白名单": "服务器的好管家",
"为什么要OP白名单": {
"根因有几点": {
"第一点": "已有OP乱给权限防不住",
"第二点": "有人利用服务器漏洞拿到OP权限",
"第三点": "不信任OP",
"第四点": "想临时给某个玩家一个op,原生不支持。必须到期手动/deop user"
}
},
"我们做了什么": {
"第一层": "临时授权,允许你给OP设置到期时间,到期自动撤销",
"第二层": "接管原生OP名单,不在白名单的一律撤销+报警"
},
"你需要做什么": [
"共3步",
"第一步:安装sdf1_login插件",
"第二步改这个文件",
"第三步按参考格式或后台指令添加授权",
"第四步:玩家自动获得授权,整点自动生效(手动改本文件时,会在15、30、45、0秒的整点自动增量热重载)"
],
"----": "----",
"启用状态": true,
"无固定期限授权列表": [],
"有固定期限授权列表": {},
"非法持有报警信息": "§8[§cOP白名单§8] §f{user} §7不持有授权却持有 OP,已于 §f{time} §7自动撤销 §8| §8[OPWL] §f{user} §7held OP illegally, revoked at §f{time}",
"默认时长": "24h",
"时长支持格式": [
"可以是中文口语:一个小时",
"可以是英文one hour",
"可以是标准写法:24H",
"格式很宽松,只要是标准json字符串格式就行。至于你怎么写,插件会解析"
],
"报警模板变量": {
"{user}": "非法持有的玩家名(也认 player / 用户 / 玩家 / username)",
"{time}": "发现时间(也认 时间 / date / 日期)",
"写法": "大小写不敏感,中英文变量名都支持;& 颜色码可用,渲染后自动转成§"
},
"接管说明": [
"本插件【接管原生OP名单】:插件首次加载时,会把当前所有原生OP自动导入「无固定期限授权列表」并写回本文件。",
"之后:任何持有OP但不在白名单的玩家(不在永久表/ 临时已过期 / 时长无法解析),一律自动撤销OP并按「非法持有报警信息」报警。",
"所以以后新增OP请用 /opwl add <玩家>,别用原版 /op;否则会被15秒内的巡检撤掉。",
"白名单全部写在本文件里(单文件),没有子目录也没有附属名单文件。"
],
"以下是模板文件": {
"# 启用状态": true,
"# 无固定期限授权列表": [
"user1",
"user2"
],
"# 有固定期限授权列表": {
"user1": "2026-10-06 23:00",
"user2": "1小时",
"user3": "two hour"
},
"# 非法持有报警信息": "支持 {user} 与 {time} 变量,中英文写法都认;留空则用内置默认文案",
"# 默认时长": "24小时"
},
"为什么这次选择json": {
"根因依旧2点": {
"1": "防止非专业人士修改本文件",
"2": "使用专业格式,让小白试探着劝退(一旦解析失败,所有玩家OP全部卸载)"
}
},
"指令参数": {
"opwl": "帮助菜单",
"opwl add user": "添加user用户为无固定期限用户",
"opwl addtime user": "按默认时长添加临时授权用户",
"opwl addtime user 1h": "给user用户价1小时授权",
"opwl removetime user 1h": "给user用户扣1小时授权",
"opwl remove user": "移除user的授权",
"opwl list user": "获取user的授权状态",
"opwl list": "列出OP白名单",
"opwl reload": "重载OP白名单",
"温馨提示": "opwl命令仅限控台执行,普通用户无权",
"加授权完全无感": "因为用户本身没有op时,被授权后,插件立即给予OP并记录授权状态。删除也是。不需要先添加用户在添加授权。"
},
"安全升级": [
"本文件即是配置文件也是说明书。这就意味着,你可以把其它混淆内容一起打包进本json文件,只要保留配置项。其它的你随便删改",
"编辑本文件时,如果担心改坏了,直接联网找一个在线json解析器,把本文件扔上去。json的写法是双引号包裹内容,一对双引号就是一个键值对。用花括号做嵌套式。就像本文件的解释说明字段一样"
]
}
+35
View File
@@ -0,0 +1,35 @@
# 非法域名后缀列表
# 每行一个后缀,支持以 * 开头表示匹配所有子域名
# 修改此文件后无需重启,插件会在每次检测时重新加载
# 默认包含常见非法注册域名后缀
com
cn
net
org
io
xyz
top
club
site
online
store
tech
info
biz
ru
tk
ml
ga
cf
cc
ws
tv
link
click
date
vip
party
fun
zone
space
+113 -14
View File
@@ -1,5 +1,5 @@
name: Sdf1_login
version: 1.0
version: 2.92
main: Sdf1_login.Main
api-version: '1.21'
commands:
@@ -42,9 +42,9 @@ commands:
签到:
description: 每日签到(中文)
usage: /签到
oa:
description: 申请删除账号
usage: /oa [玩家名]
#oa:
# description: 申请删除账号
# usage: /oa [玩家名]
删除账号:
description: 申请删除账号(中文)
usage: /删除账号 [玩家名]
@@ -60,18 +60,15 @@ commands:
deny:
description: 拒绝OA请求
usage: /deny <编号> [理由]
sdf1debug:
description: 调试指令
usage: /sdf1debug <玩家> take.<阶段>
email:
description: 绑定邮箱
usage: /email <邮箱>
2fa:
description: 二次验证(TOTP)绑定
usage: /2fa add|code <6位>|remove <6位>
绑定:
description: 绑定邮箱(中文)
usage: /绑定 <邮箱>
调试:
description: 调试模式
usage: /调试
绑定邮箱:
description: 绑定邮箱
usage: /绑定邮箱
@@ -84,7 +81,109 @@ commands:
垃圾清理:
description: 手动清理掉落物
usage: /垃圾清理
menu:
description: 雪球菜单开关控制
usage: /menu <开/关>
commands:
# 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
landrec:
description: 领地记录系统(回声碎片发放)
usage: /landrec give [玩家]
传送:
description: 传送至领地
usage: /传送 <领地名>
update:
description: 检查所有插件更新(集群控制)
usage: /update
web:
description: 生成Web端登录令牌
usage: /web
控制台:
description: 生成Web端登录令牌(中文版)
usage: /控制台
mslogin:
description: 正版登录(Microsoft OAuth)
usage: /mslogin
正版:
description: 正版登录(中文版)
usage: /正版
tpa:
description: tp传送指令
usage: /tpa
tpaccept:
description: 接受传送
usage: /tpaccept
tpdeny:
description: 拒绝传送
usage: /tpdeny
tpauto:
description: 自动接收传送
usage: /tpauto
tpacancel:
description: 取消传送请求
usage: /tpacancel
tpaall:
description: 请求传送所有人
usage: /tpaall
tpahere:
description: 请求传送ta到自己身边
usage: /tpahere
sethome:
description: 设置一个家
usage: /sethome <名称>
delhome:
description: 删除一个家
usage: /delhome <名称>
home:
description: 传送到家
usage: /home [名称]
homes:
description: 查看所有家
usage: /homes
group:
description: 用户组管理
usage: /group <buy|renew|info> [组名]
back:
description: 快速返回最近记录点
usage: /back
tempban:
description: 临时封禁玩家
usage: /tempban <玩家名/IP> <时长> [理由]
tempbanip:
description: 临时封禁IP或玩家
usage: /tempbanip <IP/玩家名> <时长> [理由]
banip:
description: 永久封禁IP或玩家
usage: /banip <IP/玩家名> [永久] [理由]
whois:
description: 查询玩家信息
usage: /whois [玩家名]
who:
description: 查看在线玩家列表
usage: /who
清空队列:
description: 清空DB队列中未回应的web同步任务(管理员)
usage: /清空队列
cleartake:
description: 清空DB队列中未回应的web同步任务(管理员)
usage: /cleartake
-137
View File
@@ -1,137 +0,0 @@
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<>();
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();
if (!isLoggedIn(name)) continue;
if (config.getAfkWhitelist().contains(name)) continue;
UUID uuid = p.getUniqueId();
if (!lastAction.containsKey(uuid)) {
lastAction.put(uuid, now);
lastStage.put(uuid, 0);
continue;
}
long last = lastAction.get(uuid);
long idle = (now - last) / 1000;
int timeout = config.afkTimeout;
long remain = timeout - idle;
// 已超时:踢出
if (remain <= 0) {
p.kickPlayer("§c§l你已挂机 " + fmtSec(idle) + ",被踢出");
lastAction.remove(uuid);
lastStage.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秒 ===
// 持续音效(每秒)+ 标题,聊天仅1次
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分钟提醒 ===
// 仅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);
}
}
// 超过3分钟:重置阶段
if (remain > 180) {
lastStage.remove(uuid);
}
}
}
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);
}
public void recordAction(UUID uuid) {
lastAction.put(uuid, System.currentTimeMillis());
lastStage.put(uuid, 0);
}
public void remove(UUID uuid) {
lastAction.remove(uuid);
lastStage.remove(uuid);
}
private boolean isLoggedIn(String name) {
return ((Main) plugin).getLoggedIn().contains(name);
}
private String fmtSec(long sec) {
if (sec < 60) return sec + "秒";
long m = sec / 60;
long s = sec % 60;
return m + "分" + (s > 0 ? s + "秒" : "");
}
}
-146
View File
@@ -1,146 +0,0 @@
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";
}
}
}
-594
View File
@@ -1,594 +0,0 @@
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 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 (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("通知管理员: 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("是");
}
}
-823
View File
@@ -1,823 +0,0 @@
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
}
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 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: {
int cost = 20;
if (!mainPlugin.getDb()
.deductPoints(
p.getName(), cost)) {
p.sendMessage(
"§c积分不足,补签需 "
+ cost + " 积分");
reset(p);
return;
}
p.sendMessage(
"§a补签成功!扣除 "
+ cost + " 积分。"
+ "日期: " + msg);
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);
}
}
}
-104
View File
@@ -1,104 +0,0 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
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");
}
int streak = ((Number) plugin.getDb()
.getField(name, "checkin_streak")).intValue();
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 points = (int) (1 * multi);
plugin.getDb().addPoints(name, points);
plugin.getDb().setField(
name, "checkin_streak", streak);
plugin.getDb().setField(
name, "last_checkin_date", today);
int total = ((Number) plugin.getDb()
.getField(name, "total_checkin_days"))
.intValue();
plugin.getDb().setField(
name, "total_checkin_days", total + 1);
return plugin.getConfig2().msg("checkin_success",
"points", String.valueOf(points),
"streak", String.valueOf(streak),
"multi", String.valueOf(multi));
}
public String backCheckIn(Player p, String dateStr) {
String name = p.getName();
if (!plugin.getDb().deductPoints(name, 10)) {
return plugin.getConfig2().msg("backcheckin_no_point");
}
try {
java.util.Date target = new SimpleDateFormat(
"yyyy-MM-dd").parse(dateStr);
java.util.Date now = new SimpleDateFormat(
"yyyy-MM-dd").parse(
new SimpleDateFormat("yyyy-MM-dd")
.format(new Date()));
long diff = now.getTime() - target.getTime();
if (diff < 0 || diff > 3 * 86400000L) {
plugin.getDb().addPoints(name, 10); // 退还
return plugin.getConfig2().msg(
"backcheckin_expired");
}
plugin.getDb().addPoints(name, 1); // 补签也给1分
return plugin.getConfig2().msg("backcheckin_success");
} catch (Exception e) {
plugin.getDb().addPoints(name, 10);
return "§c[Sdf1_login] §f日期格式错误,请用 yyyy-MM-dd";
}
}
}
-451
View File
@@ -1,451 +0,0 @@
package Sdf1_login;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static java.lang.Double.parseDouble;
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 ConfigManager(File dataFolder) {
this.dataFolder = dataFolder;
}
// ==================== 消息 ====================
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("# ==========================================");
L.add("# Sdf1_login 消息配置文件");
L.add("# ==========================================");
L.add("# 颜色代码使用 § 符号");
L.add("# §0黑色 §1深蓝 §2深绿 §3深青");
L.add("# §4深红 §5深紫 §6金色 §7灰色");
L.add("# §8深灰 §9蓝色 §a绿色 §b浅蓝");
L.add("# §c红色 §d粉色 §e黄色 §f白色");
L.add("# §l粗体 §n下划线 §o斜体 §r重置");
L.add("#");
L.add("# 占位符用 {xxx} 表示,运行时自动替换");
L.add("# 支持 <b>粗体</b> 标签");
L.add("# 支持 \\n 或 <br> 换行");
L.add("# 修改后执行 /sdf1_login reload 重载");
L.add("# ==========================================");
L.add("");
// --- 登录/注册 ---
L.add("# ---- 登录注册相关 ----");
L.add("# login_timeout: 登录超时被踢出时的提示");
L.add("login_timeout=§c登录超时,请重新登录");
L.add("# not_registered: 未注册玩家收到的提示");
L.add("not_registered=§e您尚未注册,请使用 /reg <密码>");
L.add("# not_logged_in: 已注册但未登录的提示");
L.add("not_logged_in=§e您尚未登录,请使用 /login <密码>");
L.add("# already_registered: 已注册玩家再次注册时的提示");
L.add("already_registered=§c您已注册过账号");
L.add("# already_logged_in: 已登录玩家再次登录时的提示");
L.add("already_logged_in=§c您已登录");
L.add("# reg_success: 注册成功提示");
L.add("reg_success=§a注册成功!欢迎来到服务器");
L.add("# login_success: 登录成功提示");
L.add("login_success=§a登录成功!");
L.add("# login_failed: 密码错误提示");
L.add("login_failed=§c密码错误,请重试");
L.add("");
// --- 密码 ---
L.add("# ---- 密码相关 ----");
L.add("# password_wrong: 旧密码验证失败");
L.add("password_wrong=§c密码错误");
L.add("# password_changed: 密码修改成功");
L.add("password_changed=§a密码修改成功!");
L.add("# password_format_error: 密码格式不符合要求");
L.add("password_format_error=§c密码需6位以上,含大小写字母和数字");
L.add("# password_same: 新密码与旧密码相同");
L.add("password_same=§c新密码不能与旧密码相同");
L.add("# reset_no_email: 找回密码时未绑定邮箱");
L.add("reset_no_email=§c您未绑定邮箱,无法找回密码");
L.add("# reset_sent: 临时密码已发送到邮箱");
L.add("reset_sent=§a临时密码已发送到您的邮箱,请查收");
L.add("");
// --- 签到积分 ---
L.add("# ---- 签到积分相关 ----");
L.add("# checkin_already: 今日已签到");
L.add("checkin_already=§c您今日已签到");
L.add("# checkin_success: 签到成功,{points}=获得积分 {streak}=连续天数 {multi}=倍率");
L.add("checkin_success=§a签到成功!获得 {points} 积分,连续 {streak} 天,倍率 x{multi}");
L.add("# points_insufficient: 积分不足");
L.add("points_insufficient=§c积分不足,当前: {points}");
L.add("# points_purchase_success: 积分购买成功");
L.add("points_purchase_success=§a购买成功!消耗 {count} 积分");
L.add("");
// --- 挂机 ---
L.add("# ---- 挂机检测相关 ----");
L.add("# afk_set_enabled: 挂机检测开启");
L.add("afk_set_enabled=§a挂机检测已开启");
L.add("# afk_set_disabled: 挂机检测关闭");
L.add("afk_set_disabled=§c挂机检测已关闭");
L.add("# afk_kick: 因挂机被踢出");
L.add("afk_kick=§c因挂机过久被踢出");
L.add("");
// --- 邮箱 ---
L.add("# ---- 邮箱相关 ----");
L.add("# email_set: 邮箱设置成功");
L.add("email_set=§a邮箱设置成功:{email}");
L.add("# email_format_error: 邮箱格式错误");
L.add("email_format_error=§c邮箱格式不正确");
L.add("");
// --- 邀请 ---
L.add("# ---- 邀请相关 ----");
L.add("# invite_code_generated: 邀请码生成");
L.add("invite_code_generated=§a邀请码:{code}");
L.add("# invite_used: 使用邀请码成功");
L.add("invite_used=§a邀请成功,邀请人:{player}");
L.add("# invite_self: 使用自己的邀请码");
L.add("invite_self=§c不能使用自己的邀请码");
L.add("# invite_not_found: 邀请码不存在");
L.add("invite_not_found=§c邀请码不存在");
L.add("# invite_referral_bonus: 被邀请人签到后邀请人获得奖励");
L.add("invite_referral_bonus=§a邀请奖励!获得 {points} 积分");
L.add("");
// --- 礼包 ---
L.add("# ---- 新人礼包相关 ----");
L.add("# gift_not_ready: 礼包条件未满足");
L.add("gift_not_ready=§c礼包条件未满足");
L.add("# gift_claimed: 礼包领取成功");
L.add("gift_claimed=§a礼包领取成功!");
L.add("");
// --- 审批/IP ---
L.add("# ---- 审批与IP限制 ----");
L.add("# need_approval: 注册需审批");
L.add("need_approval=§e注册需审批 (工单#{id})");
L.add("# ip_max_accounts: IP注册已达上限");
L.add("ip_max_accounts=§cIP注册已达上限");
L.add("");
// --- 管理员 ---
L.add("# ---- 管理员操作 ----");
L.add("# admin_delete_confirm: 管理员删除账号确认提示");
L.add("admin_delete_confirm=§c请在聊天中输入玩家名确认删除");
L.add("");
// --- 聊天过滤 ---
L.add("# ---- 聊天过滤 ----");
L.add("# chat_muted: 禁言玩家尝试聊天");
L.add("chat_muted=§c§l[Sdf1_chat] §f你已被禁言,无法聊天");
L.add("# chat_url_blocked: 发送违规链接被拦截");
L.add("# {url}=检测到的链接地址");
L.add("chat_url_blocked=§c§l[Sdf1_chat] §f检测到违规第三方链接: §e{url}");
L.add("# chat_url_violation: 当前违规次数");
L.add("# {count}=当前违规次数");
L.add("chat_url_violation=§7当前违规次数: §e{count}");
L.add("# chat_url_admin_notify: 通知管理员,{player}=玩家名 {url}=链接 {count}=次数");
L.add("chat_url_admin_notify=§e§l[Sdf1_chat] §f{player} 发送违规链接: {url} (第{count}次)");
L.add("# chat_url_broadcast: 全服通报,{player}=玩家名");
L.add("chat_url_broadcast=§c§l[Sdf1_chat] §e{player} §f因发送违规链接被处罚");
L.add("");
// --- 通用 ---
L.add("# ---- 通用 ----");
L.add("# 未知参数: 未知命令或权限不足时的提示");
L.add("未知参数=§c未知参数或权限不足");
L.add("# only_player: 仅玩家可用的命令");
L.add("# only_player=§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);
}
private double parseDouble(String s, double def) {
try { return Double.parseDouble(s.trim()); }
catch (Exception e) { return def; }
}
public String getSmtp(String key) {
return smtpSettings.getOrDefault(key, "");
}
/** 获取挂机白名单列表 */
public List<String> getAfkWhitelist() {
List<String> list = new ArrayList<>();
File file = new File(dataFolder, "挂机白名单.txt");
if (!file.exists()) {
try {
file.createNewFile();
try (BufferedWriter w = new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream(file),
StandardCharsets.UTF_8))) {
w.write("# 每行一个玩家名,前面加 # 为注释");
w.newLine();
w.write("# 以下玩家不会被挂机踢出");
w.newLine();
}
} catch (IOException ignored) {}
}
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;
}
/** 设置SMTP单项(聊天输入用) */
public void setSmtp(String key, String value) {
smtpSettings.put(key, value);
}
/** 将当前SMTP设置写回文件 */
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账号=your_email@example.com");
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"));
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);
}
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("# ---- 垃圾站 ----");
L.add("垃圾站_启用: "
+ garbageEnabled);
L.add("垃圾站_清理间隔秒: "
+ garbageInterval);
L.add("垃圾站_保留轮数: "
+ garbageMaxRounds);
writeLines(file, L);
}
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;
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("服务商积分倍率=" + providerPointsPerScore);
L.add("基础经济奖励=" + baseEconomyReward);
L.add("经济奖励每评分=" + providerEconomyPerScore);
L.add("报单人积分奖励=" + requesterPointsReward);
writeLines(f, L);
}
// ==================== 通用读写 ====================
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 e) {
System.err.println("[Sdf1_login] 读取失败: " + file.getName());
}
}
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 e) {
System.err.println("[Sdf1_login] 写入失败: " + file.getName());
}
}
private int parseInt(String s, int def) {
try { return Integer.parseInt(s.trim()); } catch (Exception e) { return def; }
}
/**
* 生成6位验证码(字母+数字)
*/
public String generateSmtpVerifyCode() {
String chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "abcdefghijklmnopqrstuvwxyz"
+ "0123456789";
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;
}
/**
* 检查发送间隔是否>=1分钟
*/
public boolean canSendSmtpVerify() {
return System.currentTimeMillis()
- smtpLastSendTime >= 60000L;
}
public void recordSmtpSendTime() {
smtpLastSendTime = System.currentTimeMillis();
}
}

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