Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165adcb532 | ||
|
|
b6914c52f9 | ||
|
|
2e04d9cf36 | ||
|
|
a33aba1b13 | ||
|
|
d18f983b83 | ||
|
|
6daedd5dbc | ||
|
|
ffeffbe7cd | ||
|
|
e440dea033 | ||
|
|
79b75a0f76 | ||
|
|
c4f6f2fa1c | ||
|
|
3c8b573d05 | ||
|
|
5175cb46fb | ||
|
|
c8a7cd73a6 | ||
|
|
6d93d69226 | ||
|
|
203545a719 | ||
|
|
716d43b8c2 | ||
|
|
80c4073445 | ||
|
|
fe882cf73a | ||
|
|
685a1dace2 | ||
|
|
f2859fe6e5 | ||
|
|
3a4252e861 | ||
|
|
4acb6712cd | ||
|
|
a0aeabf22d | ||
|
|
d0cdadd29d | ||
|
|
3e2bd64a70 | ||
|
|
9dcf5dcb93 | ||
|
|
71b644182c | ||
|
|
d4748c4e62 | ||
|
|
f62a446d03 | ||
|
|
bca9b9af47 | ||
|
|
bcc22df797 | ||
|
|
82c688c631 | ||
|
|
831b2b53d7 | ||
|
|
8337f06cda | ||
|
|
ac1b0c86fd | ||
|
|
76c08ec0f2 | ||
|
|
a2171262e6 | ||
|
|
79280c3990 | ||
|
|
af6fb3b67e | ||
|
|
b99412cc8a | ||
|
|
e12a972ad7 | ||
|
|
489b83f29d | ||
|
|
8a669b1e24 | ||
|
|
fcc8f08ef7 | ||
|
|
ed55de014b | ||
|
|
b0e572d7ac | ||
|
|
a758bde564 | ||
|
|
4bdefdab8c | ||
|
|
4be4f1f107 | ||
|
|
2cb48de453 | ||
|
|
6f13740cce | ||
|
|
1e60d31ffd | ||
|
|
0b63a99719 | ||
|
|
fc8c82b188 | ||
|
|
e1010927e5 | ||
|
|
8a6755e725 | ||
|
|
ebcdeb6f09 | ||
|
|
d76ad12119 | ||
|
|
1188da01a9 | ||
|
|
c1009088ce | ||
|
|
7dc7d01cd0 | ||
|
|
7932bd176c | ||
|
|
ba5352121e | ||
|
|
629c16ec84 | ||
|
|
aadb3373e8 | ||
|
|
eb01cb9ea8 | ||
|
|
e20d648b08 | ||
|
|
e76b38cd7d | ||
|
|
59cca9e04a | ||
|
|
dbb4843db6 | ||
|
|
6a6cb9ec3c | ||
|
|
bd47d25165 | ||
|
|
a5871f6761 | ||
|
|
6cc8dbf582 | ||
|
|
d2f42bef62 | ||
|
|
1bd51cbcdf | ||
|
|
ce489d1450 | ||
|
|
e040e8c994 | ||
|
|
99da971fbf | ||
|
|
f7aedc6fae | ||
|
|
398a9db380 | ||
|
|
c880f1eb9e | ||
|
|
386c9d861e | ||
|
|
4160c60aa6 | ||
|
|
419b677aa9 | ||
|
|
fe1ed613ba | ||
|
|
c6b05d72c8 | ||
|
|
7f513b9fa1 | ||
|
|
9ee8023d90 | ||
|
|
5912c6fd76 | ||
|
|
e18d8df22f | ||
|
|
196e20868e | ||
|
|
2f5f02b72d | ||
|
|
23c40df6b1 | ||
|
|
c0e95b6c73 | ||
|
|
293d7b283c | ||
|
|
8fa7eb5c95 | ||
|
|
1a221330dc | ||
|
|
b138171a7c | ||
|
|
ea5d476825 | ||
|
|
17d53c40a0 | ||
|
|
4f498a80c6 | ||
|
|
5400856785 | ||
|
|
2e4ddcbaea | ||
|
|
1eb898adea | ||
|
|
b86d29b34a | ||
|
|
b5b252d1b7 | ||
|
|
eb741beeb4 | ||
|
|
186e670400 | ||
|
|
43fd8e2891 | ||
|
|
b6983f99e4 | ||
|
|
4c2e075667 | ||
|
|
27da2b5c05 | ||
|
|
a97f9274cc | ||
|
|
f033e71095 | ||
|
|
51e418ac16 | ||
|
|
b7b2647ef1 | ||
|
|
a46737d64b | ||
|
|
c46c9ebdfe | ||
|
|
fd03189f31 | ||
|
|
52e07ec9de | ||
|
|
345e1e9a9b | ||
|
|
045d9f60b8 | ||
|
|
18bd1e9b19 | ||
|
|
4db131082c | ||
|
|
eaadc0bd64 | ||
|
|
f2b87325e1 | ||
|
|
6a74dfee91 | ||
|
|
d92f1f47a9 | ||
|
|
c3d9c0925d | ||
|
|
312f40c94a | ||
|
|
0098efda3f | ||
|
|
1d5f500a28 | ||
|
|
ff83406013 | ||
|
|
d539cc10bf | ||
|
|
e30c37ef64 | ||
|
|
51aaedc2b3 | ||
|
|
b7c83903ca | ||
|
|
65fc468ce1 | ||
|
|
ace705682c | ||
|
|
b90eeb2bcd | ||
|
|
7e3af6d7d7 | ||
|
|
f3919a8715 | ||
|
|
00da16abb6 | ||
|
|
5fcecdaefd | ||
|
|
e2808276c0 | ||
|
|
a8303ba772 | ||
|
|
de589d2683 | ||
|
|
433735d595 | ||
|
|
304bd8f9ef | ||
|
|
6da7f58281 | ||
|
|
95630c7609 | ||
|
|
61bc4672a9 | ||
|
|
4afc2470b0 | ||
|
|
07689f235b | ||
|
|
e0bb61f23a | ||
|
|
ea20701db5 | ||
|
|
19d0cf1618 | ||
|
|
4781996f08 | ||
|
|
d9a3a6eb33 | ||
|
|
510db12216 | ||
|
|
3893d22aef | ||
|
|
43cb042bf0 | ||
|
|
bed190ca8d | ||
|
|
9c50e58e7d | ||
|
|
767a9892f7 | ||
|
|
cb949f5b16 | ||
|
|
1cd8415516 | ||
|
|
8f54e3ffc6 | ||
|
|
449976478e | ||
|
|
3a53ec5fdc | ||
|
|
8c857b4715 | ||
|
|
ab762ad3a9 | ||
|
|
d0b9e8a92d | ||
|
|
deabd1de98 | ||
|
|
8d151d865c | ||
|
|
a4d6a5d487 | ||
|
|
efcc0be26a | ||
|
|
8ae0fe9b7d | ||
|
|
af5f31c87e | ||
|
|
7878c07a22 | ||
|
|
2a6c6edad2 | ||
|
|
7fd559531c | ||
|
|
cb2f68cfd8 | ||
|
|
57124cab5c | ||
|
|
1c9e5808a1 | ||
|
|
8ec89ef841 | ||
|
|
9a78c693bf | ||
|
|
5515d22d4c | ||
|
|
afdae1b63b | ||
|
|
6984bc79fd | ||
|
|
8261b4dbfd | ||
|
|
984c629cc0 | ||
|
|
6ad469452f | ||
|
|
8f0bf8cd52 | ||
|
|
ffa8b4cce0 | ||
|
|
ba484e957d | ||
|
|
dff381adeb | ||
|
|
19472baab0 | ||
|
|
5e581adef3 | ||
|
|
4e11845636 | ||
|
|
b34753d1fd | ||
|
|
498245d967 | ||
|
|
be04c8b451 | ||
|
|
f5879f4087 | ||
|
|
0760cc1d2e | ||
|
|
8645c57839 | ||
|
|
0b283483a7 | ||
|
|
9b39c9e56f | ||
|
|
c266cf8555 | ||
|
|
a4972dde7e | ||
|
|
d7da6ec1a6 | ||
|
|
0115cbda41 | ||
|
|
ed57c8f82c | ||
|
|
4998d7bdfa | ||
|
|
646ecfa162 | ||
|
|
eaeab955e3 | ||
|
|
a28083f4ca | ||
|
|
b67480d073 | ||
|
|
555b9382b5 | ||
|
|
b63ecc02b8 | ||
|
|
1238c6a20c | ||
|
|
e49fcaccd8 | ||
|
|
eed83a552f | ||
|
|
c960c3ac0c | ||
|
|
ae1ff5ffd8 | ||
|
|
1e97285459 | ||
|
|
c1234c6700 | ||
|
|
e076596b4d | ||
|
|
e9ecb0ae53 | ||
|
|
ab7443b5b5 | ||
|
|
77ee56cca4 | ||
|
|
3f3fbd68bb | ||
|
|
d9cc806177 | ||
|
|
c3ee6a5c5a | ||
|
|
cb65e34f2d | ||
|
|
1ea6f4963f | ||
|
|
28c7aa119e | ||
|
|
ac79628076 | ||
|
|
02dc706635 | ||
|
|
d0be292a78 | ||
|
|
822f47dd05 | ||
|
|
78e3b2ff1e | ||
|
|
8286a4c09c | ||
|
|
6775465c21 | ||
|
|
9c269cc184 | ||
|
|
62f53234e7 | ||
|
|
a72bbfd970 | ||
|
|
6359830105 | ||
|
|
7a4aad46c6 | ||
|
|
fdd8ccb340 | ||
|
|
9456f787f8 | ||
|
|
6634fdefa0 | ||
|
|
f32c7cfe86 | ||
|
|
86b2d6ebdf | ||
|
|
c22386b8fc | ||
|
|
59701d1627 | ||
|
|
0e1f26508b | ||
|
|
8de2cffe82 | ||
|
|
dd425f4946 | ||
|
|
7a283ae4e0 | ||
|
|
e46ee109d3 | ||
|
|
ffb6e24d5f | ||
|
|
31493de10d | ||
|
|
1277c1fdf3 | ||
|
|
5fa59b71b2 | ||
|
|
591d71ede0 | ||
|
|
4927aaed01 | ||
|
|
6337bc3e75 | ||
|
|
d394bd0ec8 | ||
|
|
60151dc1d0 | ||
|
|
35eb054e4f | ||
|
|
bcc68aa4e5 | ||
|
|
b9ca56ff7f | ||
|
|
fc6a46597d | ||
|
|
1bc97f6980 | ||
|
|
6d68c4a05b | ||
|
|
3dacc80fe1 | ||
|
|
62798f2a90 | ||
|
|
64ed1e6ada | ||
|
|
5403479a2c | ||
|
|
e9bd63a1e4 | ||
|
|
866b5abe15 | ||
|
|
2237f6c855 | ||
|
|
3925dea586 | ||
|
|
02c2e91531 | ||
|
|
c0afb47535 | ||
|
|
f6265dd994 | ||
|
|
2342cc2cab | ||
|
|
35f15db034 | ||
|
|
1f97e3b969 | ||
|
|
72d2032f95 | ||
|
|
d568a2ce5d | ||
|
|
5f3bd70987 | ||
|
|
ca212233a3 | ||
|
|
a5a2ef733c | ||
|
|
5f43fb1407 | ||
|
|
c0672ad47a | ||
|
|
63251c6033 | ||
|
|
742578a107 | ||
|
|
c8c8db6220 | ||
|
|
fc42b777ca | ||
|
|
48ad7035ea | ||
|
|
b6db8e56d5 | ||
|
|
bbd0049602 | ||
|
|
b8dd9b7cd0 | ||
|
|
5c47dd44fd | ||
|
|
967feb9b94 | ||
|
|
9905f28886 | ||
|
|
3dfa7df18c | ||
|
|
8de90708dc | ||
|
|
aa45bd5ca2 | ||
|
|
57fbc1ffc1 | ||
|
|
3ad96b1080 | ||
|
|
9eb0707729 | ||
|
|
cd86441a7a | ||
|
|
cd6010bfbb | ||
|
|
8edc94b848 | ||
|
|
71777e3013 | ||
|
|
06854054ca | ||
|
|
02d2d95c9a | ||
|
|
d12ceaa44a | ||
|
|
98f9cb6410 | ||
|
|
b50bfd4379 | ||
|
|
15133de0f9 | ||
|
|
ed74d09b6a | ||
|
|
66cc7b8925 | ||
|
|
4c7ec7490f | ||
|
|
d7a986894c | ||
|
|
885e70f0f5 | ||
|
|
9bae160ce0 | ||
|
|
0c9a5b0f53 | ||
|
|
54cd3c4f59 |
@@ -1,5 +0,0 @@
|
||||
*.md text=auto encoding=UTF-8
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: 报告一个 Bug 以帮助改进插件
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Bug 描述
|
||||
请简明扼要地描述 Bug 是什么。
|
||||
|
||||
## 复现步骤
|
||||
1. 进入 ...
|
||||
2. 点击 ...
|
||||
3. 出现错误 ...
|
||||
|
||||
## 期望行为
|
||||
描述你期望发生的事情。
|
||||
|
||||
## 实际行为
|
||||
描述实际发生了什么。
|
||||
|
||||
## 服务端信息
|
||||
- 服务器类型和版本(如 Paper 1.21.4):
|
||||
- 插件版本:
|
||||
|
||||
## 截图 / 日志
|
||||
如有相关截图或报错日志,请粘贴在此处。
|
||||
|
||||
## 其他信息
|
||||
补充任何你认为有助于解决问题的信息。
|
||||
@@ -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: ''
|
||||
|
||||
---
|
||||
|
||||
## 功能描述
|
||||
请描述你希望添加的新功能。
|
||||
|
||||
## 动机
|
||||
为什么需要这个功能?它能解决什么问题?
|
||||
|
||||
## 建议的实现方式
|
||||
如果对实现方式有想法,请在此描述。
|
||||
|
||||
## 额外信息
|
||||
补充任何你认为有助于理解需求的上下文。
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: 建议提交
|
||||
about: 对插件提出改进建议或反馈
|
||||
title: "[Suggestion] "
|
||||
labels: suggestion
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 建议内容
|
||||
请描述你的建议或想法。
|
||||
|
||||
## 背景
|
||||
什么场景下产生的这个建议?
|
||||
|
||||
## 预期效果
|
||||
你认为采用这个建议后能达到什么效果?
|
||||
+60
-43
@@ -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/
|
||||
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-7
@@ -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>
|
||||
Generated
-14
@@ -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
@@ -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>
|
||||
@@ -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/>.
|
||||
@@ -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>
|
||||

|
||||
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
|
||||
<br>
|
||||

|
||||
5. 想查看玩家消费记录?小手一点,明明白白<br>
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 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>
|
||||

|
||||
- 将你的自定义物品与雪球交换<br>
|
||||

|
||||
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
|
||||
|
||||
## 5. pvp战神榜
|
||||
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
|
||||

|
||||
- 并且有着网游同款的击杀通报<br>
|
||||

|
||||
- 播报逻辑是:<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 违规
|
||||
|
||||
|
||||
```
|
||||
|
||||
## 运行截图
|
||||
|
||||
主界面
|
||||

|
||||
|
||||
邀请数据
|
||||

|
||||
|
||||
积分商店
|
||||

|
||||
|
||||
任务中心
|
||||

|
||||
|
||||
垃圾箱
|
||||

|
||||
|
||||
管理员面板
|
||||

|
||||
|
||||
管理面板-用户管理
|
||||

|
||||
|
||||
|
||||
工单中心
|
||||

|
||||
|
||||
工单管理
|
||||

|
||||
|
||||
我的工单
|
||||

|
||||
|
||||
|
||||
提交工单
|
||||

|
||||
|
||||
服务商列表
|
||||

|
||||
|
||||
服务商抢单大厅
|
||||

|
||||
|
||||
服务商-接单界面
|
||||

|
||||
|
||||
管理员-工单详情页面
|
||||

|
||||
|
||||
商城主界面
|
||||

|
||||
|
||||
商城购物车
|
||||

|
||||
|
||||
商城退单
|
||||

|
||||
|
||||
|
||||
## 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)
|
||||
@@ -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>
|
||||
+174
@@ -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> |
|
||||
<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
File diff suppressed because it is too large
Load Diff
+133
@@ -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
File diff suppressed because it is too large
Load Diff
+2499
File diff suppressed because it is too large
Load Diff
+318
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1290
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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' => '无效日期'];
|
||||
}
|
||||
@@ -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, '预览模式 - 您只能查看自己的账号信息');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+5952
File diff suppressed because it is too large
Load Diff
+982
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -1,5 +0,0 @@
|
||||
*.md text=auto encoding=UTF-8
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
@@ -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
@@ -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>
|
||||

|
||||
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
|
||||
<br>
|
||||

|
||||
5. 想查看玩家消费记录?小手一点,明明白白<br>
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 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>
|
||||

|
||||
- 将你的自定义物品与雪球交换<br>
|
||||

|
||||
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
|
||||
|
||||
## 5. pvp战神榜
|
||||
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
|
||||

|
||||
- 并且有着网游同款的击杀通报<br>
|
||||

|
||||
- 播报逻辑是:<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 违规
|
||||
|
||||
|
||||
```
|
||||
|
||||
## 运行截图
|
||||
|
||||
主界面
|
||||

|
||||
|
||||
邀请数据
|
||||

|
||||
|
||||
积分商店
|
||||

|
||||
|
||||
任务中心
|
||||

|
||||
|
||||
垃圾箱
|
||||

|
||||
|
||||
管理员面板
|
||||

|
||||
|
||||
管理面板-用户管理
|
||||

|
||||
|
||||
|
||||
工单中心
|
||||

|
||||
|
||||
工单管理
|
||||

|
||||
|
||||
我的工单
|
||||

|
||||
|
||||
|
||||
提交工单
|
||||

|
||||
|
||||
服务商列表
|
||||

|
||||
|
||||
服务商抢单大厅
|
||||

|
||||
|
||||
服务商-接单界面
|
||||

|
||||
|
||||
管理员-工单详情页面
|
||||

|
||||
|
||||
商城主界面
|
||||

|
||||
|
||||
商城购物车
|
||||

|
||||
|
||||
商城退单
|
||||

|
||||
|
||||
|
||||
## 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)
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
|
||||
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
@@ -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>
|
||||

|
||||
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
|
||||
<br>
|
||||

|
||||
5. 想查看玩家消费记录?小手一点,明明白白<br>
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 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>
|
||||

|
||||
- 将你的自定义物品与雪球交换<br>
|
||||

|
||||
- 随后系统会给你重新发放菜单,并且此时图标就是你的自定义物品了
|
||||
|
||||
## 5. pvp战神榜
|
||||
- 通过此功能,你可以在服务器里制作大服务器同款的击杀排行榜,而且能精准定位指定区域<br>
|
||||

|
||||
- 并且有着网游同款的击杀通报<br>
|
||||

|
||||
- 播报逻辑是:<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 违规
|
||||
|
||||
|
||||
```
|
||||
|
||||
## 运行截图
|
||||
|
||||
主界面
|
||||

|
||||
|
||||
邀请数据
|
||||

|
||||
|
||||
积分商店
|
||||

|
||||
|
||||
任务中心
|
||||

|
||||
|
||||
垃圾箱
|
||||

|
||||
|
||||
管理员面板
|
||||

|
||||
|
||||
管理面板-用户管理
|
||||

|
||||
|
||||
|
||||
工单中心
|
||||

|
||||
|
||||
工单管理
|
||||

|
||||
|
||||
我的工单
|
||||

|
||||
|
||||
|
||||
提交工单
|
||||

|
||||
|
||||
服务商列表
|
||||

|
||||
|
||||
服务商抢单大厅
|
||||

|
||||
|
||||
服务商-接单界面
|
||||

|
||||
|
||||
管理员-工单详情页面
|
||||

|
||||
|
||||
商城主界面
|
||||

|
||||
|
||||
商城购物车
|
||||

|
||||
|
||||
商城退单
|
||||

|
||||
|
||||
|
||||
## 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
Reference in New Issue
Block a user