Compare commits

..
333 Commits
Author SHA1 Message Date
ypsdf1 165adcb532 第57批续15: 修复pwd_verified类型不匹配——Java发数字1,PHP=== '1'严格比较永远false
- 根因: Java端JSON body发送"pwd_verified":1(整数) → PHP json_decode解析为整数1
  → === '1'严格字符串比较 → 整数1 !== 字符串'1' → $pwdVerified永远false
  → 续14修复无效(条件改对了但值永远是false)
- 修复: (string)getParam('pwd_verified','0') === '1'
2026-10-26 23:24:32 +08:00
ypsdf1 b6914c52f9 第57批续14: 修复approve分支pwdVerified时expire_at拦截导致卡在approved
- 根因: pwdVerified=true时PHP仍要求now<=expire_at(60秒时效)才completed
  → 批准+验密在approve之后,expire_at早过→状态卡在approved
  → 前端轮询等不到completed→弹窗卡在'正在验证密码'
- 修复: pwdVerified=true直接completed,expire_at只约束pending/notified阶段
2026-10-26 23:14:34 +08:00
ypsdf1 2e04d9cf36 第57批续13: 修复绑邮箱弹窗蒸发+双取消按钮
- 根因1: acctBindEmailPollTick approved分支调用不存在的acctBindEmailPwdStep()
  → JS ReferenceError → drawer已关闭、弹窗蒸发
  修复: approved后继续轮询等completed(Java验密码后写completed),不切界面

- 根因2: acctBindEmailWait的confirm按钮文字设为'取消' → 与drawer原生取消
  按钮构成双取消
  修复: confirm文字改为'等待批准中…'并禁用(仅保留drawer原生取消按钮)
2026-10-26 22:53:14 +08:00
ypsdf1 a33aba1b13 第57批续12: 修复token有效期误判+绑邮箱弹窗邮箱字段缺失
- core.php: validateWebAccess view操作不再要求web_login_verified——
  token 10分钟内有效即直接放行,在线玩家无需二次密码(之前逻辑要求
  Java推送web_login_verified才放行,导致自动注册token延期即失效)
- api/account.php: acctBindEmailPoll SQL补email字段——前端
  acctBindEmailCodeStep(d.email)拿不到邮箱,绑邮箱弹窗蒸发
2026-10-26 21:49:50 +08:00
ypsdf1 d18f983b83 第57批续10: 修复拒绝/批准后弹窗不显示——acctBindEmailFinish各分支补display=flex 2026-10-26 18:57:45 +08:00
ypsdf1 6daedd5dbc 第57批续9: 修复弹窗卡死(poll终态前关drawer)+token偶发无效(web_login_verified fallback) 2026-10-26 18:41:34 +08:00
ypsdf1 ffeffbe7cd 第57批续8-3: 微模糊毛玻璃——overlay轻blur(4px)+极浅背景(0.08),card半透明(0.45)让模糊透出 2026-10-26 18:16:47 +08:00
ypsdf1 e440dea033 第57批续8-2: 毛玻璃切一刀——overlay去blur只压暗,card提高透明度让背景透出(从全雾化改为真毛玻璃) 2026-10-26 17:24:10 +08:00
ypsdf1 79b75a0f76 第57批续8: 第4次修毛玻璃——根因修复backdrop-filter嵌套失效;overlay增强blur+降透明度,card去backdrop-filter+降透明度 2026-10-26 17:00:50 +08:00
ypsdf1 c4f6f2fa1c 第57批续7: 弹窗毛玻璃+抽屉式动画;PHP不再重复验密改为验证码确认;绑定完成直接进验证码步骤 2026-10-26 16:27:29 +08:00
ypsdf1 3c8b573d05 第57批续6: 修复2个bug
1. modal弹窗加毛玻璃效果(backdrop-filter blur)
3. acctBindEmailPwdStep显示modalOverlay(批准后卡死修复)
2026-10-26 14:58:42 +08:00
ypsdf1 5175cb46fb 第57批续5: 修复2个bug
3. acctBindEmailFinish rejected状态简化为单关闭按钮(Bug#3)
4. drawer-card毛玻璃效果修正(Bug#4)
2026-10-26 13:15:12 +08:00
ypsdf1 c8a7cd73a6 第57批续4:绑邮箱向导展示可绑定邮箱类型+drawer卡片毛玻璃效果 2026-10-26 12:00:57 +08:00
ypsdf1 6d93d69226 第57批续3:无充值规则时隐藏在线充值入口
- 头部计算 HAS_RECHARGE_RULES(查 orders.db shop_configs is_active=1 计数)
- 侧边栏 recharge 项按 HAS_RECHARGE_RULES 显示/隐藏
- <script> 注入 hasRechargeRules JS 变量
2026-10-26 10:49:39 +08:00
ypsdf1 203545a719 第57批:新增clear_bans接口(cancel ban确认解封时清空PHP封禁数据) 2026-10-25 21:31:22 +08:00
ypsdf1 716d43b8c2 第56批:checkEmailSuffix移至core.php共享(register/account可直接调用) 2026-10-25 18:57:49 +08:00
ypsdf1 80c4073445 第56批:修复webEmailBindResult非法action+后缀三模式校验(Java权威)+绑定邮箱抽屉动画 2026-10-25 18:40:35 +08:00
ypsdf1 fe882cf73a 第56批:修复webEmailBindResult「非法action」报错 - action只从POST body读,避免URL路由参数污染 2026-10-25 18:17:57 +08:00
ypsdf1 685a1dace2 第55批:login_verified=0时清除web_login_verified旧记录,防止残留导致Web端误放行 2026-10-25 17:37:21 +08:00
ypsdf1 f2859fe6e5 第53批:网页绑邮箱三接口+前端向导——不拦截改游戏内60秒批准,密码复核+IP连错5次封5分钟 2026-10-23 23:01:59 +08:00
ypsdf1 3a4252e861 第52批: 修复\/JSON转义脏值PHP侧(清洗函数+比对容错+存量自愈)
- core.php新增b64Clean(base64白名单清洗)与cleanB64DirtyOnce(存量清洗,自备.b64clean标记挂migrateDatabase锁前)
- 比对点接入b64Clean: core.php临时/主密码验证, account.php第一层比对与改密回读, sync.php改密旧密码验证
- 根因: PHP json_encode默认把base64中的/转义成\/,Java历史解析层漏还原写进库,hash脏值致两端密码恒错; 本侧为第52批四层防护的PHP半边
2026-10-23 19:21:26 +08:00
ypsdf1 4acb6712cd 修复第48批改密弹窗按钮永久禁用:checklist守卫改用_acctPwdSubmitting标志 2026-10-23 14:26:28 +08:00
ypsdf1 a0aeabf22d 第48批(2/2):网页改密强度对齐游戏+实时校验+成功面板留屏;容错解盐修脏盐玩家网页端恒密码错误;bing壁纸代理地址补api前缀
- core.php: 新增 pwdRuleError(6-20位+大写+小写+数字,与游戏 PasswordUtils.validate 同一把尺子)
  与 saltDecode(镜像Java清洗,strict解码前先剔除非base64字符)
- account.php: acctChangePassword 套用 pwdRuleError;acctPwdCheck 改走 saltDecode
- sync.php: changePassword/resetPassword 套用 pwdRuleError;changePassword 老密码校验改走 saltDecode
- player.php: 改密弹窗加实时4项校验清单(oninput更新✓/✗,全绿才可提交);
  成功后不关弹窗,展示明文新密码+复制按钮+同步说明(供截屏保存),保留 notifyJavaSync
- wallpaper.php: bing/NASA 代理地址补 api/ 前缀(原相对路径经前端路由转发后404);
  缓存键 bing->bing_v2(旧6h缓存存着坏URL,版本化立即生效)
2026-10-23 11:48:27 +08:00
ypsdf1 d0cdadd29d 第47批:2FA绑定状态双向同步(pull_state/push_state);图片壁纸下卡片/侧栏半透明毛玻璃自适应;壁纸源改四级fallback(必应主域/中国镜像/NASA APOD/picsum兜底)+转发白名单扩充 2026-10-22 23:24:32 +08:00
ypsdf1 3e2bd64a70 第46批:修复账号安全卡二维码扫不出(换Arase SVG带静区方案,提取qrcode-svg.js复用twofa_qr库);用户端&管理员端背景系统升级三模式(纯色/图片URL/必应API壁纸,遮罩强度可调,新增api/wallpaper.php代理带缓存与SSRF白名单) 2026-10-22 20:59:32 +08:00
ypsdf1 9dcf5dcb93 第45批:修复账号安全卡报缺少web_token参数(apiPost统一注入web_token)+游客模式提示先登录 2026-10-22 19:18:18 +08:00
ypsdf1 71b644182c 第44批:玩家端账号安全双层验证(改密/2FA绑定解绑/恢复代码)+admin用户搜索卡住根因修复+sync改密列名修复 2026-10-22 18:55:26 +08:00
ypsdf1 d4748c4e62 3.11内测版:在线曲线采集上收core全站唯一入口+领地管理分页
- core.php 新增 onlineCurveEnsureSnapshot:心跳判据(>120秒记0防停服假曲线)、60秒节流、保留7天、source列迁移
- sync.php 三挂点(sync_online_players/online_heartbeat/对账兜底)自动留样本,不再依赖有人开页面
- online_curve.php 三本地函数改薄壳+max字段改真实最大值+文案改Java同步自动采集语义
- admin.php 领地列表/权限商店分页渲染:每页10/20/30预制+自定义不封顶+localStorage记忆;曲线文案同步
2026-10-22 16:43:00 +08:00
ypsdf1 f62a446d03 第33批:修安全拦截三函数路由错侧——删sqlite_master探测改直查目标表
playerWebBanReason/playerWebBlacklistReason/isIpBlacklisted 原先用
sqlite_master 存在性探测句抽表名(dbExtractTables只抽到sqlite_master)
→ 被dbPickSide判为非Java表 → 在caoyuanphp查information_schema →
目标表实际在caoyuanjava → prepare失败恒放行 = 拉黑玩家仍可进后台。
对齐sync.php第16批getBanBoard先例:SQL自带表名直接路由正确侧,
prepare失败按放行处理(fail-open不锁死正常玩家)。
2026-10-21 17:08:34 +08:00
ypsdf1 bca9b9af47 第32批:后台识别拉黑信号——sync_blacklist落表、validateWebAccess拉黑拦截、CDK兑换与前端拉黑提示 2026-10-21 15:10:47 +08:00
ypsdf1 bcc22df797 第29批:封禁对账bans配置cols携带created_time(map→created)供Java收回时回填BanEntry创建时间;hashcols不动防指纹恒changed 2026-10-17 13:37:47 +08:00
youpaishidifu 82c688c631 二次更新readme.md 2026-10-16 03:53:25 +00:00
youpaishidifu 831b2b53d7 更新 readme.md 2026-10-16 03:52:35 +00:00
ypsdf1 8337f06cda 恢复误推的readme.md内容 2026-10-15 14:19:06 +08:00
ypsdf1 ac1b0c86fd 第十九批:封禁公示按播报起点过滤(web_player_bans补created_time列并随推送入库、web_ban_board_cfg存ban_cutoff、查询加created_time>=起点) + 登录页人机验证组件300px按容器宽等比缩放对齐防溢出 2026-10-15 14:08:49 +08:00
ypsdf1 76c08ec0f2 同步一份最新的readme.md 2026-10-15 12:03:08 +08:00
ypsdf1 a2171262e6 第十七批:个人中心封禁公示卡片挪到详细信息板块下面 2026-10-14 20:34:53 +08:00
ypsdf1 79280c3990 封禁公示到期时间秒/毫秒归一:Java推毫秒,接口与前端统一按秒 2026-10-14 19:22:32 +08:00
ypsdf1 af6fb3b67e 封禁公示接口加固:路由侧无表时显式换侧兜底,区分0条与表缺失 2026-10-14 19:08:49 +08:00
ypsdf1 b99412cc8a 第十六批缺陷修复:封禁公示改查java侧表+卡片入内容区自适应高度+后台AGPL页脚 2026-10-14 18:58:05 +08:00
ypsdf1 e12a972ad7 第十六批:玩家端封禁公示卡片+AGPL合规页脚与开源地址.txt+封禁synced_at保序 2026-10-14 18:08:43 +08:00
ypsdf1 489b83f29d land_api输出缓冲加固防HTML污染;admin前端apiCall容错HTML响应 2026-10-12 00:23:11 +08:00
ypsdf1 8a669b1e24 用户组新增二手交易上架数量限制与保底价格字段
- land_api.php 六处 web_user_groups 建表加 shop_max_listings/shop_min_price 列
- 三处 migrations 加 ALTER,handleUpdateUserGroup INSERT/bind 带两字段
- sync.php 建表/迁移/INSERT 同步两字段(配合Java推送)
- admin.php 用户组新建/编辑表单与提交、列表详情显示两字段(-1=跟随全局)
2026-10-11 21:24:25 +08:00
ypsdf1 fcc8f08ef7 push_shop_stock 返回回执单:回显 push_id/内容指纹fp/条数,供 Java 核验 PHP 确实收到 2026-10-11 10:37:26 +08:00
ypsdf1 ed55de014b 修复护栏体检方法名错误:SdfMySqlResult只有fetchArray没有fetch_assoc,MySQL模式下体检必抛异常被fail-open吞掉导致护栏静默失效 2026-10-10 22:35:26 +08:00
ypsdf1 b0e572d7ac 库存双向同步(分叉以Java为主):新增push_shop_stock接收插件本地库存改动,web购买补写last_modified支撑竞态护栏;数据安全护栏(盘上状态文件三态判定:清空自愈补推/指错库拒绝握手/登录页告警横幅)与MySQL表结构快照入库 2026-10-10 21:52:59 +08:00
ypsdf1 a758bde564 修复 dbSplit 切分三连环缺陷:索引名禁库前缀、并发文件锁、孤儿库自动重切
- 索引正则改为只给索引名加 jd. 前缀(SQLite 的 CREATE INDEX 不接受 ON jd.tbl,
  旧写法 12 个索引全部 near ".": syntax error)
- 用 flock(LOCK_EX|LOCK_NB) 替代 static $done/$running,多 FPM worker 不再互删 Java.db.new
- 完成标记 Java.db.splitdone:只有表数校验通过 + rename 成功才写;
  存在但无标记的孤儿库自动备份为 .orphan.<ts> 后重切
- 切分不完整(有 err 或表数不足)时作废 .new,绝不产出缺表的 Java.db
- 新增 SDF_SPLIT_FALLBACK_WEB:切分未完成期间 java 侧共用 web.db,
  避免 dbOpen 提前建出空 Java.db 导致存量数据不可见
- MySQL 模式直接跳过 SQLite 切分(否则 11MB web.db 白切 18 秒)
2026-10-10 14:50:06 +08:00
ypsdf1 4bdefdab8c 修复 MySQL 方言层三处缺陷(test12 #1064/#1170/#1071)
1. sqlMySqlQuoteKeyCol 正则漏排 duplicate:section 4 已把 ON CONFLICT(key)
   DO UPDATE 翻成 ON DUPLICATE KEY UPDATE,这里又把其中 KEY 反引号 →
   ON DUPLICATE `key` UPDATE → #1064(线上 6 条,sync_config 写 web_area_config 必炸)

2. sqlMySqlRenderValue 的 FLOAT 分支 rtrim 吃掉有效数位:%.17G 对整数值
   不产生小数点,0.0→"0"、10.0→"10"、100.0→"100",rtrim('0') 会吃成
   ""/1/1 → 空槽位 #1064(线上 15 条,syncLands 的 warp 坐标行),
   且 10.0→1 属静默篡改坐标,比报错更危险。改为仅在有小数点且非指数时去尾零。

3. sqlMySqlColumnPart 用 ^KEY\b 一票判定表级约束:web_area_config 的
   `key TEXT PRIMARY KEY` 以 key 开头被误判 → 整列跳过 TEXT→VARCHAR 转换
   → #1170(线上 137 条,建表失败连带 #1146)。改为 KEY/INDEX 须后接 (列清单) 才算表级。

4. applyIndexPrefix 只给 TEXT/BLOB 加索引前缀,但 SQLite 的 TEXT DEFAULT ''
   在 MySQL 侧翻成 VARCHAR(500),utf8mb4 下 web_item_sn(owner,item_type)
   两列 4000 字节 > InnoDB 3072 上限 → #1071(线上 9459 条,联合索引始终
   建不出来,查询退化全表扫描)。改为按 information_schema 的字符集×实际长度
   估算索引字节预算:装得下保留全列索引,装不下或撞 TEXT 族才均分加前缀。

验证:_verify_1064_fix.php 27/27、_verify_init_tables.php 本地全量 initTables
零错误、_test_mysql.php 63/63;已部署 test12。
2026-10-10 12:45:37 +08:00
ypsdf1 4be4f1f107 修复第四关「未知操作」:稍后再配按钮改原生提交,form.submit() 不带 act 导致落 default 2026-10-10 11:05:28 +08:00
ypsdf1 2cb48de453 安装锁改为文件+数据库双份:库内 _install_lock 四态门禁
- 文件锁在+库在 → 404;文件锁在+库明确丢失 → 数据丢失警告页(可确认重装)
- 文件锁没了+库内锁在 → 自动补写 install.lock 再 404;两者皆无 → 全新首跑
- 探测失败(MySQL 连不上/配置读不出)一律按「库在」从严
- n4 落盘时同步写库内 _install_lock;老站迁移幂等补写
- 扫库/清库 SQL 排除 _install_lock 标记表,避免误判非空
2026-10-10 00:16:18 +08:00
ypsdf1 6f13740cce 修复 MySQL 扫库 Unknown column:information_schema 库名补单引号
real_escape_string 只转义不加引号,裸拼 WHERE TABLE_SCHEMA = 库名会被 MySQL 当列名。
2026-10-09 23:16:14 +08:00
ypsdf1 1e60d31ffd 重构安装页为四步向导:扩展检测/数据库刹车/SMTP强校验/安全配置
- 第一关扩展检测:sqlite3/mysqli 缺失时第二关锁死对应模式(前端 disabled + instValidate 兜底)
- 第二关数据库:mysql 地址端口预填 127.0.0.1/3306;扫库非空立即刹车,列清单后手输「确认清空」二次确认,取消则退回重填
- 第三关 SMTP:四项要么全空要么全不空,全不空必须发送验证码且比对通过才放行
- 第四关安全配置:IP 白名单必填(可自动填充当前 IP),验证码留空即关闭,2FA 跳转 admin_2fa_setup.php
- 统一写盘:最后一关才落 config.php/sql.md/captcha_keys.php,写完生成 install.lock 并自删
2026-10-09 21:36:53 +08:00
ypsdf1 0b63a99719 install页去掉IP白名单门禁并修null告警, 新增0.0.0.0应急逃生通道
- install.php: sqlMdDbConfig 全新站 java/web 槽为 null, $val 闭包加 is_array 兜底,
  消除每个输入框的 array offset on null 告警; 门禁只留令牌层(全新站无白名单可言);
  安装成功页改为回显本次写入的完整配置供截图手抄, 背景自删
- admin_2fa_setup.php: 引导页白名单框上方显示当前访问IP; 应急模式免密进 boot 模式
  并挂红徽标; secInstallPending 让位改为 !secIsEmergency(), 避免与全局应急路由
  形成 install/引导页互跳死循环
- security.php: 新增 secIsEmergency() 判定白名单含 0.0.0.0; secIpAllowed 放行该条目;
  新增 secEmergencyGate() 在限流层前把非引导页/非本机请求 302 锁回引导页(用302防浏览器缓存)
- config.php: 补 0.0.0.0 应急用法与用完必删的说明注释
2026-10-09 19:56:34 +08:00
ypsdf1 fc8c82b188 修复全新部署首跑顺序:装库先于2FA引导;install表单补令牌透传
- security.php 新增 secInstallPending():install.php 在场且无 install.lock = 装库未完成
- 引导模式下所有入口先 302 到 install.php,装完自删后才轮到 admin_2fa_setup.php
- admin_login/admin_2fa_setup 兜住本机直连与已引导未装库两种入口(secTokenUrl 带令牌)
- install.php 表单隐藏 token 字段,已引导站点 POST 也能过第三层门禁
2026-10-09 18:56:46 +08:00
ypsdf1 e1010927e5 PHP MySQL 适配层与双库切分收尾, 新增自毁式 install.php 配置安装页
- core.php: sqlDialectMySql 方言翻译 + SdfMySqlDb/Stmt/Result 复刻 SQLite3 API, SdfDbRouter 路由与侧别过滤器, 软依赖兜底; 修复 dbExtractTables 把 CREATE INDEX 索引名当表名导致 web 库 0 张表的真 bug
- security.php: sqlMdDbConfig/sqlDbDir/sqlDbFile 双库配置函数, web.db 给 PHP / Java.db 给 Java
- api/minecraft_auth.php, api/poller_online.php: Java 专属表改走 sqlDbFile('java')
- install.php: 自毁式数据库配置页, 只依赖 security.php, 哨兵块替换 + 备份原子写回读校验 + install.lock + unlink 自删, 门禁 404
- .gitignore: 屏蔽 .workbuddy/ 工作目录
- 测试: db 回归 26/26, MySQL 63/63, schema 对拍 11/11, install 端到端 32/32 全绿
2026-10-09 18:16:56 +08:00
ypsdf1 8a6755e725 新功能的预热 2026-10-09 17:11:20 +08:00
ypsdf1 ebcdeb6f09 添加 AGPL-3.0 开源协议:LICENSE 为 gnu.org 官方原文,readme 补充协议说明与网络服务开源义务 2026-10-09 11:30:55 +08:00
ypsdf1 d76ad12119 正版验证密钥契约统一:minecraft_auth.php 改用 config.php 的 SECRET_KEY,与 sync.php 一致(修 test12 占位符与 MC_AUTH_SECRET 分叉导致的403密钥错误) 2026-10-08 23:29:07 +08:00
ypsdf1 1188da01a9 修补单慢根因: queryOrder两条检测路径全失效——getPlatformDB未先加载pay_secrets致平台库Access denied; getPayKeys()未定义致query_order全500。补加载secrets+改用loadPayKeys+API查询包try/catch 2026-10-08 11:04:53 +08:00
ypsdf1 c1009088ce 补单器修正误用的getResult: execute()直接返回SQLite3Result(7.4/8.2/8.5实测), 接住返回值直接fetchArray 2026-10-08 10:04:37 +08:00
ypsdf1 7dc7d01cd0 补单/查询移除前缀归属门槛:本地已有完整订单号即认本端,平台按完整订单号精确匹配 2026-10-08 01:15:07 +08:00
ypsdf1 7932bd176c 修复补单器SQLite3Stmt::fetchArray致命错误: prepare().execute()在PHP7返回Stmt需getResult()再fetchArray, 修复后平台已付订单正常补单 2026-10-08 00:57:04 +08:00
ypsdf1 ba5352121e 订单号固定格式改递增序列(00000000→CYZJ00000001起);补单器改定向补单:查本地活跃完整订单号精确匹配平台,不再按前缀批量扫 2026-10-07 23:54:42 +08:00
ypsdf1 629c16ec84 修复payComposeOrderNo三段式实现:玩家名段由payGuardLen统一拼装裁剪,清除占位残留变量 2026-10-07 21:22:40 +08:00
ypsdf1 aadb3373e8 充值订单号改读pay.md配置区:固定格式(纯数字0随机/非纯数字6位递增保底)与组合规则双分支,前缀pay.md优先+历史前缀双兼容保补单,删CALLBACK_PORT/GAME_SERVER_HOST死配置 2026-10-07 21:03:35 +08:00
ypsdf1 eb01cb9ea8 新功能的预热 2026-10-07 20:46:35 +08:00
ypsdf1 e20d648b08 领地对账接入:alignmentCatConfig新增lands配置,与Java端72列指纹逐字对齐,多退少补 2026-10-07 17:37:50 +08:00
ypsdf1 e76b38cd7d 对账退侧修复:bans已过期封禁不再计入extra,消除每轮收回1条永不收敛 2026-10-07 16:21:40 +08:00
ypsdf1 59cca9e04a 修复syncPlayerIps变更比对bug:old_ip误比导致PHP端IP永不更新、对账changed恒大于0 2026-10-07 11:46:33 +08:00
ypsdf1 dbb4843db6 对账退侧:清理 player_ip_changes 中两边 users 都不存在的孤儿IP行
- 新增 prune_orphans 开关(仅 ips 类别开启):check_alignment 算出 extra 后,
  把「Java 没有 且 PHP users 表也没有」的行直接删除,让两边对账真正收敛到 0。
- 三重保险:只清 extra、只清 PHP users 里也不存在的玩家、Java 上报 0 条时绝不动表。
- 背景:线上实测 203 条历史残留(玩家已删、IP 行还在),Java 侧无处可收,
  导致每轮对账空转「收回203 / 实际写入本地0条」,两边永远差 203 条。
2026-10-07 11:17:09 +08:00
ypsdf1 6a6cb9ec3c 订单前缀配置化+新订单号格式,补单脚本同读前缀;新增online_heartbeat心跳与sync_webtx_receipts回执补推端点 2026-10-06 18:18:22 +08:00
ypsdf1 bd47d25165 修复领地同步500:老库web_area_lands缺y_min/y_max/synced_at三列,ALTER迁移补列 2026-10-06 00:04:40 +08:00
ypsdf1 a5871f6761 对账配套:credentials字段映射与online类别、admins去整表删除、canon null整数列归一 2026-10-05 22:29:51 +08:00
ypsdf1 6cc8dbf582 交易对账改双向:check_tx_alignment 返回 extra_ids(PHP有Java无的),新增 pull_tx 交还完整流水;关键接口 set_time_limit 13秒内必答复、busy_timeout 收紧到6秒 2026-10-05 17:46:34 +08:00
ypsdf1 d2f42bef62 补单/轮询日志收敛:只有报错或真正补到单/有数据才写debug.log,无合适数据静默返回
- poller_online.php:去掉每次轮询必写的「补单开始/正在连接MySQL/MySQL DSN/连接成功」4 行;「补单完成」改为 processed>0 才写;连接失败、补单处理、交易写入等成功/报错日志保留
- sync.php:checkWebLoginConfirmations、checkPendingWebLogins 的 count=0 轮询日志改为有数据才写(每 4 秒一次是膨胀主因);check_web_login_verified 去掉「插件查询/未找到/无验证记录」三条无数据日志;三处 catch 补 debugLog 让报错进 debug.log
2026-10-05 14:40:31 +08:00
ypsdf1 1bd51cbcdf log_maintain 加 4.5MB 水位线:避免清理后贴着 5MB 上限导致每次写入都全量重建 2026-10-05 13:41:17 +08:00
ypsdf1 ce489d1450 debug.log 保留策略:只留最近3天+≤5MB,任一超标即清理到两条件同时满足 2026-10-05 13:35:39 +08:00
ypsdf1 e040e8c994 boot恢复模式不再404死胡同:持boot凭据的请求一律302回引导配置页
- security.php 新增 secBootRecoveryRedirect():secGatePage 遇到「不在 IP 白名单但持有效 boot 凭据」的请求,改 302 送回 admin_2fa_setup.php(唯一豁免页),不再返回 nginx 404;secBootAuthenticate 增加凭据预判(无 boot 参数、无会话 cookie、无活动会话则直接判失败),避免路由覆盖面变大后给每个探测请求都 session_start() 刷 session 文件。
- admin.php 令牌层改 secTokenGate(true):admin.php?boot=<引导令牌> 才能进入恢复路由(原先在令牌层就 404);页面对非白名单 IP 依旧不渲染。
- admin_2fa.php 持 boot 但不在白名单 → 直接送回配置页,不再掉进 admin_login.php / admin.php?token= 的 404 弹簧。
- admin_2fa_setup.php:boot 模式隐藏「← 返回」、顶部加恢复指引横幅、后台唯一入口下加 404 警告;新增  修正「刚保存白名单但 ADMIN_IP_WHITELIST 常量本请求内未刷新」导致横幅误报。
- 背景(db/security.log 实录):2026-10-05 09:35:58 boot_unlock 成功,09:35:59 即被 admin.php ip_block,09:42 只能靠宝塔手工改 config.php 脱困。
2026-10-05 10:56:35 +08:00
ypsdf1 99da971fbf 新增 check_tx_alignment 交易对账接口:返回PHP流水笔数/最大序列号,及指定序列号中缺失的部分,供Java只补中断批次 2026-10-04 23:52:35 +08:00
ypsdf1 f7aedc6fae Web 端同步 bad_effects 负面效果字段
- land_api.php / sync.php:建表迁移、INSERT、bindValue 补 bad_effects
- convertEffectsForFrontend 增加负面效果的管道格式 → JSON 数组转换
- 字段白名单加 bad_effects,上限校验改为增益+负面合计口径
- player.php:负面效果独立 UI 区块,addGiveEffect 抽成通用 landEffectAdd,
  新增 addBadEffect(效果档位与 Java 端同口径)
2026-10-04 22:57:37 +08:00
youpaishidifu 398a9db380 新增robots.json模板和关联文件(已脱敏) 2026-10-04 22:06:42 +08:00
ypsdf1 c880f1eb9e 修正药效生存上限表:村庄英雄5级、挖掘疲劳3级、伤害吸收4级、不祥类5级 2026-10-04 20:32:44 +08:00
ypsdf1 386c9d861e 药效等级上限改按各效果的生存可获取上限,同步 Java 端 b64e7e3
用户纠正口径:原版最大上限 = 生存模式正常探索/合成/建造能拿到的最大值,
不是 /effect 的作弊上限(amplifier 255 = 强度 256)。
  · 抗性提升 信标最高 2 级
  · 海豚的恩惠 只有潮涌能量块一个来源 → 最高 1 级

改动:
- api/land_api.php 新增 getSurvivalMaxLevel():39 个效果逐项查表
  (可升 II 的 11 个 + 单一来源恒 1 的 + 生存无来源的 + 未知兜底 1),
  与 Java 端 AreaProtection.getSurvivalMaxLevel() 134 个 case 标签
  逐项对账一致
- 新增 getEffectLevelCap():min(生存上限, 用户组 max_effect_level)
  两层同时生效;用户组配 256 也突破不了生存硬顶
- handleUpdateLandField 写入 give_effects 时改为逐个效果封顶,
  此前是统一按 256 放行,等于没封
- getLandMaxEffectLevel 注释澄清它只是「用户组软顶」,真正封顶看 getEffectLevelCap
- admin.php 两处说明文案改为生存可获取上限口径,举例抗性提升 2 级、
  海豚的恩惠 1 级
2026-10-04 19:57:23 +08:00
ypsdf1 4160c60aa6 新增用户组药效等级上限配置,与Java端双向打通
配合 Java 端 6f92028(原版药效强度上限 256)。原版 /effect 的
amplifier 硬上限是 255,即药效强度 1~256,此前 Web 侧封顶写死
255 且各建表点口径不一,本次统一为 max_effect_level 配置项。

改动:
- web_user_groups 增加 max_effect_level 列(默认 256)
  · sync.php / land_api.php / core.php 全部 8 处建表块补列
  · 3 处迁移表补 ALTER 条目,老库自动升级
- api/sync.php syncUserGroups:Java 推送时接收并写入 max_effect_level,
  <=0 或超过 256 一律归一到 256(原版硬上限不可突破)
- api/land_api.php 新增 getLandMaxEffectLevel():按领地所有者所属
  最高优先级用户组取上限,无组则 256;handleUpdateLandField 写入
  give_effects 时改用该上限 clamp(原先硬编码 255)
- api/land_api.php update_user_group 读写补该字段
- admin.php 用户组新建/编辑弹窗补「药效等级上限」输入框
  (1~256,默认 256),并附原版范围说明;两处提交参数同步带上

规则:普通玩家(未加入任何用户组)固定按 256 封顶,严禁超限;
加入用户组后按组配置封顶,填 256 即不额外限制。
2026-10-04 19:26:05 +08:00
ypsdf1 419b677aa9 修复工单上传图片误判+用户组max_effects配置同步 2026-10-04 14:48:43 +08:00
ypsdf1 fe1ed613ba 工单管理支持上传图片(兰空图床转存)
- 新增 lsky.php:兰空图床 API 客户端,token 缓存+提前60秒续签+401自动重签重试
- 新增 lsky_keys.php:图床凭据模板(仓库仅占位符,真值只在服务器,永不上传)
- api/ticket.php:新增 upload_image 动作,限 png/jpg、单张≤2048K、内容与后缀必须一致、同会话每分钟限12次;web_tickets 增 images 列(JSON 数组,上限3张,URL 仅限自家图床)
- core.php:权威建表补 images 列 + 老库幂等 ALTER 迁移
- player/admin 两端:上传即暂存、光标处插入 [图片N] 滚动占位符、上限3张;提交时随工单入库
- player/admin/service 三端详情:占位符还原为 markdown 图片,未引用图片补在末尾
2026-10-04 10:16:37 +08:00
ypsdf1 c6b05d72c8 安全:pay_poller.php 摘出仓库转为 release 模板,真实 MySQL 账密不再入库
- api/pay_poller.php 从 git 索引移除并加入 .gitignore(同 pay_secrets.php 处理)
- 本地文件改为 REPLACE_ME_* 占位符模板,硬编码的 kH3C3LLinNwYdTF5 /
  sRhsdxrpHBhmSsp8 / PAY_MD5_KEY 全部摘除(原文件备份为 .orig_backup)
- deploy_php_only.py 补 pay_secrets.php / pay_poller.php 排除项,防止批量部署把
  线上真值覆盖回旧密码
- 修复 should_exclude 通配符不生效:deploy_*.py 等 glob 原先字面比较永远不命中,
  改为 fnmatch,避免部署脚本(含 FTP 明文账密)被传到公网站点根
2026-10-02 23:19:25 +08:00
ypsdf1 7f513b9fa1 充值补单只认本方RE订单:平台多商户库不再整表拉取,杜绝垃圾充值单刷屏 2026-10-02 21:43:23 +08:00
ypsdf1 9ee8023d90 领地权限与面积限制接口:补生物蛋/涂蜡刮蜡两权限字段,新增退费比例与面积限制配置项 2026-10-02 19:31:20 +08:00
ypsdf1 5912c6fd76 引导初始化:管理密码首次由部署者自设,解开占位符密码无法保存配置的死结 2026-10-02 17:10:39 +08:00
ypsdf1 e18d8df22f 限流第四层新增凭据放行:引导模式/入口令牌/boot/后台会话不再被挂起白屏 2026-10-02 15:40:49 +08:00
ypsdf1 196e20868e 安全闸门最小化信息透露:非白名单请求统一返 nginx 原生 404,新增 DB 引导标记
- secGatePage / secGateApi 不再回 403(页面 HTML 与 JSON),一律 secNginx404(),
  探测者分不清是文件不存在还是被拦截;拒绝仍写 db/security.log。
- 新增 secIsBootstrapped() 双判据:config SEC-UPDATE 区已有令牌(既有站点即刻生效)
  或 web.db 的 sec_bootstrap 标记表有行(secWriteConfig 保存时写入)。
- 全新部署首次访问(两者皆空)为引导模式:放行并把 admin.php 跳到 admin_2fa_setup.php,
  配齐白名单/访问令牌/2FA 后一保存即自动转强制;读库异常从严按已引导。
- secTokenGate 引导模式不强制,已引导后照旧 ?token= 否则 404。
- setup 页状态卡片新增「门禁状态」展示;config.php 注释同步 404 与引导标记说明。
- 不调用 SQLite3::enableExceptions(false):PHP 8.4 该写法触发 E_DEPRECATED,
  提前输出会把 nginx404 的状态行挤掉(404 变 200),改由 @ + try/catch 兼容新旧版本。
2026-10-02 13:42:02 +08:00
youpaishidifu 2f5f02b72d 更新 captcha_keys.php 2026-10-01 15:12:35 +00:00
youpaishidifu 23c40df6b1 删除仓库内不需要的db文件夹(服务器需要仓库不需要) 2026-10-01 23:10:01 +08:00
ypsdf1 c0e95b6c73 正版验证页:人机验证挂件与卡片边框对齐(同边框色/圆角、内容居中) 2026-10-01 17:41:36 +08:00
ypsdf1 293d7b283c 修复:主脚本注释含字面量script闭合标签导致页面截断 2026-10-01 16:08:39 +08:00
ypsdf1 8fa7eb5c95 用户端 player.php 接入人机验证挂件:三个登录入口统一挂载并提交前取 token 2026-10-01 15:39:10 +08:00
ypsdf1 1a221330dc config.php 验证码段改为单开关二选一:CF/VA 取值与行内说明写清,全站厂商统一由 CAPTCHA_PROVIDER 决定,密钥仍留 captcha_keys.php 2026-10-01 15:11:31 +08:00
ypsdf1 b138171a7c 修复人机验证挂件:CF 挂件自行加载 Turnstile SDK,解决后台「验证组件加载失败」 2026-10-01 14:25:51 +08:00
ypsdf1 ea5d476825 补上admin_login与api/admin遗漏的captcha_guard require(此前调用cgWidgetHtml/cgEnforce但未引入库,线上报undefined function) 2026-10-01 14:03:58 +08:00
ypsdf1 17d53c40a0 三大场景接入人机验证:新增captcha_guard.php统一校验库与CAPTCHA_PROVIDER配置,管理员登录/用户后台账密登录/正版验证浏览器提交三处接入;captcha_keys.php清空为可入库的空模板并加填写说明,部署脚本排除密钥文件防覆盖线上真值 2026-10-01 13:38:29 +08:00
ypsdf1 4f498a80c6 fix: 验证码测试页切VAPTCHA V4(V3域名全部NXDOMAIN):SDK换c4.vaptcha.com/src/v4.js、服务端改v41官方verify+本地HMAC双方式、修PHP8.5 curl_close弃用污染JSON;.gitignore排除captcha_keys.php 2026-10-01 11:25:01 +08:00
ypsdf1 5400856785 fix: 2FA取数参数名定为t(WAF复测矩阵:token+≥32位hex被拦404,t=全长度可过,POST body可过) 2026-10-01 10:57:32 +08:00
ypsdf1 2e4ddcbaea fix: 2FA二维码token回退裸hex64,查询参数名保留token(WAF矩阵验证v1_前缀与t=长hex被拦) 2026-10-01 10:46:34 +08:00
ypsdf1 1eb898adea 2FA二维码token改v1_前缀+查询参数名t换token:规避WAF对32位以上纯hex查询串的404拦截 2026-10-01 10:37:59 +08:00
ypsdf1 b86d29b34a 新增验证码接口测试页captcha_test.php:上下布局同时接入CF Turnstile与VAPTCHA(v3),密钥四级回退(captcha_keys/config/URL/robots.json),内置siteverify与vaptcha服务端校验代理,暂不接入登录流程 2026-10-01 10:24:04 +08:00
ypsdf1 b5b252d1b7 新增玩家2FA二维码展示端:api/twofa.php(secret推送10分钟载荷+token取回)、twofa_qr.php(内联二维码库纯展示页)——2FA校验全在Java,PHP只做显示器 2026-10-01 10:17:29 +08:00
ypsdf1 eb741beeb4 更新.gitignore以屏蔽密钥文件 2026-10-01 10:06:19 +08:00
ypsdf1 186e670400 SN管理:修正顶层签发入口里被转义成字面量的 emoji 2026-09-30 17:41:53 +08:00
ypsdf1 43fd8e2891 SN管理:顶层办理签发入口(玩家ID+设备类型直发),详情页旧签发按钮移除 2026-09-30 17:32:08 +08:00
ypsdf1 b6983f99e4 新增第四层安全防护:全局限流20次/分钟/IP,白名单与带secret的内部调用豁免,超限挂起连接不返回数据 2026-09-30 15:56:16 +08:00
ypsdf1 4c2e075667 配置页防回车误触修复:回车不再命中首个提交按钮「重新生成令牌」 2026-09-30 13:30:48 +08:00
ypsdf1 27da2b5c05 SN日志动作列中文化:admin.php与player.php新增SN_ACTION_CN映射,覆盖issue_admin/cancel_force等全部动作值 2026-09-30 12:31:17 +08:00
ypsdf1 a97f9274cc 管理端 issue 命令 reason 文案修正:办理签发单独文案,不再误标为管理员强制执行 2026-09-30 12:20:11 +08:00
ypsdf1 f033e71095 SN管理端新增 issue 办理签发命令:两条件硬校验并分别提示,isForce 固定为 0;管理端详情页加入办理签发按钮与确认文案 2026-09-30 12:11:49 +08:00
ypsdf1 51e418ac16 需求4护栏:重置访问密钥不再关闭二次验证——security.php新增2FA快照/语义指纹/回滚/写后比对四函数,rotate_token与save_whitelist走最小写入+护栏,误关即回滚报错 2026-09-30 11:19:00 +08:00
ypsdf1 b7b2647ef1 需求3脱敏:config.php改为占位符分发(敏感信息不入库),清除admin.php公网前端13处与land_api.php的硬编码SECRET_KEY,改为服务端注入/常量比对 2026-09-30 11:18:41 +08:00
ypsdf1 a46737d64b SN注销冷静期分流:管理员强制注销跳过1小时冷静期,代办注销保持三条件门槛+1小时冷静 2026-09-29 23:57:27 +08:00
ypsdf1 c46c9ebdfe 紧急加固三层:admin.php 入口令牌门禁 + 2FA 扫码绑定与精确诊断
第三层(新增,config.php 的 SEC_ACCESS_TOKEN):
- 只有 admin.php?token=<令牌> 才放行,缺/错令牌一律返回 nginx 原生 404
  (与"文件真的不存在"逐字节一致,探测者无从判断),令牌检查置于白名单之前
- 登录接口在密码校验通过后才下发 token,2FA 通过与收银台跳转由服务端补拼
- 入口页加 Referrer-Policy: no-referrer,防令牌经外链 Referer 泄露
- 配置页展示后台唯一入口地址,支持一键重新生成令牌

第一层修复(2FA 验证码"无效"):
- secTotpVerify 容错窗口 ±30 秒扩到 ±90 秒
- 新增 secTotpDiagnose:失败时区分"密钥没对上"与"设备时钟偏移多少秒"并给出处置指引
- Base32 解码兼容空格/连字符与 0/1/8/9 手误,避免密钥被静默截短
- 验证页倒计时与服务器时间显示改用服务端时钟基准
- 密钥生成改幂等,"重新生成"二次确认,防止已扫码密钥被误作废

第二层(二维码):
- admin_2fa_setup.php 内联 qrcode-generator(MIT) 生成 otpauth 二维码,离线可用
- 后台侧边栏新增"安全配置"入口;admin_2fa_setup.php?boot= 在白名单 IP 下同样生效

已通过线上端到端验证 22 项 + 二维码 OpenCV 往返解码校验。
2026-09-29 22:55:46 +08:00
ypsdf1 fd03189f31 SN三条件门槛:报失/补办/注销统一前置校验与最小化提示(不透露未达标项),web_item_sn 新增 detached_at/own_chest,管理端详情展示逐条判定并拆分强制注销/代办注销 2026-09-29 20:22:20 +08:00
ypsdf1 52e07ec9de fix: SN回执幂等防止重复补发,后台命令下发后轮询回执直到出结果 2026-09-29 18:03:32 +08:00
ypsdf1 345e1e9a9b fix: SN目录快照不再把web_item_sn.cancel_time清零,保留PHP端已写入的注销时间 2026-09-29 16:48:51 +08:00
ypsdf1 045d9f60b8 SN接口:管理员命令带 is_force 强制标记(Java 侧仅本次绕过冷静期),注销前增加位置前置检查拒绝箱内/他人手上物品,命令回执按执行结果更新状态 2026-09-29 13:38:35 +08:00
ypsdf1 18bd1e9b19 feat: SN防伪后端 api/sn.php、8张SN数据表与玩家端/管理端SN页面 2026-09-29 11:16:32 +08:00
ypsdf1 4db131082c fix: 永久封禁玩家禁止登录Web后台、正版验证粘贴页响应异常时回查真实状态
- core.php 新增 playerWebBanReason():查 web_player_bans 名字封禁(永久/未过期),expire_time 兼容Java推送的毫秒单位;validateWebAccess 接入,商城/CDK/余额/token校验一律拒绝
- api/sync.php 四个登录入口全部拦截:validate_weblogin_token、web_login_request、send_email_code、verify_email_code
- api/minecraft_auth.php:success/error 清空输出缓冲保证纯JSON;新增 action=status 会话真实状态查询;粘贴页提交响应异常时回查状态,已验证则如实提示通过,不再误报"服务器响应异常"
2026-09-28 00:03:07 +08:00
ypsdf1 eaadc0bd64 chore: gitignore忽略本地开发文档《代码关联关系.md》 2026-09-27 21:44:51 +08:00
ypsdf1 f2b87325e1 feat: 封禁IP同步Web黑名单(须持游戏内/web token)、清空队列联动清理pending登录请求 2026-09-27 21:23:31 +08:00
ypsdf1 6a74dfee91 修复单件附魔书结算时 web_transactions 唯一约束冲突 2026-07-21 16:43:18 +08:00
ypsdf1 d92f1f47a9 安全加固: .gitignore添加deploy_*.py排除规则,移除deploy_php_only.py、sync.php.bak、debug_online.php的git跟踪 2026-07-20 13:56:59 +08:00
ypsdf1 c3d9c0925d 修复4个问题: 用户组购买确认弹窗、在线曲线清理、高频出售缓冲、每小时活跃数据 2026-07-20 13:18:58 +08:00
ypsdf1 312f40c94a 修复活动过期后仍走活动价格的bug:前端必须校验discount_active字段 2026-07-17 23:32:05 +08:00
ypsdf1 0098efda3f fix: 限时折扣过期时间改为当天0:00即过期 2026-07-17 17:17:59 +08:00
ypsdf1 1d5f500a28 fix: 限时折扣过期逻辑修复 + Minecraft OAuth功能改进 2026-07-17 16:51:38 +08:00
ypsdf1 ff83406013 minecraft_auth.php: 改用公共客户端ID实现OAuth,新增verify_code和paste_code_page端点 2026-07-17 14:02:38 +08:00
ypsdf1 d539cc10bf 改用Microsoft Graph User.Read scope(不依赖Xbox Live API权限) 2026-07-17 11:41:00 +08:00
ypsdf1 e30c37ef64 修改OAuth scope为XboxLive.signin 2026-07-17 11:36:20 +08:00
ypsdf1 51aaedc2b3 修复OAuth error回调路由:invalid_scope等错误也能正确显示 2026-07-17 11:34:48 +08:00
ypsdf1 b7c83903ca 修复Microsoft OAuth回调路由:自动检测code+state参数路由到callback 2026-07-17 11:15:08 +08:00
ypsdf1 65fc468ce1 新增Microsoft OAuth正版验证API
1. 新增minecraft_auth.php
   - 完整的Microsoft OAuth流程
   - 支持create_session/check_session/callback/verify_premium
   - 验证状态持久化到SQLite

2. 配置项在pay_secrets.php中(gitignored,手工FTP上传)
2026-07-17 10:23:07 +08:00
ypsdf1 ace705682c 新增支付邮件通知+玩家订单查询功能
1. 支付成功后自动发送付款凭据邮件(仅绑定邮箱的玩家)
   - handleNotify回调 + queryOrder补单两条路径均触发
   - 邮件包含订单号、商品、金额、到账债券、支付时间
   - 异步静默发送,不影响主流程

2. 玩家端新增「我的订单」页面
   - 后端新增my_orders action查询pay_orders表
   - 前端展示订单列表(状态标签、金额、债券、时间)
   - 支持刷新按钮
2026-07-16 23:31:34 +08:00
ypsdf1 b90eeb2bcd 更新MySQL连接信息:平台重建后数据库凭据变更 2026-07-16 22:22:27 +08:00
ypsdf1 7e3af6d7d7 添加折扣价格硬锁规则:优惠后价格必须小于原价,且不低于0.01元;售价低于0.01元的商品不支持折扣 2026-07-16 19:02:36 +08:00
ypsdf1 f3919a8715 实现折扣价格功能:数据库表、后端API、前端界面完整支持折扣价格字段 2026-07-16 18:33:21 +08:00
ypsdf1 00da16abb6 订正售罄≠下架:售罄商品仍展示仅禁止购买,仅下架(is_active=0)才隐藏;前端售罄按钮置灰 2026-07-16 17:38:01 +08:00
ypsdf1 5fcecdaefd 彻底修复库存0变-1:库存框改text+严格校验(空=无限,0=售罄,非法报错不再静默转-1) 2026-07-16 17:18:49 +08:00
ypsdf1 e2808276c0 修复库存=0误转为-1的bug + 盲盒显示??及随机消费语录 2026-07-16 16:48:31 +08:00
ypsdf1 a8303ba772 修复pay.php充值商品500错误:内联parseExpireDate函数(原函数仅在recharge_orders_api.php中定义) 2026-07-16 16:18:26 +08:00
ypsdf1 de589d2683 实现前端动态加载充值商品功能:从后端shop_configs表读取商品,支持选择商品支付 2026-07-16 15:33:27 +08:00
ypsdf1 433735d595 新增gitignore:忽略调试文件diag_db_content.php 2026-07-16 15:08:19 +08:00
ypsdf1 304bd8f9ef 修复充值商店配置500错误:getParam参数不匹配+前端防重复提交 2026-07-16 15:01:33 +08:00
ypsdf1 6da7f58281 新增充值商店配置管理功能:库存、临时优惠、售价、债券范围、上下架 2026-07-16 14:39:44 +08:00
ypsdf1 95630c7609 修复数据库分离: pay_orders→orders.db + web_transactions→web.db,双库不再混用 2026-07-16 14:03:50 +08:00
ypsdf1 61bc4672a9 修复支付锁定: 支付成功后恢复按钮允许发起新订单 2026-07-16 13:30:57 +08:00
ypsdf1 4afc2470b0 修复对账页面空白: 前端d.orders改为d.data.orders(success()包装格式不匹配) 2026-07-16 13:14:49 +08:00
ypsdf1 07689f235b 修复对账页面空白: pay_orders迁移到orders.db独立库+自动迁移逻辑 2026-07-16 12:36:27 +08:00
ypsdf1 e0bb61f23a 加固MySQL凭据: 默认值硬编码+全局变量显式设置 2026-07-16 12:22:10 +08:00
ypsdf1 ea20701db5 修复表名前缀: 添加global声明确保$PLATFORM_DB_PREFIX在函数内可用 2026-07-16 12:18:56 +08:00
ypsdf1 19d0cf1618 修复SQL语法错误: pay_order是MySQL保留字,加反引号 2026-07-16 12:12:48 +08:00
ypsdf1 4781996f08 修复MySQL连接: poller_online.php直接用常量替代全局变量 2026-07-16 12:04:59 +08:00
ypsdf1 d9a3a6eb33 修复500错误: 重命名getPlatformDB避免与poller_online.php冲突 2026-07-16 11:51:59 +08:00
ypsdf1 510db12216 加固对账输出: 清空所有ob+try-catch+确保返回JSON 2026-07-16 11:47:21 +08:00
ypsdf1 3893d22aef 修复对账JSON解析: 包含poller前开启输出缓冲捕获非预期输出 2026-07-16 11:42:29 +08:00
ypsdf1 43cb042bf0 修复MySQL连接: 集中管理凭据,常量未定义时用默认值兜底 2026-07-16 11:28:05 +08:00
ypsdf1 bed190ca8d 修复: 订单不存在问题-平台MySQL直查+同步机制+管理后台手动同步
- queryOrder: 本地订单不存在时直查平台MySQL,找到则自动同步到本地
- handleNotify: 回调找不到本地订单时从平台MySQL补建
- checkPlatformOrderStatus: 超时3s→10s,返回更多字段(trade_no/money/player)
- recharge_orders_api: 新增sync_platform action,管理员手动从平台同步全部已支付订单
- admin.php: 充值对账页新增「从平台同步」按钮
- 解决: 游戏已发钱但前端/后台显示订单不存在的问题

关闭 #1069 #1070
2026-07-15 21:58:30 +08:00
ypsdf1 9c50e58e7d 修复pay_redirect: submit_params合并到INSERT+详细日志
- createOrder: submit_params直接写入INSERT语句,消除INSERT/UPDATE分离导致的空参数
- handlePayRedirect: 区分'订单不存在'和'submit_params为空'两种错误+debugLog
- 根因: INSERT不含submit_params,后续UPDATE若失败则订单存在但参数为空

关闭 #1068
2026-07-15 21:15:50 +08:00
ypsdf1 767a9892f7 修复: query_order直查平台MySQL+poller详细日志
- pay.php: query_order直接查平台MySQL,不依赖poller补单
- poller_online.php: MySQL连接超时增加到10秒+详细耗时日志
- 解决: 支付成功后前端按钮不锁定+游戏无回调
2026-07-15 20:24:47 +08:00
ypsdf1 cb949f5b16 修复支付系统核心问题v2\n\n- poller_online.php: 添加全局异常捕获,防止500错误\n- pay.php: queryOrder返回created_at,syncPoller action占位\n- player.php: 续支付检测添加5分钟过期检查\n- 解决:\n 1. Java轮询500错误\n 2. 订单超时5分钟\n 3. 前端过期订单自动清除\n 4. 支付成功后按钮锁定 2026-07-15 19:48:08 +08:00
ypsdf1 1cd8415516 修复支付系统核心问题
- pay.php: 超时时间从30分钟改为5分钟
- poller_online.php: 修复debugLog未定义变量+保留输出缓冲区
- recharge_orders_api.php: 修复对账结果JSON双重包装
- player.php: 支付成功后锁定按钮+续支付检测处理已支付订单

解决:
1. 订单超时改为5分钟
2. Java轮询500错误(debugLog变量未定义)
3. 对账结果显示undefined
4. 支付成功后按钮状态修复
2026-07-15 19:23:51 +08:00
ypsdf1 8f54e3ffc6 修复: 订单对账页合并到后台+防重复支付超时+玻璃模态弹窗
- pay.php: createOrder添加30分钟过期检测+清理陈旧created订单
- admin.php: 新增充值对账标签页(内嵌)+毛玻璃详情弹窗
- admin.php: 替换所有confirm/alert为glassConfirm/glassAlert
- recharge_orders.php: 改为跳转admin.php#recharge_orders
- 移除独立页链接,改为go('recharge_orders')

关闭 #1061 #1062 #1063
2026-07-15 18:56:52 +08:00
ypsdf1 449976478e 充值对账页面+防重复支付+高频补单+轮询器改进
- recharge_orders.php + recharge_orders_api.php: 管理员充值订单对账页面
- admin.php 侧边栏添加充值对账入口
- pay.php: 创建订单时检测已有未支付订单(复用),防重复支付
- player.php: 续支付检测+支付成功后清除本地订单+恢复按钮
- poller_online.php + pay.php: 添加web_transactions唯一索引
- .gitignore: 排除密钥.md

关闭 #1056 #1057 #1055 #1060 部分
2026-07-15 18:00:11 +08:00
ypsdf1 3a53ec5fdc queryOrder 自动触发 pay_poller 补单(非阻塞 curl 3秒超时) 2026-07-15 14:56:28 +08:00
ypsdf1 8c857b4715 还原PAY_NOTIFY_URL为域名(直连IP导致404),回调靠poller补单 2026-07-15 14:51:17 +08:00
ypsdf1 ab762ad3a9 修复支付回调:排除action参数导致签名验证失败 + 详细日志 + 直连IP绕CF
- verifyNotifySign 排除 action 参数(平台签名不含URL路由参数,多出 action=xxx 导致 MD5 不匹配)
- PAY_NOTIFY_URL 改 http://43.153.203.46(同服务器回调走 Cloudflare 被 WAF 拦截 403)
- handleNotify/verifyNotifySign 增加详细 debugLog(请求方法、remote_addr、验签过程)
- 额外创建 pay_poller.php(直接读平台MySQL轮询已支付订单,绕过HTTP回调)
2026-07-15 14:40:38 +08:00
ypsdf1 d0b9e8a92d 修复在线充值:私钥损坏无法自验,切回MD5签名(平台keytype=0允许)\n\n- 用户新私钥经诊断无法openssl_verify自签自验(self-verify=0),密钥数据损坏\n- PAY_SIGN_TYPE切回MD5,MD5签名已验证通过平台验签并成功创建订单\n- 通知回调仍用RSA验签(平台公钥验平台私钥签名,平台密钥有效) 2026-07-15 13:21:58 +08:00
ypsdf1 deabd1de98 修复在线充值:切换RSA签名+本地中转页POST提交,补齐submit_params列与web_transactions表兜底
- PAY_SIGN_TYPE 切回 RSA(新商户私钥已配对平台商户公钥)
- create_order 签名后落库 submit_params,pay_redirect 自动 POST 到网关(规避GET下 notify_url 内 & 被截断)
- ensurePayOrdersTable 用 PRAGMA 探测并 ALTER 补齐 submit_params 列(兼容旧表)
- handleNotify 新增 ensureWebTransactionsTable 幂等兜底,避免新部署缺少流水表导致回调 fail
- 仅改动 api/pay.php,密钥文件不进版本库
2026-07-15 11:35:52 +08:00
ypsdf1 8d151d865c fix(pay): 移除密钥加载对本地txt文件的引用,仅使用pay_secrets.php\n\n- loadPayKeys() 不再读取「密钥文件_git拉黑.txt」,避免服务器上存在旧txt时被误用\n- 删除 parsePayKeyFile() 函数及相关txt路径探测逻辑\n- 同步修正 pay_secrets.php 注释,明确服务器端仅认本文件\n- 本地txt文件仅作人工本地记录,严禁代码引用 2026-07-15 00:56:04 +08:00
ypsdf1 a4d6a5d487 revert(pay): 恢复排除sign_type字段(彩虹易支付标准签名规则)\n\n- 测试包含sign_type后仍失败,改回排除sign_type(文档明确要求)\n- 密钥更新:商户公钥已更新为与新私钥匹配\n- 诊断结果:商户私钥与商户公钥匹配,但与平台公钥不匹配\n- 用户需在支付平台后台核对公钥 2026-07-15 00:26:42 +08:00
ypsdf1 efcc0be26a fix(pay): 尝试在签名中包含sign_type字段\n\n- 彩虹易支付签名规则可能要求包含sign_type字段\n- 修改buildSign函数:只跳过sign字段,保留sign_type\n- 此为诊断性修改,若仍失败需改回排除sign_type 2026-07-15 00:17:44 +08:00
ypsdf1 8ae0fe9b7d revert(pay): 恢复SHA256签名算法(彩虹易支付V2标准)\n\n- 测试SHA1算法后仍失败,改回SHA256(文档明确V2使用SHA256WithRSA)\n- 诊断脚本证实:商户私钥与商户公钥不是一对,导致所有签名方式验签失败\n- 根本问题:密钥不匹配,需用户核对支付平台后台公钥 2026-07-15 00:11:38 +08:00
ypsdf1 af5f31c87e fix(pay): 尝试将RSA签名算法从SHA256改为SHA1以匹配彩虹易支付平台\n\n- 彩虹易支付V2接口可能使用SHA1而非SHA256签名算法\n- 用户反馈:用Unix时间戳RSA签名校验失败,用ISO 8601格式报时间戳错误\n- 此为诊断性修改,若仍失败需改回SHA256并检查其他参数 2026-07-15 00:02:38 +08:00
ypsdf1 7878c07a22 fix(pay): 修正 timestamp 为10位Unix时间戳(秒)以符合平台接口要求\n\n- 平台要求 timestamp 为10位数Unix时间戳(单位秒),而非ISO 8601格式\n- 改回 (string)time() 生成10位秒级时间戳\n- 签名规则不变:参数按ASCII升序、排除sign/sign_type/空值,RSA-SHA256签名\n- 此前ISO 8601格式导致平台报'时间戳字段不正确,请检查服务器时间' 2026-07-14 23:51:06 +08:00
ypsdf1 2a6c6edad2 fix(pay): 修正 timestamp 为 ISO 8601 格式以符合彩虹易支付 V2 接口签名规范\n\n- 原 Unix 时间戳字符串改为 date('c') 生成的 ISO 8601 格式(如 2026-07-14T23:27:54+08:00),与平台文档示例一致\n- 签名规则未变:参数按 ASCII 升序、排除 sign/sign_type/空值,RSA-SHA256 签名\n- 若平台仍校验失败,需核对商户私钥与平台后台公钥是否匹配 2026-07-14 23:33:00 +08:00
ypsdf1 7fd559531c Merge branch 'php-wip' into php
# Conflicts:
#	.gitignore
#	api/pay.php
#	api/sync.php
2026-07-14 23:18:44 +08:00
ypsdf1 cb2f68cfd8 fix(pay): 修复商户私钥 PKCS#1/PKCS#8 头不匹配导致的 RSA 签名失败
- wrapPem 对私钥按 DER 结构(version 之后紧跟 INTEGER 0x02 为 PKCS#1,SEQUENCE 0x30 为 PKCS#8)自动选择 PEM 头,不再一概用 PKCS#8 头
- 新增 opensslLoadKey 容错加载器:首次加载失败后自动在 RSA PRIVATE KEY 与 PRIVATE KEY 头之间切换重试,彻底规避头格式不匹配引发的签名/验签失败
- 该路径为支付关键链路,签名失败会导致 create_order 直接 500,必须健壮
2026-07-14 23:17:34 +08:00
ypsdf1 57124cab5c fix(pay): 修复 create_order 返回 HTML 导致前端 JSON 解析失败
根因:PHP 8.0+ 中 openssl_free_key() 已弃用,调用会触发弃用警告。
服务端若 html_errors=On,该警告以 <br> 等 HTML 形式输出,污染 JSON 响应,
前端 res.json() 报 'Unexpected token <'。

修复:
- buildSign / verifyNotifySign 中仅在 PHP < 8.0 时调用 openssl_free_key
- 文件顶部设置 display_errors=0 / html_errors=0 / error_reporting=E_ALL,
  确保 API 错误只进日志,不污染响应
2026-07-14 22:07:17 +08:00
ypsdf1 1c9e5808a1 WIP: php 本地未提交改动
包含:
- admin.php / api/land_api.php / api/sync.php / login.php 修改
- 新增 active_players.php / online_curve.php / today_bonds.php

注:后续将视 origin/php 情况 reset --hard 对齐,本分支用于保留 WIP。
2026-07-14 22:02:10 +08:00
ypsdf1 8ec89ef841 feat: 债券在线充值联控(彩虹易支付RSA版) — pay.php + player.php
- api/pay.php: 下单/回调/查询 + RSA(SHA256)签名验签 + pay_orders表
- player.php: 在线充值UI + ?paid=1回跳 + 3s轮询补单
- 密钥仅从 git-ignored 的 pay_secrets.php / 密钥文件_git拉黑.txt 读取,不入库
- fix: 去除 open_basedir 限制的 /pay_keys 候选路径 + @抑制文件访问警告,避免污染响应
2026-07-14 21:12:56 +08:00
ypsdf1 9a78c693bf feat: 债券在线充值联控(彩虹易支付RSA版) — pay.php + player.php
- api/pay.php: 下单/回调/查询 + RSA(SHA256)签名验签 + pay_orders表
- player.php: 在线充值UI + ?paid=1回跳 + 3s轮询补单
- 密钥仅从 git-ignored 的 pay_secrets.php / 密钥文件_git拉黑.txt 读取,不入库
- fix: 去除 open_basedir 限制的 /pay_keys 候选路径 + @抑制文件访问警告,避免污染响应
2026-07-14 21:03:28 +08:00
ypsdf1 5515d22d4c security: 支付密钥文件 git 拉黑 (double insurance)
- .gitignore 新增 密钥文件_git拉黑.txt / pay_secrets.php / *.paykey
- 防止彩虹易支付 RSA 私钥/公钥/商户ID 误提交到 git
- 部署脚本 deploy_php_only.py 同步排除该文件防止上传公网 web 根目录
2026-07-14 20:25:06 +08:00
ypshidifu afdae1b63b fix(sync): 拉取交易即刻贴标防重复处理 — 新增confirmed_transactions表+ack_transaction接口 2026-07-14 11:38:30 +08:00
ypshidifu 6984bc79fd feat(admin): 搜索框支持ban/tempban关键字筛选封禁玩家 2026-07-14 00:48:28 +08:00
ypshidifu 8261b4dbfd feat(admin): 用户管理添加封禁玩家/临时封禁筛选标签页 2026-07-14 00:37:10 +08:00
ypshidifu 984c629cc0 修复(today_bonds): 改用game_transactions单一数据源消除重复计算
【根因】同一笔Web商城购买被写入两张表:
- web_transactions(PHP端cashier.php直接写入)
- game_transactions(Java端同步,含balance_before/after)
导致消费金额被重复统计(如youpaishidifu今日消费显示410而非205)。

【修复】移除所有web_transactions查询,只用game_transactions作为
唯一数据源。每笔交易通过balance_after-balance_before判断方向,
余额增加→收入,余额减少→消费,100%准确且无重复。

验证结果(youpaishidifu今日):
- 消费:205 ✓(之前显示410,实际应为205)
- 收入:5302 ✓(与XLS流水一致)
2026-07-13 21:38:45 +08:00
ypshidifu 6ad469452f 修复(today_bonds,admin.php): PHP数组key自动转整数导致r.name为number→escAdmHtml爆炸\n\n【根因】PHP中[['player_name']]当玩家名为纯数字时,PHP自动将\narray key转为integer,array_keys返回整数。JSON编码后name字段为number。\nJS端(r.name||'') → 123||'' = 123,123.replace() → TypeError。\n\n【修复】\n- today_bonds.php: 'name' => (string) 显式字符串化\n- admin.php: escAdmHtml 改用 String(s??'') 防御所有非字符串类型 2026-07-13 21:18:26 +08:00
ypshidifu 8f0bf8cd52 修复(today_bonds): web_transactions用type区分方向, game_transactions用balance差值
【问题】两表的 amount 始终为正,不能用金额符号判断收支方向。
【修复】
- web_transactions: amount<0 →消费, amount>0中用type区分(shop_buy/shop_cart=消费,其他=收入)
- game_transactions: balance_after > balance_before →收入, 反之为消费
- 覆盖 admin_add/deduct/refund 和 Java同步的所有交易类型
2026-07-13 20:51:31 +08:00
ypshidifu ffa8b4cce0 修复(today_bonds): 消费收入需同时统计web_transactions和game_transactions
用户截图中的 shop_buy 等支出记录在 game_transactions 表而非
web_transactions 表,之前的查询只覆盖 web_transactions 导致消费为0。
现在合并两个表的数据:game_transactions.amount < 0 也计入消费,
amount > 0 也计入收入。
2026-07-13 20:35:27 +08:00
ypshidifu ba484e957d 修复(today_bonds,online_curve): 债券看板消费数据源错误+在线曲线不记录快照
今日债券:
- 消费改为从 web_transactions.amount<0 取绝对值
- 收入从 web_transactions.amount>0 取
- 移除 orders.db.cashier_orders 消费查询(导致消费总是0)

在线曲线:
- 新增 ensureRecentSnapshot() 自动记录机制
- 每次 action=data 时检查上次快照是否>55秒前,是则自动记录
- 避免后台只调 action=data 导致永不记录快照的 bug
2026-07-13 20:25:58 +08:00
ypshidifu dff381adeb 修复(admin.php): 三个看板函数使用未定义的 esc() → escAdmHtml()
- today_bonds / active_players / online_curve 三个 load 函数
- 全部 8 处 esc(...) 替换为后台已定义的 escAdmHtml(...)
2026-07-13 20:04:23 +08:00
ypshidifu 19472baab0 后台集成: 三个数据看板页面(今日债券/本时活跃/在线曲线)
- admin.php 侧边栏新增三个导航项
- 新增 loadBondsToday/loadActiveHour/loadOnlineCurve 三个加载函数
- 直接从独立PHP端点(today_bonds/active_players/online_curve)获取数据
- 纯CSS条形图展示24小时在线曲线,无需Chart.js CDN依赖
- 各看板自动定时刷新(30s/10s/60s),切换页面时清理定时器
2026-07-13 19:53:28 +08:00
ypshidifu 5e581adef3 新增: 三个PHP数据看板页面
- today_bonds.php: 今日债券变动看板(消费/收入/净变动)
- active_players.php: 本小时在线活跃看板(实时在线玩家)
- online_curve.php: 24小时在线曲线图(Chart.js柱状图+自动快照)
2026-07-13 19:12:21 +08:00
ypsdf1 4e11845636 R23: sync.php新增sync_admins+sync_bans处理(修复Java推送返回未知操作) 2026-07-12 23:00:05 +08:00
ypsdf1 b34753d1fd 新增封禁名单联控 + 用户管理封禁显示
- land_api.php: web_player_bans表 + handleSyncBans + sync_bans action
- admin.php: list_bans API接口
- admin.php前端: 用户行显示封禁状态(红色+禁图标+hover详情)
2026-07-12 22:08:46 +08:00
ypsdf1 498245d967 插件管理员权限扩展 + admin名单同步
- 新增web_plugin_admins表 + isPluginAdmin/canManageLand辅助函数
- handleAddVisitor/handleRemoveVisitor/handleChangeVisitorRole/handleUpdateLandField/handleUpdateVisitorPerm/handleGetMemberPerms/handleUpdateMemberPerm/handleClearMemberPerm/handleLandDetail 9处权限检查从owner-only扩展为owner+admin
- 新增sync_admins action:Java推送tag管理员名单,PHP全量写入web_plugin_admins
2026-07-12 19:46:44 +08:00
ypsdf1 be04c8b451 修复三个问题:1)sync.php缺少:peace_mode绑定导致同步后和平模式值丢失;2)player.php效果命名修正(火焰抗性→抗火,英雄→村庄英雄);3)peace_mode前端渲染反转(isInverted从false改true) 2026-07-12 13:03:48 +08:00
ypsdf1 f5879f4087 修复PHP操作效果后JSON解析崩溃:convertEffectsForFrontend增加容错处理+handleUpdateLandField统一管道格式到变更队列+player.php所有effects JSON.parse加try-catch 2026-07-12 11:41:26 +08:00
ypsdf1 0760cc1d2e 修复Java删除成员后PHP不同步删除的问题:移除syncPermissions中导致删除永远不生效的120秒宽限期,改为检查web_admin_changes待处理变更保护 2026-07-12 11:20:11 +08:00
ypsdf1 8645c57839 修复Java撤职后被PHP stale pending变更覆盖的问题:syncPermissions接收Java同步时Ack掉冲突的待处理perm_change/add_visitor 2026-07-12 10:56:55 +08:00
ypsdf1 0b283483a7 修复: 成员权限显示未合并领地默认权限
handleGetMemberPerms 新增返回领地默认权限映射(default_perms),
严格对齐 Java getLandDefaultDeny 语义(含 denyDrop/denyPickup 反向列)。
前端 renderMemberPermDetail 对无自定义覆盖的权限键回退到领地默认,
与游戏内 getEffectiveDeny 的领地默认回退逻辑一致,
修复网页端显示"允许"而游戏内实际"禁止/限制"的权限不同步问题。
2026-07-12 10:32:10 +08:00
ypshidifu 9b39c9e56f 修复: 权限管理多项问题
1. 权限字段补齐: PHP $PERM_TYPES对齐Java全部32个权限键
   - 新增denyEntityInteract(实体交互)、denyMobAttack(攻击生物)
   - 修正denyPeaceMode→peaceMode(键名与Java对齐)
   - 数据迁移: 自动重命名DB中已有的denyPeaceMode为peaceMode

2. 权限编辑二态化: 成员权限只有允许/禁止,不存在"领地默认"
   - 移除前端"默认"状态,改为允许(green)/禁止(red)二态切换
   - 后端: 禁用时存false而非删除键,确保Java端正确识别

3. 角色切换修复: 移除visitor选项(访客=未授权玩家)
   - 下拉菜单只保留admin/member
   - 确认对话框文案对应调整

4. 同步删除逻辑: handleSyncPermissions新增清理机制
   - 删除incoming列表中不存在的旧记录(120秒宽限期)
   - synced_at=NULL的记录不参与删除(防误删PHP本地创建)
   - role CASE简化为role=excluded.role(信任Java为角色权威源)

5. 部署: json_encode加JSON_UNESCAPED_UNICODE防双重编码
2026-07-12 01:07:27 +08:00
ypsdf1 c266cf8555 R17: 修复token被误判为一次性 - 清理validateTokenSilent诊断日志+修复getDB重复ROLLBACK 2026-07-11 20:26:13 +08:00
ypsdf1 a4972dde7e R16: 修复add_visitor超时边界(>65→>=60) 2026-07-11 18:10:43 +08:00
ypsdf1 d7da6ec1a6 R15: PHP添加成员改为Java验证后入库(参考改领地主流程)
- handleAddVisitor改为先写pending队列,等Java验证后入库
- 新增handleAddVisitorCallback回调PHP添加成员结果
- 新增handleGetAddVisitorStatus供前端轮询状态
- player.php添加成员后轮询状态,支持异步验证显示
2026-07-11 15:53:12 +08:00
ypsdf1 0115cbda41 R14: 修复PHP添加成员静默失败+syncPermissions删除清理
- handleAddVisitor: 移除异步验证阻塞,先添加成员再异步校验
- syncPermissions: 新增删除清理逻辑,同步Java端已移除的成员
- 120秒宽限期防止PHP刚添加的成员被误删
2026-07-11 15:11:00 +08:00
ypsdf1 ed57c8f82c R13: PHP端handleChangeVisitorRole 新成员走add_visitor流程
- 新增成员时change_type从perm_change改为add_visitor,确保Java白名单同步
2026-07-11 14:19:08 +08:00
ypsdf1 4998d7bdfa 修复PHP添加/移除成员不通知Java同步:写入web_admin_changes变更队列
1. handleAddVisitor: 添加访客后写入add_visitor变更记录,Java轮询后同步到area_land_permissions+白名单
2. handleRemoveVisitor: 移除访客后写入remove_visitor变更记录,Java轮询后同步删除
3. playerExists已检查login.db兜底,确保添加不存在的玩家会被拒绝
2026-07-11 13:36:03 +08:00
ypsdf1 646ecfa162 修复撤职管理员报无效角色member+效果中英文名兼容
1. land_api.php: handleChangeVisitorRole白名单增加member角色
2. player.php: addGiveEffect改用中文效果名(EN_TO_CN映射)发送给Java
3. player.php: addClearEffect兼容中文效果名匹配
2026-07-11 12:21:32 +08:00
ypsdf1 eaeab955e3 修复PHP三个bug:1.changeVisitorRole支持三角色切换(admin/member/visitor)并允许领地主自由切换;2.token验证兼容POST;3.成员权限toggleMemberPerm DOM检测修正 2026-07-10 23:16:32 +08:00
ypsdf1 a28083f4ca 修复PHP两个bug:1.land_api.php玩家端token验证兼容POST(changeVisitorRole等POST请求不再误报需要登录);2.player.php成员权限toggle检测逻辑修正(isCustom原本查span但自定义文字在div里,导致永远走无自定义分支只能给不能撤) 2026-07-10 22:51:04 +08:00
ypsdf1 b67480d073 PHP权限管理全面对齐Java:1.handleUpdateLandField白名单从4项扩展到33项(覆盖所有deny/allow权限开关);2.sync.php补齐14个缺失字段的INSERT和bind(deny_mount/deny_ender_pearl/deny_bow/deny_potion/deny_fire/deny_raid/deny_container/deny_mob_attack/is_public_building/allow_visitor_teleport/confiscate_items/deny_use_items/punish_commands/clear_all_bad);3.player.php新增领地权限开关面板(34项开关,与Java GUI getPermEntries一致,支持deny/allow反转显示);4.sync.php补充新列ALTER TABLE迁移 2026-07-10 22:34:39 +08:00
ypsdf1 555b9382b5 修复PHP三个关键bug:1.player.php效果字段名clear_all_bad_effects→clear_all_bad匹配DB列名;2.validation_callback的add_visitor写入web_area_permissions表(与handleAddVisitor一致,修复成员不入库);3.validatePlayerViaJava优先本地playerExists验证,减少异步等待;4.auth兜底保底防止update_land_field误入invalid secret 2026-07-10 22:00:31 +08:00
ypsdf1 b63ecc02b8 修复PHP接口:增益效果接口补齐必要参数,玩家存在性校验兼容登录库,同步新增实体交互权限字段 2026-07-10 19:08:18 +08:00
ypsdf1 1238c6a20c 修复: core.php web_area_permissions 添加 UNIQUE(land_id,player_name) 约束
- 根因: core.php 创建表时缺少 UNIQUE 约束,导致 sync.php 的 ON CONFLICT 永远不触发,每次同步插入新行
- 新增迁移逻辑: 检测旧表缺少 UNIQUE 时自动重建表并迁移数据
2026-07-10 12:52:44 +08:00
ypsdf1 e49fcaccd8 修复: syncPermissions ON CONFLICT替代INSERT OR REPLACE防角色降级
sync.php + land_api.php: 同步权限时用ON CONFLICT, 保留已有admin/member角色
2026-07-10 10:59:12 +08:00
ypsdf1 eed83a552f 领地权限切换加全链路日志+已授权用户降级为访客的拦截
- handleChangeVisitorRole: 修复$permRow未定义(旧$oldPerms恒空);新增 admin/member 降级为 visitor 的拦截并返回错误;三处 error_log 追踪切换角色流程
- handleUpdateVisitorPerm: 入口/拦截/放行三处 error_log
- handleUpdateMemberPerm: 写入 web_admin_changes 前 error_log 记录新权限JSON
- 配合 Java 端 AreaProtection.updateVisitorPermFromWeb 的 getLogger().info 端到端追踪
2026-07-10 01:34:24 +08:00
ypsdf1 c960c3ac0c 修复调整访客权限误重置管理员身份(role默认visitor+INSERT OR REPLACE覆盖);保留付费访客expires_at;change_visitor_role同步land_name 2026-07-10 00:16:04 +08:00
ypsdf1 ae1ff5ffd8 异地登录检测/账号冻结解冻 + 公共邮件辅助函数
- core.php: 新增smtpSendEmail/queryLoginIpLocation/getBaseUrl/sendLocationAlertEmail
- security_alert.php: 接收Java通知比对IP归属并发送提醒邮件(含冻结/改密链接)
- freeze.php: 验证freeze_token冻结账号并写freeze指令
- sync.php: 冻结账号禁止Web登录; smtpSendEmail上移至core.php统一复用
- reset_password.php: 改密成功后解冻(清frozen+写unfreeze)
2026-07-09 17:09:35 +08:00
ypsdf1 1e97285459 PHP端:购物车发货前material验证 + 退款接收接口
- shop.php buy_cart: 扣款前验证每件商品material为Java可识别值(合法Material或附魔书id+ENCHANTED_BOOK组合)
- sync.php: 新增write_shop_refund action接收Java退款请求(恢复bond_cache+写refund交易)
2026-07-09 10:54:59 +08:00
ypsdf1 c1234c6700 修复重启重复发货:processing超时回退60s→3600s + pull_pending_transactions不再选中processing状态交易 2026-07-08 19:47:35 +08:00
ypsdf1 e076596b4d 修复收银台小票推送:settlement 误传中文标签导致重复发小票潜影盒
api/shop.php 推送 give_receipt_book 时误用 $modeName(中文:塞背包(环保单)/潜影盒打包)
而非原始代码,导致 Java 端 equals('backpack'/'shulker') 守卫不匹配、仍单独发小票潜影盒。
现改为推送原始 $settlement 代码;Java 端守卫已同步兼容中文标签做双保险。
2026-07-08 17:35:04 +08:00
ypsdf1 e9ecb0ae53 修复收银台:背包(环保单)模式尊重游戏内 green_discount
此前背包模式固定按 cart_backpack_rate(0.98=98折)结算,
忽略游戏内 /sdf1_login shop setgreen 设置的折扣。
现改为:green_discount<10 时以游戏折扣为准(如 setgreen 9 -> ×0.9),
覆盖默认98折;未设置(=10)时仍走 cart_backpack_rate 保持环保优惠。
前端预览 RATES.backpack 同步读取 green_discount,避免界面/实付不一致。
2026-07-08 16:35:04 +08:00
ypsdf1 ab7443b5b5 修复订单页 orders.php 的 esc 函数:重写转义逻辑,清除导致 JS 语法错误的损坏字符 2026-07-08 15:19:00 +08:00
ypsdf1 77ee56cca4 修复收银台98折错误地叠加环保单折扣导致实付约95折
- api/shop.php: 塞背包模式不再额外叠加 green_discount,仅按 cart_backpack_rate(0.98=98折) 计费,ecoPct 置0
- cashier.php: 结算标签改为动态显示真实折扣率;前端 recalc 中 ecoPct 恒为0,与后端一致,消除显示与扣款不符
2026-07-08 13:53:01 +08:00
ypsdf1 3f3fbd68bb #800 orders.php 增强缓存破坏头(解决浏览器/CDN缓存旧版导致SyntaxError)
- 新增 Pragma: no-cache 和 Expires: 0 头
- Cache-Control 增加 proxy-revalidate(强制代理/CDN不缓存)
- 线上验证通过:HTTP 200 / 14893 bytes / 全部JS函数就位 / 无PHP错误
2026-07-08 11:42:38 +08:00
ypsdf1 d9cc806177 修复: orders.php诊断错误处理中E_RECOVERABLE_ERROR误用冒号导致PHP Parse error(整页崩溃/loadOrders未定义)
- 第12行数组 E_RECOVERABLE_ERROR:'Recoverable' 误用冒号(应为 =>),导致PHP解析失败、整页只输出Parse error、所有前端JS函数(loadOrders等)缺失
- 改为 E_RECOVERABLE_ERROR=>'Recoverable',页面恢复正常(实测线上HTTP 200、STATE JSON有效、全部JS函数就位)
2026-07-08 11:08:00 +08:00
ypsdf1 c3ee6a5c5a 增强: orders.php错误诊断增强(PHP错误捕获+前置输出检测+全局JS错误捕获+Promise rejection捕获) 2026-07-08 10:57:54 +08:00
ypsdf1 cb65e34f2d 修复收银台未登录时 $initialCanCash 未定义警告(默认0) 2026-07-08 01:05:51 +08:00
ypsdf1 1ea6f4963f 修复: web.db initTables中cashiers.can_cash冗余加列用@抑制挡不住SQLite3Exception,导致每条请求抛duplicate column并刷爆debug.log(33MB)写满磁盘阻断FTP上传——改为try/catch幂等兜底 2026-07-08 00:27:56 +08:00
ypsdf1 28c7aa119e 修复: green_discount折扣率(折数)语义——前端/后端计算ecoAmt改为×折数/10,管理面板max=10默认值10,校验与文案全改 2026-07-08 00:18:47 +08:00
ypsdf1 ac79628076 修复: PHP端packmoney从(int)强转改为(float)支持小数(cashier/shop/sync三处) 2026-07-07 23:54:20 +08:00
ypsdf1 02dc706635 修复: orders.db冗余ALTER加列导致duplicate column name异常、getOrdersDB首调失败订单丢失——改为幂等兜底try/catch 2026-07-07 23:39:37 +08:00
ypsdf1 d0be292a78 修复: 收银台api()加25s超时兜底(解决结账卡死) + buy_cart小票推送容错 + 订单错误堆栈日志 + orders会话判定保护 2026-07-07 22:43:51 +08:00
ypsdf1 822f47dd05 修复: 收银台API顶层异常捕获升级为Throwable + 订单查询异常落盘debug.log便于定位 2026-07-07 21:39:40 +08:00
ypsdf1 78e3b2ff1e 修复:订单记录页order_list接口加固(查询异常返回合法JSON,杜绝PHP报错污染响应导致前端报错);orders.php渲染对getCurrentCashier空值做保护 2026-07-07 21:26:14 +08:00
ypsdf1 8286a4c09c 新增: 商品目录定时镜像同步接口 set_shop_catalog(支持删分类/加商品) + 修复 getShopConfigSync 未定义函数500错误 2026-07-07 19:40:47 +08:00
ypsdf1 6775465c21 修复: 订单页JS语法错误导致永久转圈(onclick内联JSON引号冲突改data属性+事件委托) 2026-07-07 16:52:59 +08:00
ypsdf1 9c269cc184 重构: 收银台订单库彻底独立于web.db,根除订单加载卡死
根因:订单记录原写在 web.db,与 Java 经 sync.php 高频写入共享同一 SQLite 文件,
产生文件级锁竞争(database is locked)。原 62f5323 用 busy_timeout=15s + 重试 hack
缓解,但 Java 持续写入仍会顶住 PHP 订单读查询,导致订单页长期转圈。

改动:
- 新增独立 orders.db 常量(ORDERS_DB_PATH),Java 永不读写此库
- core.php 新增 getOrdersDB() 连接独立库(WAL + busy_timeout=5s),建表并幂等迁移 web.db 历史订单
- recordCashierOrder() 改写写入 orders.db,订单写失败仅记日志返回空单号,绝不阻断购买主流程
- shop.php 将订单记录移出 web.db 事务,放到 COMMIT 之后写独立库
- cashier.php cashierOrderList() 直接读 orders.db,去掉 busy_timeout 与 5 次重试兜底 hack
- web.db 内旧 cashier_orders 表保留为历史死表(仅作迁移源),不再被读写

效果:订单查询与 Java 写入完全解耦,订单页不再受 CF/Java 锁影响而卡死。
2026-07-07 15:25:25 +08:00
ypsdf1 62f53234e7 修复: 订单记录页opcache失效确保新版生效 + 订单查询放宽busy_timeout至15s并增加重试(规避database is locked) 2026-07-07 14:26:45 +08:00
ypsdf1 a72bbfd970 修复: 订单记录页加载卡死(增加请求超时与重试) + 订单接口释放会话锁并对database is locked重试 2026-07-07 13:38:00 +08:00
ypshidifu 6359830105 修复: orders.php的esc()未定义报错(core.php补齐PHP版esc) + 收银台底部保留5条最新订单(独立页orders.php仍展示全部) 2026-07-07 12:38:37 +08:00
ypsdf1 7a4aad46c6 功能: 订单记录独立页+小票书+packmoney/green修复(??替代||)
1. 新增 orders.php 独立订单记录页面(搜索/筛选/详情弹窗)
2. 收银台底部订单区改为跳转链接(→orders.php)
3. buy_cart 成功后推送 give_receipt_book 到 Java 队列(发放 Written Book)
4. 修复 packmoney/green_discount 前端 JS || falsy 回退 bug(改用 ??)
5. 费率从后端 shop_config 动态读取(不再硬编码 RATES)
6. cashierGetConfig 扩展读取 cart_backpack_rate/cart_shulker_rate
2026-07-07 12:28:26 +08:00
ypsdf1 fdd8ccb340 优化: 收银台新增打包小票(小票弹窗+打印) + buy_cart返回order_no 2026-07-07 11:37:48 +08:00
ypsdf1 9456f787f8 优化: 收银台购物延迟压至15秒内(busy_timeout 60s→8s、移除热路径冗余DDL、合并配置批量读取) 2026-07-07 10:39:17 +08:00
ypsdf1 6634fdefa0 修复收银台SQLite3Stmt::fetch崩溃并应用环保单减免配置 2026-07-07 00:33:24 +08:00
ypsdf1 f32c7cfe86 收银台双模式收款与打包配置全链路实现
- 收银员新增 can_cash 现金收款权限字段(登录/列表/新增/编辑全链路)
- 购物车结算支持 pay_mode=cash/bond 双模式,现金模式跳过余额校验与扣债券
- 收银台前端读取可配置 packmoney 实时报价,修复此前硬编码+2元与实际收费不符
- 管理后台新增打包费(packmoney)与环保减免(green_discount)配置端口
- 同步接口新增 get/set_shop_config,Java端定时器拉取配置
- 购物小票书塞入首个潜影盒,免费潜影盒订正为原色(SHULKER_BOX)
2026-07-06 23:48:44 +08:00
ypsdf1 86b2d6ebdf 收银台前端样式统一:输入框/下拉框深色主题、自定义滚动条,修复与背景质感不搭 2026-07-06 21:48:42 +08:00
ypsdf1 c22386b8fc 修复收银台角色标签CSS样式缺失问题
- 补充 .tag-admin / .tag-cashier 标签样式定义,统一管理员与收银员角色标签颜色与边框
- 同步修正HTML初始span与JS动态赋值的class,消除无样式标签
2026-07-06 20:01:49 +08:00
ypsdf1 59701d1627 新增独立收银台页面与收银员账号体系:支持收银员/管理员登录、手动折扣(服务端强制上限)、订单记录;同步修复代购≥1000仍误报需玩家密码问题(#733 #734) 2026-07-06 19:27:14 +08:00
ypsdf1 0e1f26508b 管理后台用户组页新增全局传送点上限(maxhome)配置入口(#726)
- loadUserGroups拉取get_config展示全局home上限卡片
- 新增showHomeConfig/saveHomeConfig弹窗,POST update_config(key=max_home_per_player)
- 复用现有area_config通路,与Java端max_home_per_player配置项对应
2026-07-06 18:10:36 +08:00
ypsdf1 8de2cffe82 新增管理后台独立收银台模式:管理员可为指定玩家代购商品(商品浏览+购物车+背包/潜影盒结算+颜色可选收费+≥1000债券密码确认) 2026-07-06 17:54:55 +08:00
ypsdf1 dd425f4946 修复商城database is locked;购物车结算增强:≥1000元强制密码校验、潜影盒颜色可选收费(默认紫色免费);玩家端新增购物车模式与颜色选择器 2026-07-06 17:33:15 +08:00
ypsdf1 7a283ae4e0 商城购物车模式+收银台结算+后台折扣配置:前端购物车FAB/抽屉/结算弹窗,后端buy_cart接口事务批量购买,admin购物车配置卡片,cart_config/save_shop_config接口 2026-07-06 16:39:47 +08:00
ypsdf1 e46ee109d3 修复PHP管理面板相关问题 2026-07-06 11:22:31 +08:00
ypsdf1 ffb6e24d5f fix: PHP到期时间解析修正秒→分钟的单位换算 2026-07-03 23:37:37 +08:00
ypsdf1 31493de10d feat: PHP管理面板到期时间改为智能解析,支持数字/中文单位/日期格式,玩家只需填'30'表示30分钟 2026-07-03 22:41:34 +08:00
ypsdf1 1277c1fdf3 feat: php管理面板添加用户组成员时增加到期时间输入框 - 管理员可手动设置expiry_time - 成员表格展示到期时间列 - 到期为0表示按用户组duration_minutes自动计算 2026-07-03 19:37:05 +08:00
ypsdf1 5fa59b71b2 fix: PHP删除用户组同步 + handleDeleteUserGroup先删成员表再删组表 2026-07-03 14:27:27 +08:00
ypsdf1 591d71ede0 fix: PHP数据库锁修复 + 用户组添加/移除错误处理
1. initTables()统一创建所有核心表(web_user_groups, web_user_group_members等)
2. handleAddGroupMember改用playerExists()本地验证替代异步validatePlayerViaJava()
3. handleRemoveGroupMember增加错误处理和affected行数检查
4. 移除validation_callback/visitor中的CREATE TABLE IF NOT EXISTS
2026-07-03 13:41:22 +08:00
ypsdf1 4927aaed01 fix: Java推送成员变更到PHP新增add/remove_group_member_by_java接口; Java端调用改名加_java后缀 2026-07-03 01:16:23 +08:00
ypsdf1 6337bc3e75 fix: player.php添加自定义玻璃拟态确认对话框showConfirm(),替换原生confirm() 2026-07-02 23:48:37 +08:00
ypsdf1 d394bd0ec8 fix: PHP购买用户组流程完善 - 1. 修复duration=0永久验证(从<=0改为<0) - 2. 替换原生alert/confirm为自定义toast玻璃拟态UI - 3. 添加bond记录同步(handleSyncBondRecord) - 4. 前端购买弹窗使用玻璃拟态确认对话框 - 5. 修复可用用户组列表SQL缺失列自动迁移 2026-07-02 22:41:15 +08:00
ypsdf1 60151dc1d0 修复handleListAvailableGroups迁移缺失列+修复领地禁止点燃双方向检查 2026-07-02 20:15:12 +08:00
ypsdf1 35eb054e4f 清理仓库:移除误提交的Java编译产物
- 移除.idea/目录(IDE配置文件)
- 移除target/目录(Java编译产物)
- 移除Sdf1_login.iml(IDE模块文件)
2026-07-02 16:57:22 +08:00
ypsdf1 bcc68aa4e5 feat: 实现TimerE用户组续费轮询完整流程 - poll_group_renews action + renew_group_callback + web_group_renew表 2026-07-01 20:36:17 +08:00
youpaishidifu b9ca56ff7f feat: 修复用户组权限检查 - 所有玩家可查看购买用户组 + 修复JavaScript语法错误 2026-07-01 11:22:51 +08:00
youpaishidifu fc6a46597d fix: 修复JavaScript模板字符串中的Unicode字符²语法错误 2026-07-01 11:04:19 +08:00
youpaishidifu 1bc97f6980 feat: 领地管理增强 - 毛玻璃改主弹窗 + pending队列 + 同步状态查询 + 玩家前端UI 2026-07-01 01:20:15 +08:00
ypsdf1 6d68c4a05b feat: 改主弹窗替换为毛玻璃居中弹窗 (glassPrompt) 2026-06-29 20:57:03 +08:00
ypsdf1 3dacc80fe1 feat: 领地删除走pending队列 + sync清理PHP残留
- land_api: handleDeleteLand改为写web_admin_changes队列
- land_api: 新增delete_land_callback处理Java回调
- sync.php: syncLands增加删除PHP中不存在的领地
- 修复admin面板过户按钮(同步到plugin目录)
2026-06-29 20:45:38 +08:00
ypsdf1 62798f2a90 添加改主状态查询API,PHP前端轮询机制 2026-06-29 20:06:27 +08:00
ypsdf1 64ed1e6ada 修复改主认证: admin.php改用secret认证, 移除不存在的transfer_land_admin函数, secret认证时设置isAdmin=true 2026-06-29 19:39:55 +08:00
ypsdf1 5403479a2c 修复PHP改主校验流程:Java失败时PHP自动回退+前端不立即刷新
1. PHP handleUpdateLandOwner: 不再直接更新web_area_lands,写入pending队列,返回pending=true
2. PHP handleOwnerChangeCallback失败时:回退owner为旧值,记录日志
3. PHP admin.php adminTransferLand: 收到pending=true时不立即刷新列表,改为5秒后轮询
4. 按钮禁用防重复提交,等待回调后恢复
2026-06-29 16:38:31 +08:00
ypsdf1 e9bd63a1e4 修复过户面板:移除独立过户按钮改为主改主、adminTransferLand/deleteLand函数修复、改主流程改为pending等待Java验证、添加owner_change_callback回调 2026-06-29 15:24:04 +08:00
ypsdf1 866b5abe15 refactor: 过户流程重构 - PHP暂存等Java验证
1. handleTransferLand: 不再立即改owner,改为status=pending等Java验证
2. 新增handleTransferCallback: Java验证成功→改owner+cooldown;失败→标记failed
3. handleCancelTransfer: 支持pending状态撤回+cooldown状态回退
4. handleTransferStatus: 增加id字段
5. transfer_callback加入syncFromPhpActions
2026-06-29 12:12:10 +08:00
ypsdf1 2237f6c855 feat: 领地过户功能 + 管理面板改主修复
1. 领地过户: 添加web_land_transfers冷却表,handleTransferLand/handleCancelTransfer/handleTransferStatus
2. 管理面板改主: 移除异步验证,直接写入web_admin_changes让Java端执行(管理员操作可信任)
3. admin.php: 过户按钮+adminTransferLand JS函数+confirm二次确认
4. 玩家名正则统一为3-16位
2026-06-29 11:48:51 +08:00
ypsdf1 3925dea586 修复: sync.php新增check_player_registered action,解决Java端拉取注册数据报'未知操作' 2026-06-28 23:55:43 +08:00
ypsdf1 02c2e91531 fix: admin.php modal重复叠加导致ugNewMember获取失败 + 异步验证pending处理
- admin.php showModal: 新增[data-modal-overlay]清理逻辑防止多次打开modal导致元素复用
- doAddGroupMember: 固定player名正则3-16位, 改善错误提示文案, 处理pending响应
- admin.php modal: 移除旧的querySelectorByStyle选择器改为data属性
2026-06-28 17:12:08 +08:00
ypsdf1 c0afb47535 feat: PHP玩家验证改为异步模式
- land_api.php: 新增 pending_player_validations 表(待验证队列)
- validatePlayerViaJava(): 改为异步模式(查缓存→写pending→返回null)
- 新增 handleGetPendingValidations: Java拉取待验证列表
- 新增 handleValidationCallback: Java推送验证结果
- handleAddGroupMember/UpdateLandOwner/AddVisitor: 适配异步验证
- 改善错误提示文案
2026-06-28 15:59:42 +08:00
ypsdf1 f6265dd994 feat: 玩家验证改调Java回调服务器(替代直接读login.db)
- land_api.php: 新增 validatePlayerViaJava() 函数
  POST http://GAME_SERVER_HOST:9090/validate_player
- handleAddGroupMember: 替换直接读login.db为Java验证
- handleAddVisitor: 替换playerExists()为Java验证
- handleUpdateLandOwner: 替换playerExists()为Java验证
- config.php: 新增 GAME_SERVER_HOST (默认127.0.0.1)
2026-06-28 15:30:09 +08:00
ypsdf1 2342cc2cab fix: 用户组成员添加校验(玩家名格式+login.db存在性检查) 2026-06-28 15:16:36 +08:00
ypsdf1 35f15db034 feat: 用户组成员变更写入admin_changes通知Java同步 2026-06-28 14:27:04 +08:00
ypsdf1 1f97e3b969 修复clear_all_bad字段名不一致+handleUpdateLandField整数列类型 2026-06-28 12:17:21 +08:00
ypsdf1 72d2032f95 fix: PHP admin统计接口catch(Exception)改为catch(\Throwable),修复PHP8兼容性导致概率性unknown 2026-06-28 11:47:13 +08:00
ypsdf1 d568a2ce5d feat: PHP效果选择器+管理员角色切换+JSON编码修复
- player.php: 新增glassSelect()毛玻璃选择器弹窗(29种MC药水效果)
- player.php: addClearEffect/addGiveEffect从手动输入改为效果选择器
- player.php: 访客表格角色列改为可点击标签,点击切换管理员/访客
- land_api.php: 新增change_visitor_role API + handleChangeVisitorRole()
- land_api.php: handleListUserGroups添加JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES
2026-06-28 10:48:09 +08:00
ypsdf1 5f3bd70987 fix: SQLite只读数据库修复+远程权限修复API
- getDB(): 新增自动chmod修复数据库文件/WAL/SHM权限
- getDB(): initTables和migrateDatabase加try-catch容错,防止写入失败导致500
- 新增fixDbPermissions()远程修复API: 可修复目录/数据库/WAL文件权限+写入测试
- sync.php: 注册fix_db_permissions action
2026-06-28 10:45:33 +08:00
ypsdf1 ca212233a3 feat: 用户组PHP→Java变更通知
- handleUpdateUserGroup: 更新后写入web_admin_changes(group_change)
- handleDeleteUserGroup: 删除后写入web_admin_changes(group_change)
- Java端pollAdminChanges收到group_change后自动重载用户组配置
2026-06-28 00:16:25 +08:00
ypsdf1 a5a2ef733c feat(admin): 添加用户组管理页面
- 侧边栏新增「👥 用户组」管理入口
- 支持新建/编辑/删除用户组(组ID、显示名、颜色、优先级)
- 支持配置用户组独立领地单价和最大领地数
- 支持编辑用户组默认权限(38项权限复选框)
- 支持管理用户组成员(添加/移除玩家)
- API认证列表补充用户组相关action
2026-06-27 23:50:20 +08:00
ypsdf1 5f43fb1407 feat: 用户组同步+管理API (sync_user_groups, land_api endpoints) 2026-06-27 23:05:27 +08:00
ypsdf1 c0672ad47a feat: 公共建筑is_public_building同步字段 2026-06-27 22:44:50 +08:00
ypsdf1 63251c6033 fix: ack_admin_changes GET/POST兼容 2026-06-27 22:04:01 +08:00
ypsdf1 742578a107 修复PHP用户端两个bug:权限配置空白+glassPrompt未定义
1. 移到try-catch之前,修复handleGetMemberPerms调用时变量未定义导致perm_types返回null
2. player.php新增glassPrompt HTML元素(label/input/hint)和JS函数
3. glassAlertResolve支持glassPrompt返回输入框值
2026-06-27 21:43:00 +08:00
ypsdf1 c8c8db6220 修复IP归属地查询卡死bug:超时控制+前端AbortController
1. doQueryIpLocation增加单IP总超时5秒(防止4个API依次超时卡死)
2. 单API超时从3-5秒降至2-3秒
3. 批次从3个减为2个(最坏10秒,安全于30秒PHP限制)
4. 前端fetch增加AbortController 15秒超时
5. fetchIpApiWithTimeout默认超时改为3秒
2026-06-27 20:31:53 +08:00
ypsdf1 fc42b777ca 修复配置值前导零bug:handleUpdateConfig/syncConfig去除前导0
- handleUpdateConfig: (string)(int)去前导零
- syncConfig: 同样处理Java推送的配置值
2026-06-27 20:15:39 +08:00
ypsdf1 48ad7035ea 修复PHP→Java配置同步:handleUpdateConfig写入变更队列
- PHP修改配置后写入 web_admin_changes (config_change)
- Java pollAdminChanges 轮询拉取并应用到本地 area_config
2026-06-27 19:58:05 +08:00
ypsdf1 b6db8e56d5 权限变更同步:handleUpdateMemberPerm写入web_admin_changes
PHP端玩家修改成员权限后,写入变更队列,Java端pollAdminChanges可拉取同步回本地DB

修复:玩家端修改权限后Java不感知,下次推送时覆盖玩家修改
2026-06-27 16:55:22 +08:00
ypsdf1 bbd0049602 修复领地系统3个bug:
1. JSON解析崩溃:addConvertEffectsForFrontend将Java自定义格式转JSON数组
   - give_effects: 夜视:1:99999|SPEED:2:60 → [[夜视,1,99999],[SPEED,2,60]]
   - clear_effects: POISON,WITHER → [POISON,WITHER]
2. admin.php配置key名修复:land_price_per_sqm→create_price_per_sqm, max_lands→max_lands_per_player
3. handleUpdateLandField存储时将JSON数组转回Java格式
2026-06-27 14:52:34 +08:00
ypsdf1 b8dd9b7cd0 修复sync_lands报错:SQLITE3_DOUBLE改为SQLITE3_FLOAT 2026-06-27 14:14:20 +08:00
ypsdf1 5c47dd44fd 修复sync_lands报错:补全enable_announce等缺失列
问题:Java推送全部列,PHP表缺少enable_announce/announce_template/txt_content/deny_fluid/clear_all_bad → INSERT报错
修复:initLandTables添加5个ALTER TABLE迁移 + handleSyncLands INSERT补全对应列和bindValue
2026-06-27 13:54:51 +08:00
ypsdf1 967feb9b94 修复领地设置不同步:PHP端完整接收所有字段
问题1:handleSyncLands只存13个基础字段,30+权限字段全部丢弃
修复:INSERT扩展到63列,完整接收Java推送的所有deny_*、效果、传送点字段

问题2:web_area_lands表缺少权限列
修复:initLandTables添加46个ALTER TABLE迁移,兼容已有表

问题3:validateWebAccess检查窗口不匹配
修复:web_login_verified检查改用token实际有效期(非硬编码5分钟)
2026-06-27 13:37:29 +08:00
ypsdf1 9905f28886 新增sync_config配置同步端点 2026-06-27 12:27:30 +08:00
ypsdf1 3dfa7df18c 修复sync.php列值不匹配导致领地同步失败 2026-06-26 22:56:07 +08:00
ypsdf1 8de90708dc 修复:syncLands INSERT列值数量不匹配(deny_all_effects重复)+INSERT错误处理 2026-06-26 22:11:36 +08:00
ypsdf1 aa45bd5ca2 修复领地同步: 补全缺失字段迁移(deny_block_break等30+列)、修复重复deny_all_effects列、添加调试日志 2026-06-26 21:25:40 +08:00
ypsdf1 57fbc1ffc1 fix: Web自动登录安全+领地同步
1. validateWebAccess的view操作增加web_login_verified检查(5分钟有效)
2. pushPlayerLoginStatus在online=1时同步写入web_login_verified
3. 防止偷token自动登录: 未通过Java验证的token要求密码
2026-06-26 19:18:40 +08:00
ypsdf1 3ad96b1080 修复多个问题:库存无限+CDK逻辑简化
问题2修复: 库存无限商品数量限制
- player.php: 库存无限时最大数量从64改为999
- shop.php: 移除硬编码64上限,只检查stock>0时的库存

问题3修复: CDK兑换逻辑简化
- cdk.php: PHP不再更新bond_cache,只记录pending交易
- Java端负责拉取交易并处理加债券
- 返回给前端的balance_after改为当前余额(不预加)
2026-06-26 10:59:58 +08:00
ypsdf1 9eb0707729 问题10修复: CDK兑换不到账+假密码+隐藏密码框
player.php:
- 商城购买:总价≤1000时隐藏密码输入框,动态切换
- CDK兑换:完全移除密码输入框(CDK免验证)
- doBuy:小额交易不强制密码,大额才要求
- doCDKExchange:移除密码字段

shop.php:
- 移除硬性密码检查,交给validateWebAccess处理

cdk.php:
- 远程CDK兑换:改为cdk_redeem+pending(之前completed被Java跳过)
- 远程CDK兑换同时更新bond_cache+sync_requests
2026-06-26 10:21:49 +08:00
ypsdf1 cd86441a7a v21: PHP端增加Web登录全链路日志+验证状态同步
- check_web_login_confirmations: 返回php_verified字段(查询web_login_verified表供Java判断)
- webLoginRequest: 增加debugLog记录新请求创建
- completeWebLoginRequest: debugLog增加player和wrote_confirmations字段
- checkPendingWebLogins: 增加debugLog记录轮询结果
2026-06-25 19:36:05 +08:00
ypsdf1 cd6010bfbb v13: 安全加固 - 禁止PHP自验证密码
安全修复:
- sync.php: verifyWebPassword()函数体改为error(),完全禁止PHP直接验证密码
- 所有密码验证必须走web_login_request → Java插件本地验证 → complete_web_login_request回调
2026-06-25 14:42:01 +08:00
ypsdf1 8edc94b848 v12: PHP密码验证安全修复 + land_api GET参数支持
安全修复:
- sync.php: 删除verify_web_password端点,禁止PHP自验证密码
- core.php: validateWebAccess大额交易密码验证改为检查web_login_verified(Java验证结果)
- 密码验证强制走web_login_request流程

其他:
- land_api.php: handleAckAdminChanges支持GET参数ids(避免POST Content-Type问题)
2026-06-25 14:19:08 +08:00
ypsdf1 71777e3013 修复poll_admin_changes返回JSON包含Unicode转义序列 2026-06-24 21:36:18 +08:00
ypsdf1 06854054ca Fix land_api.php: add land_id to web_land_owner_changes INSERT, add perm_change sync for visitor perms 2026-06-24 13:09:47 +08:00
ypsdf1 02d2d95c9a fix: PHP登录安全修复 - doPasswordAuth改用web_login_request走Java验证 2026-06-24 01:26:48 +08:00
ypsdf1 d12ceaa44a feat: 成员独立权限管理 + PHP→Java变更同步 2026-06-23 23:48:19 +08:00
ypsdf1 98f9cb6410 @
领地系统大重构:全局配置+数据入库+新权限+传送点+安全删除+容器扩展

- 全局配置改为area_config表(替代txt文件)
- area_lands扩展到61列,新增10个权限布尔+5个传送点坐标
- 新增投掷物/发光/红石/门禁/音频/拴绳/农作物/剪毛/投喂等权限
- /protect settp/tp传送点系统
- 删除1分钟延迟+二次确认
- 安全传送判定(空气+实体+边界3格)
- 容器权限覆盖所有UI类型
- .gitignore清理敏感文件
@
2026-06-23 16:51:30 +08:00
ypsdf1 b50bfd4379 修复PHP定时器bug:打开弹窗时隐藏C位按钮,定时器忽略glassLoginDismissed,关闭弹窗时重新显示C位按钮 2026-06-22 23:44:32 +08:00
ypsdf1 15133de0f9 fix: admin.php卡片错位 - sidebar多了一个</div>导致content溢出flex容器
- lands菜单项的</div></div>多了一个闭合标签
- 导致.sidebar提前关闭,.main也关闭
- content div被推到flex容器外面
- 所有卡片跑到页面底部
- 移除多余</div>,恢复正确布局
2026-06-22 23:11:10 +08:00
ypsdf1 ed74d09b6a feat: 游客模式毛玻璃登录弹窗 + C位登录按钮 + 60秒定时弹窗
- admin.php: 修复go()缺失reset_requests路由(黑屏bug)
- player.php: 毛玻璃登录弹窗(backdrop-filter:blur)
- player.php: 游客模式C位大按钮(脉冲动画)
- player.php: 60秒定时弹窗(未登录自动弹出)
- sync.php: 添加领地同步接口(sync_lands/sync_land_shop)
- land_api.php: 领地管理API(新建)
2026-06-22 22:56:06 +08:00
ypsdf1 66cc7b8925 fix: 修复Web轮询500错误 — 移除只读函数的BEGIN IMMEDIATE、catch(Exception)→catch(Throwable)、增加错误日志 2026-06-22 18:54:46 +08:00
ypsdf1 4c7ec7490f fix: 修复库存不同步bug(item_id字段名不匹配); 新增库存高频轮询; 新增工单系统; 修复admin_stock竞态条件; 修复opcache_reset竞态 2026-06-22 16:54:13 +08:00
ypsdf1 d7a986894c CDK兑换修复:maxlength扩展+日期搜索+商品分类+同IP折叠+数据库锁修复 2026-06-20 14:02:41 +08:00
ypsdf1 885e70f0f5 添加Web前端代码 2026-06-19 16:02:14 +08:00
ypsdf1 9bae160ce0 Java:新增ip推送端口,后端可以查看玩家ip及归属
php:新增ip属地、实时在线数据,可以查看玩家ip、归属和实时在线情况
2026-06-18 12:35:22 +08:00
ypsdf1 0c9a5b0f53 feat: Web后台功能增强 - 即时通知机制、移动端适配、在线用户管理、主题切换 2026-06-14 17:02:39 +08:00
ypsdf1 54cd3c4f59 feat: 初始提交 Web 管理后台源码 (PHP) 2026-06-14 16:35:07 +08:00
324 changed files with 55390 additions and 174529 deletions
-5
View File
@@ -1,5 +0,0 @@
*.md text=auto encoding=UTF-8
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
-32
View File
@@ -1,32 +0,0 @@
---
name: Bug 反馈
about: 报告一个 Bug 以帮助改进插件
title: "[Bug] "
labels: bug
assignees: ''
---
## Bug 描述
请简明扼要地描述 Bug 是什么。
## 复现步骤
1. 进入 ...
2. 点击 ...
3. 出现错误 ...
## 期望行为
描述你期望发生的事情。
## 实际行为
描述实际发生了什么。
## 服务端信息
- 服务器类型和版本(如 Paper 1.21.4):
- 插件版本:
## 截图 / 日志
如有相关截图或报错日志,请粘贴在此处。
## 其他信息
补充任何你认为有助于解决问题的信息。
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: 问题排查指南
url: https://github.com/ypsdf1/sdf1_login
about: 在提交 Bug 前,请先查阅文档确认是否已有相关说明。
@@ -1,20 +0,0 @@
---
name: 功能新增
about: 提出一个新功能的需求建议
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 功能描述
请描述你希望添加的新功能。
## 动机
为什么需要这个功能?它能解决什么问题?
## 建议的实现方式
如果对实现方式有想法,请在此描述。
## 额外信息
补充任何你认为有助于理解需求的上下文。
-17
View File
@@ -1,17 +0,0 @@
---
name: 建议提交
about: 对插件提出改进建议或反馈
title: "[Suggestion] "
labels: suggestion
assignees: ''
---
## 建议内容
请描述你的建议或想法。
## 背景
什么场景下产生的这个建议?
## 预期效果
你认为采用这个建议后能达到什么效果?
+60 -43
View File
@@ -1,46 +1,63 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
*.bak*
*.bak2
*.clean_lock
*.db
*.db-journal
*.db-wal
*.db-shm
upload_debug.php
upload_via_http.php
upload_debug.ps1
deploy_*.py
deploy_*.ps1
deploy_*.sh
# 密钥文件
密钥.md
pay_secrets.php
# pay_poller.php 硬编码平台 MySQL 账密 + MD5 密钥(2026-10-02 摘出仓库,同 pay_secrets 处理:
# 本地留一份占位符模板供 release 参考,真实值只在服务器;deploy_php_only.py 永不上传它)
api/pay_poller.php
api/pay_poller.php.orig_backup
debug_admin_api.php
debug_online.php
debug_region.php
debug_region_search.php
self_update.php
version_check.php
test_sync.php
db/debug_ip_v2.php
api/test_db.php
api/diag_db_content.php
robots.json
# captcha_keys.php 已于 2026-10-02 移出忽略名单:仓库里放的是「空模板」(密钥全部清空 + 填写说明),
# 真实密钥只存在服务器上,且 deploy_php_only.py 的 EXCLUDE_PATTERNS 永不上传它,避免覆盖线上真值。
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
# 开发辅助文档(本地保留,不推送到仓库)
/代码关联关系.md
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
# ===== 支付密钥 - 严禁提交 (double insurance) =====
# 彩虹易支付 RSA 私钥/公钥/商户ID 全部在此文件,一旦进 git 或进公网 web 根目录即泄露
密钥文件_git拉黑.txt
pay_secrets.php
*.paykey
# 屏蔽无用的db文件夹(仓库不需要db文件)
db/
/*.rar
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
# 开发辅助文件(本地保留,不推送到仓库)
txt/
*.xlsx
*.patch
新建*.txt
群戏归类.txt
# 屏蔽所有md文件、python部署脚本
*.md
*.py
# 例外:readme、pay.md和sql.md
!readme.md
!pay.md
!sql.md
# 屏蔽所有的python脚本
/*.py
# 屏蔽备份文件
/*.*.back.*
# 屏蔽所有的测试文件
/*_*.*
/_test*/*.*
# ===== .workbuddy/ 工作目录(用户保留区)=====
# 存放测试脚本 / 探针 / 测试数据 / 备份文件,已被 git拉黑(2026-10-09)
.workbuddy/
-10
View File
@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-7
View File
@@ -1,7 +0,0 @@
<?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>
-14
View File
@@ -1,14 +0,0 @@
<?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_25" default="true" project-jdk-name="25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
-569
View File
@@ -1,569 +0,0 @@
# Sdf1系列登录插件使用说明
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态 |
| :--- | :--- | :--- |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
| 新人礼包 | 新玩家7日礼包 | 已完成 |
| 挂机检测 | 挂机检测踢出 | 已完成 |
| 自定义菜单 | 可以自己diy指令 | 已完成 |
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工 |
| 商城系统| 内置游戏商人可供销售商品 | 已完工 |
| 虚拟货币 | 供签到、游戏商人使用的货币系统 | 已完工 |
## 2.安装说明:
| 前置插件 | 选装类型 | 功能作用 |
| :--- | :--- | :--- |
| [CY_beibao](https://github.com/ypsdf1/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量
| EssentialsX | 可选 | 主流经济系统
| [sdf1](https://github.com/ypsdf1/sdf1_plugn) | 可选,推荐 | CDK兑换系统
### 2.1 下载
您可以从GitHub和Gitee下载到插件,将其丢入服务器plugin目录,重启服务器即可。
### 2.2. 配置文件
#### 2.2.1 消息.txt
此文件控制着整个插件的所有提示消息,您可以根据自己的需求修改,修改后需要执行 /sdf1_login reload 重载插件。支持[颜色代码](https://zh.minecraft.wiki/w/%E6%A0%BC%E5%BC%8F%E5%8C%96%E4%BB%A3%E7%A0%81)<br>
同时兼容`<b> </b>`标签,对段落加粗,`\n 和 <br>`进行段落换行
```txt
# ==========================================
# Sdf1_login 消息配置文件
# ==========================================
# 颜色代码使用 § 符号
# §0黑色 §1深蓝 §2深绿 §3深青
# §4深红 §5深紫 §6金色 §7灰色
# §8深灰 §9蓝色 §a绿色 §b浅蓝
# §c红色 §d粉色 §e黄色 §f白色
# §l粗体 §n下划线 §o斜体 §r重置
#
# 占位符用 {xxx} 表示,运行时自动替换
# 支持 <b>粗体</b> 标签
# 支持 \n 或 <br> 换行
# 修改后执行 /sdf1_login reload 重载
# ==========================================
# ---- 登录注册相关 ----
# login_timeout: 登录超时被踢出时的提示
login_timeout=§c登录超时,请重新登录
# not_registered: 未注册玩家收到的提示
not_registered=§e您尚未注册,请使用 /reg <密码>
# not_logged_in: 已注册但未登录的提示
not_logged_in=§e您尚未登录,请使用 /login <密码>
# already_registered: 已注册玩家再次注册时的提示
already_registered=§c您已注册过账号
# already_logged_in: 已登录玩家再次登录时的提示
already_logged_in=§c您已登录
# reg_success: 注册成功提示
reg_success=§a注册成功!欢迎来到服务器
# login_success: 登录成功提示
login_success=§a登录成功!
# login_failed: 密码错误提示
login_failed=§c密码错误,请重试
# ---- 密码相关 ----
# password_wrong: 旧密码验证失败
password_wrong=§c密码错误
# password_changed: 密码修改成功
password_changed=§a密码修改成功!
# password_format_error: 密码格式不符合要求
password_format_error=§c密码需6位以上,含大小写字母和数字
# password_same: 新密码与旧密码相同
password_same=§c新密码不能与旧密码相同
# reset_no_email: 找回密码时未绑定邮箱
reset_no_email=§c您未绑定邮箱,无法找回密码
# reset_sent: 临时密码已发送到邮箱
reset_sent=§a临时密码已发送到您的邮箱,请查收
# ---- 签到积分相关 ----
# checkin_already: 今日已签到
checkin_already=§c您今日已签到
# checkin_success: 签到成功,{points}=获得积分 {streak}=连续天数 {multi}=倍率
checkin_success=§a签到成功!获得 {points} 积分,连续 {streak} 天,倍率 x{multi}
# points_insufficient: 积分不足
points_insufficient=§c积分不足,当前: {points}
# points_purchase_success: 积分购买成功
points_purchase_success=§a购买成功!消耗 {count} 积分
# ---- 挂机检测相关 ----
# afk_set_enabled: 挂机检测开启
afk_set_enabled=§a挂机检测已开启
# afk_set_disabled: 挂机检测关闭
afk_set_disabled=§c挂机检测已关闭
# afk_kick: 因挂机被踢出
afk_kick=§c因挂机过久被踢出
# ---- 邮箱相关 ----
# email_set: 邮箱设置成功
email_set=§a邮箱设置成功:{email}
# email_format_error: 邮箱格式错误
email_format_error=§c邮箱格式不正确
# ---- 邀请相关 ----
# invite_code_generated: 邀请码生成
invite_code_generated=§a邀请码:{code}
# invite_used: 使用邀请码成功
invite_used=§a邀请成功,邀请人:{player}
# invite_self: 使用自己的邀请码
invite_self=§c不能使用自己的邀请码
# invite_not_found: 邀请码不存在
invite_not_found=§c邀请码不存在
# invite_referral_bonus: 被邀请人签到后邀请人获得奖励
invite_referral_bonus=§a邀请奖励!获得 {points} 积分
# ---- 新人礼包相关 ----
# gift_not_ready: 礼包条件未满足
gift_not_ready=§c礼包条件未满足
# gift_claimed: 礼包领取成功
gift_claimed=§a礼包领取成功!
# ---- 审批与IP限制 ----
# need_approval: 注册需审批
need_approval=§e注册需审批 (工单#{id})
# ip_max_accounts: IP注册已达上限
ip_max_accounts=§cIP注册已达上限
# ---- 管理员操作 ----
# admin_delete_confirm: 管理员删除账号确认提示
admin_delete_confirm=§c请在聊天中输入玩家名确认删除
# ---- 聊天过滤 ----
# chat_muted: 禁言玩家尝试聊天
chat_muted=§c§l[Sdf1_chat] §f你已被禁言,无法聊天
# chat_url_blocked: 发送违规链接被拦截
# {url}=检测到的链接地址
chat_url_blocked=§c§l[Sdf1_chat] §f检测到违规第三方链接: §e{url}
# chat_url_violation: 当前违规次数
# {count}=当前违规次数
chat_url_violation=§7当前违规次数: §e{count}
# chat_url_admin_notify: 通知管理员,{player}=玩家名 {url}=链接 {count}=次数
chat_url_admin_notify=§e§l[Sdf1_chat] §f{player} 发送违规链接: {url} (第{count}次)
# chat_url_broadcast: 全服通报,{player}=玩家名
chat_url_broadcast=§c§l[Sdf1_chat] §e{player} §f因发送违规链接被处罚
# ---- 通用 ----
# 未知参数: 未知命令或权限不足时的提示
未知参数=§c未知参数或权限不足
# only_player: 仅玩家可用的命令
# only_player=§c此命令仅玩家可用
```
### 2.2.2 挂机白名单.txt
当挂机踢出开启时,该配置下的玩家不受挂机提出影响
### 2.2.3 SMTP设置.txt
此文件控制着SMTP邮件服务器的配置,用于找回密码时发送邮件。<br>可通过GUI进行配置,也可以手动修改此配置文件。修改后,重载插件即可生效
```txt
# ===== SMTP 邮件服务器配置 =====
smtp地址=smtp.example.com
smtp端口=465
smtp账号=mail@example.com
smtp密码=example.com
发件人名称=Sdf1_login
smtp加密=true
验证码接收邮箱=example@example.com
```
### 2.2.4 插件设置.txt
此配置管控这整个插件,包括玩家能注册多少个账号、默认的管理员面板密码多少、tag包含什么视为管理员等
```txt
# ===== Sdf1_login 插件设置 =====
管理标签=admin
管理密码=qweasd
每IP最大账号数=3
审批模式=manual
自动审批延迟分钟=30
登录超时秒数=60
挂机踢出=false
挂机超时秒数=300
服务商积分倍率=2.0
基础经济奖励=100
经济奖励每评分=50.0
报单人积分奖励=10
# ---- 签到盲盒奖励 ----
签到固定金额=0
签到最小金额=10
签到最大金额=100
签到给积分=true
签到积分数量=10
```
### 2.2.5 chat.txt
该文件控制着聊天过滤,当玩家发送违规链接时,会自动根据配置规则进行处理。<br>
可用的处罚规则:
- warn: 警告
- mute: 禁言
- kick: 踢出
- ban: 封禁
- banip:封禁IP
```txt
# Sdf1_chat 聊天过滤配置
通知管理员: true
全服通报: true
禁言时长: 300
白名单:
*.minecraft.net
baidu.com
github.com
处罚规则:
1:warn:0
2:warn:0
3:mute:300
5:mute:600
8:kick:0
10:ban:0
白名单玩家:
```
### 2.2.6 radio_config.txt
```txt
# ============ Sdf1 Radio Config ============
#
# resource-pack-url
# 填外部地址(HTTP或HTTPS均可)
# 例: https://example.com/radiopack.zip
# 留空 = 使用内置HTTP服务
#
# http-port
# 内置HTTP端口(仅resource-pack-url为空时生效)
#
# ============ Settings ===============
resource-pack-url=https://*.ypshidifu.cn/|*.mcserver.ccwu.cc
http-port=443
```
- radio_config这里可以使用通配符域名,当使用通配符时,系统随机生成url,拒绝过资源包的玩家,每次都会收到提示。并且自1.2版本起,拒绝资源包=放弃游玩服务器。如果您无需广播功能,可以不放ogg文件进入`./plugin/radio/ogg`。这样服务端不会自动生成资源包,也不会强制下载了
### 2.2.7 菜单.txt
- 自定义菜单配置文件
```json
# 服务器菜单
# 使用类json格式
// 支持使用://Java单行注释、#yml单行注释、/* */块注释,也支持<!--- --->的块注释
{
标题:主城传送
指令:/dom tp 主城区 # 这里如果是xxx.txt则表示进入二级目录
权限类型:玩家
图标:COMPASS
}
{
标题:随机传送
指令:/rtp
权限类型:玩家
图标:ENDER_PEARL
}
{
标题:擂台比武
指令:/dom tp PVP
权限类型:玩家
图标:netherite_spear
}
{
标题:领地操作
指令:/dom
权限类型:玩家
图标:paper
}
{
标题:逛商店
指令:/dom tp shop
权限类型:玩家
图标:honeycomb
}
{
标题:传送开关
指令:/tptoggle
权限类型:玩家
图标:redstone_torch
}
{
标题:返回上一个位置
指令:/back
权限类型:玩家
图标:red_bed
}
{
标题:前往小黑塔
指令:/dom tp 小黑塔
权限类型:玩家
图标:dragon_head
}
{
标题:云背包
指令:/cy
权限类型:玩家
图标:potato
}
{
标题:自杀
指令:/suicide
权限类型:玩家
图标:wooden_axe
}
{
标题:游戏商人
指令:/shop
权限类型:玩家
图标:shield
格子:28
}
```
### 2.2.8 商品配置
- 配置此处的文件,负责展示游戏内所销售的商品。<br>
- 一个文件名,对应一个商品分类,使用md文件格式
```
# 建筑材料
| ID | 品名 | 材质 | 购入价 | 售出价 | 库存 | 本小时销量 | 总销量 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| STONE | 石头 | STONE | 10 | -1 | -1 | 0 | 0 |
| COBBLESTONE | 圆石 | COBBLESTONE | 5 | -1 | -1 | 0 | 26 |
| OAK_PLANKS | 橡木木板 | OAK_PLANKS | 8 | 3 | -1 | 0 | 0 |
| BRICKS | 砖块 | BRICKS | 12 | -1 | -1 | 0 | 0 |
| GLASS | 玻璃 | GLASS | 15 | 6 | -1 | 0 | 0 |
| QUARTZ_BLOCK | 石英块 | QUARTZ_BLOCK | 20 | 8 | -1 | 0 | 0 |
| AMETHYST_BLOCK | 紫水晶 | AMETHYST_BLOCK | 10 | 0 | -1 | 0 | 999 |
| OCHRE_FROGLIGHT | 赭黄蛙鸣灯 | OCHRE_FROGLIGHT | 100 | 0 | -1 | 0 | 1000 |
```
## 2.3 商城部分介绍
1. 自助退款,买多买错,不用慌,一件退单很安心<br>
2. 购物车,买的多怕买错,先加购物车,后续统一结算<br>
3. 害怕订单找不到?一键查看历史订单<br>
![](img/shop4.png)
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
![](img/shop7.png)<br>
![](img/shop6.png)
5. 想查看玩家消费记录?小手一点,明明白白<br>
![](img/shop5.png)
## 2.4 打印流水部分介绍
1. 管理员怀疑某个玩家刷钱了,怎么办?/printer 玩家名,一检查流水,很安心。很多人都刷了怎么办?/printer不加参数,一键导出全服流水,查他个底朝天<br>
2. 忘记删除怎么办?插件每分钟自动检测,旧文件超过24小时,自动删
## 3. 指令参数及介绍
- Sdf1_login 主指令<br>
- Sdf1_login reload 重载配置文件<br>
- reg 注册账号<br>
- login 登录账号<br>
- l 登录账号<br>
- sdf1_login del 删除玩家账号<br>
- sdf1_login radio 停止播放广播<br>
- sdf1_login radio reload 重载广播并重新打包资源包<br>
- menu 开/关 是否开启菜单<br>
- printer 打印流水数据,支持不带参数&带玩家名为参数<br>
- sdf1_login printer 作用同printer<br>
- sdf1_login shop 商城
- protect 区域防护
## 4. 自定义菜单物品
- 打开主界面,选择"我的"进入<br>
![](img/menu1.png)
- 将你的自定义物品与雪球交换<br>
![](img/menu2.png)
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
## 5. pvp战神榜
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
![](img/pvp1.png)
- 并且有着网游同款的击杀通报<br>
![](img/pvp3.png)
- 播报逻辑是:<br>
- 3杀<br>
- 5杀<br>
- 5杀以上每杀一次都是一条播报<br>
- 终结者通报,触发连杀的玩家被终结也会有全服通报
## 6. 区域防护
- 通过此功能,您可在服务器里需要防护的地方(比如展示框不想被玩家随便破坏、主城不准玩家袭击等)加以防护。<br>
- 还能够保护指定区域的玩家不受任何伤害。<br>
| 指令 | 参数1| 参数2 | 参数3 | 结果 |
| :---: | :---: | :---: | :---: | :---: |
| protect | 无 | 无 | 无 | 帮助信息 |
| protect | add | 区域名 | 玩家名 | 加白指定玩家到区域 |
| protect | add | 玩家名 | 无| 加白指定玩家为全局 |
| protect | additem | 区域名 | 物品名 | 加黑指定物品到区域 |
| protect | additem | 物品名 | 无 | 加黑指定物品到全局 |
| protect | addname | 区域名 | 名字 | 加白指定生物名字到区域和平模式白名单 |
| protect | addplayer | 区域名 | 玩家名字 | 加白指定玩家到区域强制模式豁免名单 |
| protect | remove | 玩家名字 | 无 | 移除玩家为全白 |
| protect | remove | 区域名 | 玩家名 | 移除玩家为区白 |
| protect | removeitem | 物品名 | 无 | 删除指定物品为全黑
| protect | removeitem | 区域名 | 物品名 | 删除指定物品为区黑
| protect | removename | 区域名 | 名字 | 删除指定名字为区域和平模式白名单 |
| protect | removeplayer | 区域名 | 名字 | 删除指定玩家为强制模式豁免名单 |
| protect | 参数不分顺序 | 区名可以写这里也可以写后面 | 玩家名可以写这里也可以写前面 | 完美的宝宝化 |
示范:
- `/protect add 末地保护区 players` 添加players玩家到末地保护区<br>
- `/protect removeitem 主城区 TNT` 从主城删了TNT这个黑名单物品<br>
- `/protect 筑城区 addwhite zhangsan` 把zhangsan加白到主城区的模式豁免名单。<br>
- 所有参数`任意排序`,`区域名字`随便填写,强大的`兜底`会自动识别"宝宝画板",你可以放心、大胆的乱写
| 参数 | 效果 |
| :---: | :---: |
| 和平模式 | 所有敌对生物全部进虚空 |
| 禁止袭击 | 立即清理玩家身上的袭击效果,传灾厄村民进虚空 |
| 清除XX效果 | 立即清除玩家身上的效果
| 给与xx效果 | 根据配置文件,给予玩家指定等级、时长的效果
## 生物属性对照表
| 中文名称 | 类型 | 附加属性 |
| :---: | :----: | :----: |
| 溺尸 | 近战/远程 | 亡灵、水生 |
| 骷髅 | 远程 | 亡灵 |
| 流浪者 | 远程 | 亡灵、寒冷群系变种 |
| 沼骸 | 远程 | 亡灵、沼泽变种 |
| 焦骸 | 远程 | 亡灵、沙漠变种、免疫阳光 |
| 掠夺者 | 远程 | 灾厄村民、袭击成员 |
| 卫道士 | 近战 | 灾厄村民、袭击成员 |
| 幻术师 | 远程 | 灾厄村民、Java专属、隐身分身 |
| 旋风人 | 远程 | 试炼密室生成、免疫弹射物、强击退 |
| 监守者 | 近战/声波远程 | 深暗之域、附带盲视效果 |
| 远古守卫者 | 近战/激光远程 | 海底神殿、水下、附加挖掘疲劳 |
| 守卫者 | 近战/激光远程 | 海底神殿、水下 |
配置文件解析:<br>
- 支持注释格式:`Java的//单行注释`、`yml的#单行注释`、`HTML的<!--- --->注释`和`Java/* */块注释`
```txt
# 区域: 主城区
起点: world,-130,-713
终点: world,-427,-560
高度范围: 0-255
没收物品: 蘑菇,毒蘑菇 #没收哪些物品(末地推荐这两个)
// 禁止放置方块 #保护区内,非区白/全白玩家禁止放置方块
// 禁止破坏方块 # 同上
禁止PVP # 玩家之间不能PVP
禁止摔伤 # 摔落无法造成任何伤害
禁止饥饿 # 不会消耗饱食度
禁止一切伤害 # 任何怪物都无法造成伤害
# 禁止丢弃物品
# 禁止末影珍珠
# 禁止使用弓箭
# 禁止骑乘
// 和平模式 // 所有的敌对生物全部传送进虚空
禁止袭击
禁止交互展示框 # 核心,任何玩家不能触碰展示框。区白/全白玩家除外
// 清除所有效果 # 清理所有负面效果
效果 村庄英雄 255 999999 # 给予玩家效果。格式为: 效果 效果名字 等级(最高255) 时间
效果 生命提升 255 999999
效果 抗性提升 255 999999
效果 抗火 255 999999
# 禁止药水
禁止爆炸
禁止使用物品: TNT
# 效果: 夜视 1 999
进入提示: 欢迎来到保护区 # 是否开启进入提示,即自定义提示
离开提示: 已离开保护区
没收提示: 你的违禁品已被没收
通报批评: §c§l{player} §f在{area}违规携带违禁品,被没收 §e{count}§f件! # 通报违禁品。{player} 玩家名 {count}数量 {area}区域
# 惩罚命令: ban {player} 60s 违规
```
## 运行截图
主界面
![](img/login.png)
邀请数据
![](img/login2.png)
积分商店
![](img/login3.png)
任务中心
![](img/login4.png)
垃圾箱
![](img/login5.png)
管理员面板
![](img/login6.png)
管理面板-用户管理
![](img/login7.png)
工单中心
![](img/login8.png)
工单管理
![](img/login9.png)
我的工单
![](img/login10.png)
提交工单
![](img/login11.png)
服务商列表
![](img/login12.png)
服务商抢单大厅
![](img/login13.png)
服务商-接单界面
![](img/login14.png)
管理员-工单详情页面
![](img/login15.png)
商城主界面
![](img/shop1.png)
商城购物车
![](img/cart1.png)
商城退单
![](img/shop2.png)
## 4. 下载地址
- GitHub:[https://github.com/ypsdf1/sdf1_login](https://github.com/ypsdf1/sdf1_login)
- Gitee:[https://gitee.com/nihaoshidifu/sdf1_login](https://gitee.com/nihaoshidifu/sdf1_login)
+146
View File
@@ -0,0 +1,146 @@
<?php
require_once __DIR__ . '/core.php';
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
if (session_status() === PHP_SESSION_NONE) session_start();
$isApi = (getParam('action') === 'data');
function getActivePlayers() {
$online = []; $count = 0;
try {
$db = getDB();
// 确保表存在
$db->exec("CREATE TABLE IF NOT EXISTS hourly_active_players (player_name TEXT PRIMARY KEY, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, hour_key TEXT NOT NULL)");
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
$currentHourKey = date('Y-m-d H');
// ★ 查询本小时活跃过的所有玩家(包括已离线但本小时曾上线的)
$res = $db->query("SELECT player_name, first_seen, last_seen FROM hourly_active_players WHERE hour_key = '" . SQLite3::escapeString($currentHourKey) . "' ORDER BY first_seen ASC");
$now = time();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
// 检查玩家当前是否在线(从online_players表判断)
$checkStmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :name");
$checkStmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
$checkRes = $checkStmt->execute();
$isOnline = $checkRes->fetchArray() !== false;
$online[] = [
'name' => $row['player_name'],
'login_time' => (int)$row['first_seen'],
'login_time_fmt' => $row['first_seen'] > 0 ? date('H:i:s', (int)$row['first_seen']) : '-',
'duration' => $row['first_seen'] > 0 ? $now - (int)$row['first_seen'] : 0,
'is_online' => $isOnline,
'last_seen' => (int)$row['last_seen'],
];
}
$count = count($online);
} catch (\Throwable $e) {
debugLog('[active_players] 查询异常: ' . $e->getMessage());
}
return ['online_list' => $online, 'online_count' => $count];
}
if ($isApi) {
success(getActivePlayers());
exit;
}
$initialState = json_encode(getActivePlayers(), JSON_UNESCAPED_UNICODE);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<title>SDF1 - 在线活跃</title>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
.header { background:linear-gradient(135deg,#1a1e2e,#0d1117); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; z-index:50; }
.header h1 { font-size:18px; color:var(--accent); }
.header .info { font-size:13px; color:var(--dim); }
.container { max-width:900px; margin:20px auto; padding:0 16px; }
/* 在线玩家卡片网格 */
.grid-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.grid-header h2 { font-size:15px; color:var(--accent); }
.grid-header .badge { background:var(--green); color:#000; padding:4px 12px; border-radius:20px; font-size:13px; font-weight:700; }
.player-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; }
.player-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px 16px; transition:border-color .2s; }
.player-card:hover { border-color:var(--accent); }
.player-card.offline { opacity:0.55; border-style:dashed; }
.player-card .name { font-size:15px; font-weight:700; color:var(--text); margin-bottom:4px; display:flex; align-items:center; gap:6px; }
.player-card .name .status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; }
.player-card .name .status-dot.on { background:var(--green); box-shadow:0 0 6px var(--green); }
.player-card .name .status-dot.off { background:var(--dim); }
.player-card .meta { font-size:12px; color:var(--dim); display:flex; justify-content:space-between; }
.player-card .meta .online-tag { color:var(--green); }
.player-card .meta .offline-tag { color:var(--yellow); }
.player-card .time-bar { margin-top:8px; height:4px; background:var(--bg); border-radius:4px; overflow:hidden; }
.player-card .time-bar .fill { height:100%; background:var(--green); border-radius:4px; transition:width 1s; }
.empty { grid-column:1/-1; text-align:center; padding:40px; color:var(--dim); }
.refresh-hint { text-align:center; font-size:12px; color:var(--dim); margin-top:16px; }
#loading { text-align:center; padding:40px; color:var(--dim); }
@media(max-width:500px) {
.player-grid { grid-template-columns:1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>🟢 本小时在线活跃</h1>
<div class="info">每10秒刷新</div>
</div>
<div class="container">
<div id="loading">加载中...</div>
<div id="content" style="display:none">
<div class="grid-header">
<h2>👤 当前在线玩家</h2>
<span class="badge" id="countBadge">0 人</span>
</div>
<div class="player-grid" id="playerGrid"></div>
<div class="refresh-hint" id="refreshHint"></div>
</div>
</div>
<script>
const state = <?= $initialState ?>;
function fmtDuration(sec) {
if (sec < 60) return sec + '秒';
if (sec < 3600) return Math.floor(sec/60) + '分' + (sec%60) + '秒';
return Math.floor(sec/3600) + '时' + Math.floor((sec%3600)/60) + '分';
}
function render(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
document.getElementById('countBadge').textContent = data.online_count + ' 人';
const grid = document.getElementById('playerGrid');
if (data.online_list.length === 0) {
grid.innerHTML = '<div class="empty">本小时暂无玩家活跃</div>';
} else {
// 在线玩家排前面,离线玩家排后面
const sorted = [...data.online_list].sort((a, b) => (b.is_online ? 1 : 0) - (a.is_online ? 1 : 0));
grid.innerHTML = sorted.map(p => {
const mx = 6 * 3600;
const pct = Math.min(100, Math.round(p.duration / mx * 100));
const cls = p.is_online ? 'player-card' : 'player-card offline';
const dot = p.is_online ? '<span class="status-dot on"></span>' : '<span class="status-dot off"></span>';
const tag = p.is_online
? `<span class="online-tag">在线 ${fmtDuration(p.duration)}</span>`
: `<span class="offline-tag">已离线</span>`;
return `<div class="${cls}"><div class="name">${dot}${esc(p.name)}</div><div class="meta"><span>首次:${p.login_time_fmt}</span>${tag}</div><div class="time-bar"><div class="fill" style="width:${pct}%;background:${p.is_online ? 'var(--green)' : 'var(--dim)'}"></div></div></div>`;
}).join('');
}
document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
}
function esc(s) { const d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
render(state);
setInterval(() => {
fetch('active_players.php?action=data&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) render(d.data);
}).catch(()=>{});
}, 10000);
</script>
</body>
</html>
+5058
View File
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* 第一层二次验证 —— 6 位动态码验证页
* 兼容 1Password / Microsoft Authenticator / Google Authenticator 等标准 RFC6238 认证器。
* 登录成功且 SEC_2FA_ENABLED=true 时,前端跳转到本页;验证通过后进入 admin.php。
*/
require_once __DIR__ . '/security.php';
secEnsureSession();
// IP 白名单闸门;boot 引导会话(配置流程中转)也放行
secGatePage(true);
// ★ boot 恢复模式(2026-10-05):持有 boot 凭据但 IP 不在白名单 → 直接送回引导配置页。
// 不加这一跳的话,下面的链路对非白名单 IP 全是 404 死胡同:
// 未登录 → 跳 admin_login.php → 404;已登录已过2FA → 跳 admin.php?token= → 404
// (线上 09:35:58 boot_unlock 后 1 秒就吃了 admin.php 的 ip_block,实录见 db/security.log)。
// 白名单校验本身没有被绕过 —— 本页对非白名单 IP 依旧不渲染,只是不再把人丢去 404。
if (!secIpAllowed() && secBootAuthenticate()) {
secBootRecoveryRedirect();
}
$enabled = defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED;
$err = '';
// 未启用 → 引导去配置页
if (!$enabled) {
header('Location: admin_2fa_setup.php');
exit;
}
// 未登录 / 旧纪元会话 → 回登录页
// (本页只依赖 security.php,不加载 core.php,故用等价内联判定)
if (empty($_SESSION['admin_auth']) || !secSessionEpochOk()) {
header('Location: admin_login.php');
exit;
}
// 已通过 → 直接进后台(★ 第三层:必须带上入口令牌,否则 admin.php 会返回 404)
if (!empty($_SESSION['admin_2fa_ok'])) {
header('Location: ' . secTokenUrl('admin.php'));
exit;
}
// ===== POST:校验动态码 =====
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$sid = session_id();
$lock = secThrottleLocked('twofa_fail', $sid, 5, 900);
if ($lock > 0) {
$err = '验证失败次数过多,已锁定,请 ' . $lock . ' 秒后再试';
secLog('2fa_throttled', 'lock=' . $lock . 's');
} else {
$code = isset($_POST['code']) ? (string)$_POST['code'] : '';
if (secTotpVerify(SEC_2FA_SECRET, $code)) {
$_SESSION['admin_2fa_ok'] = time();
$_SESSION['admin_2fa_ip'] = secClientIp();
secThrottleReset('twofa_fail', $sid);
secLog('2fa_pass', 'session granted');
header('Location: ' . secTokenUrl('admin.php'));
exit;
}
$left = secThrottleFail('twofa_fail', $sid, 5, 900);
// ★ 诊断:把"密钥录错/绑了旧密钥"和"设备时钟漂移"两种失败原因区分开,
// 否则用户只能看到一句"无效",无从下手。
$off = secTotpDiagnose(SEC_2FA_SECRET, $code);
secLog('2fa_fail', $left > 0 ? 'LOCKED_' . $left . 's'
: ($off === null ? 'wrong_secret' : 'clock_skew ' . $off . 's'));
$err = $left > 0
? '验证失败次数过多,已锁定,请 ' . $left . ' 秒后再试'
: secTotpDiagnoseMsg($off);
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDF1 - 二次验证</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117; color: #e6edf3;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
}
.box {
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
padding: 40px; width: 400px; max-width: 92%;
}
.box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 22px; }
.box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 24px; line-height: 1.7; }
.code-input {
width: 100%; padding: 14px; background: #0d1117; border: 1px solid #30363d;
border-radius: 8px; color: #e6edf3; font-size: 30px; letter-spacing: 10px;
text-align: center; outline: none; font-family: monospace;
}
.code-input:focus { border-color: #58a6ff; }
.btn {
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 16px;
}
.btn:hover { background: #79c0ff; }
.timer-bar { height: 4px; background: #30363d; border-radius: 2px; margin-top: 14px; overflow: hidden; }
.timer-bar .fill { height: 100%; background: #3fb950; width: 100%; transition: width .3s linear; }
.timer-text { font-size: 11px; color: #8b949e; text-align: center; margin-top: 6px; }
.error {
color: #f85149; font-size: 13px; text-align: center; margin-top: 14px;
background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.3);
border-radius: 6px; padding: 8px; display: block;
}
.links { text-align: center; margin-top: 18px; }
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
.ip { text-align: center; font-size: 11px; color: #8b949e; margin-top: 16px; }
.ip b { color: #d29922; font-family: monospace; }
</style>
</head>
<body>
<div class="box">
<h1>🛡️ 二次验证</h1>
<div class="sub">请输入认证器 App 中的 <b style="color:#e6edf3">6 位动态码</b><br>
(1Password / Microsoft Authenticator / Google Authenticator 均可)</div>
<form method="post" autocomplete="off">
<input class="code-input" id="code" name="code" inputmode="numeric" pattern="[0-9]*"
maxlength="6" placeholder="000000" autofocus>
<button class="btn" type="submit" id="submitBtn">验 证</button>
<div class="timer-bar"><div class="fill" id="tfill"></div></div>
<div class="timer-text" id="ttext">动态码每 30 秒刷新</div>
<div class="timer-text" id="srvtime" style="color:#58a6ff"></div>
<?php if ($err !== ''): ?>
<div class="error" style="white-space:pre-wrap;text-align:left"><?php echo htmlspecialchars($err, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</form>
<div class="links">
<a href="admin_login.php">← 返回登录</a> &nbsp;|&nbsp;
<a href="admin_2fa_setup.php">安全配置</a>
</div>
<div class="ip">当前访问 IP:<b><?php echo htmlspecialchars(secClientIp(), ENT_QUOTES, 'UTF-8'); ?></b></div>
<div class="ip" style="margin-top:4px">提示:认证器与上方<b>服务器时间</b>偏差超过 90 秒就会被拒</div>
</div>
<script>
(function() {
var input = document.getElementById('code');
input.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '').slice(0, 6);
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && this.value.length === 6) {
document.querySelector('form').submit();
}
});
// ★ 进度条/倒计时改用【服务器时钟】:
// 手机时间不准时,用浏览器自身时钟画进度条会误导用户(看着还剩 20 秒,
// 服务器那边其实已经翻到下一窗格了),所以以 PHP 渲染时刻为基准推进。
var srvBase = <?php echo time(); ?>; // 页面渲染时的服务器 Unix 时间
var srvOff = <?php echo (new DateTime('now'))->getOffset(); ?>; // 服务器时区偏移(秒)
var t0 = Date.now();
function srvNow() { return srvBase + Math.floor((Date.now() - t0) / 1000); }
function pad(n) { return (n < 10 ? '0' : '') + n; }
var fill = document.getElementById('tfill');
var text = document.getElementById('ttext');
var srvBox = document.getElementById('srvtime');
function tick() {
var s = srvNow();
var left = 30 - (s % 30);
fill.style.width = (left / 30 * 100) + '%';
var d = new Date((s + srvOff) * 1000);
text.textContent = left + ' 秒后动态码刷新';
srvBox.textContent = '服务器时间 ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds())
+ '(与你手机差多少秒请自行比对)';
}
tick();
setInterval(tick, 300);
})();
</script>
</body>
</html>
+2934
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
<?php
// ★ 紧急加固(2026-09-29):第二层 IP 白名单 —— 登录页本身也只对白名单 IP 开放
require_once __DIR__ . '/security.php';
secGatePage();
// ★ 首跑顺序(2026-10-09):装库先于安全配置 —— install.php 还没跑完就先去装库。
// 引导模式下 secGatePage 已先行 302;这里兜住的是「本机 127.0.0.1 直连」与
// 「已引导但装库没做过」两种入口。secTokenUrl 在令牌未配置时原样返回
// install.php,配了令牌则带上 ?token= 过第三层门禁(与 admin.php 同款拼法)。
if (secInstallPending()) {
header('Location: ' . secTokenUrl('install.php'));
exit;
}
// ★ 首次部署:管理密码还是分发占位符 → 登录页是个死胡同(要密码,可密码还不存在)。
// 改投初始化页,让部署者先把自己的第一个密码设出来。
if (!secAdminPassConfigured()) {
header('Location: admin_2fa_setup.php');
exit;
}
// ★ 人机验证(2026-10-02):cgWidgetHtml() 渲染挂件,未配密钥时自动放行
require_once __DIR__ . '/captcha_guard.php';
// ★ 数据安全护栏横幅(2026-10-10):库被清空/指错库时,管理员一登录就能看到告警
require_once __DIR__ . '/db_guard.php';
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDF1 - 管理后台登录</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117; color: #e6edf3;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh;
}
.login-box {
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
padding: 40px; width: 380px; max-width: 90%;
}
.login-box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 24px; }
.login-box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 28px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 14px; background: #0d1117; border: 1px solid #30363d;
border-radius: 6px; color: #e6edf3; font-size: 14px; outline: none; transition: border-color 0.2s;
}
.field input:focus { border-color: #58a6ff; }
.login-btn {
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
border-radius: 6px; font-size: 15px; font-weight: 600; cursor: pointer;
transition: background 0.2s; margin-top: 8px;
}
.login-btn:hover { background: #79c0ff; }
.login-btn:disabled { background: #30363d; cursor: not-allowed; }
.error { color: #f85149; font-size: 13px; text-align: center; margin-top: 12px; display: none; }
.links { text-align: center; margin-top: 20px; }
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
.links a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="login-box">
<h1>🔐 管理后台</h1>
<div class="sub">SDF1 插件管理系统</div>
<?php echo function_exists('dfGuardAdminBanner') ? dfGuardAdminBanner() : ''; ?>
<div class="field">
<label>管理密码</label>
<input type="password" id="password" placeholder="请输入管理密码" autofocus>
</div>
<?php echo cgWidgetHtml(); ?>
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="error" id="errorMsg"></div>
<div class="links"><a href="player.php">← 返回玩家商城</a></div>
</div>
<script>
document.getElementById('password').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
async function doLogin() {
const pw = document.getElementById('password').value;
if (!pw) return;
document.getElementById('loginBtn').disabled = true;
document.getElementById('loginBtn').textContent = '登录中...';
// ★ 人机验证(2026-10-02):CF 渲染完即有 token(基本无感);VA 没做会在这里拉起验证
let cap = {};
if (window.CaptchaGuard) {
try {
cap = await CaptchaGuard.payload();
} catch (e) {
document.getElementById('errorMsg').textContent = e.message || '请先完成人机验证';
document.getElementById('errorMsg').style.display = 'block';
document.getElementById('loginBtn').disabled = false;
document.getElementById('loginBtn').textContent = '登 录';
return;
}
}
try {
const res = await fetch('api/admin.php?action=login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign({password: pw}, cap))
});
const data = await res.json();
if (data.success) {
// ★ 第三层:登录接口在密码正确后才下发入口令牌,用来拼 admin.php?token=
const tk = (data.data && data.data.token) ? ('?token=' + encodeURIComponent(data.data.token)) : '';
// ★ 第一层:启用二次验证时先进 6 位动态码验证页,通过后再进后台
if (data.data && data.data.need_2fa) {
window.location.href = 'admin_2fa.php';
} else {
window.location.href = 'admin.php' + tk;
}
} else {
document.getElementById('errorMsg').textContent = data.message;
document.getElementById('errorMsg').style.display = 'block';
}
} catch (e) {
document.getElementById('errorMsg').textContent = '网络错误';
document.getElementById('errorMsg').style.display = 'block';
}
document.getElementById('loginBtn').disabled = false;
document.getElementById('loginBtn').textContent = '登 录';
}
</script>
</body>
</html>
+1142
View File
File diff suppressed because it is too large Load Diff
+2499
View File
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
<?php
/**
* 余额查询+充值API
* GET ?action=query - 查询余额(需token)
* POST ?action=recharge - 充值债券(需token)
* GET ?action=transactions - 查看流水(需token)
*/
// 防止任何输出污染JSON响应
ob_start();
require_once __DIR__ . '/../core.php';
ob_end_clean();
$action = getParam('action', 'query');
$token = getParam('token');
try {
switch ($action) {
case 'query':
balanceQuery($token);
break;
case 'recharge':
balanceRecharge($token);
break;
case 'transactions':
balanceTransactions($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== 查询余额 =====
function balanceQuery($token) {
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$player) error('缺少player');
// ★ 安全验证:必须验证token且token对应的玩家必须与查询的玩家匹配
$tokenInfo = null;
$queriedPlayer = $player; // 最终查询的玩家名
// 1. 检查是否为管理后台session
if (session_status() === PHP_SESSION_NONE) session_start();
$isAdmin = isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
if ($isAdmin) {
// 管理员:可以查任意玩家
$tokenInfo = ['player' => null, 'purpose' => 'admin'];
} elseif ($token) {
// 2. 验证token并获取token对应的玩家名
$tempTokenInfo = validateToken($token);
if ($tempTokenInfo) {
$tokenPlayer = $tempTokenInfo['player'];
$tokenPurpose = $tempTokenInfo['purpose'] ?? '';
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
// 管理员token:可以查任意玩家
$tokenInfo = $tempTokenInfo;
} elseif ($tokenPlayer === $player) {
// 普通token:只能查自己的余额
// 进一步验证weblogin token的访问权限
$accessResult = validateWebAccess($token, 'view', $password, $ipAddress);
if ($accessResult['ok']) {
$queriedPlayer = $accessResult['player'];
$tokenInfo = ['player' => $queriedPlayer, 'purpose' => 'weblogin'];
} elseif ($accessResult['mode'] === 'need_password') {
jsonResponse([
'success' => false,
'need_password' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} else {
error($accessResult['message'], 401);
}
} else {
error('无权查询其他玩家余额', 403);
}
} else {
error('无效token,请先登录', 401);
}
} else {
error('查询余额需要有效token,请先登录', 401);
}
// 查询债券余额(从Web端的债券缓存表)
$db = getDB();
// 先检查是否有缓存表
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
player_name TEXT PRIMARY KEY,
amount INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
$stmt = $db->prepare("SELECT amount, updated_at FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
// 同时查询积分
$stmt2 = $db->prepare("SELECT points FROM users WHERE player_name = :name");
$stmt2->bindValue(':name', $player, SQLITE3_TEXT);
$result2 = $stmt2->execute();
$row2 = $result2->fetchArray(SQLITE3_ASSOC);
$bonds = $row ? $row['amount'] : 0;
$points = $row2 ? $row2['points'] : 0;
$bondsUpdated = $row ? $row['updated_at'] : 0;
// 计算数据新鲜度
$freshness = '';
if ($bondsUpdated > 0) {
$age = time() - $bondsUpdated;
if ($age < 300) $freshness = '刚刚同步';
elseif ($age < 3600) $freshness = floor($age / 60) . '分钟前同步';
elseif ($age < 86400) $freshness = floor($age / 3600) . '小时前同步';
else $freshness = floor($age / 86400) . '天前同步(数据可能过期)';
} else {
$freshness = '尚未同步';
}
success([
'player' => $player,
'bonds' => $bonds,
'points' => $points,
'bonds_updated' => $bondsUpdated,
'freshness' => $freshness
]);
}
// ===== 充值债券 =====
function balanceRecharge($token) {
$player = getParam('player');
$amount = (int)getParam('amount', 0);
$operator = getParam('operator', 'web_admin');
if (!$player) error('缺少player');
if ($amount <= 0) error('充值金额必须大于0');
// 需要token
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo) {
$isPreview = false;
// 权限检查:只有管理员token才能充值
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
error('充值需要管理员权限');
}
validateAndUseToken($token);
}
}
$db = getDB();
// 确保缓存表存在
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
player_name TEXT PRIMARY KEY,
amount INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
// 查询当前余额
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$currentBalance = $row ? $row['amount'] : 0;
$newBalance = $currentBalance + $amount;
if ($isPreview) {
preview([
'player' => $player,
'amount' => $amount,
'balance_before' => $currentBalance,
'balance_after' => $newBalance,
'operator' => $operator
], '预览模式 - 充值不会生效');
}
// 执行充值
if ($row) {
$stmt = $db->prepare("UPDATE bond_cache SET amount = :amount, updated_at = :time WHERE player_name = :name");
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->execute();
} else {
$stmt = $db->prepare("INSERT INTO bond_cache (player_name, amount, updated_at) VALUES (:name, :amount, :time)");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
}
// 记录流水
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, status, created_at) VALUES (:player, 'admin_recharge', :amount, :reason, 'pending', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':reason', "管理员充值: {$operator}", SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
success([
'player' => $player,
'amount' => $amount,
'balance_before' => $currentBalance,
'balance_after' => $newBalance
], '充值成功');
}
// ===== 查看流水 =====
function balanceTransactions($token) {
$player = getParam('player');
$limit = (int)getParam('limit', 50);
// 需要token
$tokenInfo = null;
if ($token) {
$tokenInfo = validateToken($token);
}
if (!$tokenInfo) {
error('查看流水需要有效token', 401);
}
$db = getDB();
$txs = [];
if ($player) {
// 查看指定玩家流水
if ($tokenInfo['player'] !== $player && $tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
error('无权查看其他玩家流水');
}
// Web端交易
try {
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions WHERE player_name = :player ORDER BY created_at DESC LIMIT :limit");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
// 游戏内交易
try {
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions WHERE player_name = :player ORDER BY tx_time DESC LIMIT :limit");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
} else {
// 管理员查看全服流水
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
error('查看全服流水需要管理员权限');
}
// Web端交易
try {
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions ORDER BY created_at DESC LIMIT :limit");
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
// 游戏内交易
try {
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions ORDER BY tx_time DESC LIMIT :limit");
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
}
// 按时间排序(倒序)
usort($txs, function($a, $b) {
return ($b['created_at'] ?? 0) - ($a['created_at'] ?? 0);
});
$txs = array_slice($txs, 0, (int)$limit);
success($txs);
}
// ===== Weblogin Token验证(用于API调用) =====
function validateWebloginTokenForAPI($webToken) {
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (Exception $e) {
return false;
}
}
+320
View File
@@ -0,0 +1,320 @@
<?php
/**
* 收银台 API(独立页面后端)
* 支持:收银员登录/登出/会话、玩家查询、订单列表、收银员管理(仅管理员)
*/
// ★ 清除所有缓冲 + 发送 JSON Content-Type
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('memory_limit', '256M');
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
register_shutdown_function(function() {
while (ob_get_level() > 0) {
@ob_end_flush();
}
});
if (!extension_loaded('sqlite3')) {
exit(json_encode(['success' => false, 'message' => 'Missing SQLite3'], JSON_UNESCAPED_UNICODE));
}
require_once __DIR__ . '/../core.php';
if (!function_exists('error') || !function_exists('success') || !function_exists('getParam')) {
exit(json_encode(['success' => false, 'message' => 'core.php load failed'], JSON_UNESCAPED_UNICODE));
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$action = getParam('action', 'status');
try {
switch ($action) {
case 'login': cashierDoLogin(); break;
case 'logout': cashierDoLogout(); break;
case 'session': cashierSession(); break;
case 'player_check': cashierPlayerCheck(); break;
case 'order_list': cashierOrderList(); break;
case 'cashier_list': cashierList(); break;
case 'add': cashierAdd(); break;
case 'edit': cashierEdit(); break;
case 'delete': cashierDelete(); break;
default:
exit(json_encode(['success' => false, 'message' => '未知操作: ' . $action], JSON_UNESCAPED_UNICODE));
}
} catch (\Throwable $e) {
// ★ 2026-07-07 补强:顶层统一捕获 Throwable(含 Error),避免致命错误逃逸导致前端收到
// 非 JSON 响应(表现为“加载失败”却无具体原因)。同时落盘 debug.log 便于事后定位。
debugLog('[cashier.php] 顶层未捕获异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
exit(json_encode(['success' => false, 'message' => '服务器错误: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员登录 =====
function cashierDoLogin() {
$username = trim(getParam('username', ''));
$password = getParam('password', '');
if ($username === '' || $password === '') {
exit(json_encode(['success' => false, 'message' => '请输入账号和密码'], JSON_UNESCAPED_UNICODE));
}
if (cashierLogin($username, $password)) {
$c = getCurrentCashier();
exit(json_encode([
'success' => true,
'data' => [
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent'],
'can_cash' => (int)($c['can_cash'] ?? 0)
]
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => false, 'message' => '账号或密码错误'], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员登出 =====
function cashierDoLogout() {
cashierLogout();
exit(json_encode(['success' => true, 'message' => '已退出登录'], JSON_UNESCAPED_UNICODE));
}
// ===== 当前会话信息 =====
function cashierSession() {
if (isAdminLoggedIn()) {
exit(json_encode([
'success' => true,
'data' => [
'role' => 'admin',
'username' => 'admin',
'discount_limit_percent' => 100
]
], JSON_UNESCAPED_UNICODE));
}
if (isCashierLoggedIn()) {
$c = getCurrentCashier();
exit(json_encode([
'success' => true,
'data' => [
'role' => 'cashier',
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent'],
'can_cash' => (int)($c['can_cash'] ?? 0)
]
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => false, 'message' => '未登录'], JSON_UNESCAPED_UNICODE));
}
// ===== 查询目标玩家余额/在线状态(收银员或管理员均可)=====
function cashierPlayerCheck() {
requireCashierOrAdminSession();
$player = trim(getParam('player'));
if (!$player) exit(json_encode(['success' => false, 'message' => '请输入玩家名'], JSON_UNESCAPED_UNICODE));
if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $player)) {
exit(json_encode(['success' => false, 'message' => '玩家名格式不正确(3-16位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
}
$db = getDB();
try {
$db->exec("INSERT OR IGNORE INTO bond_cache (player_name, amount, updated_at) VALUES ('" . str_replace("'", "''", $player) . "', 0, " . time() . ")");
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
$balance = $row ? (int)$row['amount'] : 0;
$online = false;
try {
$ostmt = $db->prepare("SELECT player_name FROM online_players WHERE player_name = :name");
$ostmt->bindValue(':name', $player, SQLITE3_TEXT);
$orow = $ostmt->execute()->fetchArray(SQLITE3_ASSOC);
$online = $orow ? true : false;
} catch (\Throwable $e) {}
exit(json_encode([
'success' => true,
'data' => ['player' => $player, 'exists' => true, 'balance' => $balance, 'online' => $online]
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[cashier.php] Player check error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => '查询失败: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
}
// ===== 订单列表 =====
function cashierOrderList() {
$role = requireCashierOrAdminSession();
// 收银员只能看自己的订单
$c = null;
if ($role === 'cashier') {
$c = getCurrentCashier();
}
// ★ 尽早释放会话锁:查询期间不再占用 session 文件,避免阻塞同会话并发请求
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
// ★ 直接从独立 orders.db 读取:该库 Java 永不读写,彻底脱离 web.db 文件锁竞争,
// 因此不再需要 busy_timeout=15000 与 5 次重试兜底 hack —— 干净稳定的单次查询。
try {
$db = getOrdersDB();
} catch (\Throwable $e) {
exit(json_encode(['success' => false, 'message' => '订单库不可用: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
$limit = (int)getParam('limit', 100);
if ($limit <= 0 || $limit > 500) $limit = 100;
$sql = "SELECT * FROM cashier_orders";
$params = [];
if ($role === 'cashier' && $c) {
$sql .= " WHERE operator_name = :op";
$params[':op'] = $c['username'];
}
$sql .= " ORDER BY created_at DESC LIMIT " . (int)$limit;
$rows = [];
try {
$stmt = $db->prepare($sql);
if (!$stmt) {
exit(json_encode(['success' => false, 'message' => '查询失败: ' . $db->lastErrorMsg()], JSON_UNESCAPED_UNICODE));
}
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, SQLITE3_TEXT);
}
$result = $stmt->execute();
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
if (!empty($r['items_detail'])) {
$r['items_detail'] = json_decode($r['items_detail'], true) ?: [];
}
$rows[] = $r;
}
} catch (\Throwable $e) {
// ★ 2026-07-07 加固:任何查询异常都返回合法 JSON,绝不把 PHP 报错文本注入响应
// (否则前端 r.json() 失败,订单页表现为“加载失败/报错”)。
debugLog('[cashier.php] order_list 查询异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
debugLog('[cashier.php] order_list 异常堆栈: ' . $e->getTraceAsString());
exit(json_encode([
'success' => false,
'message' => '订单查询异常: ' . $e->getMessage()
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员列表(仅管理员)=====
function cashierList() {
requireAdminSession();
$db = getDB();
$result = $db->prepare("SELECT id, username, discount_limit_percent, can_cash, created_at, created_by FROM cashiers ORDER BY id ASC")->execute();
$rows = [];
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
$rows[] = $r;
}
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
}
// ===== 新增收银员(仅管理员)=====
function cashierAdd() {
requireAdminSession();
$username = trim(getParam('username', ''));
$password = getParam('password', '');
$discountLimit = (int)getParam('discount_limit_percent', 0);
$canCash = (int)getParam('can_cash', 0);
if ($username === '' || $password === '') {
exit(json_encode(['success' => false, 'message' => '账号和密码不能为空'], JSON_UNESCAPED_UNICODE));
}
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
exit(json_encode(['success' => false, 'message' => '账号格式不正确(3-20位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
}
if ($discountLimit < 0 || $discountLimit > 100) {
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
}
if ($canCash < 0 || $canCash > 1) {
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
}
$db = getDB();
// 查重
$chk = $db->prepare("SELECT id FROM cashiers WHERE username = :u");
$chk->bindValue(':u', $username, SQLITE3_TEXT);
if ($chk->execute()->fetchArray(SQLITE3_ASSOC)) {
exit(json_encode(['success' => false, 'message' => '账号已存在'], JSON_UNESCAPED_UNICODE));
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$salt = bin2hex(random_bytes(8));
$stmt = $db->prepare("INSERT INTO cashiers (username, password_hash, salt, discount_limit_percent, can_cash, created_at, created_by) VALUES (:u,:h,:s,:d,:c,:t,'admin')");
$stmt->bindValue(':u', $username, SQLITE3_TEXT);
$stmt->bindValue(':h', $hash, SQLITE3_TEXT);
$stmt->bindValue(':s', $salt, SQLITE3_TEXT);
$stmt->bindValue(':d', $discountLimit, SQLITE3_INTEGER);
$stmt->bindValue(':c', $canCash, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员创建成功'], JSON_UNESCAPED_UNICODE));
}
// ===== 编辑收银员(仅管理员):可改密码 / 折扣上限 =====
function cashierEdit() {
requireAdminSession();
$id = (int)getParam('id', 0);
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
$db = getDB();
$chk = $db->prepare("SELECT id FROM cashiers WHERE id = :id");
$chk->bindValue(':id', $id, SQLITE3_INTEGER);
if (!$chk->execute()->fetchArray(SQLITE3_ASSOC)) {
exit(json_encode(['success' => false, 'message' => '收银员不存在'], JSON_UNESCAPED_UNICODE));
}
$sets = [];
$params = [':id' => $id];
$newPassword = getParam('password', '');
if ($newPassword !== '') {
$sets[] = "password_hash = :h";
$params[':h'] = password_hash($newPassword, PASSWORD_DEFAULT);
$sets[] = "salt = :s";
$params[':s'] = bin2hex(random_bytes(8));
}
if (getParam('discount_limit_percent') !== null && getParam('discount_limit_percent') !== '') {
$dl = (int)getParam('discount_limit_percent', 0);
if ($dl < 0 || $dl > 100) {
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
}
$sets[] = "discount_limit_percent = :d";
$params[':d'] = $dl;
}
if (getParam('can_cash') !== null && getParam('can_cash') !== '') {
$cc = (int)getParam('can_cash', 0);
if ($cc < 0 || $cc > 1) {
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
}
$sets[] = "can_cash = :c";
$params[':c'] = $cc;
}
if (empty($sets)) {
exit(json_encode(['success' => false, 'message' => '没有可更新的字段'], JSON_UNESCAPED_UNICODE));
}
$sql = "UPDATE cashiers SET " . implode(', ', $sets) . " WHERE id = :id";
$stmt = $db->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, is_int($v) ? SQLITE3_INTEGER : SQLITE3_TEXT);
}
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员信息已更新'], JSON_UNESCAPED_UNICODE));
}
// ===== 删除收银员(仅管理员)=====
function cashierDelete() {
requireAdminSession();
$id = (int)getParam('id', 0);
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
$db = getDB();
$stmt = $db->prepare("DELETE FROM cashiers WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员已删除'], JSON_UNESCAPED_UNICODE));
}
+491
View File
@@ -0,0 +1,491 @@
<?php
/**
* CDK兑换API v2 - 20260620
* POST ?action=exchange - CDK兑换(需token,由插件请求)
* POST ?action=create - 创建CDK(需管理token)
* GET ?action=list - CDK列表(需管理token)
* POST ?action=delete - 删除CDK(需管理token)
* POST ?action=batch - 批量生成CDK(需管理token)
*/
// 防止任何输出污染JSON响应
while (ob_get_level() > 0) { ob_end_clean(); }
require_once __DIR__ . '/../core.php';
// 再次确保清理前置输出
while (ob_get_level() > 0) { ob_end_clean(); }
$action = getParam('action', 'exchange');
$token = getParam('token');
try {
switch ($action) {
case 'exchange':
cdkExchange($token);
break;
case 'create':
cdkCreate($token);
break;
case 'list':
cdkList($token);
break;
case 'delete':
cdkDelete($token);
break;
case 'batch':
cdkBatch($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== CDK兑换 =====
function cdkExchange($token) {
$code = getParam('code');
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$code) error('缺少code');
if (!$player) error('缺少player');
// ★ 双保险验证:CDK兑换需要密码确认
$isPreview = true;
$validated = false;
if ($token) {
// 先检查是否为普通管理token
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
$validated = true;
} else {
// weblogin token → 走双保险验证
$accessResult = validateWebAccess($token, 'cdk', $password, $ipAddress);
if ($accessResult['ok'] || $accessResult['mode'] === 'full_verified') {
$isPreview = false;
$validated = true;
$player = $accessResult['player'];
} elseif ($accessResult['mode'] === 'need_password') {
jsonResponse([
'success' => false,
'need_password' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} else {
error($accessResult['message'], 401);
}
}
}
// 如果没有token也不是管理token,返回预览
if (!$validated) {
$isPreview = true;
}
$db = getDB();
// ★ 第32批拉黑拦截:本地兑换前先查拉黑名单
// (web_player_blacklist 由 Java BlacklistManager 经 sync_blacklist 同步;
// 本地CDK存在也一律拒绝,与 Java CDKManager.redeem 的检查口径一致)
$blackReason = playerWebBlacklistReason($player);
if ($blackReason !== null) {
@error_log("[cdk] 拉黑拦截兑换: player={$player}");
error('你已被拉黑:' . $blackReason);
}
// 查询CDK
$stmt = $db->prepare("SELECT * FROM cdk WHERE code = :code");
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$result = $stmt->execute();
$cdk = $result->fetchArray(SQLITE3_ASSOC);
if (!$cdk) {
// ★ 本地没找到 → 写待验证请求,轮询Java Sdf1_login返回结果
$cdk = tryRemoteCdkValidation($db, $code, $player);
if (!$cdk) {
error('CDK不存在: ' . $code);
}
}
if ($cdk['used'] == 1) {
error('CDK已被使用: ' . $cdk['used_by']);
}
// 查询当前余额
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
player_name TEXT PRIMARY KEY,
amount INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$currentBalance = $row ? $row['amount'] : 0;
$newBalance = $currentBalance + $cdk['amount'];
if ($isPreview) {
preview([
'code' => $code,
'amount' => $cdk['amount'],
'type' => $cdk['type'],
'player' => $player,
'balance_before' => $currentBalance,
'balance_after' => $newBalance,
'valid' => true
], '预览模式 - CDK兑换不会生效');
}
// 执行兑换
$now = time();
$isRemote = !empty($cdk['_remote']);
// ★ 全部DB操作包装在事务中,防止database is locked
$db->exec('BEGIN IMMEDIATE');
try {
// 标记CDK已使用(仅本地CDK需要)
if (!$isRemote) {
$stmt = $db->prepare("UPDATE cdk SET used = 1, used_by = :player, used_at = :time WHERE code = :code");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$stmt->execute();
}
// ★ PHP只负责记录交易,不更新bond_cache(Java端负责加债券)
// 记录流水:远程CDK标记completed(Java在pullWebCdkRequestsAndValidate中已直接加债券)
// 本地CDK标记pending(Java通过pullPendingTransactions拉取后处理)
$txStatus = $isRemote ? 'completed' : 'pending';
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'cdk_redeem', :amount, :reason, :detail, :status, :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $cdk['amount'], SQLITE3_INTEGER);
$stmt->bindValue(':reason', $isRemote ? "CDK远程兑换: {$code}" : "CDK兑换: {$code}", SQLITE3_TEXT);
$stmt->bindValue(':detail', json_encode(['code' => $code, 'remote' => $isRemote], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':status', $txStatus, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
// 只有本地CDK需要sync_requests触发Java立即同步
if (!$isRemote) {
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
$stmt2->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt2->execute();
}
$db->exec('COMMIT');
} catch (\Throwable $e) {
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
@error_log("[cdk] 兑换事务失败: " . $e->getMessage());
}
success([
'code' => $code,
'amount' => $cdk['amount'],
'player' => $player,
'balance_before' => $currentBalance,
'balance_after' => $currentBalance
], 'CDK兑换成功,债券将由Java端处理');
}
// ===== 创建CDK =====
function cdkCreate($token) {
$amount = (int)getParam('amount', 0);
$type = getParam('type', 'bond');
$code = getParam('code');
if ($amount <= 0) error('金额必须大于0');
// 需要管理token
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
validateAndUseToken($token);
}
}
if (!$code) {
$code = strtoupper(bin2hex(random_bytes(4))); // 8位随机码
}
$db = getDB();
$now = time();
// 检查是否已存在
$stmt = $db->prepare("SELECT 1 FROM cdk WHERE code = :code");
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
$result = $stmt->execute();
if ($result->fetchArray()) {
error('CDK已存在: ' . $code);
}
if ($isPreview) {
preview([
'code' => $code,
'amount' => $amount,
'type' => $type
], '预览模式 - CDK创建不会生效');
}
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
success(['code' => $code, 'amount' => $amount, 'type' => $type], 'CDK创建成功');
}
// ===== CDK列表 =====
function cdkList($token) {
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
}
}
$db = getDB();
$result = $db->query("SELECT code, amount, type, used, used_by, created_at, used_at FROM cdk ORDER BY created_at DESC LIMIT 200");
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
if ($isPreview) {
preview($list, '预览模式');
} else {
success($list);
}
}
// ===== 删除CDK =====
function cdkDelete($token) {
$code = getParam('code');
if (!$code) error('缺少code');
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
validateAndUseToken($token);
}
}
$db = getDB();
if ($isPreview) {
preview(['code' => $code], '预览模式 - 删除不会生效');
}
$stmt = $db->prepare("DELETE FROM cdk WHERE code = :code");
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$stmt->execute();
success(['code' => $code], 'CDK已删除');
}
// ===== 批量生成CDK =====
function cdkBatch($token) {
$amount = (int)getParam('amount', 0);
$count = (int)getParam('count', 1);
$type = getParam('type', 'bond');
if ($amount <= 0) error('金额必须大于0');
if ($count < 1 || $count > 100) error('数量范围: 1-100');
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
validateAndUseToken($token);
}
}
$db = getDB();
$now = time();
$codes = [];
for ($i = 0; $i < $count; $i++) {
$code = strtoupper(bin2hex(random_bytes(4)));
$codes[] = $code;
if (!$isPreview) {
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
}
}
if ($isPreview) {
preview([
'codes' => $codes,
'amount' => $amount,
'count' => $count
], '预览模式 - 批量创建不会生效');
} else {
success([
'codes' => $codes,
'amount' => $amount,
'count' => $count
], "成功创建{$count}个CDK");
}
}
// ===== Weblogin Token验证(用于CDK兑换)=====
function validateWebloginTokenForCDK($webToken) {
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (\Throwable $e) {
return false;
}
}
// ===== 远程CDK验证:写请求表,轮询Java Sdf1_login结果 =====
function tryRemoteCdkValidation($db, $code, $player = '') {
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_requests (
request_id TEXT PRIMARY KEY,
code TEXT NOT NULL,
player_name TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
created_at INTEGER NOT NULL
)");
// ★ 迁移:给已有表补 player_name 列
$cols = [];
$colResult = $db->query("PRAGMA table_info(cdk_validate_requests)");
while ($col = $colResult->fetchArray(SQLITE3_ASSOC)) { $cols[] = $col['name']; }
if (!in_array('player_name', $cols)) {
$db->exec("ALTER TABLE cdk_validate_requests ADD COLUMN player_name TEXT DEFAULT ''");
}
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_results (
request_id TEXT PRIMARY KEY,
code TEXT NOT NULL,
status TEXT NOT NULL,
amount INTEGER DEFAULT 0,
message TEXT DEFAULT '',
used INTEGER DEFAULT 0,
used_by TEXT DEFAULT '',
created_at INTEGER NOT NULL
)");
$requestId = strtoupper(bin2hex(random_bytes(8)));
$now = time();
// ★ 写入待验证请求 — 用BEGIN IMMEDIATE防止database is locked
try {
$db->exec('BEGIN IMMEDIATE');
$stmt = $db->prepare("INSERT OR REPLACE INTO cdk_validate_requests (request_id, code, player_name, status, created_at) VALUES (:id, :code, :player, 'pending', :time)");
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (\Throwable $e) {
$db->exec('ROLLBACK');
@error_log("[cdk] 远程验证写入失败: {$e->getMessage()}");
return null;
}
@error_log("[cdk] 远程验证请求: {$requestId} code={$code} player={$player}");
// ★ 轮询结果(最多8秒,每300ms一次)
$result = null;
for ($i = 0; $i < 27; $i++) {
usleep(300000); // 300ms
$stmt = $db->prepare("SELECT * FROM cdk_validate_results WHERE request_id = :id");
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
$rs = $stmt->execute();
$result = $rs->fetchArray(SQLITE3_ASSOC);
if ($result) break;
}
// 清理请求和结果
try {
$db->exec('BEGIN IMMEDIATE');
$db->exec("DELETE FROM cdk_validate_requests WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
if ($result) {
$db->exec("DELETE FROM cdk_validate_results WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
}
$db->exec('COMMIT');
} catch (\Throwable $e) {
$db->exec('ROLLBACK');
}
if (!$result) {
@error_log("[cdk] 远程验证超时: {$requestId}");
return null;
}
@error_log("[cdk] 远程验证结果: status={$result['status']} amount={$result['amount']}");
// ★ 第32批:Java端拉黑拦截信号 → 直接提示被拉黑
// (若当 null 返回,上层会误报「CDK不存在」,掩盖真实原因)
if ($result['status'] === 'blacklisted') {
$blReason = playerWebBlacklistReason($player);
@error_log("[cdk] 远程验证 blacklisted: player={$player}");
error('你已被拉黑:' . ($blReason !== null ? $blReason : '违反服务器规则'));
}
if ($result['status'] === 'success') {
// ★ Java端已标记used并加债券,Web端只做记录不重复加钱
return [
'code' => $result['code'],
'amount' => (int)$result['amount'],
'type' => 'bond',
'used' => 0,
'used_by' => '',
'created_at' => $now,
'used_at' => 0,
'_remote' => true
];
}
return null;
}
+30
View File
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once __DIR__ . '/../security.php'; // 第四层:全局限流(2026-09-30)
require_once __DIR__ . '/../inc/function.php';
/** @var PDO $db */
function getPlayerList() {
global $db;
$stmt = $db->prepare("SELECT player_name FROM users");
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$players = [];
foreach ($rows as $row) {
$players[] = $row['player_name'];
}
exit(json_encode(['success' => true, 'data' => $players], JSON_UNESCAPED_UNICODE));
}
if (!isLogin()) error('请先登录', 401);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') error('方法不允许');
getPlayerList();
+3770
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1290
View File
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
<?php
/**
* 收银台订单前缀 / 订单号生成 —— 统一读配置的公共小工具(2026-10-06 创建,2026-10-07 改读 pay.md)
*
* 背景:平台 MySQL 的 pay_order 是多商户共用表,创单与三个补单脚本此前都把前缀
* 写死在各自代码里。生产服 / 测试服共用一个前缀就会互相捡单 —— 测试服
* 补单器把生产服的已支付单补进测试库,反过来也一样(2026-10-06 串数据的根因)。
*
* ★ 2026-10-07 订单号规则改读站点根目录 pay.md(配置+文档二合一文件):
* - 只认「### 以下是配置区域:」后第一个 ```json``` 块,其余说明区全部忽略;
* - 「前缀码」= 完整订单前缀(创单与补单永远一致),payOrderPrefix() 的第一来源;
* - 「是否固定订单号格式」= true → 用「订单号格式」模板:[]去掉、纯数字的 0 换随机;
* 模板非纯数字(或为空)→ 回退保底 6 位递增序列 000001/000002…(计数器持久化在
* db/pay_order_seq.txt,flock 防并发撞单);
* - = false → 按「当前配置」组合规则生成(+/- 连接符不进订单号,验证码位数读
* 「订单验证码长度」字段而非文本里的数字);
* - pay.md 缺失 / 解析失败 → 一律回退旧版逻辑(前缀+时间+4码+玩家名),行为与改造前一致。
*
* 前缀兼容(防串单,2026-10-06 事故修复的延续):
* - payOrderPrefix() 第一来源 pay.md「前缀码」→ 回退 config.php 的 PAY_ORDER_PREFIX
* → 回退 'RE',只保留字母数字(要拼进 SQL LIKE,与平台侧约定一致);
* - payOrderIsOurs() 认「当前前缀 + 历史前缀(PAY_ORDER_PREFIX)」任一,改前缀后
* 历史在途单仍被补单器/查询接口认领;
* - payOrderPrefixLikeAny() 给补单器用的双前缀 SQL 条件(poller_online /
* recharge_orders_api / pay_poller),扫描范围与 payOrderIsOurs 一致。
*
* ★ 本文件不依赖 core.php,任何脚本(含自包含的 poller)都能直接 require。
* ★ 可用 define('PAY_MD_PATH', '/path/pay.md') 覆盖 pay.md 路径(仅测试用)。
* ★ 债券换算(estimateBonds)不在本文件:金额→债券由后台「充值商店配置」
* (shop_configs.bond_reward) 决定,代码不干预比例。
*/
if (!function_exists('payMdOrderConfig')) {
/**
* 读 pay.md「### 以下是配置区域:」之后的第一个 ```json``` 块。
* 二合一文件里说明区的表格 / 文本一律忽略。
*
* @return array|null 解析成功返回关联数组;缺失/损坏返回 null(调用方回退旧逻辑)
*/
function payMdOrderConfig() {
$path = defined('PAY_MD_PATH') ? PAY_MD_PATH : dirname(__DIR__) . '/pay.md';
if (!is_file($path)) return null;
$md = @file_get_contents($path);
if (!is_string($md) || $md === '') return null;
$pos = strpos($md, '以下是配置区域');
if ($pos === false) return null;
if (!preg_match('/```(?:json|JSON)?\s*(.*?)```/s', substr($md, $pos), $m)) return null;
$cfg = json_decode(trim($m[1]), true);
return is_array($cfg) ? $cfg : null;
}
}
if (!function_exists('payOrderPrefix')) {
/**
* 订单号前缀(永不返回空串)—— 第一来源 pay.md「前缀码」。
* pay.md 缺失/未配/解析失败时回退 config.php 的 PAY_ORDER_PREFIX,再回退 'RE'
* (与 2026-10-06 改造前的行为完全一致,线上老配置零变化)。
* 只保留字母数字:它要拼进 SQL 的 LIKE '前缀%',也与平台侧约定一致。
*/
function payOrderPrefix() {
$p = '';
$cfg = payMdOrderConfig();
if (is_array($cfg) && isset($cfg['前缀码'])) {
$p = trim((string)$cfg['前缀码']);
}
if ($p === '') {
$p = defined('PAY_ORDER_PREFIX') ? trim((string)PAY_ORDER_PREFIX) : '';
}
if ($p === '') return 'RE';
$p = preg_replace('/[^A-Za-z0-9]/', '', $p);
return $p === '' ? 'RE' : $p;
}
}
if (!function_exists('payOrderPrefixes')) {
/**
* 本端全部有效前缀 = [当前前缀(pay.md), 历史前缀(PAY_ORDER_PREFIX,不同时才带上)]。
* 用于「认旧单」:改前缀后,改动前已生成、尚未补单的订单仍能被本端认领。
* @return string[]
*/
function payOrderPrefixes() {
$list = array(payOrderPrefix());
$legacy = defined('PAY_ORDER_PREFIX') ? preg_replace('/[^A-Za-z0-9]/', '', trim((string)PAY_ORDER_PREFIX)) : '';
if ($legacy !== '' && !in_array($legacy, $list, true)) {
$list[] = $legacy;
}
return $list;
}
}
if (!function_exists('payOrderPrefixLen')) {
/** 前缀长度(strncmp 用) */
function payOrderPrefixLen() {
return strlen(payOrderPrefix());
}
}
if (!function_exists('payOrderIsOurs')) {
/**
* 判断一个 out_trade_no 是否本端订单(补单器/查询接口的兜底校验)
* 当前前缀与历史前缀命中其一即算本端单。
* @param string $outNo
* @return bool
*/
function payOrderIsOurs($outNo) {
foreach (payOrderPrefixes() as $p) {
if (strncmp((string)$outNo, $p, strlen($p)) === 0) return true;
}
return false;
}
}
if (!function_exists('payOrderPrefixLike')) {
/** SQL 片段:out_trade_no LIKE '当前前缀%'(单前缀,向后兼容保留) */
function payOrderPrefixLike() {
return payOrderPrefix() . '%';
}
}
if (!function_exists('payOrderPrefixLikeAny')) {
/**
* SQL 条件片段:out_trade_no LIKE '当前前缀%' OR out_trade_no LIKE '历史前缀%'。
* 补单器扫描用,与 payOrderIsOurs 的认定范围一致;前缀均已净化为字母数字,无需转义。
* 用法:WHERE (".payOrderPrefixLikeAny().") AND status IN (1,2)
* @return string
*/
function payOrderPrefixLikeAny() {
$conds = array();
foreach (payOrderPrefixes() as $p) {
$conds[] = "out_trade_no LIKE '" . $p . "%'";
}
return implode(' OR ', $conds);
}
}
if (!function_exists('payRandomCode')) {
/**
* 随机校验码:长度 1~32,去掉易混的 I/O/0/1
* @param int $len
* @return string
*/
function payRandomCode($len) {
$len = (int)$len;
if ($len < 1) $len = 4;
if ($len > 32) $len = 32;
$cs = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$out = '';
for ($i = 0; $i < $len; $i++) {
$out .= $cs[mt_rand(0, strlen($cs) - 1)];
}
return $out;
}
}
if (!function_exists('payComposeOrderNo')) {
/**
* 按「当前配置」组合规则拼订单号。连接符(+/-/+/-)只作分隔,不进订单号。
* 变量映射(中文变量名与说明区英文别名都认):
* 剦缀码/front → 当前前缀;当前时间/下单时间/timeis → YmdHis;
* 订单验证码/校验码/ordcode → 随机码(长度读「订单验证码长度」,不读文本里的"N位");
* 玩家名字/用户名/username → 净化后的玩家名;未知变量忽略。
*
* @param string $tpl 「当前配置」原文,如 剦缀码+当前时间+4位订单验证码+玩家名字
* @param int $codeLen 验证码长度
* @param string $name 已净化的玩家名
* @return array|null [前段, 后段, 是否含玩家名](订单号=前段.玩家名.后段);无有效段返回 null
*/
function payComposeOrderNo($tpl, $codeLen, $name) {
$tokens = preg_split('/[+\-\x{FF0B}\x{FF0D}]/u', (string)$tpl);
if (!is_array($tokens) || count($tokens) === 0) return null;
$pre = ''; // 玩家名之前的段落
$post = ''; // 玩家名之后的段落
$withName = false; // 组合里含玩家名变量(payGuardLen 据此裁剪)
$afterName = false; // 已越过玩家名位置
$valid = false;
foreach ($tokens as $t) {
$t = trim($t);
if ($t === '') continue;
$tl = strtolower($t);
if (strpos($t, '前缀') !== false || $tl === 'front') {
$seg = payOrderPrefix();
} elseif (strpos($t, '时间') !== false || strpos($t, '日期') !== false || $tl === 'timeis') {
$seg = date('YmdHis');
} elseif (strpos($t, '验证码') !== false || strpos($t, '校验码') !== false || $tl === 'ordcode') {
$seg = payRandomCode($codeLen);
} elseif (!$afterName && !$withName
&& (strpos($t, '玩家') !== false || strpos($t, '用户') !== false || $tl === 'username')) {
// 玩家名段:本身由 payGuardLen 统一拼装,这里只切换分段状态(重复的玩家名变量忽略)
$withName = true;
$afterName = true;
$valid = true;
continue;
} else {
continue; // 未知变量忽略
}
if ($afterName) $post .= $seg; else $pre .= $seg;
$valid = true;
}
if (!$valid) return null;
return array($pre, $post, $withName);
}
}
if (!function_exists('payMakeFixedOrderNo')) {
/**
* 固定订单号格式分支:前缀码 + 订单号格式模板。
* 模板 [] 去掉;
* - 纯数字(如 00000000)→ 按【模板位数】递增序列:00000001、00000002…
* (计数器持久化在 db/pay_order_seq.txt,flock 防撞单;当前无订单则从 1 开始);
* - 非纯数字或为空 → 保底 6 位递增序列 000001、000002…。
* @param array $cfg pay.md 配置区
* @return string
*/
function payMakeFixedOrderNo($cfg) {
$p = payOrderPrefix();
$tpl = isset($cfg['订单号格式']) ? (string)$cfg['订单号格式'] : '';
$tpl = str_replace(array('[', ']', "\u{FF3B}", "\u{FF3D}", ' ', "\t"), '', $tpl);
if ($tpl !== '' && ctype_digit($tpl)) {
// ★ 纯数字模板:按模板位数递增序列(00000000 → CYZJ00000001, CYZJ00000002…)
$no = payMakeSequentialOrderNo($tpl);
} else {
// 非纯数字模板(或空)→ 保底 6 位递增序列
$no = payMakeSequentialOrderNo('000000');
}
$max = defined('PAY_ORDER_MAX_LEN') ? (int)PAY_ORDER_MAX_LEN : 64;
if ($max > 0 && strlen($no) > $max) $no = substr($no, 0, $max);
return $no;
}
}
if (!function_exists('payMakeSequentialOrderNo')) {
/**
* 递增序列订单号:前缀 + 模板位数的序列(000001、000002…,默认 6 位)。
* 计数器持久化在 db/pay_order_seq.txt(flock 排他锁读写改,防并发撞单);
* 计数器不可写时退化为「前缀+日期时间+3位随机」,保底不与历史单撞号。
*
* @param string $tpl 位数模板(纯数字,如 '00000000'),决定序列补零位数
* @return string
*/
function payMakeSequentialOrderNo($tpl = '000000') {
$p = payOrderPrefix();
$width = strlen(ctype_digit($tpl) ? $tpl : '000000');
$dir = dirname(__DIR__) . '/db';
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$file = $dir . '/pay_order_seq.txt';
$n = 0;
$fp = @fopen($file, 'c+');
if ($fp !== false) {
if (flock($fp, LOCK_EX)) {
$raw = trim((string)stream_get_contents($fp));
$n = (is_numeric($raw) && (int)$raw > 0) ? (int)$raw : 0;
$n++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string)$n);
fflush($fp);
flock($fp, LOCK_UN);
}
fclose($fp);
}
if ($n <= 0) {
return $p . date('ymdHis') . str_pad((string)mt_rand(0, 999), 3, '0', STR_PAD_LEFT);
}
return $p . str_pad((string)$n, $width, '0', STR_PAD_LEFT);
}
}
if (!function_exists('payGuardLen')) {
/**
* 长度护栏:平台 out_trade_no 默认 varchar(64)。
* 订单号 = $pre . 玩家名 . $post($withName=false 时玩家名不参与)。
* 超长时先裁玩家名段,其次整体裁尾(前缀在头部绝不被裁 —— 前缀一旦被截,
* 补单器就再也认不出这单了)。
*
* @return string
*/
function payGuardLen($pre, $post, $name, $withName) {
$max = defined('PAY_ORDER_MAX_LEN') ? (int)PAY_ORDER_MAX_LEN : 64;
if ($max <= 0) return $withName ? $pre . $name . $post : $pre . $post;
if (!$withName || $name === '') {
$no = $pre . $post;
return strlen($no) > $max ? substr($no, 0, $max) : $no;
}
$fixed = strlen($pre) + strlen($post);
$allow = $max - $fixed;
if ($allow >= strlen($name)) {
return $pre . $name . $post;
}
if ($allow > 0) {
return $pre . substr($name, 0, $allow) . $post;
}
// 极端情况(前缀配得极长):先退回不含玩家名的形态,仍超长则整体裁尾保前缀
$no = $pre . $post;
return strlen($no) > $max ? substr($no, 0, $max) : $no;
}
}
if (!function_exists('payMakeOrderNo')) {
/**
* 生成本端订单号(2026-10-07 改读 pay.md 配置区)
*
* 分支:
* 1) 「是否固定订单号格式」= true → payMakeFixedOrderNo(模板纯数字0换随机 /
* 非纯数字回退 6 位递增序列),不再读组合规则;
* 2) = false(或 pay.md 缺失/解析失败)→ 按「当前配置」组合;组合规则缺失时
* 回退旧版默认(前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名,
* 例:CYZJ20261006171418Q101youpaishidifu),行为与改造前完全一致。
*
* 玩家名只保留 [A-Za-z0-9_](Minecraft 用户名规则),其余字符剔除;
* 剔除后为空则整段省略,保证前缀/时间戳/校验码绝不被破坏。
*
* @param string $playerName 下单玩家名
* @return string
*/
function payMakeOrderNo($playerName = '') {
$cfg = payMdOrderConfig();
// 玩家名净化:只留字母数字下划线
$name = preg_replace('/[^A-Za-z0-9_]/', '', (string)$playerName);
if ($name === '') $name = '';
// —— 分支 1:固定订单号格式 ——
if (is_array($cfg) && !empty($cfg['是否固定订单号格式'])) {
return payMakeFixedOrderNo($cfg);
}
// —— 分支 2:按「当前配置」组合规则 ——
if (is_array($cfg) && isset($cfg['当前配置']) && trim((string)$cfg['当前配置']) !== '') {
$codeLen = 4;
if (isset($cfg['订单验证码长度'])) {
$l = (int)$cfg['订单验证码长度'];
if ($l >= 1 && $l <= 32) $codeLen = $l;
}
$r = payComposeOrderNo(trim((string)$cfg['当前配置']), $codeLen, $name);
if ($r !== null && ($r[0] . $r[1]) !== '') {
return payGuardLen($r[0], $r[1], $name, $r[2]);
}
}
// —— 回退:旧版默认组合(pay.md 缺失/解析失败/无有效规则)——
$p = payOrderPrefix();
$stamp = date('YmdHis'); // 14位:20261006171418
$code = payRandomCode(4); // 4位校验码(去掉易混的 I/O/0/1)
return payGuardLen($p . $stamp . $code, '', $name, true);
}
}
+386
View File
@@ -0,0 +1,386 @@
<?php
require_once __DIR__ . '/../security.php'; // 第四层:全局限流(2026-09-30)
/**
* 支付补单器(poller_online.php)— 绕过 Cloudflare WAF 拦截的 HTTP 回调通知
*
* 工作原理:
* 直接读平台 MySQL 中已支付订单(status IN (1,2)) → 与本地 SQLite(pay_orders) 比对 →
* 将未处理的订单写入 web_transactions(recharge, pending) → 供 Java 高频定时器拉取 → addBonds 到账
*
* 触发方式(双保险):
* 1. 玩家在 Web 端查询订单时,pay.php 的 query_order 异步调用本脚本(前端触发)
* 2. Java 定时拉取 web_transactions 前,先调用本脚本(后台定时触发,最可靠)
*
* 完全自包含,不依赖 core.php,避免 500 错误。幂等:重复跑不会重复写流水。
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
// 只在直接执行时清除输出缓冲区,被包含时保留
if (!defined('POLLER_NO_AUTO_RUN')) {
while (ob_get_level() > 0) { ob_end_clean(); }
}
// ===== 配置(从 pay_secrets.php 加载,集中管理) =====
$secretsFile = __DIR__ . '/pay_secrets.php';
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
@require_once $secretsFile;
}
// ★ 订单前缀读配置(2026-10-06 多服隔离):创单端 pay.php 用什么前缀,这里就扫什么前缀。
// 此前写死 'RE',测试服与生产服共用一个前缀 → 互相捡单串数据。
// ★★ 改前缀时,pay.php / poller_online.php / pay_poller.php / recharge_orders_api.php
// 四个文件必须一起改(它们现在都读同一个 PAY_ORDER_PREFIX,只要 config.php 改了就同步生效)。
require_once __DIR__ . '/pay_config.php';
$PLATFORM_DB_HOST = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
$PLATFORM_DB_NAME = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
$PLATFORM_DB_USER = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
$PLATFORM_DB_PASS = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
$GLOBALS['PLATFORM_DB_PREFIX'] = 'pay_'; $PLATFORM_DB_PREFIX = 'pay_';
// SQLite 数据库路径
$GLOBALS['SQLITE_DB_PATH'] = sqlDbFile('java'); // web_transactions (Java专属表 → Java.db)
$GLOBALS['ORDERS_DB_PATH'] = __DIR__ . '/../db/orders.db'; // pay_orders (PHP读写)
if (!function_exists('debugLog')) {
function debugLog($msg, $ctx = []) {
$logFile = __DIR__ . '/../db/debug.log';
$ts = date('Y-m-d H:i:s');
$entry = "[$ts] $msg";
if ($ctx) $entry .= ' | Context: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE);
$entry .= "\n";
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
// ★ 保留策略(2026-10-05):只留最近3天 + ≤5MB,任一超标即清理到两条件同时满足
if (is_file(__DIR__ . '/../log_maintain.php')) {
@require_once __DIR__ . '/../log_maintain.php';
if (function_exists('logMaintain')) { @logMaintain($logFile); }
}
}
} // end if (!function_exists('debugLog'))
function getSQLite() {
// web.db — Java 高频读写(web_transactions 表)
static $db = null;
if ($db === null) {
$db = new SQLite3($GLOBALS['SQLITE_DB_PATH']);
$db->enableExceptions(true);
$db->exec('PRAGMA journal_mode=WAL');
$db->exec('PRAGMA busy_timeout=5000');
// web_transactions 表(Java 读取补单)
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT,
type TEXT,
amount INTEGER,
operator TEXT,
reason TEXT,
detail TEXT,
status TEXT DEFAULT 'pending',
created_at INTEGER
)");
@$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)");
}
return $db;
}
function getOrdersSQLite() {
// orders.db — PHP 订单库(pay_orders 表),独立于 Java 的 web.db
static $db = null;
if ($db === null) {
$db = new SQLite3($GLOBALS['ORDERS_DB_PATH']);
$db->enableExceptions(true);
$db->exec('PRAGMA journal_mode=WAL');
$db->exec('PRAGMA busy_timeout=5000');
// pay_orders 表
$db->exec("CREATE TABLE IF NOT EXISTS pay_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
out_trade_no TEXT UNIQUE,
trade_no TEXT,
player_name TEXT NOT NULL,
tier_id INTEGER DEFAULT 0,
money TEXT,
bond_amount INTEGER DEFAULT 0,
status TEXT DEFAULT 'created',
name TEXT,
platform_sign TEXT,
submit_params TEXT,
created_at INTEGER,
paid_at INTEGER
)");
}
return $db;
}
function getPlatformDB() {
try {
// ★ 直接用常量(从 pay_secrets.php 加载),不依赖全局变量
$host = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
$dbname = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
$user = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
$pass = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
// ★ 2026-10-05 日志收敛:连接过程(正在连接/DSN/连接成功)属于每次轮询的
// 常规路径,一律不写日志——补单器约 20 秒跑一次,这 3 行是 debug.log
// 膨胀的元凶之一。只有下面 catch 的"连接失败"(报错)才写日志。
$start = microtime(true);
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 15,
]);
return $pdo;
} catch (\Throwable $e) {
$elapsed = round((microtime(true) - ($start ?? microtime(true))) * 1000);
debugLog("[poller] MySQL连接失败", [
'error' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'elapsed_ms'=> $elapsed,
'dsn' => $dsn ?? 'undefined',
'user' => $user ?? 'undefined',
]);
throw $e;
}
}
/**
* 债券换算(仅当本地 pay_orders 无 bond_amount 时作为兜底估算)
* 0.01元 → 1债券;>=1元 → 金额*10债券
*/
function estimateBonds($money) {
$m = (float)$money;
if ($m >= 1.0) return (int)($m * 10);
return max(1, (int)($m * 100));
}
/**
* 进程锁:防止多个触发源(Java 定时、前端 query_order、管理员手动"立即对账")
* 并发跑补单造成重复写入 / 资源争用。非阻塞,若已被占用则跳过本次。
* 锁文件独立存在,进程退出后内核自动释放。
*/
function pollerAcquireLock() {
static $fp = null;
if ($fp === null) {
$lockPath = __DIR__ . '/../db/poller.lock';
$fp = @fopen($lockPath, 'c');
}
if ($fp === false) return false;
return flock($fp, LOCK_EX | LOCK_NB);
}
function pollPaidOrders() {
global $PLATFORM_DB_PREFIX;
$startTime = microtime(true);
// ★ 2026-10-05 日志收敛:不再无条件写"补单开始"。没有合适订单时下面直接
// 静默 return(只回 JSON,不写日志);只有真正补到单(成功)或出错才写。
// 并发保护:已有补单进程在跑则直接跳过,杜绝重复补单
if (!pollerAcquireLock()) {
echo json_encode(['result' => 'already_running', 'detail' => '另一个补单进程正在运行,本次跳过']);
return;
}
$sqlite = getOrdersSQLite(); // orders.db(pay_orders)
$webSqlite = getSQLite(); // web.db(web_transactions)
try {
$pdo = getPlatformDB();
} catch (\Throwable $e) {
debugLog('[poller_online] 平台数据库连接失败', [
'error' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'context' => 'pollPaidOrders',
]);
echo json_encode([
'error' => 'platform_db_connect_failed',
'detail' => $e->getMessage(),
'code' => $e->getCode(),
'file' => basename($e->getFile()),
'line' => $e->getLine(),
]);
return;
}
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,`pay_order` 里
// 绝大多数是其他商户/项目的单(纯数字单号)。以前不加过滤整表拉,平台每来一单
// 就被我们"捡走"、凭空写进本地 pay_orders + web_transactions,产生玩家=unknown
// 的垃圾充值单(Java 每次拉交易都刷屏处理它们),还顺手把别人的订单 notify
// 改成 1 —— 纯属多管闲事。本方单号由 pay.php createOrder 生成,前缀取自
// config.php 的 PAY_ORDER_PREFIX(生产 'RE' / 测试服 'RT' 等,各部署各配),
// 平台侧据此过滤,PHP 侧再兜底校验一次;平台其他订单一概不读不写。
// 仍不过滤 notify:平台可能已标记 notify=1 但本地 DB 未更新(HTTP 回调被
// CF WAF 拦截),是否补单以本地 pay_orders 状态为准。
$prefix = $PLATFORM_DB_PREFIX;
// ★ 2026-10-07 改为【定向补单】:不再按前缀批量扫平台共享库,
// 而是先取本地 pay_orders 里所有未补单(非 paid)的【完整订单号】,
// 再让平台只按这些完整订单号精确匹配。这样就算生产/测试共用 CYZJ 前缀,
// 测试服脚本也只会查它自己本地活跃的订单号,抓不到生产服的单。
$activeStmt = $sqlite->prepare("SELECT out_trade_no FROM pay_orders WHERE status != 'paid' AND out_trade_no IS NOT NULL AND out_trade_no != ''");
// ★ 2026-10-08 实测修正:SQLite3Stmt::execute() 直接返回 SQLite3Result(PHP 7.4/8.2/8.5
// 三个版本实测一致),Stmt/Result 上都没有 getResult() 方法。此前一版写
// $activeStmt->fetchArray()(Stmt 无此方法→致命错误)、上一版写 getResult()
// (方法不存在→致命错误),补单器其实一直没跑起来过。
$activeRes = $activeStmt->execute();
$activeNos = array();
while ($activeRes && ($a = $activeRes->fetchArray(SQLITE3_ASSOC)) !== false) {
$activeNos[] = (string)$a['out_trade_no'];
}
if (empty($activeNos)) {
echo json_encode(['result' => 'no_local_active_orders']);
return;
}
// 平台侧按完整订单号精确匹配(IN 子句,最多取前 200 个,足够覆盖并发场景)
$activeNos = array_slice($activeNos, 0, 200);
$inList = "'" . implode("','", array_map('addslashes', $activeNos)) . "'";
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, notify, param, version, addtime FROM `{$prefix}order` WHERE out_trade_no IN (" . $inList . ") AND status IN (1, 2)");
$stmt->execute();
$allOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($allOrders)) {
echo json_encode(['result' => 'no_paid_orders_on_platform', 'active_local' => count($activeNos)]);
return;
}
// 只保留「本地确实在跟踪、且还没补单」的订单(二次过滤,防本地已被补单/已支付)
$orders = [];
foreach ($allOrders as $o) {
$outNo = (string)$o['out_trade_no'];
if (!in_array($outNo, $activeNos, true)) {
continue;
}
$check = $sqlite->prepare("SELECT status FROM pay_orders WHERE out_trade_no = :no");
$check->bindValue(':no', $outNo, SQLITE3_TEXT);
$checkRes = $check->execute();
$row = $checkRes ? $checkRes->fetchArray(SQLITE3_ASSOC) : false;
if (!$row || $row['status'] !== 'paid') {
$orders[] = $o;
}
}
if (empty($orders)) {
echo json_encode(['result' => 'no_pending_orders', 'active_local' => count($activeNos)]);
return;
}
$processed = 0;
$skipped = 0;
$now = time();
foreach ($orders as $order) {
$outTradeNo = $order['out_trade_no'];
$tradeNo = $order['trade_no'];
$playerName = $order['param'] ?? 'unknown';
$money = (string)$order['money'];
// 查本地 pay_orders(拿到正确的 bond_amount,创建订单时按档位设置)
$check = $sqlite->prepare("SELECT status, bond_amount FROM pay_orders WHERE out_trade_no = :no");
$check->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$checkRes = $check->execute();
$row = $checkRes ? $checkRes->fetchArray(SQLITE3_ASSOC) : false;
if ($row && $row['status'] === 'paid') {
$skipped++;
markNotified($pdo, $tradeNo);
continue;
}
// 债券数:优先用本地订单记录的 bond_amount(最准确),否则估算
$bonds = 0;
if ($row && !empty($row['bond_amount'])) {
$bonds = (int)$row['bond_amount'];
} else {
$bonds = estimateBonds($money);
}
debugLog('[poller_online] 补单处理', [
'out_trade_no' => $outTradeNo,
'player' => $playerName,
'money' => $money,
'bonds' => $bonds,
'platform_notify'=> $order['notify'],
'has_local_order'=> $row ? 1 : 0,
]);
// 写/更新本地 pay_orders
if (!$row) {
$ins = $sqlite->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :t, :t)");
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$ins->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$ins->bindValue(':p', $playerName, SQLITE3_TEXT);
$ins->bindValue(':m', $money, SQLITE3_TEXT);
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
$ins->execute();
} else {
$upd = $sqlite->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t, bond_amount=:b WHERE out_trade_no=:no AND status != 'paid'");
$upd->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
$upd->bindValue(':b', $bonds, SQLITE3_INTEGER);
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$upd->execute();
}
// 写 web_transactions(幂等:INSERT OR IGNORE 防止竞态条件重复插入)→ web.db
$txIns = $webSqlite->prepare("INSERT OR IGNORE INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '支付平台(补单)', '在线充值(支付宝)', :d, 'pending', :t)");
$txIns->bindValue(':p', $playerName, SQLITE3_TEXT);
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
$txIns->execute();
// 检查是否是新插入的(affected_rows > 0 表示新插入,=0 表示已存在被忽略)
if ($webSqlite->changes() > 0) {
debugLog('[poller_online] 充值交易已写入 web_transactions', [
'player' => $playerName, 'bonds' => $bonds,
'out_trade_no' => $outTradeNo, 'money' => $money,
]);
}
markNotified($pdo, $tradeNo);
$processed++;
}
$elapsed = round((microtime(true) - $startTime) * 1000);
// ★ 2026-10-05 日志收敛:只在本轮真正补到单(processed > 0)时写"补单完成";
// 没有实际补单动作则静默,不再每轮刷一条。
if ($processed > 0) {
debugLog('[poller] 补单完成', ['processed' => $processed, 'skipped' => $skipped, 'elapsed_ms' => $elapsed]);
}
echo json_encode([
'result' => 'ok',
'processed' => $processed,
'skipped' => $skipped,
'foreign_skipped' => 0,
'total' => count($orders),
'elapsed_ms' => $elapsed,
]);
}
function markNotified($pdo, $tradeNo) {
global $PLATFORM_DB_PREFIX;
try {
$prefix = $PLATFORM_DB_PREFIX;
$stmt = $pdo->prepare("UPDATE {$prefix}order SET notify = 1 WHERE trade_no = ? AND notify = 0");
$stmt->execute([$tradeNo]);
} catch (\Throwable $e) {
debugLog('[poller_online] 标记notify失败: ' . $e->getMessage(), ['trade_no' => $tradeNo]);
}
}
// ===== 入口 =====
// 若被其它脚本 include 并定义了 POLLER_NO_AUTO_RUN,则只暴露函数(getPlatformDB 等),
// 不在此处自动执行补单(由调用方自行决定何时触发)。
if (!defined('POLLER_NO_AUTO_RUN')) {
header('Content-Type: application/json; charset=utf-8');
try {
pollPaidOrders();
} catch (\Throwable $e) {
debugLog('[poller_online] 未捕获异常', ['error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()]);
echo json_encode(['error' => 'uncaught_exception', 'detail' => $e->getMessage()]);
}
}
+698
View File
@@ -0,0 +1,698 @@
<?php
/**
* 充值订单API(recharge_orders_api.php)
* 提供充值订单查询、统计、筛选功能
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ob_start();
require_once __DIR__ . '/../core.php';
ob_end_clean();
// 加载支付密钥(含MySQL凭据)
$secretsFile = __DIR__ . '/pay_secrets.php';
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
@require_once $secretsFile;
}
// ★ 订单前缀读配置(2026-10-06 多服隔离):与 pay.php 创单端共用同一份 PAY_ORDER_PREFIX
require_once __DIR__ . '/pay_config.php';
/**
* 获取平台MySQL数据库连接(集中凭据管理)
* 注意:poller_online.php 也有同名函数,此处用不同名称避免 redeclare 冲突
*/
function getAdminPlatformDB() {
$host = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
$dbname = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
$user = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
$pass = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_TIMEOUT => 15, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return $pdo;
}
/**
* 确保充值商店配置表存在
*/
function ensureShopConfigsTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS shop_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_name TEXT NOT NULL DEFAULT '',
stock INTEGER NOT NULL DEFAULT -1,
temporary_offer TEXT NOT NULL DEFAULT '',
offer_expire TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
discount_price REAL NOT NULL DEFAULT 0,
bond_reward TEXT NOT NULL DEFAULT '1-1',
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)");
// 兼容旧表:检查并添加discount_price字段
$hasCol = false;
$cols = $db->query("PRAGMA table_info(shop_configs)");
while ($col = $cols->fetchArray(SQLITE3_ASSOC)) {
if ($col['name'] === 'discount_price') { $hasCol = true; break; }
}
if (!$hasCol) {
$db->exec("ALTER TABLE shop_configs ADD COLUMN discount_price REAL NOT NULL DEFAULT 0");
}
}
// 检查管理员登录
if (!isAdminLoggedIn()) {
error('无权限访问', 403);
}
$action = getParam('action', '');
switch ($action) {
case 'list':
handleOrderList();
break;
case 'detail':
handleOrderDetail();
break;
case 'shop_config':
handleShopConfig();
break;
case 'reconcile':
handleReconcile();
break;
case 'sync_platform':
handleSyncFromPlatform();
break;
default:
error('未知操作');
}
/**
* 获取订单列表
*/
function handleOrderList() {
// ★ 使用独立订单库,不和Java共享web.db
$db = getOrdersDB();
ensurePayOrdersTable($db);
// ★ 增量迁移:从 web.db 同步缺失的 pay_orders 到 orders.db(处理历史数据)
try {
$webDb = getDB();
$tableCheck = $webDb->query("SELECT name FROM sqlite_master WHERE type='table' AND name='pay_orders'");
if ($tableCheck->fetchArray()) {
$migrateData = $webDb->query("SELECT * FROM pay_orders");
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, tier_id, money, bond_amount, status, name, platform_sign, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :ti, :m, :b, :s, :n, :ps, :sp, :ca, :pa)");
$migrated = 0;
while ($row = $migrateData->fetchArray(SQLITE3_ASSOC)) {
$ins->bindValue(':no', $row['out_trade_no'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':tn', $row['trade_no'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':p', $row['player_name'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':ti', $row['tier_id'] ?? 0, SQLITE3_INTEGER);
$ins->bindValue(':m', $row['money'] ?? '0', SQLITE3_TEXT);
$ins->bindValue(':b', $row['bond_amount'] ?? 0, SQLITE3_INTEGER);
$ins->bindValue(':s', $row['status'] ?? 'created', SQLITE3_TEXT);
$ins->bindValue(':n', $row['name'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':ps', $row['platform_sign'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':sp', $row['submit_params'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':ca', $row['created_at'] ?? 0, SQLITE3_INTEGER);
$ins->bindValue(':pa', $row['paid_at'] ?? 0, SQLITE3_INTEGER);
$ins->execute();
if ($db->changes() > 0) $migrated++;
}
if ($migrated > 0) {
debugLog('[handleOrderList] 增量迁移web.db→orders.db', ['migrated' => $migrated]);
}
}
} catch (Exception $e) {
debugLog('[handleOrderList] 迁移失败', ['error' => $e->getMessage()]);
}
$player = getParam('player', '');
$orderNo = getParam('order_no', '');
$status = getParam('status', '');
$limit = min(200, max(1, (int)getParam('limit', 50)));
// 构建查询
$conditions = [];
$params = [];
if ($player) {
$conditions[] = "player_name LIKE :player";
$params[':player'] = '%' . $player . '%';
}
if ($orderNo) {
$conditions[] = "out_trade_no LIKE :order_no";
$params[':order_no'] = '%' . $orderNo . '%';
}
if ($status) {
$conditions[] = "status = :status";
$params[':status'] = $status;
}
$where = '';
if ($conditions) {
$where = 'WHERE ' . implode(' AND ', $conditions);
}
// 查询订单
$sql = "SELECT * FROM pay_orders $where ORDER BY created_at DESC LIMIT :limit";
$stmt = $db->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, SQLITE3_TEXT);
}
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$orders = [];
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$orders[] = $row;
}
// 统计数据
$statsSql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status='created' THEN 1 ELSE 0 END) as created,
SUM(CASE WHEN status='paid' THEN CAST(money AS REAL) ELSE 0 END) as totalMoney
FROM pay_orders";
$statsResult = $db->querySingle($statsSql, true);
$stats = [
'total' => $statsResult['total'] ?? 0,
'paid' => $statsResult['paid'] ?? 0,
'created' => $statsResult['created'] ?? 0,
'totalMoney' => number_format($statsResult['totalMoney'] ?? 0, 2),
];
success(['orders' => $orders, 'stats' => $stats]);
}
/**
* 获取单个订单详情
*/
function handleOrderDetail() {
$outTradeNo = getParam('out_trade_no');
if (!$outTradeNo) {
error('缺少订单号');
}
// ★ 使用独立订单库
$db = getOrdersDB();
ensurePayOrdersTable($db);
$stmt = $db->prepare("SELECT * FROM pay_orders WHERE out_trade_no = :no");
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) {
error('订单不存在');
}
success(['order' => $row]);
}
/**
* 手动触发对账(调用 poller_online.php)
*/
function handleReconcile() {
// 直接包含 poller_online.php 并调用 pollPaidOrders
define('POLLER_NO_AUTO_RUN', true);
// ★ 清空所有已有缓冲区(防止任何HTML/警告污染JSON)
while (ob_get_level() > 0) { ob_end_clean(); }
// 包含 poller_online.php(定义函数,不执行)
require_once __DIR__ . '/poller_online.php';
// 记录对账开始
if (function_exists('debugLog')) {
debugLog('[reconcile] 开始对账', [
'admin' => $_SESSION['admin_user'] ?? 'unknown',
'time' => date('Y-m-d H:i:s'),
]);
}
// 开始对账 - 捕获所有输出
ob_start();
try {
pollPaidOrders();
} catch (\Throwable $e) {
// 捕获异常,输出JSON错误
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'poller_exception', 'detail' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
exit;
}
$output = ob_get_clean();
// 记录原始输出
if (function_exists('debugLog')) {
debugLog('[reconcile] pollPaidOrders输出', [
'output_len' => strlen($output),
'output' => substr($output, 0, 500),
]);
}
// 解析结果
$result = json_decode($output, true);
if (!$result || !is_array($result)) {
if (function_exists('debugLog')) {
debugLog('[reconcile] JSON解析失败', ['output' => substr($output, 0, 200)]);
}
// 确保输出干净的JSON
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'poller_output_invalid', 'raw' => substr($output, 0, 100)], JSON_UNESCAPED_UNICODE);
exit;
}
// 记录解析结果
if (function_exists('debugLog')) {
debugLog('[reconcile] 解析结果', [
'result' => $result,
]);
}
// 直接输出结果
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8');
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* 从平台MySQL同步所有已支付订单到本地
*/
function handleSyncFromPlatform() {
// ★ 使用独立订单库,不和Java共享web.db(Java高频写入会锁死SQLite)
$db = getOrdersDB();
ensurePayOrdersTable($db);
// 注意:web_transactions 表只在 web.db 中(Java读取),不在 orders.db 中
try {
$pdo = getAdminPlatformDB();
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,整表拉 200 条
// 会把其他商户/项目的单(纯数字单号)也同步成本地充值单,凭空造出
// 玩家=unknown 的垃圾订单。本方单号前缀取自 config.php 的 PAY_ORDER_PREFIX
// (pay.php createOrder 用同一个值生成),平台侧据此过滤,PHP 侧再兜底校验;
// 平台其他订单一概不碰。
// ★ 2026-10-06:前缀改为读配置(原来写死 'RE',测试服改前缀后这里不跟着改
// 就会把生产服的单同步进来)。
// ★ 2026-10-07:前缀第一来源改为站点根 pay.md「前缀码」;扫描改为「当前前缀+历史前缀」
// 双条件(payOrderPrefixLikeAny),改过前缀码后在途老单仍能同步、不丢单。
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, param, addtime FROM `pay_order` WHERE (" . payOrderPrefixLikeAny() . ") AND status IN (1, 2) ORDER BY addtime DESC LIMIT 200");
$stmt->execute();
$platformOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
$synced = 0;
$alreadyPaid = 0;
$foreign = 0;
$errors = [];
$now = time();
foreach ($platformOrders as $po) {
$outTradeNo = $po['out_trade_no'];
// ★ 兜底校验:与创单端读同一份 PAY_ORDER_PREFIX
if (!payOrderIsOurs($outTradeNo)) {
$foreign++; // 非本方订单:不建单、不写流水
continue;
}
$player = $po['param'] ?? 'unknown';
$tradeNo = $po['trade_no'] ?? '';
$money = (string)$po['money'];
// ★ 债券换算保持原始行为(2026-10-06 用户指示:代码不干预换算比例,
// 金额→券数由后台「面板-充值商店配置」决定,运维自行调整)。
// 本行仅在本地无档位记录时作兜底估算,公式与改造前完全一致。
$bonds = ($money >= '1.0') ? (int)($money * 10) : max(1, (int)($money * 100));
// 查本地是否已有
$check = $db->prepare("SELECT id, status FROM pay_orders WHERE out_trade_no = :no");
$check->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$localRow = $check->execute()->fetchArray(SQLITE3_ASSOC);
if ($localRow && $localRow['status'] === 'paid') {
$alreadyPaid++;
continue;
}
if ($localRow) {
// 已有记录但未标记paid → 更新
$upd = $db->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t WHERE out_trade_no=:no AND status != 'paid'");
$upd->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$upd->execute();
} else {
// 本地无记录 → 创建
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, name, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :n, '', :t, :t)");
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$ins->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$ins->bindValue(':p', $player, SQLITE3_TEXT);
$ins->bindValue(':m', $money, SQLITE3_TEXT);
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
$ins->bindValue(':n', '债券充值', SQLITE3_TEXT);
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
$ins->execute();
}
// 写 web_transactions(幂等)— 注意:web_transactions 在 web.db 中(Java读取),不在 orders.db
try {
$webDb = getDB(); // 获取 web.db 连接
ensureWebTransactionsTable($webDb);
$txCheck = $webDb->prepare("SELECT id FROM web_transactions WHERE detail = :d AND type = 'recharge' LIMIT 1");
$txCheck->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txRow = $txCheck->execute()->fetchArray(SQLITE3_ASSOC);
if (!$txRow) {
$txIns = $webDb->prepare("INSERT OR IGNORE INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '管理员同步', '在线充值(支付宝)', :d, 'pending', :t)");
$txIns->bindValue(':p', $player, SQLITE3_TEXT);
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
$txIns->execute();
}
} catch (Exception $e) {
// web_transactions 写入失败不影响主流程(poller 会兜底)
debugLog('[sync_platform] web_transactions写入失败', ['out_trade_no' => $outTradeNo, 'error' => $e->getMessage()]);
}
$synced++;
}
success([
'platform_total' => count($platformOrders),
'synced' => $synced,
'already_paid' => $alreadyPaid,
'foreign_skipped' => $foreign,
], "同步完成:平台{$synced}笔已同步,{$alreadyPaid}笔已存在,{$foreign}笔非本方订单已忽略");
} catch (\Throwable $e) {
error('同步失败: ' . $e->getMessage());
}
}
/**
* 确保 web_transactions 表存在
*/
function ensureWebTransactionsTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
type TEXT,
amount INTEGER DEFAULT 0,
operator TEXT,
reason TEXT,
detail TEXT,
status TEXT DEFAULT 'pending',
created_at INTEGER
)");
@$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)");
}
function ensurePayOrdersTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS pay_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
out_trade_no TEXT UNIQUE,
trade_no TEXT,
player_name TEXT NOT NULL,
tier_id INTEGER DEFAULT 0,
money TEXT,
bond_amount INTEGER DEFAULT 0,
status TEXT DEFAULT 'created',
name TEXT,
platform_sign TEXT,
submit_params TEXT,
created_at INTEGER,
paid_at INTEGER
)");
// 检查并添加缺失的列
$hasCol = false;
$res = $db->query("PRAGMA table_info(pay_orders)");
while ($col = $res->fetchArray(SQLITE3_ASSOC)) {
if ($col['name'] === 'submit_params') { $hasCol = true; break; }
}
if (!$hasCol) {
$db->exec("ALTER TABLE pay_orders ADD COLUMN submit_params TEXT");
}
}
/**
* 处理充值商店配置
*/
function handleShopConfig() {
$db = getOrdersDB();
ensureShopConfigsTable($db);
$method = getParam('method', 'list');
switch ($method) {
case 'list':
handleShopConfigList($db);
break;
case 'save':
handleShopConfigSave($db);
break;
case 'delete':
handleShopConfigDelete($db);
break;
case 'toggle':
handleShopConfigToggle($db);
break;
default:
error('未知操作');
}
}
/**
* 获取商店配置列表
*/
function handleShopConfigList($db) {
$stmt = $db->query("SELECT * FROM shop_configs ORDER BY id DESC");
$configs = [];
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
// 解析有效期
$expireInfo = parseExpireDate($row['offer_expire']);
$row['expire_valid'] = $expireInfo['valid'];
$row['expire_timestamp'] = $expireInfo['timestamp'];
$row['expire_display'] = $expireInfo['display'];
$configs[] = $row;
}
success($configs);
}
/**
* 保存商店配置(添加或编辑)
*/
function handleShopConfigSave($db) {
$id = intval(getParam('id', 0));
$itemName = getParam('item_name', '');
$stock = intval(getParam('stock', -1));
$temporaryOffer = getParam('temporary_offer', '');
$offerExpire = getParam('offer_expire', '');
$price = floatval(getParam('price', 0));
$discountPriceRaw = getParam('discount_price', '0');
$bondReward = getParam('bond_reward', '1-1');
$isActive = intval(getParam('is_active', 1));
// 验证库存
if ($stock < -1) {
error('库存不能小于-1');
}
// 验证售价
if ($price <= 0) {
error('售价必须大于0');
}
// 解析优惠后价格
// 支持两种格式:
// 1. 直接价格(如 1、2、1.5)→ 直接使用
// 2. 折扣(如 0.9、0.8)→ 计算 原价 × 折扣
// 硬锁规则:
// - 0.01元以下的商品不支持折扣
// - 优惠后价格必须小于原价
$discountPrice = 0;
$discountPriceRaw = trim($discountPriceRaw);
// 0.01元以下的商品不支持折扣
if ($price < 0.01) {
if ($discountPriceRaw !== '' && $discountPriceRaw !== '0') {
error('售价低于0.01元的商品不支持折扣');
}
} elseif ($discountPriceRaw !== '' && $discountPriceRaw !== '0') {
$discountValue = floatval($discountPriceRaw);
if ($discountValue <= 0) {
error('优惠后价格必须大于0');
}
// 判断是直接价格还是折扣(<1的数认为是折扣)
if ($discountValue < 1) {
// 折扣:原价 × 折扣
$discountPrice = round($price * $discountValue, 2);
} else {
// 直接价格
$discountPrice = round($discountValue, 2);
}
// 硬锁:优惠后价格必须小于原价
if ($discountPrice >= $price) {
error('优惠后价格必须小于原价(当前原价: ' . number_format($price, 2) . '元)');
}
// 硬锁:优惠后价格不能低于0.01元
if ($discountPrice < 0.01) {
error('优惠后价格不能低于0.01元');
}
}
// 验证债券范围格式(支持盲盒范围,如"1-10")
if (!preg_match('/^\d+(-\d+)?$/', $bondReward)) {
error('债券范围格式错误,应为数字或范围如"1-10"');
}
// 解析并验证有效期
$expireInfo = parseExpireDate($offerExpire);
if ($offerExpire && !$expireInfo['valid']) {
error('有效期格式错误,请使用yyyy-MM-dd或中文格式如"2026年7月16日"');
}
$now = time();
if ($id > 0) {
// 编辑
$stmt = $db->prepare("UPDATE shop_configs SET
item_name = :name,
stock = :stock,
temporary_offer = :offer,
offer_expire = :expire,
price = :price,
discount_price = :discount_price,
bond_reward = :bond,
is_active = :active,
updated_at = :updated
WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
} else {
// 添加
$stmt = $db->prepare("INSERT INTO shop_configs
(item_name, stock, temporary_offer, offer_expire, price, discount_price, bond_reward, is_active, created_at, updated_at)
VALUES (:name, :stock, :offer, :expire, :price, :discount_price, :bond, :active, :created, :updated)");
$stmt->bindValue(':created', $now, SQLITE3_INTEGER);
}
$stmt->bindValue(':name', $itemName, SQLITE3_TEXT);
$stmt->bindValue(':stock', $stock, SQLITE3_INTEGER);
$stmt->bindValue(':offer', $temporaryOffer, SQLITE3_TEXT);
$stmt->bindValue(':expire', $offerExpire, SQLITE3_TEXT);
$stmt->bindValue(':price', $price, SQLITE3_FLOAT);
$stmt->bindValue(':discount_price', $discountPrice, SQLITE3_FLOAT);
$stmt->bindValue(':bond', $bondReward, SQLITE3_TEXT);
$stmt->bindValue(':active', $isActive, SQLITE3_INTEGER);
$stmt->bindValue(':updated', $now, SQLITE3_INTEGER);
$stmt->execute();
$savedId = $id > 0 ? $id : $db->lastInsertRowID();
success(['id' => $savedId, 'discount_price' => $discountPrice], $id > 0 ? '配置已更新' : '配置已添加');
}
/**
* 删除商店配置
*/
function handleShopConfigDelete($db) {
$id = intval(getParam('id', 0));
if ($id <= 0) {
error('无效的配置ID');
}
$stmt = $db->prepare("DELETE FROM shop_configs WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
if ($db->changes() > 0) {
success([], '配置已删除');
} else {
error('配置不存在');
}
}
/**
* 切换商店配置上下架状态
*/
function handleShopConfigToggle($db) {
$id = intval(getParam('id', 0));
if ($id <= 0) {
error('无效的配置ID');
}
$stmt = $db->prepare("UPDATE shop_configs SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END, updated_at = :updated WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->bindValue(':updated', time(), SQLITE3_INTEGER);
$stmt->execute();
if ($db->changes() > 0) {
// 获取新状态
$check = $db->prepare("SELECT is_active FROM shop_configs WHERE id = :id");
$check->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $check->execute()->fetchArray(SQLITE3_ASSOC);
$newStatus = $row ? $row['is_active'] : 0;
success(['is_active' => $newStatus], $newStatus ? '已上架' : '已下架');
} else {
error('配置不存在');
}
}
/**
* 解析有效期日期(支持yyyy-MM-dd和中文格式如"2026年7月16日")
*/
function parseExpireDate($expireStr) {
if (empty($expireStr)) {
return ['valid' => true, 'timestamp' => 0, 'display' => '长期有效'];
}
$expireStr = trim($expireStr);
// 尝试解析yyyy-MM-dd格式
if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $expireStr)) {
$time = strtotime($expireStr . ' 23:59:59');
if ($time !== false) {
return [
'valid' => true,
'timestamp' => $time,
'display' => date('Y-m-d', $time) . ' 到期'
];
}
}
// 尝试解析中文格式(如"2026年7月16日")
if (preg_match('/(\d{4})年(\d{1,2})月(\d{1,2})日/', $expireStr, $matches)) {
$year = $matches[1];
$month = $matches[2];
$day = $matches[3];
$time = mktime(23, 59, 59, $month, $day, $year);
if ($time !== false) {
return [
'valid' => true,
'timestamp' => $time,
'display' => "{$year}-{$month}-{$day} 到期"
];
}
}
// 尝试使用strtotime解析其他格式
$time = strtotime($expireStr);
if ($time !== false && $time > time()) {
return [
'valid' => true,
'timestamp' => $time,
'display' => date('Y-m-d', $time) . ' 到期'
];
}
return ['valid' => false, 'timestamp' => 0, 'display' => '无效日期'];
}
+329
View File
@@ -0,0 +1,329 @@
<?php
/**
* 注册API
* POST ?action=check - 检查用户名是否已注册
* POST ?action=register - 注册新账号(需token)
* GET ?action=query - 查询账号信息(需token)
*/
// 防止任何输出污染JSON响应
ob_start();
require_once __DIR__ . '/../core.php';
ob_end_clean();
$action = getParam('action', 'check');
$token = getParam('token');
try {
switch ($action) {
case 'check':
registerCheck();
break;
case 'register':
registerAccount($token);
break;
case 'web_register':
webRegister();
break;
case 'query':
registerQuery($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===== 检查用户名 =====
function registerCheck() {
$player = getParam('player');
if (!$player) error('缺少player参数');
$db = getDB();
// 只查询users表实际存在的列
$stmt = $db->prepare("SELECT player_name, register_time, email FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
success([
'exists' => !!$row,
'player' => $player,
'info' => $row ?: null
]);
}
// ===== 注册账号 =====
function registerAccount($token) {
$player = getParam('player');
$passwordHash = getParam('password_hash');
$salt = getParam('salt');
$email = getParam('email', '');
$ip = getParam('ip', '');
if (!$player) error('缺少player');
if (!$passwordHash) error('缺少password_hash');
if (!$salt) error('缺少salt');
// ★ 第56批:邮箱后缀校验(与Java端一致)
if ($email !== '' && !checkEmailSuffix($email)) {
$mode = '默认';
$cfgDb = getDB();
$cfgRow = $cfgDb->query("SELECT mode FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if ($cfgRow) $mode = (string)$cfgRow['mode'];
if ($mode === '白名单') error('该邮箱后缀不在允许列表中');
else if ($mode === '黑名单') error('该邮箱后缀被禁止使用');
else error('该邮箱后缀不可用');
}
// 验证token(注册需要token)
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo) {
$isPreview = false;
validateAndUseToken($token);
}
}
$db = getDB();
// 检查是否已注册
$stmt = $db->prepare("SELECT 1 FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
if ($result->fetchArray()) {
error('账号已存在: ' . $player);
}
if ($isPreview) {
preview([
'player' => $player,
'email' => $email,
'would_register' => true
], '预览模式 - 注册不会生效');
}
// 执行注册
$now = time();
$stmt = $db->prepare("INSERT INTO users (player_name, register_time, email) VALUES (:name, :time, :email)");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
$stmt->execute();
// 记录
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, created_at) VALUES (:player, 'register', 0, 'Web端注册', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
success(['player' => $player], '注册成功');
}
// ===== Web注册请求(由Web端提交,等待插件同步)=====
function webRegister() {
$webToken = getParam('web_token');
$password = getParam('password');
$email = getParam('email', '');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$webToken) error('缺少web_token');
if (!$password) error('缺少password');
if (strlen($password) < 6) error('密码长度至少6位');
// ★ 第56批:邮箱后缀校验(与Java端一致)
if ($email !== '' && !checkEmailSuffix($email)) {
$mode = '默认';
$cfgDb = getDB();
$cfgRow = $cfgDb->query("SELECT mode FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if ($cfgRow) $mode = (string)$cfgRow['mode'];
if ($mode === '白名单') error('该邮箱后缀不在允许列表中');
else if ($mode === '黑名单') error('该邮箱后缀被禁止使用');
else error('该邮箱后缀不可用');
}
// 验证web_token
$db = getDB();
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$tokenRow = $result->fetchArray(SQLITE3_ASSOC);
if (!$tokenRow) error('无效的Web登录Token');
$createdAt = (int)($tokenRow['created_at'] ?? 0);
$expireSeconds = (int)($tokenRow['expire_seconds'] ?? 600);
if (time() - $createdAt > $expireSeconds) error('Web登录Token已过期');
$playerName = $tokenRow['player_name'];
// 检查是否已注册
if (isPlayerRegistered($playerName)) {
error('玩家已注册: ' . $playerName);
}
// 生成密码凭证(与插件相同的SHA-256算法)
$saltBytes = random_bytes(16);
$salt = base64_encode($saltBytes);
$passwordHash = base64_encode(hash('sha256', $saltBytes . $password, true));
// ★ 创建web_register_requests表记录(等待插件同步)
$db->exec("CREATE TABLE IF NOT EXISTS web_register_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
email TEXT DEFAULT '',
ip_address TEXT DEFAULT '',
created_at INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
processed_at INTEGER DEFAULT 0
)");
// 插入注册请求
$stmt = $db->prepare("INSERT INTO web_register_requests (player_name, password_hash, salt, email, ip_address, created_at) VALUES (:player, :hash, :salt, :email, :ip, :time)");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':hash', $passwordHash, SQLITE3_TEXT);
$stmt->bindValue(':salt', $salt, SQLITE3_TEXT);
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
$stmt->bindValue(':ip', $ipAddress, SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
$requestId = $db->lastInsertRowID();
success([
'player' => $playerName,
'request_id' => $requestId,
'email' => $email
], '注册请求已提交,等待游戏同步');
}
// ===== 查询账号信息 =====
function registerQuery($token) {
$player = getParam('player');
if (!$player) error('缺少player');
$db = getDB();
// 只查询users表实际存在的列
$stmt = $db->prepare("SELECT player_name, register_time, last_login_time, email, points, gift_stage, total_online_time FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) {
error('账号不存在: ' . $player);
}
// 处理Java插件推送的毫秒级时间戳,转换为秒级
if ($row['register_time'] && $row['register_time'] > 1000000000000) {
$row['register_time'] = (int)($row['register_time'] / 1000);
}
if ($row['last_login_time'] && $row['last_login_time'] > 1000000000000) {
$row['last_login_time'] = (int)($row['last_login_time'] / 1000);
}
// ★ 获取债券余额
$bonds = 0;
try {
$bondStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$bondStmt->bindValue(':name', $player, SQLITE3_TEXT);
$bondResult = $bondStmt->execute();
$bondRow = $bondResult->fetchArray(SQLITE3_ASSOC);
if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0);
} catch (\Throwable $e) {}
// ★ 获取累计在线天数(不重复日期计数)
$totalOnlineDays = 0;
try {
$odStmt = $db->prepare("SELECT COUNT(DISTINCT login_date) as cnt FROM player_daily_logins WHERE player_name = :name");
$odStmt->bindValue(':name', $player, SQLITE3_TEXT);
$odResult = $odStmt->execute();
$odRow = $odResult->fetchArray(SQLITE3_ASSOC);
if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0);
} catch (\Throwable $e) {}
// ★ 获取累计签到天数
$totalCheckinDays = 0;
try {
$ciStmt = $db->prepare("SELECT COUNT(DISTINCT checkin_date) as cnt FROM player_checkins WHERE player_name = :name");
$ciStmt->bindValue(':name', $player, SQLITE3_TEXT);
$ciResult = $ciStmt->execute();
$ciRow = $ciResult->fetchArray(SQLITE3_ASSOC);
if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0);
} catch (\Throwable $e) {}
// 合并额外数据
$row['bonds'] = $bonds;
$row['total_online_days'] = $totalOnlineDays;
$row['total_checkin_days'] = $totalCheckinDays;
// 邮箱脱敏
if (!empty($row['email'])) {
$parts = explode('@', $row['email']);
if (count($parts) === 2) {
$row['masked_email'] = str_repeat('*', min(strlen($parts[0]), 3)) . '@' . $parts[1];
} else {
$row['masked_email'] = '***';
}
} else {
$row['masked_email'] = '';
}
// ★ 严格校验:必须有有效token且token对应的玩家必须与查询的玩家匹配(除非是管理员token)
if (!$token) {
// 无token:隐藏敏感信息
$previewRow = [
'player_name' => $row['player_name'],
'register_time' => $row['register_time'],
'last_login_time' => $row['last_login_time'],
'email' => '',
'masked_email' => '',
'points' => 0,
'gift_stage' => 0,
'total_online_time' => 0,
'bonds' => 0,
'total_online_days' => 0,
'total_checkin_days' => 0
];
preview($previewRow, '预览模式 - 部分信息已隐藏');
return;
}
$tokenInfo = validateTokenSilent($token);
if (!$tokenInfo) {
error('无效token,请先登录', 401);
}
$tokenPurpose = $tokenInfo['purpose'] ?? '';
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
// 管理员token:返回完整信息
success($row);
} elseif ($tokenInfo['player'] === $player) {
// 普通token:只能查自己的完整信息
success($row);
} else {
// 不是自己的账号,返回脱敏信息
$maskedRow = [
'player_name' => $row['player_name'],
'register_time' => $row['register_time'],
'last_login_time' => $row['last_login_time'],
'email' => '',
'masked_email' => '***',
'points' => 0,
'gift_stage' => 0,
'total_online_time' => 0,
'bonds' => 0,
'total_online_days' => 0,
'total_checkin_days' => 0
];
preview($maskedRow, '预览模式 - 您只能查看自己的账号信息');
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* 异地登录检测回调(Java 插件在玩家加入服务器时异步调用)
*
* 调用方式:GET api/security_alert.php
* ?action=login_location_alert&secret=...&name=<玩家>&ip=<登录IP>
*
* 职责:
* 1. 用 ip9.com.cn 查询本次登录 IP 归属
* 2. 与上次登录归属比较,不一致且非首次 → 向绑定邮箱发送提醒邮件
* 3. 邮件内含「冻结账号」与「修改密码」两个链接
* 4. 更新 users.last_login_location / last_login_ip
*/
require_once __DIR__ . '/../core.php';
header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? '';
if ($action !== 'login_location_alert') {
echo json_encode(['success' => false, 'message' => '未知操作']);
exit;
}
$secret = $_GET['secret'] ?? '';
if (!$secret || $secret !== SECRET_KEY) {
echo json_encode(['success' => false, 'message' => '密钥错误']);
exit;
}
$name = $_GET['name'] ?? '';
$ip = $_GET['ip'] ?? '';
if (!$name || !$ip) {
echo json_encode(['success' => false, 'message' => '参数缺失']);
exit;
}
$db = getDB();
// 查询账号信息
$stmt = $db->prepare(
"SELECT email, last_login_location, last_login_ip, frozen
FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
// 查询本次 IP 归属
$location = queryLoginIpLocation($ip);
if ($location === null) {
// 归属查询失败:仅更新 IP,不阻断、不提醒
$upd = $db->prepare(
"UPDATE users SET last_login_ip = :ip WHERE player_name = :name");
$upd->bindValue(':ip', $ip, SQLITE3_TEXT);
$upd->bindValue(':name', $name, SQLITE3_TEXT);
$upd->execute();
echo json_encode(['success' => true, 'message' => 'IP归属查询失败,仅更新IP']);
exit;
}
if (!$row) {
// 理论上 Java 已 syncUserOnJoin,不会走到这里
echo json_encode(['success' => true, 'message' => '无用户记录']);
exit;
}
$lastLoc = $row['last_login_location'] ?? '';
$lastIp = $row['last_login_ip'] ?? '';
$email = $row['email'] ?? '';
// 更新归属与 IP
$upd = $db->prepare(
"UPDATE users SET last_login_ip = :ip,
last_login_location = :loc WHERE player_name = :name");
$upd->bindValue(':ip', $ip, SQLITE3_TEXT);
$upd->bindValue(':loc', $location, SQLITE3_TEXT);
$upd->bindValue(':name', $name, SQLITE3_TEXT);
$upd->execute();
// 首次登录(无任何历史记录)不提醒
if ($lastLoc === '' && $lastIp === '') {
echo json_encode(['success' => true, 'message' => '首次登录,已记录']);
exit;
}
// 归属变化才发送提醒邮件
if ($lastLoc !== $location && !empty($email)) {
// 生成冻结 token 并持久化,供邮件中的冻结链接使用
$freezeToken = bin2hex(random_bytes(16));
$ut = $db->prepare("UPDATE users SET freeze_token = :t WHERE player_name = :name");
$ut->bindValue(':t', $freezeToken, SQLITE3_TEXT);
$ut->bindValue(':name', $name, SQLITE3_TEXT);
$ut->execute();
sendLocationAlertEmail($name, $email, $ip, $location, $lastLoc, $freezeToken);
}
echo json_encode(['success' => true, 'message' => 'ok']);
+711
View File
@@ -0,0 +1,711 @@
<?php
/**
* 商城API
* GET ?action=list - 商品列表(无需token,公开接口)
* POST ?action=buy - 购买商品(需token)
* GET ?action=categories - 分类列表(无需token,公开接口)
*/
// 防止任何输出污染JSON响应
while (ob_get_level() > 0) { ob_end_clean(); }
require_once __DIR__ . '/../core.php';
// 再次确保清理前置输出
while (ob_get_level() > 0) { ob_end_clean(); }
$action = getParam('action', 'list');
$token = getParam('token');
try {
switch ($action) {
case 'list':
shopList($token);
break;
case 'buy':
shopBuy($token);
break;
case 'buy_cart':
shopBuyCart($token);
break;
case 'cart_config':
cartConfig();
break;
case 'categories':
shopCategories($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
// ★ 强制回滚可能残留的事务(防止database is locked连锁故障)
try { $db = getDB(); $db->exec('ROLLBACK'); } catch (\Throwable $_) {}
while (ob_get_level() > 0) ob_end_clean();
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===== 商品列表(公开,无需token) =====
function shopList($token = null) {
try {
$db = getDB();
$category = getParam('category');
if ($category) {
$stmt = $db->prepare("SELECT * FROM shop_items WHERE category = :cat ORDER BY id");
$stmt->bindValue(':cat', $category, SQLITE3_TEXT);
$result = $stmt->execute();
} else {
$result = $db->query("SELECT * FROM shop_items ORDER BY category, id");
}
$items = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$items[] = $row;
}
// 直接返回数据,不验证token(公开接口)
$isPreview = !$token;
if ($token && validateTokenSilent($token)) {
$isPreview = false;
}
if ($isPreview) {
preview($items, '预览模式 - 无有效token');
} else {
success($items);
}
} catch (Exception $e) {
error('商城加载失败: ' . $e->getMessage(), 500);
}
}
// validateTokenSilent() 已在 core.php 中定义,不再重复声明
// ===== 分类列表 =====
function shopCategories() {
$db = getDB();
$result = $db->query("SELECT DISTINCT category, COUNT(*) as item_count FROM shop_items GROUP BY category");
$cats = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$cats[] = $row;
}
success($cats);
}
// ===== 购买商品 =====
function shopBuy($token) {
$itemId = getParam('item_id');
$amount = (int)getParam('amount', 1);
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$itemId) error('缺少item_id');
if (!$player) error('缺少player');
if ($amount < 1) error('数量无效');
// ★ 双保险验证:购买操作需要密码确认
$isPreview = true;
$isOnline = false;
$isRegistered = false;
if ($token) {
// 先检查是否为普通管理token
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
if (validateToken($token)) {
validateAndUseToken($token);
}
} else {
// weblogin token → 走双保险验证
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
if ($accessResult['ok']) {
$isPreview = false;
$player = $accessResult['player'];
$isOnline = $accessResult['online'] ?? false;
$isRegistered = $accessResult['registered'] ?? false;
} elseif ($accessResult['mode'] === 'need_password') {
jsonResponse([
'success' => false,
'need_password' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} elseif ($accessResult['mode'] === 'need_game_login') {
jsonResponse([
'success' => false,
'need_game_login' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} elseif ($accessResult['mode'] === 'need_register') {
jsonResponse([
'success' => false,
'need_register' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} else {
error($accessResult['message'], 401);
}
}
}
// 查询商品
$db = getDB();
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$result = $stmt->execute();
$item = $result->fetchArray(SQLITE3_ASSOC);
if (!$item) error('商品不存在: ' . $itemId);
// 检查库存
if ($item['stock'] == 0) error('商品已售罄');
if ($item['stock'] > 0 && $item['stock'] < $amount) error('库存不足');
$totalPrice = $item['buy_price'] * $amount;
// 检查玩家余额
if (!$isPreview && $totalPrice > 0) {
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
$balanceResult = $balanceStmt->execute();
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
if (!$balanceRow) {
error('玩家余额查询失败');
}
$balance = (int)$balanceRow['amount'];
if ($balance < $totalPrice) {
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $totalPrice . ' 债券');
}
}
if ($isPreview) {
// 预览模式:返回计算结果但不执行
preview([
'item_id' => $itemId,
'item_name' => $item['display_name'],
'amount' => $amount,
'unit_price' => $item['buy_price'],
'total_price' => $totalPrice,
'stock_before' => $item['stock'],
'stock_after' => $item['stock'] > 0 ? $item['stock'] - $amount : -1,
'player' => $player
], '预览模式 - 购买不会生效');
}
// ★ 实际购买 — 包装在事务中,防止database is locked
$db->exec('BEGIN IMMEDIATE');
try {
// 更新库存(同时写admin_stock确保Java同步)
if ($item['stock'] > 0) {
$newStock = $item['stock'] - $amount;
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->execute();
} else {
// 无限库存,只更新销量
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->execute();
}
// 记录交易
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_buy', :amount, :reason, :detail, 'pending', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER);
$stmt->bindValue(':reason', "购买: {$item['display_name']} x{$amount}", SQLITE3_TEXT);
$stmt->bindValue(':detail', json_encode([
'item_id' => $itemId,
'amount' => $amount,
'unit_price' => $item['buy_price'],
'tx_id' => bin2hex(random_bytes(8))
], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
// ★ 写入sync_requests(必须在success/exit之前!)
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt2->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
error('购买失败: ' . $e->getMessage(), 500);
}
success([
'item_id' => $itemId,
'item_name' => $item['display_name'],
'amount' => $amount,
'total_price' => $totalPrice,
'player' => $player
], '购买成功');
}
// ===== 购物车配置读取(批量优化:单次查询读取所有需要的配置) =====
function getShopConfig($key, $default = null) {
try {
$db = getDB();
$stmt = $db->prepare("SELECT cfg_value FROM shop_config WHERE cfg_key = :k");
$stmt->bindValue(':k', $key, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
return $row ? $row['cfg_value'] : $default;
} catch (Exception $e) {
return $default;
}
}
/**
* 批量读取多个shop_config键(一次查询替代N次getShopConfig调用)
* @return array key => value (不存在的key不返回)
*/
function getShopConfigs(array $keys) {
if (empty($keys)) return [];
try {
$db = getDB();
$placeholders = implode(',', array_fill(0, count($keys), '?'));
$stmt = $db->prepare("SELECT cfg_key, cfg_value FROM shop_config WHERE cfg_key IN ($placeholders)");
foreach ($keys as $i => $k) {
$stmt->bindValue($i + 1, $k, SQLITE3_TEXT);
}
$result = $stmt->execute();
$map = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$map[$row['cfg_key']] = $row['cfg_value'];
}
return $map;
} catch (Exception $e) {
return [];
}
}
// 返回购物车折扣/加价配置(前端展示用,★ 批量读取)
function cartConfig() {
$cfg = getShopConfigs(['cart_backpack_rate', 'cart_shulker_rate', 'packmoney', 'green_discount']);
success([
'backpack_rate' => (float)($cfg['cart_backpack_rate'] ?? '0.98'),
'shulker_rate' => (float)($cfg['cart_shulker_rate'] ?? '1.00'),
'packmoney' => (float)($cfg['packmoney'] ?? '5'),
'green_discount' => (float)($cfg['green_discount'] ?? '10')
]);
}
// ===== 购物车结算(多商品一次性购买) =====
function shopBuyCart($token) {
$__t0 = microtime(true);
debugLog('[buy_cart] 进入请求');
$rawItems = getParam('items');
$settlement = getParam('settlement', 'backpack'); // backpack=塞背包, shulker=潜影盒打包
$shulkerColor = getParam('shulker_color', 'default'); // 潜影盒颜色(default原色免费,其它+2元)
$payMode = getParam('pay_mode', 'bond'); // bond=债券扣款, cash=现金仅记账
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$rawItems) error('购物车为空');
$items = json_decode($rawItems, true);
if (!is_array($items) || count($items) === 0) error('购物车格式错误');
// 校验每一项
$parsed = [];
foreach ($items as $it) {
if (!isset($it['item_id']) || !isset($it['amount'])) error('商品项缺少字段');
$pid = (string)$it['item_id'];
$amt = (int)$it['amount'];
if ($amt < 1) error('数量无效: ' . $pid);
$parsed[] = ['item_id' => $pid, 'amount' => $amt];
}
if (!$player) error('缺少player');
// ★ 双保险验证(与单件购买一致)
$isPreview = true;
$isAdminToken = false;
$isOnline = false;
$isRegistered = false;
$isCashier = false;
$isAdminSession = false;
$operatorDiscountLimit = 0;
$cashierRow = null;
$orderNo = '';
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
$isAdminToken = true; // ★ 记录代购令牌身份,避免下方大额复检重复校验已消费的token导致误判
if (validateToken($token)) validateAndUseToken($token);
} else {
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
if ($accessResult['ok']) {
$isPreview = false;
$player = $accessResult['player'];
$isOnline = $accessResult['online'] ?? false;
$isRegistered = $accessResult['registered'] ?? false;
} elseif (($accessResult['mode'] ?? '') === 'need_password') {
jsonResponse(['success' => false, 'need_password' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
} elseif (($accessResult['mode'] ?? '') === 'need_game_login') {
jsonResponse(['success' => false, 'need_game_login' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
} elseif (($accessResult['mode'] ?? '') === 'need_register') {
jsonResponse(['success' => false, 'need_register' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
} else {
error($accessResult['message'], 401);
}
}
} else {
// 收银员/管理员会话代购通路(独立收银台,无需token)
if (isCashierLoggedIn()) {
$isPreview = false;
$isCashier = true;
$cashierRow = getCurrentCashier();
$operatorDiscountLimit = (int)($cashierRow['discount_limit_percent'] ?? 0);
} elseif (isAdminLoggedIn()) {
$isPreview = false;
$isAdminSession = true;
}
}
// 结算模式对应的折扣/加价系数(★ 批量读取所有配置键,1次查询替代4次)
$cfg = getShopConfigs(['cart_shulker_rate', 'packmoney', 'cart_backpack_rate', 'green_discount']);
if ($settlement === 'shulker') {
$rate = (float)($cfg['cart_shulker_rate'] ?? '1.00');
$modeName = '潜影盒打包';
// 潜影盒颜色额外收费(原色/紫色免费,其它加收打包费,打包费来自配置 packmoney)
$packMoney = (float)($cfg['packmoney'] ?? '5');
$colorFee = ($shulkerColor !== 'default' && $shulkerColor !== 'purple') ? $packMoney : 0;
// 颜色名称映射
$colorNames = ['default'=>'原色','purple'=>'原色','white'=>'白色','black'=>'黑色','red'=>'红色','blue'=>'蓝色','green'=>'绿色','yellow'=>'黄色','orange'=>'橙色'];
$colorName = $colorNames[$shulkerColor] ?? $shulkerColor;
$ecoPct = 0;
} else {
$settlement = 'backpack';
$rawBackpackRate = (float)($cfg['cart_backpack_rate'] ?? '0.98');
// ★ 修复(2026-07-08):背包(环保单)模式必须尊重游戏内 green_discount 设置。
// green_discount 语义:10=不打折,9=9折(×0.9),8.5=8.5折(×0.85)。
// 游戏内设置了打折(green_discount < 10)时,以游戏折扣为准(如 setgreen 9 → ×0.9),
// 覆盖默认的 cart_backpack_rate(0.98=98折);未设置(=10)时仍走 cart_backpack_rate 保持原有环保优惠。
$greenDiscount = (float)($cfg['green_discount'] ?? 10);
$rate = ($greenDiscount < 10) ? ($greenDiscount / 10) : $rawBackpackRate;
$modeName = '塞背包(环保单)';
$colorFee = 0;
$colorName = '';
$ecoPct = 0;
}
$db = getDB();
$lines = [];
$subtotal = 0;
foreach ($parsed as &$p) {
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
$result = $stmt->execute();
$item = $result->fetchArray(SQLITE3_ASSOC);
if (!$item) error('商品不存在: ' . $p['item_id']);
if ($item['stock'] == 0) error('商品已售罄: ' . $item['display_name']);
if ($item['stock'] > 0 && $item['stock'] < $p['amount']) error('库存不足: ' . $item['display_name'] . '(剩余' . $item['stock'] . ')');
// ★ 发货前验证:检查商品material是否为Java端可识别的有效值
// 防止扣款后发现Java无法匹配该材料(如附魔书ID被误写为material字段)导致钱扣了货没给
$mat = strtoupper(trim($item['material']));
$validMaterial = (
// 标准Bukkit Material:大写字母+数字+下划线,如 DIAMOND_SWORD, ENCHANTED_BOOK
preg_match('/^[A-Z][A-Z0-9_]+$/', $mat) &&
// 排除明显不是合法Material的值(如附魔书ID MENDING_I 等——这些应写在id而非material列)
// ENCHANTED_BOOK 是唯一合法的附魔书Material
($mat === 'ENCHANTED_BOOK' || !preg_match('/_(I{1,3}|II|III|IV|V|[1-5])$/', $mat))
);
// 允许 id 字段是附魔书格式(如 MENDING_I),此时 material 必须是 ENCHANTED_BOOK
if (!$validMaterial) {
$pid = strtoupper(trim($p['item_id']));
$isEnchantedId = preg_match('/^[A-Z][A-Z0-9]*_[IVX12]+$/', $pid);
if (!($isEnchantedId && $mat === 'ENCHANTED_BOOK') && $mat !== 'ENCHANTED_BOOK') {
error('商品无法发放(material无效): ' . $item['display_name']
. ' [material=' . $item['material'] . ', id=' . $p['item_id'] . ']'
. ' — 请管理员检查该商品的material字段是否正确');
}
}
$lineTotal = (int)$item['buy_price'] * $p['amount'];
$subtotal += $lineTotal;
$p['item'] = $item;
$lines[] = [
'item_id' => $p['item_id'],
'name' => $item['display_name'],
'unit_price' => (int)$item['buy_price'],
'amount' => $p['amount'],
'line_total' => $lineTotal
];
}
unset($p);
// 先按费率计算,再叠加环保单折扣(不打包/塞背包,折扣率为折数),最后加颜色打包费
// green_discount 语义:10=不打折,9.9=9.9折(×0.99),9.8=9.8折(×0.98)
$baseAfterRate = (int)round($subtotal * $rate);
if ($ecoPct > 0) $baseAfterRate = (int)round($baseAfterRate * $ecoPct / 10);
$totalPrice = $baseAfterRate + $colorFee;
$saved = $subtotal - $totalPrice; // 折扣省下的(潜影盒加价+颜色费时为负)
// ===== 收银员/管理员手动折扣(服务端强制上限,前端声称不可信)=====
$discountPercent = 0;
$discountAmount = 0;
$finalPrice = $totalPrice;
$canDiscount = ($isCashier || $isAdminToken || $isAdminSession);
if ($canDiscount) {
$reqDiscount = (float)getParam('discount_percent', 0);
if ($reqDiscount < 0) $reqDiscount = 0;
if ($reqDiscount > 100) $reqDiscount = 100;
// 收银员受自身折扣上限限制;管理员无限制(最大100)
$limit = $isCashier ? $operatorDiscountLimit : 100;
if ($reqDiscount > $limit) {
error('折扣超过权限上限(收银员最大可打' . $limit . '% off)', 403);
}
$discountPercent = $reqDiscount;
if ($discountPercent > 0) {
$discountAmount = (int)round($totalPrice * $discountPercent / 100);
$finalPrice = $totalPrice - $discountAmount;
}
}
// ★ 安全检查:服务端计算的实际扣款额≥1000时,必须已通过密码验证(防止前端绕过)
// 管理员代购令牌(admin/all)已在上方标记$isAdminToken并消费,此处不再重新校验token,
// 而是校验代购操作者(管理员)登录密码;普通玩家通路走validateWebAccess已在上方完成验证;
// 收银员通路已通过收银员会话登录,视为已授权,无需额外密码
if ($finalPrice >= 1000 && !$isPreview) {
if ($isCashier) {
// 收银员已登录即授权,无需额外密码
} elseif ($isAdminToken || $isAdminSession) {
// 代购通路:校验管理员登录密码(代购操作者的密码),而非目标玩家游戏密码
// ★ 首次部署:ADMIN_PASS 还是占位符 = 没配置密码,此时任何"管理员密码"
// 都无从校验 → 一律按未通过处理(引导页会要求先设置密码)。
$passReady = function_exists('secAdminPassConfigured')
? secAdminPassConfigured()
: (defined('ADMIN_PASS') && trim((string)ADMIN_PASS) !== ''
&& trim((string)ADMIN_PASS) !== 'REPLACE_ME_ADMIN_PASSWORD');
if (!$password || trim($password) === '' || !$passReady || $password !== ADMIN_PASS) {
jsonResponse(['success' => false, 'need_password' => true,
'player' => $player, 'message' => '金额≥1000债券需确认管理员密码'], 401);
}
} elseif (!isset($accessResult['ok'])) {
// 非管理员且未通过密码验证 → 拒绝
error('大额交易需验证游戏登录密码', 401);
}
}
// ★ 现金收款权限校验:收银员需管理员授予 can_cash 权限
if ($payMode === 'cash') {
if ($isCashier && empty($cashierRow['can_cash'])) {
error('该收银员无现金收款权限(需管理员在收银员管理中开启)', 403);
}
// 现金模式:仅记账,不扣玩家债券(跳过余额校验与扣款)
}
// 检查余额(现金模式跳过:仅记账)
if (!$isPreview && $finalPrice > 0 && $payMode !== 'cash') {
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
$balanceResult = $balanceStmt->execute();
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
if (!$balanceRow) error('玩家余额查询失败');
$balance = (int)$balanceRow['amount'];
if ($balance < $finalPrice) {
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $finalPrice . ' 债券');
}
}
if ($isPreview) {
preview([
'settlement' => $settlement,
'mode_name' => $modeName,
'rate' => $rate,
'items' => $lines,
'subtotal' => $subtotal,
'total_price' => $totalPrice,
'saved' => $saved,
'player' => $player
], '预览模式 - 结算不会生效');
}
// ★ 实际购买 — 包装在事务中
$db->exec('BEGIN IMMEDIATE');
try {
foreach ($parsed as $p) {
$item = $p['item'];
if ($item['stock'] > 0) {
$newStock = $item['stock'] - $p['amount'];
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
$stmt->execute();
} else {
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
$stmt->execute();
}
}
// 记录一条合并交易
$reasonItems = array_map(function ($l) { return $l['name'] . ' x' . $l['amount']; }, $lines);
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_cart', :amount, :reason, :detail, 'pending', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $finalPrice, SQLITE3_INTEGER);
$stmt->bindValue(':reason', "购物车结算({$modeName}" . ($colorName ? "-{$colorName}潜影盒" : '') . "): " . implode('、', $reasonItems), SQLITE3_TEXT);
$detailData = [
'settlement' => $settlement,
'rate' => $rate,
'subtotal' => $subtotal,
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'pay_mode' => $payMode,
'items' => $lines,
'tx_id' => bin2hex(random_bytes(8))
];
if ($settlement === 'shulker') {
$detailData['shulker_color'] = $shulkerColor;
$detailData['color_name'] = $colorName;
$detailData['color_fee'] = $colorFee;
}
$stmt->bindValue(':detail', json_encode($detailData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
// 写入sync_requests
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt2->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
error('购物车结算失败: ' . $e->getMessage(), 500);
}
// ★ 购买主流程提交成功后,再记录收银台订单到独立 orders.db。
// 与 web.db 事务解耦:订单写失败仅影响日志/小票,绝不回滚已成功的库存与发药。
$orderNo = '';
if ($isCashier || $isAdminToken || $isAdminSession) {
$operatorType = $isCashier ? 'cashier' : 'admin';
$operatorName = $isCashier ? ($cashierRow['username'] ?? '') : 'admin';
try {
$orderNo = recordCashierOrder([
'operator_type' => $operatorType,
'operator_name' => $operatorName,
'player_name' => $player,
'items_detail' => $lines,
'subtotal' => $subtotal,
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'settlement' => $settlement,
'payment_method' => $payMode
]);
} catch (\Throwable $e) {
$orderNo = '';
debugLog('buy_cart: 订单记录失败(不影响购买): ' . $e->getMessage());
}
}
// ★ 推送小票书给在线玩家(写入 web_admin_changes → Java pollAdminChanges 拾取并发放 Written Book)
// 注意:小票推送失败绝不影响已成功的购买主流程与订单记录,单独容错。
if (!empty($orderNo) && !empty($player)) {
try {
pushReceiptBookToPlayer($player, [
'order_no' => $orderNo,
'order_time' => date('Y-m-d H:i:s'),
'order_player' => $player,
'operator' => ($isCashier ? ($cashierRow['username'] ?? '') : 'admin'),
'settlement' => $settlement,
'pay_method' => $payMode,
'total_price' => (int)$finalPrice,
'items_text' => array_map(function($l) { return $l['name'] . ' x' . $l['amount'] . ' ... ' . $l['line_total'] . '债'; }, $lines)
]);
} catch (\Throwable $e) {
debugLog('buy_cart: 小票书推送失败(不影响购买): ' . $e->getMessage());
}
}
debugLog('[buy_cart] 完成, 耗时=' . round((microtime(true) - $__t0) * 1000) . 'ms, orderNo=' . ($orderNo ?? ''));
success([
'settlement' => $settlement,
'mode_name' => $modeName,
'rate' => $rate,
'items' => $lines,
'subtotal' => $subtotal,
'total_price' => $finalPrice,
'original_price' => $totalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'saved' => $saved,
'player' => $player,
'order_no' => $orderNo ?? ''
], '购物车结算成功');
}
// ===== Weblogin Token验证(用于商城购买)=====
function validateWebloginTokenForShop($webToken) {
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (Exception $e) {
return false;
}
}
// ===== 推送打包小票书给在线玩家(写入 web_admin_changes → Java 拾取发放 Written Book)=====
function pushReceiptBookToPlayer($playerName, $orderData) {
try {
$db = getDB();
// 确保表存在
$db->exec("CREATE TABLE IF NOT EXISTS web_admin_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
change_type TEXT NOT NULL,
target_id TEXT NOT NULL,
target_name TEXT DEFAULT '',
change_data TEXT DEFAULT '{}',
created_at INTEGER DEFAULT 0,
acknowledged INTEGER DEFAULT 0,
acked_at INTEGER DEFAULT 0,
status TEXT DEFAULT 'done'
)");
$stmt = $db->prepare("INSERT INTO web_admin_changes (change_type, target_id, target_name, change_data, created_at, status)
VALUES ('give_receipt_book', '0', :player, :data, :time, 'pending')");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':data', json_encode($orderData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
} catch (Exception $e) {
error_log('[小票书] 推送小票书失败: ' . $e->getMessage());
}
}
+1388
View File
File diff suppressed because it is too large Load Diff
+5952
View File
File diff suppressed because it is too large Load Diff
+982
View File
@@ -0,0 +1,982 @@
<?php
/**
* 工单系统API
*
* 用户端:提交工单、查看我的工单、查看详情、回复(MD)、撤销
* 管理员端:查看所有工单、回复、驳回、分配服务商
* 服务商端:抢单大厅、我处理的工单、回复、标记完结
*/
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件)
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
register_shutdown_function(function() {
while (ob_get_level() > 0) { @ob_end_flush(); }
});
require_once __DIR__ . '/../core.php';
// 工单附件图:兰空图床客户端(本文件不含敏感值,凭据在 lsky_keys.php)
require_once __DIR__ . '/../lsky.php';
if (session_status() === PHP_SESSION_NONE) { session_start(); }
$action = getParam('action', '');
try {
switch ($action) {
// ===== 用户端 =====
case 'create': ticketCreate(); break;
case 'my_list': ticketMyList(); break;
case 'detail': ticketDetail(); break;
case 'reply': ticketReply(); break;
case 'withdraw': ticketWithdraw(); break;
// ===== 管理员端 =====
case 'list_all': ticketListAll(); break;
case 'admin_reply': ticketAdminReply(); break;
case 'reject': ticketReject(); break;
case 'assign': ticketAssign(); break;
case 'admin_create': ticketAdminCreate(); break;
case 'admin_complete': ticketAdminComplete(); break;
// ===== 服务商端 =====
case 'provider_list': ticketProviderList(); break;
case 'available': ticketAvailable(); break;
case 'grab': ticketGrab(); break;
case 'provider_reply': ticketProviderReply(); break;
case 'complete': ticketProviderComplete(); break;
// ===== 通用 =====
case 'upload_image': ticketUploadImage(); break;
case 'providers': ticketProviders(); break;
case 'provider_check': ticketProviderCheck(); break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
error('服务器错误: ' . $e->getMessage());
}
// ============================================================
// 工具函数
// ============================================================
function requireWebToken() {
$token = getParam('token');
if (!$token) error('缺少token', 401);
$info = validateTokenSilent($token);
if (!$info) error('token无效或已过期', 401);
return $info;
}
function requireAdmin() {
if (!isAdminLoggedIn()) error('未登录管理后台', 401);
}
/**
* 检查是否为管理员或授权的服务商用户
* 管理员通过session认证,用户通过token认证
*/
function getAuthUser() {
if (isAdminLoggedIn()) return ['player' => 'admin', 'role' => 'admin'];
return requireWebToken();
}
function isServiceProviderInGame($playerName) {
// 优先从Web.db的web_service_providers表读取(Java同步数据)
$db = getDB();
try {
$stmt = $db->prepare("SELECT 1 FROM web_service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray();
if ($row !== false) return true;
} catch (\Throwable $e) { /* 表可能不存在 */ }
// 回退:尝试读取Java的login.db(本地开发环境)
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) return false;
try {
$loginDb = new SQLite3($loginDbPath);
$loginDb->enableExceptions(true);
$stmt = $loginDb->prepare("SELECT 1 FROM service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray();
$loginDb->close();
return $row !== false;
} catch (Exception $e) {
return false;
}
}
function getServiceProviderRole($playerName) {
// 优先从Web.db读取
$db = getDB();
try {
$stmt = $db->prepare("SELECT role FROM web_service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if ($row) return $row['role'];
} catch (\Throwable $e) { /* 表可能不存在 */ }
// 回退:读取Java的login.db
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) return null;
try {
$loginDb = new SQLite3($loginDbPath);
$loginDb->enableExceptions(true);
$stmt = $loginDb->prepare("SELECT role FROM service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$loginDb->close();
return $row ? $row['role'] : null;
} catch (Exception $e) {
return null;
}
}
// ★ 单张工单附件图上限
define('TICKET_MAX_IMAGES', 3);
/**
* 读取并校验请求里的 images 参数(数组或 JSON 字符串均可),返回 URL 列表。
* 每个 URL 必须以自家图床前缀开头(LSKY_ALLOWED_HOST_PREFIX),最多 3 张。
*/
function normalizeTicketImages() {
$raw = getParam('images', []);
if (is_string($raw)) $raw = json_decode($raw, true);
if ($raw === null || $raw === '') $raw = [];
if (!is_array($raw)) error('images 参数格式错误');
$prefix = LSKY_ALLOWED_HOST_PREFIX;
$out = [];
foreach ($raw as $u) {
if (!is_string($u)) error('图片地址格式错误');
$u = trim($u);
if ($u === '') continue;
if (strpos($u, $prefix) !== 0) error('不支持的图片地址(仅接受自家图床)');
$out[] = $u;
if (count($out) >= TICKET_MAX_IMAGES) break;
}
return $out;
}
function encodeTicketImages(array $list) {
return json_encode(array_values($list), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
function decodeTicketImages($raw) {
if (!is_string($raw) || $raw === '') return [];
$d = json_decode($raw, true);
return is_array($d) ? $d : [];
}
/**
* 上传工单附件图片:转存自家兰空图床,返回可直接引用的 URL。
* 认证:管理后台 session 或用户 token(getAuthUser)。
* 限制:仅 png/jpg(★ 2026-10-04 起按图片真实内容判定,不看文件名后缀)、单张 ≤2048K;同会话每分钟最多 12 次(防刷)。
*/
/**
* ★ 清洗上传文件名(2026-10-04 任务4)
* 全角句点→半角、零宽字符剔除、去老浏览器带的完整路径、去首尾空白。
*/
function sanitizeImageFileName($name) {
// 全角/非法字符 -> 半角合法字符(Windows 会拒收含全角字符的文件名)
$name = str_replace(array(
"\xe3\x80\x8e", // 全角句点
"\xef\xbd\xa1", // 半角浊点
"\xe3\x80\x8f", // 全角斜线
"\xe2\x80\x8b", // 零宽空格
"\xe2\x80\x8c", // 零宽非连接符
"\xe2\x80\x8d", // 零宽连接符
"\xef\xbb\xbf", // BOM
"\xef\xbc\x8c", // 全角 小于号
"\xef\xbc\x9e", // 全角 大于号
"\xef\xbc\x9a", // 全角 冒号
"\xef\xbc\x9f", // 全角 问号
"\xef\xbc\x8a", // 全角 星号
"\xef\xbc\x87", // 全角 双引号
"\xef\xbc\x9c", // 全角 竖线
"\xef\xbc\xa0", // 全角空格
), array(
'.', '.', '/', '', '', '', '',
'_', '_', '_', '_', '_', '_', '_', ' ',
), $name);
// 老浏览器会带完整路径 C:\fakepath\a.png
$name = str_replace('\\', '/', $name);
$pos = strrpos($name, '/');
if ($pos !== false) $name = substr($name, $pos + 1);
return trim($name);
}
/** 后缀归一:jpeg / jpe / jfif -> jpg,其余转小写去点 */
function normalizeImageExt($ext) {
$ext = strtolower(trim((string)$ext));
$ext = str_replace('.', '', $ext);
if (in_array($ext, array('jpeg', 'jpe', 'jfif', 'jif'), true)) return 'jpg';
return $ext;
}
function ticketUploadImage() {
getAuthUser();
// 简单限流:同一会话 1 分钟内最多 12 张
$now = time();
$slot = isset($_SESSION['lsky_up']) && is_array($_SESSION['lsky_up']) ? $_SESSION['lsky_up'] : ['t' => 0, 'n' => 0];
if ($now - (int)$slot['t'] > 60) $slot = ['t' => $now, 'n' => 0];
if ((int)$slot['n'] >= 12) error('图片上传太频繁,请稍后再试');
$slot['n']++;
$_SESSION['lsky_up'] = $slot;
if (empty($_FILES) || empty($_FILES['image'])) {
error('未收到图片文件(表单字段名必须是 image)');
}
$f = $_FILES['image'];
if ((int)$f['error'] !== UPLOAD_ERR_OK) {
$msgs = [
UPLOAD_ERR_INI_SIZE => '图片超过服务器单文件大小上限',
UPLOAD_ERR_FORM_SIZE => '图片超过表单大小上限',
UPLOAD_ERR_PARTIAL => '图片上传中断,请重试',
UPLOAD_ERR_NO_FILE => '未选择文件',
UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录缺失',
UPLOAD_ERR_CANT_WRITE => '服务器写入临时文件失败',
];
$m = isset($msgs[$f['error']]) ? $msgs[$f['error']] : ('上传错误码 ' . $f['error']);
error($m);
}
// 文件名清洗:全角句点→半角、去零宽字符、去首尾空白
$orig = sanitizeImageFileName(basename((string)$f['name']));
// 大小:≤2048K
if ((int)$f['size'] > 2048 * 1024) error('图片不能超过 2048K');
if (!is_uploaded_file($f['tmp_name'])) error('非法上传');
// ★ 2026-10-04 修复:判定「是什么图」只看图片真实内容,不看文件名后缀。
// 旧逻辑用 pathinfo 后缀判类型,导致 .jpeg / 全角句点 / 尾部空格 /
// 无后缀的真实 png、jpg 全被误判成「不是 png/jpg 图片」。
// 这里 getimagesize 认真实格式,防伪造后缀的安全目的依然成立。
$info = @getimagesize($f['tmp_name']);
if ($info === false) error('无法识别的图片内容(请确认文件确实是 png 或 jpg)');
$itype = (int)$info[2];
if ($itype === IMAGETYPE_PNG) $realExt = 'png';
elseif ($itype === IMAGETYPE_JPEG) $realExt = 'jpg';
else error('仅支持 png / jpg 格式');
// 后缀归一:jpeg/jpe/jfif 一律当 jpg
$ext = normalizeImageExt(pathinfo($orig, PATHINFO_EXTENSION));
// 用真实后缀重命名,去掉 .jpeg / 空格 / 全角点等乱七八糟的原名
$base = pathinfo($orig, PATHINFO_FILENAME);
if ($base === '') $base = 'ticket_image';
$orig = mb_substr($base, 0, 60) . '.' . $realExt;
$r = lskyUploadFile($f['tmp_name'], $orig);
if (empty($r['ok'])) error('图片上传失败: ' . (isset($r['msg']) && $r['msg'] !== '' ? $r['msg'] : '未知错误'));
success(['url' => $r['url'], 'thumbnail' => isset($r['thumb']) ? $r['thumb'] : $r['url']], '上传成功');
}
function ticketCreate() {
$info = requireWebToken();
$player = $info['player'];
$type = trim(getParam('type', ''));
$title = trim(getParam('title', ''));
$description = trim(getParam('description', ''));
if (empty($type)) error('请选择工单类型');
if (empty($title)) error('请输入工单标题');
if (mb_strlen($title) > 100) error('标题不能超过100字');
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
if (!in_array($type, $validTypes)) error('无效的工单类型');
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
$db = getDB();
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, images, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :images, :now, :now)");
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$ticketId = $db->lastInsertRowID();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('创建失败: ' . $e->getMessage());
}
success(['id' => $ticketId], '工单提交成功');
}
function ticketMyList() {
$info = requireWebToken();
$player = $info['player'];
$db = getDB();
$status = getParam('status', '');
$page = max(1, (int)getParam('page', 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$where = "WHERE requester = :player";
$params = [':player' => $player];
if ($status && $status !== 'all') {
$where .= " AND status = :status";
$params[':status'] = $status;
}
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success([
'list' => $list,
'total' => (int)$total,
'page' => $page,
'pages' => ceil($total / $limit)
]);
}
function ticketDetail() {
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('工单不存在');
// 权限检查:管理员可以直接查看,普通用户只能看自己的工单
if (isAdminLoggedIn()) {
// 管理员,直接放行
} else {
$info = requireWebToken();
$player = $info['player'];
if ($row['requester'] !== $player && $row['assigned_to'] !== $player) {
error('无权查看此工单');
}
}
// 获取回复列表
$replies = getTicketReplies($id);
$row['replies'] = $replies;
// 附件图:老库/老工单没有 images 列时兜底为空数组
$row['images'] = decodeTicketImages(isset($row['images']) ? $row['images'] : null);
success($row);
}
function ticketReply() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
$db = getDB();
// 检查工单状态和权限
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['requester'] !== $player) error('只能回复自己的工单');
if (in_array($ticket['status'], ['withdrawn', 'rejected'])) error('该工单已关闭,无法回复');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
// 插入回复
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'user', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 更新工单状态为已回复
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketWithdraw() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['requester'] !== $player) error('无权操作此工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET status = 'withdrawn', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('撤销失败');
}
success(null, '工单已撤销');
}
// ===== 管理员端 =====
function ticketListAll() {
requireAdmin();
$db = getDB();
// 兜底建表(服务器core.php可能版本旧)
try {
$db->exec("CREATE TABLE IF NOT EXISTS web_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT DEFAULT 'submitted',
requester TEXT NOT NULL,
assigned_to TEXT DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
images TEXT DEFAULT '[]',
reject_reason TEXT DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)");
// ★ 迁移:老库补 images 列(工单附件图 URL 数组 JSON)
try {
$db->exec("ALTER TABLE web_tickets ADD COLUMN images TEXT DEFAULT '[]'");
} catch (Exception $e) { /* 列已存在 */ }
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
sender TEXT NOT NULL,
role TEXT DEFAULT 'user',
message TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_requester ON web_tickets(requester)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_status ON web_tickets(status)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_assigned ON web_tickets(assigned_to)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wtr_ticket ON web_ticket_replies(ticket_id)");
} catch (\Throwable $e) {
error('工单表初始化失败: ' . $e->getMessage());
}
try {
$status = getParam('status', 'all');
$page = max(1, (int)getParam('page', 1));
$search = trim(getParam('search', ''));
$limit = 20;
$offset = ($page - 1) * $limit;
$where = "WHERE 1=1";
$params = [];
if ($status && $status !== 'all') {
$where .= " AND status = :status";
$params[':status'] = $status;
}
if ($search) {
$where .= " AND (requester LIKE :search OR title LIKE :search2)";
$params[':search'] = "%$search%";
$params[':search2'] = "%$search%";
}
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
// 统计各状态数量
$stats = [];
foreach (['submitted', 'replied', 'completed', 'withdrawn', 'rejected'] as $s) {
$cnt = $db->querySingle("SELECT COUNT(*) FROM web_tickets WHERE status = '$s'");
$stats[$s] = (int)$cnt;
}
$stats['all'] = (int)$db->querySingle("SELECT COUNT(*) FROM web_tickets");
success([
'list' => $list,
'total' => (int)$total,
'page' => $page,
'pages' => ceil($total / $limit),
'stats' => $stats
]);
} catch (\Throwable $e) {
error('查询工单失败: ' . $e->getMessage());
}
}
function ticketAdminReply() {
requireAdmin();
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 管理员回复 → 状态更新为已回复(不改变assigned_to,保留原服务商所有权)
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketReject() {
requireAdmin();
$id = (int)getParam('id', 0);
$reason = trim(getParam('reason', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($reason)) error('请输入驳回原因');
if (mb_strlen($reason) > 500) error('驳回原因不能超过500字');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['status'] === 'withdrawn' || $ticket['status'] === 'rejected') error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET status = 'rejected', reject_reason = :reason, updated_at = :now WHERE id = :id");
$stmt->bindValue(':reason', $reason, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('驳回失败');
}
success(null, '工单已驳回');
}
function ticketAssign() {
requireAdmin();
$id = (int)getParam('id', 0);
$provider = trim(getParam('provider', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($provider)) error('请输入服务商名称');
// 验证是否为服务商
if (!isServiceProviderInGame($provider)) error("$provider 不是服务商");
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('分配失败');
}
success(null, "已分配给 $provider");
}
function ticketAdminCreate() {
requireAdmin();
$type = trim(getParam('type', ''));
$title = trim(getParam('title', ''));
$description = trim(getParam('description', ''));
$provider = trim(getParam('provider', ''));
if (empty($type)) error('请选择工单类型');
if (empty($title)) error('请输入工单标题');
if (mb_strlen($title) > 100) error('标题不能超过100字');
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
if (!in_array($type, $validTypes)) error('无效的工单类型');
// 如果指定了服务商,验证身份
if ($provider && !isServiceProviderInGame($provider)) {
error("$provider 不是服务商");
}
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
$db = getDB();
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, images, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :images, :now, :now)");
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$ticketId = $db->lastInsertRowID();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('创建失败: ' . $e->getMessage());
}
$msg = '工单已创建';
if ($provider) $msg .= ",已派发给 $provider";
success(['id' => $ticketId], $msg);
}
function ticketAdminComplete() {
requireAdmin();
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', '✅ 工单已由管理员标记完结', :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('操作失败');
}
success(null, '已标记完结');
}
// ===== 服务商端 =====
function ticketProviderList() {
$info = requireWebToken();
$player = $info['player'];
// 验证服务商身份
$role = getServiceProviderRole($player);
if (!$role) error('你不是服务商');
$db = getDB();
$type = getParam('type', 'all');
// processing: 活跃(已提交/已回复)
// completed: 已完结/已撤销/已驳回
// all: 全部跟过的工单
$where = "WHERE assigned_to = :player";
$params = [':player' => $player];
if ($type === 'processing') {
$where .= " AND status IN ('submitted', 'replied')";
} elseif ($type === 'completed') {
$where .= " AND status IN ('completed', 'withdrawn', 'rejected')";
}
// 'all' 不加status过滤
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY updated_at DESC LIMIT 100");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success(['list' => $list, 'role' => $role]);
}
function ticketAvailable() {
$info = requireWebToken();
$player = $info['player'];
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE status = 'submitted' AND assigned_to = '' ORDER BY created_at DESC LIMIT 50");
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success(['list' => $list]);
}
function ticketGrab() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['status'] !== 'submitted') error('该工单已被接单');
if (!empty($ticket['assigned_to'])) error('该工单已被分配');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, updated_at = :now WHERE id = :id");
$stmt->bindValue(':provider', $player, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('抢单失败');
}
success(null, '抢单成功');
}
function ticketProviderReply() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketProviderComplete() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
// 插入完结回复
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', '✅ 工单已处理完毕', :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 状态改为completed
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('操作失败');
}
success(null, '已标记完结');
}
// ===== 通用 =====
function ticketProviders() {
if (!isAdminLoggedIn()) error('未登录管理后台');
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) success(['list' => []]);
try {
$loginDb = new SQLite3($loginDbPath);
$result = $loginDb->query("SELECT player_name, role, join_time FROM service_providers WHERE active = 1");
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
$loginDb->close();
success(['list' => $list]);
} catch (Exception $e) {
success(['list' => []]);
}
}
function ticketProviderCheck() {
$info = requireWebToken();
$player = $info['player'];
$isProvider = isServiceProviderInGame($player);
$role = $isProvider ? getServiceProviderRole($player) : null;
success(['is_provider' => $isProvider, 'role' => $role]);
}
// ===== 辅助函数 =====
function getTicketReplies($ticketId) {
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_ticket_replies WHERE ticket_id = :tid ORDER BY created_at ASC");
$stmt->bindValue(':tid', $ticketId, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
return $list;
}
+227
View File
@@ -0,0 +1,227 @@
<?php
/**
* 玩家 2FA (TOTP) 二维码展示中转 + 绑定状态双向同步(第47批)—— PHP 只做显示器与镜像
*
* 2FA 的动态码校验逻辑在 Java 端与 PHP 端各自本地完成(同一 Base32 密钥),
* 这里负责:
* 1) 暂存 otpauth 载荷供玩家浏览器渲染二维码,10 分钟自动过期
* 2) 以 web_account_2fa 为持久镜像,供 Java 端拉取/推送绑定状态
* (修复:网页端绑定后游戏内 /2fa 显示"未绑定")
*
* Java -> PHP(secret 校验):
* push_qr 存一条二维码载荷(token -> otpauth URI)
* pull_state 拉取该玩家网页端绑定状态(bound/secret/bound_at/updated_at)
* push_state 推送 Java 端绑定/解绑结果(网页端无记录或记录更旧时落库)
* 玩家浏览器(持有随机 token 即可读):
* get 按 t 取回 otpauth URI
*
* 同步语义见 Java 端 TwoFactorManager.applyWebState():
* 单向导入(网页绑定游戏未绑定)/ 解绑传播(网页解绑且更晚)/
* 密钥冲突取时间新者;恢复代码两端格式不同(网页16位明文 vs 游戏6位哈希),各自维护。
*/
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
require_once __DIR__ . '/../core.php';
/** 载荷有效期(秒),与 Java 端 twofa_pending_at 的 10 分钟对齐 */
define('TWOFA_QR_TTL', 600);
function twofaEnsureTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS web_twofa_qr (
token TEXT PRIMARY KEY,
player TEXT NOT NULL,
otpauth TEXT NOT NULL,
expires_at INTEGER NOT NULL
)");
}
function twofaClean($db) {
$db->exec("DELETE FROM web_twofa_qr WHERE expires_at < " . time());
}
/** Java 侧通信校验(与 api/sn.php 同款) */
function twofaRequireSecret() {
$secret = getParam('secret', '');
if ($secret === '' || $secret !== SECRET_KEY) {
error('密钥错误', 403);
}
}
/** token 形态:64 位十六进制(Java SecureRandom 256bit)。
* WAF 实测(2026-10-01 矩阵含重复验证):查询参数名 `token` + ≥32位纯hex 会被
* CF 源站 404 拦截;参数名 `t` 任意长度(16/32/48/64)均可过,POST body 亦可过。
* 故取数接口固定用 `t=`(`token=` 仅作兼容回退)。 */
function twofaValidToken($t) {
return is_string($t) && preg_match('/^[a-f0-9]{32,128}$/', $t) === 1;
}
$action = getParam('action', '');
try {
switch ($action) {
// ===== Java -> PHP:存二维码载荷 =====
case 'push_qr': {
twofaRequireSecret();
$token = (string) getParam('token', '');
$player = (string) getParam('player', '');
$otpauth = (string) getParam('otpauth', '');
if (!twofaValidToken($token)) error('token格式错误', 400);
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
if (strpos($otpauth, 'otpauth://totp/') !== 0 || strlen($otpauth) > 512) {
error('otpauth载荷不合法', 400);
}
$db = getDB();
twofaEnsureTable($db);
twofaClean($db);
$stmt = $db->prepare("INSERT OR REPLACE INTO web_twofa_qr
(token, player, otpauth, expires_at) VALUES (:t, :p, :o, :e)");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':o', $otpauth, SQLITE3_TEXT);
$stmt->bindValue(':e', time() + TWOFA_QR_TTL, SQLITE3_INTEGER);
$stmt->execute();
success(['expires_in' => TWOFA_QR_TTL], 'stored');
break;
}
// ===== 玩家浏览器:取二维码载荷 =====
case 'get': {
// 主参数名必须是 t:`token=<32+位hex>` 会被源站 WAF 404,`t=<hex>` 实测全长度可过
$token = (string) getParam('t', '');
if ($token === '') $token = (string) getParam('token', '');
if (!twofaValidToken($token)) error('参数错误', 400);
$db = getDB();
twofaEnsureTable($db);
twofaClean($db);
$stmt = $db->prepare("SELECT player, otpauth, expires_at
FROM web_twofa_qr WHERE token = :t");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('二维码不存在或已过期', 404);
success([
'player' => $row['player'],
'otpauth' => $row['otpauth'],
'expires_at' => (int) $row['expires_at'],
]);
break;
}
// ===== Java -> PHP:拉取网页端 2FA 绑定状态(第47批双向同步) =====
case 'pull_state': {
twofaRequireSecret();
$player = (string) getParam('player', '');
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
$db = getDB();
$stmt = $db->prepare("SELECT totp_secret, bound_at, updated_at
FROM web_account_2fa WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) {
success(['exists' => false, 'bound' => false,
'secret' => '', 'bound_at' => 0, 'updated_at' => 0]);
}
$bound = ((int) $row['bound_at'] > 0 && (string) $row['totp_secret'] !== '');
success([
'exists' => true,
'bound' => $bound,
// 明文密钥仅回给持 SECRET_KEY 的 Java 服务端,绝不出现在浏览器接口
'secret' => $bound ? (string) $row['totp_secret'] : '',
'bound_at' => (int) $row['bound_at'],
'updated_at' => (int) $row['updated_at'],
]);
break;
}
// ===== Java -> PHP:推送 Java 端绑定/解绑结果(第47批双向同步) =====
case 'push_state': {
twofaRequireSecret();
$player = (string) getParam('player', '');
$bound = (string) getParam('bound', '') === '1';
// ★ TOTP 密钥参数名必须是 twofa_secret:'secret' 已被通信密钥占用
$secret = (string) getParam('twofa_secret', '');
$boundAt = (int) getParam('bound_at', '0');
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
if ($bound && !preg_match('/^[A-Z2-7]{16,64}$/', $secret)) {
error('密钥格式错误', 400);
}
$db = getDB();
$stmt = $db->prepare("SELECT bound_at, recovery_codes
FROM web_account_2fa WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
$existingAt = $row ? (int) $row['bound_at'] : -1; // -1 = 无记录
$now = time();
if ($bound) {
// 仅当网页端无记录、未绑定、或绑定时间更旧时覆盖;
// 网页端已绑定且不比我们旧 → 不动(网页端用户主动操作优先)
$webNewer = ($existingAt > 0 && $boundAt > 0 && $existingAt >= $boundAt);
if ($webNewer) {
success(['applied' => false, 'reason' => 'web_side_newer']);
}
// 游戏端恢复代码是 SHA-256 哈希且格式与网页端(16位明文 JSON)不兼容,
// 推送时清空网页端恢复代码列表,避免网页端用旧代码通过第二层验证
$stmt = $db->prepare("INSERT INTO web_account_2fa
(player_name, totp_secret, recovery_codes, bound_at, updated_at)
VALUES (:p, :s, '', :b, :u)
ON CONFLICT(player_name) DO UPDATE SET
totp_secret = :s, recovery_codes = '',
bound_at = :b, updated_at = :u");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':s', $secret, SQLITE3_TEXT);
$stmt->bindValue(':b', $boundAt > 0 ? $boundAt : $now, SQLITE3_INTEGER);
$stmt->bindValue(':u', $now, SQLITE3_INTEGER);
$stmt->execute();
success(['applied' => true, 'action' => 'bound']);
} else {
// 解绑:仅当网页端记录不比我们新时清空(防覆盖网页端更晚的重新绑定)
$webNewer = ($existingAt > 0 && $boundAt > 0 && $existingAt > $boundAt);
if ($webNewer) {
success(['applied' => false, 'reason' => 'web_side_newer']);
}
if ($existingAt === 0) {
success(['applied' => false, 'reason' => 'already_unbound']);
}
if ($existingAt > 0) {
$stmt = $db->prepare("UPDATE web_account_2fa SET
totp_secret = '', recovery_codes = '', bound_at = 0, updated_at = :u
WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':u', $now, SQLITE3_INTEGER);
$stmt->execute();
}
success(['applied' => true, 'action' => 'unbound']);
}
break;
}
default:
error('未知操作', 400);
}
} catch (\Throwable $e) {
debugLog('twofa异常: ' . $e->getMessage(), ['action' => $action]);
error('服务器内部错误', 500);
}
+179
View File
@@ -0,0 +1,179 @@
<?php
/**
* 自定义背景壁纸源代理 —— 第46批创建,第47批改多源 fallback
*
* 用户端(player.php)与管理员端(admin.php)的自定义背景系统共用:
* action=bing 每日壁纸元数据(图片URL/标题/版权/日期),6小时文件缓存
* 第47批:单一必应源 → 四级 fallback 链(用户侧必应取不到图的对策):
* 1) www.bing.com HPImageArchive 2) cn.bing.com 镜像
* 3) NASA APOD (api.nasa.gov) 4) picsum.photos 兜底(永不失败)
* action=image 按 u 参数转发图片二进制(白名单域,防SSRF/防盗链),浏览器缓存6小时
* 第47批:白名单随多源扩充 NASA / picsum / unsplash 域
*
* 自定义图片URL模式为浏览器直连(前端 background-image),不经过本代理。
* 自包含轻量实现,不依赖 core.php/DB。
*/
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
/** 壁纸元数据缓存时长(秒)—— 每日换图,6小时足够 */
define('WPJ_BING_TTL', 21600);
function wpjOut($ok, $msg, $data = null) {
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
echo json_encode(array('success' => $ok, 'message' => $msg, 'data' => $data),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/** 缓存文件放系统临时目录 —— Web 根可能只读,临时目录必可写 */
function wpjCacheFile($key) {
return rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'sdf1_wp_' . $key . '.json';
}
function wpjCacheGet($key, $ttl) {
$f = wpjCacheFile($key);
if (is_file($f) && (time() - filemtime($f)) < $ttl) {
$j = json_decode((string)@file_get_contents($f), true);
if (is_array($j)) { return $j; }
}
return null;
}
function wpjCacheSet($key, $arr) {
@file_put_contents(wpjCacheFile($key),
json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
function wpjFetch($url, $timeout, $referer) {
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) sdf1-wallpaper',
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_REFERER => $referer,
));
$body = curl_exec($ch);
curl_close($ch);
return is_string($body) ? $body : '';
}
$ctx = stream_context_create(array('http' => array(
'timeout' => $timeout,
'header' => "User-Agent: Mozilla/5.0 sdf1-wallpaper\r\nReferer: " . $referer . "\r\n",
)));
$body = @file_get_contents($url, false, $ctx);
return is_string($body) ? $body : '';
}
$act = isset($_GET['action']) ? $_GET['action'] : '';
/* ===== 每日壁纸元数据(第47批:多源 fallback 链) ===== */
if ($act === 'bing') {
// ★第48批#247:key 版本化 bing -> bing_v2(旧缓存存着无 api/ 前缀的坏 URL,须失效)
$cached = wpjCacheGet('bing_v2', WPJ_BING_TTL);
if ($cached !== null) { wpjOut(true, 'ok', $cached); }
$data = null;
// 源1/2:必应官方 HPImageArchive(主域失败换中国镜像)
foreach (array('https://www.bing.com', 'https://cn.bing.com') as $base) {
$body = wpjFetch($base . '/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN', 6, $base . '/');
$j = json_decode($body, true);
if (!is_array($j) || empty($j['images'][0]['urlbase'])) { continue; }
$img = $j['images'][0];
$direct = $base . $img['urlbase'] . '_1920x1080.jpg';
$data = array(
// 前端优先用代理地址(防防盗链),direct 作兜底直连
// ★第48批#247:补 api/ 前缀(原相对路径经前端路由转发后 404,图片取不到)
'url' => 'api/wallpaper.php?action=image&u=' . rawurlencode($direct),
'direct' => $direct,
'source' => 'bing',
'title' => isset($img['title']) ? $img['title'] : '',
'copyright' => isset($img['copyright']) ? $img['copyright'] : '',
'date' => isset($img['startdate']) ? $img['startdate'] : '',
);
break;
}
// 源3:NASA 每日天文图 APOD(DEMO_KEY 限速,但有6小时缓存,服务器低频调用足够)
if ($data === null) {
$body = wpjFetch('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY&hd=true', 8, 'https://api.nasa.gov/');
$j = json_decode($body, true);
if (is_array($j) && !empty($j['hdurl']) && preg_match('#^https?://#i', $j['hdurl'])) {
$direct = $j['hdurl'];
$data = array(
// ★第48批#247:补 api/ 前缀(同 bing)
'url' => 'api/wallpaper.php?action=image&u=' . rawurlencode($direct),
'direct' => $direct,
'source' => 'nasa',
'title' => isset($j['title']) ? $j['title'] : 'NASA 每日天文图',
'copyright' => isset($j['copyright']) ? $j['copyright'] : 'NASA APOD',
'date' => isset($j['date']) ? $j['date'] : date('Y-m-d'),
);
}
}
// 源4:picsum 固定尺寸摄影图(无 key、永不应答失败的最后兜底;302 跳转浏览器直接可用)
if ($data === null) {
$direct = 'https://picsum.photos/1920/1080';
$data = array(
'url' => $direct,
'direct' => $direct,
'source' => 'picsum',
'title' => '随机摄影图',
'copyright' => 'Photos from Unsplash (picsum.photos)',
'date' => date('Y-m-d'),
);
}
wpjCacheSet('bing_v2', $data);
wpjOut(true, 'ok', $data);
}
/* ===== 图片转发(白名单域,防 SSRF;第47批随多源扩充) ===== */
if ($act === 'image') {
$u = isset($_GET['u']) ? (string)$_GET['u'] : '';
$host = @parse_url($u, PHP_URL_HOST);
if ($u === '' || !is_string($host) || $host === '') { wpjOut(false, '参数无效'); }
$host = strtolower($host);
$allow = array(
// 必应(源1/2)
'www.bing.com', 'bing.com', 'th.bing.com', 'cn.bing.com',
// NASA APOD(源3,hdurl 指向 apod.nasa.gov / www.nasa.gov 等)
'apod.nasa.gov', 'www.nasa.gov', 'images-api.nasa.gov',
// picsum 兜底链(源4,302 目标域)
'picsum.photos', 'fastly.picsum.photos', 'images.unsplash.com',
);
if (!in_array($host, $allow, true)) { wpjOut(false, '不支持的图片源'); }
$referer = 'https://www.bing.com/';
if ($host === 'apod.nasa.gov' || $host === 'www.nasa.gov' || $host === 'images-api.nasa.gov') {
$referer = 'https://apod.nasa.gov/';
} elseif ($host === 'picsum.photos' || $host === 'fastly.picsum.photos' || $host === 'images.unsplash.com') {
$referer = 'https://picsum.photos/';
}
$bin = wpjFetch($u, 10, $referer);
if (!is_string($bin) || strlen($bin) < 1000) { wpjOut(false, '图片下载失败'); }
// 粗校验图片魔数,防返回HTML错误页被当图片缓存
$head = substr($bin, 0, 3);
if ($head !== "\xFF\xD8\xFF" && substr($bin, 0, 4) !== "\x89PNG" && stripos(substr($bin, 0, 32), 'RIFF') !== 0) {
wpjOut(false, '图片源返回内容异常');
}
header('Content-Type: image/jpeg');
header('Cache-Control: public, max-age=21600');
echo $bin;
exit;
}
wpjOut(false, '未知 action');
-5
View File
@@ -1,5 +0,0 @@
*.md text=auto encoding=UTF-8
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
-46
View File
@@ -1,46 +0,0 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
# 开发辅助文件(本地保留,不推送到仓库)
txt/
*.xlsx
*.patch
新建*.txt
群戏归类.txt
File diff suppressed because it is too large Load Diff
-569
View File
@@ -1,569 +0,0 @@
# Sdf1系列登录插件使用说明
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态 |
| :--- | :--- | :--- |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
| 新人礼包 | 新玩家7日礼包 | 已完成 |
| 挂机检测 | 挂机检测踢出 | 已完成 |
| 自定义菜单 | 可以自己diy指令 | 已完成 |
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工 |
| 商城系统| 内置游戏商人可供销售商品 | 已完工 |
| 虚拟货币 | 供签到、游戏商人使用的货币系统 | 已完工 |
## 2.安装说明:
| 前置插件 | 选装类型 | 功能作用 |
| :--- | :--- | :--- |
| [CY_beibao](https://gitee.com/nihaoshidifu/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量
| EssentialsX | 可选 | 主流经济系统
| [sdf1](https://gitee.com/nihaoshidifu/sdf1_plugn) | 可选,推荐 | CDK兑换系统
### 2.1 下载
您可以从GitHub和Gitee下载到插件,将其丢入服务器plugin目录,重启服务器即可。
### 2.2. 配置文件
#### 2.2.1 消息.txt
此文件控制着整个插件的所有提示消息,您可以根据自己的需求修改,修改后需要执行 /sdf1_login reload 重载插件。支持[颜色代码](https://zh.minecraft.wiki/w/%E6%A0%BC%E5%BC%8F%E5%8C%96%E4%BB%A3%E7%A0%81)<br>
同时兼容`<b> </b>`标签,对段落加粗,`\n 和 <br>`进行段落换行
```txt
# ==========================================
# Sdf1_login 消息配置文件
# ==========================================
# 颜色代码使用 § 符号
# §0黑色 §1深蓝 §2深绿 §3深青
# §4深红 §5深紫 §6金色 §7灰色
# §8深灰 §9蓝色 §a绿色 §b浅蓝
# §c红色 §d粉色 §e黄色 §f白色
# §l粗体 §n下划线 §o斜体 §r重置
#
# 占位符用 {xxx} 表示,运行时自动替换
# 支持 <b>粗体</b> 标签
# 支持 \n 或 <br> 换行
# 修改后执行 /sdf1_login reload 重载
# ==========================================
# ---- 登录注册相关 ----
# login_timeout: 登录超时被踢出时的提示
login_timeout=§c登录超时,请重新登录
# not_registered: 未注册玩家收到的提示
not_registered=§e您尚未注册,请使用 /reg <密码>
# not_logged_in: 已注册但未登录的提示
not_logged_in=§e您尚未登录,请使用 /login <密码>
# already_registered: 已注册玩家再次注册时的提示
already_registered=§c您已注册过账号
# already_logged_in: 已登录玩家再次登录时的提示
already_logged_in=§c您已登录
# reg_success: 注册成功提示
reg_success=§a注册成功!欢迎来到服务器
# login_success: 登录成功提示
login_success=§a登录成功!
# login_failed: 密码错误提示
login_failed=§c密码错误,请重试
# ---- 密码相关 ----
# password_wrong: 旧密码验证失败
password_wrong=§c密码错误
# password_changed: 密码修改成功
password_changed=§a密码修改成功!
# password_format_error: 密码格式不符合要求
password_format_error=§c密码需6位以上,含大小写字母和数字
# password_same: 新密码与旧密码相同
password_same=§c新密码不能与旧密码相同
# reset_no_email: 找回密码时未绑定邮箱
reset_no_email=§c您未绑定邮箱,无法找回密码
# reset_sent: 临时密码已发送到邮箱
reset_sent=§a临时密码已发送到您的邮箱,请查收
# ---- 签到积分相关 ----
# checkin_already: 今日已签到
checkin_already=§c您今日已签到
# checkin_success: 签到成功,{points}=获得积分 {streak}=连续天数 {multi}=倍率
checkin_success=§a签到成功!获得 {points} 积分,连续 {streak} 天,倍率 x{multi}
# points_insufficient: 积分不足
points_insufficient=§c积分不足,当前: {points}
# points_purchase_success: 积分购买成功
points_purchase_success=§a购买成功!消耗 {count} 积分
# ---- 挂机检测相关 ----
# afk_set_enabled: 挂机检测开启
afk_set_enabled=§a挂机检测已开启
# afk_set_disabled: 挂机检测关闭
afk_set_disabled=§c挂机检测已关闭
# afk_kick: 因挂机被踢出
afk_kick=§c因挂机过久被踢出
# ---- 邮箱相关 ----
# email_set: 邮箱设置成功
email_set=§a邮箱设置成功:{email}
# email_format_error: 邮箱格式错误
email_format_error=§c邮箱格式不正确
# ---- 邀请相关 ----
# invite_code_generated: 邀请码生成
invite_code_generated=§a邀请码:{code}
# invite_used: 使用邀请码成功
invite_used=§a邀请成功,邀请人:{player}
# invite_self: 使用自己的邀请码
invite_self=§c不能使用自己的邀请码
# invite_not_found: 邀请码不存在
invite_not_found=§c邀请码不存在
# invite_referral_bonus: 被邀请人签到后邀请人获得奖励
invite_referral_bonus=§a邀请奖励!获得 {points} 积分
# ---- 新人礼包相关 ----
# gift_not_ready: 礼包条件未满足
gift_not_ready=§c礼包条件未满足
# gift_claimed: 礼包领取成功
gift_claimed=§a礼包领取成功!
# ---- 审批与IP限制 ----
# need_approval: 注册需审批
need_approval=§e注册需审批 (工单#{id})
# ip_max_accounts: IP注册已达上限
ip_max_accounts=§cIP注册已达上限
# ---- 管理员操作 ----
# admin_delete_confirm: 管理员删除账号确认提示
admin_delete_confirm=§c请在聊天中输入玩家名确认删除
# ---- 聊天过滤 ----
# chat_muted: 禁言玩家尝试聊天
chat_muted=§c§l[Sdf1_chat] §f你已被禁言,无法聊天
# chat_url_blocked: 发送违规链接被拦截
# {url}=检测到的链接地址
chat_url_blocked=§c§l[Sdf1_chat] §f检测到违规第三方链接: §e{url}
# chat_url_violation: 当前违规次数
# {count}=当前违规次数
chat_url_violation=§7当前违规次数: §e{count}
# chat_url_admin_notify: 通知管理员,{player}=玩家名 {url}=链接 {count}=次数
chat_url_admin_notify=§e§l[Sdf1_chat] §f{player} 发送违规链接: {url} (第{count}次)
# chat_url_broadcast: 全服通报,{player}=玩家名
chat_url_broadcast=§c§l[Sdf1_chat] §e{player} §f因发送违规链接被处罚
# ---- 通用 ----
# 未知参数: 未知命令或权限不足时的提示
未知参数=§c未知参数或权限不足
# only_player: 仅玩家可用的命令
# only_player=§c此命令仅玩家可用
```
### 2.2.2 挂机白名单.txt
当挂机踢出开启时,该配置下的玩家不受挂机提出影响
### 2.2.3 SMTP设置.txt
此文件控制着SMTP邮件服务器的配置,用于找回密码时发送邮件。<br>可通过GUI进行配置,也可以手动修改此配置文件。修改后,重载插件即可生效
```txt
# ===== SMTP 邮件服务器配置 =====
smtp地址=smtp.example.com
smtp端口=465
smtp账号=mail@example.com
smtp密码=example.com
发件人名称=Sdf1_login
smtp加密=true
验证码接收邮箱=example@example.com
```
### 2.2.4 插件设置.txt
此配置管控这整个插件,包括玩家能注册多少个账号、默认的管理员面板密码多少、tag包含什么视为管理员等
```txt
# ===== Sdf1_login 插件设置 =====
管理标签=admin
管理密码=qweasd
每IP最大账号数=3
审批模式=manual
自动审批延迟分钟=30
登录超时秒数=60
挂机踢出=false
挂机超时秒数=300
服务商积分倍率=2.0
基础经济奖励=100
经济奖励每评分=50.0
报单人积分奖励=10
# ---- 签到盲盒奖励 ----
签到固定金额=0
签到最小金额=10
签到最大金额=100
签到给积分=true
签到积分数量=10
```
### 2.2.5 chat.txt
该文件控制着聊天过滤,当玩家发送违规链接时,会自动根据配置规则进行处理。<br>
可用的处罚规则:
- warn: 警告
- mute: 禁言
- kick: 踢出
- ban: 封禁
- banip:封禁IP
```txt
# Sdf1_chat 聊天过滤配置
通知管理员: true
全服通报: true
禁言时长: 300
白名单:
*.minecraft.net
baidu.com
github.com
处罚规则:
1:warn:0
2:warn:0
3:mute:300
5:mute:600
8:kick:0
10:ban:0
白名单玩家:
```
### 2.2.6 radio_config.txt
```txt
# ============ Sdf1 Radio Config ============
#
# resource-pack-url
# 填外部地址(HTTP或HTTPS均可)
# 例: https://example.com/radiopack.zip
# 留空 = 使用内置HTTP服务
#
# http-port
# 内置HTTP端口(仅resource-pack-url为空时生效)
#
# ============ Settings ===============
resource-pack-url=https://*.ypshidifu.cn/|*.mcserver.ccwu.cc
http-port=443
```
- radio_config这里可以使用通配符域名,当使用通配符时,系统随机生成url,拒绝过资源包的玩家,每次都会收到提示。并且自1.2版本起,拒绝资源包=放弃游玩服务器。如果您无需广播功能,可以不放ogg文件进入`./plugin/radio/ogg`。这样服务端不会自动生成资源包,也不会强制下载了
### 2.2.7 菜单.txt
- 自定义菜单配置文件
```json
# 服务器菜单
# 使用类json格式
// 支持使用://Java单行注释、#yml单行注释、/* */块注释,也支持<!--- --->的块注释
{
标题:主城传送
指令:/dom tp 主城区 # 这里如果是xxx.txt则表示进入二级目录
权限类型:玩家
图标:COMPASS
}
{
标题:随机传送
指令:/rtp
权限类型:玩家
图标:ENDER_PEARL
}
{
标题:擂台比武
指令:/dom tp PVP
权限类型:玩家
图标:netherite_spear
}
{
标题:领地操作
指令:/dom
权限类型:玩家
图标:paper
}
{
标题:逛商店
指令:/dom tp shop
权限类型:玩家
图标:honeycomb
}
{
标题:传送开关
指令:/tptoggle
权限类型:玩家
图标:redstone_torch
}
{
标题:返回上一个位置
指令:/back
权限类型:玩家
图标:red_bed
}
{
标题:前往小黑塔
指令:/dom tp 小黑塔
权限类型:玩家
图标:dragon_head
}
{
标题:云背包
指令:/cy
权限类型:玩家
图标:potato
}
{
标题:自杀
指令:/suicide
权限类型:玩家
图标:wooden_axe
}
{
标题:游戏商人
指令:/shop
权限类型:玩家
图标:shield
格子:28
}
```
### 2.2.8 商品配置
- 配置此处的文件,负责展示游戏内所销售的商品。<br>
- 一个文件名,对应一个商品分类,使用md文件格式
```
# 建筑材料
| ID | 品名 | 材质 | 购入价 | 售出价 | 库存 | 本小时销量 | 总销量 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| STONE | 石头 | STONE | 10 | -1 | -1 | 0 | 0 |
| COBBLESTONE | 圆石 | COBBLESTONE | 5 | -1 | -1 | 0 | 26 |
| OAK_PLANKS | 橡木木板 | OAK_PLANKS | 8 | 3 | -1 | 0 | 0 |
| BRICKS | 砖块 | BRICKS | 12 | -1 | -1 | 0 | 0 |
| GLASS | 玻璃 | GLASS | 15 | 6 | -1 | 0 | 0 |
| QUARTZ_BLOCK | 石英块 | QUARTZ_BLOCK | 20 | 8 | -1 | 0 | 0 |
| AMETHYST_BLOCK | 紫水晶 | AMETHYST_BLOCK | 10 | 0 | -1 | 0 | 999 |
| OCHRE_FROGLIGHT | 赭黄蛙鸣灯 | OCHRE_FROGLIGHT | 100 | 0 | -1 | 0 | 1000 |
```
## 2.3 商城部分介绍
1. 自助退款,买多买错,不用慌,一件退单很安心<br>
2. 购物车,买的多怕买错,先加购物车,后续统一结算<br>
3. 害怕订单找不到?一键查看历史订单<br>
![](img/shop4.png)
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
![](img/shop7.png)<br>
![](img/shop6.png)
5. 想查看玩家消费记录?小手一点,明明白白<br>
![](img/shop5.png)
## 2.4 打印流水部分介绍
1. 管理员怀疑某个玩家刷钱了,怎么办?/printer 玩家名,一检查流水,很安心。很多人都刷了怎么办?/printer不加参数,一键导出全服流水,查他个底朝天<br>
2. 忘记删除怎么办?插件每分钟自动检测,旧文件超过24小时,自动删
## 3. 指令参数及介绍
- Sdf1_login 主指令<br>
- Sdf1_login reload 重载配置文件<br>
- reg 注册账号<br>
- login 登录账号<br>
- l 登录账号<br>
- sdf1_login del 删除玩家账号<br>
- sdf1_login radio 停止播放广播<br>
- sdf1_login radio reload 重载广播并重新打包资源包<br>
- menu 开/关 是否开启菜单<br>
- printer 打印流水数据,支持不带参数&带玩家名为参数<br>
- sdf1_login printer 作用同printer<br>
- sdf1_login shop 商城
- protect 区域防护
## 4. 自定义菜单物品
- 打开主界面,选择"我的"进入<br>
![](img/menu1.png)
- 将你的自定义物品与雪球交换<br>
![](img/menu2.png)
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
## 5. pvp战神榜
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
![](img/pvp1.png)
- 并且有着网游同款的击杀通报<br>
![](img/pvp3.png)
- 播报逻辑是:<br>
- 3杀<br>
- 5杀<br>
- 5杀以上每杀一次都是一条播报<br>
- 终结者通报,触发连杀的玩家被终结也会有全服通报
## 6. 区域防护
- 通过此功能,您可在服务器里需要防护的地方(比如展示框不想被玩家随便破坏、主城不准玩家袭击等)加以防护。<br>
- 还能够保护指定区域的玩家不受任何伤害。<br>
| 指令 | 参数1| 参数2 | 参数3 | 结果 |
| :---: | :---: | :---: | :---: | :---: |
| protect | 无 | 无 | 无 | 帮助信息 |
| protect | add | 区域名 | 玩家名 | 加白指定玩家到区域 |
| protect | add | 玩家名 | 无| 加白指定玩家为全局 |
| protect | additem | 区域名 | 物品名 | 加黑指定物品到区域 |
| protect | additem | 物品名 | 无 | 加黑指定物品到全局 |
| protect | addname | 区域名 | 名字 | 加白指定生物名字到区域和平模式白名单 |
| protect | addplayer | 区域名 | 玩家名字 | 加白指定玩家到区域强制模式豁免名单 |
| protect | remove | 玩家名字 | 无 | 移除玩家为全白 |
| protect | remove | 区域名 | 玩家名 | 移除玩家为区白 |
| protect | removeitem | 物品名 | 无 | 删除指定物品为全黑
| protect | removeitem | 区域名 | 物品名 | 删除指定物品为区黑
| protect | removename | 区域名 | 名字 | 删除指定名字为区域和平模式白名单 |
| protect | removeplayer | 区域名 | 名字 | 删除指定玩家为强制模式豁免名单 |
| protect | 参数不分顺序 | 区名可以写这里也可以写后面 | 玩家名可以写这里也可以写前面 | 完美的宝宝化 |
示范:
- `/protect add 末地保护区 players` 添加players玩家到末地保护区<br>
- `/protect removeitem 主城区 TNT` 从主城删了TNT这个黑名单物品<br>
- `/protect 筑城区 addwhite zhangsan` 把zhangsan加白到主城区的模式豁免名单。<br>
- 所有参数`任意排序`,`区域名字`随便填写,强大的`兜底`会自动识别"宝宝画板",你可以放心、大胆的乱写
| 参数 | 效果 |
| :---: | :---: |
| 和平模式 | 所有敌对生物全部进虚空 |
| 禁止袭击 | 立即清理玩家身上的袭击效果,传灾厄村民进虚空 |
| 清除XX效果 | 立即清除玩家身上的效果
| 给与xx效果 | 根据配置文件,给予玩家指定等级、时长的效果
## 生物属性对照表
| 中文名称 | 类型 | 附加属性 |
| :---: | :----: | :----: |
| 溺尸 | 近战/远程 | 亡灵、水生 |
| 骷髅 | 远程 | 亡灵 |
| 流浪者 | 远程 | 亡灵、寒冷群系变种 |
| 沼骸 | 远程 | 亡灵、沼泽变种 |
| 焦骸 | 远程 | 亡灵、沙漠变种、免疫阳光 |
| 掠夺者 | 远程 | 灾厄村民、袭击成员 |
| 卫道士 | 近战 | 灾厄村民、袭击成员 |
| 幻术师 | 远程 | 灾厄村民、Java专属、隐身分身 |
| 旋风人 | 远程 | 试炼密室生成、免疫弹射物、强击退 |
| 监守者 | 近战/声波远程 | 深暗之域、附带盲视效果 |
| 远古守卫者 | 近战/激光远程 | 海底神殿、水下、附加挖掘疲劳 |
| 守卫者 | 近战/激光远程 | 海底神殿、水下 |
配置文件解析:<br>
- 支持注释格式:`Java的//单行注释`、`yml的#单行注释`、`HTML的<!--- --->注释`和`Java/* */块注释`
```txt
# 区域: 主城区
起点: world,-130,-713
终点: world,-427,-560
高度范围: 0-255
没收物品: 蘑菇,毒蘑菇 #没收哪些物品(末地推荐这两个)
// 禁止放置方块 #保护区内,非区白/全白玩家禁止放置方块
// 禁止破坏方块 # 同上
禁止PVP # 玩家之间不能PVP
禁止摔伤 # 摔落无法造成任何伤害
禁止饥饿 # 不会消耗饱食度
禁止一切伤害 # 任何怪物都无法造成伤害
# 禁止丢弃物品
# 禁止末影珍珠
# 禁止使用弓箭
# 禁止骑乘
// 和平模式 // 所有的敌对生物全部传送进虚空
禁止袭击
禁止交互展示框 # 核心,任何玩家不能触碰展示框。区白/全白玩家除外
// 清除所有效果 # 清理所有负面效果
效果 村庄英雄 255 999999 # 给予玩家效果。格式为: 效果 效果名字 等级(最高255) 时间
效果 生命提升 255 999999
效果 抗性提升 255 999999
效果 抗火 255 999999
# 禁止药水
禁止爆炸
禁止使用物品: TNT
# 效果: 夜视 1 999
进入提示: 欢迎来到保护区 # 是否开启进入提示,即自定义提示
离开提示: 已离开保护区
没收提示: 你的违禁品已被没收
通报批评: §c§l{player} §f在{area}违规携带违禁品,被没收 §e{count}§f件! # 通报违禁品。{player} 玩家名 {count}数量 {area}区域
# 惩罚命令: ban {player} 60s 违规
```
## 运行截图
主界面
![](img/login.png)
邀请数据
![](img/login2.png)
积分商店
![](img/login3.png)
任务中心
![](img/login4.png)
垃圾箱
![](img/login5.png)
管理员面板
![](img/login6.png)
管理面板-用户管理
![](img/login7.png)
工单中心
![](img/login8.png)
工单管理
![](img/login9.png)
我的工单
![](img/login10.png)
提交工单
![](img/login11.png)
服务商列表
![](img/login12.png)
服务商抢单大厅
![](img/login13.png)
服务商-接单界面
![](img/login14.png)
管理员-工单详情页面
![](img/login15.png)
商城主界面
![](img/shop1.png)
商城购物车
![](img/cart1.png)
商城退单
![](img/shop2.png)
## 4. 下载地址
- GitHub:[https://github.com/ypsdf1/sdf1_login](https://github.com/ypsdf1/sdf1_login)
- Gitee:[https://gitee.com/nihaoshidifu/sdf1_login](https://gitee.com/nihaoshidifu/sdf1_login)
-66
View File
@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>sdf1</groupId>
<artifactId>Sdf1_login</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21.4-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.MilkBowl</groupId>
<artifactId>VaultAPI</artifactId>
<version>1.7.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>Sdf1_login</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1,197 +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<>();
// 是否已进入挂机检测(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 +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";
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,697 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.File;
import java.sql.*;
import java.util.*;
public class BondManager {
private final Main plugin;
private Connection db;
// ===== 交易类型常量 =====
public static final String TX_ADMIN_GIVE = "admin_give";
public static final String TX_ADMIN_DEDUCT = "admin_deduct";
public static final String TX_TRANSFER_OUT = "transfer_out";
public static final String TX_TRANSFER_IN = "transfer_in";
public static final String TX_REDEEM = "redeem";
public static final String TX_SHOP_BUY = "shop_buy";
public static final String TX_DAILY_SIGN = "daily_sign";
public static final String TX_FREEZE = "freeze";
public static final String TX_UNFREEZE = "unfreeze";
public static final int MAX_WATER_DAYS = 14;
public static final int ADMIN_MAX_WATER_DAYS = 30;
public BondManager(Main plugin) {
this.plugin = plugin;
initDB();
}
// ======================== 数据库初始化 ========================
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(), "bond.db");
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
// 债券余额(含冻结状态)
st.execute("CREATE TABLE IF NOT EXISTS bonds ("
+ "player_name TEXT PRIMARY KEY,"
+ "amount INTEGER DEFAULT 0,"
+ "status TEXT DEFAULT 'normal')");
// 兼容旧表
try {
st.execute("ALTER TABLE bonds "
+ "ADD COLUMN status "
+ "TEXT DEFAULT 'normal'");
} catch (SQLException ignored) {}
// 旧流水(保留兼容)
st.execute("CREATE TABLE IF NOT EXISTS bond_log ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "player_name TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "reason TEXT DEFAULT '',"
+ "time INTEGER DEFAULT 0)");
// 新交易流水表
st.execute("CREATE TABLE IF NOT EXISTS "
+ "bond_transaction ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "player_name TEXT NOT NULL,"
+ "type TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "target_player TEXT DEFAULT '',"
+ "operator TEXT DEFAULT '',"
+ "reason TEXT DEFAULT '',"
+ "balance_after INTEGER DEFAULT 0,"
+ "time INTEGER DEFAULT 0)");
try {
st.execute("CREATE INDEX "
+ "IF NOT EXISTS idx_tx_pt "
+ "ON bond_transaction("
+ "player_name, time)");
} catch (SQLException ignored) {}
try {
st.execute("ALTER TABLE bond_transaction "
+ "ADD COLUMN balance_before "
+ "INTEGER DEFAULT 0");
} catch (SQLException ignored) {}
st.close();
} catch (Exception e) {
throw new RuntimeException(
"[Bond] DB init failed: "
+ e.getMessage(), e);
}
}
// ======================== 账户状态 ========================
public boolean isFrozen(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return "frozen".equals(s);
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return false;
}
public String getAccountStatus(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return s != null ? s : "normal";
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return "normal";
}
public void freezeAccount(String player) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='frozen' "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
}
public void unfreezeAccount(String player) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='normal' "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
}
private void ensureAccount(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR IGNORE INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,0,'normal')");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
} catch (SQLException ignored) {}
}
// ======================== 余额查询 ========================
public int getBonds(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT amount FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
int v = 0;
if (rs.next()) v = rs.getInt("amount");
rs.close(); ps.close();
return v;
} catch (SQLException e) { return 0; }
}
/** 该玩家是否在 bond 表中有真实记录 */
public boolean hasBondAccount(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM bonds "
+ "WHERE player_name=? "
+ "AND amount > 0");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
boolean exists = rs.next();
rs.close(); ps.close();
return exists;
} catch (SQLException e) {
return false;
}
}
// ======================== 增加债券 ========================
public int addBonds(String player, int amount) {
return addBondsInternal(player, amount,
TX_ADMIN_GIVE, "", "",
"管理员发放");
}
public int addBonds(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
if (isFrozen(player)) return -1;
return addBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
private int addBondsInternal(String player,
int amount,
String type,
String targetPlayer,
String operator,
String reason) {
int before = getBonds(player);
int after = before + amount;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status)"
+ " VALUES (?,?, 'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, after);
ps.setInt(3, after);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
logTransaction(player, type, amount,
targetPlayer, operator, reason,
before, after);
logLegacy(player, amount);
return after;
}
// ======================== 扣除债券 ========================
private boolean deductBondsInternal(String player,
int amount,
String type,
String targetPlayer,
String operator,
String reason) {
int before = getBonds(player);
if (before < amount) return false;
int after = before - amount;
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET amount=? "
+ "WHERE player_name=?");
ps.setInt(1, after);
ps.setString(2, player);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
logTransaction(player, type, amount,
targetPlayer, operator, reason,
before, after);
logLegacy(player, -amount);
return true;
}
// ======================== 流水写入 ========================
private void logTransaction(String player,
String type,
int amount,
String targetPlayer,
String operator,
String reason,
int balanceBefore,
int balanceAfter) {
try {
// 合并模式:shop_buy 30秒内同商品合并
if ("shop_buy".equals(type)
&& reason != null
&& !reason.isEmpty()) {
long mergeWindow =
System.currentTimeMillis() - 30000L;
PreparedStatement chk =
db.prepareStatement(
"SELECT id, amount "
+ "FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND type=? "
+ "AND reason=? "
+ "AND time > ? "
+ "ORDER BY time DESC "
+ "LIMIT 1");
chk.setString(1, player);
chk.setString(2, type);
chk.setString(3, reason);
chk.setLong(4, mergeWindow);
ResultSet rs = chk.executeQuery();
if (rs.next()) {
int existId = rs.getInt("id");
int existAmt = rs.getInt("amount");
rs.close();
chk.close();
PreparedStatement upd =
db.prepareStatement(
"UPDATE bond_transaction "
+ "SET amount=?, "
+ "balance_after=?, "
+ "time=? "
+ "WHERE id=?");
upd.setInt(1, existAmt + amount);
upd.setInt(2, balanceAfter);
upd.setLong(3,
System.currentTimeMillis());
upd.setInt(4, existId);
upd.executeUpdate();
upd.close();
return;
}
rs.close();
chk.close();
}
// 原始插入
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bond_transaction "
+ "(player_name, type, amount,"
+ " target_player, operator,"
+ " reason, balance_before,"
+ " balance_after, time)"
+ " VALUES (?,?,?,?,?,?,?,?,?)");
ps.setString(1, player);
ps.setString(2, type);
ps.setInt(3, amount);
ps.setString(4,
targetPlayer != null ? targetPlayer : "");
ps.setString(5,
operator != null ? operator : "");
ps.setString(6,
reason != null ? reason : "");
ps.setInt(7, balanceBefore);
ps.setInt(8, balanceAfter);
ps.setLong(9, System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
// ======================== 扣除债券 ========================
/** 完整版:含冻结检查 + 流水记录 */
public boolean deductBonds(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
if (isFrozen(player)) return false;
return deductBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
public boolean checkoutCart(String player,
String[] itemNames,
int[] subtotals) {
int total = 0;
for (int s : subtotals) total += s;
if (isFrozen(player)) return false;
int before = getBonds(player);
if (before < total) return false;
int after = before - total;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,?,'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, after);
ps.setInt(3, after);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
return false;
}
int running = before;
for (int i = 0; i < itemNames.length; i++) {
int next = running - subtotals[i];
logTransaction(player, "shop_buy", subtotals[i],
"", "商店系统",
"购物车购买-" + itemNames[i],
running, next);
running = next;
}
logLegacy(player, -total);
return true;
}
public boolean setBonds(String player, int amount) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bonds "
+ "(player_name,amount,status) "
+ "VALUES (?,?, 'normal') "
+ "ON CONFLICT(player_name) "
+ "DO UPDATE SET amount=?");
ps.setString(1, player);
ps.setInt(2, amount);
ps.setInt(3, amount);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return true;
}
// ======================== 转账 ========================
public TransferResult transfer(String from,
String to,
int amount) {
if (from.equalsIgnoreCase(to))
return new TransferResult(false,
"不能给自己转账");
if (isFrozen(from))
return new TransferResult(false,
"你的账户已被冻结");
if (isFrozen(to))
return new TransferResult(false,
to + " 的账户已被冻结");
if (isSameIP(from, to))
return new TransferResult(false,
"不能与同IP玩家转账");
if (!deductBondsInternal(from, amount,
TX_TRANSFER_OUT, to, from,
"转账给" + to))
return new TransferResult(false, "债券不足");
addBondsInternal(to, amount,
TX_TRANSFER_IN, from, from,
"收到来自" + from + "的转账");
return new TransferResult(true, "转账成功");
}
// ======================== 同IP检测 ========================
public boolean isSameIP(String p1, String p2) {
String ip1 = getPlayerIPAny(p1);
String ip2 = getPlayerIPAny(p2);
if (ip1 == null || ip2 == null) return false;
return ip1.equals(ip2);
}
/** 获取同IP下的所有其他在线玩家 */
public List<String> getSameIPPlayers(String player) {
String ip = getPlayerIPAny(player);
if (ip == null) return Collections.emptyList();
List<String> list = new ArrayList<>();
for (Player p :
plugin.getServer().getOnlinePlayers()) {
String pIp = plugin.getPlayerIP(p);
if (pIp != null && pIp.equals(ip)
&& !p.getName()
.equalsIgnoreCase(player)) {
list.add(p.getName());
}
}
return list;
}
private String getPlayerIPAny(String player) {
// 在线:直接取
Player p = plugin.getServer()
.getPlayerExact(player);
if (p != null) {
String ip = plugin.getPlayerIP(p);
if (ip != null) return ip;
}
// 离线:查主库
try {
Object v = plugin.getDb()
.getField(player, "last_login_ip");
if (v != null) return v.toString();
} catch (Exception ignored) {}
try {
Object v = plugin.getDb()
.getField(player, "register_ip");
if (v != null) return v.toString();
} catch (Exception ignored) {}
return null;
}
// ======================== 流水查询 ========================
public List<Map<String, Object>> getTransactions(
String player, int days) {
days = Math.min(days, MAX_WATER_DAYS);
long cutoff = System.currentTimeMillis()
- (long) days * 24 * 60 * 60 * 1000;
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND time>=? "
+ "ORDER BY time DESC");
ps.setString(1, player);
ps.setLong(2, cutoff);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("operator",
rs.getString("operator"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
// 放在 getTransactions 方法后面:
/**
* 获取该玩家全部流水(不限时间,供打印使用)
*/
public List<Map<String, Object>>
getAllTransactions(String player) {
List<Map<String, Object>> list =
new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "ORDER BY time ASC");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("player_name",
rs.getString("player_name"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("operator",
rs.getString("operator"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
// 放在 getAllTransactions 方法后面:
/**
* 获取所有有流水记录的玩家名(去重)
*/
public List<String> getAllPlayerNames() {
List<String> list = new ArrayList<>();
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT DISTINCT player_name "
+ "FROM bond_transaction "
+ "ORDER BY player_name ASC");
while (rs.next()) {
list.add(rs.getString("player_name"));
}
rs.close(); st.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
public List<Map<String, Object>>
getTransferTransactions(
String player, int days) {
days = Math.min(days, MAX_WATER_DAYS);
long cutoff = System.currentTimeMillis()
- (long) days * 24 * 60 * 60 * 1000;
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND time>=? "
+ "AND (type=? OR type=?) "
+ "ORDER BY time DESC");
ps.setString(1, player);
ps.setLong(2, cutoff);
ps.setString(3, TX_TRANSFER_OUT);
ps.setString(4, TX_TRANSFER_IN);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id", rs.getInt("id"));
row.put("type", rs.getString("type"));
row.put("amount",
rs.getInt("amount"));
row.put("target_player",
rs.getString("target_player"));
row.put("reason",
rs.getString("reason"));
row.put("balance_after",
rs.getInt("balance_after"));
row.put("balance_before", rs.getInt("balance_before"));
row.put("time", rs.getLong("time"));
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
private void logLegacy(String player, int amount) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO bond_log "
+ "(player_name,amount,time) "
+ "VALUES (?,?,?)");
ps.setString(1, player);
ps.setInt(2, amount);
ps.setLong(3,
System.currentTimeMillis());
ps.executeUpdate(); ps.close();
} catch (SQLException ignored) {}
}
public Map<String, Object> getLog(
String player, int limit) {
Map<String, Object> info = new LinkedHashMap<>();
info.put("balance", getBonds(player));
return info;
}
// ======================== 转账结果 ========================
public static class TransferResult {
public final boolean success;
public final String message;
public TransferResult(boolean success,
String message) {
this.success = success;
this.message = message;
}
}
}
@@ -1,378 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.*;
public class BondPrinter {
private final Main plugin;
private final File outputDir;
private static final SimpleDateFormat FILE_SDF =
new SimpleDateFormat(
"yyyy-MM-dd_HH-mm-ss");
private static final SimpleDateFormat CELL_SDF =
new SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss");
private static final LinkedHashMap<String, String>
TYPE_NAMES = new LinkedHashMap<>();
static {
TYPE_NAMES.put("admin_give", "管理员发放");
TYPE_NAMES.put("admin_deduct", "管理员扣除");
TYPE_NAMES.put("transfer_out", "转出");
TYPE_NAMES.put("transfer_in", "转入");
TYPE_NAMES.put("redeem", "CDK兑换");
TYPE_NAMES.put("shop_buy", "商城购买");
TYPE_NAMES.put("shop_sell", "商城出售"); // ← 补上
TYPE_NAMES.put("daily_sign", "签到奖励");
TYPE_NAMES.put("freeze", "冻结");
TYPE_NAMES.put("unfreeze", "解冻");
}
public BondPrinter(Main plugin) {
this.plugin = plugin;
this.outputDir = new File(
plugin.getDataFolder(), "printer");
if (!this.outputDir.exists()) {
this.outputDir.mkdirs();
}
}
// ============ 打印单个玩家 ============
public void printPlayer(String player,
CommandSender sender) {
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() -> doPrintPlayer(
player, sender));
}
private void doPrintPlayer(String player,
CommandSender sender) {
try {
List<Map<String, Object>> txs =
plugin.getBonds()
.getAllTransactions(player);
String fileName = player + "_"
+ FILE_SDF.format(new Date())
+ ".xls";
File file = new File(outputDir, fileName);
deleteOld(player);
PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(file),
StandardCharsets.UTF_8));
try {
writeXmlHeader(pw,
escXml(player));
writeXmlRow(pw,
new String[]{
"时间", "类型",
"金额", "对象",
"操作者", "理由",
"操作前余额",
"操作后余额"});
for (Map<String, Object> tx : txs) {
writeXmlRow(pw, new String[]{
txTime(tx),
txTypeName(tx),
String.valueOf(
txInt(tx,
"amount")),
txStr(tx,
"target_player"),
txStr(tx, "operator"),
txStr(tx, "reason"),
String.valueOf(
txInt(tx,
"balance_before")),
String.valueOf(
txInt(tx,
"balance_after"))
});
}
writeXmlFooter(pw);
pw.flush();
} finally {
pw.close();
}
if (sender != null) {
sender.sendMessage("§a[打印] §f"
+ player + " §a流水已导出");
sender.sendMessage("§7文件: §f"
+ fileName);
sender.sendMessage("§7共 §f"
+ txs.size()
+ " §7条,§724小时后删除");
}
} catch (Exception e) {
if (sender != null) {
sender.sendMessage("§c导出失败: "
+ e.getMessage());
}
plugin.getLogger().severe(
"[Printer] " + e.getMessage());
}
}
// ============ 打印全服 ============
public void printAll(CommandSender sender) {
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() -> doPrintAll(sender));
}
private void doPrintAll(CommandSender sender) {
try {
List<String> players =
plugin.getBonds()
.getAllPlayerNames();
if (players.isEmpty()) {
if (sender != null) {
sender.sendMessage("§c没有流水数据");
}
return;
}
String fileName = "全服流水_"
+ FILE_SDF.format(new Date())
+ ".xls";
File file = new File(outputDir, fileName);
// ===== 收集所有流水 =====
// key = 时间戳+玩家+金额,用于去重
// value = 一行数据
List<String[]> allRows = new ArrayList<>();
Set<String> seenKeys = new HashSet<>();
for (String player : players) {
List<Map<String, Object>> txs =
plugin.getBonds()
.getAllTransactions(player);
for (Map<String, Object> tx : txs) {
int txId = ((Number) tx.get("id"))
.intValue();
String dedupKey =
String.valueOf(txId);
if (!seenKeys.add(dedupKey)) {
continue;
}
String timeStr = txTime(tx);
long timeMs = 0;
Object t = tx.get("time");
if (t != null) {
timeMs =
((Number) t).longValue();
}
allRows.add(new String[]{
player,
timeStr,
txTypeName(tx),
String.valueOf(
txInt(tx, "amount")),
txStr(tx, "target_player"),
txStr(tx, "operator"),
txStr(tx, "reason"),
String.valueOf(
txInt(tx,
"balance_before")),
String.valueOf(
txInt(tx,
"balance_after")),
// 额外存时间戳用于排序
String.valueOf(timeMs)
});
}
}
// ===== 按时间戳排序 =====
allRows.sort((a, b) -> {
long ta = Long.parseLong(
a[a.length - 1]);
long tb = Long.parseLong(
b[b.length - 1]);
return Long.compare(ta, tb);
});
// ===== 写入文件 =====
PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(file),
StandardCharsets.UTF_8));
try {
writeXmlHeader(pw, "全服流水");
writeXmlRow(pw, new String[]{
"玩家", "时间", "类型",
"金额", "对象", "操作者",
"理由", "操作前余额",
"操作后余额"
});
for (String[] row : allRows) {
// 去掉最后的时间戳列
String[] cells =
new String[row.length - 1];
System.arraycopy(row, 0,
cells, 0, cells.length);
writeXmlRow(pw, cells);
}
writeXmlFooter(pw);
pw.flush();
if (sender != null) {
sender.sendMessage(
"§a[打印] §f全服流水 §a已导出");
sender.sendMessage("§7文件: §f"
+ fileName);
sender.sendMessage("§7共 §f"
+ players.size()
+ " 名玩家,§f"
+ allRows.size() + " §7条");
sender.sendMessage(
"§7按时间从早到晚排序");
sender.sendMessage(
"§724小时后自动删除");
}
} finally {
pw.close();
}
} catch (Exception e) {
if (sender != null) {
sender.sendMessage("§c导出失败: "
+ e.getMessage());
}
plugin.getLogger().severe(
"[Printer] " + e.getMessage());
}
}
// ============ 清理 ============
public void cleanupOldFiles() {
long now = System.currentTimeMillis();
long maxAge = 24L * 60 * 60 * 1000;
File[] files = outputDir.listFiles(
(d, n) -> n.endsWith(".xls"));
if (files == null) {
return;
}
int count = 0;
for (File f : files) {
if (now - f.lastModified() > maxAge) {
if (f.delete()) {
count++;
}
}
}
if (count > 0) {
plugin.getLogger().info(
"[Printer] 清理 "
+ count + " 个过期文件");
}
}
private void deleteOld(String player) {
File[] old = outputDir.listFiles(
(d, n) -> n.startsWith(player + "_")
&& n.endsWith(".xls"));
if (old != null) {
for (File f : old) {
f.delete();
}
}
}
// ============ XML工具 ============
private void writeXmlHeader(PrintWriter pw,
String sheetName) {
pw.println("<?xml version=\"1.0\"?>");
pw.println("<?mso-application "
+ "progid=\"Excel.Sheet\"?>");
pw.println("<Workbook xmlns="
+ "\"urn:schemas-microsoft-com"
+ ":office:spreadsheet\"");
pw.println(" xmlns:ss="
+ "\"urn:schemas-microsoft-com"
+ ":office:spreadsheet\">");
pw.println(" <Worksheet ss:Name=\""
+ sheetName + "\">");
pw.println(" <Table>");
}
private void writeXmlRow(PrintWriter pw,
String[] cells) {
pw.println(" <Row>");
for (String cell : cells) {
pw.println(" <Cell>"
+ "<Data ss:Type=\"String\">"
+ escXml(cell != null ? cell : "")
+ "</Data></Cell>");
}
pw.println(" </Row>");
}
private void writeXmlFooter(PrintWriter pw) {
pw.println(" </Table>");
pw.println(" </Worksheet>");
pw.println("</Workbook>");
}
private String escXml(String val) {
if (val == null) {
return "";
}
return val.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
private String txTime(Map<String, Object> tx) {
Object t = tx.get("time");
if (t == null) return "";
long ms = ((Number) t).longValue();
return CELL_SDF.format(new Date(ms));
}
private String txTypeName(
Map<String, Object> tx) {
String type = (String) tx.get("type");
return TYPE_NAMES.getOrDefault(type, type);
}
private int txInt(Map<String, Object> tx,
String key) {
Object v = tx.get(key);
if (v == null) return 0;
return ((Number) v).intValue();
}
private String txStr(Map<String, Object> tx,
String key) {
Object v = tx.get(key);
return v != null ? v.toString() : "";
}
}
@@ -1,293 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.*;
import java.sql.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.nio.charset.StandardCharsets;
public class CDKManager {
private final Main plugin;
private Connection db;
private final Map<String, CDKInput> pendingInputs
= new ConcurrentHashMap<>();
public CDKManager(Main plugin) {
this.plugin = plugin;
initDB();
}
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(), "bond.db");
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
st.execute("CREATE TABLE IF NOT EXISTS cdk ("
+ "code TEXT PRIMARY KEY,"
+ "amount INTEGER DEFAULT 0,"
+ "type TEXT DEFAULT 'bond',"
+ "used INTEGER DEFAULT 0,"
+ "used_by TEXT DEFAULT '',"
+ "created_time INTEGER DEFAULT 0,"
+ "used_time INTEGER DEFAULT 0)");
st.close();
} catch (Exception e) {
throw new RuntimeException(
"[CDK] DB init failed: "
+ e.getMessage(), e);
}
}
// ===== CDK兑换(供SDF1调用) =====
/**
* 兑换CDK,返回结果字符串
* @return "success:金额:余额前:余额后"
* 或 "fail:原因"
*/
public String redeem(String code, String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT amount,type,used FROM cdk "
+ "WHERE code=?");
ps.setString(1, code);
ResultSet rs = ps.executeQuery();
if (!rs.next()) {
rs.close(); ps.close();
return "fail:not_found";
}
int amount = rs.getInt("amount");
String type = rs.getString("type");
int used = rs.getInt("used");
rs.close(); ps.close();
if (used == 1)
return "fail:already_used";
if (plugin.getBonds()
.isFrozen(player))
return "fail:frozen";
PreparedStatement up = db.prepareStatement(
"UPDATE cdk SET used=1,"
+ "used_by=?,used_time=? "
+ "WHERE code=?");
up.setString(1, player);
up.setLong(2,
System.currentTimeMillis());
up.setString(3, code);
up.executeUpdate(); up.close();
if ("bond".equals(type)) {
int bef = plugin.getBonds()
.getBonds(player);
plugin.getBonds().addBonds(player,
amount,
BondManager.TX_REDEEM,
"", "CDK系统",
"CDK兑换: " + code);
int aft = plugin.getBonds()
.getBonds(player);
return "success:" + amount
+ ":" + bef + ":" + aft;
}
return "fail:unknown_type";
} catch (SQLException e) {
return "fail:" + e.getMessage();
}
}
// ===== 聊天输入系统 =====
public void requestInput(Player p,
String type,
String context) {
CDKInput input = new CDKInput();
input.type = type;
input.context = context;
input.time = System.currentTimeMillis();
pendingInputs.put(p.getName(), input);
}
public boolean isListening(String playerName) {
return pendingInputs
.containsKey(playerName);
}
public void onChat(Player p, String msg) {
CDKInput input = pendingInputs
.remove(p.getName());
if (input == null) return;
if (System.currentTimeMillis()
- input.time > 60000) {
p.sendMessage("§c输入超时");
return;
}
if ("cdk".equals(input.type)) {
String result = redeem(
msg.trim(), p.getName());
if (result != null
&& result.startsWith("success:")) {
String[] parts = result.split(":");
int amt = Integer.parseInt(parts[1]);
int bef = Integer.parseInt(parts[2]);
int aft = Integer.parseInt(parts[3]);
p.sendMessage("§e§l[CDK] §f兑换成功!"
+ "获得 §c" + amt + "§f债券");
p.sendMessage("§7余额: §e" + bef
+ " §7-> §a" + aft);
} else {
p.sendMessage("§c§l[CDK] §f"
+ (result != null
? result.replace("fail:", "")
: "无效的兑换码"));
}
return;
}
if ("econ".equals(input.type)) {
handleEconInput(p, msg.trim(),
input.context);
return;
}
if ("bond".equals(input.type)) {
handleBondInput(p, msg.trim(),
input.context);
}
}
private void handleEconInput(Player admin,
String msg,
String target) {
net.milkbowl.vault.economy.Economy econ
= null;
try {
var reg = plugin.getServer()
.getServicesManager()
.getRegistration(
net.milkbowl.vault
.economy
.Economy.class);
if (reg != null)
econ = reg.getProvider();
} catch (Exception ignored) {}
if (econ == null) {
admin.sendMessage("§c经济系统不可用");
return;
}
if (!msg.startsWith("+")
&& !msg.startsWith("-")) {
admin.sendMessage("§c格式: §a+100"
+ " §7或 §c-50");
return;
}
try {
String numStr =
msg.substring(1).trim();
double amount =
Double.parseDouble(numStr);
if (amount <= 0) {
admin.sendMessage("§c金额必须>0");
return;
}
if (msg.startsWith("-")) {
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp == null) {
admin.sendMessage("§c" + target
+ " 不在线");
return;
}
if (!econ.has(tp, amount)) {
admin.sendMessage("§c余额不足");
return;
}
econ.withdrawPlayer(tp, amount);
admin.sendMessage("§a扣款成功");
} else {
econ.depositPlayer(target, amount);
admin.sendMessage("§a到账成功");
}
} catch (NumberFormatException e) {
admin.sendMessage("§c格式错误");
}
}
private void handleBondInput(Player admin,
String msg,
String target) {
if (!msg.startsWith("+")
&& !msg.startsWith("-")) {
admin.sendMessage("§c格式: §a+100"
+ " §7或 §c-50");
return;
}
try {
String sign = msg.substring(0, 1);
String rest =
msg.substring(1).trim();
String[] parts =
rest.split("\\s+", 2);
int amount = Integer.parseInt(parts[0]);
String reason = parts.length >= 2
? parts[1].trim()
: "管理员手动调整";
if (amount <= 0) {
admin.sendMessage("§c金额必须>0");
return;
}
String opName = admin.getName();
String txType = "-".equals(sign)
? BondManager.TX_ADMIN_DEDUCT
: BondManager.TX_ADMIN_GIVE;
if ("-".equals(sign)) {
if (plugin.getBonds()
.isFrozen(target)) {
admin.sendMessage("§c账户冻结");
return;
}
if (plugin.getBonds().deductBonds(
target, amount, txType,
"", opName, reason)) {
admin.sendMessage("§a扣除成功");
} else {
admin.sendMessage("§c债券不足");
}
} else {
if (plugin.getBonds()
.isFrozen(target)) {
admin.sendMessage("§c账户冻结");
return;
}
plugin.getBonds().addBonds(target,
amount, txType,
"", opName, reason);
admin.sendMessage("§a发放成功");
}
} catch (NumberFormatException e) {
admin.sendMessage("§c格式错误");
}
}
public void cancelInput(String player) {
pendingInputs.remove(player);
}
static class CDKInput {
String type;
String context;
long time;
}
}
@@ -1,612 +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 boolean enabled = true;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean v) {
enabled = v;
}
private static final Pattern URL_PATTERN =
Pattern.compile(
"(?i)(?:https?://\\S+)"
+ "|(?:(?:[a-zA-Z0-9]"
+ "(?:[a-zA-Z0-9\\-]*"
+ "[a-zA-Z0-9])?\\.)+"
+ "[a-zA-Z]{2,})"
+ "(?:/\\S*)?");
private static final Pattern DOT_VARIANTS =
Pattern.compile(
"[\uff0e\u3002\u2025\u2026\u00b7]");
private static final Pattern COLOR_CODES =
Pattern.compile(
"\u00a7[0-9a-fk-orA-FK-OR]");
private static final Set<String> FILE_EXT;
static {
Set<String> s = new HashSet<>(
Arrays.asList(
"txt", "yml", "yaml",
"json", "xml", "java",
"class", "jar", "png",
"jpg", "jpeg", "gif",
"bmp", "svg", "ico",
"webp", "mp3", "mp4",
"avi", "mkv", "wav",
"flac", "ogg", "zip",
"rar", "7z", "tar",
"gz", "cfg", "conf",
"ini", "log", "db",
"sql", "sh", "bat",
"html", "htm", "css",
"js", "md", "pdf",
"dll", "so", "exe",
"bin", "c", "cpp",
"py", "rb", "go",
"rs", "kt", "lua",
"php", "asp", "jsp"));
FILE_EXT = Collections.unmodifiableSet(s);
}
public ChatFilterManager(JavaPlugin plugin) {
this.plugin = plugin;
}
// ========== 配置加载 ==========
public void loadConfig() {
whitelistUrls.clear();
whitelistPlayers.clear();
punishmentRules.clear();
punishmentDurations.clear();
messages.clear();
File f = new File(
plugin.getDataFolder(), "chat.txt");
if (!f.exists()) writeDefaultFile(f);
try (BufferedReader r =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8))) {
String section = "";
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("\uFEFF"))
line = line.substring(1);
String t = line.trim();
if (t.isEmpty()
|| t.startsWith("#"))
continue;
if (t.equals("白名单")
|| t.equals("白名单:")) {
section = "whitelist";
continue;
}
if (t.equals("处罚规则")
|| t.equals("处罚规则:")) {
section = "punishment";
continue;
}
if (t.equals("白名单玩家")
|| t.equals("白名单玩家:")) {
section = "players";
continue;
}
if (t.contains(":")) {
String[] kv = t.split(":", 2);
String k = kv[0].trim();
String v = kv[1].trim();
if (equals("启用过滤")
|| equals("enabled")) {
enabled = parseBool(v);
continue;
}
if (k.equals("通知管理员")) {
notifyAdmin = parseBool(v);
continue;
}
if (k.equals("全服通报")) {
notifyAll = parseBool(v);
continue;
}
if (k.equals("禁言时长")) {
try {
muteDuration =
Integer.parseInt(v);
} catch (Exception ignored) {
}
continue;
}
}
switch (section) {
case "whitelist":
whitelistUrls.add(
t.toLowerCase());
break;
case "punishment":
String[] parts = t.split(":");
if (parts.length >= 2) {
try {
int cnt =
Integer.parseInt(
parts[0]
.trim());
String type =
parts[1].trim()
.toLowerCase();
int dur =
parts.length >= 3
? Integer.parseInt(
parts[2]
.trim())
: muteDuration;
punishmentRules.put(
cnt, type);
punishmentDurations.put(
cnt, dur);
} catch (Exception ignored) {
}
}
break;
case "players":
whitelistPlayers.add(t);
break;
}
}
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_chat] 配置加载失败: "
+ e.getMessage());
}
// 从消息.txt读取chat_开头的消息
loadMessages();
}
private void loadMessages() {
File msgFile = new File(
plugin.getDataFolder(),
"消息.txt");
if (!msgFile.exists()) return;
try (BufferedReader mr =
new BufferedReader(
new InputStreamReader(
new FileInputStream(
msgFile),
StandardCharsets.UTF_8))) {
String mline;
while ((mline = mr.readLine()) != null) {
mline = mline.trim();
if (mline.isEmpty()
|| mline.startsWith("#"))
continue;
// 支持 = 和 : 两种分隔符
int eqIdx = mline.indexOf('=');
int coIdx = mline.indexOf(':');
int splitIdx = -1;
if (eqIdx >= 0 && coIdx >= 0) {
splitIdx = Math.min(eqIdx, coIdx);
} else if (eqIdx >= 0) {
splitIdx = eqIdx;
} else if (coIdx >= 0) {
splitIdx = coIdx;
}
if (splitIdx < 0) continue;
String k = mline.substring(0,
splitIdx).trim();
String v = mline.substring(
splitIdx + 1).trim();
if (k.startsWith("chat_")) {
messages.put(k, v);
}
}
} catch (Exception ignored) {
}
}
private void writeDefaultFile(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# Sdf1_chat 聊天过滤配置");
pw.println("启用过滤: " + enabled);
pw.println("通知管理员: true");
pw.println("全服通报: false");
pw.println("禁言时长: 300");
pw.println();
pw.println("白名单:");
pw.println("*.minecraft.net");
pw.println("baidu.com");
pw.println("github.com");
pw.println();
pw.println("处罚规则:");
pw.println("1:warn:0");
pw.println("2:warn:0");
pw.println("3:mute:300");
pw.println("5:mute:600");
pw.println("8:kick:0");
pw.println("10:ban:0");
pw.println();
pw.println("白名单玩家:");
} catch (IOException ignored) {
}
}
public void saveConfig() {
File f = new File(
plugin.getDataFolder(), "chat.txt");
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# Sdf1_chat 聊天过滤配置");
pw.println("通知管理员: " + notifyAdmin);
pw.println("全服通报: " + notifyAll);
pw.println("禁言时长: " + muteDuration);
pw.println();
pw.println("白名单:");
for (String u : whitelistUrls)
pw.println(u);
pw.println();
pw.println("处罚规则:");
for (Map.Entry<Integer, String> entry
: punishmentRules.entrySet()) {
int dur = punishmentDurations
.getOrDefault(entry.getKey(),
muteDuration);
pw.println(entry.getKey() + ":"
+ entry.getValue()
+ ":" + dur);
}
pw.println();
pw.println("白名单玩家:");
for (String p : whitelistPlayers)
pw.println(p);
} catch (IOException e) {
plugin.getLogger().severe(
"[Sdf1_chat] 保存失败: "
+ e.getMessage());
}
}
// ========== 消息 ==========
public String msg(String key,
String... args) {
String template = messages.getOrDefault(
key, key);
for (int i = 0; i < args.length - 1;
i += 2) {
template = template.replace(
"{" + args[i] + "}",
args[i + 1]);
}
return template;
}
// ========== URL检测 ==========
private String normalizeMessage(String msg) {
msg = COLOR_CODES.matcher(msg)
.replaceAll("");
msg = DOT_VARIANTS.matcher(msg)
.replaceAll(".");
return msg;
}
public List<String> extractUrls(
String message) {
String normalized =
normalizeMessage(message);
List<String> urls = new ArrayList<>();
Matcher m = URL_PATTERN
.matcher(normalized);
while (m.find()) {
String url = m.group().toLowerCase();
url = url.replaceAll(
"[\\s.,;!?,。;!?、)》」』】"
+ "\\]\\[\\((]+$",
"");
if (!url.isEmpty()) urls.add(url);
}
return urls;
}
public boolean isWhitelisted(String url) {
String clean = url
.replaceAll("^https?://", "")
.toLowerCase();
int colon = clean.indexOf(':');
int slash = clean.indexOf('/');
if (colon > 0 && (slash < 0
|| colon < slash)) {
clean = clean.substring(0, colon);
}
slash = clean.indexOf('/');
if (slash > 0) {
clean = clean.substring(0, slash);
}
for (String entry : whitelistUrls) {
String e = entry.toLowerCase().trim();
if (e.startsWith("*.")) {
String suffix = e.substring(1);
if (clean.endsWith(suffix)
|| clean.equals(
suffix.substring(1)))
return true;
} else {
if (clean.equals(e)
|| clean.endsWith(
"." + e))
return true;
}
}
return false;
}
public boolean isLikelyDomain(String url) {
int lastDot = url.lastIndexOf('.');
if (lastDot < 0
|| lastDot >= url.length() - 1)
return false;
String ext = url.substring(lastDot + 1)
.toLowerCase();
return !FILE_EXT.contains(ext);
}
// ========== 玩家操作 ==========
public boolean isMuted(String name) {
Long expiry = mutedPlayers.get(name);
if (expiry == null) return false;
if (System.currentTimeMillis()
>= expiry) {
mutedPlayers.remove(name);
return false;
}
return true;
}
public String fmtDuration(int seconds) {
if (seconds <= 0) return "0秒";
int day = seconds / 86400;
int hr = (seconds % 86400) / 3600;
int min = (seconds % 3600) / 60;
int sec = seconds % 60;
StringBuilder sb = new StringBuilder();
if (day > 0) sb.append(day).append("天");
if (hr > 0)
sb.append(hr).append("小时");
if (min > 0)
sb.append(min).append("分");
if (sec > 0 || sb.length() == 0)
sb.append(sec).append("秒");
return sb.toString();
}
public void applyPunishment(Player player,
int violation) {
String type = "warn";
int duration = muteDuration;
int maxRule = 0;
for (Map.Entry<Integer, String> entry
: punishmentRules.entrySet()) {
if (violation >= entry.getKey()
&& entry.getKey() > maxRule) {
maxRule = entry.getKey();
type = entry.getValue();
duration = punishmentDurations
.getOrDefault(
entry.getKey(),
muteDuration);
}
}
if (type.equals("mute")) {
if (isMuted(player.getName()))
return;
mutedPlayers.put(player.getName(),
System.currentTimeMillis()
+ (long) duration
* 1000L);
}
final String fType = type;
final int fDur = duration;
final int fV = violation;
final String fName = player.getName();
Bukkit.getScheduler().runTask(plugin,
() -> {
Player p = Bukkit.getPlayer(fName);
if (p == null) return;
switch (fType) {
case "warn":
// 警告不处罚,只提示
break;
case "mute":
p.sendMessage(
msg("chat_muted"));
break;
case "kick":
p.kickPlayer(
msg("chat_muted"));
break;
case "ban":
Bukkit.dispatchCommand(
Bukkit.getConsoleSender(),
"ban " + fName
+ " §c多次发送违规链接");
break;
case "banip":
InetSocketAddress addr =
p.getAddress();
if (addr != null) {
String ip = addr
.getAddress()
.getHostAddress();
Bukkit.dispatchCommand(
Bukkit.getConsoleSender(),
"ban-ip " + ip
+ " §c发送违规链接");
}
break;
}
});
}
public void unmutePlayer(String name) {
mutedPlayers.remove(name);
}
public void resetPlayer(String name) {
mutedPlayers.remove(name);
violationCount.remove(name);
}
public boolean isPlayerWhitelisted(
String name) {
return whitelistPlayers.contains(name);
}
public boolean isNotifyAdmin() {
return notifyAdmin;
}
public boolean isNotifyAll() {
return notifyAll;
}
public int getViolationCount(String name) {
return violationCount
.getOrDefault(name, 0);
}
public void incrementViolation(
String name) {
violationCount.put(name,
getViolationCount(name) + 1);
}
public List<String> getWhitelistUrls() {
return whitelistUrls;
}
public Set<String> getWhitelistPlayers() {
return whitelistPlayers;
}
public Map<Integer, String>
getPunishmentRules() {
return punishmentRules;
}
public Map<Integer, Integer>
getPunishmentDurations() {
return punishmentDurations;
}
public Map<String, Long>
getMutedPlayers() {
return mutedPlayers;
}
public void setNotifyAdmin(boolean v) {
notifyAdmin = v;
}
public void setNotifyAll(boolean v) {
notifyAll = v;
}
public void setMuteDuration(int v) {
muteDuration = v;
}
public void addUrl(String url) {
whitelistUrls.add(
url.toLowerCase().trim());
saveConfig();
}
public boolean removeUrl(String url) {
boolean r = whitelistUrls.remove(
url.toLowerCase().trim());
saveConfig();
return r;
}
public void addWhitelistPlayer(
String name) {
whitelistPlayers.add(name);
saveConfig();
}
public boolean removeWhitelistPlayer(
String name) {
boolean r =
whitelistPlayers.remove(name);
saveConfig();
return r;
}
public void cleanExpired() {
long now = System.currentTimeMillis();
mutedPlayers.entrySet().removeIf(
e -> e.getValue() <= now);
}
private boolean parseBool(String v) {
String s = v.toLowerCase().trim();
return s.equals("true")
|| s.equals("on")
|| s.equals("yes")
|| s.equals("1")
|| s.contains("开启")
|| s.contains("启用")
|| s.contains("是");
}
}
@@ -1,842 +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,
BACK_CHECK_ECONOMY_CONFIRM,
USER_MGMT_SEARCH,
}
public static class PlayerState {
public InputType type = InputType.NONE;
public String targetPlayer = "";
public String ticketType = "";
public String ticketTitle = "";
public String ticketDesc = "";
public int ticketId = 0;
public int smtpStep = 0;
public int scoreValue = 0;
}
private final Map<String, PlayerState> states =
new HashMap<>();
private Main plugin;
public PlayerState getState(Player p) {
return states.computeIfAbsent(
p.getName(),
k -> new PlayerState());
}
public void reset(Player p) {
states.remove(p.getName());
}
public boolean isInFlow(Player p) {
PlayerState state = states.get(p.getName());
return state != null
&& state.type != InputType.NONE;
}
public void handleInput(Player p, String msg,
Main mainPlugin) {
if (this.plugin == null)
this.plugin = mainPlugin;
PlayerState state = getState(p);
ConfigManager config =
mainPlugin.getConfig2();
DatabaseManager db = mainPlugin.getDb();
switch (state.type) {
case CHANGE_PWD_STEP1: {
String name = p.getName();
String salt = (String) db.getField(
name, "password_salt");
String hash =
PasswordUtils.hash(msg, salt);
boolean mainOk =
db.checkPassword(name, hash);
if (mainOk) {
state.type =
InputType.CHANGE_PWD_STEP2;
state.ticketTitle = "main";
p.sendMessage(
"§e请输入新密码:");
return;
}
boolean tempOk =
db.checkPasswordOrTemp(
name, hash);
if (tempOk) {
state.type =
InputType.CHANGE_PWD_STEP2;
state.ticketTitle = "temp";
p.sendMessage(
"§e请输入新密码:");
return;
}
p.sendMessage(config.msg(
"password_wrong"));
return;
}
case CHANGE_PWD_STEP2: {
String name = p.getName();
if (!PasswordUtils.validate(msg)) {
p.sendMessage(config.msg(
"password_format_error"));
reset(p);
return;
}
String oldHash =
(String) db.getField(name,
"password_hash");
String oldSalt =
(String) db.getField(name,
"password_salt");
String newSalt =
PasswordUtils.generateSalt();
String newHash =
PasswordUtils.hash(
msg, newSalt);
if (newHash.equals(oldHash)) {
p.sendMessage(config.msg(
"password_same"));
reset(p);
return;
}
mainPlugin.recordPasswordChange(
name, oldHash, oldSalt);
db.setField(name,
"password_hash", newHash);
db.setField(name,
"password_salt", newSalt);
mainPlugin
.getNeedsPasswordChange()
.remove(name);
p.sendMessage(config.msg(
"password_changed"));
reset(p);
return;
}
case SET_EMAIL: {
if (!msg.contains("@")
|| !msg.contains(".")) {
p.sendMessage(config.msg(
"email_format_error"));
reset(p);
return;
}
String oldEmail = (String)
db.getField(p.getName(),
"email");
String emailAddr = msg.trim();
// 已绑定邮箱:验证登录密码
if (oldEmail != null
&& !oldEmail.isEmpty()) {
state.targetPlayer = emailAddr;
state.type =
InputType.SET_EMAIL_VERIFY;
state.ticketTitle =
"need_pwd";
p.sendMessage(
"§e您已绑定邮箱: "
+ oldEmail);
p.sendMessage(
"§e请验证登录密码以确认修改:");
return;
}
// 未绑定邮箱:直接发验证码
String code = String.valueOf(
(int) (Math.random()
* 900000
+ 100000));
state.targetPlayer = emailAddr;
state.ticketTitle = code;
state.type =
InputType.SET_EMAIL_VERIFY;
final String to = emailAddr;
final String c = code;
final Player fp = p;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () -> {
boolean sent = mainPlugin
.getEmail()
.sendVerifyCode(
to, c);
Bukkit.getScheduler()
.runTask(mainPlugin,
() -> {
if (sent) {
fp.sendMessage(
"§a验证码已发送到 "
+ to);
fp.sendMessage(
"§e输入6位验证码(0取消):");
} else {
fp.sendMessage(
"§c发送失败");
reset(fp);
}
});
});
return;
}
case SET_EMAIL_VERIFY: {
if ("0".equals(msg.trim())
|| "取消".equals(
msg.trim())) {
p.sendMessage(
"§c邮箱设置已取消");
reset(p);
return;
}
String expected =
state.ticketTitle;
if (msg.trim().equals(expected)) {
db.setField(p.getName(),
"email",
state.targetPlayer);
p.sendMessage(
"§a邮箱已设置: "
+ state.targetPlayer);
reset(p);
} else {
p.sendMessage(
"§c验证码错误(0取消):");
}
return;
}
case ADMIN_AUTH: {
if (msg.equals(
config.adminPassword)) {
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getGui()
.openAdmin(p));
} else {
p.sendMessage(
"§c管理密码错误");
}
reset(p);
return;
}
case ADMIN_SET_POINTS: {
try {
int pts = Integer.parseInt(
msg.trim());
db.setField(
state.targetPlayer,
"points", pts);
p.sendMessage("§a已将 "
+ state.targetPlayer
+ " 积分设为 " + pts);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效数字");
}
String tgt =
state.targetPlayer;
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getGui()
.openUserDetail(
p, tgt));
reset(p);
return;
}
case ADMIN_SET_PWD: {
if (!PasswordUtils.validate(msg)) {
p.sendMessage(config.msg(
"password_format_error"));
reset(p);
return;
}
String tgt = state.targetPlayer;
String salt =
PasswordUtils
.generateSalt();
String hash =
PasswordUtils.hash(
msg, salt);
db.setField(tgt,
"temp_password", hash);
db.setField(tgt, "temp_pw_expire",
System.currentTimeMillis()
+ 300000L);
db.setField(tgt,
"temp_pw_used", 0);
p.sendMessage("§a已为 " + tgt
+ " 设置临时密码");
Player tp =
Bukkit.getPlayer(tgt);
if (tp != null && tp.isOnline())
tp.kickPlayer(
"§c管理员已设置临时密码,"
+ "请使用临时密码登录");
// 邮件通知
String emailAddr = (String)
db.getField(tgt, "email");
if (emailAddr != null
&& !emailAddr.isEmpty()) {
String time =
new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date());
String body = "玩家 " + tgt
+ ":\n您的密码已于 "
+ time
+ " 被管理员重置为临时密码。\n"
+ "如果是本人操作请忽略。\n"
+ "如非本人操作请立即联系管理员。";
final String to = emailAddr;
final String fBody = body;
final String fName = tgt;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () ->
mainPlugin.getEmail()
.sendTempPassword(
to,
fName,
fBody));
}
reset(p);
return;
}
case ADMIN_DELETE_CONFIRM: {
if (msg.equals(
state.targetPlayer)) {
db.deleteUser(
state.targetPlayer);
p.sendMessage("§a已删除 "
+ state.targetPlayer);
Player tgt =
Bukkit.getPlayer(
state.targetPlayer);
if (tgt != null
&& tgt.isOnline())
tgt.kickPlayer(
"§c账号已被删除");
} else {
p.sendMessage(
"§c输入不匹配,已取消");
}
reset(p);
return;
}
case ADMIN_SET_AFK_TIME: {
try {
int minutes =
Integer.parseInt(
msg.trim());
config.afkTimeout =
minutes * 60;
config.saveSettings();
p.sendMessage(
"§a挂机时长已设为 "
+ minutes
+ " 分钟");
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效数字");
}
reset(p);
return;
}
case SMTP_CONFIG: {
String m = msg.trim();
if ("000".equals(m)
|| "取消取消取消"
.equals(m)) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
if ("取消".equals(m)) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
int step = state.smtpStep;
if (step <= 1) {
if (!"0".equals(m))
config.setSmtp(
"smtp地址", m);
state.smtpStep = 2;
p.sendMessage("§e当前端口: "
+ config.getSmtp(
"smtp端口"));
p.sendMessage(
"§e输入端口(0跳过):");
} else if (step == 2) {
if (!"0".equals(m))
config.setSmtp(
"smtp端口", m);
state.smtpStep = 3;
p.sendMessage(
"§e输入发件邮箱:");
} else if (step == 3) {
if (!"0".equals(m))
config.setSmtp(
"smtp账号", m);
state.smtpStep = 4;
p.sendMessage(
"§e输入SMTP密码(0跳过):");
} else if (step == 4) {
if (!"0".equals(m))
config.setSmtp(
"smtp密码", m);
state.smtpStep = 5;
p.sendMessage(
"§e输入接收邮箱(留空发到发件邮箱):");
} else if (step == 5) {
String recv = m;
if (recv.isEmpty()
|| "0".equals(recv))
recv = config.getSmtp(
"smtp账号");
config.setSmtp(
"验证码接收邮箱", recv);
if (!config
.canSendSmtpVerify()) {
p.sendMessage(
"§c发送间隔不足1分钟");
reset(p);
return;
}
String code = config
.generateSmtpVerifyCode();
String addr = config.getSmtp(
"smtp地址");
String acct = config.getSmtp(
"smtp账号");
if (acct.isEmpty()
|| addr.isEmpty()) {
p.sendMessage(
"§c地址或账号为空");
reset(p);
return;
}
config.recordSmtpSendTime();
final String fRecv = recv;
final String fCode = code;
final Player fp = p;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () -> {
Bukkit.getScheduler()
.runTask(
mainPlugin,
() -> fp.sendMessage(
"§e正在发送验证码..."));
boolean sent =
mainPlugin.getEmail()
.sendVerifyCode(
fRecv,
fCode);
final boolean fSent = sent;
Bukkit.getScheduler()
.runTask(
mainPlugin,
() -> {
if (fSent) {
state.type =
InputType
.SMTP_VERIFY;
state.smtpStep = 0;
fp.sendMessage(
"§a验证码已发送到 "
+ fRecv);
fp.sendMessage(
"§e输入6位验证码(0取消):");
} else {
fp.sendMessage(
"§c发送失败,查看控制台");
reset(fp);
}
});
});
}
return;
}
case SMTP_VERIFY: {
if ("0".equals(msg.trim())
|| "取消".equals(
msg.trim())) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
if (config.checkSmtpVerifyCode(
msg.trim())) {
config.saveSmtp();
config.clearSmtpVerifyCode();
String addr = config.getSmtp(
"smtp地址");
String port = config.getSmtp(
"smtp端口");
String acct = config.getSmtp(
"smtp账号");
String recv = config.getSmtp(
"验证码接收邮箱");
boolean ssl = config.smtpSsl;
p.sendMessage("§a§l=== SMTP配置完成 ===");
p.sendMessage("§7地址: §f" + addr);
p.sendMessage("§7端口: §f" + port);
p.sendMessage("§7账号: §f" + acct);
p.sendMessage("§7接收: §f" + recv);
p.sendMessage("§7加密: §f" + ssl);
p.sendMessage("§a§l====================");
reset(p);
} else {
p.sendMessage(
"§c验证码错误(0取消):");
}
return;
}
case BACK_CHECKIN: {
String result =
mainPlugin.getCheckIn()
.backCheckIn(p, msg);
if (result == null) {
// 等待选择(NEED_CONFIRM)
return;
}
if (result.startsWith(
"NEED_CONFIRM:")) {
String[] parts =
result.split(":");
// 存储到Main的Map
mainPlugin.pendingBackCheck.put(
p.getName(),
parts[1] + ":" + parts[2]);
return;
}
if (result.startsWith("§c")) {
p.sendMessage(result);
} else {
p.sendMessage(result);
}
reset(p);
return;
}
case INVITE_INPUT_CODE: {
String code = msg.trim();
String myName = p.getName();
String myCode =
(String) db.getField(
myName,
"invite_code");
if (code.isEmpty()) {
p.sendMessage(
"§c邀请码不能为空");
reset(p);
return;
}
if (code.equals(myCode)) {
p.sendMessage(
"§c不能用自己邀请码");
reset(p);
return;
}
boolean found = false;
for (Map<String, Object> u :
db.getAllUsers()) {
String uc = (String)
u.getOrDefault(
"invite_code", "");
if (code.equals(uc)) {
String inv = (String)
u.get("player_name");
db.setField(myName,
"invited_by", inv);
db.addPoints(inv, 50);
p.sendMessage(
"§a邀请成功!邀请人: "
+ inv);
Player ip =
Bukkit.getPlayer(inv);
if (ip != null
&& ip.isOnline())
ip.sendMessage("§a"
+ myName
+ " 使用了您的邀请码");
found = true;
break;
}
}
if (!found)
p.sendMessage(
"§c邀请码不存在");
reset(p);
return;
}
case TICKET_TITLE: {
state.ticketTitle = msg;
state.type =
InputType.TICKET_DESC;
p.sendMessage(
"§e请输入工单详细描述:");
return;
}
case TICKET_DESC: {
String tt = state.ticketType;
String tl = state.ticketTitle;
reset(p);
mainPlugin.getTicket()
.submitTicket(
p, tt, tl, msg);
return;
}
case TICKET_SCORE: {
int tid = state.ticketId;
try {
int sc = Integer.parseInt(
msg.trim());
reset(p);
mainPlugin.getTicket()
.confirmAndScore(
p, tid, sc);
} catch (
NumberFormatException e) {
reset(p);
p.sendMessage(
"§c请输入数字1-5");
}
return;
}
case TICKET_REJECT: {
int tid = state.ticketId;
reset(p);
mainPlugin.getTicket()
.rejectTicket(
p, tid, msg);
return;
}
case TICKET_ASSIGN: {
int tid = state.ticketId;
String[] parts = msg.trim()
.split("\\s+");
String prov = parts[0];
int price = 0;
if (parts.length >= 2) {
try {
price = Integer.parseInt(
parts[1]);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c价格格式错误");
}
}
reset(p);
mainPlugin.getTicket()
.assignToProvider(
p, tid, prov,
price);
final int fTid = tid;
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getTicket()
.openDetail(
p, fTid));
return;
}
case TICKET_INVITE_PROVIDER: {
String tgt = msg.trim();
reset(p);
if (!db.userExists(tgt)) {
p.sendMessage(
"§c玩家不存在");
return;
}
db.addServiceProvider(
tgt, "waiter");
p.sendMessage("§a已邀请 " + tgt
+ " 成为服务商");
return;
}
case TICKET_REMOVE_PROVIDER: {
String tgt = msg.trim();
reset(p);
if (!db.isServiceProvider(tgt)) {
p.sendMessage("§c" + tgt
+ " 不是服务商");
return;
}
db.removeServiceProvider(tgt);
p.sendMessage("§a已移除 " + tgt);
return;
}
case TICKET_COMPLETE: {
try {
int tid = Integer.parseInt(
msg.trim());
mainPlugin.getTicket()
.completeAsProvider(
p, tid);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效工单号");
}
reset(p);
return;
}
case TICKET_REPORT_ISSUE: {
String report = msg.trim();
reset(p);
p.sendMessage("§e异常已上报");
String tag = config.adminTag;
for (Player op :
Bukkit.getOnlinePlayers()) {
if (op.getScoreboardTags()
.contains(tag)) {
op.sendMessage(
"§c§l[工单异常] §f"
+ p.getName()
+ ": " + report);
}
}
return;
}
case TICKET_REPLY: {
int replyTid = state.ticketId;
reset(p);
mainPlugin.getTicket()
.replyTicket(
p, replyTid, msg);
return;
}
case TICKET_ADMIN_TYPE: {
state.ticketType = msg.trim();
state.type =
InputType
.TICKET_ADMIN_TITLE;
p.sendMessage(
"§e请输入工单标题:");
return;
}
case TICKET_ADMIN_TITLE: {
state.ticketTitle = msg;
state.type =
InputType
.TICKET_ADMIN_DESC;
p.sendMessage(
"§e请输入工单描述:");
return;
}
case TICKET_ADMIN_DESC: {
state.targetPlayer = msg;
state.type =
InputType
.TICKET_ADMIN_PROVIDER;
p.sendMessage(
"§e输入服务商名和价格:");
p.sendMessage(
"§7例: playerA 20");
return;
}
case TICKET_ADMIN_PROVIDER: {
String[] parts2 = msg.trim()
.split("\\s+");
String prov2 = parts2[0];
int price2 = 0;
if (parts2.length >= 2) {
try {
price2 = Integer.parseInt(
parts2[1]);
} catch (
NumberFormatException e) {
}
}
String t2 = state.ticketType;
String ti2 = state.ticketTitle;
String d2 = state.targetPlayer;
reset(p);
mainPlugin.getTicket()
.adminCreateAndAssign(
p, t2, ti2,
d2, prov2, price2);
return;
}
default:
reset(p);
return;
}
}
}
@@ -1,302 +0,0 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class CheckInManager {
private final Main plugin;
public CheckInManager(Main plugin) {
this.plugin = plugin;
}
// ===== 今日是否已签到 =====
public boolean isCheckedInToday(
String name) {
String last = (String) plugin.getDb()
.getField(name,
"last_checkin_date");
String today = new SimpleDateFormat(
"yyyy-MM-dd")
.format(new Date());
return today.equals(last);
}
// ===== 签到 =====
// ===== 签到 =====
public String checkIn(Player p) {
String name = p.getName();
if (isCheckedInToday(name)) {
return plugin.getConfig2()
.msg("checkin_already");
}
Object streakObj = plugin.getDb()
.getField(name, "checkin_streak");
int streak = streakObj != null
? ((Number) streakObj).intValue()
: 0;
String lastDate = (String) plugin.getDb()
.getField(name, "last_checkin_date");
String today = new SimpleDateFormat("yyyy-MM-dd")
.format(new Date());
if (lastDate != null && !lastDate.isEmpty()) {
try {
java.util.Date lastD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(lastDate);
java.util.Date todayD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(today);
long diff = todayD.getTime()
- lastD.getTime();
if (diff > 86400000L * 2) {
streak = 0;
} else if (diff > 86400000L) {
streak++;
}
} catch (Exception ignored) {
}
}
if (streak == 0) streak = 1;
else streak++;
double multi = 1.0;
if (streak >= 21) multi = 1.5;
else if (streak >= 14) multi = 1.3;
else if (streak >= 7) multi = 1.1;
int basePoints =
plugin.getConfig2().checkinPoints;
int points = (int) (basePoints * multi);
plugin.getDb().addPoints(name, points);
plugin.getDb().setField(name,
"checkin_streak", streak);
plugin.getDb().setField(name,
"last_checkin_date", today);
Object totalObj = plugin.getDb()
.getField(name, "total_checkin_days");
int total = totalObj != null
? ((Number) totalObj).intValue()
: 0;
plugin.getDb().setField(name,
"total_checkin_days", total + 1);
// ★ 债券盲盒奖励(6参数版,写入流水)★
int bondMin =
plugin.getConfig2().checkinBondMin;
int bondMax =
plugin.getConfig2().checkinBondMax;
if (bondMin <= 0) bondMin = 1;
if (bondMax < bondMin) bondMax = bondMin;
int bondReward = bondMin
+ new java.util.Random()
.nextInt(bondMax - bondMin + 1);
plugin.getBonds().addBonds(name, bondReward,
"daily_sign", "", "签到系统",
"每日签到连续" + streak + "天");
return plugin.getConfig2()
.msg("checkin_success")
.replace("{points}",
String.valueOf(points))
.replace("{streak}",
String.valueOf(streak))
.replace("{multi}",
String.valueOf(multi))
+ " §e+" + bondReward + "债券";
}
// ===== 获取连续天数 =====
public int getStreak(String name) {
Object val = plugin.getDb()
.getField(name,
"checkin_streak");
if (val instanceof Number) {
return ((Number) val)
.intValue();
}
return 0;
}
// 标准化日期
private String normalizeDate(String input) {
String s = input.trim().replace("/", "-");
if (s.matches("\\d{8}")) {
return s.substring(0, 4) + "-"
+ s.substring(4, 6) + "-"
+ s.substring(6, 8);
}
return s;
}
// ===== 补签入口 =====
public String backCheckIn(
Player p, String dateStr) {
String name = p.getName();
String normalized =
normalizeDate(dateStr);
// 严格日期验证
java.text.SimpleDateFormat sdf =
new java.text.SimpleDateFormat(
"yyyy-MM-dd");
sdf.setLenient(false);
java.util.Date target;
try {
target = sdf.parse(normalized);
} catch (Exception ex) {
return "§c无效日期: " + dateStr;
}
String reformat = sdf.format(target);
if (!reformat.equals(normalized)) {
return "§c无效日期: " + dateStr;
}
String today = sdf.format(new Date());
if (normalized.compareTo(today) >= 0) {
return "§c今天不需要补签";
}
String threeDaysAgo = getPastDate(3);
if (normalized.compareTo(
threeDaysAgo) < 0) {
return "§c只能补签3天内"
+ "(最多到 " + threeDaysAgo + ")";
}
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
boolean alreadyBacked = false;
if (!existing.isEmpty()) {
for (String d : existing.split(",")) {
if (normalizeDate(d.trim())
.equals(normalized)) {
alreadyBacked = true;
break;
}
}
}
if (alreadyBacked) {
return "§c该日期已补签: " + normalized;
}
Object normalCheck =
plugin.getDb().getField(
name, "last_checkin_date");
String lastCheckin = normalCheck != null
? normalCheck.toString() : "";
if (lastCheckin.equals(normalized)) {
return "§c该日期已签到: " + normalized;
}
// ★ 补签消耗:3债券 + 10积分
// 积分不够时:用 9债券(3倍)代替 ★
int bondCost = 3;
int pointCost = 10;
int bondAltCost = 9; // 3倍债券替代
int myBonds = plugin.getBonds()
.getBonds(name);
Object ptsObj = plugin.getDb()
.getField(name, "points");
int myPoints = ptsObj != null
? ((Number) ptsObj).intValue()
: 0;
// 方案A:3债券 + 10积分
boolean canA = myBonds >= bondCost
&& myPoints >= pointCost;
// 方案B:9债券(积分不够时)
boolean canB = myBonds >= bondAltCost
&& myPoints < pointCost;
if (!canA && !canB) {
if (myPoints < pointCost
&& myBonds < bondAltCost) {
return "§c补签费用不足"
+ "(需3债券+10积分,"
+ "或9债券代替)"
+ " 当前: "
+ myBonds + "债券 "
+ myPoints + "积分";
}
if (myBonds < bondCost) {
return "§c债券不足(需"
+ bondCost + "枚)";
}
return "§c积分不足(需"
+ pointCost + "分)";
}
// 执行扣款
if (canA) {
// 方案A:3债券 + 10积分
plugin.getBonds().deductBonds(
name, bondCost,
"admin_deduct", "",
"补签系统", "补签扣款");
plugin.getDb().deductPoints(
name, pointCost);
} else {
// 方案B:9债券代替
plugin.getBonds().deductBonds(
name, bondAltCost,
"admin_deduct", "",
"补签系统", "补签扣款(债券替代)");
}
doBackCheckIn(p, normalized);
String costMsg = canA
? bondCost + "债券+" + pointCost + "积分"
: bondAltCost + "债券(替代)";
return "§a补签成功!日期: "
+ normalized
+ " §7消耗: " + costMsg;
}
// ===== 执行补签 =====
// ===== 执行补签 =====
public void doBackCheckIn(
Player p, String dateStr) {
String name = p.getName();
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
String updated = existing.isEmpty()
? dateStr
: existing + "," + dateStr;
plugin.getDb().setField(name,
"backcheck_dates", updated);
int streak = getStreak(name);
plugin.getDb().setField(name,
"checkin_streak", streak + 1);
// ★ 补签不发放奖励,只记录
}
// ===== 获取N天前日期 =====
private String getPastDate(
int daysAgo) {
Calendar cal =
Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH,
-daysAgo);
return new SimpleDateFormat(
"yyyy-MM-dd")
.format(cal.getTime());
}
}
@@ -1,227 +0,0 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.logging.Logger;
public class CommissionManager {
private final Main main;
private final Logger logger;
private final List<double[]> rules =
new ArrayList<>();
public CommissionManager(Main main) {
this.main = main;
this.logger = main.getLogger();
loadRules();
}
public void loadRules() {
rules.clear();
File f = new File(
main.getDataFolder(),
"提成规则.txt");
if (!f.exists()) {
writeDefault(f);
}
try (BufferedReader br =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets
.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
line = line.trim();
if (line.isEmpty()
|| line.startsWith("#"))
continue;
// 格式: 邀请人数:提成百分比
// 如 "5:15" = 邀请5人提成15%
String[] parts =
line.split(":");
if (parts.length >= 2) {
try {
int count = Integer.parseInt(
parts[0].trim());
double rate = Double
.parseDouble(
parts[1]
.trim()) / 100.0;
rules.add(new double[]{
count, rate});
} catch (Exception ignored) {
}
}
}
// 按人数排序(从大到小)
rules.sort((a, b) ->
Double.compare(b[0], a[0]));
logger.info(
"[Commission] 加载 "
+ rules.size() + " 条提成规则");
} catch (Exception e) {
logger.warning(
"[Commission] 加载失败: "
+ e.getMessage());
}
}
private void writeDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# 邀请提成规则");
pw.println("# 格式: 邀请人数:提成百分比");
pw.println("# 邀请10人提成20%");
pw.println("10:20");
pw.println("# 邀请5人提成15%");
pw.println("5:15");
pw.println("# 邀请1人提成10%");
pw.println("1:10");
} catch (IOException ignored) {
}
}
/**
* 获取邀请人的提成比例
* 根据已邀请人数匹配最高规则
*/
public double getCommissionRate(
String inviter) {
int inviteCount = getInviteCount(
inviter);
for (double[] rule : rules) {
if (inviteCount >= (int) rule[0]) {
return rule[1];
}
}
return 0;
}
/**
* 获取邀请人数
*/
public int getInviteCount(
String inviter) {
try {
Object val =
main.getDb().getField(
inviter,
"monthly_invite_count");
if (val instanceof Number) {
return ((Number) val).intValue();
}
Object total =
main.getDb().getField(
inviter,
"total_invite_count");
if (total instanceof Number) {
return ((Number) total).intValue();
}
} catch (Exception ignored) {
}
return 0;
}
/**
* 发放提成
* 被邀请人获得奖励时,邀请人获得提成
*/
public void payCommission(
String invitee,
double originalAmount) {
// 查找邀请人
String inviter = getInviter(invitee);
if (inviter == null
|| inviter.isEmpty()) return;
double rate = getCommissionRate(
inviter);
if (rate <= 0) return;
double commission =
originalAmount * rate;
if (commission <= 0) return;
// 通过 Vault 发放
var rsp = main.getServer()
.getServicesManager()
.getRegistration(
net.milkbowl.vault
.economy.Economy.class);
if (rsp == null) return;
net.milkbowl.vault.economy.Economy econ =
rsp.getProvider();
if (econ == null) return;
econ.depositPlayer(inviter, commission);
// 通知邀请人
Player inviterP =
main.getServer()
.getPlayer(inviter);
if (inviterP != null
&& inviterP.isOnline()) {
inviterP.sendMessage(
"§a§l[邀请提成] §f"
+ invitee + " 完成任务,"
+ "你获得 §e"
+ String.format("%.2f",
commission)
+ " §f游戏币提成 ("
+ (int)(rate * 100) + "%)");
}
logger.info(
"[Commission] 提成: "
+ inviter + " ← "
+ invitee + " | "
+ String.format("%.2f",
originalAmount)
+ " × "
+ (int)(rate * 100) + "% = "
+ String.format("%.2f",
commission));
}
/**
* 获取邀请人
*/
private String getInviter(String player) {
try {
Object val =
main.getDb().getField(
player,
"invited_by");
if (val != null
&& !val.toString()
.isEmpty()) {
return val.toString();
}
} catch (Exception ignored) {
}
return null;
}
/**
* 判断金额是否包含经济内容
*/
public boolean hasEconomyContent(
String text) {
String t = text.toLowerCase();
return t.contains("游戏币")
|| t.contains("金币")
|| t.contains("积分")
|| t.contains("块钱")
|| t.contains("元")
|| t.contains("$")
|| t.contains("经济")
|| t.contains("余额");
}
}
@@ -1,372 +0,0 @@
package Sdf1_login;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class ConfigManager {
private final File dataFolder;
private final Map<String, String> messages
= new LinkedHashMap<>();
private final Map<String, String> smtpSettings
= new LinkedHashMap<>();
// ===== 公共字段 =====
public String adminTag = "admin";
public String adminPassword = "qweasd";
public int maxAccountsPerIP = 3;
public String approvalMode = "manual";
public int autoApproveDelayMinutes = 30;
public int loginTimeout = 60;
public boolean afkEnabled = false;
public int afkTimeout = 300;
public boolean smtpSsl = true;
public String smtpVerifyCode = "";
public long smtpVerifyExpire = 0;
public long smtpLastSendTime = 0;
public String smtpConfiguringPlayer = "";
public String broadcastServerUrl = "";
public int httpPort = 8080;
public String garbageTag = "admin";
public double checkinRewardFixed = 0;
public double checkinRewardMin = 0;
public double checkinRewardMax = 0;
public boolean checkinGivePoints = true;
public int checkinPoints = 10;
public String checkinRewardType = "none";
public int backCheckPointMultiplier = 5;
public int checkinBondMin = 1;
public int checkinBondMax = 3;
public double providerPointsPerScore = 2.0;
public int baseEconomyReward = 100;
public double providerEconomyPerScore = 50.0;
public int requesterPointsReward = 10;
public boolean garbageEnabled = true;
public int garbageInterval = 300;
public int garbageMaxRounds = 1;
// ★ 构造函数接收 File,和 Main.java 调用一致 ★
public ConfigManager(File dataFolder) {
this.dataFolder = dataFolder;
}
public String rewardChannel = "债券"; // bonds = 债券,economy = Vault经济
// ==================== 消息 ====================
public void loadMessages() {
messages.clear();
File file = new File(dataFolder, "消息.txt");
if (!file.exists()) createDefaultMessages(file);
loadMap(file, messages);
}
public String msg(String key) {
String val = messages.getOrDefault(key, key);
val = val.replace("<b>", "&l");
val = val.replace("</b>", "&r");
val = val.replace("<br>", "\n");
val = val.replace("\\n", "\n");
val = val.replaceAll(
"&([0-9a-fk-orA-FK-OR])", "§$1");
return val;
}
private void createDefaultMessages(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 消息配置 =====");
L.add("login_timeout=§c登录超时");
L.add("not_registered=§e您尚未注册");
L.add("not_logged_in=§e您尚未登录");
L.add("already_registered=§c您已注册过");
L.add("already_logged_in=§c您已登录");
L.add("reg_success=§a注册成功!");
L.add("login_success=§a登录成功!");
L.add("login_failed=§c密码错误");
L.add("password_wrong=§c密码错误");
L.add("password_changed=§a密码修改成功!");
L.add("password_format_error=§c密码需6位以上含大小写和数字");
L.add("password_same=§c新密码不能与旧密码相同");
L.add("checkin_already=§c您今日已签到");
L.add("checkin_success=§a签到成功!获得 {points} 积分");
L.add("chat_muted=§c你已被禁言");
L.add("未知参数=§c未知参数或权限不足");
writeLines(f, L);
}
// ==================== SMTP ====================
public void loadSmtp() {
smtpSettings.clear();
File file = new File(dataFolder, "SMTP设置.txt");
if (!file.exists()) createDefaultSmtp(file);
loadMap(file, smtpSettings);
}
public String getSmtp(String key) {
return smtpSettings.getOrDefault(key, "");
}
public void setSmtp(String key, String value) {
smtpSettings.put(key, value);
}
public void saveSmtp() {
File file = new File(dataFolder, "SMTP设置.txt");
List<String> L = new ArrayList<>();
L.add("# ===== SMTP 配置 =====");
for (Map.Entry<String, String> e
: smtpSettings.entrySet()) {
L.add(e.getKey() + "=" + e.getValue());
}
writeLines(file, L);
}
private void createDefaultSmtp(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== SMTP 配置 =====");
L.add("smtp地址=smtp.example.com");
L.add("smtp端口=465");
L.add("smtp账号=");
L.add("smtp密码=");
L.add("发件人名称=Sdf1_login");
L.add("smtp加密=true");
writeLines(f, L);
}
// ==================== 插件设置 ====================
public void loadSettings() {
File file = new File(dataFolder, "插件设置.txt");
if (!file.exists()) createDefaultSettings(file);
Map<String, String> m = new LinkedHashMap<>();
loadMap(file, m);
adminTag = m.getOrDefault("管理标签", adminTag);
adminPassword = m.getOrDefault("管理密码", adminPassword);
maxAccountsPerIP = parseInt(m.getOrDefault("每IP最大账号数", "3"), 3);
approvalMode = m.getOrDefault("审批模式", approvalMode);
autoApproveDelayMinutes = parseInt(m.getOrDefault("自动审批延迟分钟", "30"), 30);
loginTimeout = parseInt(m.getOrDefault("登录超时秒数", "60"), 60);
afkEnabled = Boolean.parseBoolean(m.getOrDefault("挂机踢出", "false"));
afkTimeout = parseInt(m.getOrDefault("挂机超时秒数", "300"), 300);
smtpSsl = Boolean.parseBoolean(m.getOrDefault("smtp加密", "true"));
httpPort = parseInt(m.getOrDefault("http-port", "8080"), 8080);
broadcastServerUrl = m.getOrDefault("resource-pack-url", "");
providerPointsPerScore = parseDouble(m.getOrDefault("服务商积分倍率", "2.0"), 2.0);
baseEconomyReward = parseInt(m.getOrDefault("基础经济奖励", "100"), 100);
providerEconomyPerScore = parseDouble(m.getOrDefault("经济奖励每评分", "50.0"), 50.0);
requesterPointsReward = parseInt(m.getOrDefault("报单人积分奖励", "10"), 10);
garbageEnabled = Boolean.parseBoolean(m.getOrDefault("垃圾站_启用", "true"));
garbageInterval = parseInt(m.getOrDefault("垃圾站_清理间隔秒", "300"), 300);
garbageMaxRounds = parseInt(m.getOrDefault("垃圾站_保留轮数", "1"), 1);
checkinRewardFixed = parseDouble(m.getOrDefault("签到固定金额", "0"), 0);
checkinRewardMin = parseDouble(m.getOrDefault("签到最小金额", "0"), 0);
checkinRewardMax = parseDouble(m.getOrDefault("签到最大金额", "0"), 0);
checkinGivePoints = Boolean.parseBoolean(m.getOrDefault("签到给积分", "true"));
checkinPoints = parseInt(m.getOrDefault("签到积分数量", "10"), 10);
backCheckPointMultiplier = parseInt(m.getOrDefault("补签积分倍率", "5"), 5);
checkinBondMin = parseInt(m.getOrDefault("签到债券最小", "1"), 1);
checkinBondMax = parseInt(m.getOrDefault("签到债券最大", "3"), 3);
if (checkinBondMax < checkinBondMin) checkinBondMax = checkinBondMin;
if (checkinRewardFixed > 0) checkinRewardType = "fixed";
else if (checkinRewardMin > 0 && checkinRewardMax > checkinRewardMin) checkinRewardType = "range";
else checkinRewardType = "none";
rewardChannel = m.getOrDefault("奖励发放方式", "债券");
rewardChannel = normalizeRewardChannel(rewardChannel);
// 验证合法值
if (!"economy".equalsIgnoreCase(rewardChannel)
&& !"bonds".equalsIgnoreCase(rewardChannel)) {
rewardChannel = "bonds";
}
// ★ 自动补充新配置项 ★
boolean needAppend = false;
List<String> newKeys = new ArrayList<>();
if (!m.containsKey("签到债券最小")) { needAppend = true; newKeys.add("签到债券最小=" + checkinBondMin); }
if (!m.containsKey("签到债券最大")) { needAppend = true; newKeys.add("签到债券最大=" + checkinBondMax); }
if (!m.containsKey("奖励发放方式")) {
needAppend = true;
newKeys.add("奖励发放方式=债券");
}
if (needAppend) {
try {
FileWriter fw = new FileWriter(file, true);
BufferedWriter bw = new BufferedWriter(fw);
bw.newLine();
for (String key : newKeys) { bw.write(key); bw.newLine(); }
bw.flush(); bw.close();
System.out.println("[Config] 已补充签到债券配置");
} catch (IOException ignored) {}
if (!m.containsKey("奖励发放方式")) {
try {
FileWriter fw = new FileWriter(file, true);
BufferedWriter bw = new BufferedWriter(fw);
bw.newLine();
bw.write("奖励发放方式=bonds");
bw.newLine();
bw.flush(); bw.close();
} catch (IOException ignored) {}
}
}
}
/**
* 奖励方式标准化:中英文全兼容
* "债券" "bonds" "Bond" "BONDS" → "bonds"
* "经济" "economy" "Economy" "ECONOMY" → "economy"
* 其他任何值 → "bonds"(默认债券)
*/
private String normalizeRewardChannel(String raw) {
if (raw == null) return "bonds";
String s = raw.trim().toLowerCase();
// 中文
if (s.contains("债券")) return "bonds";
if (s.contains("经济")) return "economy";
// 英文
if (s.contains("bond")) return "bonds";
if (s.contains("econ")) return "economy";
if (s.contains("Vault")) return "economy";
// 兜底
return "bonds";
}
public void saveSettings() {
File file = new File(dataFolder, "插件设置.txt");
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 插件设置 =====");
L.add("管理标签=" + adminTag);
L.add("管理密码=" + adminPassword);
L.add("每IP最大账号数=" + maxAccountsPerIP);
L.add("审批模式=" + approvalMode);
L.add("自动审批延迟分钟=" + autoApproveDelayMinutes);
L.add("登录超时秒数=" + loginTimeout);
L.add("挂机踢出=" + afkEnabled);
L.add("挂机超时秒数=" + afkTimeout);
L.add("resource-pack-url=" + broadcastServerUrl);
L.add("http-port=" + httpPort);
L.add("垃圾站_启用=" + garbageEnabled);
L.add("垃圾站_清理间隔秒=" + garbageInterval);
L.add("垃圾站_保留轮数=" + garbageMaxRounds);
L.add("签到固定金额=" + checkinRewardFixed);
L.add("签到最小金额=" + checkinRewardMin);
L.add("签到最大金额=" + checkinRewardMax);
L.add("签到给积分=" + checkinGivePoints);
L.add("签到积分数量=" + checkinPoints);
L.add("签到债券最小=" + checkinBondMin);
L.add("签到债券最大=" + checkinBondMax);
L.add("补签积分倍率=" + backCheckPointMultiplier);
L.add("奖励发放方式=债券");
writeLines(file, L);
}
private void createDefaultSettings(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 插件设置 =====");
L.add("管理标签=admin");
L.add("管理密码=qweasd");
L.add("每IP最大账号数=3");
L.add("审批模式=manual");
L.add("自动审批延迟分钟=30");
L.add("登录超时秒数=60");
L.add("挂机踢出=false");
L.add("挂机超时秒数=300");
L.add("resource-pack-url=");
L.add("http-port=8080");
L.add("垃圾站_启用=true");
L.add("垃圾站_清理间隔秒=300");
L.add("垃圾站_保留轮数=1");
L.add("签到固定金额=0");
L.add("签到最小金额=50");
L.add("签到最大金额=500");
L.add("签到给积分=true");
L.add("签到积分数量=10");
L.add("签到债券最小=1");
L.add("签到债券最大=3");
L.add("补签积分倍率=5");
L.add("奖励发放方式=债券");
writeLines(f, L);
}
public List<String> getAfkWhitelist() {
List<String> list = new ArrayList<>();
File file = new File(dataFolder, "挂机白名单.txt");
if (!file.exists()) {
try {
file.createNewFile();
FileWriter fw = new FileWriter(file);
fw.write("# 每行一个玩家名\n");
fw.flush();
fw.close();
} catch (IOException ignored) {}
return list;
}
try (BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(file),
StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (!line.isEmpty() && !line.startsWith("#"))
list.add(line);
}
} catch (IOException ignored) {}
return list;
}
// ==================== 其他 ====================
public String generateSmtpVerifyCode() {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder sb = new StringBuilder();
java.util.Random r = new java.util.Random();
for (int i = 0; i < 6; i++) sb.append(chars.charAt(r.nextInt(chars.length())));
smtpVerifyCode = sb.toString();
smtpVerifyExpire = System.currentTimeMillis() + 300000L;
return smtpVerifyCode;
}
public boolean checkSmtpVerifyCode(String code) {
if (System.currentTimeMillis() > smtpVerifyExpire) { smtpVerifyCode = ""; return false; }
return smtpVerifyCode.equalsIgnoreCase(code);
}
public void clearSmtpVerifyCode() { smtpVerifyCode = ""; smtpVerifyExpire = 0; }
public boolean canSendSmtpVerify() { return System.currentTimeMillis() - smtpLastSendTime >= 60000L; }
public void recordSmtpSendTime() { smtpLastSendTime = System.currentTimeMillis(); }
// ==================== 文件工具 ====================
private void loadMap(File file, Map<String, String> map) {
try (BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
int eq = line.indexOf('=');
if (eq > 0) map.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim());
}
} catch (IOException ignored) {}
}
private void writeLines(File file, List<String> lines) {
try (BufferedWriter w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) {
for (String l : lines) { w.write(l); w.newLine(); }
} catch (IOException ignored) {}
}
private double parseDouble(String s, double def) { try { return Double.parseDouble(s.trim()); } catch (Exception e) { return def; } }
private int parseInt(String s, int def) { try { return Integer.parseInt(s.trim()); } catch (Exception e) { return def; } }
}
@@ -1,577 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
public class CypayCommand
implements CommandExecutor, TabCompleter {
private final Main plugin;
private static final SimpleDateFormat SDF =
new SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss");
public CypayCommand(Main plugin) {
this.plugin = plugin;
}
private boolean hasAdminPerm(CommandSender s) {
if (s.isOp()) return true;
if (!(s instanceof Player)) return true;
Player p = (Player) s;
String tag =
plugin.getConfig2().adminTag;
return !tag.isEmpty()
&& p.getScoreboardTags().contains(tag);
}
private int parseAmount(String s) {
return Integer.parseInt(
s.trim().replaceAll("[+]", ""));
}
private List<String> filter(
List<String> list, String prefix) {
String low = prefix.toLowerCase();
return list.stream()
.filter(x -> x.toLowerCase()
.startsWith(low))
.collect(Collectors.toList());
}
@Override
public boolean onCommand(CommandSender sender,
Command cmd,
String label,
String[] args) {
// ===== /cypay — 查自己 =====
if (args.length == 0) {
String name = sender instanceof Player
? sender.getName() : "Console";
int bonds =
plugin.getBonds().getBonds(name);
String status = plugin.getBonds()
.getAccountStatus(name);
sender.sendMessage(
"§e§l[债券] §f" + name
+ " 余额: §a" + bonds);
sender.sendMessage(
"§e§l[债券] §f状态: "
+ ("frozen".equals(status)
? "§c冻结中"
: "§a正常"));
return true;
}
// ===== /cypay import =====
/* if (args.length == 2
&& "import".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String file = args[1];
if (!file.endsWith(".txt"))
file += ".txt";
int result = plugin.getCDK()
.importFromFile(file);
if (result == -1)
sender.sendMessage("§c文件不存在: "
+ file);
else
sender.sendMessage("§a导入完成: "
+ result + " 条");
return true;
}*/
// ===== /cypay freeze =====
if (args.length == 2
&& "freeze".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
if (plugin.getBonds().isFrozen(target)) {
sender.sendMessage("§c" + target
+ " 已处于冻结状态");
return true;
}
plugin.getBonds().freezeAccount(target);
sender.sendMessage("§a已冻结 " + target
+ " 的债券账户");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§c§l[系统] 你的债券账户"
+ "已被管理员冻结");
return true;
}
// ===== /cypay unfreeze =====
if (args.length == 2
&& "unfreeze".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
if (!plugin.getBonds()
.isFrozen(target)) {
sender.sendMessage("§c" + target
+ " 未被冻结");
return true;
}
plugin.getBonds().unfreezeAccount(target);
sender.sendMessage("§a已解冻 " + target
+ " 的债券账户");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§a§l[系统] 你的债券账户"
+ "已被解冻");
return true;
}
// ===== /cypay water [days] =====
if (args.length >= 2
&& "water".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
int days = 7;
if (args.length >= 3) {
try {
days = Integer.parseInt(args[2]);
} catch (NumberFormatException ignored) {
}
}
days = Math.min(days,
BondManager.ADMIN_MAX_WATER_DAYS);
if (days <= 0) days = 1;
List<Map<String, Object>> txs =
plugin.getBonds()
.getTransactions(
target, days);
sender.sendMessage(
"§e§l===== " + target
+ " 流水记录 (近"
+ days + "天) =====");
sender.sendMessage("§f当前余额: §a"
+ plugin.getBonds()
.getBonds(target)
+ " §f状态: "
+ (plugin.getBonds()
.isFrozen(target)
? "§c冻结" : "§a正常"));
if (txs.isEmpty()) {
sender.sendMessage("§7 (暂无记录)");
} else {
for (Map<String, Object> tx : txs) {
sender.sendMessage(
formatTxLine(tx));
}
}
sender.sendMessage(
"§e§l=========================="
+ "==========");
return true;
}
// ===== /cypay printer [player] =====
if ("printer".equalsIgnoreCase(args[0])) {
if (!hasAdminPerm(sender)
&& !(sender instanceof Player)
== false) {
// 控制台也可以用
if (!(sender instanceof Player))
; // 放行
else if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
}
if (args.length == 1) {
// 打印全服
sender.sendMessage(
"§e正在导出全服流水...");
plugin.getBonds(); // 确保实例就绪
if (plugin.getBondPrinter() != null) {
plugin.getBondPrinter()
.printAll(sender);
} else {
sender.sendMessage("§cPrinter未初始化");
}
return true;
}
if (args.length == 2) {
// 打印指定玩家
String target = args[1];
sender.sendMessage("§e正在导出 "
+ target + " 的流水...");
if (plugin.getBondPrinter() != null) {
plugin.getBondPrinter()
.printPlayer(target, sender);
} else {
sender.sendMessage("§cPrinter未初始化");
}
return true;
}
}
// ===== /cypay give/remove =====
if (args.length == 3) {
String action = args[0].toLowerCase();
if ("give".equals(action)
|| "remove".equals(action)) {
if (!hasAdminPerm(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
String target = args[1];
try {
int amount =
parseAmount(args[2]);
if (amount <= 0) {
sender.sendMessage(
"§c金额必须大于0");
return true;
}
String txType =
"give".equals(action)
? BondManager
.TX_ADMIN_GIVE
: BondManager
.TX_ADMIN_DEDUCT;
String opName =
sender instanceof Player
? sender.getName()
: "Console";
if ("give".equals(action)) {
plugin.getBonds().addBonds(
target, amount,
txType, "",
opName, "管理员发放");
sender.sendMessage(
"§a已给 " + target
+ " " + amount
+ " 债券");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§a管理员给了你 "
+ amount
+ " 债券");
} else {
if (plugin.getBonds()
.deductBonds(target,
amount,
txType, "",
opName,
"管理员扣除")) {
sender.sendMessage(
"§a已扣除 " + target
+ " " + amount
+ " 债券");
Player tp = plugin
.getServer()
.getPlayerExact(
target);
if (tp != null)
tp.sendMessage(
"§c管理员扣除了"
+ "你的 "
+ amount
+ " 债券");
} else {
sender.sendMessage(
"§c" + target
+ " 债券不足"
+ "或账户冻结");
}
}
} catch (NumberFormatException e) {
sender.sendMessage(
"§c金额格式错误");
}
return true;
}
}
// ===== /cypay <player> <amount> — 仅玩家转账 =====
if (args.length == 2) {
try {
int amount = parseAmount(args[1]);
if (amount <= 0) {
sender.sendMessage(
"§c金额必须大于0");
return true;
}
// ★ 控制台禁止转账
if (!(sender instanceof Player)) {
sender.sendMessage(
"§c控制台不能转账,请使用 "
+ "/cypay give <玩家> <金额>");
return true;
}
Player p = (Player) sender;
String target = args[0];
BondManager.TransferResult r =
plugin.getBonds().transfer(
p.getName(),
target, amount);
if (r.success) {
p.sendMessage("§e§l[债券转账] §f向 §a"
+ target + " §f转出 §c"
+ amount + " §f枚债券");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage("§e§l[债券转账] §f收到 §a"
+ p.getName()
+ " §f转入 §a"
+ amount + " §f枚债券");
} else {
p.sendMessage("§c" + r.message
+ ",当前: "
+ plugin.getBonds()
.getBonds(p.getName()));
}
return true;
} catch (NumberFormatException ignored) {
}
}
// ===== /cypay <player> — 查指定玩家 =====
if (args.length == 1) {
String target = args[0];
// ★ 验证玩家在 login.db 中存在 ★
if (!plugin.getDb().userExists(target)) {
sender.sendMessage("§c玩家 " + target
+ " 不存在于服务器");
return true;
}
// String target = args[0];
if (hasAdminPerm(sender)) {
int bonds = plugin.getBonds()
.getBonds(target);
String status = plugin.getBonds()
.getAccountStatus(target);
sender.sendMessage(
"§e§l[债券] §f" + target
+ " 余额: §a"
+ bonds);
sender.sendMessage(
"§e§l[债券] §f状态: "
+ ("frozen".equals(
status)
? "§c冻结中"
: "§a正常"));
} else {
int bonds = plugin.getBonds()
.getBonds(
sender.getName());
sender.sendMessage(
"§e§l[债券] §f你的余额: §a"
+ bonds);
}
return true;
}
showHelp(sender);
return true;
}
// ===== 流水行格式化 =====
private String formatTxLine(
Map<String, Object> tx) {
String type = (String) tx.get("type");
int amount = ((Number) tx.get("amount"))
.intValue();
String tgt = (String) tx.get("target_player");
String reason = (String) tx.get("reason");
int bal = ((Number) tx.get("balance_after"))
.intValue();
long time = ((Number) tx.get("time"))
.longValue();
boolean pos = amount > 0;
String sign = pos ? "+" : "";
String color = pos ? "§a" : "§c";
StringBuilder sb = new StringBuilder();
sb.append("§7[")
.append(SDF.format(new Date(time)))
.append("] ")
.append(formatTxType(type))
.append(" ")
.append(color).append(sign)
.append(amount).append("§7");
if (tgt != null && !tgt.isEmpty())
sb.append(" §e↔ ").append(tgt);
if (reason != null && !reason.isEmpty())
sb.append(" §7(").append(reason)
.append(")");
sb.append(" §7余额:§f").append(bal);
return sb.toString();
}
private String formatTxType(String type) {
if (type == null) return "§7未知";
switch (type) {
case BondManager.TX_ADMIN_GIVE:
return "§a管理员发放";
case BondManager.TX_ADMIN_DEDUCT:
return "§c管理员扣除";
case BondManager.TX_TRANSFER_OUT:
return "§c转出";
case BondManager.TX_TRANSFER_IN:
return "§a转入";
case BondManager.TX_REDEEM:
return "§aCDK兑换";
case BondManager.TX_SHOP_BUY:
return "§e商城购买";
case BondManager.TX_DAILY_SIGN:
return "§a签到奖励";
case BondManager.TX_FREEZE:
return "§c冻结";
case BondManager.TX_UNFREEZE:
return "§a解冻";
default:
return "§7" + type;
}
}
private void showHelp(CommandSender sender) {
sender.sendMessage("§e§l债券命令帮助:");
sender.sendMessage(
"§e/cypay §7- 查询自己余额");
sender.sendMessage(
"§e/cypay <玩家> §7- 查余额(管理员)");
sender.sendMessage(
"§e/cypay <玩家> <金额> §7- 转账");
if (hasAdminPerm(sender)) {
sender.sendMessage(
"§e/cypay give <玩家> <金额> §7- 给债券");
sender.sendMessage(
"§e/cypay remove <玩家> <金额> §7- 扣债券");
sender.sendMessage(
"§e/cypay import <文件> §7- 导入CDK");
sender.sendMessage(
"§e/cypay freeze <玩家> §7- 冻结");
sender.sendMessage(
"§e/cypay unfreeze <玩家> §7- 解冻");
sender.sendMessage(
"§e/cypay water <玩家> [天数] §7- 流水");
sender.sendMessage(
"§e/cypay printer §7- 导出全服流水xlsx");
sender.sendMessage(
"§e/cypay printer <玩家> §7- 导出指定玩家xlsx");
}
}
@Override
public List<String> onTabComplete(
CommandSender sender, Command command,
String label, String[] args) {
List<String> list = new ArrayList<>();
if (args.length == 1) {
String last = args[0].toLowerCase();
if (hasAdminPerm(sender)) {
list.add("give");
list.add("remove");
list.add("import");
list.add("freeze");
list.add("unfreeze");
list.add("water");
list.add("printer");
File cdkDir = new File(
plugin.getDataFolder(),
"cdk");
if (cdkDir.exists()) {
File[] files = cdkDir.listFiles(
(d, n) -> n
.endsWith(".txt"));
if (files != null)
for (File f : files)
list.add(f.getName());
}
}
for (Player o :
Bukkit.getOnlinePlayers())
list.add(o.getName());
return filter(list, last);
}
if (args.length == 2) {
String first = args[0].toLowerCase();
if ("give".equals(first)
|| "remove".equals(first)
|| "freeze".equals(first)
|| "unfreeze".equals(first)
|| "water".equals(first)) {
for (Player o :
Bukkit.getOnlinePlayers())
list.add(o.getName());
return filter(list,
args[1].toLowerCase());
}
list.add("10");
list.add("50");
list.add("100");
return filter(list,
args[1].toLowerCase());
}
if (args.length == 3) {
String first = args[0].toLowerCase();
if ("give".equals(first)
|| "remove".equals(first)) {
list.add("10");
list.add("50");
list.add("100");
list.add("999");
return filter(list,
args[2].toLowerCase());
}
if ("water".equals(first)) {
list.add("1");
list.add("3");
list.add("7");
list.add("14");
return filter(list,
args[2].toLowerCase());
}
}
return list;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,284 +0,0 @@
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
@@ -1,573 +0,0 @@
package Sdf1_login;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.scheduler.BukkitRunnable;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.*;
public class GarbageManager {
private final Main plugin;
private Connection db;
private int cleanupInterval = 1800;
private int maxRounds = 1;
private boolean enabled = true;
private int currentRound = 0;
private final Map<String, Integer>
recyclePages = new HashMap<>();
public GarbageManager(Main plugin) {
this.plugin = plugin;
}
public void init() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(),
"garbage.db");
db = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
st.execute("CREATE TABLE IF NOT EXISTS "
+ "items ("
+ "id INTEGER PRIMARY KEY "
+ "AUTOINCREMENT,"
+ "item_data TEXT NOT NULL,"
+ "amount INTEGER DEFAULT 1,"
+ "round INTEGER DEFAULT 0,"
+ "create_time INTEGER NOT NULL"
+ ")");
st.execute("CREATE TABLE IF NOT EXISTS "
+ "config ("
+ "key TEXT PRIMARY KEY,"
+ "value TEXT NOT NULL"
+ ")");
st.close();
} catch (Exception e) {
plugin.getLogger().severe(
"[Garbage] DB失败: "
+ e.getMessage());
}
}
public void loadConfig() {
// 从 ConfigManager 读取
cleanupInterval =
plugin.getConfig2()
.garbageInterval;
enabled =
plugin.getConfig2()
.garbageEnabled;
maxRounds =
plugin.getConfig2()
.garbageMaxRounds;
// 从数据库读取当前轮次
try {
String val = getConfigValue("round");
if (val != null)
currentRound =
Integer.parseInt(val);
} catch (Exception ignored) {
}
plugin.getLogger().info(
"[Garbage] 间隔: "
+ cleanupInterval + "秒, "
+ "轮次保留: "
+ maxRounds + "轮, "
+ "启用: " + enabled);
}
private String getConfigValue(String key) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT value FROM config "
+ "WHERE key=?");
ps.setString(1, key);
ResultSet rs = ps.executeQuery();
String val = rs.next()
? rs.getString("value") : null;
rs.close();
ps.close();
return val;
} catch (Exception e) {
return null;
}
}
private void setConfigValue(String key,
String value) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO config "
+ "(key, value) "
+ "VALUES (?, ?)");
ps.setString(1, key);
ps.setString(2, value);
ps.executeUpdate();
ps.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public void startAutoCleanup() {
if (!enabled) return;
new BukkitRunnable() {
int countdown = 0;
@Override
public void run() {
// 在线人数≤0时暂停
if (Bukkit.getOnlinePlayers()
.isEmpty()) {
countdown = 0;
return;
}
// 重新读取配置(支持热更)
cleanupInterval =
plugin.getConfig2()
.garbageInterval;
int remain =
cleanupInterval - countdown;
if (remain == 300)
broadcast("§e§l[垃圾站] §f距离清理还有 §e5分钟");
else if (remain == 180)
broadcast("§e§l[垃圾站] §f距离清理还有 §e3分钟");
else if (remain == 60)
broadcast("§e§l[垃圾站] §f距离清理还有 §e1分钟");
else if (remain == 30)
broadcast("§e§l[垃圾站] §f距离清理还有 §e30秒");
else if (remain == 10)
broadcast("§e§l[垃圾站] §f距离清理还有 §e10秒");
else if (remain <= 5 && remain > 0)
broadcast("§e§l[垃圾站] §c" + remain + "§f...");
else if (remain == 0) {
doCleanup();
countdown = 0;
return;
}
countdown++;
}
}.runTaskTimer(plugin, 20L, 20L);
}
private void doCleanup() {
// 删除过期轮次的物品
int deleteRound =
currentRound - maxRounds;
if (deleteRound >= 0) {
int deleted = deleteOldItems(
deleteRound);
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
}
// 递增轮次
currentRound++;
setConfigValue("round",
String.valueOf(currentRound));
// 收集当前掉落物
int count = collectItems();
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// 打印下次清理时间
long nextMs = System.currentTimeMillis()
+ (long) cleanupInterval * 1000L;
String nextTime =
new SimpleDateFormat("HH:mm:ss")
.format(new Date(nextMs));
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
+ nextTime);
}
public int collectItems() {
int count = 0;
for (org.bukkit.World w :
Bukkit.getWorlds()) {
Collection<Item> items =
w.getEntitiesByClass(Item.class);
for (Item item : items) {
ItemStack stack =
item.getItemStack();
if (stack == null
|| stack.getType()
== Material.AIR)
continue;
saveItem(stack);
item.remove();
count++;
}
}
return count;
}
public void saveItem(ItemStack stack) {
try {
String data = stackToBase64(stack);
if (data.isEmpty()) return;
PreparedStatement ps = db.prepareStatement(
"INSERT INTO items "
+ "(item_data, amount, "
+ "round, create_time) "
+ "VALUES (?,?,?,?)");
ps.setString(1, data);
ps.setInt(2, stack.getAmount());
ps.setInt(3, currentRound);
ps.setLong(4,
System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (Exception e) {
e.printStackTrace();
}
}
private int deleteOldItems(int beforeRound) {
try {
PreparedStatement ps = db.prepareStatement(
"DELETE FROM items "
+ "WHERE round <= ?");
ps.setInt(1, beforeRound);
int rows = ps.executeUpdate();
ps.close();
return rows;
} catch (Exception e) {
return 0;
}
}
// ========== 序列化 ==========
private String stackToBase64(ItemStack stack) {
try {
Map<String, Object> map =
stack.serialize();
String json = new Gson().toJson(map);
return Base64.getEncoder()
.encodeToString(
json.getBytes(
StandardCharsets.UTF_8));
} catch (Exception e) {
return "";
}
}
private ItemStack base64ToStack(String base64) {
try {
byte[] bytes = Base64.getDecoder()
.decode(base64);
String json = new String(bytes,
StandardCharsets.UTF_8);
Type type = new TypeToken<
Map<String, Object>>() {}
.getType();
Map<String, Object> map =
new Gson().fromJson(json, type);
map = fixNumberTypes(map);
return ItemStack.deserialize(map);
} catch (Exception e) {
return null;
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> fixNumberTypes(
Map<String, Object> map) {
Map<String, Object> result =
new LinkedHashMap<>();
for (Map.Entry<String, Object> e
: map.entrySet()) {
Object v = e.getValue();
if (v instanceof Double) {
double d = (Double) v;
if (d == (long) d) {
result.put(e.getKey(), (long) d);
} else {
result.put(e.getKey(), d);
}
} else if (v instanceof Map) {
result.put(e.getKey(),
fixNumberTypes(
(Map<String, Object>) v));
} else if (v instanceof List) {
List<Object> list =
new ArrayList<>();
for (Object item : (List<?>) v) {
if (item instanceof Map) {
list.add(fixNumberTypes(
(Map<String, Object>) item));
} else {
list.add(item);
}
}
result.put(e.getKey(), list);
} else {
result.put(e.getKey(), v);
}
}
return result;
}
// ========== 回收站GUI ==========
public void openRecycle(Player p) {
openRecyclePage(p, 0);
}
public void openRecyclePage(Player p, int page) {
Inventory g = Bukkit.createInventory(
null, 54, "§6§l垃圾回收站");
List<Map<String, Object>> allItems =
getStoredItems(999);
int total = allItems.size();
int pageSize = 40;
int totalPages = Math.max(1,
(int) Math.ceil(
(double) total / pageSize));
// 修正页码
if (page < 0) page = 0;
if (page >= totalPages)
page = totalPages - 1;
recyclePages.put(p.getName(), page);
// 填充物品(0-39 格 = 第1页,40-79 = 第2页...)
int start = page * pageSize;
int end = Math.min(start + pageSize,
total);
int slot = 0;
for (int i = start; i < end; i++) {
if (slot >= 40) break;
Map<String, Object> item =
allItems.get(i);
int id = ((Number) item.get("id"))
.intValue();
String data = (String) item
.get("item_data");
int amount = ((Number) item
.get("amount")).intValue();
ItemStack stack = base64ToStack(data);
if (stack != null) {
stack.setAmount(amount);
ItemMeta im =
stack.getItemMeta();
if (im != null) {
List<String> lore =
new ArrayList<>();
lore.add("§7ID: #" + id);
lore.add("§a点击取出");
im.setLore(lore);
stack.setItemMeta(im);
}
g.setItem(slot, stack);
}
slot++;
}
// 49 格:上一页
if (page > 0) {
g.setItem(49, mk(Material.ARROW,
"§e上一页 §7("
+ page + "/"
+ totalPages + ")"));
} else {
g.setItem(49, mk(Material.ARROW,
"§e第一页 §7("
+ "1/" + totalPages + ")"));
}
// 50 格:统计信息
g.setItem(50, mk(Material.CHEST,
"§e回收站统计",
"§7总存量: §f" + total + " 个",
"§7当前页: §f" + (page + 1)
+ "/" + totalPages));
// 51 格:下一页
boolean canNext = page < totalPages - 1;
if (canNext) {
g.setItem(51, mk(Material.ARROW,
"§e下一页 §7("
+ (page + 2) + "/"
+ totalPages + ")"));
} else {
g.setItem(51, mk(Material.ARROW,
"§7已是最后一页"));
}
// 52 格:返回
g.setItem(52, mk(Material.ARROW,
"§7返回",
"§7返回主菜单"));
p.openInventory(g);
}
public int getRecyclePage(String name) {
return recyclePages
.getOrDefault(name, 0);
}
public boolean handleClick(Player p,
String title, int slot) {
if (!title.equals("§6§l垃圾回收站"))
return false;
// 49:上一页
if (slot == 49) {
int page = recyclePages
.getOrDefault(p.getName(), 0);
if (page > 0)
openRecyclePage(p, page - 1);
return true;
}
// 51:下一页
if (slot == 51) {
int page = recyclePages
.getOrDefault(p.getName(), 0);
openRecyclePage(p, page + 1);
return true;
}
// 52:返回
if (slot == 52) {
plugin.getGui().openMain(p);
return true;
}
// 50/52/53:不处理
if (slot >= 49 && slot <= 53)
return true;
return true;
}
private List<Map<String, Object>>
getStoredItems(int limit) {
List<Map<String, Object>> list =
new ArrayList<>();
try {
PreparedStatement ps =
db.prepareStatement(
"SELECT id, item_data, "
+ "amount "
+ "FROM items "
+ "ORDER BY "
+ "create_time "
+ "DESC LIMIT ?");
ps.setInt(1, limit);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("id",
rs.getInt("id"));
row.put("item_data",
rs.getString("item_data"));
row.put("amount",
rs.getInt("amount"));
list.add(row);
}
rs.close();
ps.close();
} catch (Exception e) {
e.printStackTrace();
}
return list;
}
public boolean removeItem(int id) {
try {
PreparedStatement ps =
db.prepareStatement(
"DELETE FROM items "
+ "WHERE id=?");
ps.setInt(1, id);
int rows = ps.executeUpdate();
ps.close();
return rows > 0;
} catch (Exception e) {
return false;
}
}
public int getItemCount() {
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT COUNT(*) FROM items");
int count = 0;
if (rs.next())
count = rs.getInt(1);
rs.close();
st.close();
return count;
} catch (Exception e) {
return 0;
}
}
private void broadcast(String msg) {
// 同步打印到控制台
plugin.getLogger().info(
msg.replaceAll("§[0-9a-fk-orA-FK-OR]", ""));
// 同步打印到聊天栏
Bukkit.broadcastMessage(msg);
}
private ItemStack mk(Material mat,
String name, String... lore) {
ItemStack it = new ItemStack(mat);
ItemMeta im = it.getItemMeta();
if (im != null) {
im.setDisplayName(name);
if (lore.length > 0) {
List<String> list =
new ArrayList<>();
for (String l : lore)
if (!l.isEmpty()) list.add(l);
if (!list.isEmpty())
im.setLore(list);
}
it.setItemMeta(im);
}
return it;
}
public void close() {
try {
if (db != null && !db.isClosed())
db.close();
} catch (Exception ignored) {
}
}
}
@@ -1,259 +0,0 @@
package Sdf1_login;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
public class GiftManager {
private final File giftFolder;
private final Main plugin;
private final List<Map<String, Object>> stages =
new ArrayList<>();
public GiftManager(Main plugin) {
this.plugin = plugin;
this.giftFolder = new File(
plugin.getDataFolder(), "任务面板");
}
public void loadStages() {
stages.clear();
if (!giftFolder.exists()) {
giftFolder.mkdirs();
writeDefaultStages();
}
File[] files = giftFolder.listFiles(
(dir, name) -> name.endsWith(".md"));
if (files == null) return;
Arrays.sort(files);
for (File f : files) {
stages.add(parseMarkdown(f));
}
}
private Map<String, Object> parseMarkdown(File f) {
Map<String, Object> stage = new LinkedHashMap<>();
stage.put("stage", stages.size() + 1);
stage.put("name",
f.getName().replace(".md", ""));
stage.put("rewards", new ArrayList<String>());
try {
BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8));
String line;
List<String> rewards = new ArrayList<>();
while ((line = r.readLine()) != null) {
if (line.contains("礼包内容")
|| line.contains("奖励")
|| line.contains("任务内容")) {
int colon = line.indexOf(':');
if (colon < 0)
colon = line.indexOf('\uff1a');
if (colon >= 0) {
rewards.add(
line.substring(colon + 1)
.trim());
}
}
}
stage.put("rewards", rewards);
r.close();
} catch (Exception e) {
e.printStackTrace();
}
return stage;
}
private void writeStage(String name, String content) {
File f = new File(giftFolder, name);
if (f.exists()) return;
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.print(content);
} catch (IOException ignored) {}
}
private void writeDefaultStages() {
writeStage("第一阶段.md",
"# 第一阶段\n\n## 领取条件\n\n- 无前级条件\n\n## 礼包内容\n\n- 皮革套装 × 1\n- 木制武器工具套装 × 1\n");
writeStage("第二阶段.md",
"# 第二阶段\n\n## 领取条件\n\n- 在线时长>1小时\n\n## 礼包内容\n\n- 500 游戏币\n");
writeStage("第三阶段.md",
"# 第三阶段\n\n## 领取条件\n\n- 破坏100个方块\n- 在线>1天\n\n## 礼包内容\n\n- 1500 游戏币\n- 铁质工具套装 × 1\n- 铁质防具 × 1\n");
writeStage("第四阶段.md",
"# 第四阶段\n\n## 领取条件\n\n- 破坏/放置300方块\n- 在线≥7天\n\n## 礼包内容\n\n- 钻石套装 × 1\n- 2000 游戏币\n");
}
/** 从任意文件夹加载任务列表 */
public List<Map<String, Object>> loadTasksFromFolder(
File folder) {
List<Map<String, Object>> tasks =
new ArrayList<>();
if (!folder.exists() || !folder.isDirectory())
return tasks;
File[] files = folder.listFiles(
(dir, name) -> name.endsWith(".md"));
if (files == null) return tasks;
Arrays.sort(files);
for (File f : files) {
Map<String, Object> task =
new LinkedHashMap<>();
task.put("name",
f.getName().replace(".md", ""));
task.put("conditions",
new ArrayList<String>());
task.put("rewards",
new ArrayList<String>());
try {
BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8));
String line;
List<String> current = null;
while ((line = r.readLine()) != null) {
if (line.startsWith("# ")) {
task.put("name",
line.substring(2)
.trim());
} else if (line.contains("条件")
|| line.contains("领取条件")
|| line.contains("完成条件")) {
current = (List<String>) task
.get("conditions");
} else if (line.contains("内容")
|| line.contains("奖励")
|| line.contains("任务内容")
|| line.contains("礼包内容")) {
current = (List<String>) task
.get("rewards");
} else if (line.startsWith("- ")
&& current != null) {
current.add(line.substring(2)
.trim());
}
}
r.close();
} catch (Exception e) {
e.printStackTrace();
}
tasks.add(task);
}
return tasks;
}
public boolean canClaim(Player p, int stage) {
String name = p.getName();
int curStage = ((Number) plugin.getDb()
.getField(name, "gift_stage"))
.intValue();
String claimed = (String) plugin.getDb()
.getField(name, "gift_claimed");
if (claimed == null) claimed = "";
return curStage >= stage
&& !claimed.contains(
String.valueOf(stage));
}
public void claimReward(Player p, int stage) {
if (!canClaim(p, stage)) return;
Map<String, Object> user = plugin.getDb()
.getUser(p.getName());
String claimed = (String) user.getOrDefault(
"gift_claimed", "");
claimed += "[" + stage + "]";
plugin.getDb().setField(
p.getName(), "gift_claimed", claimed);
plugin.getDb().setField(
p.getName(), "gift_stage",
Math.max(stage,
((Number) user.getOrDefault(
"gift_stage", 0))
.intValue()));
if (stage == 1) {
giveItem(p, Material.LEATHER_HELMET);
giveItem(p, Material.LEATHER_CHESTPLATE);
giveItem(p, Material.LEATHER_LEGGINGS);
giveItem(p, Material.LEATHER_BOOTS);
giveItem(p, Material.WOODEN_SWORD);
giveItem(p, Material.WOODEN_PICKAXE);
giveItem(p, Material.WOODEN_AXE);
giveItem(p, Material.WOODEN_SHOVEL);
} else if (stage == 2) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 500);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
} else if (stage == 3) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 1500);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
giveItem(p, Material.IRON_PICKAXE);
giveItem(p, Material.IRON_AXE);
giveItem(p, Material.IRON_SWORD);
giveItem(p, Material.IRON_HELMET);
giveItem(p, Material.IRON_CHESTPLATE);
giveItem(p, Material.IRON_LEGGINGS);
giveItem(p, Material.IRON_BOOTS);
} else if (stage == 4) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 2000);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
giveItem(p, Material.DIAMOND_HELMET);
giveItem(p, Material.DIAMOND_CHESTPLATE);
giveItem(p, Material.DIAMOND_LEGGINGS);
giveItem(p, Material.DIAMOND_BOOTS);
}
p.sendMessage(plugin.getConfig2().msg(
"gift_claimed"));
}
private void giveItem(Player p, Material mat) {
p.getInventory().addItem(new ItemStack(mat));
}
public List<Map<String, Object>> getStages() {
return stages;
}
public int getMaxStage() {
return stages.size();
}
}
@@ -1,110 +0,0 @@
package Sdf1_login;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class IPGroupManager {
private final Main plugin;
private final int maxAccounts;
public IPGroupManager(Main plugin, int maxAccounts) {
this.plugin = plugin;
this.maxAccounts = maxAccounts;
}
public boolean canRegister(String ip) {
if (maxAccounts <= 0) return true;
if (ip == null || ip.isEmpty()) return true;
int count = getAccountCount(ip);
boolean can = count < maxAccounts;
plugin.getLogger().info(
"[IP限制] IP=" + ip
+ " 已注册=" + count
+ " 上限=" + maxAccounts
+ " 允许=" + can);
return can;
}
public int getAccountCount(String ip) {
if (ip == null || ip.isEmpty()) return 0;
return queryAccountsByIP(ip).size();
}
public List<String> getAccounts(String ip) {
return queryAccountsByIP(ip);
}
private List<String> queryAccountsByIP(String ip) {
List<String> accounts = new ArrayList<>();
Connection conn = plugin.getDb().getDb();
if (conn == null) return accounts;
try {
// 主查询:ip_address 列
PreparedStatement ps = conn.prepareStatement(
"SELECT player_name FROM users "
+ "WHERE ip_address = ?");
ps.setString(1, ip);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
accounts.add(
rs.getString("player_name"));
}
rs.close();
ps.close();
// 兜底:如果主列没数据,查 last_login_ip
if (accounts.isEmpty()) {
PreparedStatement ps2 = conn.prepareStatement(
"SELECT player_name FROM users "
+ "WHERE last_login_ip = ? "
+ "AND (ip_address IS NULL "
+ "OR ip_address = '' "
+ "OR ip_address != ?)");
ps2.setString(1, ip);
ps2.setString(2, ip);
ResultSet rs2 = ps2.executeQuery();
while (rs2.next()) {
String name = rs2.getString("player_name");
if (!accounts.contains(name))
accounts.add(name);
}
rs2.close();
ps2.close();
}
// 兜底2:查 register_ip
if (accounts.isEmpty()) {
PreparedStatement ps3 = conn.prepareStatement(
"SELECT player_name FROM users "
+ "WHERE register_ip = ? "
+ "AND (ip_address IS NULL "
+ "OR ip_address = '') "
+ "AND (last_login_ip IS NULL "
+ "OR last_login_ip = '' "
+ "OR last_login_ip != ?)");
ps3.setString(1, ip);
ps3.setString(2, ip);
ResultSet rs3 = ps3.executeQuery();
while (rs3.next()) {
String name = rs3.getString("player_name");
if (!accounts.contains(name))
accounts.add(name);
}
rs3.close();
ps3.close();
}
plugin.getLogger().info(
"[IP限制] 查询IP=" + ip
+ " 结果数=" + accounts.size()
+ " 账号=" + accounts);
} catch (Exception e) {
e.printStackTrace();
}
return accounts;
}
}
@@ -1,46 +0,0 @@
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);
}
}
}
@@ -1,96 +0,0 @@
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;
}
}
@@ -1,95 +0,0 @@
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);
}
}
@@ -1,311 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
public class LoginManager {
private final Main plugin;
public LoginManager(Main plugin) {
this.plugin = plugin;
}
public boolean handleRegister(Player p,
String[] args) {
String name = p.getName();
// 已注册玩家不能再注册
if (plugin.getDb().userExists(name)) {
p.sendMessage(plugin.getConfig2()
.msg("already_registered"));
return true;
}
// IP注册限制检查
String ip = plugin.getPlayerIP(p);
if (ip != null && plugin.getConfig2()
.maxAccountsPerIP > 0) {
if (!plugin.getIPGroup()
.canRegister(ip)) {
int count = plugin.getIPGroup()
.getAccountCount(ip);
int max = plugin.getConfig2()
.maxAccountsPerIP;
p.sendMessage("§c该IP注册已达上限("
+ count + "/" + max + ")");
return true;
}
}
if (args.length < 1) {
p.sendMessage("§c用法: /reg <密码>");
p.sendMessage("§7密码要求: 6位以上,"
+ "包含大小写字母和数字");
return true;
}
String pwd = args[0];
if (pwd.length() < 6) {
p.sendMessage("§c密码长度不足6位");
return true;
}
if (!pwd.matches(".*[a-z].*")
|| !pwd.matches(".*[A-Z].*")
|| !pwd.matches(".*[0-9].*")) {
p.sendMessage("§c密码必须包含大写字母、"
+ "小写字母和数字");
return true;
}
String salt =
PasswordUtils.generateSalt();
String hash =
PasswordUtils.hash(pwd, salt);
plugin.getDb().createUser(
name, hash, salt);
plugin.getLoggedIn().add(name);
p.setAllowFlight(false);
p.setFlying(false);
plugin.getDb().setLoggedIn(name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
plugin.getDb().recordIP(name, ip);
plugin.getDb().setField(name,
"register_time",
System.currentTimeMillis());
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("reg_success"));
playRegisterSound(p);
try {
org.bukkit.plugin.Plugin cy =
Bukkit.getPluginManager()
.getPlugin("CY_beibao");
if (cy != null && cy.isEnabled()) {
cy.getClass().getMethod(
"onSdf1Activation",
String.class,
int.class, int.class)
.invoke(cy, name, 9, 0);
}
} catch (Exception ignored) {
}
return true;
}
private void playRegisterSound(Player p) {
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_CHIME,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_BASS,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING,
1.0f, 0.79f);
}
public boolean handleLogin(Player p,
String[] args) {
String name = p.getName();
// ★ 在 handleLogin 方法开头
if (plugin.getNeedsPasswordChange().contains(name)
&& plugin.getLoggedIn().contains(name)) {
// 已登录但还没改密码,只允许执行 /sdf1_login pw
p.sendMessage("§c请先修改密码: /sdf1_login pw <旧密码> <新密码>");
return true;
}
try {
if (plugin.getLoggedIn().contains(name)) {
p.sendMessage(plugin.getConfig2()
.msg("already_logged_in"));
return true;
}
if (!plugin.getDb().userExists(name)) {
p.sendMessage(plugin.getConfig2()
.msg("not_registered"));
return true;
}
if (args.length < 1) {
p.sendMessage("§c用法: /login <密码>");
return true;
}
String pwd = args[0];
String salt = (String) plugin.getDb()
.getField(name, "password_salt");
if (salt == null) {
p.sendMessage(
"§c数据异常,请联系管理员");
plugin.getLogger().severe(
"[Sdf1_login] 玩家 " + name
+ " 盐值为null");
return true;
}
String hash =
PasswordUtils.hash(pwd, salt);
boolean matchMain =
plugin.getDb().checkPassword(
name, hash);
if (matchMain) {
plugin.getLoggedIn().add(name);
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
// 只在有有效备份时恢复
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("login_success"));
activateCY(p, name);
return true;
}
boolean matchTemp =
plugin.getDb()
.checkPasswordOrTemp(
name, hash);
if (matchTemp) {
plugin.getLoggedIn().add(name);
plugin.getDb().setLoggedIn(name, true);
p.setAllowFlight(false);
p.setFlying(false);
plugin.getDb().setField(name, "last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name, "last_online_check",
System.currentTimeMillis());
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
// ★ 确保这行存在
plugin.getNeedsPasswordChange().add(name);
p.sendMessage(plugin.getConfig2().msg("login_success"));
p.sendMessage("§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
p.sendMessage("§7用法: /sdf1_login pw");
activateCY(p, name);
return true;
}
p.sendMessage(plugin.getConfig2()
.msg("login_failed"));
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_login] handleLogin异常: "
+ e.getMessage());
e.printStackTrace();
p.sendMessage(
"§c登录过程出错,请联系管理员");
}
return true;
}
public void handleReset(Player p) {
String name = p.getName();
String emailAddr = (String)
plugin.getDb().getField(
name, "email");
if (emailAddr == null
|| emailAddr.isEmpty()) {
p.sendMessage(
"§c未设置邮箱,无法重置密码");
return;
}
String tempPwd = PasswordUtils
.generateTempPassword();
String salt = (String)
plugin.getDb().getField(
name, "password_salt");
String hash = PasswordUtils.hash(
tempPwd, salt);
plugin.getDb().setField(name,
"temp_password", hash);
plugin.getDb().setField(name,
"temp_pw_expire",
System.currentTimeMillis()
+ 300000L);
plugin.getDb().setField(name,
"temp_pw_used", 0);
p.sendMessage("§e正在发送临时密码...");
final String to = emailAddr;
final String fName = name;
final String pwd = tempPwd;
final Main self = plugin;
Bukkit.getScheduler()
.runTaskAsynchronously(self,
() -> {
final boolean[] sent = {false};
Thread t = new Thread(() -> {
sent[0] = self.getEmail()
.sendTempPassword(
to, fName, pwd);
}, "Sdf1_login-reset");
t.setDaemon(true);
t.start();
try {
t.join(15000);
} catch (
InterruptedException ignored) {
}
if (t.isAlive()) {
t.interrupt();
Bukkit.getScheduler().runTask(
self, () ->
p.sendMessage(
"§c邮件发送超时"));
return;
}
Bukkit.getScheduler().runTask(
self, () -> {
if (sent[0]) {
p.sendMessage(
"§a临时密码已发送到 "
+ to);
} else {
p.sendMessage(
"§c邮件发送失败");
}
});
});
}
private void activateCY(Player p,
String name) {
try {
org.bukkit.plugin.Plugin cy =
Bukkit.getPluginManager()
.getPlugin("CY_beibao");
if (cy != null && cy.isEnabled()) {
cy.getClass().getMethod(
"onSdf1Activation",
String.class,
int.class, int.class)
.invoke(cy, name, 0, 0);
}
} catch (Exception ignored) {
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,152 +0,0 @@
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);
}
}
@@ -1,452 +0,0 @@
package Sdf1_login;
import org.bukkit.*;
import org.bukkit.entity.Player;
import org.bukkit.event.*;
import org.bukkit.event.inventory.*;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.nio.file.Files;
public class MenuManager implements Listener {
private final Main plugin;
private final List<MenuItem> items =
new ArrayList<>();
private final Set<String> chatWait =
ConcurrentHashMap.newKeySet();
private final Map<String, String> chatField =
new ConcurrentHashMap<>();
private final Map<String, Integer> chatIdx =
new ConcurrentHashMap<>();
// ===== 菜单项 =====
public static class MenuItem {
public String title = "新菜单项";
public String command = "";
public String permType = "玩家";
public Integer slot = null;
public ItemStack icon;
public MenuItem() {
icon = randomIcon();
}
public MenuItem(String t,
String c, String p) {
title = t;
command = c;
permType = p;
icon = randomIcon();
}
static ItemStack randomIcon() {
Material[] pool = {
Material.COMPASS,
Material.ENDER_PEARL,
Material.DIAMOND,
Material.GOLD_INGOT,
Material.EMERALD,
Material.NETHER_STAR,
Material.BEACON,
Material.ENCHANTING_TABLE,
Material.CHEST,
Material.FIREWORK_ROCKET,
Material.BOOK,
Material.ELYTRA,
Material.TOTEM_OF_UNDYING
};
Material m = pool[
(int) (Math.random()
* pool.length)];
ItemStack it =
new ItemStack(m);
ItemMeta im = it.getItemMeta();
im.setDisplayName("§e默认图标");
it.setItemMeta(im);
return it;
}
}
public MenuManager(Main plugin) {
this.plugin = plugin;
}
public List<MenuItem> getItems() {
return items;
}
// ===== 读取菜单.txt =====
public void loadMenu() {
items.clear();
File f = new File(
plugin.getDataFolder(),
"菜单.txt");
if (!f.exists()) createDefault(f);
parseFile(f);
plugin.getLogger().info(
"[Menu] 加载 "
+ items.size()
+ " 个菜单项");
}
private void parseFile(File f) {
plugin.getLogger().info("[Menu] ===== 扫描 =====");
plugin.getLogger().info("[Menu] 路径: "
+ f.getAbsolutePath());
plugin.getLogger().info("[Menu] 大小: "
+ f.length());
try {
List<String> lines = Files.readAllLines(
f.toPath(), StandardCharsets.UTF_8);
plugin.getLogger().info("[Menu] 总行数: "
+ lines.size());
boolean inBlock = false;
boolean inHtml = false;
StringBuilder buf = new StringBuilder();
boolean inItem = false;
int parsed = 0;
for (int lineNum = 1;
lineNum <= lines.size();
lineNum++) {
String raw = lines.get(lineNum - 1);
String l = raw.trim();
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 原文[" + l.length()
+ "字符] " + l);
// ===== 第1优先:跨行块注释 =====
if (inBlock) {
if (l.contains("*/")) {
inBlock = false;
l = l.substring(
l.indexOf("*/") + 2)
.trim();
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 块注释结束");
} else {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [块注释中]");
continue;
}
}
// ===== 第2优先:跨行HTML注释 =====
if (inHtml) {
if (l.contains("-->")) {
inHtml = false;
l = l.substring(
l.indexOf("-->") + 3)
.trim();
} else {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [HTML注释中]");
continue;
}
}
// ===== 第3优先:整行注释(最高裁决) =====
// 以 # 或 // 开头的行,直接跳过
// 不管后面包含什么 /* <!-- 等
if (l.startsWith("#")
|| l.startsWith("//")) {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [整行注释,跳过]");
continue;
}
// ===== 第4优先:块注释开始 =====
if (l.contains("/*")) {
l = l.substring(0, l.indexOf("/*"))
.trim();
inBlock = true;
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 进入/*块注释"
+ " 剩余[" + l + "]");
if (l.isEmpty()) {
continue;
}
}
// ===== 第5优先:HTML注释开始 =====
if (l.contains("<!--")) {
l = l.substring(0,
l.indexOf("<!--")).trim();
inHtml = true;
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 进入<!--注释");
if (l.isEmpty()) {
continue;
}
}
// ===== 第6优先:行内注释 =====
int hashPos = l.indexOf("#");
int slashPos = l.indexOf("//");
if (hashPos >= 0) {
l = l.substring(0, hashPos).trim();
}
if (slashPos >= 0) {
if (hashPos < 0
|| slashPos < hashPos) {
l = l.substring(0, slashPos)
.trim();
}
}
if (l.isEmpty()) {
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": 空");
continue;
}
plugin.getLogger().info(
"[Menu] L" + lineNum
+ ": [保留] " + l);
if (l.equals("{")) {
inItem = true;
buf.setLength(0);
plugin.getLogger().info(
"[Menu] === 块开始 ===");
continue;
}
if (l.equals("}")) {
if (inItem) {
plugin.getLogger().info(
"[Menu] === 块结束 ===");
parseBlock(buf.toString());
parsed++;
plugin.getLogger().info(
"[Menu] 当前菜单项: "
+ items.size());
}
inItem = false;
continue;
}
if (inItem) {
buf.append(l).append("\n");
}
}
plugin.getLogger().info(
"[Menu] ===== 完毕 =====");
plugin.getLogger().info(
"[Menu] 块数: " + parsed);
plugin.getLogger().info(
"[Menu] 菜单项: "
+ items.size());
} catch (Exception e) {
plugin.getLogger().warning(
"[Menu] 异常: "
+ e.getMessage());
}
}
private void parseBlock(String block) {
String title = "";
String cmd = "";
String perm = "玩家";
String iconMat = "";
int slotNum = -1;
for (String line : block.split("\n")) {
line = line.trim();
if (line.isEmpty()) continue;
String v = "";
int idx = line.indexOf(":");
if (idx < 0) idx = line.indexOf(":");
if (idx >= 0) {
v = line.substring(idx + 1).trim();
if (v.startsWith("\"")
&& v.endsWith("\"")
&& v.length() >= 2) {
v = v.substring(1, v.length() - 1);
}
}
if (line.startsWith("标题")) {
title = v;
} else if (line.startsWith("指令")) {
cmd = v;
if (!cmd.isEmpty()
&& !cmd.startsWith("/")) {
cmd = "/" + cmd;
}
} else if (line.startsWith("权限")) {
perm = v;
} else if (line.startsWith("图标")) {
iconMat = v;
} else if (line.startsWith("格子")) {
try {
int s = Integer.parseInt(v);
if (s >= 1 && s <= 54) {
slotNum = s;
}
} catch (NumberFormatException ignored) {
}
}
}
if (title.isEmpty()) return;
MenuItem mi = new MenuItem(title, cmd, perm);
if (slotNum >= 1 && slotNum <= 54) {
mi.slot = slotNum;
}
if (!iconMat.isEmpty()) {
try {
String matName = iconMat
.replace("minecraft:", "")
.replace("bukkit:", "")
.trim().toUpperCase();
Material m =
Material.valueOf(matName);
mi.icon = new ItemStack(m);
ItemMeta im =
mi.icon.getItemMeta();
im.setDisplayName(
"§e" + title);
mi.icon.setItemMeta(im);
} catch (Exception e) {
plugin.getLogger().warning(
"[Menu] 无法解析: "
+ iconMat);
}
}
items.add(mi);
}
private void createDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets
.UTF_8))) {
pw.println("# 服务器菜单");
pw.println("# {}定义菜单项");
pw.println("# 支持"
+ " # // /* */ <!-- -->");
pw.println();
pw.println("{");
pw.println(" 标题:主城传送");
pw.println(" 指令:/spawn");
pw.println(" 权限类型:玩家");
pw.println(" 图标:COMPASS");
pw.println("}");
pw.println();
pw.println("{");
pw.println(" 标题:随机传送");
pw.println(" 指令:/rtp");
pw.println(" 权限类型:玩家");
pw.println(" 图标:ENDER_PEARL");
pw.println("}");
} catch (Exception ignored) {
}
}
// ===== 保存 =====
public void saveMenu() {
File f = new File(
plugin.getDataFolder(),
"菜单.txt");
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# 服务器菜单");
pw.println();
for (MenuItem mi : items) {
pw.println("{");
pw.println(" 标题:"
+ mi.title);
pw.println(" 指令:"
+ mi.command);
pw.println(" 权限类型:"
+ mi.permType);
// ★ 保存实际材质名 ★
pw.println(" 图标:"
+ mi.icon.getType().name());
pw.println("}");
pw.println();
}
} catch (Exception e) {
plugin.getLogger().warning(
"[Menu] 保存失败: "
+ e.getMessage());
}
}
// ===== 聊天编辑 =====
public boolean isEditing(String name) {
return chatWait.contains(name);
}
public void editTitle(
Player p, int idx) {
chatIdx.put(p.getName(), idx);
chatWait.add(p.getName());
chatField.put(p.getName(), "title");
p.closeInventory();
p.sendMessage("§e请输入菜单标题:");
}
public void editCommand(
Player p, int idx) {
chatIdx.put(p.getName(), idx);
chatWait.add(p.getName());
chatField.put(p.getName(), "command");
p.closeInventory();
p.sendMessage("§e请输入指令"
+ "(自动补全/):");
}
public void onChat(Player p, String input) {
String name = p.getName();
if (!chatWait.remove(name)) return;
String field = chatField.remove(name);
Integer idx = chatIdx.get(name);
if (idx == null || idx >= items.size()) return;
MenuItem mi = items.get(idx);
if ("title".equals(field)) {
mi.title = input;
p.sendMessage("§a标题: " + input);
} else if ("command".equals(field)) {
if (!input.startsWith("/"))
input = "/" + input;
mi.command = input;
p.sendMessage("§a指令: " + input);
}
Bukkit.getScheduler().runTask(plugin, () ->
plugin.gui.openEditor(p, idx));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,72 +0,0 @@
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);
}
}
@@ -1,130 +0,0 @@
package Sdf1_login;
import java.io.File;
import java.sql.*;
import java.util.logging.Logger;
public class PlayerStorage {
private Connection conn;
private final Logger logger;
public PlayerStorage(File dataFolder,
Logger logger) {
this.logger = logger;
File dbFile = new File(
dataFolder,
"quest_data.db");
try {
Class.forName(
"org.sqlite.JDBC");
conn = DriverManager
.getConnection(
"jdbc:sqlite:"
+ dbFile
.getAbsolutePath());
conn.setAutoCommit(true);
Statement s =
conn.createStatement();
s.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_kv ("
+ "player TEXT NOT NULL,"
+ "key TEXT NOT NULL,"
+ "value TEXT,"
+ "PRIMARY KEY"
+ "(player, key))");
s.close();
logger.info(
"[QuestStorage] 初始化: "
+ dbFile.getAbsolutePath());
} catch (Exception e) {
logger.severe(
"[QuestStorage] 失败: "
+ e.getMessage());
}
}
public Object getCondition(
String player, String key) {
if (conn == null) return null;
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value "
+ "FROM quest_kv "
+ "WHERE player=? "
+ "AND key=?");
ps.setString(1,
player.toLowerCase());
ps.setString(2, key);
ResultSet rs =
ps.executeQuery();
String val = rs.next()
? rs.getString("value")
: null;
rs.close();
ps.close();
return parseValue(val);
} catch (SQLException e) {
return null;
}
}
public void setCondition(
String player, String key) {
setConditionRaw(
player, key, "true");
}
public void debugPlayer(String player) {
}
public void setConditionRaw(
String player, String key,
String value) {
if (conn == null) return;
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO quest_kv"
+ "(player,key,value)"
+ " VALUES(?,?,?)");
ps.setString(1,
player.toLowerCase());
ps.setString(2, key);
ps.setString(3, value);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
logger.warning(
"[QuestStorage] 写入失败: "
+ key + "=" + value
+ " | " + e.getMessage());
}
}
public void close() {
try {
if (conn != null
&& !conn.isClosed())
conn.close();
} catch (Exception e) {}
}
private Object parseValue(String v) {
if (v == null) return null;
try {
return Long.parseLong(v);
} catch (Exception e1) {
try {
return Double.parseDouble(v);
} catch (Exception e2) {
if ("true".equalsIgnoreCase(v))
return true;
if ("false".equalsIgnoreCase(v))
return false;
return v;
}
}
}
}
@@ -1,374 +0,0 @@
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;
}
}
@@ -1,138 +0,0 @@
package Sdf1_login;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class QuestStorage {
private Connection conn;
public QuestStorage(File dataFolder) {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
dataFolder,
"quest_data.db");
conn = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getPath());
initTable();
} catch (Exception e) {
e.printStackTrace();
}
}
private void initTable()
throws SQLException {
Statement stmt =
conn.createStatement();
stmt.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_conditions ("
+ "player TEXT NOT NULL, "
+ "condition_key TEXT NOT NULL, "
+ "value TEXT DEFAULT '1', "
+ "timestamp INTEGER, "
+ "PRIMARY KEY "
+ "(player, condition_key))");
stmt.close();
}
/**
* 标记条件已完成(value = "1")
*/
public void setCondition(
String player,
String condition) {
setConditionRaw(
player, condition, "1");
}
/**
* 写入任意值(计数器等)
*/
public void setConditionRaw(
String player,
String condition,
String value) {
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO "
+ "quest_conditions "
+ "(player, "
+ "condition_key, "
+ "value, timestamp) "
+ "VALUES "
+ "(?, ?, ?, ?)");
ps.setString(1, player);
ps.setString(2, condition);
ps.setString(3, value);
ps.setLong(4,
System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 查询条件值
*/
public Object getCondition(
String player,
String condition) {
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value FROM "
+ "quest_conditions "
+ "WHERE player = ? "
+ "AND "
+ "condition_key = ?");
ps.setString(1, player);
ps.setString(2, condition);
ResultSet rs =
ps.executeQuery();
if (rs.next()) {
String val =
rs.getString("value");
rs.close();
ps.close();
// 尝试解析为数字
try {
if (val.contains(".")) {
return Double.parseDouble(
val);
}
return Long.parseLong(val);
} catch (NumberFormatException e) {
return val;
}
}
rs.close();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public void close() {
try {
if (conn != null
&& !conn.isClosed()) {
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,144 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player
.PlayerResourcePackStatusEvent;
import java.util.UUID;
import java.util.concurrent
.ConcurrentHashMap;
public class RadioDownloadListener
implements Listener {
private final Main plugin;
private final java.util.logging.Logger log;
private final ConcurrentHashMap<UUID, Integer>
failCount = new ConcurrentHashMap<>();
private final ConcurrentHashMap<UUID, Boolean>
blocked = new ConcurrentHashMap<>();
private static final int MAX_FAILED = 3;
public RadioDownloadListener(Main plugin) {
this.plugin = plugin;
this.log = plugin.getLogger();
}
public void onPlayerQuit(UUID uuid) {
failCount.remove(uuid);
blocked.remove(uuid);
}
@EventHandler
public void onStatus(
PlayerResourcePackStatusEvent e) {
Player p = e.getPlayer();
UUID uuid = p.getUniqueId();
String status =
e.getStatus().name();
log.info("[Radio] === 资源包状态 ===");
log.info("[Radio] 玩家: "
+ p.getName());
log.info("[Radio] UUID: " + uuid);
log.info("[Radio] 状态: "
+ status);
log.info("[Radio] 原始状态枚举: "
+ e.getStatus().getClass()
.getSimpleName()
+ "."
+ e.getStatus().name());
if (blocked.containsKey(uuid)) {
log.info("[Radio] 已熔断,跳过: "
+ p.getName());
return;
}
switch (status) {
case "SUCCESS":
log.info("[Radio] "
+ p.getName()
+ " ✓ 资源包加载成功");
failCount.remove(uuid);
blocked.remove(uuid);
break;
case "ACCEPTED":
log.info("[Radio] "
+ p.getName()
+ " 客户端已接受,"
+ "等待下载...");
break;
case "DECLINED":
log.warning("[Radio] "
+ p.getName()
+ " ✗ 拒绝了资源包");
log.warning("[Radio] "
+ "可能原因: "
+ "1.客户端设置禁用资源包"
+ " 2.已缓存拒绝记录"
+ " 3.SSL证书不匹配");
handleFailure(p, uuid);
break;
case "FAILED_DOWNLOAD":
log.warning("[Radio] "
+ p.getName()
+ " ✗ 下载失败");
log.warning("[Radio] "
+ "可能原因: "
+ "1.URL无法访问"
+ " 2.SSL证书错误"
+ " 3.网络超时"
+ " 4.文件不存在(404)");
handleFailure(p, uuid);
break;
default:
log.info("[Radio] "
+ p.getName()
+ " 未知状态: "
+ status);
break;
}
}
private void handleFailure(Player p, UUID uuid) {
int failNum = failCount.getOrDefault(uuid, 0) + 1;
failCount.put(uuid, failNum);
log.warning("[Radio] " + p.getName()
+ " 失败次数: " + failNum + "/" + MAX_FAILED);
if (failNum >= MAX_FAILED) {
blocked.put(uuid, true);
log.severe("[Radio] " + p.getName()
+ " 已达最大失败次数,停止重试,"
+ "玩家继续留在服务器");
// 不踢出,只记录熔断,玩家正常游玩
p.sendMessage("§e§l[Radio] 资源包加载失败"
+ ",已取消下载资源包,部分画面音频可能出现问题"
+ "\n§d§l若想继续下载,请退出重进。若持续失败,请上报管理员");
return;
}
// 打了包没测试
p.sendMessage("§e§l[Radio] 下载失败,"
+ "重试... (" + failNum + "/" + MAX_FAILED + ")");
// 5秒后重试
Bukkit.getScheduler()
.runTaskLater(plugin, () -> {
if (p.isOnline() && plugin.radio != null) {
plugin.radio.sendResourcePack(p);
}
}, 100L);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,62 +0,0 @@
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);
}
}
@@ -1,248 +0,0 @@
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();
}
}
@@ -1,170 +0,0 @@
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();
}
}
@@ -1,32 +0,0 @@
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);
}
}
@@ -1,49 +0,0 @@
package Sdf1_login;
import org.bukkit.Material;
public class ShopItem {
private final String id;
private final String displayName;
private final Material material;
private final int buyPrice;
private final int sellPrice;
private int stock;
private int hourlySales;
private int totalSales;
public ShopItem(String id, String displayName,
Material material, int buyPrice,
int sellPrice, int stock,
int hourlySales, int totalSales) {
this.id = id;
this.displayName = displayName;
this.material = material;
this.buyPrice = buyPrice;
this.sellPrice = sellPrice;
this.stock = stock;
this.hourlySales = hourlySales;
this.totalSales = totalSales;
}
public int getEffectiveSellPrice() {
if (sellPrice <= 0) return 0;
return sellPrice;
}
public String getId() { return id; }
public String getDisplayName() { return displayName; }
public Material getMaterial() { return material; }
public int getBuyPrice() { return buyPrice; }
public int getSellPrice() { return sellPrice; }
public int getStock() { return stock; }
public int getHourlySales() { return hourlySales; }
public int getTotalSales() { return totalSales; }
public void setStock(int s) { this.stock = s; }
public void setHourlySales(int s) { this.hourlySales = s; }
public void setTotalSales(int s) { this.totalSales = s; }
public void addSales(int n) {
this.hourlySales += n;
this.totalSales += n;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,523 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.HttpURLConnection;
import java.net.URL;
public class UpdateChecker {
private final Main plugin;
private final String currentVersion;
// ===== 仓库信息 =====
private static final String GITHUB_OWNER =
"ypsdf1";
private static final String GITHUB_REPO =
"sdf1_login";
private static final String GITEE_OWNER =
"nihaoshidifu";
private static final String GITEE_REPO =
"sdf1_login";
// API 地址
private static final String GITHUB_API =
"https://api.github.com/repos/"
+ GITHUB_OWNER + "/"
+ GITHUB_REPO
+ "/tags";
private static final String GITEE_API =
"https://gitee.com/api/v5/repos/"
+ GITEE_OWNER + "/"
+ GITEE_REPO
+ "/tags";
// 仓库页面链接(给用户看的)
private static final String GITHUB_LINK =
"https://github.com/"
+ GITHUB_OWNER + "/"
+ GITHUB_REPO + "/releases";
private static final String GITEE_LINK =
"https://gitee.com/"
+ GITEE_OWNER + "/"
+ GITEE_REPO + "/releases";
// 单次请求超时(毫秒)
private static final int TIMEOUT_MS = 3000;
// 熔断阈值
private static final int TRIP_THRESHOLD = 5;
// ===== 熔断状态(内存态,重启自动清除) =====
private volatile boolean circuitTripped =
false;
private volatile int failCount = 0;
// ===== 自动检测禁用(持久化到文件) =====
private volatile boolean autoCheckDisabled =
false;
private final File disabledFile;
public UpdateChecker(Main plugin) {
this.plugin = plugin;
this.currentVersion =
plugin.getDescription()
.getVersion();
this.disabledFile = new File(
plugin.getDataFolder(),
"update_disabled.dat");
loadDisabledState();
}
// ========== 持久化禁用状态 ==========
private void loadDisabledState() {
if (disabledFile.exists()) {
try (BufferedReader br =
new BufferedReader(
new FileReader(
disabledFile))) {
String line = br.readLine();
if (line != null) {
autoCheckDisabled =
"true".equals(
line.trim());
}
} catch (Exception ignored) {
}
}
}
private void saveDisabledState() {
try (PrintWriter pw =
new PrintWriter(
new FileWriter(
disabledFile))) {
pw.println(autoCheckDisabled
? "true" : "false");
} catch (Exception ignored) {
}
}
// ========== 公开方法 ==========
/**
* onEnable 调用(异步静默)
* 熔断或禁用则直接跳过
*/
public void checkOnEnable() {
if (autoCheckDisabled) return;
if (circuitTripped) return;
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() -> doCheck(null, true));
}
/**
* /sdf1_login update 调用(异步)
* 忽略熔断和禁用状态,强制检测
* 只有拿到版号才解除熔断
*/
public void checkUpdate(
CommandSender sender) {
Bukkit.getScheduler()
.runTaskAsynchronously(plugin,
() ->
doCheck(sender,
false));
}
// ========== 核心检测逻辑 ==========
private void doCheck(CommandSender sender,
boolean isAuto) {
if (isAuto) {
if (autoCheckDisabled
|| circuitTripped) {
return;
}
}
// 通道1:GitHub
CheckResult gh =
tryChannel(GITHUB_API,
"GitHub", GITHUB_LINK);
// 通道2:Gitee
CheckResult ge =
tryChannel(GITEE_API,
"Gitee", GITEE_LINK);
// 选择成功的结果
CheckResult chosen = null;
String failMsg = null;
if (gh.isSuccess()) {
chosen = gh;
} else if (ge.isSuccess()) {
chosen = ge;
} else {
// 双通道均失败
failCount++;
// 拼接两个通道的失败原因
StringBuilder sb =
new StringBuilder();
if (gh.error != null) {
sb.append("§7GitHub: §c")
.append(gh.error);
}
if (ge.error != null) {
if (sb.length() > 0)
sb.append("\n");
sb.append("§7Gitee: §c")
.append(ge.error);
}
if (failCount >= TRIP_THRESHOLD) {
circuitTripped = true;
plugin.getLogger().warning(
"[Sdf1_login] 更新检测"
+ "已熔断,连续失败"
+ failCount + "次");
}
if (sender != null) {
final String msg =
sb.toString();
final boolean tripped =
circuitTripped;
final int fc = failCount;
Bukkit.getScheduler()
.runTask(plugin, () -> {
sender.sendMessage(
"§c§l[更新] "
+ "检测失败");
sender.sendMessage(msg);
sender.sendMessage(
"§e失败次数: "
+ fc + "/"
+ TRIP_THRESHOLD);
if (tripped) {
sender.sendMessage(
"§c已熔断,"
+ "请重启服务器"
+ "重置");
}
});
}
return;
}
// 至少一个通道成功
failCount = 0;
// 检查禁用信号
if (isDisableSignal(chosen.version)) {
autoCheckDisabled = true;
saveDisabledState();
plugin.getLogger().info(
"[Sdf1_login] 远程版本信号"
+ " \"" + chosen.version
+ "\",自动更新检测已禁用");
if (sender != null) {
Bukkit.getScheduler()
.runTask(plugin, () ->
sender.sendMessage(
"§e[更新] 远程已"
+ "禁用自动"
+ "更新检测"));
}
return;
}
// 手动调用:成功才解除熔断和禁用
if (!isAuto) {
if (circuitTripped) {
circuitTripped = false;
}
if (autoCheckDisabled) {
autoCheckDisabled = false;
saveDisabledState();
}
}
final String src = chosen.source;
final String lnk = chosen.link;
final String rv = chosen.version;
Bukkit.getScheduler()
.runTask(plugin, () -> {
if (sender == null) return;
sender.sendMessage(
"§e[更新] 检测成功"
+ " via: §a" + src);
sender.sendMessage(
"§e[更新] 本地版本:"
+ " §f"
+ currentVersion);
sender.sendMessage(
"§e[更新] 远程版本:"
+ " §f" + rv);
if (isNewer(rv,
currentVersion)) {
sender.sendMessage(
"§c[更新] 发现新"
+ "版本: " + rv);
sender.sendMessage(
"§a请前往仓库下载"
+ "更新:");
sender.sendMessage(
"§a" + lnk);
} else {
sender.sendMessage(
"§a[更新] 已是"
+ "最新版本");
}
// 显示另一通道的失败原因
// (仅信息,不影响结果)
CheckResult other =
(src.equals("GitHub"))
? ge : gh;
if (!other.isSuccess()
&& other.error != null) {
sender.sendMessage(
"§7[更新] "
+ other.source
+ "不可用: "
+ other.error);
}
});
}
// ========== 禁用信号判断 ==========
private boolean isDisableSignal(
String version) {
if (version == null) return false;
String v = version.trim().toLowerCase();
return "999".equals(v)
|| "关闭".equals(v)
|| "false".equals(v)
|| "禁用".equals(v);
}
// ========== HTTP 请求(带超时) ==========
// ========== 检测结果 ==========
private static class CheckResult {
final String version;
final String source;
final String link;
final String error;
CheckResult(String version,
String source,
String link,
String error) {
this.version = version;
this.source = source;
this.link = link;
this.error = error;
}
boolean isSuccess() {
return version != null;
}
}
// ========== HTTP 请求 ==========
private CheckResult tryChannel(
String apiUrl,
String sourceName,
String link) {
HttpURLConnection conn = null;
try {
URL url = new URL(apiUrl);
conn = (HttpURLConnection)
url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; "
+ "Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36");
conn.setRequestProperty(
"Accept",
"*/*");
conn.setRequestProperty(
"Accept-Language",
"zh-CN,zh;q=0.9");
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == 404) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 未找到 (HTTP 404)");
}
if (code == 403) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 被限流 (HTTP 403)");
}
if (code != 200) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": HTTP " + code);
}
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
conn
.getInputStream()));
StringBuilder sb =
new StringBuilder();
String line;
while ((line = reader.readLine())
!= null) {
sb.append(line);
}
reader.close();
String tag =
parseTagName(sb.toString());
if (tag == null) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 无法解析版本号");
}
return new CheckResult(tag,
sourceName, link, null);
} catch (java.net
.SocketTimeoutException e) {
return new CheckResult(null,
sourceName, link,
sourceName + ": 连接超时");
} catch (java.net.ConnectException e) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": 连接被拒绝");
} catch (java.net
.UnknownHostException e) {
return new CheckResult(null,
sourceName, link,
sourceName
+ ": DNS解析失败");
} catch (Exception e) {
return new CheckResult(null,
sourceName, link,
sourceName + ": "
+ e.getMessage());
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
/**
* 从 JSON 中提取 tag_name
*/
private String parseTagName(String json) {
// tags API 返回数组: [{"name":"v1.1",...}]
// releases API 返回对象: {"tag_name":"v1.1"}
// 尝试 tag_name(releases格式)
int idx =
json.indexOf("\"tag_name\"");
if (idx >= 0) {
int colon =
json.indexOf(":", idx);
int q1 =
json.indexOf("\"", colon + 1);
int q2 =
json.indexOf("\"", q1 + 1);
if (q2 > q1) {
return cleanTag(
json.substring(
q1 + 1, q2));
}
}
// 尝试 "name"(tags格式)
idx = json.indexOf("\"name\"");
if (idx >= 0) {
int colon =
json.indexOf(":", idx);
int q1 =
json.indexOf("\"", colon + 1);
int q2 =
json.indexOf("\"", q1 + 1);
if (q2 > q1) {
return cleanTag(
json.substring(
q1 + 1, q2));
}
}
return null;
}
private String cleanTag(String tag) {
if (tag.startsWith("v")
|| tag.startsWith("V")) {
tag = tag.substring(1);
}
return tag.isEmpty() ? null : tag;
}
// ========== 版本比较 ==========
private boolean isNewer(String remote,
String local) {
String[] r = remote.split("\\.");
String[] l = local.split("\\.");
int len = Math.max(r.length, l.length);
for (int i = 0; i < len; i++) {
int ri = i < r.length
? parseNum(r[i]) : 0;
int li = i < l.length
? parseNum(l[i]) : 0;
if (ri > li) return true;
if (ri < li) return false;
}
return false;
}
private int parseNum(String s) {
try {
return Integer.parseInt(
s.replaceAll("[^0-9]", ""));
} catch (Exception e) {
return 0;
}
}
}
@@ -1,370 +0,0 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.util.*;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
public class UserGroupManager {
private final Main plugin;
private Connection db;
private final Map<String, UserGroupConfig> groupConfigs = new ConcurrentHashMap<>();
public UserGroupManager(Main plugin) {
this.plugin = plugin;
initDB();
loadGroupConfigs();
startExpiryChecker();
}
// ===== DB =====
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(plugin.getDataFolder(), "usergroup.db");
db = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
st.execute("CREATE TABLE IF NOT EXISTS user_group_config ("
+ "group_name TEXT PRIMARY KEY,"
+ "display_name TEXT DEFAULT '',"
+ "display_color TEXT DEFAULT '§f',"
+ "display_emoji TEXT DEFAULT '',"
+ "priority INTEGER DEFAULT 0,"
+ "is_permanent INTEGER DEFAULT 1,"
+ "duration_minutes INTEGER DEFAULT 0,"
+ "pack_url TEXT DEFAULT '',"
+ "pack_hash TEXT DEFAULT '',"
+ "pack_prompt TEXT DEFAULT 'Custom Resource Pack',"
+ "pack_size INTEGER DEFAULT 0)");
st.execute("CREATE TABLE IF NOT EXISTS user_group_member ("
+ "player_name TEXT NOT NULL,"
+ "group_name TEXT NOT NULL,"
+ "added_by TEXT DEFAULT 'system',"
+ "added_time INTEGER DEFAULT 0,"
+ "expiry_time INTEGER DEFAULT 0,"
+ "PRIMARY KEY(player_name, group_name))");
st.close();
} catch (Exception e) {
throw new RuntimeException("[UserGroup] DB init failed: " + e.getMessage(), e);
}
}
// ===== 配置加载 =====
public void loadGroupConfigs() {
groupConfigs.clear();
File dir = new File(plugin.getDataFolder(), "用户组");
if (!dir.exists()) dir.mkdirs();
File[] files = dir.listFiles((d, n) -> n.endsWith(".txt"));
if (files == null) return;
for (File f : files) {
try {
List<String> lines = java.nio.file.Files.readAllLines(
f.toPath(), StandardCharsets.UTF_8);
UserGroupConfig cfg = parseGroupConfig(lines);
if (cfg != null) {
groupConfigs.put(cfg.name, cfg);
saveGroupConfigToDB(cfg);
}
} catch (Exception e) {
plugin.getLogger().warning("[UserGroup] Failed to load: " + f.getName());
}
}
}
private UserGroupConfig parseGroupConfig(List<String> lines) {
UserGroupConfig cfg = new UserGroupConfig();
boolean inBlock = false;
for (String raw : lines) {
String l = raw;
// 块注释
if (l.contains("<!--")) { inBlock = true; continue; }
if (l.contains("-->")) { inBlock = false; continue; }
if (l.contains("/*")) { inBlock = true; continue; }
if (l.contains("*/")) { inBlock = false; continue; }
if (inBlock) continue;
// 行注释
l = l.trim();
if (l.isEmpty()) continue;
if (l.startsWith("#") || l.startsWith("//")) continue;
// 行内注释
int h = l.indexOf('#');
int s = l.indexOf("//");
if (h >= 0) l = l.substring(0, h).trim();
if (s >= 0 && (h < 0 || s < h)) l = l.substring(0, s).trim();
if (l.isEmpty()) continue;
// 解析键值对
String key = l;
String val = "";
int idx = l.indexOf('\uff1a');
if (idx < 0) idx = l.indexOf(':');
if (idx >= 0) {
key = l.substring(0, idx).trim();
val = l.substring(idx + 1).trim();
if (val.startsWith("\"") && val.endsWith("\""))
val = val.substring(1, val.length() - 1);
}
switch (key) {
case "组名":
case "name": cfg.name = val; break;
case "显示名":
case "display": cfg.displayName = val; break;
case "颜色":
case "color": cfg.displayColor = val; break;
case "符号":
case "emoji": cfg.displayEmoji = val; break;
case "优先级":
case "priority":
try { cfg.priority = Integer.parseInt(val); } catch (Exception ignored) {}
break;
case "类型":
case "type":
cfg.permanent = val.equalsIgnoreCase("永久") || val.equalsIgnoreCase("permanent");
break;
case "时长":
case "duration":
try { cfg.durationMinutes = Integer.parseInt(val); } catch (Exception ignored) {}
break;
case "资源包URL":
case "pack_url": cfg.packUrl = val; break;
case "资源包Hash":
case "pack_hash": cfg.packHash = val; break;
case "资源包提示":
case "pack_prompt": cfg.packPrompt = val; break;
case "资源包大小":
case "pack_size":
try { cfg.packSize = Integer.parseInt(val); } catch (Exception ignored) {}
break;
case "本地文件":
case "local_file": cfg.localFile = val; break;
}
}
if (cfg.name == null || cfg.name.isEmpty()) return null;
return cfg;
}
private void saveGroupConfigToDB(UserGroupConfig cfg) {
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO user_group_config "
+ "(group_name, display_name, display_color, display_emoji,"
+ " priority, is_permanent, duration_minutes,"
+ " pack_url, pack_hash, pack_prompt, pack_size)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?)");
ps.setString(1, cfg.name);
ps.setString(2, cfg.displayName);
ps.setString(3, cfg.displayColor);
ps.setString(4, cfg.displayEmoji);
ps.setInt(5, cfg.priority);
ps.setInt(6, cfg.permanent ? 1 : 0);
ps.setInt(7, cfg.durationMinutes);
ps.setString(8, cfg.packUrl);
ps.setString(9, cfg.packHash);
ps.setString(10, cfg.packPrompt);
ps.setInt(11, cfg.packSize);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
// ===== 玩家操作 =====
public boolean addPlayer(String player, String groupName, String addedBy, long durationMinutes) {
UserGroupConfig cfg = groupConfigs.get(groupName);
if (cfg == null) return false;
long now = System.currentTimeMillis();
long expiry = cfg.permanent ? 0 : now + durationMinutes * 60 * 1000;
if (!cfg.permanent && durationMinutes > 0) expiry = now + durationMinutes * 60 * 1000;
else if (!cfg.permanent) expiry = now + cfg.durationMinutes * 60 * 1000;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO user_group_member "
+ "(player_name, group_name, added_by, added_time, expiry_time)"
+ " VALUES (?,?,?,?,?)");
ps.setString(1, player);
ps.setString(2, groupName);
ps.setString(3, addedBy);
ps.setLong(4, now);
ps.setLong(5, expiry);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
return false;
}
Player p = plugin.getServer().getPlayerExact(player);
if (p != null) sendResourcePack(p);
return true;
}
public boolean removePlayer(String player, String groupName) {
try {
PreparedStatement ps = db.prepareStatement(
"DELETE FROM user_group_member WHERE player_name=? AND group_name=?");
ps.setString(1, player);
ps.setString(2, groupName);
int rows = ps.executeUpdate();
ps.close();
return rows > 0;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
public List<Map<String, Object>> getPlayerGroups(String player) {
List<Map<String, Object>> list = new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT * FROM user_group_member WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
long now = System.currentTimeMillis();
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("group_name", rs.getString("group_name"));
row.put("added_by", rs.getString("added_by"));
long expiry = rs.getLong("expiry_time");
row.put("expiry_time", expiry);
row.put("expired", expiry > 0 && expiry <= now);
list.add(row);
}
rs.close(); ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
public UserGroupConfig getHighestGroup(String player) {
List<Map<String, Object>> groups = getPlayerGroups(player);
long now = System.currentTimeMillis();
UserGroupConfig best = null;
for (Map<String, Object> g : groups) {
long expiry = (long) g.get("expiry_time");
if (expiry > 0 && expiry <= now) continue;
String name = (String) g.get("group_name");
UserGroupConfig cfg = groupConfigs.get(name);
if (cfg != null && (best == null || cfg.priority > best.priority)) {
best = cfg;
}
}
return best;
}
public boolean isPlayerInGroup(String player, String groupName) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM user_group_member WHERE player_name=? AND group_name=?");
ps.setString(1, player);
ps.setString(2, groupName);
boolean exists = ps.executeQuery().next();
ps.close();
return exists;
} catch (SQLException e) {
return false;
}
}
// ===== 过期自动移除 =====
private void startExpiryChecker() {
new BukkitRunnable() {
@Override
public void run() {
checkExpiry();
}
}.runTaskTimerAsynchronously(plugin, 1200L, 1200L); // 每分钟检查
}
private void checkExpiry() {
try {
long now = System.currentTimeMillis();
PreparedStatement ps = db.prepareStatement(
"SELECT player_name, group_name FROM user_group_member "
+ "WHERE expiry_time > 0 AND expiry_time <= ?");
ps.setLong(1, now);
ResultSet rs = ps.executeQuery();
List<String[]> expired = new ArrayList<>();
while (rs.next()) {
expired.add(new String[]{
rs.getString("player_name"),
rs.getString("group_name")});
}
rs.close(); ps.close();
for (String[] e : expired) {
PreparedStatement del = db.prepareStatement(
"DELETE FROM user_group_member WHERE player_name=? AND group_name=?");
del.setString(1, e[0]);
del.setString(2, e[1]);
del.executeUpdate();
del.close();
plugin.getLogger().info("[UserGroup] 过期移除: " + e[0] + " -> " + e[1]);
}
} catch (SQLException e) {
e.printStackTrace();
}
}
// ===== 资源包分发 =====
public void sendResourcePack(Player p) {
UserGroupConfig cfg = getHighestGroup(p.getName());
if (cfg == null || cfg.packUrl == null || cfg.packUrl.isEmpty()) return;
String url = cfg.packUrl;
String prompt = cfg.packPrompt != null
? cfg.packPrompt : "Custom Resource Pack";
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
try {
p.setResourcePack(url, new byte[0], prompt, false);
plugin.getLogger().info("[UserGroup] 资源包已发送: "
+ p.getName() + " -> " + cfg.displayName);
} catch (Exception e) {
plugin.getLogger().warning(
"[UserGroup] 资源包发送失败: " + e.getMessage());
}
}, 40L);
}
public Map<String, UserGroupConfig> getGroupConfigs() {
return groupConfigs;
}
// ===== 配置类 =====
public static class UserGroupConfig {
public String name;
public String displayName = "";
public String displayColor = "§f";
public String displayEmoji = "";
public int priority = 0;
public boolean permanent = true;
public int durationMinutes = 0;
public String packUrl = "";
public String packHash = "";
public String packPrompt = "Custom Resource Pack";
public int packSize = 0;
public String localFile = "";
}
}
@@ -1,158 +0,0 @@
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;
}
}
@@ -1,163 +0,0 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class WelcomeManager {
private final Main plugin;
private final Random random = new Random();
private final List<String> registerMsgs =
new ArrayList<>();
private final List<String> loginMsgs =
new ArrayList<>();
public WelcomeManager(Main plugin) {
this.plugin = plugin;
loadMessages();
}
public void loadMessages() {
registerMsgs.clear();
loginMsgs.clear();
File f = new File(
plugin.getDataFolder(),
"欢迎仪式.txt");
if (!f.exists()) {
writeDefault(f);
}
try (BufferedReader r =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets
.UTF_8))) {
String section = "";
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("\uFEFF"))
line = line.substring(1);
String t = line.trim();
if (t.isEmpty()
|| t.startsWith("#"))
continue;
if (t.equals("注册")
|| t.equals("注册:")) {
section = "register";
continue;
}
if (t.equals("登录")
|| t.equals("登录:")) {
section = "login";
continue;
}
// 支持 = 和 : 两种分隔
if (section.isEmpty()) {
if (t.startsWith("注册")
&& (t.contains("=")
|| t.contains(":"))) {
int idx = t.indexOf('=');
int idx2 = t.indexOf(':');
if (idx < 0) idx = idx2;
else if (idx2 >= 0)
idx = Math.min(idx, idx2);
String v = t.substring(idx + 1)
.trim();
if (!v.isEmpty())
registerMsgs.add(
colorize(v));
continue;
}
if (t.startsWith("登录")
&& (t.contains("=")
|| t.contains(":"))) {
int idx = t.indexOf('=');
int idx2 = t.indexOf(':');
if (idx < 0) idx = idx2;
else if (idx2 >= 0)
idx = Math.min(idx, idx2);
String v = t.substring(idx + 1)
.trim();
if (!v.isEmpty())
loginMsgs.add(
colorize(v));
continue;
}
continue;
}
if (!t.isEmpty()) {
if (section.equals("register"))
registerMsgs.add(
colorize(t));
else
loginMsgs.add(
colorize(t));
}
}
} catch (Exception e) {
plugin.getLogger().severe(
"[欢迎] 加载失败: "
+ e.getMessage());
}
plugin.getLogger().info(
"[欢迎] 加载完成: 注册="
+ registerMsgs.size()
+ " 登录=" + loginMsgs.size());
}
public void onRegister(Player p) {
sendRandom(p, registerMsgs, "注册");
}
public void onLogin(Player p) {
sendRandom(p, loginMsgs, "登录");
}
private void sendRandom(Player p,
List<String> msgs, String tag) {
if (msgs.isEmpty()) return;
String msg = msgs.get(
random.nextInt(msgs.size()));
msg = msg.replace("{player}",
p.getName());
final String finalMsg = msg;
Bukkit.getScheduler()
.runTaskLater(plugin,
() -> Bukkit
.broadcastMessage(
finalMsg), 10L);
}
private String colorize(String msg) {
return msg.replace("&", "\u00a7");
}
private void writeDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets
.UTF_8))) {
pw.println("# 欢迎仪式配置");
pw.println("# &为颜色代码");
pw.println("# {player} = 玩家名");
pw.println();
pw.println("注册:");
pw.println("&a&l欢迎 &f{player} &a加入服务器!&7[注册]");
pw.println("&6&l{player} &e已完成注册,欢迎入驻!&7[注册]");
pw.println("&b&l新成员 &f{player} &b已就位!&7[注册]");
pw.println();
pw.println("登录:");
pw.println("&a&l欢迎回来 &f{player}&a!&7[登录]");
pw.println("&e&l{player} &e已登录,继续冒险吧!&7[登录]");
pw.println("&b&l{player} &b回来了!&7[登录]");
} catch (IOException ignored) {
}
}
}
@@ -1,104 +0,0 @@
name: Sdf1_login
version: 1.0
main: Sdf1_login.Main
api-version: '1.21'
commands:
sdf1_login:
description: Sdf1_login主命令
usage: /sdf1_login
reg:
description: 注册
usage: /reg <密码>
login:
description: 登录
usage: /login <密码>
l:
description: 登录快捷版
usage: /l <密码>
注册:
description: 注册(中文)
usage: /注册 <密码>
登录:
description: 登录(中文)
usage: /登录 <密码>
玩家信息:
description: 打开面板(中文)
usage: /玩家信息
改密码:
description: 修改密码(中文)
usage: /改密码 <旧密码> <新密码> <确认新密码>
设置邮箱:
description: 设置邮箱(中文)
usage: /设置邮箱 <邮箱地址>
撤销:
description: 撤销操作(中文)
usage: /撤销
删除:
description: 删除玩家(中文)
usage: /删除 <玩家> <密码>
找回密码:
description: 找回密码(中文)
usage: /找回密码
签到:
description: 每日签到(中文)
usage: /签到
#oa:
# description: 申请删除账号
# usage: /oa [玩家名]
删除账号:
description: 申请删除账号(中文)
usage: /删除账号 [玩家名]
通过:
description: 批准OA请求(中文)
usage: /通过 <编号>
拒绝:
description: 拒绝OA请求(中文)
usage: /拒绝 <编号> [理由]
accept:
description: 批准OA请求
usage: /accept <编号>
deny:
description: 拒绝OA请求
usage: /deny <编号> [理由]
email:
description: 绑定邮箱
usage: /email <邮箱>
绑定:
description: 绑定邮箱(中文)
usage: /绑定 <邮箱>
绑定邮箱:
description: 绑定邮箱
usage: /绑定邮箱
chat:
description: 聊天过滤管理
usage: /chat
recycle:
description: 打开垃圾回收站
usage: /recycle
垃圾清理:
description: 手动清理掉落物
usage: /垃圾清理
menu:
description: 雪球菜单开关控制
usage: /menu <开/关>
pvp :
description: PVP榜单
usage: /pvp
cypay :
description: 草原探险债券
usage: /cypay
printer :
description: 打印债券流水
usage: /printer
shop :
description: 游戏商人
usage: /shop
商店:
description: 打开商店
usage: /商店
#testorder:
# description: 测试订单中心
# usage: /testorder
protect:
description: 区域保护
usage: /protect
@@ -1,305 +0,0 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class CheckInManager {
private final Main plugin;
public CheckInManager(Main plugin) {
this.plugin = plugin;
}
// ===== 今日是否已签到 =====
public boolean isCheckedInToday(
String name) {
String last = (String) plugin.getDb()
.getField(name,
"last_checkin_date");
String today = new SimpleDateFormat(
"yyyy-MM-dd")
.format(new Date());
return today.equals(last);
}
// ===== 签到 =====
// ===== 签到 =====
public String checkIn(Player p) {
String name = p.getName();
if (isCheckedInToday(name)) {
return plugin.getConfig2()
.msg("checkin_already");
}
Object streakObj = plugin.getDb()
.getField(name, "checkin_streak");
int streak = streakObj != null
? ((Number) streakObj).intValue()
: 0;
String lastDate = (String) plugin.getDb()
.getField(name, "last_checkin_date");
String today = new SimpleDateFormat("yyyy-MM-dd")
.format(new Date());
if (lastDate != null && !lastDate.isEmpty()) {
try {
java.util.Date lastD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(lastDate);
java.util.Date todayD =
new SimpleDateFormat("yyyy-MM-dd")
.parse(today);
long diff = todayD.getTime()
- lastD.getTime();
if (diff > 86400000L * 2) {
// 间隔超过2天,重置连续天数
streak = 1;
} else if (diff > 86400000L) {
// 间隔1-2天(连续签到),天数+1
streak++;
}
// 间隔<=1天(同一天),不改变连续天数
} catch (Exception ignored) {
}
} else {
// 首次签到
streak = 1;
}
double multi = 1.0;
if (streak >= 21) multi = 1.5;
else if (streak >= 14) multi = 1.3;
else if (streak >= 7) multi = 1.1;
int basePoints =
plugin.getConfig2().checkinPoints;
int points = (int) (basePoints * multi);
plugin.getDb().addPoints(name, points);
plugin.getDb().setField(name,
"checkin_streak", streak);
plugin.getDb().setField(name,
"last_checkin_date", today);
Object totalObj = plugin.getDb()
.getField(name, "total_checkin_days");
int total = totalObj != null
? ((Number) totalObj).intValue()
: 0;
plugin.getDb().setField(name,
"total_checkin_days", total + 1);
// ★ 债券盲盒奖励(6参数版,写入流水)★
int bondMin =
plugin.getConfig2().checkinBondMin;
int bondMax =
plugin.getConfig2().checkinBondMax;
if (bondMin <= 0) bondMin = 1;
if (bondMax < bondMin) bondMax = bondMin;
int bondReward = bondMin
+ new java.util.Random()
.nextInt(bondMax - bondMin + 1);
plugin.getBonds().addBonds(name, bondReward,
"daily_sign", "", "签到系统",
"每日签到连续" + streak + "天");
return plugin.getConfig2()
.msg("checkin_success")
.replace("{points}",
String.valueOf(points))
.replace("{streak}",
String.valueOf(streak))
.replace("{multi}",
String.valueOf(multi))
+ " §e+" + bondReward + "债券";
}
// ===== 获取连续天数 =====
public int getStreak(String name) {
Object val = plugin.getDb()
.getField(name,
"checkin_streak");
if (val instanceof Number) {
return ((Number) val)
.intValue();
}
return 0;
}
// 标准化日期
private String normalizeDate(String input) {
String s = input.trim().replace("/", "-");
if (s.matches("\\d{8}")) {
return s.substring(0, 4) + "-"
+ s.substring(4, 6) + "-"
+ s.substring(6, 8);
}
return s;
}
// ===== 补签入口 =====
public String backCheckIn(
Player p, String dateStr) {
String name = p.getName();
String normalized =
normalizeDate(dateStr);
// 严格日期验证
java.text.SimpleDateFormat sdf =
new java.text.SimpleDateFormat(
"yyyy-MM-dd");
sdf.setLenient(false);
java.util.Date target;
try {
target = sdf.parse(normalized);
} catch (Exception ex) {
return "§c无效日期: " + dateStr;
}
String reformat = sdf.format(target);
if (!reformat.equals(normalized)) {
return "§c无效日期: " + dateStr;
}
String today = sdf.format(new Date());
if (normalized.compareTo(today) >= 0) {
return "§c今天不需要补签";
}
String threeDaysAgo = getPastDate(3);
if (normalized.compareTo(
threeDaysAgo) < 0) {
return "§c只能补签3天内"
+ "(最多到 " + threeDaysAgo + ")";
}
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
boolean alreadyBacked = false;
if (!existing.isEmpty()) {
for (String d : existing.split(",")) {
if (normalizeDate(d.trim())
.equals(normalized)) {
alreadyBacked = true;
break;
}
}
}
if (alreadyBacked) {
return "§c该日期已补签: " + normalized;
}
Object normalCheck =
plugin.getDb().getField(
name, "last_checkin_date");
String lastCheckin = normalCheck != null
? normalCheck.toString() : "";
if (lastCheckin.equals(normalized)) {
return "§c该日期已签到: " + normalized;
}
// ★ 补签消耗:3债券 + 10积分
// 积分不够时:用 9债券(3倍)代替 ★
int bondCost = 3;
int pointCost = 10;
int bondAltCost = 9; // 3倍债券替代
int myBonds = plugin.getBonds()
.getBonds(name);
Object ptsObj = plugin.getDb()
.getField(name, "points");
int myPoints = ptsObj != null
? ((Number) ptsObj).intValue()
: 0;
// 方案A:3债券 + 10积分
boolean canA = myBonds >= bondCost
&& myPoints >= pointCost;
// 方案B:9债券(积分不够时)
boolean canB = myBonds >= bondAltCost
&& myPoints < pointCost;
if (!canA && !canB) {
if (myPoints < pointCost
&& myBonds < bondAltCost) {
return "§c补签费用不足"
+ "(需3债券+10积分,"
+ "或9债券代替)"
+ " 当前: "
+ myBonds + "债券 "
+ myPoints + "积分";
}
if (myBonds < bondCost) {
return "§c债券不足(需"
+ bondCost + "枚)";
}
return "§c积分不足(需"
+ pointCost + "分)";
}
// 执行扣款
if (canA) {
// 方案A:3债券 + 10积分
plugin.getBonds().deductBonds(
name, bondCost,
"admin_deduct", "",
"补签系统", "补签扣款");
plugin.getDb().deductPoints(
name, pointCost);
} else {
// 方案B:9债券代替
plugin.getBonds().deductBonds(
name, bondAltCost,
"admin_deduct", "",
"补签系统", "补签扣款(债券替代)");
}
doBackCheckIn(p, normalized);
String costMsg = canA
? bondCost + "债券+" + pointCost + "积分"
: bondAltCost + "债券(替代)";
return "§a补签成功!日期: "
+ normalized
+ " §7消耗: " + costMsg;
}
// ===== 执行补签 =====
// ===== 执行补签 =====
public void doBackCheckIn(
Player p, String dateStr) {
String name = p.getName();
Object val = plugin.getDb()
.getField(name, "backcheck_dates");
String existing = val != null
? val.toString() : "";
String updated = existing.isEmpty()
? dateStr
: existing + "," + dateStr;
plugin.getDb().setField(name,
"backcheck_dates", updated);
int streak = getStreak(name);
plugin.getDb().setField(name,
"checkin_streak", streak + 1);
// ★ 补签不发放奖励,只记录
}
// ===== 获取N天前日期 =====
private String getPastDate(
int daysAgo) {
Calendar cal =
Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH,
-daysAgo);
return new SimpleDateFormat(
"yyyy-MM-dd")
.format(cal.getTime());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,138 +0,0 @@
package Sdf1_login;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class QuestStorage {
private Connection conn;
public QuestStorage(File dataFolder) {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
dataFolder,
"quest_data.db");
conn = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getPath());
initTable();
} catch (Exception e) {
e.printStackTrace();
}
}
private void initTable()
throws SQLException {
Statement stmt =
conn.createStatement();
stmt.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_conditions ("
+ "player TEXT NOT NULL, "
+ "condition_key TEXT NOT NULL, "
+ "value TEXT DEFAULT '1', "
+ "timestamp INTEGER, "
+ "PRIMARY KEY "
+ "(player, condition_key))");
stmt.close();
}
/**
* 标记条件已完成(value = "1")
*/
public void setCondition(
String player,
String condition) {
setConditionRaw(
player, condition, "1");
}
/**
* 写入任意值(计数器等)
*/
public void setConditionRaw(
String player,
String condition,
String value) {
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO "
+ "quest_conditions "
+ "(player, "
+ "condition_key, "
+ "value, timestamp) "
+ "VALUES "
+ "(?, ?, ?, ?)");
ps.setString(1, player);
ps.setString(2, condition);
ps.setString(3, value);
ps.setLong(4,
System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 查询条件值
*/
public Object getCondition(
String player,
String condition) {
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value FROM "
+ "quest_conditions "
+ "WHERE player = ? "
+ "AND "
+ "condition_key = ?");
ps.setString(1, player);
ps.setString(2, condition);
ResultSet rs =
ps.executeQuery();
if (rs.next()) {
String val =
rs.getString("value");
rs.close();
ps.close();
// 尝试解析为数字
try {
if (val.contains(".")) {
return Double.parseDouble(
val);
}
return Long.parseLong(val);
} catch (NumberFormatException e) {
return val;
}
}
rs.close();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public void close() {
try {
if (conn != null
&& !conn.isClosed()) {
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-569
View File
@@ -1,569 +0,0 @@
# Sdf1系列登录插件使用说明
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态 |
| :--- | :--- | :--- |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
| 新人礼包 | 新玩家7日礼包 | 已完成 |
| 挂机检测 | 挂机检测踢出 | 已完成 |
| 自定义菜单 | 可以自己diy指令 | 已完成 |
| 自定义菜单图标 | 可以用自己喜欢的物品当图标 | 已完工 |
| 商城系统| 内置游戏商人可供销售商品 | 已完工 |
| 虚拟货币 | 供签到、游戏商人使用的货币系统 | 已完工 |
## 2.安装说明:
| 前置插件 | 选装类型 | 功能作用 |
| :--- | :--- | :--- |
| [CY_beibao](https://gitee.com/nihaoshidifu/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量
| EssentialsX | 可选 | 主流经济系统
| [sdf1](https://gitee.com/nihaoshidifu/sdf1_plugn) | 可选,推荐 | CDK兑换系统
### 2.1 下载
您可以从GitHub和Gitee下载到插件,将其丢入服务器plugin目录,重启服务器即可。
### 2.2. 配置文件
#### 2.2.1 消息.txt
此文件控制着整个插件的所有提示消息,您可以根据自己的需求修改,修改后需要执行 /sdf1_login reload 重载插件。支持[颜色代码](https://zh.minecraft.wiki/w/%E6%A0%BC%E5%BC%8F%E5%8C%96%E4%BB%A3%E7%A0%81)<br>
同时兼容`<b> </b>`标签,对段落加粗,`\n 和 <br>`进行段落换行
```txt
# ==========================================
# Sdf1_login 消息配置文件
# ==========================================
# 颜色代码使用 § 符号
# §0黑色 §1深蓝 §2深绿 §3深青
# §4深红 §5深紫 §6金色 §7灰色
# §8深灰 §9蓝色 §a绿色 §b浅蓝
# §c红色 §d粉色 §e黄色 §f白色
# §l粗体 §n下划线 §o斜体 §r重置
#
# 占位符用 {xxx} 表示,运行时自动替换
# 支持 <b>粗体</b> 标签
# 支持 \n 或 <br> 换行
# 修改后执行 /sdf1_login reload 重载
# ==========================================
# ---- 登录注册相关 ----
# login_timeout: 登录超时被踢出时的提示
login_timeout=§c登录超时,请重新登录
# not_registered: 未注册玩家收到的提示
not_registered=§e您尚未注册,请使用 /reg <密码>
# not_logged_in: 已注册但未登录的提示
not_logged_in=§e您尚未登录,请使用 /login <密码>
# already_registered: 已注册玩家再次注册时的提示
already_registered=§c您已注册过账号
# already_logged_in: 已登录玩家再次登录时的提示
already_logged_in=§c您已登录
# reg_success: 注册成功提示
reg_success=§a注册成功!欢迎来到服务器
# login_success: 登录成功提示
login_success=§a登录成功!
# login_failed: 密码错误提示
login_failed=§c密码错误,请重试
# ---- 密码相关 ----
# password_wrong: 旧密码验证失败
password_wrong=§c密码错误
# password_changed: 密码修改成功
password_changed=§a密码修改成功!
# password_format_error: 密码格式不符合要求
password_format_error=§c密码需6位以上,含大小写字母和数字
# password_same: 新密码与旧密码相同
password_same=§c新密码不能与旧密码相同
# reset_no_email: 找回密码时未绑定邮箱
reset_no_email=§c您未绑定邮箱,无法找回密码
# reset_sent: 临时密码已发送到邮箱
reset_sent=§a临时密码已发送到您的邮箱,请查收
# ---- 签到积分相关 ----
# checkin_already: 今日已签到
checkin_already=§c您今日已签到
# checkin_success: 签到成功,{points}=获得积分 {streak}=连续天数 {multi}=倍率
checkin_success=§a签到成功!获得 {points} 积分,连续 {streak} 天,倍率 x{multi}
# points_insufficient: 积分不足
points_insufficient=§c积分不足,当前: {points}
# points_purchase_success: 积分购买成功
points_purchase_success=§a购买成功!消耗 {count} 积分
# ---- 挂机检测相关 ----
# afk_set_enabled: 挂机检测开启
afk_set_enabled=§a挂机检测已开启
# afk_set_disabled: 挂机检测关闭
afk_set_disabled=§c挂机检测已关闭
# afk_kick: 因挂机被踢出
afk_kick=§c因挂机过久被踢出
# ---- 邮箱相关 ----
# email_set: 邮箱设置成功
email_set=§a邮箱设置成功:{email}
# email_format_error: 邮箱格式错误
email_format_error=§c邮箱格式不正确
# ---- 邀请相关 ----
# invite_code_generated: 邀请码生成
invite_code_generated=§a邀请码:{code}
# invite_used: 使用邀请码成功
invite_used=§a邀请成功,邀请人:{player}
# invite_self: 使用自己的邀请码
invite_self=§c不能使用自己的邀请码
# invite_not_found: 邀请码不存在
invite_not_found=§c邀请码不存在
# invite_referral_bonus: 被邀请人签到后邀请人获得奖励
invite_referral_bonus=§a邀请奖励!获得 {points} 积分
# ---- 新人礼包相关 ----
# gift_not_ready: 礼包条件未满足
gift_not_ready=§c礼包条件未满足
# gift_claimed: 礼包领取成功
gift_claimed=§a礼包领取成功!
# ---- 审批与IP限制 ----
# need_approval: 注册需审批
need_approval=§e注册需审批 (工单#{id})
# ip_max_accounts: IP注册已达上限
ip_max_accounts=§cIP注册已达上限
# ---- 管理员操作 ----
# admin_delete_confirm: 管理员删除账号确认提示
admin_delete_confirm=§c请在聊天中输入玩家名确认删除
# ---- 聊天过滤 ----
# chat_muted: 禁言玩家尝试聊天
chat_muted=§c§l[Sdf1_chat] §f你已被禁言,无法聊天
# chat_url_blocked: 发送违规链接被拦截
# {url}=检测到的链接地址
chat_url_blocked=§c§l[Sdf1_chat] §f检测到违规第三方链接: §e{url}
# chat_url_violation: 当前违规次数
# {count}=当前违规次数
chat_url_violation=§7当前违规次数: §e{count}
# chat_url_admin_notify: 通知管理员,{player}=玩家名 {url}=链接 {count}=次数
chat_url_admin_notify=§e§l[Sdf1_chat] §f{player} 发送违规链接: {url} (第{count}次)
# chat_url_broadcast: 全服通报,{player}=玩家名
chat_url_broadcast=§c§l[Sdf1_chat] §e{player} §f因发送违规链接被处罚
# ---- 通用 ----
# 未知参数: 未知命令或权限不足时的提示
未知参数=§c未知参数或权限不足
# only_player: 仅玩家可用的命令
# only_player=§c此命令仅玩家可用
```
### 2.2.2 挂机白名单.txt
当挂机踢出开启时,该配置下的玩家不受挂机提出影响
### 2.2.3 SMTP设置.txt
此文件控制着SMTP邮件服务器的配置,用于找回密码时发送邮件。<br>可通过GUI进行配置,也可以手动修改此配置文件。修改后,重载插件即可生效
```txt
# ===== SMTP 邮件服务器配置 =====
smtp地址=smtp.example.com
smtp端口=465
smtp账号=mail@example.com
smtp密码=example.com
发件人名称=Sdf1_login
smtp加密=true
验证码接收邮箱=example@example.com
```
### 2.2.4 插件设置.txt
此配置管控这整个插件,包括玩家能注册多少个账号、默认的管理员面板密码多少、tag包含什么视为管理员等
```txt
# ===== Sdf1_login 插件设置 =====
管理标签=admin
管理密码=qweasd
每IP最大账号数=3
审批模式=manual
自动审批延迟分钟=30
登录超时秒数=60
挂机踢出=false
挂机超时秒数=300
服务商积分倍率=2.0
基础经济奖励=100
经济奖励每评分=50.0
报单人积分奖励=10
# ---- 签到盲盒奖励 ----
签到固定金额=0
签到最小金额=10
签到最大金额=100
签到给积分=true
签到积分数量=10
```
### 2.2.5 chat.txt
该文件控制着聊天过滤,当玩家发送违规链接时,会自动根据配置规则进行处理。<br>
可用的处罚规则:
- warn: 警告
- mute: 禁言
- kick: 踢出
- ban: 封禁
- banip:封禁IP
```txt
# Sdf1_chat 聊天过滤配置
通知管理员: true
全服通报: true
禁言时长: 300
白名单:
*.minecraft.net
baidu.com
github.com
处罚规则:
1:warn:0
2:warn:0
3:mute:300
5:mute:600
8:kick:0
10:ban:0
白名单玩家:
```
### 2.2.6 radio_config.txt
```txt
# ============ Sdf1 Radio Config ============
#
# resource-pack-url
# 填外部地址(HTTP或HTTPS均可)
# 例: https://example.com/radiopack.zip
# 留空 = 使用内置HTTP服务
#
# http-port
# 内置HTTP端口(仅resource-pack-url为空时生效)
#
# ============ Settings ===============
resource-pack-url=https://*.ypshidifu.cn/|*.mcserver.ccwu.cc
http-port=443
```
- radio_config这里可以使用通配符域名,当使用通配符时,系统随机生成url,拒绝过资源包的玩家,每次都会收到提示。并且自1.2版本起,拒绝资源包=放弃游玩服务器。如果您无需广播功能,可以不放ogg文件进入`./plugin/radio/ogg`。这样服务端不会自动生成资源包,也不会强制下载了
### 2.2.7 菜单.txt
- 自定义菜单配置文件
```json
# 服务器菜单
# 使用类json格式
// 支持使用://Java单行注释、#yml单行注释、/* */块注释,也支持<!--- --->的块注释
{
标题:主城传送
指令:/dom tp 主城区 # 这里如果是xxx.txt则表示进入二级目录
权限类型:玩家
图标:COMPASS
}
{
标题:随机传送
指令:/rtp
权限类型:玩家
图标:ENDER_PEARL
}
{
标题:擂台比武
指令:/dom tp PVP
权限类型:玩家
图标:netherite_spear
}
{
标题:领地操作
指令:/dom
权限类型:玩家
图标:paper
}
{
标题:逛商店
指令:/dom tp shop
权限类型:玩家
图标:honeycomb
}
{
标题:传送开关
指令:/tptoggle
权限类型:玩家
图标:redstone_torch
}
{
标题:返回上一个位置
指令:/back
权限类型:玩家
图标:red_bed
}
{
标题:前往小黑塔
指令:/dom tp 小黑塔
权限类型:玩家
图标:dragon_head
}
{
标题:云背包
指令:/cy
权限类型:玩家
图标:potato
}
{
标题:自杀
指令:/suicide
权限类型:玩家
图标:wooden_axe
}
{
标题:游戏商人
指令:/shop
权限类型:玩家
图标:shield
格子:28
}
```
### 2.2.8 商品配置
- 配置此处的文件,负责展示游戏内所销售的商品。<br>
- 一个文件名,对应一个商品分类,使用md文件格式
```
# 建筑材料
| ID | 品名 | 材质 | 购入价 | 售出价 | 库存 | 本小时销量 | 总销量 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| STONE | 石头 | STONE | 10 | -1 | -1 | 0 | 0 |
| COBBLESTONE | 圆石 | COBBLESTONE | 5 | -1 | -1 | 0 | 26 |
| OAK_PLANKS | 橡木木板 | OAK_PLANKS | 8 | 3 | -1 | 0 | 0 |
| BRICKS | 砖块 | BRICKS | 12 | -1 | -1 | 0 | 0 |
| GLASS | 玻璃 | GLASS | 15 | 6 | -1 | 0 | 0 |
| QUARTZ_BLOCK | 石英块 | QUARTZ_BLOCK | 20 | 8 | -1 | 0 | 0 |
| AMETHYST_BLOCK | 紫水晶 | AMETHYST_BLOCK | 10 | 0 | -1 | 0 | 999 |
| OCHRE_FROGLIGHT | 赭黄蛙鸣灯 | OCHRE_FROGLIGHT | 100 | 0 | -1 | 0 | 1000 |
```
## 2.3 商城部分介绍
1. 自助退款,买多买错,不用慌,一件退单很安心<br>
2. 购物车,买的多怕买错,先加购物车,后续统一结算<br>
3. 害怕订单找不到?一键查看历史订单<br>
![](img/shop4.png)
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
![](img/shop7.png)<br>
![](img/shop6.png)
5. 想查看玩家消费记录?小手一点,明明白白<br>
![](img/shop5.png)
## 2.4 打印流水部分介绍
1. 管理员怀疑某个玩家刷钱了,怎么办?/printer 玩家名,一检查流水,很安心。很多人都刷了怎么办?/printer不加参数,一键导出全服流水,查他个底朝天<br>
2. 忘记删除怎么办?插件每分钟自动检测,旧文件超过24小时,自动删
## 3. 指令参数及介绍
- Sdf1_login 主指令<br>
- Sdf1_login reload 重载配置文件<br>
- reg 注册账号<br>
- login 登录账号<br>
- l 登录账号<br>
- sdf1_login del 删除玩家账号<br>
- sdf1_login radio 停止播放广播<br>
- sdf1_login radio reload 重载广播并重新打包资源包<br>
- menu 开/关 是否开启菜单<br>
- printer 打印流水数据,支持不带参数&带玩家名为参数<br>
- sdf1_login printer 作用同printer<br>
- sdf1_login shop 商城
- protect 区域防护
## 4. 自定义菜单物品
- 打开主界面,选择"我的"进入<br>
![](img/menu1.png)
- 将你的自定义物品与雪球交换<br>
![](img/menu2.png)
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
## 5. pvp战神榜
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
![](img/pvp1.png)
- 并且有着网游同款的击杀通报<br>
![](img/pvp3.png)
- 播报逻辑是:<br>
- 3杀<br>
- 5杀<br>
- 5杀以上每杀一次都是一条播报<br>
- 终结者通报,触发连杀的玩家被终结也会有全服通报
## 6. 区域防护
- 通过此功能,您可在服务器里需要防护的地方(比如展示框不想被玩家随便破坏、主城不准玩家袭击等)加以防护。<br>
- 还能够保护指定区域的玩家不受任何伤害。<br>
| 指令 | 参数1| 参数2 | 参数3 | 结果 |
| :---: | :---: | :---: | :---: | :---: |
| protect | 无 | 无 | 无 | 帮助信息 |
| protect | add | 区域名 | 玩家名 | 加白指定玩家到区域 |
| protect | add | 玩家名 | 无| 加白指定玩家为全局 |
| protect | additem | 区域名 | 物品名 | 加黑指定物品到区域 |
| protect | additem | 物品名 | 无 | 加黑指定物品到全局 |
| protect | addname | 区域名 | 名字 | 加白指定生物名字到区域和平模式白名单 |
| protect | addplayer | 区域名 | 玩家名字 | 加白指定玩家到区域强制模式豁免名单 |
| protect | remove | 玩家名字 | 无 | 移除玩家为全白 |
| protect | remove | 区域名 | 玩家名 | 移除玩家为区白 |
| protect | removeitem | 物品名 | 无 | 删除指定物品为全黑
| protect | removeitem | 区域名 | 物品名 | 删除指定物品为区黑
| protect | removename | 区域名 | 名字 | 删除指定名字为区域和平模式白名单 |
| protect | removeplayer | 区域名 | 名字 | 删除指定玩家为强制模式豁免名单 |
| protect | 参数不分顺序 | 区名可以写这里也可以写后面 | 玩家名可以写这里也可以写前面 | 完美的宝宝化 |
示范:
- `/protect add 末地保护区 players` 添加players玩家到末地保护区<br>
- `/protect removeitem 主城区 TNT` 从主城删了TNT这个黑名单物品<br>
- `/protect 筑城区 addwhite zhangsan` 把zhangsan加白到主城区的模式豁免名单。<br>
- 所有参数`任意排序`,`区域名字`随便填写,强大的`兜底`会自动识别"宝宝画板",你可以放心、大胆的乱写
| 参数 | 效果 |
| :---: | :---: |
| 和平模式 | 所有敌对生物全部进虚空 |
| 禁止袭击 | 立即清理玩家身上的袭击效果,传灾厄村民进虚空 |
| 清除XX效果 | 立即清除玩家身上的效果
| 给与xx效果 | 根据配置文件,给予玩家指定等级、时长的效果
## 生物属性对照表
| 中文名称 | 类型 | 附加属性 |
| :---: | :----: | :----: |
| 溺尸 | 近战/远程 | 亡灵、水生 |
| 骷髅 | 远程 | 亡灵 |
| 流浪者 | 远程 | 亡灵、寒冷群系变种 |
| 沼骸 | 远程 | 亡灵、沼泽变种 |
| 焦骸 | 远程 | 亡灵、沙漠变种、免疫阳光 |
| 掠夺者 | 远程 | 灾厄村民、袭击成员 |
| 卫道士 | 近战 | 灾厄村民、袭击成员 |
| 幻术师 | 远程 | 灾厄村民、Java专属、隐身分身 |
| 旋风人 | 远程 | 试炼密室生成、免疫弹射物、强击退 |
| 监守者 | 近战/声波远程 | 深暗之域、附带盲视效果 |
| 远古守卫者 | 近战/激光远程 | 海底神殿、水下、附加挖掘疲劳 |
| 守卫者 | 近战/激光远程 | 海底神殿、水下 |
配置文件解析:<br>
- 支持注释格式:`Java的//单行注释`、`yml的#单行注释`、`HTML的<!--- --->注释`和`Java/* */块注释`
```txt
# 区域: 主城区
起点: world,-130,-713
终点: world,-427,-560
高度范围: 0-255
没收物品: 蘑菇,毒蘑菇 #没收哪些物品(末地推荐这两个)
// 禁止放置方块 #保护区内,非区白/全白玩家禁止放置方块
// 禁止破坏方块 # 同上
禁止PVP # 玩家之间不能PVP
禁止摔伤 # 摔落无法造成任何伤害
禁止饥饿 # 不会消耗饱食度
禁止一切伤害 # 任何怪物都无法造成伤害
# 禁止丢弃物品
# 禁止末影珍珠
# 禁止使用弓箭
# 禁止骑乘
// 和平模式 // 所有的敌对生物全部传送进虚空
禁止袭击
禁止交互展示框 # 核心,任何玩家不能触碰展示框。区白/全白玩家除外
// 清除所有效果 # 清理所有负面效果
效果 村庄英雄 255 999999 # 给予玩家效果。格式为: 效果 效果名字 等级(最高255) 时间
效果 生命提升 255 999999
效果 抗性提升 255 999999
效果 抗火 255 999999
# 禁止药水
禁止爆炸
禁止使用物品: TNT
# 效果: 夜视 1 999
进入提示: 欢迎来到保护区 # 是否开启进入提示,即自定义提示
离开提示: 已离开保护区
没收提示: 你的违禁品已被没收
通报批评: §c§l{player} §f在{area}违规携带违禁品,被没收 §e{count}§f件! # 通报违禁品。{player} 玩家名 {count}数量 {area}区域
# 惩罚命令: ban {player} 60s 违规
```
## 运行截图
主界面
![](img/login.png)
邀请数据
![](img/login2.png)
积分商店
![](img/login3.png)
任务中心
![](img/login4.png)
垃圾箱
![](img/login5.png)
管理员面板
![](img/login6.png)
管理面板-用户管理
![](img/login7.png)
工单中心
![](img/login8.png)
工单管理
![](img/login9.png)
我的工单
![](img/login10.png)
提交工单
![](img/login11.png)
服务商列表
![](img/login12.png)
服务商抢单大厅
![](img/login13.png)
服务商-接单界面
![](img/login14.png)
管理员-工单详情页面
![](img/login15.png)
商城主界面
![](img/shop1.png)
商城购物车
![](img/cart1.png)
商城退单
![](img/shop2.png)
## 4. 下载地址
- GitHub:[https://github.com/ypsdf1/sdf1_login](https://github.com/ypsdf1/sdf1_login)
- Gitee:[https://gitee.com/nihaoshidifu/sdf1_login](https://gitee.com/nihaoshidifu/sdf1_login)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

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